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
178 lines
5.9 KiB
TypeScript
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);
|