diff --git a/preview/settings.svg b/preview/settings.svg
index f5143bfb..f5d14a18 100644
--- a/preview/settings.svg
+++ b/preview/settings.svg
@@ -199,58 +199,50 @@
- RLY
- Relay debrid streams · proxy media through this machine
+ SHT
+ Adult screenshots · screenshots from adult torrent desc…
↵
toggle
- · off
+ · on
- CFA
- Cloudflare Access · origin request gate · read-only
+ HST
+ Adult history · show adult items in Library and Continu…
+ ↵
+ toggle
- · not configured
+ · off
- RD
- Real-Debrid · active · real-debrid.com
- ↵
- switch ·
- x
- sign out
+ RLY
+ Relay debrid streams · proxy media through this machine
+ ↵
+ toggle
- ✓
- premium · 60d left
+ · off
- TB
- TorBox · torbox.app
- ↵
- switch ·
- x
- sign out ·
- a
- use
+ CFA
+ Cloudflare Access · origin request gate · read-only
- ✓
- pro · 128d left
+ · not configured
- RUT
- RuTracker · rutracker.org
+ RD
+ Real-Debrid · active · real-debrid.com
↵
switch ·
x
@@ -258,23 +250,23 @@
✓
- Signed in as you
+ premium · 60d left
- RCD
- reccd · self-hosted · private se…
- ↵
- edit ·
+ TB
+ TorBox · torbox.app
+ ↵
+ switch ·
x
- clear ·
- i
- import
+ sign out ·
+ a
+ use
✓
- Connected · reccd.local:4100
+ pro · 128d left
╰────────────────────────────────────────────────────────────────────────────╯
↑↓←→
diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx
index 6f136fc3..d3017af4 100644
--- a/scripts/render-previews-impl.tsx
+++ b/scripts/render-previews-impl.tsx
@@ -157,6 +157,7 @@ function makeStore(
omdbApiKey: "",
adultEnabled: false,
adultScreenshots: true,
+ adultHistoryVisible: false,
streamActive: false,
castStatus: null,
debridStatus: null,
@@ -447,6 +448,7 @@ save(
onToggleAdult={() => {}}
onToggleProxy={() => {}}
onToggleAdultScreenshots={() => {}}
+ onToggleAdultHistoryVisible={() => {}}
/>
diff --git a/src/config/config.test.ts b/src/config/config.test.ts
index b75e58b7..d24d7ca0 100644
--- a/src/config/config.test.ts
+++ b/src/config/config.test.ts
@@ -8,6 +8,7 @@ import {
resolveReccConfig,
resolveAdultContent,
resolveAdultScreenshots,
+ resolveAdultHistoryVisible,
defaultConfig,
resolveActiveDebrid,
resolveDebridTokenFor,
@@ -220,6 +221,32 @@ describe("sanitiseSettingsPatch — adultScreenshots", () => {
});
});
+describe("resolveAdultHistoryVisible", () => {
+ it("defaults to OFF when absent and honours an explicit true", () => {
+ expect(resolveAdultHistoryVisible({ downloadDir: "/d", trackers: [] })).toBe(false);
+ expect(
+ resolveAdultHistoryVisible({ downloadDir: "/d", trackers: [], adultHistoryVisible: false }),
+ ).toBe(false);
+ expect(
+ resolveAdultHistoryVisible({ downloadDir: "/d", trackers: [], adultHistoryVisible: true }),
+ ).toBe(true);
+ });
+});
+
+describe("sanitiseSettingsPatch — adultHistoryVisible", () => {
+ it("coerces to a strict boolean", () => {
+ expect(sanitiseSettingsPatch({ adultHistoryVisible: false })).toEqual({
+ adultHistoryVisible: false,
+ });
+ expect(sanitiseSettingsPatch({ adultHistoryVisible: true })).toEqual({
+ adultHistoryVisible: true,
+ });
+ expect(sanitiseSettingsPatch({ adultHistoryVisible: "yes" })).toEqual({
+ adultHistoryVisible: false,
+ });
+ });
+});
+
describe("config vpnInterface", () => {
it("round-trips the VPN kill-switch interface", async () => {
await saveConfig({ downloadDir: "/tmp/dl", trackers: [], vpnInterface: "tun0" });
diff --git a/src/config/config.ts b/src/config/config.ts
index 8c10bd66..9b698b84 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -95,6 +95,12 @@ export interface Config {
// Screenshots pulled from adult torrent descriptions in the preview. Absent =
// ON (adult content is already an explicit opt-in); false turns them off.
adultScreenshots?: boolean;
+ // Whether adult ("Porn") items appear in the Library and Continue Watching
+ // lists. Absent/false = OFF (hidden), independent of adultContent (which
+ // gates search-time results) — a user can search adult content without
+ // wanting it mixed into their watch history, and items saved before this
+ // setting existed had no way to be hidden at all.
+ adultHistoryVisible?: boolean;
// Remembered UI preferences, so torlink reopens the way you left it. Stored
// as opaque strings validated by the UI layer (parseSort/parseSection) so a
// hand-edited or stale value degrades gracefully to the default.
@@ -263,6 +269,7 @@ export interface RawSettingsPatch {
mediaPlayer?: unknown;
adultContent?: unknown;
adultScreenshots?: unknown;
+ adultHistoryVisible?: unknown;
proxyDebridStreams?: unknown;
downloadLimitKbps?: unknown;
uploadLimitKbps?: unknown;
@@ -302,6 +309,8 @@ export function sanitiseSettingsPatch(raw: RawSettingsPatch): Partial {
}
if (raw.adultContent !== undefined) out.adultContent = raw.adultContent === true;
if (raw.adultScreenshots !== undefined) out.adultScreenshots = raw.adultScreenshots === true;
+ if (raw.adultHistoryVisible !== undefined)
+ out.adultHistoryVisible = raw.adultHistoryVisible === true;
if (raw.proxyDebridStreams !== undefined) out.proxyDebridStreams = raw.proxyDebridStreams === true;
if (raw.downloadLimitKbps !== undefined) out.downloadLimitKbps = positiveInt(raw.downloadLimitKbps);
if (raw.uploadLimitKbps !== undefined) out.uploadLimitKbps = positiveInt(raw.uploadLimitKbps);
@@ -426,6 +435,16 @@ export function resolveAdultScreenshots(config: Config): boolean {
return config.adultScreenshots !== false;
}
+// Whether adult items show in Library/Continue Watching. Default OFF: unlike
+// adultScreenshots (which only ever appears once adultContent is already an
+// explicit opt-in), history items can predate the user ever having touched
+// adultContent, so this must not surprise anyone browsing history for the
+// first time after this feature ships. No env override: a plain preference,
+// not host config.
+export function resolveAdultHistoryVisible(config: Config): boolean {
+ return config.adultHistoryVisible === true;
+}
+
const RECC_URL_ENV = "TORLINK_RECC_URL";
const RECC_TOKEN_ENV = "TORLINK_RECC_TOKEN";
diff --git a/src/sources/registry.test.ts b/src/sources/registry.test.ts
index c7b052d4..eb065c17 100644
--- a/src/sources/registry.test.ts
+++ b/src/sources/registry.test.ts
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest";
-import { SOURCES, enabledSources, sourcesByGroup, toggleDisabledSource } from "./registry";
+import {
+ SOURCES,
+ enabledSources,
+ sourcesByGroup,
+ toggleDisabledSource,
+ categoryForSource,
+ isAdultSource,
+ visibleWithAdultHistory,
+ nonEmptyCategories,
+} from "./registry";
// Adult sources are hidden unless the adult flag is passed, so the default
// enabled set is the non-adult subset.
@@ -60,6 +69,69 @@ describe("toggleDisabledSource", () => {
});
});
+describe("categoryForSource", () => {
+ it("returns the source's first group", () => {
+ expect(categoryForSource("yts")).toBe("Movies");
+ expect(categoryForSource("tpb-porn")).toBe("Porn");
+ // bittorrented has multiple groups; the first is the documented pick.
+ expect(categoryForSource("bittorrented")).toBe("Movies");
+ });
+
+ it("returns Unknown for an unrecognised or missing source id", () => {
+ expect(categoryForSource("not-a-source" as never)).toBe("Unknown");
+ expect(categoryForSource(undefined)).toBe("Unknown");
+ });
+});
+
+describe("isAdultSource", () => {
+ it("is true only for adult sources", () => {
+ expect(isAdultSource("tpb-porn")).toBe(true);
+ expect(isAdultSource("x1337-porn")).toBe(true);
+ expect(isAdultSource("yts")).toBe(false);
+ });
+
+ it("treats an unrecognised or missing source id as non-adult", () => {
+ expect(isAdultSource("not-a-source" as never)).toBe(false);
+ expect(isAdultSource(undefined)).toBe(false);
+ });
+});
+
+describe("visibleWithAdultHistory", () => {
+ const items = [{ source: "yts" as const }, { source: "tpb-porn" as const }, { source: undefined }];
+
+ it("drops adult items when the flag is off", () => {
+ expect(visibleWithAdultHistory(items, false)).toEqual([
+ { source: "yts" },
+ { source: undefined },
+ ]);
+ });
+
+ it("keeps every item when the flag is on", () => {
+ expect(visibleWithAdultHistory(items, true)).toEqual(items);
+ });
+
+ it("does not mutate the input", () => {
+ const input = [...items];
+ visibleWithAdultHistory(input, false);
+ expect(input).toEqual(items);
+ });
+});
+
+describe("nonEmptyCategories", () => {
+ it("always keeps All first, even with no items", () => {
+ expect(nonEmptyCategories([])).toEqual(["All"]);
+ });
+
+ it("keeps only categories that are present, in fixed order", () => {
+ expect(nonEmptyCategories(["Porn", "TV", "TV", "Movies"])).toEqual([
+ "All",
+ "Movies",
+ "TV",
+ "Porn",
+ ]);
+ });
+});
+
describe("RuTracker sources", () => {
it("includes the six RuTracker sources", () => {
const ids = SOURCES.map((s) => s.id);
diff --git a/src/sources/registry.ts b/src/sources/registry.ts
index d3148fe2..948aa165 100644
--- a/src/sources/registry.ts
+++ b/src/sources/registry.ts
@@ -70,6 +70,44 @@ export function toggleDisabledSource(
return disabled.includes(id) ? disabled.filter((d) => d !== id) : [...disabled, id];
}
+// The category tab a saved/history item belongs to, from its stored source id.
+// Deliberately does NOT fall back to DEFAULT_SOURCE's group like getSource()
+// does: an unrecognised or absent id (a deleted/renamed source, or a legacy
+// hand-pasted magnet with no source at all) must land in the always-visible
+// "Unknown" bucket rather than being silently misfiled into a real tab.
+export function categoryForSource(id: SourceId | undefined): SourceGroup | "Unknown" {
+ if (id === undefined) return "Unknown";
+ return SOURCES.find((s) => s.id === id)?.groups?.[0] ?? "Unknown";
+}
+
+// Whether a source id is one of the adult ("Porn") sources. An unrecognised or
+// absent id answers false, for the same reason categoryForSource answers
+// "Unknown" rather than guessing.
+export function isAdultSource(id: SourceId | undefined): boolean {
+ if (id === undefined) return false;
+ return SOURCES.find((s) => s.id === id)?.adult === true;
+}
+
+// Drops adult items unless the setting says show them. The single choke point
+// both the web routes and the TUI filter saved/history items through, so an
+// adult item can never reach either front end by a path that forgot to check.
+export function visibleWithAdultHistory(
+ items: readonly T[],
+ adultHistoryVisible: boolean,
+): T[] {
+ return adultHistoryVisible ? [...items] : items.filter((i) => !isAdultSource(i.source));
+}
+
+const CATEGORY_ORDER = ["All", "Games", "Movies", "TV", "Anime", "Music", "Books", "Porn", "Unknown"];
+
+// Which category tabs actually have at least one item, in a fixed display
+// order. "All" is always present, even with zero items, so there is always
+// somewhere to land.
+export function nonEmptyCategories(categories: readonly string[]): string[] {
+ const present = new Set(categories);
+ return CATEGORY_ORDER.filter((c) => c === "All" || present.has(c));
+}
+
// "Porn" is kept last and only surfaced when adult content is enabled.
const GROUP_ORDER: readonly SourceGroup[] = ["Games", "Movies", "TV", "Anime", "Music", "Books"];
const ADULT_GROUP_ORDER: readonly SourceGroup[] = [...GROUP_ORDER, "Porn"];
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index c9f75add..b65ebc79 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -14,6 +14,7 @@ import {
resolveOmdbApiKey,
resolveAdultContent,
resolveAdultScreenshots,
+ resolveAdultHistoryVisible,
resolveCloudflareAccess,
isCloudflareAccessHalfConfigured,
qualityPrefsFrom,
@@ -102,7 +103,7 @@ import {
watchedFor,
markWatched,
} from "../util/favouriteList";
-import { toggleDisabledSource } from "../sources/registry";
+import { toggleDisabledSource, visibleWithAdultHistory } from "../sources/registry";
import { Logo } from "./components/Logo";
import { DebridBadge } from "./components/DebridBadge";
import { Sidebar, RAIL_WIDTH } from "./components/Sidebar";
@@ -2259,6 +2260,14 @@ export function App({
setNotice(enabled ? "Adult screenshots enabled." : "Adult screenshots disabled.");
}, [config, persistConfig]);
+ const toggleAdultHistoryVisible = useCallback(() => {
+ setShowHelp(false);
+ // Default is OFF (absent === hidden), so the first toggle turns it ON.
+ const enabled = config?.adultHistoryVisible !== true;
+ persistConfig({ adultHistoryVisible: enabled });
+ setNotice(enabled ? "Adult history enabled." : "Adult history disabled.");
+ }, [config, persistConfig]);
+
// Persist a custom DNS spec and apply it immediately, so the next search uses
// it without a restart. An empty value falls back to the system resolver.
const setDns = useCallback(
@@ -2627,6 +2636,16 @@ export function App({
const store: Store | null = useMemo(() => {
if (!queue || !config) return null;
+ // Adult items are filtered out of Favourites/Continue Watching here, at the
+ // one place both lists reach the Store, rather than in each component —
+ // the same choke-point discipline as enabledSources() for search. Neither
+ // field is read anywhere else EXCEPT Results.tsx's playedIndex (built from
+ // streamHistory for the "▸ Played" marker), which loses that marker for a
+ // hidden adult item too — matching the web UI, whose equivalent marker is
+ // built from the same already-filtered GET /api/saved response.
+ const adultHistoryVisible = resolveAdultHistoryVisible(config);
+ const visibleFavourites = visibleWithAdultHistory(config.favourites ?? [], adultHistoryVisible);
+ const visibleStreamHistory = visibleWithAdultHistory(streamHistory, adultHistoryVisible);
return {
config,
setConfig,
@@ -2638,12 +2657,12 @@ export function App({
searchHistory: config.searchHistory ?? [],
savedSearches: config.savedSearches ?? [],
toggleSavedSearch,
- favourites: config.favourites ?? [],
+ favourites: visibleFavourites,
toggleFavourite,
removeFavourite,
openFavourite,
isFavourited,
- streamHistory,
+ streamHistory: visibleStreamHistory,
openStreamHistory,
removeStreamHistory: removeStreamHistoryEntry,
autoPlayTitle,
@@ -2678,6 +2697,7 @@ export function App({
omdbApiKey: resolveOmdbApiKey(config),
adultEnabled: resolveAdultContent(config),
adultScreenshots: resolveAdultScreenshots(config),
+ adultHistoryVisible: resolveAdultHistoryVisible(config),
streamActive: activeStream !== null,
castStatus,
debridStatus,
@@ -3582,6 +3602,7 @@ export function App({
onToggleAdult={toggleAdult}
onToggleProxy={toggleProxy}
onToggleAdultScreenshots={toggleAdultScreenshots}
+ onToggleAdultHistoryVisible={toggleAdultHistoryVisible}
dnsEnvOverride={process.env["TORLINK_DNS"] !== undefined}
playerEnvOverride={Boolean(process.env["TORLINK_PLAYER"]?.trim())}
adultEnvOverride={process.env["TORLINK_ADULT"] !== undefined}
diff --git a/src/ui/components/ContinueWatching.test.tsx b/src/ui/components/ContinueWatching.test.tsx
index f9430522..21e866b5 100644
--- a/src/ui/components/ContinueWatching.test.tsx
+++ b/src/ui/components/ContinueWatching.test.tsx
@@ -3,6 +3,7 @@ import { render } from "ink-testing-library";
import { ContinueWatching } from "./ContinueWatching";
import { makeTestStore } from "../testHarness";
import type { StreamHistoryItem } from "../../core/streamHistory";
+import type { SourceId } from "../../sources/types";
const item = (over: Partial): StreamHistoryItem => ({
key: "k", title: "Kepler", rawName: "Kepler.S02E04.1080p.WEB-DL",
@@ -109,3 +110,59 @@ describe("ContinueWatching Enter", () => {
expect(sections).toEqual(["all"]);
});
});
+
+describe("ContinueWatching category tabs", () => {
+ it("shows an All tab alongside the one category present, even when every item shares it", async () => {
+ const { lastFrame } = renderWith({
+ streamHistory: [item({ source: "eztv" as SourceId }), item({ key: "k2", source: "eztv" as SourceId })],
+ });
+ await flush();
+ const frame = lastFrame() ?? "";
+ expect(frame).toContain("All");
+ expect(frame).toContain("TV");
+ });
+
+ it("[ and ] cycle tabs and filter the list, wrapping at the ends", async () => {
+ const { stdin, lastFrame } = renderWith({
+ streamHistory: [
+ item({ key: "tv", title: "Kepler", source: "eztv" as SourceId }),
+ item({ key: "movie", title: "Ashfall", type: "movie", season: undefined, episode: undefined, source: "yts" as SourceId }),
+ ],
+ });
+ await flush();
+ // Tab order is All, Movies, TV — see CATEGORY_ORDER in registry.ts.
+ expect(lastFrame()).toContain("All");
+ expect(lastFrame()).toContain("Kepler");
+ expect(lastFrame()).toContain("Ashfall");
+
+ stdin.write("]");
+ await flush();
+ expect(lastFrame()).toContain("Ashfall");
+ expect(lastFrame()).not.toContain("Kepler");
+
+ stdin.write("]");
+ await flush();
+ expect(lastFrame()).toContain("Kepler");
+ expect(lastFrame()).not.toContain("Ashfall");
+
+ // Wraps back to All.
+ stdin.write("]");
+ await flush();
+ expect(lastFrame()).toContain("Kepler");
+ expect(lastFrame()).toContain("Ashfall");
+ });
+
+ it("tabs whatever categories are present in streamHistory, including Porn — adult filtering happens upstream in App.tsx, not here", async () => {
+ // This component has no adult-specific logic: App.tsx filters adult items
+ // out of the Store's streamHistory field before this component ever sees
+ // it (gated by adultHistoryVisible). A Porn item reaching this component at
+ // all means the setting was on, in which case it gets a tab like any other
+ // category — pinned here so a future refactor can't quietly duplicate the
+ // filter (or its absence) at this layer.
+ const { lastFrame } = renderWith({
+ streamHistory: [item({ source: "eztv" as SourceId }), item({ key: "k2", source: "tpb-porn" as SourceId })],
+ });
+ await flush();
+ expect(lastFrame()).toContain("Porn");
+ });
+});
diff --git a/src/ui/components/ContinueWatching.tsx b/src/ui/components/ContinueWatching.tsx
index 591e85f1..7498ffed 100644
--- a/src/ui/components/ContinueWatching.tsx
+++ b/src/ui/components/ContinueWatching.tsx
@@ -6,6 +6,7 @@ import { wrapStep } from "../move";
import { COLOR, GUTTER, ICON } from "../theme";
import { nextEpisode, nextLabel } from "../../core/streamHistory";
import { cleanText, truncate } from "../../util/format";
+import { categoryForSource, nonEmptyCategories } from "../../sources/registry";
export function ContinueWatching() {
const {
@@ -23,14 +24,32 @@ export function ContinueWatching() {
} = useStore();
const focused = region === "content" && section === "continueWatching";
const [cursor, setCursor] = useState(0);
- const clamped = Math.min(cursor, Math.max(0, streamHistory.length - 1));
+ const [category, setCategory] = useState("All");
+
+ // Adult items are already excluded from `streamHistory` upstream (App.tsx,
+ // gated by adultHistoryVisible) when the setting is off, so these tabs never
+ // include "Porn" in that case — same guarantee as the web UI's tab strip.
+ const categories = nonEmptyCategories(streamHistory.map((i) => categoryForSource(i.source)));
+ const shown =
+ category === "All" ? streamHistory : streamHistory.filter((i) => categoryForSource(i.source) === category);
+ const clamped = Math.min(cursor, Math.max(0, shown.length - 1));
+
+ // [ / ] cycle the category tab — see Favourites.tsx, which uses the same pair.
+ const changeCategory = (delta: 1 | -1) => {
+ if (categories.length <= 1) return;
+ const idx = Math.max(0, categories.indexOf(category));
+ setCategory(categories[wrapStep(idx, delta, categories.length)]!);
+ setCursor(0);
+ };
useInput(
(input, key) => {
- if (key.upArrow || input === "k") setCursor(wrapStep(clamped, -1, streamHistory.length));
- else if (key.downArrow || input === "j") setCursor(wrapStep(clamped, 1, streamHistory.length));
+ if (key.upArrow || input === "k") setCursor(wrapStep(clamped, -1, shown.length));
+ else if (key.downArrow || input === "j") setCursor(wrapStep(clamped, 1, shown.length));
+ else if (input === "[") changeCategory(-1);
+ else if (input === "]") changeCategory(1);
else if (key.return) {
- const item = streamHistory[clamped];
+ const item = shown[clamped];
if (!item) return;
const next = nextEpisode(item);
// Null for a film AND for a series watched via a season pack — see
@@ -46,13 +65,13 @@ export function ContinueWatching() {
// episode is known — Enter's next-episode search has no equivalent
// "just play what I was watching" action, and the browser row keeps
// both a plain play and a Play next for exactly that reason.
- const item = streamHistory[clamped];
+ const item = shown[clamped];
if (item) openStreamHistory(item);
} else if (input === "x" && !streamActive) {
- const item = streamHistory[clamped];
+ const item = shown[clamped];
if (item) removeStreamHistory(item.key);
} else if (input === "s") {
- const item = streamHistory[clamped];
+ const item = shown[clamped];
if (item) {
setSection("all");
submitQuery(item.title);
@@ -70,27 +89,40 @@ export function ContinueWatching() {
Stream something and it will show up here.
) : (
- {streamHistory.map((item, index) => {
- const here = focused && index === clamped;
- const next = nextLabel(item);
- return (
-
-
- {here ? ICON.pointer : ""}
-
-
-
- {truncate(cleanText(item.title), nameW)}
-
-
- {next ? (
-
- {next}
-
- ) : null}
+
+ {categories.map((cat, i) => (
+
+
+ {cat}
+
- );
- })}
+ ))}
+
+ {shown.length === 0 ? (
+ Nothing in this category.
+ ) : (
+ shown.map((item, index) => {
+ const here = focused && index === clamped;
+ const next = nextLabel(item);
+ return (
+
+
+ {here ? ICON.pointer : ""}
+
+
+
+ {truncate(cleanText(item.title), nameW)}
+
+
+ {next ? (
+
+ {next}
+
+ ) : null}
+
+ );
+ })
+ )}
)}
diff --git a/src/ui/components/Favourites.test.tsx b/src/ui/components/Favourites.test.tsx
new file mode 100644
index 00000000..fbc67023
--- /dev/null
+++ b/src/ui/components/Favourites.test.tsx
@@ -0,0 +1,126 @@
+import { describe, it, expect, vi } from "vitest";
+import { render } from "ink-testing-library";
+import { Favourites } from "./Favourites";
+import { makeTestStore } from "../testHarness";
+import type { FavouriteItem } from "../../config/config";
+import type { SourceId } from "../../sources/types";
+
+const fav = (over: Partial): FavouriteItem => ({
+ id: "a".repeat(40),
+ name: "Kepler.S02.1080p.WEB-DL",
+ magnet: "magnet:?xt=urn:btih:" + "a".repeat(40),
+ addedAt: 0,
+ ...over,
+});
+
+const flush = async () => {
+ await new Promise((r) => setTimeout(r, 0));
+};
+
+// See ContinueWatching.test.tsx for why the mock reads a mutable holder.
+let store = makeTestStore();
+vi.mock("../store", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, useStore: () => store };
+});
+
+function renderWith(overrides: Parameters[0]) {
+ store = makeTestStore({ region: "content", section: "library", ...overrides });
+ return render();
+}
+
+describe("Favourites Enter/x", () => {
+ it("opens the row under the cursor on Enter", async () => {
+ const opened: string[] = [];
+ const { stdin } = renderWith({
+ favourites: [fav({ id: "b".repeat(40), name: "Kestrel" }), fav({ name: "Kepler" })],
+ openFavourite: (f) => opened.push(f.name),
+ });
+ await flush();
+ stdin.write("\r");
+ await flush();
+ expect(opened).toEqual(["Kestrel"]);
+ });
+
+ it("removes the row under the cursor on x when no stream is active", async () => {
+ const removed: string[] = [];
+ const { stdin } = renderWith({
+ favourites: [fav({})],
+ removeFavourite: (id) => removed.push(id),
+ streamActive: false,
+ });
+ await flush();
+ stdin.write("x");
+ await flush();
+ expect(removed).toEqual(["a".repeat(40)]);
+ });
+
+ it("ignores x while a stream is active, so it doesn't fight the global stop binding", async () => {
+ const removed: string[] = [];
+ const { stdin } = renderWith({
+ favourites: [fav({})],
+ removeFavourite: (id) => removed.push(id),
+ streamActive: true,
+ });
+ await flush();
+ stdin.write("x");
+ await flush();
+ expect(removed).toEqual([]);
+ });
+});
+
+describe("Favourites category tabs", () => {
+ it("shows an All tab alongside the one category present, even when every item shares it", async () => {
+ const { lastFrame } = renderWith({
+ favourites: [fav({ source: "eztv" as SourceId }), fav({ id: "b".repeat(40), source: "eztv" as SourceId })],
+ });
+ await flush();
+ const frame = lastFrame() ?? "";
+ expect(frame).toContain("All");
+ expect(frame).toContain("TV");
+ });
+
+ it("[ and ] cycle tabs and filter the list, wrapping at the ends", async () => {
+ const { stdin, lastFrame } = renderWith({
+ favourites: [
+ fav({ id: "b".repeat(40), name: "Kepler", source: "eztv" as SourceId }),
+ fav({ id: "c".repeat(40), name: "Ashfall", source: "yts" as SourceId }),
+ ],
+ });
+ await flush();
+ // Tab order is All, Movies, TV — see CATEGORY_ORDER in registry.ts.
+ expect(lastFrame()).toContain("All");
+ expect(lastFrame()).toContain("Kepler");
+ expect(lastFrame()).toContain("Ashfall");
+
+ stdin.write("]");
+ await flush();
+ expect(lastFrame()).toContain("Ashfall");
+ expect(lastFrame()).not.toContain("Kepler");
+
+ stdin.write("]");
+ await flush();
+ expect(lastFrame()).toContain("Kepler");
+ expect(lastFrame()).not.toContain("Ashfall");
+
+ // Wraps back to All.
+ stdin.write("]");
+ await flush();
+ expect(lastFrame()).toContain("Kepler");
+ expect(lastFrame()).toContain("Ashfall");
+ });
+
+ it("tabs whatever categories are present in favourites, including Porn — adult filtering happens upstream in App.tsx, not here", async () => {
+ // This component has no adult-specific logic: App.tsx filters adult items
+ // out of the Store's favourites field before this component ever sees it
+ // (gated by adultHistoryVisible). A Porn item reaching this component at
+ // all means the setting was on, in which case it gets a tab like any other
+ // category — pinned here so a future refactor can't quietly duplicate the
+ // filter (or its absence) at this layer.
+ const { lastFrame } = renderWith({
+ favourites: [fav({ source: "eztv" as SourceId }), fav({ id: "b".repeat(40), source: "tpb-porn" as SourceId })],
+ });
+ await flush();
+ expect(lastFrame()).toContain("Porn");
+ });
+});
diff --git a/src/ui/components/Favourites.tsx b/src/ui/components/Favourites.tsx
index 5f59c0c2..2c92daf3 100644
--- a/src/ui/components/Favourites.tsx
+++ b/src/ui/components/Favourites.tsx
@@ -5,27 +5,48 @@ import { Panel } from "./Panel";
import { wrapStep } from "../move";
import { COLOR, GUTTER, ICON, sourceStyle } from "../theme";
import { formatBytes, cleanText, truncate } from "../../util/format";
+import { categoryForSource, nonEmptyCategories } from "../../sources/registry";
export function Favourites() {
const { favourites, removeFavourite, openFavourite, region, section, contentWidth, listRows, streamActive } =
useStore();
const focused = region === "content" && section === "library";
const [cursor, setCursor] = useState(0);
- const clamped = Math.min(cursor, Math.max(0, favourites.length - 1));
+ const [category, setCategory] = useState("All");
+
+ // Adult items are already excluded from `favourites` upstream (App.tsx,
+ // gated by adultHistoryVisible) when the setting is off, so these tabs never
+ // include "Porn" in that case — same guarantee as the web UI's tab strip.
+ const categories = nonEmptyCategories(favourites.map((f) => categoryForSource(f.source)));
+ const shown =
+ category === "All" ? favourites : favourites.filter((f) => categoryForSource(f.source) === category);
+ const clamped = Math.min(cursor, Math.max(0, shown.length - 1));
+
+ // [ / ] cycle the category tab — the same key pair works for ContinueWatching,
+ // chosen because neither pane's existing bindings (arrows/j/k, Return, x) use
+ // it. Resets the cursor since the list under it just changed.
+ const changeCategory = (delta: 1 | -1) => {
+ if (categories.length <= 1) return;
+ const idx = Math.max(0, categories.indexOf(category));
+ setCategory(categories[wrapStep(idx, delta, categories.length)]!);
+ setCursor(0);
+ };
useInput(
(input, key) => {
- if (key.upArrow || input === "k") setCursor(wrapStep(clamped, -1, favourites.length));
- else if (key.downArrow || input === "j") setCursor(wrapStep(clamped, 1, favourites.length));
+ if (key.upArrow || input === "k") setCursor(wrapStep(clamped, -1, shown.length));
+ else if (key.downArrow || input === "j") setCursor(wrapStep(clamped, 1, shown.length));
+ else if (input === "[") changeCategory(-1);
+ else if (input === "]") changeCategory(1);
else if (key.return) {
- const fav = favourites[clamped];
+ const fav = shown[clamped];
if (fav) openFavourite(fav);
} else if (input === "x" && !streamActive) {
// "x" is reserved globally for stopping an active stream (App.tsx); Ink
// fans a keypress out to every live useInput handler rather than
// cascading, so without this guard the same "x" would both stop the
// stream and delete this row. Skip our own "x" while one is live.
- const fav = favourites[clamped];
+ const fav = shown[clamped];
if (fav) removeFavourite(fav.id);
}
},
@@ -40,37 +61,50 @@ export function Favourites() {
Favourite a series with b from the stream file list.
) : (
- {favourites.map((fav, index) => {
- const here = focused && index === clamped;
- const ss = sourceStyle(fav.source);
- return (
-
-
- {here ? ICON.pointer : ""}
-
-
-
- {truncate(cleanText(fav.name), nameW)}
-
-
- {fav.watched?.length ? (
-
- {`${fav.watched.length} watched`}
+
+ {categories.map((cat, i) => (
+
+
+ {cat}
+
+
+ ))}
+
+ {shown.length === 0 ? (
+ Nothing in this category.
+ ) : (
+ shown.map((fav, index) => {
+ const here = focused && index === clamped;
+ const ss = sourceStyle(fav.source);
+ return (
+
+
+ {here ? ICON.pointer : ""}
+
+
+
+ {truncate(cleanText(fav.name), nameW)}
+
- ) : null}
- {fav.sizeBytes && fav.sizeBytes > 0 ? (
-
- {formatBytes(fav.sizeBytes)}
+ {fav.watched?.length ? (
+
+ {`${fav.watched.length} watched`}
+
+ ) : null}
+ {fav.sizeBytes && fav.sizeBytes > 0 ? (
+
+ {formatBytes(fav.sizeBytes)}
+
+ ) : null}
+
+
+ {ss.tag}
+
- ) : null}
-
-
- {ss.tag}
-
-
- );
- })}
+ );
+ })
+ )}
)}
diff --git a/src/ui/components/Settings.test.tsx b/src/ui/components/Settings.test.tsx
index a1b724fb..259b3bf4 100644
--- a/src/ui/components/Settings.test.tsx
+++ b/src/ui/components/Settings.test.tsx
@@ -6,7 +6,7 @@ import type { ReccStatus } from "../../recc/status";
import type { DebridStatus } from "../../integrations/debrid/types";
import { defaultConfig, type Config } from "../../config/config";
-function storeStub(config: Partial = {}, adultEnabled = false): Store {
+function storeStub(config: Partial = {}, adultEnabled = false, adultHistoryVisible = false): Store {
return {
region: "content",
section: "settings",
@@ -14,8 +14,9 @@ function storeStub(config: Partial = {}, adultEnabled = false): Store {
// the windowing itself is exercised by the Accounts scroll behaviour this
// pane inherited; here we just want to assert on the full list.
contentWidth: 76,
- listRows: 60,
+ listRows: 70,
adultEnabled,
+ adultHistoryVisible,
config: { ...defaultConfig, ...config },
} as unknown as Store;
}
@@ -73,6 +74,7 @@ const baseProps = {
onToggleAdult: noop,
onToggleProxy: noop,
onToggleAdultScreenshots: noop,
+ onToggleAdultHistoryVisible: noop,
dnsEnvOverride: false,
playerEnvOverride: false,
adultEnvOverride: false,
@@ -84,9 +86,14 @@ function props(over: Partial = {}) {
return { ...baseProps, ...over };
}
-function renderSettings(over: Partial = {}, config: Partial = {}, adultEnabled = false) {
+function renderSettings(
+ over: Partial = {},
+ config: Partial = {},
+ adultEnabled = false,
+ adultHistoryVisible = false,
+) {
return render(
-
+
,
);
@@ -107,6 +114,7 @@ describe("Settings", () => {
expect(frame).toContain("Cast host");
expect(frame).toContain("Media player");
expect(frame).toContain("Adult content");
+ expect(frame).toContain("Adult history");
expect(frame).toContain("Relay debrid streams");
});
@@ -174,6 +182,20 @@ describe("Settings", () => {
expect(locked).toContain("env");
});
+ it("shows the adult history toggle state, independent of adult content", () => {
+ // The value renders on the line below the label, so check the pair — not
+ // just "off"/"on" anywhere in the frame, which several other rows also say.
+ const valueAfter = (frame: string, label: string): string | undefined => {
+ const lines = frame.split("\n");
+ const i = lines.findIndex((l) => l.includes(label));
+ return i >= 0 ? lines[i + 1] : undefined;
+ };
+ const off = renderSettings({}, {}, true, false).lastFrame() ?? "";
+ expect(valueAfter(off, "Adult history")).toContain("off");
+ const on = renderSettings({}, {}, false, true).lastFrame() ?? "";
+ expect(valueAfter(on, "Adult history")).toContain("on");
+ });
+
it("shows the media player as auto-detect when unset", () => {
const frame = renderSettings().lastFrame() ?? "";
expect(frame).toContain("auto-detect");
diff --git a/src/ui/components/Settings.tsx b/src/ui/components/Settings.tsx
index 2f1bf529..fade255c 100644
--- a/src/ui/components/Settings.tsx
+++ b/src/ui/components/Settings.tsx
@@ -61,6 +61,7 @@ interface SettingsProps {
onEditCastHost: () => void;
onToggleAdult: () => void;
onToggleAdultScreenshots: () => void;
+ onToggleAdultHistoryVisible: () => void;
onToggleProxy: () => void;
dnsEnvOverride?: boolean;
playerEnvOverride?: boolean;
@@ -103,7 +104,7 @@ function withEnv(value: string, env: boolean | undefined): string {
}
export function Settings(props: SettingsProps) {
- const { region, section, contentWidth, listRows, config, adultEnabled } = useStore();
+ const { region, section, contentWidth, listRows, config, adultEnabled, adultHistoryVisible } = useStore();
const focused = region === "content" && section === "settings";
const [cursor, setCursor] = useState(0);
@@ -217,6 +218,14 @@ export function Settings(props: SettingsProps) {
props.onToggleAdultScreenshots,
"toggle",
),
+ setting(
+ "HST",
+ "Adult history",
+ "show adult items in Library and Continue Watching",
+ adultHistoryVisible ? "on" : "off",
+ props.onToggleAdultHistoryVisible,
+ "toggle",
+ ),
setting(
"RLY",
"Relay debrid streams",
diff --git a/src/ui/store.ts b/src/ui/store.ts
index 01ba4ccb..92ab3511 100644
--- a/src/ui/store.ts
+++ b/src/ui/store.ts
@@ -212,6 +212,9 @@ export interface Store {
// True when adult-result screenshots are enabled (default on). Gates the
// screenshot fetch + half-block render in the search preview pane.
adultScreenshots: boolean;
+ // True when adult items are shown in Library/Continue Watching (default
+ // off). Independent of adultEnabled, which gates search-time results only.
+ adultHistoryVisible: boolean;
// True while a torrent-stream session is live. While true, "x" is reserved
// globally for stopping the stream, so components with their own "x"
// handler (clear history, sign out) must ignore it.
diff --git a/src/ui/testHarness.ts b/src/ui/testHarness.ts
index c73310a5..300ab027 100644
--- a/src/ui/testHarness.ts
+++ b/src/ui/testHarness.ts
@@ -166,6 +166,7 @@ export function makeTestStore(overrides: Partial = {}): Store {
omdbApiKey: "",
adultEnabled: false,
adultScreenshots: true,
+ adultHistoryVisible: false,
sort: "none",
setSort: noop,
disabledSources: [],
diff --git a/src/web/routes.test.ts b/src/web/routes.test.ts
index a76a1ab8..e9e4c9c7 100644
--- a/src/web/routes.test.ts
+++ b/src/web/routes.test.ts
@@ -1567,6 +1567,7 @@ describe("GET /api/settings", () => {
expect(body.settings.downloadDir).toBe("/media/dl");
expect(body.settings.mediaPlayer).toBe("mpv");
expect(body.settings.adultContent).toBe(true);
+ expect(body.settings.adultHistoryVisible).toBe(false);
expect(body.settings.proxyDebridStreams).toBe(true);
expect(body.settings.downloadLimitKbps).toBe(1500);
expect(body.settings.uploadLimitKbps).toBeNull();
@@ -1635,6 +1636,17 @@ describe("POST /api/settings", () => {
expect(out.settings.downloadLimitKbps).toBe(2000);
});
+ it("writes and echoes adultHistoryVisible independently of adultContent", async () => {
+ let saved: Config | null = null;
+ const res = await post(
+ { action: "set", settings: { adultHistoryVisible: true } },
+ { loadConfigImpl: async () => searchConfig(), saveConfigImpl: async (c) => { saved = c; } },
+ );
+ expect(saved!.adultHistoryVisible).toBe(true);
+ expect(saved!.adultContent).toBeUndefined();
+ expect((res.json as SettingsResponse).settings.adultHistoryVisible).toBe(true);
+ });
+
it("clears a limit sent as null", async () => {
let saved: Config | null = null;
await post(
@@ -2696,6 +2708,7 @@ describe("handleWebApi — GET /api/saved", () => {
id: "a".repeat(40),
name: "Kepler.S02.1080p.WEB-DL",
source: "eztv",
+ category: "TV",
sizeBytes: 24_000_000_000,
addedAt: 1_700_000_000_000,
watched: 3,
@@ -2750,7 +2763,13 @@ describe("handleWebApi — GET /api/saved", () => {
"",
);
expect(res.status).toBe(200);
- expect(res.json).toEqual({ savedSearches: [], library: [], continueWatching: [] });
+ expect(res.json).toEqual({
+ savedSearches: [],
+ library: [],
+ continueWatching: [],
+ libraryCategories: ["All"],
+ continueWatchingCategories: ["All"],
+ });
});
it("requires the token when one is configured", async () => {
@@ -2788,7 +2807,7 @@ describe("GET /api/saved — continueWatching", () => {
key: "kepler||series", title: "Kepler", type: "series",
season: 2, episode: 4, next: { season: 2, episode: 5 },
rawName: "Kepler.S02E04.1080p", infoHash: "a".repeat(40),
- startedAt: 1_700_000_000_000,
+ startedAt: 1_700_000_000_000, category: "Unknown",
});
// A season pack names no episode, so there is no honest next to offer.
expect(body.continueWatching[1]?.next).toBeNull();
@@ -2832,6 +2851,59 @@ describe("GET /api/saved — continueWatching", () => {
});
});
+describe("GET /api/saved — adultHistoryVisible filtering", () => {
+ const withAdultData = (adultHistoryVisible?: boolean) =>
+ deps({
+ loadConfigImpl: async () => ({
+ ...defaultConfig,
+ downloadDir: "/tmp/dl",
+ ...(adultHistoryVisible !== undefined ? { adultHistoryVisible } : {}),
+ favourites: [
+ { id: "a".repeat(40), name: "Kepler", magnet: `magnet:?xt=urn:btih:${"a".repeat(40)}`,
+ source: "tpb-porn" as SourceId, addedAt: 1_700_000_000_000 },
+ { id: "b".repeat(40), name: "Ashfall", magnet: `magnet:?xt=urn:btih:${"b".repeat(40)}`,
+ source: "yts" as SourceId, addedAt: 1_600_000_000_000 },
+ ],
+ }),
+ loadStreamHistoryImpl: async () => [
+ { key: "kepler|porn", title: "Kepler", rawName: "Kepler.1080p", infoHash: "c".repeat(40),
+ magnet: `magnet:?xt=urn:btih:${"c".repeat(40)}`, source: "tpb-porn" as SourceId,
+ startedAt: 1_700_000_000_000 },
+ { key: "ashfall|movie", title: "Ashfall", type: "movie" as const, year: 1999,
+ rawName: "Ashfall.1999.1080p", infoHash: "d".repeat(40),
+ magnet: `magnet:?xt=urn:btih:${"d".repeat(40)}`, source: "yts" as SourceId,
+ startedAt: 1_600_000_000_000 },
+ ],
+ });
+
+ it("excludes adult items and the Porn tab entirely when the setting is absent/off", async () => {
+ for (const adultHistoryVisible of [undefined, false] as const) {
+ const res = await handleWebApi(
+ withAdultData(adultHistoryVisible),
+ "GET", "/api/saved", new URLSearchParams(), undefined, "",
+ );
+ const body = res.json as SavedResponse;
+ expect(body.library.map((f) => f.id)).toEqual(["b".repeat(40)]);
+ expect(body.continueWatching.map((c) => c.key)).toEqual(["ashfall|movie"]);
+ expect(body.libraryCategories).not.toContain("Porn");
+ expect(body.continueWatchingCategories).not.toContain("Porn");
+ expect(JSON.stringify(body)).not.toContain("tpb-porn");
+ }
+ });
+
+ it("includes adult items and the Porn tab once the setting is on", async () => {
+ const res = await handleWebApi(
+ withAdultData(true),
+ "GET", "/api/saved", new URLSearchParams(), undefined, "",
+ );
+ const body = res.json as SavedResponse;
+ expect(body.library.map((f) => f.id)).toEqual(["a".repeat(40), "b".repeat(40)]);
+ expect(body.continueWatching.map((c) => c.key)).toEqual(["kepler|porn", "ashfall|movie"]);
+ expect(body.libraryCategories).toEqual(["All", "Movies", "Porn"]);
+ expect(body.continueWatchingCategories).toEqual(["All", "Movies", "Porn"]);
+ });
+});
+
describe("handleWebApi — GET /api/library/downloaded", () => {
it("returns completed-download infoHashes, lower-cased", async () => {
const rt = runtime();
@@ -2892,6 +2964,21 @@ describe("handleWebApi — POST /api/continue-watching", () => {
expect(saved[0]).toHaveLength(1);
});
+ it("keeps an adult item out of the response when the setting is off, even after removing an unrelated item", async () => {
+ const d = deps({
+ loadConfigImpl: async () => ({ ...defaultConfig, downloadDir: "/tmp/dl" }),
+ loadStreamHistoryImpl: async () => [
+ item({ key: "kepler||series", source: "tpb-porn" as SourceId }),
+ item({ key: "harrowgate||series", title: "Harrowgate", source: "yts" as SourceId }),
+ ],
+ saveStreamHistoryImpl: async () => {},
+ });
+ const res = await post(d, { key: "harrowgate||series", action: "remove" });
+ const body = res.json as { continueWatching: PublicStreamHistoryItem[]; continueWatchingCategories: string[] };
+ expect(body.continueWatching).toEqual([]);
+ expect(body.continueWatchingCategories).toEqual(["All"]);
+ });
+
it("is idempotent — removing a key that is not there changes nothing", async () => {
const d = deps({
loadStreamHistoryImpl: async () => [item()],
@@ -3098,6 +3185,19 @@ describe("handleWebApi — POST /api/library", () => {
expect(stored?.magnet).toContain("tr=");
});
+ it("excludes a pre-existing adult favourite from the response when adultHistoryVisible is off", async () => {
+ const { deps: d } = capture({
+ favourites: [fav({ id: HASH, source: "tpb-porn" as SourceId })],
+ });
+ // Toggling a DIFFERENT (non-adult) favourite must not resurrect the
+ // pre-existing adult one into the response.
+ const other = "c".repeat(40);
+ const res = await post(d, { infoHash: other, name: "Ashfall", source: "yts", action: "toggle" });
+ const body = res.json as LibraryResponse;
+ expect(body.library.map((f) => f.id)).toEqual([other]);
+ expect(body.libraryCategories).not.toContain("Porn");
+ });
+
it("omits a zero sizeBytes rather than storing it as a known-and-empty size", async () => {
const { deps: d, saved } = capture();
await post(d, { infoHash: HASH, name: "Kepler", sizeBytes: 0, action: "toggle" });
@@ -3353,6 +3453,17 @@ describe("handleWebApi — POST /api/library", () => {
expect(absent.saved).toHaveLength(0);
});
+ it("excludes an adult favourite from the watched-branch response when adultHistoryVisible is off", async () => {
+ const adultHash = "c".repeat(40);
+ const { deps: d } = capture({
+ favourites: [fav({ source: "yts" as SourceId }), fav({ id: adultHash, source: "tpb-porn" as SourceId })],
+ });
+ const res = await post(d, { infoHash: HASH, name: "Kepler", action: "watched", filename: "ep1.mkv" });
+ const body = res.json as LibraryResponse;
+ expect(body.library.map((f) => f.id)).toEqual([HASH]);
+ expect(body.libraryCategories).not.toContain("Porn");
+ });
+
it("rejects watched without a filename", async () => {
const { deps: d, saved } = capture({ favourites: [fav()] });
const res = await post(d, { infoHash: HASH, name: "Kepler", action: "watched" });
diff --git a/src/web/routes.ts b/src/web/routes.ts
index 83958302..460a45b0 100644
--- a/src/web/routes.ts
+++ b/src/web/routes.ts
@@ -27,6 +27,7 @@ import {
qualityPrefsFrom,
resolveAdultContent,
resolveAdultScreenshots,
+ resolveAdultHistoryVisible,
resolveMediaPlayer,
resolveOmdbApiKey,
resolveOwnerEmail,
@@ -67,7 +68,14 @@ import {
removeFavourite as removeFromFavourites,
toggleFavourite as toggleInFavourites,
} from "../util/favouriteList";
-import { enabledSources, sourcesByGroup, SOURCES } from "../sources/registry";
+import {
+ enabledSources,
+ sourcesByGroup,
+ SOURCES,
+ categoryForSource,
+ visibleWithAdultHistory,
+ nonEmptyCategories,
+} from "../sources/registry";
import { isSkipped, sourceHealth, type Health } from "../sources/sourceHealth";
import { blankPerSource, runSearch, type SearchImpl, type SearchSnapshot } from "../core/search";
import { cachedHashesFor } from "../core/cachedHashes";
@@ -1086,6 +1094,7 @@ export function toPublicFavourite(f: FavouriteItem): PublicFavourite {
name: f.name,
addedAt: f.addedAt,
watched: f.watched?.length ?? 0,
+ category: categoryForSource(f.source),
};
if (f.sizeBytes !== undefined && f.sizeBytes > 0) out.sizeBytes = f.sizeBytes;
if (f.source !== undefined) out.source = f.source;
@@ -1097,6 +1106,7 @@ export function toPublicStreamHistoryItem(item: StreamHistoryItem): PublicStream
const out: PublicStreamHistoryItem = {
key: item.key, title: item.title, rawName: item.rawName,
infoHash: item.infoHash, startedAt: item.startedAt, next: nextEpisode(item),
+ category: categoryForSource(item.source),
};
if (item.year !== undefined) out.year = item.year;
if (item.type !== undefined) out.type = item.type;
@@ -1120,12 +1130,21 @@ async function savedLists(deps: WebDeps, accessEmail?: string): Promise loadStreamHistory(profileId)))();
+ const adultHistoryVisible = resolveAdultHistoryVisible(config);
+ const library = visibleWithAdultHistory(profileFavourites(config, profileId), adultHistoryVisible).map(
+ toPublicFavourite,
+ );
+ const continueWatching = visibleWithAdultHistory(history, adultHistoryVisible).map(
+ toPublicStreamHistoryItem,
+ );
const out: SavedResponse = {
// loadConfig already normalises both (junk dropped, caps applied), so these
// coalesces are for a config object built in a test, not for disk data.
savedSearches: profileSavedSearches(config, profileId),
- library: profileFavourites(config, profileId).map(toPublicFavourite),
- continueWatching: history.map(toPublicStreamHistoryItem),
+ library,
+ continueWatching,
+ libraryCategories: nonEmptyCategories(library.map((f) => f.category)),
+ continueWatchingCategories: nonEmptyCategories(continueWatching.map((c) => c.category)),
};
return { status: 200, json: out };
}
@@ -1210,7 +1229,13 @@ async function continueWatchingAction(
await (deps.saveStreamHistoryImpl ?? ((items) => saveStreamHistory(items, profileId)))(next);
}
- const out: ContinueWatchingResponse = { continueWatching: next.map(toPublicStreamHistoryItem) };
+ const continueWatching = visibleWithAdultHistory(next, resolveAdultHistoryVisible(config)).map(
+ toPublicStreamHistoryItem,
+ );
+ const out: ContinueWatchingResponse = {
+ continueWatching,
+ continueWatchingCategories: nonEmptyCategories(continueWatching.map((c) => c.category)),
+ };
return { status: 200, json: out };
}
@@ -1278,11 +1303,15 @@ async function libraryAction(
} catch {
// A convenience list must never fail a play the user already started.
}
+ const library = visibleWithAdultHistory(favourites, resolveAdultHistoryVisible(config)).map(
+ toPublicFavourite,
+ );
const out: LibraryResponse = {
// Not an error when absent: the browser fires this after a successful
// play and must not be told off for playing something unfavourited.
favourited: isFavourited(favourites, infoHash),
- library: favourites.map(toPublicFavourite),
+ library,
+ libraryCategories: nonEmptyCategories(library.map((f) => f.category)),
};
return { status: 200, json: out };
}
@@ -1330,9 +1359,13 @@ async function libraryAction(
}
}
+ const library = visibleWithAdultHistory(favourites, resolveAdultHistoryVisible(config)).map(
+ toPublicFavourite,
+ );
const out: LibraryResponse = {
favourited: isFavourited(favourites, infoHash),
- library: favourites.map(toPublicFavourite),
+ library,
+ libraryCategories: nonEmptyCategories(library.map((f) => f.category)),
};
return { status: 200, json: out };
}
@@ -1411,6 +1444,7 @@ function toPublicWritableSettings(config: Config): PublicWritableSettings {
mediaPlayer: resolveMediaPlayer(config),
adultContent: resolveAdultContent(config),
adultScreenshots: resolveAdultScreenshots(config),
+ adultHistoryVisible: resolveAdultHistoryVisible(config),
proxyDebridStreams: config.proxyDebridStreams === true,
downloadLimitKbps: config.downloadLimitKbps ?? null,
uploadLimitKbps: config.uploadLimitKbps ?? null,
diff --git a/src/web/static/app.ts b/src/web/static/app.ts
index c5a783a0..9731e77f 100644
--- a/src/web/static/app.ts
+++ b/src/web/static/app.ts
@@ -149,11 +149,13 @@ import {
continueWatchingFallbackQuery,
continueWatchingStatus,
continueWatchingSub,
+ continueWatchingForCategory,
emptySaved,
favouriteLabel,
favouriteMeta,
isInLibrary,
libraryBody,
+ libraryForCategory,
libraryStatus,
libraryToggleNotice,
savedSearchesBody,
@@ -285,8 +287,10 @@ const savedSearchesStatusLine = el("saved-searches-status"
const savedSearchesRows = el("saved-searches-rows");
const libraryStatusLine = el("library-status");
const libraryRows = el("library-rows");
+const libraryTabsBar = el("library-tabs");
const continueStatusLine = el("continue-status");
const continueRows = el("continue-rows");
+const continueTabsBar = el("continue-tabs");
const reccTypeSelect = el("recc-type");
const reccGenreInput = el("recc-genre");
@@ -1335,6 +1339,12 @@ async function saveSettings(patch: Partial): Promise {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "tab";
+ button.textContent = cat;
+ button.dataset.group = cat.toLowerCase();
+ button.setAttribute("aria-pressed", String(cat === savedState.continueWatchingCategory));
+ button.addEventListener("click", () => {
+ if (cat === savedState.continueWatchingCategory) return;
+ savedState = { ...savedState, continueWatchingCategory: cat };
+ renderSaved();
+ });
+ return button;
+ }),
+ );
+}
+
+function renderLibraryTabs(): void {
+ libraryTabsBar.replaceChildren(
+ ...savedState.libraryCategories.map((cat) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "tab";
+ button.textContent = cat;
+ button.dataset.group = cat.toLowerCase();
+ button.setAttribute("aria-pressed", String(cat === savedState.libraryCategory));
+ button.addEventListener("click", () => {
+ if (cat === savedState.libraryCategory) return;
+ savedState = { ...savedState, libraryCategory: cat };
+ renderSaved();
+ });
+ return button;
+ }),
+ );
+}
+
function renderSaved(): void {
- continueRows.replaceChildren(...savedState.continueWatching.map(renderContinueRow));
+ renderContinueTabs();
+ renderLibraryTabs();
+ continueRows.replaceChildren(...continueWatchingForCategory(savedState).map(renderContinueRow));
savedSearchesRows.replaceChildren(...savedState.savedSearches.map(renderSavedSearchRow));
- libraryRows.replaceChildren(...savedState.library.map(renderLibraryRow));
+ libraryRows.replaceChildren(...libraryForCategory(savedState).map(renderLibraryRow));
// Continue-watching and library rows both carry Play buttons.
paintPlayBusy();
diff --git a/src/web/static/index.html b/src/web/static/index.html
index afbd9b6c..cbfb6262 100644
--- a/src/web/static/index.html
+++ b/src/web/static/index.html
@@ -451,6 +451,7 @@ Accounts
continue watching
+
Loading…
@@ -462,6 +463,7 @@ saved searches
diff --git a/src/web/static/pickModel.test.ts b/src/web/static/pickModel.test.ts
index b3cd8ed0..e9989ba7 100644
--- a/src/web/static/pickModel.test.ts
+++ b/src/web/static/pickModel.test.ts
@@ -5,7 +5,7 @@ import type { PickableResult } from "../../util/releasePick";
const row = (over: Partial): PublicStreamHistoryItem => ({
key: "k", title: "Kepler", rawName: "Kepler.S02E04.1080p.WEB-DL",
- infoHash: "abc", startedAt: 0, next: { season: 2, episode: 5 }, ...over,
+ infoHash: "abc", startedAt: 0, next: { season: 2, episode: 5 }, category: "Unknown", ...over,
});
describe("prefs round-trip", () => {
diff --git a/src/web/static/savedModel.test.ts b/src/web/static/savedModel.test.ts
index fb538b36..338aad83 100644
--- a/src/web/static/savedModel.test.ts
+++ b/src/web/static/savedModel.test.ts
@@ -14,6 +14,8 @@ import {
favouriteMeta,
isInLibrary,
libraryBody,
+ libraryForCategory,
+ continueWatchingForCategory,
libraryStatus,
libraryToggleNotice,
relativeAge,
@@ -33,7 +35,14 @@ import { nextEpisode, nextLabel, type StreamHistoryItem } from "../../core/strea
const HASH = "b".repeat(40);
function favourite(over: Partial = {}): PublicFavourite {
- return { id: HASH, name: "Kepler.S02.1080p", addedAt: 1_700_000_000_000, watched: 0, ...over };
+ return {
+ id: HASH,
+ name: "Kepler.S02.1080p",
+ addedAt: 1_700_000_000_000,
+ watched: 0,
+ category: "Unknown",
+ ...over,
+ };
}
function loaded(over: Partial = {}): SavedState {
@@ -50,6 +59,7 @@ const base: PublicStreamHistoryItem = {
rawName: "Kepler.S02E04.1080p",
infoHash: "a".repeat(40),
startedAt: 1_700_000_000_000,
+ category: "Unknown",
};
// Exactly 86,400,000 ms after `base.startedAt`, so "1 day ago" is arithmetic
// rather than a guess about how the formatter rounds.
@@ -63,6 +73,10 @@ describe("emptySaved", () => {
savedSearches: [],
library: [],
continueWatching: [],
+ libraryCategory: "All",
+ continueWatchingCategory: "All",
+ libraryCategories: ["All"],
+ continueWatchingCategories: ["All"],
loaded: false,
error: null,
});
@@ -229,10 +243,14 @@ describe("applySaved", () => {
savedSearches: ["tin rivers"],
library: [favourite()],
continueWatching: [base],
+ libraryCategories: ["All", "TV"],
+ continueWatchingCategories: ["All", "Movies"],
});
expect(next.savedSearches).toEqual(["tin rivers"]);
expect(next.library).toHaveLength(1);
expect(next.continueWatching).toEqual([base]);
+ expect(next.libraryCategories).toEqual(["All", "TV"]);
+ expect(next.continueWatchingCategories).toEqual(["All", "Movies"]);
expect(next.loaded).toBe(true);
expect(next.error).toBeNull();
});
@@ -245,6 +263,10 @@ describe("applySaved", () => {
savedSearches: [],
library: [],
continueWatching: [],
+ libraryCategory: "All",
+ continueWatchingCategory: "All",
+ libraryCategories: ["All"],
+ continueWatchingCategories: ["All"],
loaded: true,
error: null,
});
@@ -256,10 +278,39 @@ describe("applySaved", () => {
savedSearches: [],
library: [],
continueWatching: [],
+ libraryCategory: "All",
+ continueWatchingCategory: "All",
+ libraryCategories: ["All"],
+ continueWatchingCategories: ["All"],
loaded: true,
error: null,
});
});
+
+ it("clamps the active tab back to All when it is no longer in the new category list", () => {
+ const state = loaded({ libraryCategory: "TV", continueWatchingCategory: "Porn" });
+ const next = applySaved(state, {
+ savedSearches: [],
+ library: [],
+ continueWatching: [],
+ libraryCategories: ["All", "Movies"],
+ continueWatchingCategories: ["All"],
+ });
+ expect(next.libraryCategory).toBe("All");
+ expect(next.continueWatchingCategory).toBe("All");
+ });
+
+ it("keeps the active tab when it is still present in the new category list", () => {
+ const state = loaded({ libraryCategory: "TV" });
+ const next = applySaved(state, {
+ savedSearches: [],
+ library: [],
+ continueWatching: [],
+ libraryCategories: ["All", "TV"],
+ continueWatchingCategories: ["All"],
+ });
+ expect(next.libraryCategory).toBe("TV");
+ });
});
describe("applySavedSearchesResponse", () => {
@@ -269,6 +320,10 @@ describe("applySavedSearchesResponse", () => {
savedSearches: ["tin rivers"],
library: [],
continueWatching: [],
+ libraryCategory: "All",
+ continueWatchingCategory: "All",
+ libraryCategories: ["All"],
+ continueWatchingCategories: ["All"],
loaded: true,
error: null,
});
@@ -311,6 +366,13 @@ describe("applyLibraryResponse", () => {
expect(applyLibraryResponse(state, {})).toEqual(state);
expect(applyLibraryResponse(state, { library: "nope" })).toEqual(state);
});
+
+ it("updates libraryCategories and clamps the active tab", () => {
+ const state = loaded({ libraryCategory: "Porn" });
+ const next = applyLibraryResponse(state, { library: [], libraryCategories: ["All", "TV"] });
+ expect(next.libraryCategories).toEqual(["All", "TV"]);
+ expect(next.libraryCategory).toBe("All");
+ });
});
describe("savedSearchesToggleNotice", () => {
@@ -360,6 +422,42 @@ describe("applyContinueWatchingResponse", () => {
expect(applyContinueWatchingResponse(state, {})).toEqual(state);
expect(applyContinueWatchingResponse(state, { continueWatching: "nope" })).toEqual(state);
});
+
+ it("updates continueWatchingCategories and clamps the active tab", () => {
+ const state = loaded({ continueWatchingCategory: "Porn" });
+ const next = applyContinueWatchingResponse(state, {
+ continueWatching: [],
+ continueWatchingCategories: ["All", "Movies"],
+ });
+ expect(next.continueWatchingCategories).toEqual(["All", "Movies"]);
+ expect(next.continueWatchingCategory).toBe("All");
+ });
+});
+
+describe("libraryForCategory / continueWatchingForCategory", () => {
+ const tv = favourite({ id: "c".repeat(40), category: "TV" });
+ const movie = favourite({ id: "d".repeat(40), category: "Movies" });
+ const tvShow = { ...base, key: "tv-show", category: "TV" };
+ const film = { ...base, key: "film", category: "Movies" };
+
+ it("returns every item on the All tab", () => {
+ const state = loaded({ library: [tv, movie], libraryCategory: "All" });
+ expect(libraryForCategory(state)).toEqual([tv, movie]);
+ const cwState = loaded({ continueWatching: [tvShow, film], continueWatchingCategory: "All" });
+ expect(continueWatchingForCategory(cwState)).toEqual([tvShow, film]);
+ });
+
+ it("returns only items matching the active tab", () => {
+ const state = loaded({ library: [tv, movie], libraryCategory: "TV" });
+ expect(libraryForCategory(state)).toEqual([tv]);
+ const cwState = loaded({ continueWatching: [tvShow, film], continueWatchingCategory: "Movies" });
+ expect(continueWatchingForCategory(cwState)).toEqual([film]);
+ });
+
+ it("returns an empty list for a tab with no matching items", () => {
+ const state = loaded({ library: [tv], libraryCategory: "Porn" });
+ expect(libraryForCategory(state)).toEqual([]);
+ });
});
describe("relativeAge", () => {
@@ -502,6 +600,7 @@ describe("continueWatchingGroup", () => {
rawName: "Harrowgate.S03.1080p.WEB-DL",
infoHash: "a".repeat(40),
startedAt: 0,
+ category: "Unknown",
...over,
});
diff --git a/src/web/static/savedModel.ts b/src/web/static/savedModel.ts
index b5edc63e..1a466864 100644
--- a/src/web/static/savedModel.ts
+++ b/src/web/static/savedModel.ts
@@ -32,6 +32,14 @@ export interface SavedState {
* the store's own order, a silent divergence from the TUI's list.
*/
continueWatching: PublicStreamHistoryItem[];
+ /** The active category tab for `library` — "All" or one of `libraryCategories`. */
+ libraryCategory: string;
+ /** The active category tab for `continueWatching` — "All" or one of `continueWatchingCategories`. */
+ continueWatchingCategory: string;
+ /** Non-empty category tabs for `library`, "All" always first, from the server. */
+ libraryCategories: string[];
+ /** Non-empty category tabs for `continueWatching`, "All" always first, from the server. */
+ continueWatchingCategories: string[];
/**
* Whether a response has ever arrived.
*
@@ -46,7 +54,36 @@ export interface SavedState {
}
export function emptySaved(): SavedState {
- return { savedSearches: [], library: [], continueWatching: [], loaded: false, error: null };
+ return {
+ savedSearches: [],
+ library: [],
+ continueWatching: [],
+ libraryCategory: "All",
+ continueWatchingCategory: "All",
+ libraryCategories: ["All"],
+ continueWatchingCategories: ["All"],
+ loaded: false,
+ error: null,
+ };
+}
+
+/** The item's own category matches the active tab, or the active tab is "All". */
+export function libraryForCategory(state: SavedState): PublicFavourite[] {
+ return state.libraryCategory === "All"
+ ? state.library
+ : state.library.filter((f) => f.category === state.libraryCategory);
+}
+
+/** Same as {@link libraryForCategory}, for `continueWatching`. */
+export function continueWatchingForCategory(state: SavedState): PublicStreamHistoryItem[] {
+ return state.continueWatchingCategory === "All"
+ ? state.continueWatching
+ : state.continueWatching.filter((c) => c.category === state.continueWatchingCategory);
+}
+
+/** Falls back to "All" once the active tab is no longer among the available ones — e.g. the last item in it was just removed. */
+function clampCategory(current: string, available: readonly string[]): string {
+ return available.includes(current) ? current : "All";
}
/** The `POST /api/saved-searches` body. Trimmed here so the box's stray spaces cannot create a second entry. */
@@ -166,18 +203,23 @@ export function libraryStatus(state: SavedState): SavedStatus {
* error line, which is what those guards are for.
*/
export function applySaved(state: SavedState, body: unknown): SavedState {
- const savedSearches =
- body && typeof body === "object" ? (body as { savedSearches?: unknown }).savedSearches : undefined;
- const library = body && typeof body === "object" ? (body as { library?: unknown }).library : undefined;
- const continueWatching =
- body && typeof body === "object"
- ? (body as { continueWatching?: unknown }).continueWatching
- : undefined;
+ const obj = body && typeof body === "object" ? (body as Record) : undefined;
+ const savedSearches = obj?.savedSearches;
+ const library = obj?.library;
+ const continueWatching = obj?.continueWatching;
+ const libraryCategories = Array.isArray(obj?.libraryCategories) ? (obj.libraryCategories as string[]) : ["All"];
+ const continueWatchingCategories = Array.isArray(obj?.continueWatchingCategories)
+ ? (obj.continueWatchingCategories as string[])
+ : ["All"];
return {
...state,
savedSearches: Array.isArray(savedSearches) ? (savedSearches as string[]) : [],
library: Array.isArray(library) ? (library as PublicFavourite[]) : [],
continueWatching: Array.isArray(continueWatching) ? (continueWatching as PublicStreamHistoryItem[]) : [],
+ libraryCategories,
+ continueWatchingCategories,
+ libraryCategory: clampCategory(state.libraryCategory, libraryCategories),
+ continueWatchingCategory: clampCategory(state.continueWatchingCategory, continueWatchingCategories),
loaded: true,
error: null,
};
@@ -206,10 +248,16 @@ export function applySavedSearchesResponse(state: SavedState, body: unknown): Sa
/** The same fold as {@link applySavedSearchesResponse}, for `POST /api/library`. */
export function applyLibraryResponse(state: SavedState, body: unknown): SavedState {
- const list = body && typeof body === "object" ? (body as { library?: unknown }).library : undefined;
+ const obj = body && typeof body === "object" ? (body as Record) : undefined;
+ const list = obj?.library;
+ const libraryCategories = Array.isArray(obj?.libraryCategories)
+ ? (obj.libraryCategories as string[])
+ : state.libraryCategories;
return {
...state,
library: Array.isArray(list) ? (list as PublicFavourite[]) : state.library,
+ libraryCategories,
+ libraryCategory: clampCategory(state.libraryCategory, libraryCategories),
loaded: true,
error: null,
};
@@ -217,11 +265,16 @@ export function applyLibraryResponse(state: SavedState, body: unknown): SavedSta
/** The same fold as {@link applySavedSearchesResponse}, for `POST /api/continue-watching`. */
export function applyContinueWatchingResponse(state: SavedState, body: unknown): SavedState {
- const list =
- body && typeof body === "object" ? (body as { continueWatching?: unknown }).continueWatching : undefined;
+ const obj = body && typeof body === "object" ? (body as Record) : undefined;
+ const list = obj?.continueWatching;
+ const continueWatchingCategories = Array.isArray(obj?.continueWatchingCategories)
+ ? (obj.continueWatchingCategories as string[])
+ : state.continueWatchingCategories;
return {
...state,
continueWatching: Array.isArray(list) ? (list as PublicStreamHistoryItem[]) : state.continueWatching,
+ continueWatchingCategories,
+ continueWatchingCategory: clampCategory(state.continueWatchingCategory, continueWatchingCategories),
loaded: true,
error: null,
};
diff --git a/src/web/static/searchModel.test.ts b/src/web/static/searchModel.test.ts
index 873ad80d..3b1c3ec3 100644
--- a/src/web/static/searchModel.test.ts
+++ b/src/web/static/searchModel.test.ts
@@ -887,6 +887,7 @@ describe("positionLookup", () => {
rawName: "Harrowgate.S03E07.1080p.WEB-DL",
infoHash: "a1",
startedAt: 1,
+ category: "Unknown",
...over,
});
diff --git a/src/web/static/settingsModel.test.ts b/src/web/static/settingsModel.test.ts
index d3dab6df..49689dcc 100644
--- a/src/web/static/settingsModel.test.ts
+++ b/src/web/static/settingsModel.test.ts
@@ -16,6 +16,7 @@ function response(over: Partial = {}, envOver: Par
mediaPlayer: "",
adultContent: false,
adultScreenshots: true,
+ adultHistoryVisible: false,
proxyDebridStreams: false,
downloadLimitKbps: null,
uploadLimitKbps: null,
@@ -48,6 +49,7 @@ describe("settingsSections", () => {
it("reflects the stored values in each control", () => {
const res = response({
adultContent: true,
+ adultHistoryVisible: true,
proxyDebridStreams: true,
downloadDir: "/media/dl",
mediaPlayer: "mpv",
@@ -56,6 +58,7 @@ describe("settingsSections", () => {
});
expect(controlFor(res, "adultContent")).toMatchObject({ kind: "toggle", value: true, locked: false });
expect(controlFor(res, "adultScreenshots")).toMatchObject({ kind: "toggle", value: true, locked: false });
+ expect(controlFor(res, "adultHistoryVisible")).toMatchObject({ kind: "toggle", value: true, locked: false });
expect(controlFor(res, "proxyDebridStreams")).toMatchObject({ kind: "toggle", value: true });
expect(controlFor(res, "downloadDir")).toMatchObject({ kind: "text", value: "/media/dl" });
expect(controlFor(res, "mediaPlayer")).toMatchObject({ kind: "text", value: "mpv" });
diff --git a/src/web/static/settingsModel.ts b/src/web/static/settingsModel.ts
index 2b7c027c..7da0a63c 100644
--- a/src/web/static/settingsModel.ts
+++ b/src/web/static/settingsModel.ts
@@ -17,7 +17,7 @@ import type { PublicWritableSettings, SettingsAccounts, SettingsResponse } from
// one redeclares a producer's payload shape in the browser bundle.
export type { PublicWritableSettings, SettingsAccounts, SettingsEnvLocks, SettingsResponse } from "../wire";
-export type ToggleKey = "adultContent" | "adultScreenshots" | "proxyDebridStreams";
+export type ToggleKey = "adultContent" | "adultScreenshots" | "adultHistoryVisible" | "proxyDebridStreams";
export type NumberKey = "downloadLimitKbps" | "uploadLimitKbps" | "seedRatio" | "seedMinutes";
export type TextKey = "downloadDir" | "mediaPlayer";
@@ -81,6 +81,15 @@ export function settingsSections(res: SettingsResponse): SettingsSection[] {
locked: false,
lockNote: null,
},
+ {
+ kind: "toggle",
+ key: "adultHistoryVisible",
+ label: "Adult history",
+ hint: "Show adult items in Library and Continue Watching.",
+ value: s.adultHistoryVisible,
+ locked: false,
+ lockNote: null,
+ },
],
},
{
diff --git a/src/web/static/streamFlow.test.ts b/src/web/static/streamFlow.test.ts
index 646662b3..cafadaed 100644
--- a/src/web/static/streamFlow.test.ts
+++ b/src/web/static/streamFlow.test.ts
@@ -372,6 +372,7 @@ describe("wantedEpisodeFor", () => {
rawName: "Harrowgate.S03E04.1080p.WEB-DL",
infoHash: "a".repeat(40),
startedAt: 1,
+ category: "Unknown",
});
// The divergence this closes: the terminal preselects from EVERY play path,
diff --git a/src/web/wire.ts b/src/web/wire.ts
index 4368c546..a0110cb2 100644
--- a/src/web/wire.ts
+++ b/src/web/wire.ts
@@ -574,6 +574,8 @@ export interface PublicWritableSettings {
adultContent: boolean;
/** Whether screenshots are pulled from adult torrent descriptions. */
adultScreenshots: boolean;
+ /** Whether adult items appear in the Library and Continue Watching lists. */
+ adultHistoryVisible: boolean;
proxyDebridStreams: boolean;
downloadLimitKbps: number | null;
uploadLimitKbps: number | null;
@@ -895,6 +897,10 @@ export interface PublicFavourite {
name: string;
sizeBytes?: number;
source?: string;
+ /** The category tab this item belongs to: a SourceGroup, or "Unknown" when
+ * `source` is absent or unrecognised. Computed server-side (categoryForSource)
+ * so the source→group/adult mapping never has to live in the client bundle. */
+ category: string;
/** Epoch ms. */
addedAt: number;
/** How many episodes have been streamed, NOT which ones. */
@@ -922,6 +928,8 @@ export interface PublicStreamHistoryItem {
rawName: string;
infoHash: string;
startedAt: number;
+ /** The category tab this item belongs to — see PublicFavourite.category. */
+ category: string;
}
/**
@@ -943,6 +951,12 @@ export interface SavedResponse {
savedSearches: string[];
library: PublicFavourite[];
continueWatching: PublicStreamHistoryItem[];
+ /** Non-empty category tabs for `library`, in a fixed display order, "All"
+ * always first. "Porn" is absent whenever adultHistoryVisible is off, even
+ * if the unfiltered data would have had Porn items. */
+ libraryCategories: string[];
+ /** Same as `libraryCategories`, for `continueWatching`. */
+ continueWatchingCategories: string[];
}
/**
@@ -1015,6 +1029,8 @@ export interface LibraryResponse {
/** Whether THIS torrent is in the library afterwards. */
favourited: boolean;
library: PublicFavourite[];
+ /** Same as `SavedResponse.libraryCategories`. */
+ libraryCategories: string[];
}
/**
@@ -1035,6 +1051,8 @@ export interface ContinueWatchingRequest {
/** The 200 body of `POST /api/continue-watching`. Same contract as `LibraryResponse`: the caller renders what comes back. */
export interface ContinueWatchingResponse {
continueWatching: PublicStreamHistoryItem[];
+ /** Same as `SavedResponse.continueWatchingCategories`. */
+ continueWatchingCategories: string[];
}
/**