Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 14 additions & 1 deletion src/sources/bittorrented.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
36 changes: 27 additions & 9 deletions src/sources/bittorrented.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -57,15 +57,24 @@ export function mapBittorrentedResults(results: BtResult[], id: SourceId): Torre
return out;
}

async function search(query: string, opts: SearchOptions = {}): Promise<TorrentResult[]> {
// 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<TorrentResult[]> {
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",
Expand All @@ -78,7 +87,7 @@ async function search(query: string, opts: SearchOptions = {}): Promise<TorrentR
if (!res.ok) throw new HttpError(res.status, `BitTorrented returned ${res.status}`);

const json = (await res.json()) as BtResponse;
return mapBittorrentedResults(json.results ?? [], "bittorrented");
return mapBittorrentedResults(json.results ?? [], id);
}

export const bittorrented: Source = {
Expand All @@ -87,5 +96,14 @@ export const bittorrented: Source = {
groups: ["Movies", "TV"],
homepage: BASE,
reportsHealth: true,
search,
search: (query, opts = {}) => 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),
};
98 changes: 98 additions & 0 deletions src/sources/piratebay.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../util/net")>();
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<string, unknown[]>): 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"]));
});
});
21 changes: 20 additions & 1 deletion src/sources/piratebay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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),
};
11 changes: 7 additions & 4 deletions src/sources/registry.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -19,6 +19,9 @@ export const SOURCES: readonly Source[] = [
nyaa,
subsplease,
bittorrented,
tpbMusic,
x1337Music,
bittorrentedAudio,
];

export const DEFAULT_SOURCE: Source = SOURCES[0]!;
Expand All @@ -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) => ({
Expand Down
7 changes: 5 additions & 2 deletions src/sources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 16 additions & 1 deletion src/sources/x1337.test.ts
Original file line number Diff line number Diff line change
@@ -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) =>
`<ul class="list"><li><strong>Date uploaded</strong><span>${span}</span> </li></ul>`;
Expand All @@ -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"]),
);
});
});
23 changes: 21 additions & 2 deletions src/sources/x1337.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Category, string> = {
Movies: "/popular-movies",
TV: "/popular-tv",
Music: "/popular-music",
};

async function search(
query: string,
cat: "Movies" | "TV",
cat: Category,
source: SourceId,
opts: SearchOptions = {},
): Promise<TorrentResult[]> {
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 = "";
Expand Down Expand Up @@ -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),
};
3 changes: 2 additions & 1 deletion src/ui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions src/ui/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ export const SOURCE_STYLE: Record<SourceId, { tag: string; color: string }> = {
"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
Expand Down