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