From 7b0224a45ad06e514bb815f25d90531c3d4829d9 Mon Sep 17 00:00:00 2001 From: Nose Date: Sun, 17 May 2026 12:29:13 -0700 Subject: [PATCH] update for flac metadata on iphone/rpi --- README.md | 6 +- scripts/README.md | 371 +--------------------------------- scripts/setup-mpv-handler.mjs | 28 ++- src/audioMetadata.ts | 72 ++++--- 4 files changed, 80 insertions(+), 397 deletions(-) diff --git a/README.md b/README.md index 9599b8b..bca2e0b 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ npm run uninstall:mpv-handler ### Linux desktop +Run this on the Linux desktop client that should receive `mpv-handler://` links, not on the server that is merely hosting API Media Player. + 1. Install `mpv` and optionally `yt-dlp`. 2. Download and extract the latest upstream Linux release: @@ -82,13 +84,15 @@ npm run uninstall:mpv-handler https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip ``` +The upstream project currently publishes an official Linux release for `amd64` only. On Raspberry Pi or other ARM Linux systems, build or obtain a compatible `mpv-handler` binary first, then point `--root` at that extracted folder instead. + 3. Run the helper against the extracted folder: ```bash npm run setup:mpv-handler -- --root /path/to/extracted/mpv-handler-linux-amd64 ``` -On Linux the helper copies the binary and desktop files into `~/.local`, writes `config.toml`, and runs `xdg-mime` for both protocol handlers. +On Linux the helper copies the binary and desktop files into `~/.local`, writes `config.toml` to `~/.config/mpv-handler/config.toml` (or `$XDG_CONFIG_HOME/mpv-handler/config.toml`), and runs `xdg-mime` for both protocol handlers. ### Android diff --git a/scripts/README.md b/scripts/README.md index 074d8b4..7596aa7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -35,13 +35,17 @@ npm run uninstall:mpv-handler https://github.com/akiirui/mpv-handler/releases/latest/download/mpv-handler-linux-amd64.zip ``` +The official upstream Linux release is currently `amd64` only. On Raspberry Pi or other ARM Linux systems, build or obtain a compatible `mpv-handler` binary first and point `--root` at that extracted folder. + 2. Point the helper at that extracted folder: ```bash npm run setup:mpv-handler -- --root /path/to/extracted/mpv-handler-linux-amd64 ``` -The helper copies files into `~/.local/bin` and `~/.local/share/applications`, updates `config.toml`, and runs `xdg-mime` for both protocol handlers. +Run this on the Linux desktop client that should handle `mpv-handler://`, not on the machine that is only serving the web app. + +The helper copies files into `~/.local/bin` and `~/.local/share/applications`, writes config to `~/.config/mpv-handler/config.toml` (or `$XDG_CONFIG_HOME/mpv-handler/config.toml`), and runs `xdg-mime` for both protocol handlers. ## Useful flags @@ -105,368 +109,3 @@ https://github.com/akiirui/mpv-handler/releases ``` This repo uses the upstream protocol scheme and binaries; the docs here only describe the setup flow for API Media Player. -*** Add File: c:\Forgejo\API-MediaPlayer\scripts\uninstall-mpv-handler.ps1 -#Requires -Version 5.1 -#Requires -RunAsAdministrator - -[CmdletBinding()] -param() - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -function Remove-ProtocolKey { - param( - [Parameter(Mandatory = $true)] - [string]$SchemeName - ) - - $classesRoot = [Microsoft.Win32.Registry]::ClassesRoot - try { - $classesRoot.DeleteSubKeyTree($SchemeName, $false) - } catch { - } -} - -if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { - throw 'This uninstaller is only for Windows.' -} - -Remove-ProtocolKey -SchemeName 'mpv' -Remove-ProtocolKey -SchemeName 'mpv-debug' -Remove-ProtocolKey -SchemeName 'mpv-handler' -Remove-ProtocolKey -SchemeName 'mpv-handler-debug' - -Write-Host 'Successfully removed mpv-handler protocol registration.' -ForegroundColor Green -*** Add File: c:\Forgejo\API-MediaPlayer\scripts\setup-mpv-handler.mjs -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process' -import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, chmodSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const scriptPath = fileURLToPath(import.meta.url) -const scriptDir = path.dirname(scriptPath) -const templateConfigPath = path.join(scriptDir, 'config.toml') - -const usage = `Usage: - npm run setup:mpv-handler - npm run setup:mpv-handler -- --root /path/to/mpv-handler - npm run setup:mpv-handler -- --mpv /path/to/mpv --ytdl /path/to/yt-dlp - -Options: - --root Path to the extracted mpv-handler folder. - --mpv Override the mpv executable path written to config.toml. - --ytdl Override the yt-dlp executable path written to config.toml. - --skip-config Do not create or update config.toml. - --keep-existing Windows only. Keep existing protocol keys instead of replacing them. - --dry-run Print the actions without changing files or running installers. - --help Show this help text. -` - -function fail(message) { - console.error(`Error: ${message}`) - process.exit(1) -} - -function parseArgs(argv) { - const options = { - root: '', - mpv: '', - ytdl: '', - skipConfig: false, - keepExisting: false, - dryRun: false, - help: false, - } - - for (let index = 0; index < argv.length; index += 1) { - const value = argv[index] - - if (value === '--help' || value === '-h') { - options.help = true - continue - } - - if (value === '--skip-config') { - options.skipConfig = true - continue - } - - if (value === '--keep-existing') { - options.keepExisting = true - continue - } - - if (value === '--dry-run') { - options.dryRun = true - continue - } - - if (value === '--root' || value === '--mpv' || value === '--ytdl') { - const nextValue = argv[index + 1] - if (!nextValue || nextValue.startsWith('--')) { - fail(`Missing value for ${value}`) - } - - if (value === '--root') options.root = nextValue - if (value === '--mpv') options.mpv = nextValue - if (value === '--ytdl') options.ytdl = nextValue - index += 1 - continue - } - - fail(`Unknown argument: ${value}`) - } - - return options -} - -function escapeTomlString(value) { - return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') -} - -function upsertTomlValue(content, key, value) { - const line = `${key} = "${escapeTomlString(value)}"` - const pattern = new RegExp(`^\\s*#?\\s*${key}\\s*=.*$`, 'm') - if (pattern.test(content)) { - return content.replace(pattern, line) - } - - const trimmed = content.trimEnd() - return trimmed ? `${trimmed}\n${line}\n` : `${line}\n` -} - -function resolveOnPath(commandNames) { - const locator = process.platform === 'win32' ? 'where.exe' : 'which' - - for (const commandName of commandNames) { - const result = spawnSync(locator, [commandName], { encoding: 'utf8' }) - if (result.status !== 0) continue - - const match = result.stdout - .split(/\r?\n/) - .map((entry) => entry.trim()) - .find((entry) => entry) - - if (match && existsSync(match)) { - return match - } - } - - return '' -} - -function isFile(candidatePath) { - try { - return statSync(candidatePath).isFile() - } catch { - return false - } -} - -function looksLikeRoot(rootPath) { - const requiredFiles = process.platform === 'win32' - ? ['mpv-handler.exe', 'mpv-handler-debug.exe'] - : process.platform === 'linux' - ? ['mpv-handler', 'mpv-handler.desktop', 'mpv-handler-debug.desktop'] - : ['mpv-handler'] - - return requiredFiles.every((fileName) => isFile(path.join(rootPath, fileName))) -} - -function resolveRoot(options) { - const candidates = [] - if (options.root) candidates.push(path.resolve(options.root)) - candidates.push(scriptDir) - candidates.push(process.cwd()) - - const seen = new Set() - for (const candidate of candidates) { - if (!candidate || seen.has(candidate)) continue - seen.add(candidate) - if (looksLikeRoot(candidate)) { - return candidate - } - } - - if (process.platform === 'win32') { - fail('Could not find mpv-handler files. Re-run with --root pointing at the extracted mpv-handler folder.') - } - - if (process.platform === 'linux') { - fail('Could not find a Linux mpv-handler release. Download and extract the upstream archive, then pass --root /path/to/extracted/mpv-handler-linux-amd64.') - } - - fail('Automatic setup is not available for this platform yet.') -} - -function ensureConfig(rootPath, options) { - const configPath = path.join(rootPath, 'config.toml') - if (options.skipConfig) { - return { configPath, changed: false, mpvPath: '', ytdlPath: '' } - } - - let content = existsSync(configPath) - ? readFileSync(configPath, 'utf8') - : readFileSync(templateConfigPath, 'utf8') - - const detectedMpv = options.mpv || resolveOnPath(process.platform === 'win32' ? ['mpv.com', 'mpv.exe', 'mpv'] : ['mpv']) - const detectedYtdl = options.ytdl || resolveOnPath(process.platform === 'win32' ? ['yt-dlp.exe', 'yt-dlp'] : ['yt-dlp']) - - let changed = !existsSync(configPath) - let nextContent = content - - if (detectedMpv) { - const updated = upsertTomlValue(nextContent, 'mpv', detectedMpv) - changed ||= updated !== nextContent - nextContent = updated - } - - if (detectedYtdl) { - const updated = upsertTomlValue(nextContent, 'ytdl', detectedYtdl) - changed ||= updated !== nextContent - nextContent = updated - } - - if (changed && !options.dryRun) { - writeFileSync(configPath, nextContent, 'utf8') - } - - return { - configPath, - changed, - mpvPath: detectedMpv, - ytdlPath: detectedYtdl, - } -} - -function runWindowsSetup(rootPath, options) { - const powershell = resolveOnPath(['powershell.exe', 'pwsh.exe']) || 'powershell.exe' - const installScript = path.join(scriptDir, 'install-mpv-handler.ps1') - const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', installScript, '-InstallRoot', rootPath] - - if (options.keepExisting) { - args.push('-KeepExistingProtocolKeys') - } - - if (options.dryRun) { - console.log('Dry run: would execute Windows installer') - console.log(`${powershell} ${args.map((value) => JSON.stringify(value)).join(' ')}`) - return - } - - const result = spawnSync(powershell, args, { stdio: 'inherit' }) - if (result.status !== 0) { - fail('Windows protocol registration failed. Re-run the command from an elevated PowerShell or Windows Terminal.') - } -} - -function rewriteDesktopExec(content, targetBinary) { - return content.replace(/^Exec=.*$/m, (line) => { - const prefix = 'Exec=' - const rest = line.slice(prefix.length).trim() - const firstSpaceIndex = rest.indexOf(' ') - const suffix = firstSpaceIndex === -1 ? '' : rest.slice(firstSpaceIndex) - return `${prefix}${targetBinary}${suffix}` - }) -} - -function runLinuxSetup(rootPath, options) { - const localBin = path.join(os.homedir(), '.local', 'bin') - const applicationsDir = path.join(os.homedir(), '.local', 'share', 'applications') - const targetBinary = path.join(localBin, 'mpv-handler') - const copies = [ - { - source: path.join(rootPath, 'mpv-handler'), - target: targetBinary, - executable: true, - }, - { - source: path.join(rootPath, 'mpv-handler.desktop'), - target: path.join(applicationsDir, 'mpv-handler.desktop'), - patchExec: true, - }, - { - source: path.join(rootPath, 'mpv-handler-debug.desktop'), - target: path.join(applicationsDir, 'mpv-handler-debug.desktop'), - patchExec: true, - }, - ] - - if (options.dryRun) { - console.log('Dry run: would install Linux desktop files to ~/.local') - for (const item of copies) { - console.log(`copy ${item.source} -> ${item.target}`) - } - console.log('xdg-mime default mpv-handler.desktop x-scheme-handler/mpv-handler') - console.log('xdg-mime default mpv-handler-debug.desktop x-scheme-handler/mpv-handler-debug') - return - } - - mkdirSync(localBin, { recursive: true }) - mkdirSync(applicationsDir, { recursive: true }) - - for (const item of copies) { - if (item.patchExec) { - const content = readFileSync(item.source, 'utf8') - writeFileSync(item.target, rewriteDesktopExec(content, targetBinary), 'utf8') - continue - } - - copyFileSync(item.source, item.target) - if (item.executable) { - chmodSync(item.target, 0o755) - } - } - - for (const args of [ - ['default', 'mpv-handler.desktop', 'x-scheme-handler/mpv-handler'], - ['default', 'mpv-handler-debug.desktop', 'x-scheme-handler/mpv-handler-debug'], - ]) { - const result = spawnSync('xdg-mime', args, { stdio: 'inherit' }) - if (result.status !== 0) { - fail(`xdg-mime failed for ${args[2]}. Run the command manually after fixing your desktop environment registration.`) - } - } -} - -function main() { - const options = parseArgs(process.argv.slice(2)) - - if (options.help) { - console.log(usage) - return - } - - if (!['win32', 'linux', 'darwin'].includes(process.platform)) { - fail(`Unsupported platform: ${process.platform}`) - } - - if (process.platform === 'darwin') { - console.log('Automatic macOS protocol registration is not available in this repo yet.') - console.log('The app can still browse media on macOS, but desktop playback setup must be handled manually.') - return - } - - const rootPath = resolveRoot(options) - const configResult = ensureConfig(rootPath, options) - - console.log(`Platform: ${process.platform}`) - console.log(`mpv-handler root: ${rootPath}`) - console.log(`config.toml: ${configResult.configPath}${configResult.changed ? ' (updated)' : ' (unchanged)'}`) - console.log(`mpv: ${configResult.mpvPath || 'not detected'}`) - console.log(`yt-dlp: ${configResult.ytdlPath || 'not detected'}`) - - if (process.platform === 'win32') { - runWindowsSetup(rootPath, options) - } else if (process.platform === 'linux') { - runLinuxSetup(rootPath, options) - } - - console.log('mpv-handler setup complete.') -} - -main() diff --git a/scripts/setup-mpv-handler.mjs b/scripts/setup-mpv-handler.mjs index e272900..b3b182f 100644 --- a/scripts/setup-mpv-handler.mjs +++ b/scripts/setup-mpv-handler.mjs @@ -156,22 +156,43 @@ function resolveRoot(options) { } if (process.platform === 'linux') { - fail('Could not find a Linux mpv-handler release. Download and extract the upstream archive, then pass --root /path/to/extracted/mpv-handler-linux-amd64.') + const archHint = process.arch === 'x64' + ? 'Download and extract the upstream archive, then pass --root /path/to/extracted/mpv-handler-linux-amd64.' + : `The upstream project only publishes an official linux-amd64 archive. On ${process.arch}, build or obtain a compatible mpv-handler binary and pass --root /path/to/extracted/mpv-handler.` + fail(`Could not find a Linux mpv-handler release. ${archHint}`) } fail('Automatic setup is not available for this platform yet.') } +function getLinuxConfigDir() { + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() + return xdgConfigHome + ? path.join(xdgConfigHome, 'mpv-handler') + : path.join(os.homedir(), '.config', 'mpv-handler') +} + +function getConfigPath(rootPath) { + if (process.platform === 'linux') { + return path.join(getLinuxConfigDir(), 'config.toml') + } + + return path.join(rootPath, 'config.toml') +} + function ensureConfig(rootPath, options) { - const configPath = path.join(rootPath, 'config.toml') + const configPath = getConfigPath(rootPath) if (options.skipConfig) { return { configPath, changed: false, mpvPath: '', ytdlPath: '' } } const configExists = existsSync(configPath) + const bundledConfigPath = path.join(rootPath, 'config.toml') const content = configExists ? readFileSync(configPath, 'utf8') - : readFileSync(templateConfigPath, 'utf8') + : existsSync(bundledConfigPath) + ? readFileSync(bundledConfigPath, 'utf8') + : readFileSync(templateConfigPath, 'utf8') const detectedMpv = options.mpv || resolveOnPath(process.platform === 'win32' ? ['mpv.com', 'mpv.exe', 'mpv'] : ['mpv']) const detectedYtdl = options.ytdl || resolveOnPath(process.platform === 'win32' ? ['yt-dlp.exe', 'yt-dlp'] : ['yt-dlp']) @@ -192,6 +213,7 @@ function ensureConfig(rootPath, options) { } if (changed && !options.dryRun) { + mkdirSync(path.dirname(configPath), { recursive: true }) writeFileSync(configPath, nextContent, 'utf8') } diff --git a/src/audioMetadata.ts b/src/audioMetadata.ts index 8b84e67..b50b074 100644 --- a/src/audioMetadata.ts +++ b/src/audioMetadata.ts @@ -26,6 +26,8 @@ const AUDIO_METADATA_EXTENSIONS = new Set([ let ffmpegInstance: FFmpeg | null = null let ffmpegLoadPromise: Promise | null = null +let ffmpegOperationPromise: Promise = Promise.resolve() +let ffmpegTempFileCounter = 0 function sanitizeExtension(extension?: string | null) { return (extension || '').trim().replace(/^\./, '').toLowerCase() @@ -54,6 +56,17 @@ async function getFFmpeg() { } } +function createTempFileStem() { + ffmpegTempFileCounter += 1 + return `metadata-${Date.now()}-${ffmpegTempFileCounter}` +} + +function queueFFmpegOperation(operation: () => Promise) { + const pending = ffmpegOperationPromise.then(operation, operation) + ffmpegOperationPromise = pending.then(() => undefined, () => undefined) + return pending +} + function buildMetadataArgs(metadata: AudioTagMetadata) { const args: string[] = [] if (metadata.title) args.push('-metadata', `title=${metadata.title}`) @@ -74,37 +87,42 @@ export function supportsContainerMetadataEmbedding(extension?: string | null, mi export async function embedContainerAudioMetadata(blob: Blob, extension: string | undefined, metadata: AudioTagMetadata) { if (!metadata.title && !metadata.artist && !metadata.album && !metadata.trackNumber) return blob - const normalizedExtension = sanitizeExtension(extension) || 'audio' - const ffmpeg = await getFFmpeg() - const { fetchFile } = await import('@ffmpeg/util') - const inputPath = `input.${normalizedExtension}` - const outputPath = `output.${normalizedExtension}` + return queueFFmpegOperation(async () => { + const normalizedExtension = sanitizeExtension(extension) || 'audio' + const ffmpeg = await getFFmpeg() + const { fetchFile } = await import('@ffmpeg/util') + const tempStem = createTempFileStem() + const inputPath = `${tempStem}-input.${normalizedExtension}` + const outputPath = `${tempStem}-output.${normalizedExtension}` - await ffmpeg.writeFile(inputPath, await fetchFile(blob)) + await ffmpeg.writeFile(inputPath, await fetchFile(blob)) - try { - const exitCode = await ffmpeg.exec([ - '-i', inputPath, - '-map', '0', - '-map_metadata', '-1', - '-c', 'copy', - ...buildMetadataArgs(metadata), - outputPath, - ]) + try { + const exitCode = await ffmpeg.exec([ + '-nostdin', + '-y', + '-i', inputPath, + '-map', '0', + '-map_metadata', '-1', + '-c', 'copy', + ...buildMetadataArgs(metadata), + outputPath, + ]) - if (exitCode !== 0) { - throw new Error(`FFmpeg metadata update failed (${exitCode})`) + if (exitCode !== 0) { + throw new Error(`FFmpeg metadata update failed (${exitCode})`) + } + + const outputData = await ffmpeg.readFile(outputPath) + const outputBytes = outputData instanceof Uint8Array ? outputData : new Uint8Array(new TextEncoder().encode(String(outputData))) + const outputCopy = new Uint8Array(outputBytes.byteLength) + outputCopy.set(outputBytes) + return new Blob([outputCopy], { type: blob.type || 'application/octet-stream' }) + } finally { + try { await ffmpeg.deleteFile(inputPath) } catch {} + try { await ffmpeg.deleteFile(outputPath) } catch {} } - - const outputData = await ffmpeg.readFile(outputPath) - const outputBytes = outputData instanceof Uint8Array ? outputData : new Uint8Array(new TextEncoder().encode(String(outputData))) - const outputCopy = new Uint8Array(outputBytes.byteLength) - outputCopy.set(outputBytes) - return new Blob([outputCopy], { type: blob.type || 'application/octet-stream' }) - } finally { - try { await ffmpeg.deleteFile(inputPath) } catch {} - try { await ffmpeg.deleteFile(outputPath) } catch {} - } + }) } export type { AudioTagMetadata } \ No newline at end of file