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:
@@ -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)}`,
|
||||
};
|
||||
}
|
||||
@@ -64,14 +64,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = auth.getRepository();
|
||||
const handle = auth.getRepository().getHandle();
|
||||
|
||||
log.debug("🎬 Starting video download for item:", itemId, "quality:", quality);
|
||||
|
||||
// Get stream URL based on quality
|
||||
const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
|
||||
log.debug(" Stream URL obtained");
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await commands.storageGetPath();
|
||||
|
||||
@@ -109,9 +105,12 @@
|
||||
// Pin the item metadata
|
||||
await downloads.pinItem(itemId);
|
||||
|
||||
// Actually start the download
|
||||
await commands.startDownload(downloadId, streamUrl, targetDir);
|
||||
log.debug(" Download started");
|
||||
// Resolve the URL and start it the same way the series/season buttons
|
||||
// do: Rust picks the transcode from the row's preset, predicts the size
|
||||
// so the bar has a total when the response has no length (DR-290), and
|
||||
// the queue pump starts it when a slot is free.
|
||||
await commands.enqueueVideoDownloads(handle, [downloadId], targetDir);
|
||||
log.debug(" Download enqueued");
|
||||
} catch (error) {
|
||||
log.error("Failed to start video download:", error);
|
||||
} finally {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user