Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
198 lines
6.5 KiB
Svelte
198 lines
6.5 KiB
Svelte
<script lang="ts">
|
|
import { downloads, videoDownloads } 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 {
|
|
seasonId: string;
|
|
seriesName: string;
|
|
seasonName: string;
|
|
seasonNumber: number;
|
|
episodeCount: number;
|
|
className?: string;
|
|
size?: "sm" | "md" | "lg";
|
|
}
|
|
|
|
let { seasonId, seriesName, seasonName, seasonNumber, episodeCount, className = "", size = "md" }: Props = $props();
|
|
|
|
let isProcessing = $state(false);
|
|
let showQualityPicker = $state(false);
|
|
|
|
// Count downloads for this season
|
|
const seasonDownloads = $derived(
|
|
$videoDownloads.filter((d) =>
|
|
d.seriesName === seriesName &&
|
|
d.seasonName === seasonName
|
|
)
|
|
);
|
|
|
|
const completedCount = $derived(
|
|
seasonDownloads.filter((d) => d.status === "completed").length
|
|
);
|
|
|
|
const inProgressCount = $derived(
|
|
seasonDownloads.filter((d) => d.status === "downloading" || d.status === "pending").length
|
|
);
|
|
|
|
const hasDownloads = $derived(completedCount > 0 || inProgressCount > 0);
|
|
const allDownloaded = $derived(completedCount >= episodeCount);
|
|
|
|
async function startSeasonDownload(quality: QualityPreset) {
|
|
showQualityPicker = false;
|
|
if (isProcessing) return;
|
|
|
|
isProcessing = true;
|
|
try {
|
|
const userId = $auth.user?.id;
|
|
if (!userId) {
|
|
console.error("No user ID found");
|
|
return;
|
|
}
|
|
|
|
console.log("📺 Starting season download for:", seasonName, "quality:", quality);
|
|
|
|
// Get target directory
|
|
const targetDir = await commands.storageGetPath();
|
|
const basePath = `${targetDir}/videos`;
|
|
|
|
// Queue all episodes in this season
|
|
const downloadIds = await downloads.downloadSeason(
|
|
seasonId,
|
|
seriesName,
|
|
seasonName,
|
|
seasonNumber,
|
|
userId,
|
|
basePath,
|
|
quality
|
|
);
|
|
|
|
console.log(`✅ Queued ${downloadIds.length} episodes for download`);
|
|
|
|
// Pin the season item
|
|
await downloads.pinItem(seasonId);
|
|
|
|
// Resolve each episode's transcode URL (server-side) and enqueue. The
|
|
// backend pump then starts up to max_concurrent and advances through the
|
|
// rest as slots free up.
|
|
const handle = auth.getRepository().getHandle();
|
|
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
|
|
console.log(" Episodes enqueued; backend pump will start them");
|
|
} catch (error) {
|
|
console.error("Failed to start season download:", error);
|
|
} finally {
|
|
isProcessing = false;
|
|
}
|
|
}
|
|
|
|
function handleClick(e: MouseEvent) {
|
|
e.stopPropagation();
|
|
if (isProcessing) return;
|
|
showQualityPicker = true;
|
|
}
|
|
|
|
function getButtonText(): string {
|
|
if (allDownloaded) {
|
|
return size === "sm" ? "✓" : "Downloaded";
|
|
}
|
|
if (inProgressCount > 0) {
|
|
return size === "sm" ? `${inProgressCount}` : `Downloading (${inProgressCount})`;
|
|
}
|
|
if (completedCount > 0) {
|
|
return size === "sm" ? `${completedCount}/${episodeCount}` : `Download (${completedCount}/${episodeCount})`;
|
|
}
|
|
return size === "sm" ? "⬇" : "Download Season";
|
|
}
|
|
|
|
function getButtonColor(): string {
|
|
if (allDownloaded) {
|
|
return "bg-green-600 hover:bg-green-700";
|
|
}
|
|
if (inProgressCount > 0) {
|
|
return "bg-blue-600 hover:bg-blue-700";
|
|
}
|
|
return "bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-hover)]";
|
|
}
|
|
|
|
const sizeClasses = $derived(
|
|
size === "sm" ? "px-2 py-1 text-xs" :
|
|
size === "lg" ? "px-6 py-3 text-base" :
|
|
"px-4 py-2 text-sm"
|
|
);
|
|
|
|
const iconSize = $derived(
|
|
size === "sm" ? "w-3 h-3" :
|
|
size === "lg" ? "w-6 h-6" :
|
|
"w-4 h-4"
|
|
);
|
|
</script>
|
|
|
|
<div class="relative {className}">
|
|
<button
|
|
onclick={handleClick}
|
|
disabled={isProcessing || allDownloaded}
|
|
class="flex items-center gap-2 rounded-lg text-white font-medium transition-colors {sizeClasses} {getButtonColor()} {isProcessing || allDownloaded ? 'opacity-70 cursor-not-allowed' : ''}"
|
|
>
|
|
{#if inProgressCount > 0}
|
|
<!-- Spinner -->
|
|
<svg class="{iconSize} animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
{:else if allDownloaded}
|
|
<!-- Checkmark -->
|
|
<svg class="{iconSize}" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{:else}
|
|
<!-- Download icon -->
|
|
<svg class="{iconSize}" 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}
|
|
{#if size !== "sm"}
|
|
<span>{getButtonText()}</span>
|
|
{:else}
|
|
<span>{getButtonText()}</span>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Quality picker dropdown -->
|
|
{#if showQualityPicker}
|
|
<div class="absolute z-50 mt-2 left-0 w-48 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
|
<div class="p-3 border-b border-gray-700">
|
|
<div class="text-sm font-medium text-white">Download Quality</div>
|
|
<div class="text-xs text-gray-400 mt-1">{episodeCount} episodes</div>
|
|
</div>
|
|
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
|
<button
|
|
onclick={() => startSeasonDownload(key as QualityPreset)}
|
|
class="w-full px-4 py-3 text-left hover:bg-gray-700 transition-colors flex justify-between items-center"
|
|
>
|
|
<span class="text-sm text-white">{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-4 py-3 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Click outside to close -->
|
|
{#if showQualityPicker}
|
|
<button
|
|
class="fixed inset-0 z-40"
|
|
onclick={() => showQualityPicker = false}
|
|
aria-label="Close quality picker"
|
|
></button>
|
|
{/if}
|