From dffd880ef0a7a6d7361574f944871019c0b191e6 Mon Sep 17 00:00:00 2001 From: Sloth Date: Fri, 4 Sep 2026 11:12:23 -0700 Subject: [PATCH 1/3] fix(app): fix retry functionality causing the yt-dlp process to be interrupted and restarted for really long videos --- src/electron/ffmpegUtils.mjs | 30 ++++++- src/electron/main.mjs | 81 +++++++++++++++++-- src/ui/components/LinearProgressWithLabel.tsx | 19 +++-- src/ui/hooks/useDownloadVideo.test.tsx | 5 +- src/ui/hooks/useDownloadVideo.tsx | 20 ++++- src/ui/screens/LibraryVideoDetail.tsx | 3 +- src/ui/screens/OtherPlatformDownloadCard.tsx | 11 ++- src/ui/screens/VideoDetailCard.tsx | 11 ++- src/ui/screens/VideoQualityDownload.tsx | 28 ++++++- src/utils/utils.ts | 15 +++- 10 files changed, 191 insertions(+), 32 deletions(-) diff --git a/src/electron/ffmpegUtils.mjs b/src/electron/ffmpegUtils.mjs index 183d723..489bdb4 100644 --- a/src/electron/ffmpegUtils.mjs +++ b/src/electron/ffmpegUtils.mjs @@ -44,17 +44,22 @@ export function summarizeFfmpegError(stderr, code) { // different callers (the main download pipeline's postprocess step, and the // Library view's standalone ffmpeg utilities) that report progress over two // different IPC channels. -export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) { +export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath, onLog, isDev = false }) { function getMediaDurationSeconds(filePath) { return new Promise((resolve, reject) => { + onLog?.(`[ffmpeg] probing duration: ${filePath}`); const proc = spawn(ffprobeBinaryPath, ['-v', 'quiet', '-print_format', 'json', '-show_format', filePath]); let stdout = ''; let stderr = ''; proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - proc.on('error', reject); + proc.on('error', (err) => { + onLog?.(`[ffmpeg] ffprobe failed to start for ${filePath}: ${err.message}`); + reject(err); + }); proc.on('close', (code) => { if (code !== 0) { + onLog?.(`[ffmpeg] ffprobe exited with code ${code} for ${filePath}: ${stderr}`); reject(new Error(stderr || `ffprobe exited with code ${code}`)); return; } @@ -62,6 +67,7 @@ export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) { const duration = parseFloat(JSON.parse(stdout).format.duration); resolve(Number.isFinite(duration) ? duration : 0); } catch { + onLog?.(`[ffmpeg] failed to parse ffprobe output for ${filePath}`); reject(new Error('Failed to parse ffprobe output')); } }); @@ -75,9 +81,12 @@ export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) { function runFfmpegWithProgress({ inputPath, outputPath, codecArgs, totalDurationSeconds, onProgress, extraInputArgs = [], preInputArgs = [] }) { return new Promise((resolve, reject) => { const args = [...preInputArgs, '-i', inputPath, ...extraInputArgs, ...codecArgs, '-progress', 'pipe:1', '-y', outputPath]; + onLog?.(`[ffmpeg] running: ${ffmpegBinaryPath} ${args.join(' ')}`); const proc = spawn(ffmpegBinaryPath, args); let stderr = ''; let buffer = ''; + let lastDevProgressLogAt = 0; + const DEV_PROGRESS_LOG_INTERVAL_MS = 30 * 1000; proc.stdout.on('data', (chunk) => { buffer += chunk.toString(); @@ -88,13 +97,25 @@ export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) { if (match && totalDurationSeconds > 0) { const percent = Math.min(100, (Number(match[1]) / (totalDurationSeconds * 1_000_000)) * 100); onProgress?.(percent); + // Same rationale as main.mjs's own download-progress + // throttle: -progress pipe:1 emits several lines per + // second, dev-only and sampled to keep main.log readable. + if (isDev && Date.now() - lastDevProgressLogAt >= DEV_PROGRESS_LOG_INTERVAL_MS) { + lastDevProgressLogAt = Date.now(); + onLog?.(`[ffmpeg] ${outputPath}: ${percent.toFixed(1)}%`); + } } } }); proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - proc.on('error', reject); + proc.on('error', (err) => { + onLog?.(`[ffmpeg] failed to start: ${err.message}`); + reject(err); + }); proc.on('close', (code) => { + onLog?.(`[ffmpeg] exited with code ${code}: ${outputPath}`); if (code !== 0) { + onLog?.(`[ffmpeg] stderr for ${outputPath}: ${stderr}`); reject(new Error(summarizeFfmpegError(stderr, code))); } else { resolve(); @@ -136,7 +157,8 @@ export function createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }) { } try { await runFfmpegWithProgress({ inputPath, outputPath, codecArgs: ['-c', 'copy'], totalDurationSeconds, onProgress }); - } catch { + } catch (err) { + onLog?.(`[ffmpeg] remux failed for ${outputPath}, falling back to re-encode: ${err instanceof Error ? err.message : String(err)}`); await runFfmpegWithProgress({ inputPath, outputPath, codecArgs: reencodeCodecArgs, totalDurationSeconds, onProgress }); } } diff --git a/src/electron/main.mjs b/src/electron/main.mjs index b477211..5bd18c0 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -152,7 +152,7 @@ const { readVideoInfoCache, writeVideoInfoCache } = createVideoInfoCache(videoIn const { ensureChannelIcon, ensureVideoThumbnail, ensurePlaylistThumbnail } = createThumbnailFetchers({ ytdlpPath, ffmpegDir, cookiesArgs, jsRuntimeArgs, ytdlpSpawnEnv, onLog: log, }); -const ffmpegRunner = createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath }); +const ffmpegRunner = createFfmpegRunner({ ffmpegBinaryPath, ffprobeBinaryPath, onLog: log, isDev }); const { getMediaDurationSeconds, getFfmpegVersion, runFfmpegWithProgress, convertWithFallback, clipAndConvert } = ffmpegRunner; // Registers app-video:// as a privileged scheme so the Library tab's player @@ -1269,10 +1269,12 @@ const cancelledDownloadRequestIds = new Set(); // silently do nothing until the next attempt actually started. const pendingRetryCancellers = new Map(); -// No progress line (download OR postprocess) for this long is treated as a -// stall and killed -- deliberately not a fixed total-duration cap, since this -// app's own differentiator is handling very long downloads (9+ hour videos -// per the README); only *silence* is suspicious, not overall length. +// No progress line for this long during the actual *download* is treated as +// a stall and killed -- deliberately not a fixed total-duration cap, since +// this app's own differentiator is handling very long downloads (9+ hour +// videos per the README); only *silence* is suspicious, not overall length. +// Does NOT apply while a postprocessor (e.g. yt-dlp's own audio+video +// Merger) is running -- see inPostprocess in attemptDownload below for why. const STALL_TIMEOUT_MS = 5 * 60 * 1000; const STALL_CHECK_INTERVAL_MS = 30 * 1000; @@ -1356,6 +1358,8 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { downloadArgs = buildDownloadArgs(options); } + log(`[download] ${options.requestId} starting: url=${options.videoUrl} resolution=${options.resolution} format=${options.format} postprocess=${postprocess} outputPath=${options.outputPath}`); + // Only actually decrements/untracks once -- called from whichever branch // (success, or a failure that's giving up rather than retrying) turns out // to be this download's true end. @@ -1372,6 +1376,7 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { // restarting from zero. async function handleFailure({ kind, message }, attempt) { if (kind === ERROR_KINDS.CANCELLED) { + log(`[download] ${options.requestId} attempt ${attempt} cancelled: ${message}`); finishDownload(); if (rawDir) fs.rmSync(rawDir, { recursive: true, force: true }); send({ type: 'error', payload: { message, kind, retryable: false } }); @@ -1379,6 +1384,7 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { } if (isAutoRetryable(kind) && attempt < MAX_AUTO_RETRIES) { const nextAttemptInMs = getBackoffMs(attempt); + log(`[download] ${options.requestId} attempt ${attempt} failed (${kind}): ${message} -- retrying (attempt ${attempt + 1}) in ${nextAttemptInMs}ms`); send({ type: 'retrying', payload: { attempt: attempt + 1, kind, message, nextAttemptInMs } }); const timer = setTimeout(() => { pendingRetryCancellers.delete(options.requestId); @@ -1390,12 +1396,14 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { }); return; } + log(`[download] ${options.requestId} attempt ${attempt} failed (${kind}): ${message} -- giving up`); finishDownload(); if (rawDir) fs.rmSync(rawDir, { recursive: true, force: true }); send({ type: 'error', payload: { message, kind, retryable: false } }); } function attemptDownload(attempt) { + log(`[download] ${options.requestId} attempt ${attempt} spawning: ${ytdlpPath} ${downloadArgs.join(' ')}`); // detached only on POSIX -- see killDownloadProcessTree above. const script = spawn(ytdlpPath, downloadArgs, { detached: process.platform !== 'win32', env: ytdlpSpawnEnv() }); activeDownloadProcesses.set(options.requestId, script); @@ -1407,11 +1415,33 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { // whichever happens first wins, the other is a no-op. let settled = false; const touch = () => { lastActivity = Date.now(); }; + // yt-dlp's postprocess progress-template (see POSTPROCESS| handling + // in parseLine below) only ever reports 'started' and 'finished' -- + // there's no real progress heartbeat for the time in between, which + // for something like an audio+video Merger on a very long recording + // can legitimately run well past STALL_TIMEOUT_MS with zero output. + // Confirmed to have actually caused a stuck-in-a-loop bug: the + // watchdog was killing an in-progress (not hung, just silent) merge, + // classifying it as a stall, and auto-retrying -- which restarts the + // merge from scratch every time, forever. The stall watchdog exists + // to catch a genuinely dead *network* transfer; it has no way to + // distinguish that from "ffmpeg is still working," so it's suspended + // entirely for the whole postprocessing phase rather than guessed at + // with a longer fixed timeout. + let inPostprocess = false; + // dev-only throttle for the (very high-volume, once-per-second-ish) + // download PROGRESS lines -- logging every one of those for a + // multi-hour download would make main.log unreadable, so only the + // low-volume status transitions (below) are always logged, and raw + // in-progress percentages are sampled at most this often. + let lastDevProgressLogAt = 0; + const DEV_PROGRESS_LOG_INTERVAL_MS = 30 * 1000; const stallCheck = setInterval(() => { - if (settled || Date.now() - lastActivity < STALL_TIMEOUT_MS) return; + if (settled || inPostprocess || Date.now() - lastActivity < STALL_TIMEOUT_MS) return; settled = true; clearInterval(stallCheck); + log(`[download] ${options.requestId} attempt ${attempt} STALLED -- no progress/output for ${Math.round((Date.now() - lastActivity) / 1000)}s, killing process tree`); killDownloadProcessTree(script); handleFailure({ kind: ERROR_KINDS.STALLED, message: 'No progress for several minutes -- the connection may have dropped.' }, attempt); }, STALL_CHECK_INTERVAL_MS); @@ -1422,6 +1452,7 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { clearInterval(stallCheck); activeDownloadProcesses.delete(options.requestId); const classified = classifyDownloadError({ spawnError: err }); + log(`[download] ${options.requestId} attempt ${attempt} failed to spawn yt-dlp: ${err.message}`); handleFailure(classified, attempt); }); @@ -1430,6 +1461,13 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { if (line.startsWith('PROGRESS|')) { const [, status, downloadedBytes, totalBytes, percent, eta, speed] = line.split('|'); if (status === 'downloading') { + // High-volume (near-continuous for the life of the + // download) -- only sampled in dev mode, see + // DEV_PROGRESS_LOG_INTERVAL_MS above. + if (isDev && Date.now() - lastDevProgressLogAt >= DEV_PROGRESS_LOG_INTERVAL_MS) { + lastDevProgressLogAt = Date.now(); + log(`[download] ${options.requestId} attempt ${attempt} downloading: ${percent.trim()} eta=${eta} speed=${speed.trim()} (${downloadedBytes}/${totalBytes} bytes)`); + } send({ type: 'progress', payload: { @@ -1441,14 +1479,34 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { }, }); } else if (status === 'finished') { + log(`[download] ${options.requestId} attempt ${attempt} yt-dlp reports download finished`); send({ type: 'downloadDone', payload: {} }); } } else if (line.startsWith('POSTPROCESS|')) { const [, status, processor] = line.split('|'); + // Always logged, unlike PROGRESS| above -- postprocess + // (e.g. yt-dlp's own audio+video Merger) only ever emits a + // handful of these per download, and this is exactly the + // stage that's been observed hanging with no other feedback. + log(`[download] ${options.requestId} attempt ${attempt} postprocess ${status}: ${processor}`); + // See inPostprocess's own comment above the stall watchdog: + // suspend stall-based auto-retry for the whole span between a + // postprocessor starting and finishing, since yt-dlp reports + // nothing in between it can be judged against. + if (status === 'finished') { + inPostprocess = false; + } else { + inPostprocess = true; + } send({ type: 'postprocessing', payload: { stage: status === 'started' ? 'start' : status, processor }, }); + } else if (isDev) { + // Any other stdout line (yt-dlp's own non-progress chatter -- + // e.g. "[Merger] Merging formats into ...", extractor debug + // output) -- dev-only since this is otherwise unfiltered. + log(`[download] ${options.requestId} attempt ${attempt} stdout: ${line}`); } } @@ -1472,6 +1530,11 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { parseLine(line); } else { error += line + '\n'; + // Previously console.error-only, invisible in a packaged + // build with no attached terminal -- this is where a real + // ffmpeg/merge failure's actual explanation lands, so it now + // always goes to main.log too, not just dev mode. + log(`[download] ${options.requestId} attempt ${attempt} stderr: ${line}`); console.error('yt-dlp stderr:', line); } })); @@ -1481,6 +1544,7 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { settled = true; clearInterval(stallCheck); activeDownloadProcesses.delete(options.requestId); + log(`[download] ${options.requestId} attempt ${attempt} yt-dlp exited with code ${code}`); if (cancelledDownloadRequestIds.delete(options.requestId)) { handleFailure({ kind: ERROR_KINDS.CANCELLED, message: 'Cancelled.' }, attempt); @@ -1497,6 +1561,7 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { if (!postprocess) { finishDownload(); const finalFile = findFinalFile(options.outputPath); + log(`[download] ${options.requestId} attempt ${attempt} done -> ${finalFile}`); rememberAppPath(finalFile); send({ type: 'done', payload: { filename: finalFile } }); return; @@ -1504,7 +1569,9 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { try { const rawFile = findRawDownloadedFile(rawDir); + log(`[download] ${options.requestId} attempt ${attempt} yt-dlp finished, starting direct ffmpeg postprocess pass: rawFile=${rawFile} target=${postprocessOutputPath}`); const duration = await getMediaDurationSeconds(rawFile); + log(`[download] ${options.requestId} attempt ${attempt} probed raw file duration: ${duration}s`); const onFfmpegProgress = (postprocessPercent) => send({ type: 'postprocessing', payload: { stage: 'progress', processor: 'ffmpeg', postprocessPercent }, @@ -1530,6 +1597,7 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { fs.rmSync(rawDir, { recursive: true, force: true }); finishDownload(); + log(`[download] ${options.requestId} attempt ${attempt} postprocess complete -> ${postprocessOutputPath}`); rememberAppPath(postprocessOutputPath); send({ type: 'done', payload: { filename: postprocessOutputPath } }); } catch (err) { @@ -1541,6 +1609,7 @@ ipcMain.handle('downloadVideoWithProgressUpdates', (event, options) => { // codec) that retrying won't fix. const rawMessage = err instanceof Error ? err.message : String(err); const kind = await recheckDiskSpaceIfAmbiguous(ERROR_KINDS.UNKNOWN, options.outputPath); + log(`[download] ${options.requestId} attempt ${attempt} postprocess FAILED (${kind}): ${rawMessage}`); finishDownload(); if (rawDir) fs.rmSync(rawDir, { recursive: true, force: true }); send({ type: 'error', payload: { message: rawMessage, kind, retryable: false } }); diff --git a/src/ui/components/LinearProgressWithLabel.tsx b/src/ui/components/LinearProgressWithLabel.tsx index 9847535..974484f 100644 --- a/src/ui/components/LinearProgressWithLabel.tsx +++ b/src/ui/components/LinearProgressWithLabel.tsx @@ -5,17 +5,24 @@ import type { LinearProgressProps } from '@mui/material/LinearProgress'; // download with no postprocess step), valueBuffer = the "how much has // loaded" progress underneath it -- the same visual metaphor as a video // player's seek bar. -export default function LinearProgressWithLabel(props: LinearProgressProps & { value: number; valueBuffer: number }) { - const { value, valueBuffer } = props; +// +// indeterminate switches to MUI's own indeterminate variant (a bar that +// animates with no fixed endpoint) instead -- for stages that have no real +// percentage to show at all (e.g. yt-dlp's own merge step, which only +// reports started/finished), rather than displaying a fake fixed value that +// reads as a stuck/broken progress bar. +export default function LinearProgressWithLabel(props: LinearProgressProps & { value: number; valueBuffer: number; indeterminate?: boolean }) { + const { value, valueBuffer, indeterminate, ...rest } = props; const displayValue = value > 0 ? value : valueBuffer; return ( - - - - {`${Math.round(displayValue)}%`} + + {!indeterminate && + + {`${Math.round(displayValue)}%`} + } ); } diff --git a/src/ui/hooks/useDownloadVideo.test.tsx b/src/ui/hooks/useDownloadVideo.test.tsx index 12521f5..eef13a7 100644 --- a/src/ui/hooks/useDownloadVideo.test.tsx +++ b/src/ui/hooks/useDownloadVideo.test.tsx @@ -67,17 +67,20 @@ describe('useDownloadVideo', () => { const { result } = renderHook(() => useDownloadVideo()); emit({ type: 'postprocessing', payload: { postprocessPercent: 90 } as DownloadProgressMessage['payload'] }); expect(result.current.postprocessProgress).toBe(90); + expect(result.current.postprocessIndeterminate).toBe(false); // Real ffmpeg progress restarting near 0 (e.g. a second postprocess step) must not get stuck at a prior high value. emit({ type: 'postprocessing', payload: { postprocessPercent: 5 } as DownloadProgressMessage['payload'] }); expect(result.current.postprocessProgress).toBe(5); }); - it('falls back to the 50/100 approximation when no real postprocessPercent is given', () => { + it('falls back to the 50/100 approximation when no real postprocessPercent is given, and flags it as indeterminate while started', () => { const { result } = renderHook(() => useDownloadVideo()); emit({ type: 'postprocessing', payload: { stage: 'start' } as DownloadProgressMessage['payload'] }); expect(result.current.postprocessProgress).toBe(50); + expect(result.current.postprocessIndeterminate).toBe(true); emit({ type: 'postprocessing', payload: { stage: 'finished' } as DownloadProgressMessage['payload'] }); expect(result.current.postprocessProgress).toBe(100); + expect(result.current.postprocessIndeterminate).toBe(false); }); it('sets isError and downloadError on an error event', () => { diff --git a/src/ui/hooks/useDownloadVideo.tsx b/src/ui/hooks/useDownloadVideo.tsx index 59f59dc..2af64d6 100644 --- a/src/ui/hooks/useDownloadVideo.tsx +++ b/src/ui/hooks/useDownloadVideo.tsx @@ -4,6 +4,11 @@ import type { DownloadProgressMessage, DownloadVideoParams } from '../../types' function useDownloadVideo() { const [downloadProgress, setDownloadProgress] = useState(0); const [postprocessProgress, setPostprocessProgress] = useState(0); + // True only for yt-dlp's own merge-step postprocessing (started, no real + // percentage yet) -- distinct from postprocessProgress's 2-state 50/100 + // approximation below, so consumers can show an indeterminate bar instead + // of a percentage that isn't actually measuring anything. + const [postprocessIndeterminate, setPostprocessIndeterminate] = useState(false); const [downloadStatus, setDownloadStatus] = useState("Idle"); const [finalFilePath, setFinalFilePath] = useState(''); const [isDone, setIsDone] = useState(false); @@ -36,6 +41,7 @@ function useDownloadVideo() { setDownloadProgress(0); setPostprocessProgress(0); + setPostprocessIndeterminate(false); setDownloadStatus("Idle"); setFinalFilePath(''); setIsDone(false); @@ -89,12 +95,17 @@ function useDownloadVideo() { // branch) can leave postprocessProgress at 100 already, and // clamping here would stick our real, near-0-starting percent // at 100 instead of showing real progress. + setPostprocessIndeterminate(false); setPostprocessProgress(payload.postprocessPercent); } else { // yt-dlp's own merge-step postprocessing only ever reports - // started/finished, never a real percentage -- a deliberate - // 2-state approximation, fine here since a plain stream merge - // is fast, unlike the slower re-encode case handled above. + // started/finished, never a real percentage. A stuck-at-50% + // bar reads as broken (especially noticeable on a long + // recording, where this step can legitimately run for a + // while) -- consumers should render an indeterminate bar + // instead while this is true, rather than trusting + // postprocessProgress's 50/100 approximation as a real value. + setPostprocessIndeterminate(payload.stage === 'start'); setPostprocessProgress(payload.stage === 'start' ? 50 : 100); } break; @@ -108,6 +119,7 @@ function useDownloadVideo() { setIsError(true); setIsRetrying(false); setDownloadErrorKind(payload.kind ?? null); + setPostprocessIndeterminate(false); if (downloadError != null) { accumErr = { previous: downloadError, current: msg }; } @@ -122,6 +134,7 @@ function useDownloadVideo() { setFinalFilePath(payload.filename); setDownloadProgress(100); setPostprocessProgress(100); + setPostprocessIndeterminate(false); setIsDone(true); break; } @@ -137,6 +150,7 @@ function useDownloadVideo() { finalFilePath, downloadProgress, postprocessProgress, + postprocessIndeterminate, downloadStatus, isDone, isError, diff --git a/src/ui/screens/LibraryVideoDetail.tsx b/src/ui/screens/LibraryVideoDetail.tsx index b176d86..bf6f6bd 100644 --- a/src/ui/screens/LibraryVideoDetail.tsx +++ b/src/ui/screens/LibraryVideoDetail.tsx @@ -174,7 +174,7 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on return () => window.electronAPI.removeFfmpegUtilityProgressListener(); }, []); - const { downloadProgress, postprocessProgress, downloadStatus, finalFilePath, isDone, isError, downloadErrorKind, isRetrying, startDownload, cancelDownload } = useDownloadVideo(); + const { downloadProgress, postprocessProgress, postprocessIndeterminate, downloadStatus, finalFilePath, isDone, isError, downloadErrorKind, isRetrying, startDownload, cancelDownload } = useDownloadVideo(); // A genuinely different video was selected (not just a data refresh of the // same one, e.g. after a download/swap/version-add) -- jump to its latest. @@ -836,6 +836,7 @@ export default function LibraryVideoDetail({ video, onBack, onLibraryChanged, on downloadStatus={downloadStatus} downloadProgress={downloadProgress} postprocessProgress={postprocessProgress} + postprocessIndeterminate={postprocessIndeterminate} swappingQuality={swappingQuality} onCancelQualitySwap={() => setSwappingQuality(false)} isDownloading={isDownloading} diff --git a/src/ui/screens/OtherPlatformDownloadCard.tsx b/src/ui/screens/OtherPlatformDownloadCard.tsx index f5851c1..7ca463d 100644 --- a/src/ui/screens/OtherPlatformDownloadCard.tsx +++ b/src/ui/screens/OtherPlatformDownloadCard.tsx @@ -29,7 +29,7 @@ import LabelOutlinedIcon from '@mui/icons-material/LabelOutlined'; import CancelIcon from '@mui/icons-material/Cancel'; import type { DownloadVideoParams } from '../../types'; import useDownloadVideo from '../hooks/useDownloadVideo.tsx'; -import { getPlatformLabel } from '../../utils/utils.ts'; +import { getPlatformLabel, isLongVideoForPostprocess } from '../../utils/utils.ts'; import LinearProgressWithLabel from '../components/LinearProgressWithLabel'; // Deliberately minimal, and deliberately NOT a branch inside VideoDetailCard @@ -46,6 +46,7 @@ interface OtherPlatformVideoDataProps { fullTitle: string; thumbnail: string; uploader: string | null; + duration?: number | null; durationString: string | null; uploadDate: string | null; description: string | null; @@ -68,7 +69,7 @@ export default function OtherPlatformDownloadCard({ videoMetaData }: OtherPlatfo const [embedError, setEmbedError] = useState(null); const [embedSuccessOpen, setEmbedSuccessOpen] = useState(false); - const { finalFilePath, downloadProgress, postprocessProgress, downloadStatus, isDone, isError, downloadError, downloadErrorKind, isRetrying, startDownload, cancelDownload } = useDownloadVideo(); + const { finalFilePath, downloadProgress, postprocessProgress, postprocessIndeterminate, downloadStatus, isDone, isError, downloadError, downloadErrorKind, isRetrying, startDownload, cancelDownload } = useDownloadVideo(); const platformLabel = getPlatformLabel(videoMetaData.originalUrl); // SoundCloud is audio-only, so this always extracts a real MP3 (the same @@ -192,7 +193,11 @@ export default function OtherPlatformDownloadCard({ videoMetaData }: OtherPlatfo Retrying after a download error... } - + + {!isDone && isLongVideoForPostprocess(videoMetaData.duration) && + + This is a long video -- postprocessing may take a while with no visible progress. + } {isDone && isSoundCloud &&