feat(downloads): estimate a transcode's size so the progress bar moves

A transcode is produced as it is sent — chunked, with no Content-Length —
and the worker reported progress 0.0 for its whole duration: an empty bar
reading "0%" while the byte count climbed for an hour. That is the case
every film whose audio must be re-encoded lands in.

The backend already fetches the item to decide the audio policy, and that
item carries what a prediction needs: the source's size (an `original`
download copies the picture, so the output is the source give or take the
audio track) and its runtime (a preset re-encodes at fixed rates, so the
size is rate × runtime — from a preset table the URL builder now shares, so
the two cannot drift). The prediction is made where the URL is resolved and
persisted as the row's file_size. The worker uses it only when the response
has no length; the server's figure always wins; an estimated bar is capped
at 99% so a low prediction never shows a finished download still running;
and the Completed event now carries the bytes actually written so the
frontend stops persisting the row's file_size as the final size.

The row renders three honest states: exact "42%", estimated "~42%" with
"X / ~Y", or — with no total at all — an indeterminate band and the bytes
so far, never "0%". The single-video button joins the series/season buttons
on the enqueue path so all three resolve, and predict, in one place.

DR-290, UT-252, UT-253, UT-254.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-21 11:18:29 +02:00
co-authored by Claude Opus 5
parent e271874b1d
commit b3228cb4f4
15 changed files with 716 additions and 105 deletions
+73
View File
@@ -568,6 +568,79 @@ describe("downloads store", () => {
expect(state.downloads[123].status).toBe("completed");
});
/// A transcode's total is a prediction. The bar must carry that so it can
/// say "~", and the row's persisted size on completion must be the bytes
/// the worker counted — never the prediction.
/// TRACES: UR-071 | DR-290 | UT-254
it("carries the estimate flag through progress and persists the real size on completion", async () => {
const { downloads, initDownloadEvents } = await import("./downloads");
mockInvoke.mockResolvedValueOnce({
downloads: [
{
id: 7,
itemId: "film",
userId: "user-1",
filePath: "videos/movies/film.mp4",
fileSize: 3_000_000_000, // predicted at resolve time
status: "downloading",
progress: 0,
bytesDownloaded: 0,
queuedAt: "2024-01-01T00:00:00Z",
retryCount: 0,
priority: 0,
mediaType: "video",
downloadSource: "user",
},
],
stats: {
total: 1,
activeCount: 1,
queuedCount: 0,
completedCount: 0,
failedCount: 0,
pausedCount: 0,
},
});
await downloads.refresh("user-1");
await initDownloadEvents();
eventHandler!({
payload: {
type: "progress",
downloadId: 7,
itemId: "film",
bytesDownloaded: 1_500_000_000,
totalBytes: 3_000_000_000,
progress: 0.5,
estimated: true,
},
});
let row = get(downloads).downloads[7];
expect(row.fileSizeEstimated).toBe(true);
expect(row.progress).toBe(0.5);
mockInvoke.mockClear();
mockInvoke.mockResolvedValueOnce(undefined);
eventHandler!({
payload: {
type: "completed",
downloadId: 7,
itemId: "film",
filePath: "videos/movies/film.mp4",
bytesDownloaded: 3_120_000_000,
},
});
await new Promise((resolve) => setTimeout(resolve, 50));
row = get(downloads).downloads[7];
expect(row.status).toBe("completed");
expect(row.fileSizeEstimated).toBe(false);
expect(row.fileSize).toBe(3_120_000_000);
const persisted = mockInvoke.mock.calls.find((c) => c[0] === "mark_download_completed");
expect(persisted?.[1]).toMatchObject({ bytesDownloaded: 3_120_000_000 });
});
it("should handle failed event and refresh", async () => {
const { downloads, initDownloadEvents } = await import("./downloads");
+15 -1
View File
@@ -17,6 +17,8 @@ export interface DownloadInfo {
userId: string;
filePath: string;
fileSize?: number;
/** `fileSize` is the backend's prediction (a transcode states no length), not the server's word. */
fileSizeEstimated?: boolean;
mimeType?: string;
status: "pending" | "downloading" | "completed" | "failed" | "paused";
progress: number;
@@ -58,6 +60,8 @@ export interface DownloadEvent {
bytesDownloaded?: number;
totalBytes?: number;
progress?: number;
/** On 'progress': `totalBytes` is an estimate, so `progress` stays below 1 until 'completed'. */
estimated?: boolean;
filePath?: string;
error?: string;
}
@@ -598,17 +602,24 @@ function handleDownloadEvent(payload: DownloadEvent): void {
progress: payload.progress,
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
fileSize: payload.totalBytes || download.fileSize,
fileSizeEstimated: payload.estimated ?? download.fileSizeEstimated ?? false,
});
}
break;
case "completed":
if (download) {
// The bytes actually written, as the worker counted them. A predicted
// fileSize must never be persisted as the real one (DR-290).
const finalBytes =
payload.bytesDownloaded ||
(download.fileSizeEstimated ? 0 : download.fileSize) ||
download.bytesDownloaded;
// Persist to database
commands
.markDownloadCompleted(
payload.downloadId,
payload.totalBytes || download.fileSize || download.bytesDownloaded,
finalBytes,
payload.filePath || download.filePath,
)
.catch((err) => log.error("Failed to persist download completion:", err));
@@ -616,6 +627,9 @@ function handleDownloadEvent(payload: DownloadEvent): void {
updateDownloadInStore(payload.downloadId, {
status: "completed",
progress: 1.0,
bytesDownloaded: finalBytes,
fileSize: finalBytes,
fileSizeEstimated: false,
completedAt: new Date().toISOString(),
filePath: payload.filePath || download.filePath,
});