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
@@ -1,5 +1,6 @@
<script lang="ts">
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
import { describeProgress, formatBytes } from "./downloadProgress";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("DownloadItem");
@@ -10,20 +11,8 @@
let { download }: Props = $props();
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
function formatProgress(): string {
if (!download.fileSize) {
return formatBytes(download.bytesDownloaded);
}
return `${formatBytes(download.bytesDownloaded)} / ${formatBytes(download.fileSize)}`;
}
// Exact, estimated, or unknown total — see downloadProgress.ts (DR-290).
const view = $derived(describeProgress(download));
function getStatusColor(): string {
switch (download.status) {
@@ -201,15 +190,24 @@
<!-- Progress Bar (for active/paused downloads) -->
{#if download.status === "downloading" || download.status === "paused"}
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
style="width: {download.progress * 100}%"
></div>
<div class="w-full bg-gray-700 rounded-full h-2 mb-2 overflow-hidden">
{#if view.kind === "indeterminate"}
<!-- No total to measure against: a moving band, not a bar stuck at 0% -->
<div
class="h-2 w-1/3 rounded-full {getStatusColor()} {download.status === 'downloading'
? 'animate-indeterminate'
: ''}"
></div>
{:else}
<div
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
style="width: {view.percent}%"
></div>
{/if}
</div>
<div class="flex items-center justify-between text-xs text-gray-400">
<span>{Math.round(download.progress * 100)}%</span>
<span>{formatProgress()}</span>
<span>{view.percentLabel}</span>
<span>{view.label}</span>
</div>
{:else if download.status === "completed"}
<p class="text-xs text-gray-400">{formatBytes(download.bytesDownloaded)}</p>
@@ -365,3 +363,18 @@
</div>
</div>
</div>
<style>
/* A band sweeping the track: "still moving, size unknown". */
@keyframes indeterminate {
from {
transform: translateX(-100%);
}
to {
transform: translateX(300%);
}
}
.animate-indeterminate {
animation: indeterminate 1.4s ease-in-out infinite;
}
</style>
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { describeProgress } from "./downloadProgress";
const base = {
status: "downloading" as const,
progress: 0,
bytesDownloaded: 0,
fileSize: undefined as number | undefined,
fileSizeEstimated: false,
};
// TRACES: UR-071 | DR-290 | UT-254
describe("describeProgress", () => {
it("shows an indeterminate bar, not 0%, when the size is unknown", () => {
// A transcode has no Content-Length and (before the estimate) no total:
// 200 MB in, the bar read "0%". That is not progress information.
const view = describeProgress({ ...base, bytesDownloaded: 200 * 1024 * 1024 });
expect(view.kind).toBe("indeterminate");
expect(view.percent).toBeNull();
expect(view.label).toBe("200.0 MB");
});
it("marks an estimated total as approximate", () => {
const view = describeProgress({
...base,
progress: 0.42,
bytesDownloaded: 420,
fileSize: 1000,
fileSizeEstimated: true,
});
expect(view.kind).toBe("estimated");
expect(view.percent).toBe(42);
expect(view.percentLabel).toBe("~42%");
expect(view.label).toBe("420 B / ~1000 B");
});
it("reports an exact total plainly", () => {
const view = describeProgress({
...base,
progress: 0.5,
bytesDownloaded: 512,
fileSize: 1024,
});
expect(view.kind).toBe("exact");
expect(view.percent).toBe(50);
expect(view.percentLabel).toBe("50%");
expect(view.label).toBe("512 B / 1.0 KB");
});
it("keeps a paused download's bar where it stopped", () => {
const view = describeProgress({
...base,
status: "paused",
progress: 0.25,
bytesDownloaded: 256,
fileSize: 1024,
});
expect(view.kind).toBe("exact");
expect(view.percent).toBe(25);
});
});
@@ -0,0 +1,64 @@
/**
* What the progress bar on a download row should say.
*
* Extracted from `DownloadItem.svelte` so the three cases can be unit-tested:
* an exact total (the server sent `Content-Length`), an estimated one (a
* transcode sends none, so the backend predicted it from the source), and no
* total at all — which used to render as an empty bar reading "0%" for the
* whole of a transcode download. Progress with no denominator is not 0%; it
* is unknown, and the bar says so.
*
* TRACES: UR-071 | DR-290 | UT-254
*/
export interface ProgressSource {
status: "pending" | "downloading" | "completed" | "failed" | "paused";
/** 0..1 as the backend reports it; already capped short of 1 when estimated. */
progress: number;
bytesDownloaded: number;
fileSize?: number;
/** `fileSize` is the backend's prediction, not the server's word. */
fileSizeEstimated?: boolean;
}
export type ProgressKind = "exact" | "estimated" | "indeterminate";
export interface ProgressView {
kind: ProgressKind;
/** Whole percent for the bar width; `null` when there is nothing to measure against. */
percent: number | null;
/** "42%" / "~42%"; empty when indeterminate. */
percentLabel: string;
/** "512 B / 1.0 KB", "420 B / ~1000 B", or just the bytes so far. */
label: string;
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`;
}
export function describeProgress(d: ProgressSource): ProgressView {
const downloaded = formatBytes(d.bytesDownloaded);
if (!d.fileSize) {
return { kind: "indeterminate", percent: null, percentLabel: "", label: downloaded };
}
const percent = Math.round(Math.min(Math.max(d.progress, 0), 1) * 100);
if (d.fileSizeEstimated) {
return {
kind: "estimated",
percent,
percentLabel: `~${percent}%`,
label: `${downloaded} / ~${formatBytes(d.fileSize)}`,
};
}
return {
kind: "exact",
percent,
percentLabel: `${percent}%`,
label: `${downloaded} / ${formatBytes(d.fileSize)}`,
};
}