update for flac metadata on iphone/rpi
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+5
-366
@@ -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> Path to the extracted mpv-handler folder.
|
||||
--mpv <path> Override the mpv executable path written to config.toml.
|
||||
--ytdl <path> 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()
|
||||
|
||||
@@ -156,21 +156,42 @@ 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')
|
||||
: existsSync(bundledConfigPath)
|
||||
? readFileSync(bundledConfigPath, 'utf8')
|
||||
: readFileSync(templateConfigPath, 'utf8')
|
||||
|
||||
const detectedMpv = options.mpv || resolveOnPath(process.platform === 'win32' ? ['mpv.com', 'mpv.exe', 'mpv'] : ['mpv'])
|
||||
@@ -192,6 +213,7 @@ function ensureConfig(rootPath, options) {
|
||||
}
|
||||
|
||||
if (changed && !options.dryRun) {
|
||||
mkdirSync(path.dirname(configPath), { recursive: true })
|
||||
writeFileSync(configPath, nextContent, 'utf8')
|
||||
}
|
||||
|
||||
|
||||
+20
-2
@@ -26,6 +26,8 @@ const AUDIO_METADATA_EXTENSIONS = new Set([
|
||||
|
||||
let ffmpegInstance: FFmpeg | null = null
|
||||
let ffmpegLoadPromise: Promise<FFmpeg> | null = null
|
||||
let ffmpegOperationPromise: Promise<void> = 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<T>(operation: () => Promise<T>) {
|
||||
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,16 +87,20 @@ 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
|
||||
|
||||
return queueFFmpegOperation(async () => {
|
||||
const normalizedExtension = sanitizeExtension(extension) || 'audio'
|
||||
const ffmpeg = await getFFmpeg()
|
||||
const { fetchFile } = await import('@ffmpeg/util')
|
||||
const inputPath = `input.${normalizedExtension}`
|
||||
const outputPath = `output.${normalizedExtension}`
|
||||
const tempStem = createTempFileStem()
|
||||
const inputPath = `${tempStem}-input.${normalizedExtension}`
|
||||
const outputPath = `${tempStem}-output.${normalizedExtension}`
|
||||
|
||||
await ffmpeg.writeFile(inputPath, await fetchFile(blob))
|
||||
|
||||
try {
|
||||
const exitCode = await ffmpeg.exec([
|
||||
'-nostdin',
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-map', '0',
|
||||
'-map_metadata', '-1',
|
||||
@@ -105,6 +122,7 @@ export async function embedContainerAudioMetadata(blob: Blob, extension: string
|
||||
try { await ffmpeg.deleteFile(inputPath) } catch {}
|
||||
try { await ffmpeg.deleteFile(outputPath) } catch {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type { AudioTagMetadata }
|
||||
Reference in New Issue
Block a user