* fix(ffmpeg): require bundled ffprobe directory * ci(build): upload artifacts on PR * fix(download): allow embed thumbnail on macos
This commit is contained in:
60
.github/workflows/build.yml
vendored
60
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -7,3 +7,5 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
3
resources/.gitignore
vendored
3
resources/.gitignore
vendored
@@ -6,6 +6,9 @@ ffmpeg.exe
|
||||
ffmpeg_macos
|
||||
ffmpeg_linux
|
||||
ffmpeg
|
||||
ffmpeg/
|
||||
ffprobe
|
||||
ffprobe.exe
|
||||
deno.exe
|
||||
deno
|
||||
|
||||
|
||||
@@ -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 <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and rename the binary to match the filenames above.
|
||||
- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from <https://github.com/eko5624/mpv-mac/releases/latest>. 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 <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and copy `ffmpeg` and `ffprobe` into `resources/ffmpeg/`.
|
||||
- **macOS**: Download the `ffmpeg-*.zip` asset from <https://github.com/eko5624/mpv-mac/releases/latest>, 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)
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,46 +28,58 @@ class FfmpegManager {
|
||||
|
||||
private async findFfmpegBinary(): Promise<string> {
|
||||
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.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,30 +520,26 @@ export function Settings() {
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
{platform !== 'darwin' && (
|
||||
<>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedThumbnail ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedThumbnail', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedThumbnail ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedThumbnail', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
</>
|
||||
)}
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
|
||||
Reference in New Issue
Block a user