Files
jellytau/src/lib/components/library/VideoDownloadButton.svelte
T
dtourolle d01c2aab9f
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m39s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 36s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Failing after 1m57s
Migrate all IPC call sites to typed tauri-specta commands.*
Replace the remaining ~155 untyped invoke() calls across stores, services,
components, and routes with the generated commands.* wrappers from
$lib/api/bindings, so every IPC call is compile-time-checked against the
command signatures.

- Register repository_get_subtitle_url and repository_get_video_download_url
  in specta_builder() and the invoke_handler; regenerate bindings.ts.
- Source duplicated wire types (AutoplaySettings, CacheConfig, Session,
  ConnectivityStatus, audio/video settings, etc.) from bindings.
- Fix two bugs surfaced by the typed wrappers:
  - VideoDownloadButton passed an un-awaited Promise as the stream URL.
  - setAutoplaySettings omitted the required userId argument.
- Update unit tests asserting the old invoke(name, args) shape.
- Remove the five param-naming guard tests; the compiler and codegen now
  enforce what they checked.

svelte-check: 0 errors. vitest: green. cargo test --lib: green.
2026-06-21 08:47:04 +02:00

294 lines
9.0 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";
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) {
console.error("No user ID found");
return;
}
const repo = auth.getRepository();
console.log("🎬 Starting video download for item:", itemId, "quality:", quality);
// Get stream URL based on quality
const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
console.log(" 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`;
}
console.log(" 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
);
console.log(" Download queued with ID:", downloadId);
// Pin the item metadata
await downloads.pinItem(itemId);
// Actually start the download
await commands.startDownload(downloadId, streamUrl, targetDir);
console.log(" Download started");
} catch (error) {
console.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}