feat(library): focused music/TV/movie landing screens + self-draining download queue
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s

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).
This commit is contained in:
2026-06-24 20:44:17 +02:00
parent dcf08f30bc
commit 17a35573a0
33 changed files with 2045 additions and 188 deletions
+14
View File
@@ -62,6 +62,20 @@
return;
}
// Route to dedicated TV library landing page
if (lib.collectionType === "tvshows") {
library.setCurrentLibrary(lib);
goto("/library/tv");
return;
}
// Route to dedicated movies library landing page
if (lib.collectionType === "movies") {
library.setCurrentLibrary(lib);
goto("/library/movies");
return;
}
// For other library types, load items normally
library.setCurrentLibrary(lib);
library.clearGenres();
+160
View File
@@ -0,0 +1,160 @@
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { currentLibrary } from "$lib/stores/library";
import { movies } from "$lib/stores/movies";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const categories: Category[] = [
{
id: "all",
name: "All Movies",
icon: "M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z",
description: "Browse all movies",
route: "/library/movies/all",
},
{
id: "genres",
name: "Genres",
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
description: "Browse by genre",
route: "/library/movies/genres",
},
];
async function load() {
if (!$currentLibrary) {
goto("/library");
return;
}
await movies.loadSections($currentLibrary.id);
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
onMount(async () => {
await load();
markLoaded();
});
$effect(() => {
checkServerReachability($isServerReachable);
});
function handleItemClick(item: MediaItem) {
if (item.type === "Folder") {
goto(`/library/${item.id}`);
} else {
// Movies play directly.
goto(`/player/${item.id}`);
}
}
const heroItems = $derived($movies.heroItems);
const continueWatching = $derived($movies.continueWatching);
const recentlyAdded = $derived($movies.recentlyAdded);
const genreRows = $derived($movies.genreRows);
const isLoading = $derived($movies.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
continueWatching.length > 0 ||
recentlyAdded.length > 0
);
</script>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</h1>
<button
onclick={() => goto("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Continue Watching -->
{#if continueWatching.length > 0}
<Carousel
title="Continue Watching"
items={continueWatching}
onItemClick={handleItemClick}
/>
{/if}
<!-- Recently Added -->
{#if recentlyAdded.length > 0}
<Carousel
title="Recently Added"
items={recentlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/movies/all")}
/>
{/if}
<!-- One slider per genre -->
{#each genreRows as row (row.id)}
<Carousel
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(`/library/movies/genres`)}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,27 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* Movie browser (all movies)
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Movie" as const,
title: "Movies",
backPath: "/library/movies",
searchPlaceholder: "Search movies...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
</script>
<GenericMediaListPage {config} />
+127 -102
View File
@@ -1,8 +1,15 @@
<!-- TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039 -->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { auth } from "$lib/stores/auth";
import { currentLibrary } from "$lib/stores/library";
import { music } from "$lib/stores/music";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
@@ -10,10 +17,9 @@
icon: string;
description: string;
route: string;
backgroundImage?: string;
}
let categories: Category[] = [
const categories: Category[] = [
{
id: "tracks",
name: "Tracks",
@@ -42,124 +48,143 @@
description: "Browse by genre",
route: "/library/music/genres",
},
{
id: "playlists",
name: "Playlists",
icon: "M4 6h16M4 10h16M4 14h10M14 14v6l5-3-5-3z",
description: "Your playlists",
route: "/library/music/playlists",
},
];
// Fetch album art for categories
async function loadCategoryImages() {
async function load() {
if (!$currentLibrary) {
console.log("Current library not set yet, retrying...");
goto("/library");
return;
}
try {
const repo = auth.getRepository();
// Fetch a recent album to use as background for albums category
const albums = await repo.getLatestItems($currentLibrary.id, 5);
if (albums.length > 0) {
const albumWithImage = albums.find(a => a.primaryImageTag);
if (albumWithImage) {
categories = categories.map(cat =>
cat.id === "albums"
? { ...cat, backgroundImage: albumWithImage.id }
: cat
);
}
}
// Fetch a recent audio track for tracks category
const tracks = await repo.getRecentlyPlayedAudio(5);
if (tracks.length > 0) {
const trackWithImage = tracks.find((t: typeof tracks[0]) => t.primaryImageTag);
if (trackWithImage) {
categories = categories.map(cat =>
cat.id === "tracks"
? { ...cat, backgroundImage: trackWithImage.id }
: cat
);
}
}
} catch (error) {
console.error("Failed to load category images:", error);
}
await music.loadSections($currentLibrary.id);
}
function getImageUrl(itemId: string | undefined) {
if (!itemId) return undefined;
return `http://tauri.localhost/image/primary/${itemId}?size=400&quality=95`;
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
onMount(() => {
loadCategoryImages();
onMount(async () => {
await load();
markLoaded();
});
function handleCategoryClick(route: string) {
goto(route);
$effect(() => {
checkServerReachability($isServerReachable);
});
function handleItemClick(item: MediaItem) {
// Albums, artists, and playlists all browse to a detail page.
goto(`/library/${item.id}`);
}
const heroItems = $derived($music.heroItems);
const recentlyPlayed = $derived($music.recentlyPlayed);
const newlyAdded = $derived($music.newlyAdded);
const playlists = $derived($music.playlists);
const rediscover = $derived($music.rediscover);
const isLoading = $derived($music.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
recentlyPlayed.length > 0 ||
newlyAdded.length > 0 ||
playlists.length > 0 ||
rediscover.length > 0
);
</script>
<div class="space-y-8">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-white">Music Library</h1>
<p class="text-gray-400 mt-1">Choose a category to browse</p>
</div>
<button
onclick={() => goto('/library')}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- Category Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{#each categories as category (category.id)}
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">Music</h1>
<button
onclick={() => handleCategoryClick(category.route)}
class="group relative bg-[var(--color-surface)] rounded-xl overflow-hidden hover:shadow-lg transition-all duration-200 text-left h-48"
style={category.backgroundImage ? `background-image: url('${getImageUrl(category.backgroundImage)}')` : ''}
onclick={() => goto("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
>
<!-- Background image overlay -->
{#if category.backgroundImage}
<div class="absolute inset-0 bg-black/40 group-hover:bg-black/50 transition-colors"></div>
{:else}
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-transparent"></div>
{/if}
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<!-- Content -->
<div class="relative z-10 h-full flex flex-col justify-between p-6">
<!-- Icon and text section -->
<div>
<!-- Icon -->
<div class="w-14 h-14 mb-4 rounded-full bg-[var(--color-jellyfin)]/30 backdrop-blur-sm flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-7 h-7 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Recently Listened -->
{#if recentlyPlayed.length > 0}
<Carousel
title="Recently Listened"
items={recentlyPlayed}
onItemClick={handleItemClick}
/>
{/if}
<!-- Playlists -->
{#if playlists.length > 0}
<Carousel
title="Playlists"
items={playlists}
onItemClick={handleItemClick}
showAll={() => goto("/library/music/playlists")}
/>
{/if}
<!-- Newly Added -->
{#if newlyAdded.length > 0}
<Carousel
title="Newly Added"
items={newlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/music/albums")}
/>
{/if}
<!-- Rediscover -->
{#if rediscover.length > 0}
<Carousel
title="Haven't listened to in a while"
items={rediscover}
onItemClick={handleItemClick}
/>
{/if}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start playing some music to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<!-- Text -->
<h2 class="text-xl font-bold text-white mb-1 group-hover:text-[var(--color-jellyfin)] transition-colors">
{category.name}
</h2>
<p class="text-gray-300 text-sm">
{category.description}
</p>
</div>
<!-- Arrow indicator -->
<div class="flex items-center text-[var(--color-jellyfin)] opacity-0 group-hover:opacity-100 transition-opacity">
<span class="text-sm font-medium mr-1">Browse</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</button>
{/each}
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
</div>
{/if}
+176
View File
@@ -0,0 +1,176 @@
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { currentLibrary } from "$lib/stores/library";
import { tv } from "$lib/stores/tv";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const categories: Category[] = [
{
id: "shows",
name: "All Shows",
icon: "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z",
description: "Browse all series",
route: "/library/tv/shows",
},
{
id: "genres",
name: "Genres",
icon: "M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
description: "Browse by genre",
route: "/library/shows/genres",
},
];
async function load() {
if (!$currentLibrary) {
goto("/library");
return;
}
await tv.loadSections($currentLibrary.id);
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
onMount(async () => {
await load();
markLoaded();
});
$effect(() => {
checkServerReachability($isServerReachable);
});
function handleItemClick(item: MediaItem) {
switch (item.type) {
case "Series":
case "Season":
case "Folder":
goto(`/library/${item.id}`);
break;
default:
// Episodes and movies play directly.
goto(`/player/${item.id}`);
break;
}
}
const heroItems = $derived($tv.heroItems);
const continueWatching = $derived($tv.continueWatching);
const nextUp = $derived($tv.nextUp);
const recentlyAdded = $derived($tv.recentlyAdded);
const genreRows = $derived($tv.genreRows);
const isLoading = $derived($tv.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
continueWatching.length > 0 ||
nextUp.length > 0 ||
recentlyAdded.length > 0
);
</script>
{#if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
<button
onclick={() => goto("/library")}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Continue Watching -->
{#if continueWatching.length > 0}
<Carousel
title="Continue Watching"
items={continueWatching}
onItemClick={handleItemClick}
/>
{/if}
<!-- Next Up -->
{#if nextUp.length > 0}
<Carousel
title="Next Up"
items={nextUp}
onItemClick={handleItemClick}
/>
{/if}
<!-- Recently Added -->
{#if recentlyAdded.length > 0}
<Carousel
title="Recently Added"
items={recentlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/tv/shows")}
/>
{/if}
<!-- One slider per genre -->
{#each genreRows as row (row.id)}
<Carousel
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(`/library/shows/genres`)}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
</div>
</div>
{/if}
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* TV show browser (all series)
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Series" as const,
title: "TV Shows",
backPath: "/library/tv",
searchPlaceholder: "Search shows...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
</script>
<GenericMediaListPage {config} />