Files
jellytau/src/lib/stores/tv.ts
T
dtourolle be907b4945 fix(home): stop Next Up repeating Continue Watching
Jellyfin's /Shows/NextUp defaults EnableResumable=true, which returns a
partially-watched episode as its own series' next up — precisely the
episode /Items/Resume already returns. Home's "Next Episode" row and the
TV landing's Next Up row therefore duplicated Continue Watching card for
card.

build_next_up_endpoint now sends EnableResumable=false, and because
servers predating that parameter ignore it, filterInProgressNextUpItems
also drops any next-up entry whose id appears in the resume list. It is
the mirror of DR-089 and sits beside it: presentation-layer de-duplication
over two lists the frontend already holds. The resume filter still reads
its frontier from the unfiltered Next Up list, so pruning in-progress
entries cannot resurrect a stale resume card.

The code changes were swept into 5e8efa25 by a concurrent `git add -A`;
this carries the remainder — DR-197 / JA-036 / UT-190..192, the
renumbering off the DR-196 collision that commit created, the regenerated
matrix, and the requirement-count guard.

TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191, UT-192
2026-08-16 22:18:06 +02:00

178 lines
5.9 KiB
TypeScript

// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089, DR-197
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import {
filterSupersededResumeItems,
filterInProgressNextUpItems,
} from "./continueWatchingFilter";
/** 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);
/** Artwork check for hero candidates: own/parent backdrop or primary image. */
const hasArt = (i: MediaItem) =>
!!(i.backdropImageTags && i.backdropImageTags.length > 0) ||
!!(i.parentBackdropImageTags && i.parentBackdropImageTags.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, rawNextUp, latest, surprise] = await Promise.all([
repo.getResumeItems(libraryId, SECTION_LIMIT),
repo.getNextUpEpisodes(undefined, 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: ["Series"],
sortBy: "Random",
recursive: true,
limit: SECTION_LIMIT,
})
.then(r => r.items)
.catch(() => [] as MediaItem[]),
]);
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
// Then drop episodes the user has moved past — a stale partial position
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
rawNextUp
);
// And drop from Next Up the episodes that are already under way — those
// are Continue Watching's, or the two rows show the same cards.
const nextUp = filterInProgressNextUpItems(rawNextUp, resume);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
const heroItems = buildHeroMix([continueWatching, nextUp, latest, surprise], hasArt);
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);