Files
jellytau/src/lib/stores/movies.ts
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

158 lines
4.9 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";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("MoviesStore");
/** 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.imageId;
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 }));
log.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) {
log.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) {
log.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);