From 8760978333a9dab8e0710328a5cdc6cb91e07d3d Mon Sep 17 00:00:00 2001 From: Ash Date: Wed, 2 Sep 2026 10:06:46 +0100 Subject: [PATCH] feat(web): hide adult items from Library/Continue Watching by default, add category tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adult-sourced favourites and stream-history items used to stay visible forever once saved, regardless of the search-time adultContent setting — browsing the Porn category left Continue Watching full of adult items with no way to hide them. A new independent adultHistoryVisible setting (default off) now gates them out entirely server-side, and while we're splitting the lists at all, Library and Continue Watching both gain general category tabs (Movies/TV/Anime/etc, derived from each item's source) rather than a single flat list. - src/sources/registry.ts: categoryForSource/isAdultSource/ visibleWithAdultHistory/nonEmptyCategories — the shared classification and filtering helpers, reused by the TUI in a follow-up PR. - src/config/config.ts: adultHistoryVisible config field + resolver + sanitiseSettingsPatch entry. - src/web/wire.ts, routes.ts: PublicFavourite/PublicStreamHistoryItem gain a server-computed `category`; SavedResponse/LibraryResponse/ ContinueWatchingResponse gain non-empty tab lists; savedLists/libraryAction/ continueWatchingAction filter adult items out of every response when the setting is off, so they never reach the browser at all. - src/web/static/savedModel.ts, app.ts, settingsModel.ts, index.html: tab strips for both lists, driven by the server-provided category lists, plus the new "Adult history" toggle in the settings dialog. Verified manually via serve --web: toggling the setting live updates the tab strips and their data with no page reload. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XddtC9qHvnPLb4xp9fiL7n --- src/config/config.test.ts | 27 +++++++ src/config/config.ts | 19 +++++ src/sources/registry.test.ts | 74 ++++++++++++++++- src/sources/registry.ts | 38 +++++++++ src/web/routes.test.ts | 115 ++++++++++++++++++++++++++- src/web/routes.ts | 46 +++++++++-- src/web/static/app.ts | 60 +++++++++++++- src/web/static/index.html | 2 + src/web/static/pickModel.test.ts | 2 +- src/web/static/savedModel.test.ts | 101 ++++++++++++++++++++++- src/web/static/savedModel.ts | 75 ++++++++++++++--- src/web/static/searchModel.test.ts | 1 + src/web/static/settingsModel.test.ts | 3 + src/web/static/settingsModel.ts | 11 ++- src/web/static/streamFlow.test.ts | 1 + src/web/wire.ts | 18 +++++ 16 files changed, 568 insertions(+), 25 deletions(-) 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/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