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>
328 lines
9.5 KiB
Svelte
328 lines
9.5 KiB
Svelte
<script lang="ts">
|
|
import { downloads } from "$lib/stores/downloads";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { commands } from "$lib/api/bindings";
|
|
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("VideoDownloadButton");
|
|
|
|
interface Props {
|
|
itemId: string;
|
|
itemName?: string;
|
|
// For movies
|
|
isMovie?: boolean;
|
|
// For episodes
|
|
seriesName?: string;
|
|
seasonName?: string;
|
|
episodeNumber?: number;
|
|
seasonNumber?: number;
|
|
size?: "sm" | "md" | "lg";
|
|
className?: string;
|
|
}
|
|
|
|
let {
|
|
itemId,
|
|
itemName = "",
|
|
isMovie = false,
|
|
seriesName,
|
|
seasonName,
|
|
episodeNumber,
|
|
seasonNumber,
|
|
size = "md",
|
|
className = "",
|
|
}: Props = $props();
|
|
|
|
const sizeClasses = {
|
|
sm: "w-4 h-4",
|
|
md: "w-5 h-5",
|
|
lg: "w-6 h-6",
|
|
};
|
|
|
|
let isProcessing = $state(false);
|
|
let showQualityPicker = $state(false);
|
|
let buttonEl: HTMLButtonElement;
|
|
let dropdownPos = $state({ top: 0, left: 0 });
|
|
|
|
// Find download for this item
|
|
const downloadInfo = $derived(
|
|
Object.values($downloads.downloads).find((d) => d.itemId === itemId),
|
|
);
|
|
|
|
const status = $derived(downloadInfo?.status || "not_downloaded");
|
|
const progress = $derived(downloadInfo?.progress || 0);
|
|
|
|
async function startDownload(quality: QualityPreset) {
|
|
showQualityPicker = false;
|
|
if (isProcessing) return;
|
|
|
|
isProcessing = true;
|
|
try {
|
|
const userId = $auth.user?.id;
|
|
if (!userId) {
|
|
log.error("No user ID found");
|
|
return;
|
|
}
|
|
|
|
const handle = auth.getRepository().getHandle();
|
|
|
|
log.debug("🎬 Starting video download for item:", itemId, "quality:", quality);
|
|
|
|
// Get target directory
|
|
const targetDir = await commands.storageGetPath();
|
|
|
|
// Create file path
|
|
const safeName = (itemName || itemId).replace(/[/\\:*?"<>|]/g, "_");
|
|
let filePath: string;
|
|
|
|
if (isMovie) {
|
|
filePath = `videos/movies/${safeName}.mp4`;
|
|
} else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) {
|
|
const safeSeriesName = seriesName.replace(/[/\\:*?"<>|]/g, "_");
|
|
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}_${safeName}.mp4`;
|
|
} else {
|
|
filePath = `videos/${safeName}.mp4`;
|
|
}
|
|
|
|
log.debug(" File path:", filePath);
|
|
|
|
// Queue download with video metadata
|
|
const downloadId = await downloads.downloadVideo(
|
|
itemId,
|
|
userId,
|
|
filePath,
|
|
"video/mp4",
|
|
isMovie ? 500 : 1000 - (episodeNumber || 0), // Movies have medium priority, episodes ordered by number
|
|
itemName || undefined,
|
|
quality,
|
|
seriesName,
|
|
seasonName,
|
|
episodeNumber,
|
|
seasonNumber,
|
|
);
|
|
log.debug(" Download queued with ID:", downloadId);
|
|
|
|
// Pin the item metadata
|
|
await downloads.pinItem(itemId);
|
|
|
|
// 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 {
|
|
isProcessing = false;
|
|
}
|
|
}
|
|
|
|
async function handleClick() {
|
|
if (isProcessing) return;
|
|
|
|
if (status === "completed") {
|
|
// Delete download
|
|
if (downloadInfo?.id) {
|
|
await downloads.delete(downloadInfo.id);
|
|
// Unpin when deleted
|
|
await downloads.unpinItem(itemId);
|
|
}
|
|
} else if (status === "downloading" || status === "pending") {
|
|
// Cancel download
|
|
if (downloadInfo?.id) {
|
|
await downloads.cancel(downloadInfo.id);
|
|
}
|
|
} else if (status === "failed") {
|
|
// Show quality picker to retry
|
|
openQualityPicker();
|
|
} else {
|
|
// Show quality picker
|
|
openQualityPicker();
|
|
}
|
|
}
|
|
|
|
function openQualityPicker() {
|
|
if (buttonEl) {
|
|
const rect = buttonEl.getBoundingClientRect();
|
|
const dropdownWidth = 160; // w-40
|
|
const padding = 8;
|
|
let left = rect.right - dropdownWidth;
|
|
// Clamp to viewport bounds
|
|
left = Math.max(padding, Math.min(left, window.innerWidth - dropdownWidth - padding));
|
|
dropdownPos = { top: rect.bottom + 4, left };
|
|
}
|
|
showQualityPicker = true;
|
|
}
|
|
|
|
function getTitle(): string {
|
|
switch (status) {
|
|
case "completed":
|
|
return "Downloaded - Click to remove";
|
|
case "downloading":
|
|
return `Downloading... ${Math.round(progress * 100)}%`;
|
|
case "pending":
|
|
return "Queued for download";
|
|
case "paused":
|
|
return "Download paused";
|
|
case "failed":
|
|
return "Download failed - Click to retry";
|
|
default:
|
|
return "Download for offline playback";
|
|
}
|
|
}
|
|
|
|
function getColor(): string {
|
|
switch (status) {
|
|
case "completed":
|
|
return "text-green-500 hover:text-green-400";
|
|
case "downloading":
|
|
case "pending":
|
|
return "text-blue-500 hover:text-blue-400";
|
|
case "failed":
|
|
return "text-red-500 hover:text-red-400";
|
|
default:
|
|
return "text-gray-400 hover:text-white";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="relative">
|
|
<button
|
|
bind:this={buttonEl}
|
|
onclick={handleClick}
|
|
disabled={isProcessing}
|
|
class="p-2 rounded-full transition-all {getColor()} {isProcessing
|
|
? 'opacity-50 cursor-wait'
|
|
: ''} {className}"
|
|
title={getTitle()}
|
|
aria-label={getTitle()}
|
|
>
|
|
<div class="relative {sizeClasses[size]}">
|
|
{#if status === "downloading"}
|
|
<!-- Progress ring -->
|
|
<svg class="absolute inset-0 -rotate-90" viewBox="0 0 24 24">
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
opacity="0.2"
|
|
/>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-dasharray={2 * Math.PI * 10}
|
|
stroke-dashoffset={2 * Math.PI * 10 * (1 - progress)}
|
|
stroke-linecap="round"
|
|
class="transition-all duration-300"
|
|
/>
|
|
</svg>
|
|
<svg
|
|
class="absolute inset-0 m-auto w-3 h-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
|
</svg>
|
|
{:else if status === "completed"}
|
|
<svg
|
|
class={sizeClasses[size]}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{:else if status === "pending"}
|
|
<svg
|
|
class={sizeClasses[size]}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z"
|
|
/>
|
|
</svg>
|
|
{:else if status === "failed"}
|
|
<svg
|
|
class={sizeClasses[size]}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<svg
|
|
class={sizeClasses[size]}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Quality picker dropdown (fixed position, viewport-clamped) -->
|
|
{#if showQualityPicker}
|
|
<button
|
|
class="fixed inset-0 z-40"
|
|
onclick={() => (showQualityPicker = false)}
|
|
aria-label="Close quality picker"
|
|
></button>
|
|
<div
|
|
class="fixed z-50 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
|
|
style="top: {dropdownPos.top}px; left: {dropdownPos.left}px;"
|
|
>
|
|
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">Select Quality</div>
|
|
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
|
<button
|
|
onclick={() => startDownload(key as QualityPreset)}
|
|
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-700 transition-colors flex justify-between items-center"
|
|
>
|
|
<span>{preset.label}</span>
|
|
{#if preset.videoBitrate}
|
|
<span class="text-xs text-gray-500"
|
|
>{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span
|
|
>
|
|
{:else}
|
|
<span class="text-xs text-gray-500">Direct</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
<button
|
|
onclick={() => (showQualityPicker = false)}
|
|
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{/if}
|