🏗️ 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
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.
183 lines
6.8 KiB
TypeScript
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;
|
|
}
|