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.
329 lines
9.4 KiB
Svelte
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}
|