Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveReccConfig,
resolveAdultContent,
resolveAdultScreenshots,
resolveAdultHistoryVisible,
defaultConfig,
resolveActiveDebrid,
resolveDebridTokenFor,
Expand Down Expand Up @@ -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" });
Expand Down
19 changes: 19 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -263,6 +269,7 @@ export interface RawSettingsPatch {
mediaPlayer?: unknown;
adultContent?: unknown;
adultScreenshots?: unknown;
adultHistoryVisible?: unknown;
proxyDebridStreams?: unknown;
downloadLimitKbps?: unknown;
uploadLimitKbps?: unknown;
Expand Down Expand Up @@ -302,6 +309,8 @@ export function sanitiseSettingsPatch(raw: RawSettingsPatch): Partial<Config> {
}
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);
Expand Down Expand Up @@ -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";

Expand Down
74 changes: 73 additions & 1 deletion src/sources/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions src/sources/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { source?: SourceId }>(
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"];
Expand Down
115 changes: 113 additions & 2 deletions src/web/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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" });
Expand Down
Loading
Loading