From 3540e0c903d1c2ac7bf1e0a07d5e83d614a4675a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?U=C4=9Fur=20=C3=87akar?= <166568043+ugurckr@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:19:42 +0300 Subject: [PATCH] feat: add a Music tab fed by TPB, 1337x, and BitTorrented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Music is the one big category the curated list had no answer for: someone after an album had to search Movies and hope. All three sources already carry it, so the tab costs three new source entries, not a new integration. Each source gets its own id rather than a second group on an existing one, because each asks its site a different question: apibay by category set, 1337x by its Music search path, BitTorrented by media type. Separate ids also keep per-source health and outage reporting honest per tab. What counts as music is drawn tightly. TPB takes 101 (music) and 104 (FLAC), where most lossless rips land. Music videos (203) stay video and audio books (102) are not music, so neither is admitted. BitTorrented is asked for type=audio, so the tab structurally cannot show video rows. One existing behaviour changes with it: the apibay category filter now runs over browse feeds as well as searches. It has to. apibay publishes no top-100 for music alone, so the empty-query feed is the parent Audio list — music and audio books mixed together. Movies and TV browse their own leaf feeds (207, 208), which their category sets already admit, so the filter is a no-op there; there is a test pinning exactly that. A row that carries no category at all is kept rather than dropped: an unfiled row beats an empty tab. --- README.md | 3 +- src/sources/bittorrented.test.ts | 15 ++++- src/sources/bittorrented.ts | 36 +++++++++--- src/sources/piratebay.test.ts | 98 ++++++++++++++++++++++++++++++++ src/sources/piratebay.ts | 21 ++++++- src/sources/registry.ts | 11 ++-- src/sources/types.ts | 7 ++- src/sources/x1337.test.ts | 17 +++++- src/sources/x1337.ts | 23 +++++++- src/ui/store.ts | 3 +- src/ui/theme.ts | 3 + 11 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 src/sources/piratebay.test.ts diff --git a/README.md b/README.md index fb61505b..02288805 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ A short, hand-picked list of trusted sources: | Movies | YTS, The Pirate Bay, 1337x, BitTorrented | | TV | EZTV, The Pirate Bay, 1337x, BitTorrented | | Anime | Nyaa, SubsPlease | +| Music | The Pirate Bay, 1337x, BitTorrented | -Games are the only category that can run code, so they come from FitGirl alone, a repacker with a long, trusted track record; everything else is plain video and subtitles. If a source is down, the search carries on without it, and torlink tells you which one is offline. +Games are the only category that can run code, so they come from FitGirl alone, a repacker with a long, trusted track record; everything else is plain video, audio and subtitles. If a source is down, the search carries on without it, and torlink tells you which one is offline. ## Headless diff --git a/src/sources/bittorrented.test.ts b/src/sources/bittorrented.test.ts index 2a32f95e..f11542fc 100644 --- a/src/sources/bittorrented.test.ts +++ b/src/sources/bittorrented.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { mapBittorrentedResults, bittorrented } from "./bittorrented"; +import { mapBittorrentedResults, bittorrented, bittorrentedAudio } from "./bittorrented"; describe("mapBittorrentedResults", () => { it("maps an API row to a torrent result with a built magnet, tagged by source id", () => { @@ -62,3 +62,16 @@ describe("bittorrented", () => { expect(bittorrented.reportsHealth).toBe(true); }); }); + +describe("BitTorrented media types", () => { + it("serves music from its own id, and never Games", () => { + expect(bittorrentedAudio.id).toBe("bittorrented-audio"); + expect(bittorrentedAudio.groups).toEqual(["Music"]); + // Games stay FitGirl's alone: a crawl can't vouch for what an installer runs. + for (const s of [bittorrented, bittorrentedAudio]) { + expect(s.groups).not.toContain("Games"); + } + // One site, one label. + expect(bittorrentedAudio.label).toBe(bittorrented.label); + }); +}); diff --git a/src/sources/bittorrented.ts b/src/sources/bittorrented.ts index 55f0e94f..8a420733 100644 --- a/src/sources/bittorrented.ts +++ b/src/sources/bittorrented.ts @@ -3,9 +3,9 @@ import { buildMagnet } from "./magnet"; import type { SearchOptions, Source, SourceId, TorrentResult } from "./types"; // BitTorrented is a general index (its own library plus a large DHT crawl). -// torlink takes its video type only and feeds it to Movies and TV. Anime stays -// with its dedicated sources (the API can't tell anime from any other video) -// and Games stays FitGirl's alone. Its JSON API returns real swarm counts, so +// torlink asks it one media type per tab: video for Movies and TV, audio for +// Music. Anime stays with its dedicated sources (the API can't tell anime from +// any other video) and Games stays FitGirl's alone. Its JSON API returns real swarm counts, so // reportsHealth is true. const BASE = "https://bittorrented.com"; @@ -57,15 +57,24 @@ export function mapBittorrentedResults(results: BtResult[], id: SourceId): Torre return out; } -async function search(query: string, opts: SearchOptions = {}): Promise { +// The API's own type names. It rejects anything else with a 400, so these are +// the ones the registry can ask for. +type MediaType = "video" | "audio"; + +async function search( + query: string, + type: MediaType, + id: SourceId, + opts: SearchOptions = {}, +): Promise { const q = query.trim(); if (q.length < MIN_QUERY) return []; - // Video only: keeps the category tabs plain video and structurally excludes - // the index's other media types. One request per search. + // One media type per request, so a tab structurally cannot show another's + // rows. One request per search. const params = new URLSearchParams({ q, - type: "video", + type, limit: "50", sortBy: "seeders", sortOrder: "desc", @@ -78,7 +87,7 @@ async function search(query: string, opts: SearchOptions = {}): Promise search(query, "video", "bittorrented", opts), +}; + +export const bittorrentedAudio: Source = { + id: "bittorrented-audio", + label: "BitTorrented", + groups: ["Music"], + homepage: BASE, + reportsHealth: true, + search: (query, opts = {}) => search(query, "audio", "bittorrented-audio", opts), }; diff --git a/src/sources/piratebay.test.ts b/src/sources/piratebay.test.ts new file mode 100644 index 00000000..4bf848bf --- /dev/null +++ b/src/sources/piratebay.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fetchResilient } from "../util/net"; +import { tpbMovies, tpbMusic, tpbTv } from "./piratebay"; + +vi.mock("../util/net", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchResilient: vi.fn() }; +}); + +const mocked = vi.mocked(fetchResilient); + +// An apibay row, trimmed to what the mapper reads. The info hash has to be a +// real 40-char hex string or the mapper drops the row. +function row(n: number, category: string, name = `Row ${n}`) { + return { + id: String(n), + name, + info_hash: n.toString(16).padStart(40, "0"), + seeders: "10", + leechers: "1", + size: "1000", + category, + }; +} + +// Answers each URL from a table; anything unlisted rejects, so a test that +// reaches for an unexpected feed fails loudly rather than silently. +function serve(table: Record): void { + mocked.mockImplementation(async (url: string) => { + const key = Object.keys(table).find((k) => url.includes(k)); + if (!key) throw new Error(`unexpected request: ${url}`); + return { + ok: true, + status: 200, + json: async () => table[key], + } as unknown as Response; + }); +} + +const names = (rows: { name: string }[]): string[] => rows.map((r) => r.name); + +beforeEach(() => { + mocked.mockReset(); +}); + +describe("Pirate Bay music category filter", () => { + it("keeps music and lossless, drops audio books and music videos", async () => { + serve({ + "q.php": [ + row(1, "101", "album mp3"), + row(2, "104", "album flac"), + row(3, "102", "novel audiobook"), + row(4, "203", "live concert video"), + ], + }); + + expect(names(await tpbMusic.search("some band"))).toEqual(["album mp3", "album flac"]); + }); + + it("filters the browse feed too, since Audio's top-100 mixes music with audio books", async () => { + serve({ + data_top100_100: [row(1, "104", "flac rip"), row(2, "102", "audiobook")], + }); + + expect(names(await tpbMusic.search(""))).toEqual(["flac rip"]); + }); + + it("keeps a row that carries no category rather than dropping it", async () => { + serve({ "q.php": [{ ...row(1, "", "unfiled release"), category: undefined }] }); + expect(names(await tpbMusic.search("x"))).toEqual(["unfiled release"]); + }); + + it("leaves the movie and TV tabs' own filters untouched", async () => { + serve({ "q.php": [row(1, "207", "a movie"), row(2, "101", "an album")] }); + expect(names(await tpbMovies.search("x"))).toEqual(["a movie"]); + + serve({ "q.php": [row(1, "208", "an episode"), row(2, "101", "an album")] }); + expect(names(await tpbTv.search("x"))).toEqual(["an episode"]); + }); + + it("leaves a movie browse feed intact now that browse is filtered as well", async () => { + // 207 is exactly what MOVIE_CATS admits, so the new browse-side filter is a + // no-op here — this is the regression guard for that. + serve({ data_top100_207: [row(1, "207", "top movie")] }); + expect(names(await tpbMovies.search(""))).toEqual(["top movie"]); + }); +}); + +describe("Pirate Bay tab wiring", () => { + it("gives music its own source id and group", () => { + expect(tpbMusic.id).toBe("tpb-music"); + expect(tpbMusic.groups).toEqual(["Music"]); + // Same site, so it still reports real swarm counts. + expect(tpbMusic.reportsHealth).toBe(true); + // One site, one label: the tag answers who found a row, not what kind. + expect(new Set([tpbMovies.label, tpbTv.label, tpbMusic.label])).toEqual(new Set(["TPB"])); + }); +}); diff --git a/src/sources/piratebay.ts b/src/sources/piratebay.ts index da4065da..5ffb4259 100644 --- a/src/sources/piratebay.ts +++ b/src/sources/piratebay.ts @@ -6,9 +6,16 @@ const API = "https://apibay.org"; const MOVIE_CATS = new Set([201, 202, 207, 209]); const TV_CATS = new Set([205, 208]); +// Music (101) and FLAC (104), which is where most lossless rips land. Music +// videos (203) are video, and audio books (102) are not music at all, so +// neither belongs on this tab. +const MUSIC_CATS = new Set([101, 104]); const TOP_MOVIES = `${API}/precompiled/data_top100_207.json`; const TOP_TV = `${API}/precompiled/data_top100_208.json`; +// 100 is the parent Audio feed: apibay publishes no top-100 for music alone, so +// this one arrives mixed with audio books and the category filter sorts it out. +const TOP_MUSIC = `${API}/precompiled/data_top100_100.json`; interface ApibayItem { id?: string; @@ -67,7 +74,10 @@ async function search( ); const out: TorrentResult[] = []; for (const it of items) { - if (q && !cats.has(Number(it.category))) continue; + // Browse feeds are filtered as well as searches: the parent Audio feed + // mixes music with audio books, which are not the same tab. A row with no + // category at all is kept — an unfiled row beats an empty tab. + if (it.category && !cats.has(Number(it.category))) continue; const r = toResult(it, source); if (r) out.push(r); } @@ -91,3 +101,12 @@ export const tpbTv: Source = { reportsHealth: true, search: (query, opts = {}) => search(query, TV_CATS, TOP_TV, "tpb-tv", opts), }; + +export const tpbMusic: Source = { + id: "tpb-music", + label: "TPB", + groups: ["Music"], + homepage: "https://thepiratebay.org", + reportsHealth: true, + search: (query, opts = {}) => search(query, MUSIC_CATS, TOP_MUSIC, "tpb-music", opts), +}; diff --git a/src/sources/registry.ts b/src/sources/registry.ts index 07643a9c..e7e1b81d 100644 --- a/src/sources/registry.ts +++ b/src/sources/registry.ts @@ -1,10 +1,10 @@ -import { bittorrented } from "./bittorrented"; +import { bittorrented, bittorrentedAudio } from "./bittorrented"; import { eztv } from "./eztv"; import { fitgirl } from "./fitgirl"; import { nyaa } from "./nyaa"; import { subsplease } from "./subsplease"; -import { tpbMovies, tpbTv } from "./piratebay"; -import { x1337Movies, x1337Tv } from "./x1337"; +import { tpbMovies, tpbMusic, tpbTv } from "./piratebay"; +import { x1337Movies, x1337Music, x1337Tv } from "./x1337"; import { yts } from "./yts"; import type { Source, SourceGroup, SourceId } from "./types"; @@ -19,6 +19,9 @@ export const SOURCES: readonly Source[] = [ nyaa, subsplease, bittorrented, + tpbMusic, + x1337Music, + bittorrentedAudio, ]; export const DEFAULT_SOURCE: Source = SOURCES[0]!; @@ -27,7 +30,7 @@ export function getSource(id: SourceId): Source { return SOURCES.find((s) => s.id === id) ?? DEFAULT_SOURCE; } -const GROUP_ORDER: readonly SourceGroup[] = ["Games", "Movies", "TV", "Anime"]; +const GROUP_ORDER: readonly SourceGroup[] = ["Games", "Movies", "TV", "Anime", "Music"]; export function sourcesByGroup(): { group: SourceGroup; sources: Source[] }[] { return GROUP_ORDER.map((group) => ({ diff --git a/src/sources/types.ts b/src/sources/types.ts index 39bf7be7..83c6b32f 100644 --- a/src/sources/types.ts +++ b/src/sources/types.ts @@ -8,9 +8,12 @@ export type SourceId = | "tpb-tv" | "x1337-movies" | "x1337-tv" - | "bittorrented"; + | "x1337-music" + | "tpb-music" + | "bittorrented" + | "bittorrented-audio"; -export type SourceGroup = "Games" | "Movies" | "TV" | "Anime"; +export type SourceGroup = "Games" | "Movies" | "TV" | "Anime" | "Music"; export interface TorrentResult { infoHash: string; diff --git a/src/sources/x1337.test.ts b/src/sources/x1337.test.ts index e6099cf9..7704003a 100644 --- a/src/sources/x1337.test.ts +++ b/src/sources/x1337.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseUploadDate } from "./x1337"; +import { parseUploadDate, x1337Movies, x1337Music, x1337Tv } from "./x1337"; const detail = (span: string) => `
  • Date uploaded${span}
`; @@ -21,3 +21,18 @@ describe("parseUploadDate", () => { expect(parseUploadDate(detail("sometime"))).toBeUndefined(); }); }); + +describe("1337x category variants", () => { + it("gives each tab its own source id while sharing the site", () => { + expect([x1337Movies.id, x1337Tv.id, x1337Music.id]).toEqual([ + "x1337-movies", + "x1337-tv", + "x1337-music", + ]); + expect(x1337Music.groups).toEqual(["Music"]); + // One site, one label: the tag answers who found a row, not what kind. + expect(new Set([x1337Movies.label, x1337Tv.label, x1337Music.label])).toEqual( + new Set(["1337x"]), + ); + }); +}); diff --git a/src/sources/x1337.ts b/src/sources/x1337.ts index b65fdaea..3d610a63 100644 --- a/src/sources/x1337.ts +++ b/src/sources/x1337.ts @@ -79,16 +79,26 @@ async function detailInfo( } } +// The site's own category names, used verbatim in its search paths. +type Category = "Movies" | "TV" | "Music"; + +// Where each category's empty-query browse lives. +const POPULAR: Record = { + Movies: "/popular-movies", + TV: "/popular-tv", + Music: "/popular-music", +}; + async function search( query: string, - cat: "Movies" | "TV", + cat: Category, source: SourceId, opts: SearchOptions = {}, ): Promise { const q = query.trim(); const path = q ? `/category-search/${encodeURIComponent(q).replace(/%20/g, "+")}/${cat}/1/` - : `/popular-${cat === "Movies" ? "movies" : "tv"}`; + : POPULAR[cat]; let base = ""; let html = ""; @@ -158,3 +168,12 @@ export const x1337Tv: Source = { reportsHealth: true, search: (query, opts = {}) => search(query, "TV", "x1337-tv", opts), }; + +export const x1337Music: Source = { + id: "x1337-music", + label: "1337x", + groups: ["Music"], + homepage: "https://1337x.to", + reportsHealth: true, + search: (query, opts = {}) => search(query, "Music", "x1337-music", opts), +}; diff --git a/src/ui/store.ts b/src/ui/store.ts index b96eeaf5..8b52f456 100644 --- a/src/ui/store.ts +++ b/src/ui/store.ts @@ -7,7 +7,7 @@ import type { SourceGroup, SourceId } from "../sources/types"; export type View = "splash" | "browser"; -export type Category = "all" | "games" | "movies" | "tv" | "anime"; +export type Category = "all" | "games" | "movies" | "tv" | "anime" | "music"; export type Section = Category | "downloads" | "seeding"; @@ -17,6 +17,7 @@ export const CATEGORIES: { key: Category; label: string; group?: SourceGroup }[] { key: "movies", label: "Movies", group: "Movies" }, { key: "tv", label: "TV", group: "TV" }, { key: "anime", label: "Anime", group: "Anime" }, + { key: "music", label: "Music", group: "Music" }, ]; export type Region = "sidebar" | "content" | "help"; diff --git a/src/ui/theme.ts b/src/ui/theme.ts index c191ec1f..ab788fda 100644 --- a/src/ui/theme.ts +++ b/src/ui/theme.ts @@ -38,7 +38,10 @@ export const SOURCE_STYLE: Record = { "tpb-tv": { tag: "TPB", color: "#5fd0c5" }, "x1337-movies": { tag: "1337", color: "#f6a55c" }, "x1337-tv": { tag: "1337", color: "#f6a55c" }, + "tpb-music": { tag: "TPB", color: "#5fd0c5" }, + "x1337-music": { tag: "1337", color: "#f6a55c" }, bittorrented: { tag: "BT", color: "#7db8f0" }, + "bittorrented-audio": { tag: "BT", color: "#7db8f0" }, }; // Tolerant lookup: a source id may be absent (a pasted magnet / bare infohash) or