diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5784c04..8972cbd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,16 +33,16 @@ jobs: ytdlp_output: yt-dlp.exe ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe - ffmpeg_output: ffmpeg.exe + ffprobe_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe - platform: macos os: macos-latest build_script: pnpm run build:mac ytdlp_asset: yt-dlp_macos ytdlp_output: yt-dlp_macos - ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip - ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip + ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip + ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip ffmpeg_inner_path: ffmpeg/ffmpeg - ffmpeg_output: ffmpeg_macos + ffprobe_inner_path: ffmpeg/ffprobe - platform: linux os: ubuntu-latest build_script: pnpm run build:linux @@ -50,7 +50,7 @@ jobs: ytdlp_output: yt-dlp_linux ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg - ffmpeg_output: ffmpeg_linux + ffprobe_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffprobe steps: - name: Check out Git repository uses: actions/checkout@v4 @@ -77,8 +77,11 @@ jobs: Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force $source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}' - $destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}' - Copy-Item -Path $source -Destination $destination -Force + $ffprobeSource = Join-Path 'ffmpeg' '${{ matrix.ffprobe_inner_path }}' + $destinationDir = Join-Path 'resources' 'ffmpeg' + New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null + Copy-Item -Path $source -Destination (Join-Path $destinationDir 'ffmpeg.exe') -Force + Copy-Item -Path $ffprobeSource -Destination (Join-Path $destinationDir 'ffprobe.exe') -Force Remove-Item ffmpeg.zip -Force Remove-Item ffmpeg -Recurse -Force @@ -86,7 +89,8 @@ jobs: if: matrix.platform == 'macos' shell: bash env: - FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }} + FFMPEG_OUTPUT: ffmpeg + FFPROBE_OUTPUT: ffprobe run: | set -euo pipefail curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip @@ -97,6 +101,8 @@ jobs: arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}" x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}" + arm_probe="ffmpeg-arm/${{ matrix.ffprobe_inner_path }}" + x86_probe="ffmpeg-x86/${{ matrix.ffprobe_inner_path }}" if [[ ! -f "$arm_bin" ]]; then arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)" @@ -104,6 +110,12 @@ jobs: if [[ ! -f "$x86_bin" ]]; then x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)" fi + if [[ ! -f "$arm_probe" ]]; then + arm_probe="$(find ffmpeg-arm -type f -name ffprobe -print -quit)" + fi + if [[ ! -f "$x86_probe" ]]; then + x86_probe="$(find ffmpeg-x86 -type f -name ffprobe -print -quit)" + fi if [[ ! -f "$arm_bin" ]]; then echo "::error::Missing arm64 ffmpeg binary at $arm_bin" @@ -113,9 +125,19 @@ jobs: echo "::error::Missing x86_64 ffmpeg binary at $x86_bin" exit 1 fi + if [[ ! -f "$arm_probe" ]]; then + echo "::error::Missing arm64 ffprobe binary at $arm_probe" + exit 1 + fi + if [[ ! -f "$x86_probe" ]]; then + echo "::error::Missing x86_64 ffprobe binary at $x86_probe" + exit 1 + fi - lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT" - chmod +x "resources/$FFMPEG_OUTPUT" + mkdir -p resources/ffmpeg + lipo -create "$arm_bin" "$x86_bin" -output "resources/ffmpeg/$FFMPEG_OUTPUT" + lipo -create "$arm_probe" "$x86_probe" -output "resources/ffmpeg/$FFPROBE_OUTPUT" + chmod +x "resources/ffmpeg/$FFMPEG_OUTPUT" "resources/ffmpeg/$FFPROBE_OUTPUT" rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip - name: Download ffmpeg binary (Linux) @@ -130,8 +152,10 @@ jobs: fi mkdir ffmpeg tar -xf ffmpeg.tar.xz -C ffmpeg - cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}" - chmod +x "resources/${{ matrix.ffmpeg_output }}" + mkdir -p resources/ffmpeg + cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/ffmpeg/ffmpeg" + cp "ffmpeg/${{ matrix.ffprobe_inner_path }}" "resources/ffmpeg/ffprobe" + chmod +x "resources/ffmpeg/ffmpeg" "resources/ffmpeg/ffprobe" rm -rf ffmpeg.tar.xz ffmpeg - name: Download yt-dlp binary @@ -230,5 +254,15 @@ jobs: uses: actions/upload-artifact@v4 with: name: dist-${{ matrix.os }} - path: dist/ + path: | + dist/*.exe + dist/*.zip + dist/*.dmg + dist/*.AppImage + dist/*.snap + dist/*.deb + dist/*.rpm + dist/*.tar.gz + dist/*.yml + dist/*.blockmap retention-days: 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70ce644..82454f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,3 +7,5 @@ on: jobs: build: uses: ./.github/workflows/build.yml + with: + upload_artifacts: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cef9f0..6b09bc1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ src/ - Build production bundles with `pnpm build`. - Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`. - Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection. -- Bundle platform binaries of `yt-dlp` and `ffmpeg` under `resources/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box. +- Bundle platform binaries of `yt-dlp` and `ffmpeg/ffprobe` under `resources/ffmpeg/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box. ## Working on Changes - Keep each pull request focused on a single problem or feature. diff --git a/build/after-pack.cjs b/build/after-pack.cjs index 7b46f66..b34ec69 100644 --- a/build/after-pack.cjs +++ b/build/after-pack.cjs @@ -2,7 +2,12 @@ const { execFileSync } = require('node:child_process') const fs = require('node:fs') const path = require('node:path') -const BINARIES = ['yt-dlp_macos', 'ffmpeg_macos', 'deno'] +const BINARIES = [ + 'yt-dlp_macos', + path.join('ffmpeg', 'ffmpeg'), + path.join('ffmpeg', 'ffprobe'), + 'deno' +] const findAppBundle = (appOutDir) => { const entries = fs.readdirSync(appOutDir) diff --git a/resources/.gitignore b/resources/.gitignore index 6360655..b84f492 100644 --- a/resources/.gitignore +++ b/resources/.gitignore @@ -6,6 +6,9 @@ ffmpeg.exe ffmpeg_macos ffmpeg_linux ffmpeg +ffmpeg/ +ffprobe +ffprobe.exe deno.exe deno diff --git a/resources/README.md b/resources/README.md index 3d8c736..a9e351d 100644 --- a/resources/README.md +++ b/resources/README.md @@ -48,27 +48,27 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux" ``` -## ffmpeg Binaries +## ffmpeg/ffprobe Binaries -ffmpeg is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform: +ffmpeg is required for merging audio/video streams and audio extraction. ffprobe is required for post-processing metadata. Bundle both binaries under `resources/ffmpeg/`. ### Required Files -1. **Windows**: `ffmpeg.exe` -2. **macOS**: `ffmpeg_macos` -3. **Linux**: `ffmpeg_linux` +1. **Windows**: `resources/ffmpeg/ffmpeg.exe` and `resources/ffmpeg/ffprobe.exe` +2. **macOS**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe` +3. **Linux**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe` ### How to Download -- **Windows / Linux**: Grab static builds from (or ) and rename the binary to match the filenames above. -- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from . Extract them and merge into a universal binary with `lipo -create`, then save the result as `resources/ffmpeg_macos`. -- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`). +- **Windows / Linux**: Grab static builds from (or ) and copy `ffmpeg` and `ffprobe` into `resources/ffmpeg/`. +- **macOS**: Download the `ffmpeg-*.zip` asset from , then copy `ffmpeg` and `ffprobe` from the archive into `resources/ffmpeg/`. +- On macOS/Linux ensure both binaries are executable: `chmod +x resources/ffmpeg/ffmpeg resources/ffmpeg/ffprobe`. ### Note -- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH. -- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations. -- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary +- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/ffprobe from the system PATH. +- You can override the lookup path via `FFMPEG_PATH`. It must point to a directory containing both `ffmpeg` and `ffprobe`. +- File sizes: ~40-80 MB per ffmpeg build (ffmpeg + ffprobe) ## JS Runtime (Deno) diff --git a/scripts/check-ytdlp.js b/scripts/check-ytdlp.js index 4c966df..23d17ee 100644 --- a/scripts/check-ytdlp.js +++ b/scripts/check-ytdlp.js @@ -23,10 +23,10 @@ if (!supportedPlatforms.includes(platform)) { const binaries = [ { label: 'yt-dlp', - filenameMap: { - win: 'yt-dlp.exe', - mac: 'yt-dlp_macos', - linux: 'yt-dlp_linux' + paths: { + win: ['yt-dlp.exe'], + mac: ['yt-dlp_macos'], + linux: ['yt-dlp_linux'] }, help: { default: 'https://github.com/yt-dlp/yt-dlp/releases/latest' @@ -34,10 +34,23 @@ const binaries = [ }, { label: 'ffmpeg', - filenameMap: { - win: 'ffmpeg.exe', - mac: 'ffmpeg_macos', - linux: 'ffmpeg_linux' + paths: { + win: ['ffmpeg/ffmpeg.exe'], + mac: ['ffmpeg/ffmpeg'], + linux: ['ffmpeg/ffmpeg'] + }, + help: { + win: 'https://ffmpeg.org/download.html', + linux: 'https://ffmpeg.org/download.html', + mac: 'https://github.com/eko5624/mpv-mac/releases/latest' + } + }, + { + label: 'ffprobe', + paths: { + win: ['ffmpeg/ffprobe.exe'], + mac: ['ffmpeg/ffprobe'], + linux: ['ffmpeg/ffprobe'] }, help: { win: 'https://ffmpeg.org/download.html', @@ -47,10 +60,10 @@ const binaries = [ }, { label: 'deno', - filenameMap: { - win: 'deno.exe', - mac: 'deno', - linux: 'deno' + paths: { + win: ['deno.exe'], + mac: ['deno'], + linux: ['deno'] }, help: { default: 'https://github.com/denoland/deno/releases/latest' @@ -61,12 +74,15 @@ const binaries = [ let hasMissingBinary = false for (const binary of binaries) { - const filename = binary.filenameMap[platform] - const binaryPath = path.join(__dirname, '..', 'resources', filename) + const candidates = binary.paths[platform] || [] + const found = candidates.find((filename) => + fs.existsSync(path.join(__dirname, '..', 'resources', filename)) + ) - if (!fs.existsSync(binaryPath)) { - console.error(`❌ Error: resources/${filename} not found!`) - console.error(`Please download ${filename} to the resources/ directory first.`) + if (!found) { + const expected = candidates.length ? candidates.join(' or ') : binary.label + console.error(`❌ Error: resources/${expected} not found!`) + console.error(`Please download ${binary.label} to the resources/ directory first.`) const help = typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default if (help) { @@ -74,7 +90,7 @@ for (const binary of binaries) { } hasMissingBinary = true } else { - console.log(`✅ ${filename} found in resources/ directory`) + console.log(`✅ ${binary.label} found: resources/${found}`) } } diff --git a/scripts/setup-dev-binaries.js b/scripts/setup-dev-binaries.js index 66dc196..bba8a0a 100755 --- a/scripts/setup-dev-binaries.js +++ b/scripts/setup-dev-binaries.js @@ -14,6 +14,7 @@ const http = require('node:http') // Configuration const RESOURCES_DIR = path.join(__dirname, '..', 'resources') +const FFMPEG_DIR = path.join(RESOURCES_DIR, 'ffmpeg') const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download' const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download' const GITHUB_TOKEN = @@ -29,7 +30,9 @@ const PLATFORM_CONFIG = { ffmpeg: { url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip', innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe', + ffprobeInnerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffprobe.exe', output: 'ffmpeg.exe', + ffprobeOutput: 'ffprobe.exe', extract: 'unzip', release: { repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], @@ -46,9 +49,11 @@ const PLATFORM_CONFIG = { ffmpeg: { // For development, download only the architecture matching current system arm64: { - url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip', + url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip', innerPath: 'ffmpeg/ffmpeg', - output: 'ffmpeg_macos', + ffprobeInnerPath: 'ffmpeg/ffprobe', + output: 'ffmpeg', + ffprobeOutput: 'ffprobe', extract: 'unzip', release: { repo: 'eko5624/mpv-mac', @@ -56,9 +61,11 @@ const PLATFORM_CONFIG = { } }, x64: { - url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip', + url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip', innerPath: 'ffmpeg/ffmpeg', - output: 'ffmpeg_macos', + ffprobeInnerPath: 'ffmpeg/ffprobe', + output: 'ffmpeg', + ffprobeOutput: 'ffprobe', extract: 'unzip', release: { repo: 'eko5624/mpv-mac', @@ -75,7 +82,9 @@ const PLATFORM_CONFIG = { ffmpeg: { url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz', innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg', - output: 'ffmpeg_linux', + ffprobeInnerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffprobe', + output: 'ffmpeg', + ffprobeOutput: 'ffprobe', extract: 'tar', release: { repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], @@ -419,23 +428,43 @@ async function downloadYtDlp(config) { } async function downloadFfmpegWindows(config) { - const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg - const outputPath = path.join(RESOURCES_DIR, output) + const { + url: fallbackUrl, + innerPath: fallbackInnerPath, + ffprobeInnerPath: fallbackFfprobeInnerPath, + output, + ffprobeOutput, + release + } = config.ffmpeg + const outputPath = path.join(FFMPEG_DIR, output) + const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null - if (fileExists(outputPath)) { + const ffmpegExists = fileExists(outputPath) + const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true + + if (ffmpegExists && ffprobeExists) { const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') - if (!validation.ok) { - log(`Existing ${output} failed version check: ${validation.message}`, 'warn') + const ffprobeValidation = ffprobeOutputPath + ? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe') + : { ok: true } + if (!validation.ok || !ffprobeValidation.ok) { + log( + `Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`, + 'warn' + ) + } else { + log('ffmpeg and ffprobe already exist, skipping download', 'info') + return } - log(`${output} already exists, skipping download`, 'info') - return } log(`Downloading ffmpeg for Windows...`, 'download') + ensureDir(FFMPEG_DIR) const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') let downloadUrl = fallbackUrl let innerPath = fallbackInnerPath + let ffprobeInnerPath = fallbackFfprobeInnerPath if (release) { try { @@ -446,6 +475,10 @@ async function downloadFfmpegWindows(config) { if (inferred) { innerPath = inferred } + const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe.exe') + if (inferredFfprobe) { + ffprobeInnerPath = inferredFfprobe + } } } catch (error) { log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn') @@ -463,6 +496,13 @@ async function downloadFfmpegWindows(config) { } fs.copyFileSync(sourcePath, outputPath) + if (ffprobeInnerPath && ffprobeOutputPath) { + const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath.replace(/\\/g, path.sep)) + if (!fileExists(ffprobeSourcePath)) { + throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`) + } + fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath) + } const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { if (validation.code === 'ETIMEDOUT') { @@ -493,19 +533,38 @@ async function downloadFfmpegMac(config) { throw new Error(`Unsupported architecture: ${arch}`) } - const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig - const outputPath = path.join(RESOURCES_DIR, output) + const { + url: fallbackUrl, + innerPath, + ffprobeInnerPath, + output, + ffprobeOutput, + release + } = ffmpegConfig + const outputPath = path.join(FFMPEG_DIR, output) + const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null - if (fileExists(outputPath)) { + const ffmpegExists = fileExists(outputPath) + const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true + + if (ffmpegExists && ffprobeExists) { const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') - if (!validation.ok) { - log(`Existing ${output} failed version check: ${validation.message}`, 'warn') + const ffprobeValidation = ffprobeOutputPath + ? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe') + : { ok: true } + if (!validation.ok || !ffprobeValidation.ok) { + log( + `Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`, + 'warn' + ) + } else { + log('ffmpeg and ffprobe already exist, skipping download', 'info') + return } - log(`${output} already exists, skipping download`, 'info') - return } log(`Downloading ffmpeg for macOS (${arch})...`, 'download') + ensureDir(FFMPEG_DIR) const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') let downloadUrl = fallbackUrl @@ -533,6 +592,14 @@ async function downloadFfmpegMac(config) { fs.copyFileSync(sourcePath, outputPath) setExecutable(outputPath) + if (ffprobeInnerPath && ffprobeOutputPath) { + const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath) + if (!fileExists(ffprobeSourcePath)) { + throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`) + } + fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath) + setExecutable(ffprobeOutputPath) + } const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { safeUnlink(outputPath) @@ -551,23 +618,43 @@ async function downloadFfmpegMac(config) { } async function downloadFfmpegLinux(config) { - const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg - const outputPath = path.join(RESOURCES_DIR, output) + const { + url: fallbackUrl, + innerPath: fallbackInnerPath, + ffprobeInnerPath: fallbackFfprobeInnerPath, + output, + ffprobeOutput, + release + } = config.ffmpeg + const outputPath = path.join(FFMPEG_DIR, output) + const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null - if (fileExists(outputPath)) { + const ffmpegExists = fileExists(outputPath) + const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true + + if (ffmpegExists && ffprobeExists) { const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') - if (!validation.ok) { - log(`Existing ${output} failed version check: ${validation.message}`, 'warn') + const ffprobeValidation = ffprobeOutputPath + ? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe') + : { ok: true } + if (!validation.ok || !ffprobeValidation.ok) { + log( + `Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`, + 'warn' + ) + } else { + log('ffmpeg and ffprobe already exist, skipping download', 'info') + return } - log(`${output} already exists, skipping download`, 'info') - return } log(`Downloading ffmpeg for Linux...`, 'download') + ensureDir(FFMPEG_DIR) const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') let downloadUrl = fallbackUrl let innerPath = fallbackInnerPath + let ffprobeInnerPath = fallbackFfprobeInnerPath if (release) { try { @@ -578,6 +665,10 @@ async function downloadFfmpegLinux(config) { if (inferred) { innerPath = inferred } + const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe') + if (inferredFfprobe) { + ffprobeInnerPath = inferredFfprobe + } } } catch (error) { log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn') @@ -596,6 +687,14 @@ async function downloadFfmpegLinux(config) { fs.copyFileSync(sourcePath, outputPath) setExecutable(outputPath) + if (ffprobeInnerPath && ffprobeOutputPath) { + const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath) + if (!fileExists(ffprobeSourcePath)) { + throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`) + } + fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath) + setExecutable(ffprobeOutputPath) + } const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') if (!validation.ok) { safeUnlink(outputPath) diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index e47244f..f68495f 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -137,9 +137,7 @@ export const buildDownloadArgs = ( } else { args.push('--no-embed-subs') } - if (process.platform !== 'darwin') { - args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail') - } + args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail') args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata') args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters') diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 88c9cd4..9eba4ba 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -55,6 +55,8 @@ const formatYtDlpCommand = (args: string[]): string => { return `yt-dlp ${quoted.join(' ')}` } +const resolveFfmpegLocation = (ffmpegPath: string): string => path.dirname(ffmpegPath) + const ensureDirectoryExists = (dir?: string): void => { if (!dir) { return @@ -908,7 +910,8 @@ class DownloadEngine extends EventEmitter { return } - args.push('--ffmpeg-location', ffmpegPath) + const ffmpegLocation = resolveFfmpegLocation(ffmpegPath) + args.push('--ffmpeg-location', ffmpegLocation) args.push(urlArg) const ytDlpCommand = formatYtDlpCommand(args) diff --git a/src/main/lib/ffmpeg-manager.ts b/src/main/lib/ffmpeg-manager.ts index 4fd181f..ee6eb77 100644 --- a/src/main/lib/ffmpeg-manager.ts +++ b/src/main/lib/ffmpeg-manager.ts @@ -28,46 +28,58 @@ class FfmpegManager { private async findFfmpegBinary(): Promise { const platform = os.platform() - const resourceCandidates: string[] = [] + const ffmpegFileName = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + const ffprobeFileName = platform === 'win32' ? 'ffprobe.exe' : 'ffprobe' - if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) { - scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH) - return process.env.FFMPEG_PATH + const resolveBundledFfmpeg = (dirPath: string, label: string): string | null => { + const ffmpegPath = path.join(dirPath, ffmpegFileName) + const ffprobePath = path.join(dirPath, ffprobeFileName) + if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) { + return null + } + if (platform !== 'win32') { + try { + fs.chmodSync(ffmpegPath, 0o755) + fs.chmodSync(ffprobePath, 0o755) + } catch (error) { + scopedLoggers.engine.warn(`Failed to set executable permission on ${label}:`, error) + } + } + scopedLoggers.engine.info(`Using ${label}:`, ffmpegPath) + return ffmpegPath } - if (platform === 'win32') { - resourceCandidates.push('ffmpeg.exe') - } else if (platform === 'darwin') { - resourceCandidates.push('ffmpeg_macos', 'ffmpeg') - } else { - resourceCandidates.push('ffmpeg_linux', 'ffmpeg') + const envPath = process.env.FFMPEG_PATH + if (envPath) { + if (!fs.existsSync(envPath)) { + throw new Error( + 'FFMPEG_PATH does not exist. Provide a directory containing ffmpeg and ffprobe.' + ) + } + const stats = fs.statSync(envPath) + if (!stats.isDirectory()) { + throw new Error('FFMPEG_PATH must be a directory containing ffmpeg and ffprobe.') + } + const resolved = resolveBundledFfmpeg(envPath, 'ffmpeg from FFMPEG_PATH directory') + if (resolved) { + return resolved + } + throw new Error('FFMPEG_PATH must contain both ffmpeg and ffprobe.') } const resourcesPath = this.getResourcesPath() - for (const candidate of resourceCandidates) { - const fullPath = path.join(resourcesPath, candidate) - if (fs.existsSync(fullPath)) { - if (platform !== 'win32') { - try { - fs.chmodSync(fullPath, 0o755) - } catch (error) { - scopedLoggers.engine.warn( - 'Failed to set executable permission on ffmpeg binary:', - error - ) - } - } - scopedLoggers.engine.info('Using bundled ffmpeg:', fullPath) - return fullPath - } + const bundledDir = path.join(resourcesPath, 'ffmpeg') + const bundledResolved = resolveBundledFfmpeg(bundledDir, 'bundled ffmpeg') + if (bundledResolved) { + return bundledResolved } if (platform === 'darwin') { - const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'] - for (const candidate of commonPaths) { - if (fs.existsSync(candidate)) { - scopedLoggers.engine.info('Using system ffmpeg:', candidate) - return candidate + const commonDirs = ['/opt/homebrew/bin', '/usr/local/bin'] + for (const candidate of commonDirs) { + const resolved = resolveBundledFfmpeg(candidate, 'system ffmpeg') + if (resolved) { + return resolved } } } @@ -76,8 +88,10 @@ class FfmpegManager { try { const systemPath = execSync('which ffmpeg').toString().trim() if (systemPath && fs.existsSync(systemPath)) { - scopedLoggers.engine.info('Using system ffmpeg:', systemPath) - return systemPath + const resolved = resolveBundledFfmpeg(path.dirname(systemPath), 'system ffmpeg') + if (resolved) { + return resolved + } } } catch (_error) { // Ignore error and continue @@ -88,8 +102,10 @@ class FfmpegManager { try { const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0] if (output && fs.existsSync(output)) { - scopedLoggers.engine.info('Using system ffmpeg:', output) - return output + const resolved = resolveBundledFfmpeg(path.dirname(output), 'system ffmpeg') + if (resolved) { + return resolved + } } } catch (_error) { // Ignore error and continue @@ -97,7 +113,7 @@ class FfmpegManager { } throw new Error( - 'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.' + 'ffmpeg/ffprobe not found. Bundle them under resources/ffmpeg/ (asarUnpack) or set FFMPEG_PATH to a directory containing both.' ) } } diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index c6faa0f..dcf5b72 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -520,30 +520,26 @@ export function Settings() { - {platform !== 'darwin' && ( - <> - - - {t('settings.embedThumbnail')} - {t('settings.embedThumbnailDescription')} - - - { - try { - handleSettingChange('embedThumbnail', value) - } catch (error) { - logger.error('[Settings] Error toggling embedThumbnail:', error) - } - }} - /> - - + + + {t('settings.embedThumbnail')} + {t('settings.embedThumbnailDescription')} + + + { + try { + handleSettingChange('embedThumbnail', value) + } catch (error) { + logger.error('[Settings] Error toggling embedThumbnail:', error) + } + }} + /> + + - - - )} +