diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d645a26 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,133 @@ +name: Build + +on: + workflow_call: + inputs: + upload_artifacts: + required: false + type: boolean + default: false + description: 'Whether to upload build artifacts' + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - platform: windows + os: windows-latest + build_script: pnpm run build:win + ytdlp_asset: yt-dlp.exe + 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 + - 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_inner_path: ffmpeg/ffmpeg + ffmpeg_output: ffmpeg_macos + - platform: linux + os: ubuntu-latest + build_script: pnpm run build:linux + ytdlp_asset: yt-dlp + 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 + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Install Dependencies + run: pnpm install + + - name: Download ffmpeg binary (Windows) + if: matrix.platform == 'windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $ffmpegUrl = '${{ matrix.ffmpeg_url }}' + 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 + + - name: Download ffmpeg binary (macOS) + if: matrix.platform == 'macos' + shell: bash + env: + FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }} + run: | + set -euo pipefail + curl -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip + unzip -q ffmpeg-arm.zip -d ffmpeg-arm + + curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip + unzip -q ffmpeg-x86.zip -d ffmpeg-x86 + + arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}" + x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}" + + if [[ ! -f "$arm_bin" ]]; then + echo "::error::Missing arm64 ffmpeg binary at $arm_bin" + exit 1 + fi + if [[ ! -f "$x86_bin" ]]; then + echo "::error::Missing x86_64 ffmpeg binary at $x86_bin" + exit 1 + fi + + lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT" + chmod +x "resources/$FFMPEG_OUTPUT" + rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip + + - name: Download ffmpeg binary (Linux) + if: matrix.platform == 'linux' + shell: bash + run: | + set -euo pipefail + curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz + 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 }}" + + - name: Download yt-dlp binary + shell: bash + run: | + curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}" + if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then + chmod +x "resources/${{ matrix.ytdlp_output }}" + fi + + - name: Lint and format check + run: pnpm run check && pnpm run typecheck + + - name: Build application + run: ${{ matrix.build_script }} + + - name: Upload build artifacts + if: inputs.upload_artifacts == true + uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.os }} + path: dist/ + retention-days: 1 + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c82c2ba..70ce644 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,48 +5,5 @@ on: branches: [ main ] jobs: - test: - strategy: - fail-fast: false - matrix: - include: - - os: windows-latest - build-script: pnpm run build:win - ytdlp-asset: yt-dlp.exe - ytdlp-output: yt-dlp.exe - - os: ubuntu-latest - build-script: pnpm run build:linux - ytdlp-asset: yt-dlp - ytdlp-output: yt-dlp_linux - runs-on: ${{ matrix.os }} - - steps: - - name: Check out Git repository - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - - name: Install Dependencies - run: pnpm install - - - name: Download yt-dlp binary - shell: bash - run: | - curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp-asset }}" -o "resources/${{ matrix.ytdlp-output }}" - if [[ "${{ runner.os }}" == "Linux" ]]; then - chmod +x "resources/${{ matrix.ytdlp-output }}" - fi - - - name: Lint and format check - run: pnpm run check - - - name: Build application - run: ${{ matrix.build-script }} + build: + uses: ./.github/workflows/build.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 991dbe7..44c0348 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,57 +6,26 @@ on: - v*.*.* jobs: + build: + uses: ./.github/workflows/build.yml + with: + upload_artifacts: true + release: - runs-on: ${{ matrix.os }} - - strategy: - matrix: - include: - - os: windows-latest - build_script: pnpm run build:win - ytdlp_asset: yt-dlp.exe - ytdlp_output: yt-dlp.exe - - os: macos-latest - build_script: pnpm run build:mac - ytdlp_asset: yt-dlp_macos - ytdlp_output: yt-dlp_macos - - os: ubuntu-latest - build_script: pnpm run build:linux - ytdlp_asset: yt-dlp - ytdlp_output: yt-dlp_linux - + needs: [build] + runs-on: ubuntu-latest steps: - name: Check out Git repository uses: actions/checkout@v4 - - name: Install Node.js - uses: actions/setup-node@v4 + - name: Download artifacts + uses: actions/download-artifact@v4 with: - node-version: 20 + pattern: dist-* + merge-multiple: true + path: dist/ - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - - name: Install Dependencies - run: pnpm install - - - name: Download yt-dlp binary - shell: bash - run: | - curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}" - if [[ "${{ runner.os }}" == "Linux" ]]; then - chmod +x "resources/${{ matrix.ytdlp_output }}" - fi - - - name: Lint and format check - run: pnpm run check - - - name: Build application - run: ${{ matrix.build_script }} - - - name: release + - name: Release uses: softprops/action-gh-release@v1 with: generate_release_notes: true @@ -73,4 +42,3 @@ jobs: dist/*.blockmap env: GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} - diff --git a/.github/workflows/translator.yaml b/.github/workflows/translator.yaml index 92c0a09..64d6ae6 100644 --- a/.github/workflows/translator.yaml +++ b/.github/workflows/translator.yaml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: lizheming/github-translate-action + - uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5976cec..9cef9f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +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. ## Working on Changes - Keep each pull request focused on a single problem or feature. diff --git a/package.json b/package.json index 32eb63e..e50d2d0 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "typecheck": "pnpm run typecheck:node && pnpm run typecheck:web", "start": "electron-vite preview", "dev": "node scripts/set-console-encoding.js && electron-vite dev", - "build": "pnpm run typecheck && electron-vite build", - "postinstall": "electron-builder install-app-deps", + "build": "electron-vite build", + "setup": "node scripts/setup-dev-binaries.js", + "postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps", "build:unpack": "pnpm run build && electron-builder --dir", "build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win", "build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64", diff --git a/resources/.gitignore b/resources/.gitignore index 7ae4548..6d02c3a 100644 --- a/resources/.gitignore +++ b/resources/.gitignore @@ -2,8 +2,11 @@ yt-dlp.exe yt-dlp_macos yt-dlp_linux +ffmpeg.exe +ffmpeg_macos +ffmpeg_linux +ffmpeg # But keep the README !README.md !.gitignore - diff --git a/resources/README.md b/resources/README.md index d485295..74d2506 100644 --- a/resources/README.md +++ b/resources/README.md @@ -48,8 +48,24 @@ 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 is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform: + +### Required Files + +1. **Windows**: `ffmpeg.exe` +2. **macOS**: `ffmpeg_macos` +3. **Linux**: `ffmpeg_linux` + +### 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`). + ### Note -- If you don't place binaries here, the app will attempt to download them at runtime -- The app will automatically use the bundled version if available -- File sizes: ~10-15 MB per binary +- 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 diff --git a/scripts/check-ytdlp.js b/scripts/check-ytdlp.js index dc284ff..ee9882f 100644 --- a/scripts/check-ytdlp.js +++ b/scripts/check-ytdlp.js @@ -3,38 +3,6 @@ const fs = require('node:fs') const path = require('node:path') -/** - * Check if yt-dlp binary exists in resources directory - * Usage: node scripts/check-ytdlp.js [platform] - * Platform options: win, mac, linux - * Exit with error code 1 if not found - */ -function checkYtDlpExists(platform) { - const platformMap = { - win: 'yt-dlp.exe', - mac: 'yt-dlp_macos', - linux: 'yt-dlp_linux' - } - - const filename = platformMap[platform] - if (!filename) { - console.error('❌ Error: Invalid platform specified!') - console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]') - process.exit(1) - } - - const ytdlpPath = path.join(__dirname, '..', 'resources', filename) - - if (!fs.existsSync(ytdlpPath)) { - console.error(`❌ Error: resources/${filename} not found!`) - console.error(`Please download ${filename} to the resources/ directory first.`) - console.error('You can download it from: https://github.com/yt-dlp/yt-dlp/releases/latest') - process.exit(1) - } - - console.log(`✅ ${filename} found in resources/ directory`) -} - // Get platform from command line arguments const platform = process.argv[2] @@ -44,5 +12,61 @@ if (!platform) { process.exit(1) } -// Run the check -checkYtDlpExists(platform) +const supportedPlatforms = ['win', 'mac', 'linux'] + +if (!supportedPlatforms.includes(platform)) { + console.error('❌ Error: Invalid platform specified!') + console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]') + process.exit(1) +} + +const binaries = [ + { + label: 'yt-dlp', + filenameMap: { + win: 'yt-dlp.exe', + mac: 'yt-dlp_macos', + linux: 'yt-dlp_linux' + }, + help: { + default: 'https://github.com/yt-dlp/yt-dlp/releases/latest' + } + }, + { + label: 'ffmpeg', + filenameMap: { + win: 'ffmpeg.exe', + mac: 'ffmpeg_macos', + linux: 'ffmpeg_linux' + }, + help: { + win: 'https://ffmpeg.org/download.html', + linux: 'https://ffmpeg.org/download.html', + mac: 'https://github.com/eko5624/mpv-mac/releases/latest' + } + } +] + +let hasMissingBinary = false + +for (const binary of binaries) { + const filename = binary.filenameMap[platform] + const binaryPath = 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.`) + const help = + typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default + if (help) { + console.error(`See ${help}`) + } + hasMissingBinary = true + } else { + console.log(`✅ ${filename} found in resources/ directory`) + } +} + +if (hasMissingBinary) { + process.exit(1) +} diff --git a/scripts/setup-dev-binaries.js b/scripts/setup-dev-binaries.js new file mode 100755 index 0000000..7ba6d4b --- /dev/null +++ b/scripts/setup-dev-binaries.js @@ -0,0 +1,343 @@ +#!/usr/bin/env node + +/** + * Development environment setup script + * Automatically downloads yt-dlp and ffmpeg binaries based on the current system + */ + +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const { execSync } = require('node:child_process') +const https = require('node:https') +const http = require('node:http') + +// Configuration +const RESOURCES_DIR = path.join(__dirname, '..', 'resources') +const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download' + +// Platform configuration +const PLATFORM_CONFIG = { + win32: { + ytdlp: { + asset: 'yt-dlp.exe', + output: 'yt-dlp.exe' + }, + 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', + output: 'ffmpeg.exe', + extract: 'unzip' + } + }, + darwin: { + ytdlp: { + asset: 'yt-dlp_macos', + output: 'yt-dlp_macos' + }, + 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', + innerPath: 'ffmpeg/ffmpeg', + output: 'ffmpeg_macos', + extract: 'unzip' + }, + x64: { + url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip', + innerPath: 'ffmpeg/ffmpeg', + output: 'ffmpeg_macos', + extract: 'unzip' + } + } + }, + linux: { + ytdlp: { + asset: 'yt-dlp', + output: 'yt-dlp_linux' + }, + 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', + extract: 'tar' + } + } +} + +// Utility functions +function log(message, type = 'info') { + const icons = { + info: '📦', + success: '✅', + error: '❌', + warn: '⚠️', + download: '⬇️' + } + console.log(`${icons[type] || 'ℹ️'} ${message}`) +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } +} + +function downloadFile(url, dest) { + return new Promise((resolve, reject) => { + const protocol = url.startsWith('https') ? https : http + const file = fs.createWriteStream(dest) + + protocol + .get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Handle redirect + file.close() + fs.unlinkSync(dest) + return downloadFile(response.headers.location, dest).then(resolve).catch(reject) + } + + if (response.statusCode !== 200) { + file.close() + fs.unlinkSync(dest) + return reject(new Error(`Failed to download: ${response.statusCode}`)) + } + + response.pipe(file) + file.on('finish', () => { + file.close() + resolve() + }) + }) + .on('error', (err) => { + file.close() + fs.unlinkSync(dest) + reject(err) + }) + }) +} + +function extractZip(zipPath, extractDir) { + const platform = os.platform() + ensureDir(extractDir) + + if (platform === 'win32') { + // Use PowerShell Expand-Archive on Windows + try { + const zipAbsPath = path.resolve(zipPath) + const extractAbsDir = path.resolve(extractDir) + execSync( + `powershell -NoProfile -Command "Expand-Archive -Path '${zipAbsPath.replace(/'/g, "''")}' -DestinationPath '${extractAbsDir.replace(/'/g, "''")}' -Force"`, + { stdio: 'inherit' } + ) + } catch (error) { + throw new Error(`Failed to extract zip: ${error.message}`) + } + } else { + // Use unzip command on macOS/Linux + try { + execSync(`unzip -q "${zipPath}" -d "${extractDir}"`, { stdio: 'inherit' }) + } catch (error) { + throw new Error(`Failed to extract zip: ${error.message}`) + } + } +} + +function extractTarXz(tarPath, extractDir) { + ensureDir(extractDir) + execSync(`tar -xf "${tarPath}" -C "${extractDir}"`, { stdio: 'inherit' }) +} + +function setExecutable(filePath) { + if (os.platform() !== 'win32') { + fs.chmodSync(filePath, 0o755) + } +} + +function fileExists(filePath) { + return fs.existsSync(filePath) +} + +// Main download functions +async function downloadYtDlp(config) { + const { asset, output } = config.ytdlp + const outputPath = path.join(RESOURCES_DIR, output) + + if (fileExists(outputPath)) { + log(`${output} already exists, skipping download`, 'info') + return + } + + log(`Downloading ${asset}...`, 'download') + const url = `${YTDLP_BASE_URL}/${asset}` + const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`) + + try { + await downloadFile(url, tempPath) + fs.renameSync(tempPath, outputPath) + setExecutable(outputPath) + log(`Downloaded ${output} successfully`, 'success') + } catch (error) { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath) + } + throw error + } +} + +async function downloadFfmpegWindows(config) { + const { url, innerPath, output } = config.ffmpeg + const outputPath = path.join(RESOURCES_DIR, output) + + if (fileExists(outputPath)) { + log(`${output} already exists, skipping download`, 'info') + return + } + + log(`Downloading ffmpeg for Windows...`, 'download') + const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip') + const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') + + try { + await downloadFile(url, tempZip) + log('Extracting ffmpeg...', 'info') + extractZip(tempZip, extractDir) + + const sourcePath = path.join(extractDir, innerPath.replace(/\\/g, path.sep)) + if (!fileExists(sourcePath)) { + throw new Error(`ffmpeg binary not found at ${sourcePath}`) + } + + fs.copyFileSync(sourcePath, outputPath) + log(`Downloaded ${output} successfully`, 'success') + + // Cleanup + fs.unlinkSync(tempZip) + fs.rmSync(extractDir, { recursive: true, force: true }) + } catch (error) { + if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip) + if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true }) + throw error + } +} + +async function downloadFfmpegMac(config) { + const arch = os.arch() + const ffmpegConfig = config.ffmpeg[arch === 'arm64' ? 'arm64' : 'x64'] + + if (!ffmpegConfig) { + throw new Error(`Unsupported architecture: ${arch}`) + } + + const { url, innerPath, output } = ffmpegConfig + const outputPath = path.join(RESOURCES_DIR, output) + + if (fileExists(outputPath)) { + log(`${output} already exists, skipping download`, 'info') + return + } + + log(`Downloading ffmpeg for macOS (${arch})...`, 'download') + const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip') + const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') + + try { + await downloadFile(url, tempZip) + log('Extracting ffmpeg...', 'info') + extractZip(tempZip, extractDir) + + const sourcePath = path.join(extractDir, innerPath) + if (!fileExists(sourcePath)) { + throw new Error(`ffmpeg binary not found at ${sourcePath}`) + } + + fs.copyFileSync(sourcePath, outputPath) + setExecutable(outputPath) + log(`Downloaded ${output} successfully`, 'success') + + // Cleanup + fs.unlinkSync(tempZip) + fs.rmSync(extractDir, { recursive: true, force: true }) + } catch (error) { + if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip) + if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true }) + throw error + } +} + +async function downloadFfmpegLinux(config) { + const { url, innerPath, output } = config.ffmpeg + const outputPath = path.join(RESOURCES_DIR, output) + + if (fileExists(outputPath)) { + log(`${output} already exists, skipping download`, 'info') + return + } + + log(`Downloading ffmpeg for Linux...`, 'download') + const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz') + const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') + + try { + await downloadFile(url, tempTar) + log('Extracting ffmpeg...', 'info') + extractTarXz(tempTar, extractDir) + + const sourcePath = path.join(extractDir, innerPath) + if (!fileExists(sourcePath)) { + throw new Error(`ffmpeg binary not found at ${sourcePath}`) + } + + fs.copyFileSync(sourcePath, outputPath) + setExecutable(outputPath) + log(`Downloaded ${output} successfully`, 'success') + + // Cleanup + fs.unlinkSync(tempTar) + fs.rmSync(extractDir, { recursive: true, force: true }) + } catch (error) { + if (fs.existsSync(tempTar)) fs.unlinkSync(tempTar) + if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true }) + throw error + } +} + +// Main setup function +async function setup() { + const platform = os.platform() + const config = PLATFORM_CONFIG[platform] + + if (!config) { + log(`Unsupported platform: ${platform}`, 'error') + process.exit(1) + } + + log(`Setting up development binaries for ${platform}...`, 'info') + ensureDir(RESOURCES_DIR) + + try { + // Download yt-dlp + await downloadYtDlp(config) + + // Download ffmpeg + if (platform === 'win32') { + await downloadFfmpegWindows(config) + } else if (platform === 'darwin') { + await downloadFfmpegMac(config) + } else if (platform === 'linux') { + await downloadFfmpegLinux(config) + } + + log('Development environment setup completed!', 'success') + } catch (error) { + log(`Setup failed: ${error.message}`, 'error') + process.exit(1) + } +} + +// Run setup +if (require.main === module) { + setup() +} + +module.exports = { setup } diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index a044635..0429dc0 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -14,7 +14,8 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string => return 'bestvideo+none' } if (!audioFormat || audioFormat === 'best') { - return 'best' + // Use bestvideo+bestaudio to ensure video and audio are merged into a single file + return 'bestvideo+bestaudio' } return `bestvideo+${audioFormat}` } @@ -53,7 +54,11 @@ export const buildDownloadArgs = ( // Format selection if (options.type === 'video') { - args.push('-f', resolveVideoFormatSelector(options)) + const formatSelector = resolveVideoFormatSelector(options) + args.push('-f', formatSelector) + // Let yt-dlp automatically choose the best merge format (mkv/webm/mp4) + // based on codec compatibility. Forcing MP4 can cause failures + // when codecs are incompatible (e.g., VP9+Opus requires mkv/webm) } else if (options.type === 'audio') { args.push('-f', resolveAudioFormatSelector(options)) } else if (options.type === 'extract') { diff --git a/src/main/index.ts b/src/main/index.ts index ef117cf..3010655 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ import appIcon from '../../build/icon.png?asset' import { configureLogger } from './config/logger-config' import { services } from './ipc' import { downloadEngine } from './lib/download-engine' +import { ffmpegManager } from './lib/ffmpeg-manager' import { ytdlpManager } from './lib/ytdlp-manager' import { settingsManager } from './settings' import { createTray, destroyTray } from './tray' @@ -179,6 +180,15 @@ app.whenReady().then(async () => { // IPC services are automatically registered by electron-ipc-decorator when imported log.info('IPC services available:', Object.keys(services)) + // Initialize ffmpeg + try { + log.info('Initializing ffmpeg...') + await ffmpegManager.initialize() + log.info('ffmpeg initialized successfully') + } catch (error) { + log.error('Failed to initialize ffmpeg:', error) + } + // Initialize yt-dlp try { log.info('Initializing yt-dlp...') diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 8c8b5fa..a9da7a3 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -12,7 +12,7 @@ import type { VideoFormat, VideoInfo } from '../../shared/types' -import { buildDownloadArgs } from '../download-engine/args-builder' +import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder' import { findFormatByIdCandidates, parseSizeToBytes, @@ -21,6 +21,7 @@ import { import { settingsManager } from '../settings' import { scopedLoggers } from '../utils/logger' import { DownloadQueue } from './download-queue' +import { ffmpegManager } from './ffmpeg-manager' import { historyManager } from './history-manager' import { ytdlpManager } from './ytdlp-manager' @@ -496,6 +497,46 @@ class DownloadEngine extends EventEmitter { const args = buildDownloadArgs(options, downloadPath, settings) + // Check if format selector contains '+' which means video and audio will be merged + const formatSelector = + options.type === 'video' ? resolveVideoFormatSelector(options) : undefined + const willMerge = formatSelector?.includes('+') ?? false + + const urlArg = args.pop() + if (!urlArg) { + const missingUrlError = new Error('Download arguments missing URL.') + scopedLoggers.download.error('Missing URL argument for download ID:', id) + this.updateDownloadInfo(id, { + status: 'error', + completedAt: Date.now(), + error: missingUrlError.message + }) + this.queue.downloadCompleted(id) + this.emit('download-error', id, missingUrlError) + this.addToHistory(id, options, 'error', missingUrlError.message) + return + } + + let ffmpegPath: string + try { + ffmpegPath = ffmpegManager.getPath() + } catch (error) { + const ffmpegError = error instanceof Error ? error : new Error(String(error)) + scopedLoggers.download.error('Failed to resolve ffmpeg for download ID:', id, ffmpegError) + this.updateDownloadInfo(id, { + status: 'error', + completedAt: Date.now(), + error: ffmpegError.message + }) + this.queue.downloadCompleted(id) + this.emit('download-error', id, ffmpegError) + this.addToHistory(id, options, 'error', ffmpegError.message) + return + } + + args.push('--ffmpeg-location', ffmpegPath) + args.push(urlArg) + const controller = new AbortController() const ytdlpProcess = ytdlp.exec(args, { signal: controller.signal @@ -593,23 +634,81 @@ class DownloadEngine extends EventEmitter { // Generate file path using downloadPath + title + ext const title = videoInfo?.title || 'Unknown' const sanitizedTitle = title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 50) - const extension = - options.type === 'audio' ? options.extractFormat || 'mp3' : actualFormat || 'mp4' + + // Determine file extension based on download type and format + // yt-dlp automatically chooses the best merge format (mkv/webm/mp4) + // based on codec compatibility, so we should use actualFormat when available + let extension: string + if (options.type === 'audio') { + extension = options.extractFormat || 'mp3' + } else if (willMerge) { + // For merged files, yt-dlp auto-selects format (mkv/webm/mp4) + // Use actualFormat if available, otherwise default to mkv (most compatible) + extension = actualFormat || 'mkv' + } else { + extension = actualFormat || 'mp4' + } + const fileName = `${sanitizedTitle}.${extension}` const finalOutputPath = path.join(downloadPath, fileName) - scopedLoggers.download.info('Generated file path for ID:', id, 'Path:', finalOutputPath) + scopedLoggers.download.info( + 'Generated file path for ID:', + id, + 'Path:', + finalOutputPath, + 'Will merge:', + willMerge + ) let fileSize: number | undefined + let actualFilePath = finalOutputPath try { const fs = await import('node:fs/promises') + // Try to find the actual file - yt-dlp may generate files with slightly different names const stats = await fs.stat(finalOutputPath) fileSize = stats.size + actualFilePath = finalOutputPath } catch (error) { - if (latestKnownSizeBytes !== undefined) { - fileSize = latestKnownSizeBytes - } else { - scopedLoggers.download.warn('Failed to get file size for ID:', id, error) + // If the expected file doesn't exist, try to find it by scanning the directory + try { + const fs = await import('node:fs/promises') + const files = await fs.readdir(downloadPath) + // Look for files matching the title pattern with the correct extension + const matchingFiles = files.filter((file) => { + const baseName = file.replace(/\.[^.]+$/, '') + const fileExt = file.split('.').pop()?.toLowerCase() + return ( + (baseName === sanitizedTitle || baseName.startsWith(sanitizedTitle)) && + fileExt === extension.toLowerCase() + ) + }) + + if (matchingFiles.length > 0) { + // Use the most recently modified file if multiple matches + const fileStats = await Promise.all( + matchingFiles.map(async (file) => { + const filePath = path.join(downloadPath, file) + const stats = await fs.stat(filePath) + return { file, path: filePath, mtime: stats.mtime, size: stats.size } + }) + ) + const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0] + actualFilePath = mostRecent.path + fileSize = mostRecent.size + scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize) + } else if (latestKnownSizeBytes !== undefined) { + fileSize = latestKnownSizeBytes + scopedLoggers.download.warn('File not found, using estimated size:', fileSize) + } else { + scopedLoggers.download.warn('Failed to find file for ID:', id, error) + } + } catch (scanError) { + if (latestKnownSizeBytes !== undefined) { + fileSize = latestKnownSizeBytes + } else { + scopedLoggers.download.warn('Failed to get file size for ID:', id, scanError) + } } } @@ -621,7 +720,7 @@ class DownloadEngine extends EventEmitter { status: 'completed', completedAt: Date.now(), fileSize, - format: actualFormat || undefined, + format: willMerge ? 'mp4' : actualFormat || undefined, quality: actualQuality || undefined, codec: actualCodec || undefined }) diff --git a/src/main/lib/ffmpeg-manager.ts b/src/main/lib/ffmpeg-manager.ts new file mode 100644 index 0000000..08396c6 --- /dev/null +++ b/src/main/lib/ffmpeg-manager.ts @@ -0,0 +1,101 @@ +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +class FfmpegManager { + private ffmpegPath: string | null = null + + async initialize(): Promise { + this.ffmpegPath = await this.findFfmpegBinary() + console.log('ffmpeg initialized at:', this.ffmpegPath) + } + + getPath(): string { + if (!this.ffmpegPath) { + throw new Error('ffmpeg not initialized. Call initialize() first.') + } + return this.ffmpegPath + } + + private getResourcesPath(): string { + if (process.env.NODE_ENV === 'development') { + return path.join(process.cwd(), 'resources') + } + return path.join(process.resourcesPath, 'app.asar.unpacked', 'resources') + } + + private async findFfmpegBinary(): Promise { + const platform = os.platform() + const resourceCandidates: string[] = [] + + if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) { + console.log('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH) + return process.env.FFMPEG_PATH + } + + if (platform === 'win32') { + resourceCandidates.push('ffmpeg.exe') + } else if (platform === 'darwin') { + resourceCandidates.push('ffmpeg_macos', 'ffmpeg') + } else { + resourceCandidates.push('ffmpeg_linux', 'ffmpeg') + } + + 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) { + console.warn('Failed to set executable permission on ffmpeg binary:', error) + } + } + console.log('Using bundled ffmpeg:', fullPath) + return fullPath + } + } + + if (platform === 'darwin') { + const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'] + for (const candidate of commonPaths) { + if (fs.existsSync(candidate)) { + console.log('Using system ffmpeg:', candidate) + return candidate + } + } + } + + if (platform === 'linux' || platform === 'freebsd') { + try { + const systemPath = execSync('which ffmpeg').toString().trim() + if (systemPath && fs.existsSync(systemPath)) { + console.log('Using system ffmpeg:', systemPath) + return systemPath + } + } catch (_error) { + // Ignore error and continue + } + } + + if (platform === 'win32') { + try { + const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0] + if (output && fs.existsSync(output)) { + console.log('Using system ffmpeg:', output) + return output + } + } catch (_error) { + // Ignore error and continue + } + } + + throw new Error( + 'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.' + ) + } +} + +export const ffmpegManager = new FfmpegManager() diff --git a/src/renderer/src/pages/Home.tsx b/src/renderer/src/pages/Home.tsx index 882e4d5..4b21c76 100644 --- a/src/renderer/src/pages/Home.tsx +++ b/src/renderer/src/pages/Home.tsx @@ -76,18 +76,20 @@ const getQualityPreset = (settings: AppSettings): OneClickQualityPreset => const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => { if (preset === 'worst') { - return ['worstaudio', 'worst'] + return ['worstaudio'] } const abrLimit = qualityPresetToAudioAbr[preset] - return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio', 'best']) + // Remove 'best' fallback to ensure merging - only use 'bestaudio' variants + return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio']) } const buildVideoFormatPreference = (settings: AppSettings): string => { const preset = getQualityPreset(settings) if (preset === 'worst') { - return 'worstvideo+worstaudio/worst' + // Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging + return 'worstvideo+worstaudio' } const maxHeight = qualityPresetToVideoHeight[preset] @@ -110,7 +112,8 @@ const buildVideoFormatPreference = (settings: AppSettings): string => { combinations.push(video) } } else { - combinations.push('best') + // Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging + combinations.push('bestvideo+bestaudio') } return dedupe(combinations).join('/')