Files
jellytau/src/lib/components/library/VideoDownloadButton.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

329 lines
9.4 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 repo = auth.getRepository();
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();
// 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);
// Actually start the download
await commands.startDownload(downloadId, streamUrl, targetDir);
log.debug(" Download started");
} 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}