Duncan Tourolle 17a35573a0
All checks were successful
🏗️ 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
feat(library): focused music/TV/movie landing screens + self-draining download queue
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).
2026-06-24 20:44:17 +02:00

177 lines
5.3 KiB
TypeScript

// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface TvState {
// Episodes the user can resume (continue watching).
continueWatching: MediaItem[];
// Next unwatched episode per in-progress series.
nextUp: MediaItem[];
// Recently added items in the TV library (series/seasons/episodes).
recentlyAdded: MediaItem[];
// One slider per genre (top genres by show count).
genreRows: GenreRow[];
// Mix used for the hero banner.
heroItems: MediaItem[];
isLoading: boolean;
error: string | null;
}
const SECTION_LIMIT = 16;
// How many genre sliders to show, and how many genres to probe to find them.
const MAX_GENRE_ROWS = 8;
const MAX_GENRES_PROBED = 20;
function createTvStore() {
const initialState: TvState = {
continueWatching: [],
nextUp: [],
recentlyAdded: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<TvState>(initialState);
/**
* Build the hero rotation. Prefer in-progress episodes (most personal),
* then fall back to next-up, then recently added. De-duplicates by id and
* prefers items that carry backdrop/primary artwork for a good banner.
*/
function buildHero(
continueWatching: MediaItem[],
nextUp: MediaItem[],
recentlyAdded: MediaItem[]
): MediaItem[] {
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.length > 0) ||
!!i.primaryImageTag;
const result: MediaItem[] = [];
const seen = new Set<string>();
for (const pool of [continueWatching, nextUp, recentlyAdded]) {
for (const item of pool) {
if (result.length >= 6) break;
if (hasArt(item) && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
}
return result.slice(0, 6);
}
async function loadSections(libraryId: string) {
update(s => ({
...s,
isLoading: s.continueWatching.length === 0 && s.recentlyAdded.length === 0,
error: null,
}));
try {
const repo = auth.getRepository();
const [resume, nextUp, latest] = await Promise.all([
repo.getResumeItems(libraryId, SECTION_LIMIT),
repo.getNextUpEpisodes(undefined, SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
]);
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.type === "Episode" || i.type === "Movie");
const heroItems = buildHero(continueWatching, nextUp, latest);
update(s => ({
...s,
continueWatching,
nextUp,
recentlyAdded: latest,
heroItems,
isLoading: false,
}));
// Genre rows are secondary — load them after the main sections paint so
// the page isn't blocked on N per-genre queries.
loadGenreRows(libraryId);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load TV sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load TV sections:", error);
}
}
/**
* Build one slider per genre, showing the top-rated series in each. We probe
* a bounded set of genres in parallel, drop empty ones, then keep the genres
* with the most shows (so niche/near-empty genres don't crowd the page).
*/
async function loadGenreRows(libraryId: string) {
try {
const repo = auth.getRepository();
const genres = await repo.getGenres(libraryId);
if (genres.length === 0) return;
const probed = genres.slice(0, MAX_GENRES_PROBED);
const rows = await Promise.all(
probed.map(async (genre): Promise<GenreRow> => {
try {
const result = await repo.getItems(libraryId, {
includeItemTypes: ["Series"],
genres: [genre.name],
sortBy: "CommunityRating",
sortOrder: "Descending",
recursive: true,
limit: SECTION_LIMIT,
});
return { id: genre.id, name: genre.name, items: result.items };
} catch (e) {
console.warn(`Failed to load genre row "${genre.name}":`, e);
return { id: genre.id, name: genre.name, items: [] };
}
})
);
const genreRows = rows
.filter(row => row.items.length > 0)
.sort((a, b) => b.items.length - a.items.length)
.slice(0, MAX_GENRE_ROWS);
update(s => ({ ...s, genreRows }));
} catch (e) {
console.warn("Failed to load TV genre rows:", e);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const tv = createTvStore();
export const tvHeroItems = derived(tv, $t => $t.heroItems);
export const isTvLoading = derived(tv, $t => $t.isLoading);