A related video played in place is the player's item now: its title and its own watched / playlist / download controls, its progress checkpointed and resumed, marked watched at its end — and its end does not step the queue. Back returns to the opened video, where it was left. The related list: - its marks announce through the shared notice (the Notification Center finds them), join the host's player session (the feed refetches on close), and stay per write, so an earlier answer no longer undoes a later mark still on its way; - every host's state write patches the cached related lists, which are never refetched; - kept for the session (gcTime), a retry asks for the page that failed, a list read so far with nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names itself on hover. Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons are left out. Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the top layer; the help sheet lists the list keys over a layer that holds a list.
184 lines
7.1 KiB
TypeScript
184 lines
7.1 KiB
TypeScript
// Lib/api/feed was barely covered, and it is where a filter turns into a query string.
|
|
// FastAPI IGNORES a query parameter it does not know, so a misspelt or renamed key does not fail —
|
|
// the filter simply stops applying while its chip still shows. Locked two ways: what each filter
|
|
// emits, and that every key emitted is one the backend's feed endpoints actually read.
|
|
import { readFileSync } from "node:fs";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { beacon, req } from "./core";
|
|
import { feedApi, type FeedFilters } from "./feed";
|
|
|
|
vi.mock("./core", () => ({ req: vi.fn(() => Promise.resolve({})), beacon: vi.fn() }));
|
|
|
|
const BASE: FeedFilters = {
|
|
tags: [],
|
|
tagModes: { language: "or", topic: "or", other: "or" },
|
|
q: "",
|
|
sort: "newest",
|
|
scope: "my",
|
|
includeNormal: true,
|
|
includeShorts: false,
|
|
includeLive: false,
|
|
show: "unwatched",
|
|
channelIds: [],
|
|
};
|
|
|
|
const lastUrl = () => new URL(vi.mocked(req).mock.lastCall![0] as string, "http://x");
|
|
const sent = (f: Partial<FeedFilters>, cursor: string | null = null) => {
|
|
void feedApi.feed({ ...BASE, ...f }, cursor, 30);
|
|
return lastUrl().searchParams;
|
|
};
|
|
|
|
beforeEach(() => vi.mocked(req).mockClear());
|
|
|
|
describe("feed filters on the wire", () => {
|
|
it("the defaults: scope, content types, show, library source, sort and page size", () => {
|
|
const p = sent({});
|
|
expect(Object.fromEntries(p)).toEqual({
|
|
scope: "my",
|
|
show_normal: "true",
|
|
include_shorts: "false",
|
|
include_live: "false",
|
|
show: "unwatched",
|
|
library_source: "organic",
|
|
sort: "newest",
|
|
limit: "30",
|
|
});
|
|
});
|
|
|
|
it("repeats tags and channels, and sends only the categories matched as ALL", () => {
|
|
const p = sent({
|
|
tags: [3, 9],
|
|
tagModes: { language: "or", topic: "and", other: "or" },
|
|
channelIds: ["UCa", "UCb"],
|
|
untagged: true,
|
|
q: "jazz",
|
|
});
|
|
expect(p.getAll("tags")).toEqual(["3", "9"]);
|
|
expect(p.getAll("channel_ids")).toEqual(["UCa", "UCb"]);
|
|
expect(p.get("tag_mode_topic")).toBe("and");
|
|
expect(p.has("tag_mode_language") || p.has("tag_mode_other")).toBe(false);
|
|
expect(p.get("untagged")).toBe("true");
|
|
expect(p.get("q")).toBe("jazz");
|
|
});
|
|
|
|
it("a year is a whole-year range and wins over the other date narrowings", () => {
|
|
const p = sent({ year: 2019, maxAgeDays: 7, dateFrom: "2024-01-01", dateTo: "2024-02-01" });
|
|
expect(p.get("published_after")).toBe("2019-01-01");
|
|
expect(p.get("published_before")).toBe("2019-12-31");
|
|
expect(p.has("max_age_days")).toBe(false);
|
|
|
|
const q = sent({ maxAgeDays: 7, dateFrom: "2024-01-01", dateTo: "2024-02-01" });
|
|
expect([q.get("max_age_days"), q.get("published_after"), q.get("published_before")]).toEqual([
|
|
"7",
|
|
"2024-01-01",
|
|
"2024-02-01",
|
|
]);
|
|
});
|
|
|
|
it("a zero-second duration bound is still a bound", () => {
|
|
const p = sent({ minDuration: 0, maxDuration: 60 });
|
|
expect([p.get("min_duration"), p.get("max_duration")]).toEqual(["0", "60"]);
|
|
});
|
|
|
|
it("the shuffle seed travels only with the shuffle sort; the cursor when paging", () => {
|
|
expect(sent({ sort: "shuffle", seed: 42 }).get("seed")).toBe("42");
|
|
expect(sent({ sort: "newest", seed: 42 }).has("seed")).toBe(false);
|
|
expect(sent({}, "CUR").get("cursor")).toBe("CUR");
|
|
});
|
|
|
|
it("the summary and the year list see the same filters, without the paging", () => {
|
|
void feedApi.feedSummary({ ...BASE, tags: [5] });
|
|
expect(lastUrl().pathname).toBe("/api/feed/summary");
|
|
expect(lastUrl().searchParams.getAll("tags")).toEqual(["5"]);
|
|
expect(lastUrl().searchParams.has("limit")).toBe(false);
|
|
void feedApi.feedYears({ ...BASE, year: 2020 });
|
|
expect(lastUrl().pathname).toBe("/api/feed/years");
|
|
});
|
|
|
|
it("every key it sends is a parameter the backend's feed endpoints read", () => {
|
|
const routes = readFileSync(
|
|
new URL("../../../../backend/app/routes/feed.py", import.meta.url),
|
|
"utf-8",
|
|
);
|
|
const block = routes.match(/def _feed_params\(([\s\S]*?)\) -> dict:/);
|
|
expect(block, "routes/feed.py no longer defines _feed_params").toBeTruthy();
|
|
const known = new Set(
|
|
[...(block?.[1] ?? "").matchAll(/^\s{4}(\w+):/gm)].flatMap((m) => (m[1] ? [m[1]] : [])),
|
|
);
|
|
known.add("sort").add("seed").add("cursor").add("limit"); // read by /feed itself
|
|
// Every field set (Required: a new filter field fails tsc here until it has a value, so its key
|
|
// is checked too). `year` wins over the other date filters, so the set goes out once with it
|
|
// and once without.
|
|
const all: Required<FeedFilters> = {
|
|
tags: [1],
|
|
tagModes: { language: "and", topic: "and", other: "and" },
|
|
q: "x",
|
|
sort: "shuffle",
|
|
seed: 1,
|
|
scope: "all",
|
|
includeNormal: false,
|
|
includeShorts: true,
|
|
includeLive: true,
|
|
show: "all",
|
|
librarySource: "search",
|
|
untagged: true,
|
|
channelIds: ["UC"],
|
|
maxAgeDays: 3,
|
|
minDuration: 1,
|
|
maxDuration: 2,
|
|
dateFrom: "2024-01-01",
|
|
dateTo: "2024-01-02",
|
|
year: 2020,
|
|
};
|
|
const everything = [...sent(all, "C").keys(), ...sent({ ...all, year: undefined }, "C").keys()];
|
|
const unknown = [...new Set(everything)].filter((k) => !known.has(k));
|
|
expect(unknown).toEqual([]);
|
|
expect(known.has("tag_mode_topic")).toBe(true); // the parse found the real list
|
|
});
|
|
});
|
|
|
|
describe("watch state and progress", () => {
|
|
it("progress is saved in whole seconds, and the unload beacon escapes the id", () => {
|
|
void feedApi.saveProgress("abc", 61.9, 600.4);
|
|
const [url, init, opts] = vi.mocked(req).mock.lastCall!;
|
|
expect(url).toBe("/api/videos/abc/progress");
|
|
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
|
|
position_seconds: 61,
|
|
duration_seconds: 600,
|
|
});
|
|
expect(opts).toEqual({ idempotent: true });
|
|
|
|
feedApi.saveProgressBeacon("a/b", 10.5, 20.5);
|
|
expect(vi.mocked(beacon)).toHaveBeenLastCalledWith("/api/videos/a%2Fb/progress", {
|
|
position_seconds: 10,
|
|
duration_seconds: 20,
|
|
});
|
|
});
|
|
|
|
it("the live search is quiet, so its panel can show a quota error inline", () => {
|
|
void feedApi.searchYoutube("cats & dogs", "NEXT", 10);
|
|
const [url, , opts] = vi.mocked(req).mock.lastCall!;
|
|
const u = new URL(url as string, "http://x");
|
|
expect(u.pathname).toBe("/api/search/youtube");
|
|
expect(Object.fromEntries(u.searchParams)).toEqual({
|
|
q: "cats & dogs",
|
|
cursor: "NEXT",
|
|
limit: "10",
|
|
});
|
|
expect(opts).toEqual({ quiet: true });
|
|
});
|
|
|
|
it("a related list is quiet too, and sends its region and cursor", () => {
|
|
void feedApi.related("a/b", "hu", "CUR1");
|
|
const [url, , opts] = vi.mocked(req).mock.lastCall!;
|
|
const u = new URL(url as string, "http://x");
|
|
expect(u.pathname).toBe("/api/search/related/a%2Fb");
|
|
expect(Object.fromEntries(u.searchParams)).toEqual({ region: "hu", cursor: "CUR1" });
|
|
expect(opts).toEqual({ quiet: true });
|
|
|
|
void feedApi.related("abc", "topical", null);
|
|
const first = new URL(vi.mocked(req).mock.lastCall![0] as string, "http://x");
|
|
expect(Object.fromEntries(first.searchParams)).toEqual({ region: "topical" });
|
|
});
|
|
});
|