🏗️ 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.
211 lines
7.3 KiB
TypeScript
211 lines
7.3 KiB
TypeScript
// TRACES: UR-062 | DR-102, DR-103, DR-142 | UT-136
|
|
import { describe, it, expect } from "vitest";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import {
|
|
seasonAnchorId,
|
|
seasonRedirectTarget,
|
|
episodeFocusHref,
|
|
episodeRedirectTarget,
|
|
seriesPlayHref,
|
|
seriesPlayLabel,
|
|
groupEpisodesBySeason,
|
|
initialExpandedSeasons,
|
|
} from "./seriesNavigation";
|
|
|
|
const SERIES = "series-1";
|
|
|
|
function ep(id: string, season: number | null, number: number | null): MediaItem {
|
|
return {
|
|
id,
|
|
name: `S${season}E${number}`,
|
|
kind: "episode",
|
|
seriesId: SERIES,
|
|
parentIndexNumber: season,
|
|
indexNumber: number,
|
|
durationMs: 1_000_000,
|
|
} as unknown as MediaItem;
|
|
}
|
|
|
|
function seasonHeader(number: number, id = `season-${number}`): MediaItem {
|
|
return {
|
|
id,
|
|
name: `Season ${number}`,
|
|
kind: "season",
|
|
seriesId: SERIES,
|
|
indexNumber: number,
|
|
} as unknown as MediaItem;
|
|
}
|
|
|
|
function withProgress(episode: MediaItem, fraction: number): MediaItem {
|
|
return {
|
|
...episode,
|
|
userData: { playbackPositionMs: (episode.durationMs ?? 0) * fraction },
|
|
} as MediaItem;
|
|
}
|
|
|
|
describe("seriesPlayHref", () => {
|
|
// The reported bug: Play resolved the first *season* child and navigated to
|
|
// /player/<seasonId>, which bounced back to the season-1 page.
|
|
it("opens the current episode's focus view, never a season or the player", () => {
|
|
const href = seriesPlayHref(SERIES, ep("s2e4", 2, 4));
|
|
expect(href).toBe("/library/series-1?episode=s2e4");
|
|
expect(href).not.toContain("/player/");
|
|
});
|
|
|
|
it("returns null for a series with no episodes so the button can hide", () => {
|
|
expect(seriesPlayHref(SERIES, null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("seriesPlayLabel", () => {
|
|
it("names the episode it will open", () => {
|
|
expect(seriesPlayLabel(ep("s2e4", 2, 4))).toBe("Play S2E4");
|
|
});
|
|
|
|
it("says Resume for a part-watched episode", () => {
|
|
expect(seriesPlayLabel(withProgress(ep("s2e4", 2, 4), 0.4))).toBe("Resume S2E4");
|
|
});
|
|
|
|
it("says Play for a barely-started or nearly-finished episode", () => {
|
|
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.001))).toBe("Play S1E1");
|
|
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.99))).toBe("Play S1E1");
|
|
});
|
|
|
|
it("degrades to a bare verb when the numbering is unknown", () => {
|
|
expect(seriesPlayLabel(ep("x", null, null))).toBe("Play");
|
|
expect(seriesPlayLabel(null)).toBe("Play");
|
|
});
|
|
});
|
|
|
|
describe("seasonRedirectTarget", () => {
|
|
it("sends a season to its series, anchored at that season", () => {
|
|
expect(seasonRedirectTarget(seasonHeader(3))).toBe("/library/series-1#season-3");
|
|
});
|
|
|
|
it("returns null when the series is unknown, so the caller can fall back", () => {
|
|
const orphan = { ...seasonHeader(3), seriesId: undefined } as MediaItem;
|
|
expect(seasonRedirectTarget(orphan)).toBeNull();
|
|
});
|
|
|
|
it("matches the anchor the season section renders", () => {
|
|
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
|
|
`/library/${SERIES}#${seasonAnchorId(2)}`
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("episodeFocusHref", () => {
|
|
it("opens an episode inside its series (never a bare episode page)", () => {
|
|
expect(episodeFocusHref(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
|
|
});
|
|
|
|
it("falls back to the bare item page when the series is unknown", () => {
|
|
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
|
|
expect(episodeFocusHref(orphan)).toBe("/library/lone");
|
|
});
|
|
});
|
|
|
|
describe("episodeRedirectTarget", () => {
|
|
it("sends a bare episode page to the episode inside its series", () => {
|
|
expect(episodeRedirectTarget(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
|
|
});
|
|
|
|
it("does not redirect an episode that has no series to fall back on", () => {
|
|
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
|
|
expect(episodeRedirectTarget(orphan)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("groupEpisodesBySeason", () => {
|
|
it("groups episodes under their season headers, in season order", () => {
|
|
const seasons = [seasonHeader(2), seasonHeader(1)];
|
|
const episodes = [ep("s1e1", 1, 1), ep("s1e2", 1, 2), ep("s2e1", 2, 1)];
|
|
|
|
const grouped = groupEpisodesBySeason(seasons, episodes);
|
|
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 2]);
|
|
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["s1e1", "s1e2"]);
|
|
expect(grouped[1].episodes.map((e) => e.id)).toEqual(["s2e1"]);
|
|
});
|
|
|
|
it("puts specials after the numbered seasons", () => {
|
|
const grouped = groupEpisodesBySeason(
|
|
[seasonHeader(0), seasonHeader(1)],
|
|
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
|
|
);
|
|
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
|
|
});
|
|
|
|
// A flat series: episodes hang off the series, no season folders exist.
|
|
it("synthesizes headers when the server returned no seasons", () => {
|
|
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
|
|
expect(grouped.map((g) => g.season.name)).toEqual(["Season 1", "Season 2"]);
|
|
expect(grouped.every((g) => g.season.kind === "season")).toBe(true);
|
|
});
|
|
|
|
it("names a synthesized season 0 'Specials'", () => {
|
|
const grouped = groupEpisodesBySeason([], [ep("s0e1", 0, 1)]);
|
|
expect(grouped[0].season.name).toBe("Specials");
|
|
});
|
|
|
|
it("gives synthesized headers distinct ids so keyed #each blocks are stable", () => {
|
|
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
|
|
const ids = grouped.map((g) => g.season.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
});
|
|
|
|
it("drops seasons that have no episodes", () => {
|
|
const grouped = groupEpisodesBySeason(
|
|
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
|
|
[ep("s2e1", 2, 1)]
|
|
);
|
|
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
|
|
});
|
|
|
|
it("buckets season-less episodes into season 1 rather than losing them", () => {
|
|
const grouped = groupEpisodesBySeason([], [ep("lone", null, 1)]);
|
|
expect(grouped).toHaveLength(1);
|
|
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["lone"]);
|
|
});
|
|
});
|
|
|
|
describe("initialExpandedSeasons", () => {
|
|
const seasons = groupEpisodesBySeason(
|
|
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
|
|
[
|
|
ep("s1e1", 1, 1),
|
|
ep("s2e1", 2, 1),
|
|
ep("s2e2", 2, 2),
|
|
ep("s3e1", 3, 1),
|
|
]
|
|
);
|
|
|
|
it("expands only the season holding the current episode", () => {
|
|
const expanded = initialExpandedSeasons(seasons, "s2e2");
|
|
expect([...expanded]).toEqual(["season-2"]);
|
|
});
|
|
|
|
it("also expands the season of a ?episode= deep link", () => {
|
|
const expanded = initialExpandedSeasons(seasons, "s1e1", "s3e1");
|
|
expect(expanded.has("season-1")).toBe(true);
|
|
expect(expanded.has("season-3")).toBe(true);
|
|
expect(expanded.has("season-2")).toBe(false);
|
|
});
|
|
|
|
it("collapses nothing extra when current and focused share a season", () => {
|
|
const expanded = initialExpandedSeasons(seasons, "s2e1", "s2e2");
|
|
expect([...expanded]).toEqual(["season-2"]);
|
|
});
|
|
|
|
it("falls back to the first season when there is no current episode", () => {
|
|
expect([...initialExpandedSeasons(seasons, null)]).toEqual(["season-1"]);
|
|
});
|
|
|
|
it("falls back to the first season when the current episode is unknown here", () => {
|
|
expect([...initialExpandedSeasons(seasons, "not-in-this-show")]).toEqual(["season-1"]);
|
|
});
|
|
|
|
it("returns nothing for a series with no seasons", () => {
|
|
expect(initialExpandedSeasons([], "s1e1").size).toBe(0);
|
|
});
|
|
});
|