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:
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user