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