Files
jellytau/src/lib/components/library/seriesNavigation.ts
T
dtourolle 1b70926c36
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.

Offline video playback — four separate defects, each of which alone stopped it:

  DR-133  A completed download's file_path is already absolute (the worker
          rewrites it on completion), but the player rooted it a second time and
          handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
  DR-134  The asset protocol was never enabled: no protocol-asset feature and no
          assetProtocol config, so convertFileSrc produced URLs nothing answered.
          Also silently defeated the cached-thumbnail path, which fails soft to
          the server copy and hid it whenever the server was reachable.
  DR-137  Tauri's asset protocol answers a range-less request by reading the
          whole file into memory, and only advertises Accept-Ranges from inside
          its range branch, so the first request never learns ranges exist.
          Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
          now served by a loopback HTTP server: bounded 4 MiB chunks streamed
          from the file handle, every response length-delimited, and a range-less
          request answered with one chunk rather than the file. Confined by a
          per-session token and to the app data directory, because loopback is
          shared between apps on Android.
  DR-138  Release builds set usesCleartextTraffic=false, so Android rejected the
          request to that server before any I/O. A network-security-config
          exempts 127.0.0.1 only; a remote server must still be HTTPS.

Downloads:

  DR-135  download_item never records media_type and the reconnect resolver read
          that NULL as 'audio', so a movie queued from a media card had its URL
          resolved by get_audio_stream_url and completed as an audio-only
          transcode. The item's own type now decides.
  DR-136  Rows already downloaded that way are requeued on reconnect, since
          prevention alone leaves them reading "downloaded" and still unplayable.

Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.

Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
2026-08-09 16:38:07 +02:00

183 lines
6.8 KiB
TypeScript

// Pure navigation/grouping logic for the series detail page.
//
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the
// series Play button used to resolve `$libraryItems[0]` — the first *season* by
// SortName — and navigate to `/player/<seasonId>`, which the player route
// bounced back to `/library/<seasonId>`. Play on a series therefore played
// nothing and landed on the season-1 page.
//
// Note what is NOT here: *which* episode is current. That is domain policy and
// lives in Rust (`repository_get_series_current_episode`); this module only
// renders and routes around the answer.
//
// TRACES: UR-062 | DR-102, DR-103, DR-142
import type { MediaItem } from "$lib/api/types";
export interface SeasonData {
season: MediaItem;
episodes: MediaItem[];
}
/** Jellyfin files specials under season 0. */
const SPECIALS_SEASON = 0;
/** Sort key for a season number: specials come after every numbered season. */
function seasonRank(seasonNumber: number | null | undefined): number {
if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1;
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber;
}
/**
* The in-page anchor for a season, so a season link scrolls the series' single
* continuous episode list instead of opening a page of its own.
*/
export function seasonAnchorId(seasonNumber: number | null | undefined): string {
return `season-${seasonNumber ?? 0}`;
}
/**
* Where a link naming a season should actually go: the series, anchored at that
* season. Returns `null` when the season carries no `seriesId` (a deep link into
* a stale cache), in which case the caller must keep rendering something rather
* than strand the user.
*/
export function seasonRedirectTarget(season: MediaItem): string | null {
if (!season.seriesId) return null;
const seasonNumber = season.indexNumber ?? season.parentIndexNumber;
return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`;
}
/**
* Where an episode link should go: the episode in the context of its series
* (ux-flows §5B.1 — an episode is never browsed as a bare Episode page).
* Falls back to the bare item page only when the series is unknown.
*/
export function episodeFocusHref(episode: MediaItem): string {
if (!episode.seriesId) return `/library/${episode.id}`;
return `/library/${episode.seriesId}?episode=${episode.id}`;
}
/**
* Where a bare `/library/<episodeId>` should actually land — the same rule
* seasons follow (DR-103). An episode is never a page of its own, so a deep
* link, a stale bookmark, or any caller that missed `episodeFocusHref` is
* redirected into the series' Episode Focus View.
*
* Returns `null` for an episode with no `seriesId` (a deep link into a stale
* cache): there is nothing to redirect *to*, so the caller renders the Focus
* View series-less rather than stranding the user (ux-flows §5B.1).
*/
export function episodeRedirectTarget(episode: MediaItem): string | null {
if (!episode.seriesId) return null;
return episodeFocusHref(episode);
}
/**
* Where the series hero button goes.
*
* The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a
* *container* navigation and Play on a *leaf* the commitment. Returns `null`
* when there is no current episode (an empty series), so the caller can hide
* the button rather than link nowhere.
*/
export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null {
if (!current) return null;
return `/library/${seriesId}?episode=${current.id}`;
}
/** Fraction of an episode already watched, 0 when unknown. */
function progressFraction(episode: MediaItem): number {
const position = episode.userData?.playbackPositionMs ?? 0;
if (!episode.durationMs || position <= 0) return 0;
return position / episode.durationMs;
}
/**
* Label for the series hero button — it names the episode it will open, so the
* viewer knows where the button leads before pressing it.
*/
export function seriesPlayLabel(current: MediaItem | null): string {
if (!current) return "Play";
const fraction = progressFraction(current);
const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play";
if (current.parentIndexNumber == null || current.indexNumber == null) return verb;
return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`;
}
/**
* Group a series' episodes under its season headers.
*
* The episodes arrive from Rust already in series order; this only decides which
* header each one renders beneath, and synthesizes a header for any season the
* server did not return one for (a flat series, or a season fetch that failed).
* Seasons with no episodes are dropped — an empty accordion row is noise.
*/
export function groupEpisodesBySeason(
seasons: MediaItem[],
episodes: MediaItem[]
): SeasonData[] {
const headerFor = new Map<number, MediaItem>();
for (const season of seasons) {
const number = season.indexNumber ?? season.parentIndexNumber;
if (number != null && !headerFor.has(number)) headerFor.set(number, season);
}
const grouped = new Map<number, MediaItem[]>();
for (const episode of episodes) {
const number = episode.parentIndexNumber ?? 1;
const bucket = grouped.get(number);
if (bucket) bucket.push(episode);
else grouped.set(number, [episode]);
}
return [...grouped.entries()]
.sort(([a], [b]) => seasonRank(a) - seasonRank(b))
.map(([number, seasonEpisodes]) => ({
season:
headerFor.get(number) ??
({
...seasonEpisodes[0],
id: `synthetic-season-${number}`,
kind: "season",
indexNumber: number,
name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`,
overview: null,
} as MediaItem),
episodes: seasonEpisodes,
}));
}
/**
* Which seasons start expanded.
*
* Only the one the viewer is in. A ten-season show otherwise renders every
* episode of every season at once, burying the one episode they came for. A
* `?episode=` deep link expands that episode's season as well, and a show with
* no resolved current episode falls back to its first season so the page is
* never entirely collapsed.
*
* Returns season ids (not numbers) so the caller can key state per section,
* including the synthesized headers.
*/
export function initialExpandedSeasons(
seasons: SeasonData[],
currentEpisodeId: string | null | undefined,
focusedEpisodeId?: string | null
): Set<string> {
if (seasons.length === 0) return new Set();
const expanded = new Set<string>();
for (const id of [currentEpisodeId, focusedEpisodeId]) {
if (!id) continue;
const owner = seasons.find((s) => s.episodes.some((e) => e.id === id));
if (owner) expanded.add(owner.season.id);
}
// Nothing matched — open the first season rather than nothing at all.
if (expanded.size === 0) expanded.add(seasons[0].season.id);
return expanded;
}