Files
jellytau/src/lib/stores/movies.ts
T
dtourolle 75014ee00f
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
Fix sleep bug, fix menu return
2026-07-01 23:49:51 +02:00

155 lines
4.8 KiB
TypeScript

// Movies library landing page data store.
// Powers the focused movies 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";
import { buildHeroMix } from "$lib/utils/heroMix";
/** A single "by genre" row: the genre name plus the movies in it. */
export interface GenreRow {
id: string;
name: string;
items: MediaItem[];
}
interface MoviesState {
// Movies the user can resume (continue watching).
continueWatching: MediaItem[];
// Recently added movies in the library.
recentlyAdded: MediaItem[];
// One slider per genre (top genres by movie 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 createMoviesStore() {
const initialState: MoviesState = {
continueWatching: [],
recentlyAdded: [],
genreRows: [],
heroItems: [],
isLoading: false,
error: null,
};
const { subscribe, set, update } = writable<MoviesState>(initialState);
/** Artwork check for hero candidates: needs a backdrop or a primary image. */
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) || !!i.primaryImageTag;
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, latest, surprise] = await Promise.all([
repo.getResumeMovies(SECTION_LIMIT),
repo.getLatestItems(libraryId, SECTION_LIMIT),
// Random pool so the hero rotation changes between visits (SortBy=Random
// shuffles server-side online, and via SQLite RANDOM() offline).
repo
.getItems(libraryId, {
includeItemTypes: ["Movie"],
sortBy: "Random",
recursive: true,
limit: SECTION_LIMIT,
})
.then(r => r.items)
.catch(() => [] as MediaItem[]),
]);
// Mix the hero: in-progress movies first (most personal), then recent
// additions, then random picks from across the library.
const heroItems = buildHeroMix([resume, latest, surprise], hasArt);
update(s => ({
...s,
continueWatching: resume,
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 movie sections";
update(s => ({ ...s, isLoading: false, error: message }));
console.error("Failed to load movie sections:", error);
}
}
/**
* Build one slider per genre, showing the top-rated movies in each. We probe
* a bounded set of genres in parallel, drop empty ones, then keep the genres
* with the most movies (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: ["Movie"],
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 movie genre rows:", e);
}
}
function reset() {
set(initialState);
}
return {
subscribe,
loadSections,
reset,
};
}
export const movies = createMoviesStore();
export const moviesHeroItems = derived(movies, $m => $m.heroItems);
export const isMoviesLoading = derived(movies, $m => $m.isLoading);