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
+51 -8
View File
@@ -2,9 +2,19 @@
// TRACES: UR-007, UR-008, UR-029, UR-030 | DR-007, DR-011, DR-033
import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import { auth } from "./auth";
/**
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
* Carries the merged cache+server results for a given search request.
*/
interface SearchUpdateEvent {
requestId: number;
result: SearchResult;
}
export type ViewMode = "grid" | "list";
interface LibraryState {
@@ -47,8 +57,23 @@ function createLibraryStore() {
const { subscribe, set, update } = writable<LibraryState>(initialState);
// Test log to confirm cache logging is active
console.log("✅ [LibraryStore] Cache logging enabled - you should see cache hit/miss logs below");
// Monotonic id identifying the most recent search request. Each new search
// bumps it; the deferred `search-event` (carrying merged cache+server
// results) is only applied when its requestId still matches the latest one,
// so out-of-order / superseded results never clobber fresher ones.
let searchRequestId = 0;
let unlistenSearch: UnlistenFn | null = null;
// Lazily subscribe to backend search updates the first time we search.
async function ensureSearchListener() {
if (unlistenSearch) return;
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
const { requestId, result } = event.payload;
// Ignore results from a query the user has already moved on from.
if (requestId !== searchRequestId) return;
update((s) => ({ ...s, searchResults: result.items }));
});
}
async function loadLibraries() {
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null }));
@@ -157,11 +182,17 @@ function createLibraryStore() {
}
async function search(query: string) {
// Bump the request id for every call (including clears) so any in-flight
// backend update for a previous query is ignored when it arrives.
const requestId = ++searchRequestId;
if (!query.trim()) {
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
return;
}
await ensureSearchListener();
update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null, searchQuery: query }));
try {
@@ -172,16 +203,21 @@ function createLibraryStore() {
setTimeout(() => reject(new Error("Search timeout - please try again")), 10000)
);
// Phase 1: the command resolves with instant local-cache results. The
// merged (cache + server) union arrives later via the `search-event`
// listener above, tagged with this same requestId.
const result = await Promise.race([
repo.search(query, { limit: 10000 }),
repo.search(query, { limit: 10000 }, requestId),
timeoutPromise
]);
update((s) => ({
...s,
searchResults: result.items,
loadingCount: Math.max(0, s.loadingCount - 1),
}));
// Only apply if this is still the active query (a newer search may have
// started while we awaited).
if (requestId === searchRequestId) {
update((s) => ({ ...s, searchResults: result.items }));
}
update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1) }));
return result;
} catch (error) {
@@ -196,6 +232,8 @@ function createLibraryStore() {
}
function clearSearch() {
// Invalidate any in-flight backend search update.
searchRequestId++;
update((s) => ({ ...s, searchQuery: "", searchResults: [] }));
}
@@ -247,6 +285,11 @@ function createLibraryStore() {
}
function reset() {
searchRequestId++;
if (unlistenSearch) {
unlistenSearch();
unlistenSearch = null;
}
set(initialState);
}