diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2629ce5..abdbb20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,21 @@ This is the first documented entry — a snapshot of what SlothArchiver could
do as of this release, not a history of every change that got it here.
Future releases will log what actually changed from the previous one.
+## [0.27.1] — 2026-09-04
+- **Fixed a bug where downloading a very long video could get stuck looping
+ forever.** The step that joins the downloaded video and audio together
+ doesn't report progress while it runs, and on a long enough recording that
+ silence was being mistaken for a stalled connection — killing the merge
+ partway through and restarting the whole download from scratch, repeatedly,
+ with no way out short of force-quitting the app.
+- **The postprocessing step now shows a proper loading animation instead of a
+ progress bar stuck at 50%**, since that stage genuinely has no percentage
+ to report — the old fixed value looked broken on anything that took more
+ than a few seconds.
+- **Downloading a video over 3 hours long now shows a heads-up** that the
+ postprocessing step may take a while with no visible progress, so it's
+ clear that's expected rather than a sign something's wrong.
+
## [0.27.0] — 2026-09-03
- **Split your library into separate sublibraries.** Create as many as you
want from the Library tab, switch between them, and pick which one a new
diff --git a/README.md b/README.md
index 112b8c9..cd5bc3a 100644
--- a/README.md
+++ b/README.md
@@ -28,22 +28,22 @@ itself.
## Download
-
| Platform | Link |
|---|---|
-| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver.Setup.0.27.0.exe) |
-| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver.0.27.0.exe) |
-| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver-0.27.0-arm64.dmg) |
-| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver-0.27.0.dmg) |
-| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.0/SlothArchiver-0.27.0.AppImage) |
+| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver.Setup.0.27.1.exe) |
+| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver.0.27.1.exe) |
+| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1-arm64.dmg) |
+| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1.dmg) |
+| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v0.27.1/SlothArchiver-0.27.1.AppImage) |
All builds are unsigned, so your OS will show a first-run security warning —
see [Installing](#installing) below.
@@ -223,7 +223,7 @@ that, it opens normally.
Step 1 — the Gatekeeper block you'll see on first launch:
-
+
If macOS still blocks it, go to **System Settings → Privacy & Security**,
scroll down to the security section, and click **Open Anyway** next to the
@@ -231,11 +231,11 @@ message about SlothArchiver.
Step 2 — find the security section in **Privacy & Security**:
-
+
Step 3 — click **Open Anyway**:
-
+
**Linux (AppImage)** — make it executable first, then run it directly:
```
diff --git a/package-lock.json b/package-lock.json
index 6788fe9..09fd40c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sloth-archiver",
- "version": "0.27.0",
+ "version": "0.27.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sloth-archiver",
- "version": "0.27.0",
+ "version": "0.27.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@emotion/react": "^11.14.0",
diff --git a/package.json b/package.json
index 0516d63..2977f34 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "sloth-archiver",
"private": true,
- "version": "0.27.0",
+ "version": "0.27.1",
"description": "A desktop app for downloading and archiving videos, with a built-in library and playlist tracking.",
"author": "Sloth ",
"license": "GPL-3.0-or-later",
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 &&
) : (
<>
diff --git a/src/ui/screens/VideoQualityDownload.tsx b/src/ui/screens/VideoQualityDownload.tsx
index 2460b9b..9a453aa 100644
--- a/src/ui/screens/VideoQualityDownload.tsx
+++ b/src/ui/screens/VideoQualityDownload.tsx
@@ -23,7 +23,7 @@ import AudiotrackIcon from '@mui/icons-material/Audiotrack';
import CancelIcon from '@mui/icons-material/Cancel';
import LocalOfferOutlinedIcon from '@mui/icons-material/LocalOfferOutlined';
import { pink } from '@mui/material/colors';
-import { buildAppVideoUrl, formatEpochLabel } from '../../utils/utils.ts';
+import { buildAppVideoUrl, formatEpochLabel, isLongVideoForPostprocess } from '../../utils/utils.ts';
import LinearProgressWithLabel from '../components/LinearProgressWithLabel';
import VideoTagsPopover from '../components/VideoTagsPopover';
import type { LibraryVideoMetadata, Resolution } from '../../types';
@@ -114,6 +114,22 @@ function DownloadCancelControls({ isRetrying, onCancelDownload }: { isRetrying:
);
}
+// Shared by the same three blocks as DownloadCancelControls above -- a
+// persistent warning label (not just a tooltip, which is too easy to miss)
+// shown below the progress bar for the whole download/postprocess span on a
+// long recording, since yt-dlp's own merge step reports no real progress and
+// can otherwise look stuck for a while on something this long. Only ever
+// rendered while a download is actively in flight (all three call sites are
+// already gated on that), so no separate isDone check is needed.
+function LongVideoPostprocessWarning({ duration }: { duration: number | null }) {
+ if (!isLongVideoForPostprocess(duration)) return null;
+ return (
+
+ This is a long video -- postprocessing may take a while with no visible progress.
+
+ );
+}
+
// The version selector + video quality download/swap controls + MP3 audio
// sub-section -- everything in the instrument panel above the FFMPEG
// utilities divider. All state stays owned by LibraryVideoDetail (the
@@ -137,6 +153,7 @@ export default function VideoQualityDownload({
downloadStatus,
downloadProgress,
postprocessProgress,
+ postprocessIndeterminate,
swappingQuality,
onCancelQualitySwap,
isDownloading,
@@ -174,6 +191,7 @@ export default function VideoQualityDownload({
downloadStatus: string;
downloadProgress: number;
postprocessProgress: number;
+ postprocessIndeterminate: boolean;
swappingQuality: boolean;
onCancelQualitySwap: () => void;
isDownloading: boolean;
@@ -266,7 +284,8 @@ export default function VideoQualityDownload({
{downloadStatus === 'Postprocessing...' ? 'Postprocessing' : 'Downloading'}
{selectedResolution && ` (${selectedResolution}${selectedResolution.toLowerCase() === 'mp3' ? '' : 'p'})`}
-
+
+
) : (
@@ -301,7 +320,8 @@ export default function VideoQualityDownload({
{downloadStatus === 'Postprocessing...' ? 'Postprocessing' : 'Downloading'}
{selectedResolution && ` (${selectedResolution}p)`}
-
+
+
) : videoResolutions.length === 0 ? (
@@ -337,7 +357,7 @@ export default function VideoQualityDownload({
{downloadStatus === 'Postprocessing...' ? 'Postprocessing' : 'Downloading'} (MP3)
-
+
) : metadata.downloadedAudioFilePath ? (
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index b177717..e1436d8 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -135,6 +135,19 @@ function thumbnailGridTemplateColumns(thumbnailSize: number): string {
return responsiveGridTemplateColumns(120, `${vw.toFixed(3)}vw`, 720);
}
+// yt-dlp's own postprocess progress-template only ever reports
+// started/finished, never a real percentage (see useDownloadVideo.tsx) --
+// for a merge/remux step that's normally fast, but on a very long recording
+// can legitimately sit at "started" for a long time with the postprocessing
+// bar showing no real movement. Arbitrary threshold picked to flag the
+// videos where that's actually likely to be noticeable, not a measured
+// cutoff.
+const LONG_VIDEO_POSTPROCESS_THRESHOLD_SECONDS = 3 * 60 * 60;
+
+function isLongVideoForPostprocess(durationSeconds: number | null | undefined): boolean {
+ return typeof durationSeconds === 'number' && durationSeconds >= LONG_VIDEO_POSTPROCESS_THRESHOLD_SECONDS;
+}
+
// Electron's ipcRenderer.invoke wraps any rejected IPC handler's error in a
// generic "Error invoking remote method '': Error: "
// wrapper before it reaches the renderer -- an implementation detail of the
@@ -146,5 +159,5 @@ function cleanElectronErrorMessage(message: string): string {
return message.replace(/^Error invoking remote method '[^']*':\s*(Error:\s*)?/, '');
}
-export { isValidUrl, isYouTubeUrl, getPlatformLabel, convertYYYYMMDDStringToDate, formatEpochLabel, buildAppVideoUrl, getBestDownloadedQuality, responsiveGridTemplateColumns, thumbnailGridTemplateColumns, cleanElectronErrorMessage };
+export { isValidUrl, isYouTubeUrl, getPlatformLabel, convertYYYYMMDDStringToDate, formatEpochLabel, buildAppVideoUrl, getBestDownloadedQuality, responsiveGridTemplateColumns, thumbnailGridTemplateColumns, cleanElectronErrorMessage, isLongVideoForPostprocess };