From 1caca0ea9a1fe10162244c530bba2d68188787e8 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:24:33 +0800 Subject: [PATCH] feat(settings): add embed features (#76) * feat(settings): add embed options * feat(settings): default embed on * refactor(setup-dev-binaries): enhance download error handling and version checks for binaries --- scripts/setup-dev-binaries.js | 109 +++++++++++++++++++++-- src/main/download-engine/args-builder.ts | 21 ++++- src/renderer/src/locales/en.json | 9 ++ src/renderer/src/pages/Settings.tsx | 88 ++++++++++++++++++ src/shared/types/index.ts | 10 ++- 5 files changed, 227 insertions(+), 10 deletions(-) diff --git a/scripts/setup-dev-binaries.js b/scripts/setup-dev-binaries.js index b648be9..4d36d52 100755 --- a/scripts/setup-dev-binaries.js +++ b/scripts/setup-dev-binaries.js @@ -8,7 +8,7 @@ const fs = require('node:fs') const path = require('node:path') const os = require('node:os') -const { execSync } = require('node:child_process') +const { execSync, spawnSync } = require('node:child_process') const https = require('node:https') const http = require('node:http') @@ -33,7 +33,7 @@ const PLATFORM_CONFIG = { extract: 'unzip', release: { repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], - assetPattern: /win64.*gpl.*\.zip$/i, + assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i, binaryName: 'ffmpeg.exe' } } @@ -79,7 +79,7 @@ const PLATFORM_CONFIG = { extract: 'tar', release: { repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], - assetPattern: /linux64.*gpl.*\.tar\.xz$/i, + assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i, binaryName: 'ffmpeg' } } @@ -125,24 +125,43 @@ function downloadFile(url, dest) { return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http const file = fs.createWriteStream(dest) + let downloadedBytes = 0 const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => { if (response.statusCode === 302 || response.statusCode === 301) { // Handle redirect file.close() safeUnlink(dest) - return downloadFile(response.headers.location, dest).then(resolve).catch(reject) + const redirectUrl = response.headers.location + if (!redirectUrl) { + return reject(new Error(`Redirect without location for ${url}`)) + } + log(`Redirected to ${redirectUrl}`, 'info') + return downloadFile(redirectUrl, dest).then(resolve).catch(reject) } + const contentLength = response.headers['content-length'] if (response.statusCode !== 200) { file.close() safeUnlink(dest) - return reject(new Error(`Failed to download: ${response.statusCode}`)) + return reject( + new Error( + `Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})` + ) + ) } + response.on('data', (chunk) => { + downloadedBytes += chunk.length + }) + response.pipe(file) file.on('finish', () => { file.close() + log( + `Downloaded ${formatBytes(downloadedBytes)} from ${url}`, + downloadedBytes ? 'success' : 'warn' + ) resolve() }) }) @@ -154,6 +173,7 @@ function downloadFile(url, dest) { request.on('error', (err) => { file.close() safeUnlink(dest) + log(`Download error for ${url}: ${err.message}`, 'error') reject(err) }) }) @@ -163,6 +183,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) { let lastError for (let attempt = 1; attempt <= retries; attempt += 1) { try { + log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download') await downloadFile(url, dest) return } catch (error) { @@ -170,7 +191,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) { safeUnlink(dest) if (attempt < retries) { const backoff = delayMs * attempt - log(`Download failed (attempt ${attempt}/${retries}): ${error.message}`, 'warn') + log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn') await new Promise((resolve) => setTimeout(resolve, backoff)) } } @@ -298,6 +319,37 @@ function fileExists(filePath) { return fs.existsSync(filePath) } +function formatBytes(bytes) { + if (!bytes || bytes <= 0) { + return 'unknown size' + } + if (bytes >= 1024 * 1024) { + return `${Math.round(bytes / (1024 * 1024))} MB` + } + return `${Math.round(bytes / 1024)} KB` +} + +function checkBinary(filePath, args, label) { + const result = spawnSync(filePath, args, { + encoding: 'utf8', + timeout: 8000, + windowsHide: true + }) + + if (result.error) { + return { ok: false, message: result.error.message } + } + + if (result.status !== 0) { + const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim() + return { ok: false, message: output || `exit code ${result.status}` } + } + + const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim() + const firstLine = output.split(/\r?\n/).find((line) => line.trim()) + return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` } +} + function getDenoAssetName(platform, arch) { if (platform === 'win32') { if (arch === 'arm64') { @@ -330,6 +382,10 @@ async function downloadYtDlp(config) { const outputPath = path.join(RESOURCES_DIR, output) if (fileExists(outputPath)) { + const validation = checkBinary(outputPath, ['--version'], 'yt-dlp') + if (!validation.ok) { + log(`Existing ${output} failed version check: ${validation.message}`, 'warn') + } log(`${output} already exists, skipping download`, 'info') return } @@ -342,6 +398,11 @@ async function downloadYtDlp(config) { await downloadFileWithRetry(url, tempPath) fs.renameSync(tempPath, outputPath) setExecutable(outputPath) + const validation = checkBinary(outputPath, ['--version'], 'yt-dlp') + if (!validation.ok) { + safeUnlink(outputPath) + throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) + } log(`Downloaded ${output} successfully`, 'success') } catch (error) { if (fs.existsSync(tempPath)) { @@ -356,6 +417,10 @@ async function downloadFfmpegWindows(config) { const outputPath = path.join(RESOURCES_DIR, output) if (fileExists(outputPath)) { + const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') + if (!validation.ok) { + log(`Existing ${output} failed version check: ${validation.message}`, 'warn') + } log(`${output} already exists, skipping download`, 'info') return } @@ -392,6 +457,11 @@ async function downloadFfmpegWindows(config) { } fs.copyFileSync(sourcePath, outputPath) + const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') + if (!validation.ok) { + safeUnlink(outputPath) + throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) + } log(`Downloaded ${output} successfully`, 'success') // Cleanup @@ -416,6 +486,10 @@ async function downloadFfmpegMac(config) { const outputPath = path.join(RESOURCES_DIR, output) if (fileExists(outputPath)) { + const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') + if (!validation.ok) { + log(`Existing ${output} failed version check: ${validation.message}`, 'warn') + } log(`${output} already exists, skipping download`, 'info') return } @@ -448,6 +522,11 @@ async function downloadFfmpegMac(config) { fs.copyFileSync(sourcePath, outputPath) setExecutable(outputPath) + const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') + if (!validation.ok) { + safeUnlink(outputPath) + throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) + } log(`Downloaded ${output} successfully`, 'success') // Cleanup @@ -465,6 +544,10 @@ async function downloadFfmpegLinux(config) { const outputPath = path.join(RESOURCES_DIR, output) if (fileExists(outputPath)) { + const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') + if (!validation.ok) { + log(`Existing ${output} failed version check: ${validation.message}`, 'warn') + } log(`${output} already exists, skipping download`, 'info') return } @@ -502,6 +585,11 @@ async function downloadFfmpegLinux(config) { fs.copyFileSync(sourcePath, outputPath) setExecutable(outputPath) + const validation = checkBinary(outputPath, ['-version'], 'ffmpeg') + if (!validation.ok) { + safeUnlink(outputPath) + throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) + } log(`Downloaded ${output} successfully`, 'success') // Cleanup @@ -528,6 +616,10 @@ async function downloadDenoRuntime() { const outputPath = path.join(RESOURCES_DIR, outputName) if (fileExists(outputPath)) { + const validation = checkBinary(outputPath, ['--version'], 'deno') + if (!validation.ok) { + log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn') + } log(`${outputName} already exists, skipping download`, 'info') return } @@ -549,6 +641,11 @@ async function downloadDenoRuntime() { fs.copyFileSync(sourcePath, outputPath) setExecutable(outputPath) + const validation = checkBinary(outputPath, ['--version'], 'deno') + if (!validation.ok) { + safeUnlink(outputPath) + throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`) + } log(`Downloaded ${outputName} successfully`, 'success') fs.unlinkSync(tempZip) diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index 4cfc0cb..355ff0c 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -68,7 +68,7 @@ export const buildDownloadArgs = ( settings: AppSettings, jsRuntimeArgs: string[] = [] ): string[] => { - const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime'] + const args: string[] = ['--no-playlist', '--no-mtime'] // Add encoding support for proper handling of non-ASCII characters args.push('--encoding', 'utf-8') @@ -95,11 +95,26 @@ export const buildDownloadArgs = ( args.push('--download-sections', `*${start}-${end || ''}`) } + const embedSubs = settings.embedSubs + const embedMetadata = settings.embedMetadata + const embedChapters = settings.embedChapters + // Subtitles - if (options.downloadSubs) { - args.push('--write-subs', '--sub-langs', 'all') + if (options.downloadSubs || embedSubs) { + args.push('--sub-langs', 'all') } + if (options.downloadSubs) { + args.push('--write-subs') + } + + args.push(embedSubs ? '--embed-subs' : '--no-embed-subs') + if (process.platform !== 'darwin') { + 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') + // Output path with proper encoding handling const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath const filenameTemplate = sanitizeFilenameTemplate( diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 5a8e2bb..5b407a2 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -89,6 +89,7 @@ "currentLocation": "Current download location - ", "downloadLocation": "Download location", "downloadSubs": "Download subtitles if available", + "downloadSubsHint": "Save subtitles as separate files when available", "end": "End", "endHint": "If kept empty, it will be downloaded to the end", "endPlaceholder": "10:00", @@ -420,6 +421,14 @@ "launchAtLoginUnsupported": "Auto launch is only available on macOS and Windows.", "enableAnalytics": "Help improve VidBee", "enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.", + "embedChapters": "Embed chapters", + "embedChaptersDescription": "Add chapter markers to the file when available", + "embedMetadata": "Embed metadata", + "embedMetadataDescription": "Write title, artist, and other metadata when available", + "embedSubs": "Embed subtitles", + "embedSubsDescription": "Embed subtitles into the video file (mp4, webm, mkv)", + "embedThumbnail": "Embed thumbnail", + "embedThumbnailDescription": "Add the thumbnail as cover art", "maxConcurrentDownloads": "Maximum number of active downloads", "maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads", "none": "None", diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 845dc87..80cd06f 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -519,6 +519,94 @@ export function Settings() { + + + + {t('settings.embedSubs')} + {t('settings.embedSubsDescription')} + + + { + try { + handleSettingChange('embedSubs', value) + } catch (error) { + logger.error('[Settings] Error toggling embedSubs:', error) + } + }} + /> + + + + + + {platform !== 'darwin' && ( + <> + + + {t('settings.embedThumbnail')} + {t('settings.embedThumbnailDescription')} + + + { + try { + handleSettingChange('embedThumbnail', value) + } catch (error) { + logger.error('[Settings] Error toggling embedThumbnail:', error) + } + }} + /> + + + + + + )} + + + + {t('settings.embedMetadata')} + {t('settings.embedMetadataDescription')} + + + { + try { + handleSettingChange('embedMetadata', value) + } catch (error) { + logger.error('[Settings] Error toggling embedMetadata:', error) + } + }} + /> + + + + + + + + {t('settings.embedChapters')} + {t('settings.embedChaptersDescription')} + + + { + try { + handleSettingChange('embedChapters', value) + } catch (error) { + logger.error('[Settings] Error toggling embedChapters:', error) + } + }} + /> + + + + diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 76185a5..d943718 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -271,6 +271,10 @@ export interface AppSettings { autoUpdate: boolean subscriptionOnlyLatestDefault: boolean enableAnalytics: boolean + embedSubs: boolean + embedThumbnail: boolean + embedMetadata: boolean + embedChapters: boolean } export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s' @@ -294,5 +298,9 @@ export const defaultSettings: AppSettings = { launchAtLogin: false, autoUpdate: true, subscriptionOnlyLatestDefault: true, - enableAnalytics: true + enableAnalytics: true, + embedSubs: true, + embedThumbnail: true, + embedMetadata: true, + embedChapters: true }