diff --git a/FORK.md b/FORK.md new file mode 100644 index 00000000..14daa796 --- /dev/null +++ b/FORK.md @@ -0,0 +1,60 @@ +# Fork notes — patrickdundas/tracker-tracker + +Fork of [jordanlambrecht/tracker-tracker](https://github.com/jordanlambrecht/tracker-tracker) +(GPL-3.0), maintained for Patrick's homelab. Deployed on the `yams` VM at +`http://192.168.8.199:3838`; see `docs/tracker-stats.md` in the `homelab-agent` repo. + +## Why this fork exists + +TorrentLeech hit-and-run tracking. TL has no public API, so stats come from scraping the logged-in +profile page — and upstream's TL adapter (via PR #175) stubs `hitAndRuns: null`. HnR visibility +across trackers is the whole reason this dashboard was deployed, so that gap is the fork's purpose. + +## Branch strategy + +| Branch | Role | +|---|---| +| `main` | **Integration + deploy branch.** `release.yml` triggers on push here and publishes to `ghcr.io/patrickdundas/tracker-tracker`. Upstream is merged *into* this. | +| `feat/*` | Work branches. PR into `main` so `ci.yml` runs before anything publishes. | + +`main` deliberately is *not* a clean upstream mirror: the release workflow only builds from `main`, +so that is where deployable code has to live. To contribute something back upstream, branch from +`upstream/main` directly rather than from our `main`. + +```bash +git remote -v +# origin git@github.com:patrickdundas/tracker-tracker.git +# upstream https://github.com/jordanlambrecht/tracker-tracker.git + +# sync with upstream +git fetch upstream && git merge upstream/main +``` + +## Versioning + +`release.yml` publishes only when `package.json`'s version has no matching GitHub Release, so +**every deployable change needs a version bump** or no image is built. + +This fork uses a `-homelab.N` suffix (`2.8.9-homelab.1`) so builds are unambiguously ours and never +collide with an upstream release tag. On an upstream sync, `package.json` will conflict on this one +line — resolve to the new upstream base plus a reset suffix, e.g. `2.9.0-homelab.1`. + +## Deltas from upstream + +| Change | Status | +|---|---| +| Upstream PR #175 merged whole (Zenith, BTN fix, IPTorrents, TorrentLeech adapters) | done — see below | +| TorrentLeech `hitAndRuns` | **the point of the fork**, not yet implemented | +| TorrentLeech `requiredRatio` / `warned` | stubbed `null` upstream; `warned` feeds the `warned` notification event | + +**On taking #175 whole rather than extracting only TorrentLeech:** all four adapters touch the same +shared files (`src/lib/adapters/index.ts`, `adapters/constants.ts`, `lib/parser.ts`). Cherry-picking +TL alone would leave this fork carrying divergent copies of those, guaranteeing conflicts when +upstream merges #175. Taken whole, that merge becomes a no-op. The unused adapters never execute +unless those trackers are configured. + +## One-time setup gotcha + +GitHub **disables workflows on forks by default**. `total_count` from +`/repos/:owner/:repo/actions/workflows` reads 0 and nothing runs until they are enabled once from +the repo's Actions tab. There is no REST endpoint for this. diff --git a/package.json b/package.json index 89213109..e88a0748 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "private-tracker-tracker", - "version": "2.8.9", + "version": "2.8.9-homelab.1", "description": "Self-hosted dashboard for monitoring private tracker stats over time", "license": "GPL-3.0", "repository": { diff --git a/src/data/trackers/broadcasthenet.ts b/src/data/trackers/broadcasthenet.ts index d642a08d..11537e8b 100644 --- a/src/data/trackers/broadcasthenet.ts +++ b/src/data/trackers/broadcasthenet.ts @@ -12,9 +12,8 @@ export const broadcasthenet: TrackerRegistryEntry = { "The top TV tracker, featuring a huge library, well-known internal releasers, great retention, and no ratio requirements.", // ── Platform & API ────────────────────────────────────────────────── - platform: "gazelle", - gazelleEnrich: true, - apiPath: "/ajax.php", + platform: "btn", + apiPath: "https://api.broadcasthe.net/", // ── Content ───────────────────────────────────────────────────────── specialty: "TV", @@ -43,8 +42,8 @@ export const broadcasthenet: TrackerRegistryEntry = { }, // ── Status ────────────────────────────────────────────────────────── - warning: true, - warningNote: "Unvalidated", + warning: false, + warningNote: "", // ── Flags ─────────────────────────────────────────────────────────── draft: false, diff --git a/src/data/trackers/index.ts b/src/data/trackers/index.ts index 1af77289..41a3df1e 100644 --- a/src/data/trackers/index.ts +++ b/src/data/trackers/index.ts @@ -53,6 +53,7 @@ export * from "./torrentleech" export * from "./tvvault" export * from "./uhdbits" export * from "./uploadcx" +export * from "./zenith" import { pier720 } from "./720pier" import { abtorrents } from "./abtorrents" @@ -105,6 +106,7 @@ import { torrentleech } from "./torrentleech" import { tvvault } from "./tvvault" import { uhdbits } from "./uhdbits" import { uploadcx } from "./uploadcx" +import { zenith } from "./zenith" export const ALL_TRACKERS: TrackerRegistryEntry[] = [ pier720, @@ -158,4 +160,5 @@ export const ALL_TRACKERS: TrackerRegistryEntry[] = [ tvvault, uhdbits, uploadcx, + zenith, ] diff --git a/src/data/trackers/iptorrents.ts b/src/data/trackers/iptorrents.ts index 14e6df95..a4319e39 100644 --- a/src/data/trackers/iptorrents.ts +++ b/src/data/trackers/iptorrents.ts @@ -12,8 +12,8 @@ export const iptorrents: TrackerRegistryEntry = { "General tracker with a controversial reputation. Extremely large userbase. Content quality can be inconsistent.", // ── Platform & API ────────────────────────────────────────────────── - platform: "custom", - apiPath: "/api/user", + platform: "iptorrents", + apiPath: "/profile", // ── Content ───────────────────────────────────────────────────────── specialty: "General", @@ -36,8 +36,8 @@ export const iptorrents: TrackerRegistryEntry = { // ── Rules ─────────────────────────────────────────────────────────── rules: { - minimumRatio: 0, - seedTimeHours: 0, + minimumRatio: 1.0, + seedTimeHours: 336, loginIntervalDays: 0, }, @@ -46,7 +46,7 @@ export const iptorrents: TrackerRegistryEntry = { warningNote: "", // ── Flags ─────────────────────────────────────────────────────────── - draft: true, + draft: false, supportsTransitPapers: false, - profileUrlPattern: "", + profileUrlPattern: "/u/{id}", } diff --git a/src/data/trackers/torrentleech.ts b/src/data/trackers/torrentleech.ts index 7afb9a99..41336666 100644 --- a/src/data/trackers/torrentleech.ts +++ b/src/data/trackers/torrentleech.ts @@ -12,8 +12,8 @@ export const torrentleech: TrackerRegistryEntry = { "Large general tracker known for having open signups very often. Broad content library across most categories.", // ── Platform & API ────────────────────────────────────────────────── - platform: "custom", - apiPath: "/api/user", + platform: "torrentleech", + apiPath: "/profile", // ── Content ───────────────────────────────────────────────────────── specialty: "General", @@ -36,7 +36,7 @@ export const torrentleech: TrackerRegistryEntry = { // ── Rules ─────────────────────────────────────────────────────────── rules: { - minimumRatio: 0, + minimumRatio: 1.0, seedTimeHours: 0, loginIntervalDays: 0, }, @@ -46,7 +46,7 @@ export const torrentleech: TrackerRegistryEntry = { warningNote: "", // ── Flags ─────────────────────────────────────────────────────────── - draft: true, + draft: false, supportsTransitPapers: false, - profileUrlPattern: "", + profileUrlPattern: "/profile/{username}", } diff --git a/src/data/trackers/zenith.ts b/src/data/trackers/zenith.ts new file mode 100644 index 00000000..ea6af774 --- /dev/null +++ b/src/data/trackers/zenith.ts @@ -0,0 +1,51 @@ +// src/data/trackers/zenith.ts + +import type { TrackerRegistryEntry } from "@/data/tracker-registry" + +export const zenith: TrackerRegistryEntry = { + // ── Identity ──────────────────────────────────────────────────────── + slug: "zenith", + name: "Zenith", + abbreviation: "ZN", + url: "https://znth.cx", + description: "Private general tracker with a clean UNIT3D interface.", + + // ── Platform & API ────────────────────────────────────────────────── + platform: "unit3d", + apiPath: "/api/user", + + // ── Content ───────────────────────────────────────────────────────── + specialty: "General", + contentCategories: ["Movies", "TV", "Music", "Games", "Books", "Audiobooks"], + language: "English", + + // ── Visual ────────────────────────────────────────────────────────── + color: "#7c3aed", + logo: "", + + // ── External Links ────────────────────────────────────────────────── + trackerHubSlug: "", + statusPageUrl: "", + + // ── Community ─────────────────────────────────────────────────────── + userClasses: [], + releaseGroups: [], + bannedGroups: [], + notableMembers: [], + + // ── Rules ─────────────────────────────────────────────────────────── + rules: { + minimumRatio: 0.4, + seedTimeHours: 72, + loginIntervalDays: 90, + }, + + // ── Status ────────────────────────────────────────────────────────── + warning: false, + warningNote: "", + + // ── Flags ─────────────────────────────────────────────────────────── + draft: false, + supportsTransitPapers: false, + profileUrlPattern: "/users/{username}", +} diff --git a/src/lib/adapters/btn.test.ts b/src/lib/adapters/btn.test.ts new file mode 100644 index 00000000..5eca185b --- /dev/null +++ b/src/lib/adapters/btn.test.ts @@ -0,0 +1,230 @@ +// src/lib/adapters/btn.test.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" +import { BtnAdapter } from "./btn" + +const API_URL = "https://api.broadcasthe.net/" + +describe("BtnAdapter", () => { + const adapter = new BtnAdapter() + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("parses a valid JSON-RPC userInfo response into TrackerStats", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "1531582", + Username: "thing7314", + Email: "seeding@mail.chrisbrunner.com", + Upload: "5000000000", + Download: "1000000000", + Lumens: "10", + Bonus: "2.5", + JoinDate: "1784775609", + Title: "", + Enabled: "1", + Paranoia: "0", + Invites: "0", + Snatches: "0", + UploadsSnatched: "0", + Class: "User", + ClassLevel: "100", + HnR: "3", + }, + } + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const stats = await adapter.fetchStats("https://broadcasthe.net", "fake-api-key", API_URL) + + expect(stats.username).toBe("thing7314") + expect(stats.group).toBe("User") + expect(stats.uploadedBytes).toBe(5000000000n) + expect(stats.downloadedBytes).toBe(1000000000n) + expect(stats.ratio).toBeCloseTo(5) + expect(stats.bufferBytes).toBe(4000000000n) + expect(stats.seedingCount).toBe(0) + expect(stats.leechingCount).toBe(0) + expect(stats.seedbonus).toBe(10) + expect(stats.freeleechTokens).toBe(2.5) + expect(stats.hitAndRuns).toBe(3) + expect(stats.requiredRatio).toBe(0) + expect(stats.warned).toBe(false) + expect(stats.remoteUserId).toBe(1531582) + expect(stats.joinedDate).toContain("2026") + }) + + it("falls back to safe defaults when undocumented fields are missing", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "42", + Username: "minimal", + Upload: "100", + Download: "50", + }, + } + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const stats = await adapter.fetchStats("https://broadcasthe.net", "fake-api-key", API_URL) + + expect(stats.username).toBe("minimal") + expect(stats.group).toBe("Unknown") + expect(stats.uploadedBytes).toBe(100n) + expect(stats.downloadedBytes).toBe(50n) + expect(stats.seedbonus).toBe(0) + expect(stats.freeleechTokens).toBe(0) + expect(stats.hitAndRuns).toBe(0) + expect(stats.joinedDate).toBeUndefined() + expect(stats.remoteUserId).toBe(42) + }) + + it("handles zero upload/download without producing Infinity", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "1", + Username: "newuser", + Upload: "0", + Download: "0", + Class: "User", + Lumens: "0", + Bonus: "0", + HnR: "0", + JoinDate: "1700000000", + }, + } + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const stats = await adapter.fetchStats("https://broadcasthe.net", "fake-api-key", API_URL) + expect(stats.ratio).toBe(0) + }) + + it("stores ratio as 0 when upload > 0 but download = 0 (avoids Infinity)", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "1", + Username: "seeder", + Upload: "1000", + Download: "0", + Class: "User", + Lumens: "0", + Bonus: "0", + HnR: "0", + JoinDate: "1700000000", + }, + } + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => mockResponse, + } as Response) + + const stats = await adapter.fetchStats("https://broadcasthe.net", "fake-api-key", API_URL) + expect(stats.ratio).toBe(0) + expect(Number.isFinite(stats.ratio)).toBe(true) + }) + + it("throws 'Invalid BTN API key' on 401", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response) + + await expect( + adapter.fetchStats("https://broadcasthe.net", "bad-key", API_URL) + ).rejects.toThrow("Invalid BTN API key") + }) + + it("throws rate-limit message on 503", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + } as Response) + + await expect( + adapter.fetchStats("https://broadcasthe.net", "fake-key", API_URL) + ).rejects.toThrow("rate limited") + }) + + it("extracts message from a JSON-RPC error body", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + jsonrpc: "2.0", + id: 1, + error: { code: -32001, message: "API key not found" }, + }), + } as Response) + + await expect( + adapter.fetchStats("https://broadcasthe.net", "fake-key", API_URL) + ).rejects.toThrow("API key not found") + }) + + it("throws a sanitized error on network failure", async () => { + const cause = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }) + vi.spyOn(global, "fetch").mockRejectedValueOnce(new TypeError("fetch failed", { cause })) + + await expect( + adapter.fetchStats("https://broadcasthe.net", "fake-key", API_URL) + ).rejects.toThrow("Failed to connect to api.broadcasthe.net") + }) + + it("sends a correctly structured JSON-RPC POST body with the API key in params", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 1, + result: { + UserID: "1", + Username: "user", + Upload: "0", + Download: "0", + Class: "User", + Lumens: "0", + Bonus: "0", + HnR: "0", + JoinDate: "1700000000", + }, + }), + } as Response) + + await adapter.fetchStats("https://broadcasthe.net", "my-secret-api-key", API_URL) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [calledUrl, init] = fetchSpy.mock.calls[0] + expect(calledUrl).toBe(API_URL) + expect(init?.method).toBe("POST") + const body = JSON.parse(init?.body as string) + expect(body).toEqual({ + jsonrpc: "2.0", + id: 1, + method: "userInfo", + params: ["my-secret-api-key"], + }) + }) +}) diff --git a/src/lib/adapters/btn.ts b/src/lib/adapters/btn.ts new file mode 100644 index 00000000..54aa508b --- /dev/null +++ b/src/lib/adapters/btn.ts @@ -0,0 +1,141 @@ +// src/lib/adapters/btn.ts + +import { computeBufferBytes } from "@/lib/data-transforms" +import { classifyFetchError } from "@/lib/error-utils" +import { localDateStr } from "@/lib/formatters" +import { ADAPTER_FETCH_TIMEOUT_MS } from "@/lib/limits" +import type { DebugApiCall, FetchOptions, TrackerAdapter, TrackerStats } from "./types" + +interface BtnUserInfoResult { + UserID: string + Username: string + Upload: string + Download: string + // The following fields are returned by the live API but not documented: + Class?: string + Lumens?: string + Bonus?: string + HnR?: string + JoinDate?: string +} + +interface BtnJsonRpcResponse { + id: number + result?: BtnUserInfoResult + error?: { code: number; message: string } +} + +function mapBtnResult(result: BtnUserInfoResult): TrackerStats { + const uploadedBytes = BigInt(result.Upload || "0") + const downloadedBytes = BigInt(result.Download || "0") + + let ratio = 0 + if (downloadedBytes > 0n) { + ratio = Number(uploadedBytes) / Number(downloadedBytes) + } + + const joinTimestamp = result.JoinDate ? parseInt(result.JoinDate, 10) : NaN + const joinedDate = + Number.isFinite(joinTimestamp) && joinTimestamp > 0 + ? localDateStr(new Date(joinTimestamp * 1000)) + : undefined + + return { + username: result.Username, + group: result.Class ?? "Unknown", + uploadedBytes, + downloadedBytes, + ratio, + bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), + seedingCount: 0, + leechingCount: 0, + seedbonus: parseFloat(result.Lumens ?? "0") || 0, + hitAndRuns: parseInt(result.HnR ?? "0", 10) || 0, + requiredRatio: 0, + warned: false, + freeleechTokens: parseFloat(result.Bonus ?? "0") || 0, + remoteUserId: parseInt(result.UserID, 10) || undefined, + joinedDate, + } +} + +async function callBtnUserInfo( + apiUrl: string, + apiKey: string, + hostname: string +): Promise { + let response: Response + try { + response = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "userInfo", + params: [apiKey], + }), + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + }) + } catch (err) { + throw classifyFetchError(err, hostname) + } + + if (response.status === 401) { + throw new Error("Invalid BTN API key") + } + if (response.status === 503) { + throw new Error("BTN API rate limited (150 calls/hour)") + } + if (!response.ok) { + throw new Error(`BTN API error: ${response.status} ${response.statusText}`) + } + + const data = (await response.json()) as BtnJsonRpcResponse + + if (data.error) { + throw new Error(data.error.message) + } + if (!data.result) { + throw new Error(`Unexpected response from ${hostname}: missing result`) + } + + return data +} + +export class BtnAdapter implements TrackerAdapter { + async fetchStats( + _baseUrl: string, + apiToken: string, + apiPath: string, + _options?: FetchOptions + ): Promise { + const hostname = new URL(apiPath).hostname + const data = await callBtnUserInfo(apiPath, apiToken, hostname) + // data.result is guaranteed by callBtnUserInfo + return mapBtnResult(data.result as BtnUserInfoResult) + } + + async fetchRaw( + _baseUrl: string, + apiToken: string, + apiPath: string, + _options?: FetchOptions + ): Promise { + const hostname = new URL(apiPath).hostname + try { + const data = await callBtnUserInfo(apiPath, apiToken, hostname) + const stats = mapBtnResult(data.result as BtnUserInfoResult) + return [{ label: "userInfo", endpoint: apiPath, data: stats, error: null }] + } catch (err) { + return [ + { + label: "userInfo", + endpoint: apiPath, + data: null, + error: err instanceof Error ? err.message : "Request failed", + }, + ] + } + } +} diff --git a/src/lib/adapters/constants.ts b/src/lib/adapters/constants.ts index f6c5e272..a40875a2 100644 --- a/src/lib/adapters/constants.ts +++ b/src/lib/adapters/constants.ts @@ -9,6 +9,9 @@ export const VALID_PLATFORM_TYPES = [ "avistaz", "digitalcore", "custom", + "btn", + "iptorrents", + "torrentleech", ] as const export type PlatformType = (typeof VALID_PLATFORM_TYPES)[number] @@ -22,4 +25,7 @@ export const DEFAULT_API_PATHS: Record = { mam: "/jsonLoad.php", avistaz: "/profile", digitalcore: "/api/v1/status", + btn: "https://api.broadcasthe.net/", + iptorrents: "/profile", + torrentleech: "/profile", } diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index 7bedadc5..97843f35 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -3,11 +3,14 @@ import type { Agent as HttpAgent } from "node:http" import { findRegistryEntry } from "@/data/tracker-registry" import { AvistazAdapter } from "./avistaz" +import { BtnAdapter } from "./btn" import { DigitalCoreAdapter } from "./digitalcore" import { GazelleAdapter } from "./gazelle" import { GGnAdapter } from "./ggn" +import { IptorrentsAdapter } from "./iptorrents" import { MamAdapter } from "./mam" import { NebulanceAdapter } from "./nebulance" +import { TorrentleechAdapter } from "./torrentleech" import type { FetchOptions, TrackerAdapter } from "./types" import { Unit3dAdapter } from "./unit3d" @@ -16,11 +19,14 @@ export { DEFAULT_API_PATHS, VALID_PLATFORM_TYPES } from "./constants" const adapters: Record = { avistaz: new AvistazAdapter(), + btn: new BtnAdapter(), digitalcore: new DigitalCoreAdapter(), gazelle: new GazelleAdapter(), ggn: new GGnAdapter(), + iptorrents: new IptorrentsAdapter(), mam: new MamAdapter(), nebulance: new NebulanceAdapter(), + torrentleech: new TorrentleechAdapter(), unit3d: new Unit3dAdapter(), } diff --git a/src/lib/adapters/iptorrents.test.ts b/src/lib/adapters/iptorrents.test.ts new file mode 100644 index 00000000..5be2c0a5 --- /dev/null +++ b/src/lib/adapters/iptorrents.test.ts @@ -0,0 +1,257 @@ +// src/lib/adapters/iptorrents.test.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" +import { IptorrentsAdapter, parseIptCredentials, parseIptProfile } from "./iptorrents" + +const STATS_HTML = ` +
+ testuser +
Ratio
0.740
+
Uploaded
14.5 GB
+
Downloaded
19.6 GB
+
Active Torrents
10 2
+
Bonus Points
55.4
+
` + +const FULL_PAGE = `${STATS_HTML}` + +describe("parseIptProfile", () => { + it("extracts stats from the header stats bar", () => { + const stats = parseIptProfile(FULL_PAGE) + expect(stats.username).toBe("testuser") + expect(stats.ratio).toBeCloseTo(0.74) + expect(stats.uploadedBytes).toBe(14_500_000_000n) + expect(stats.downloadedBytes).toBe(19_600_000_000n) + expect(stats.seedingCount).toBe(10) + expect(stats.leechingCount).toBe(2) + expect(stats.seedbonus).toBeCloseTo(55.4) + }) + + it("computes bufferBytes as upload minus download", () => { + const stats = parseIptProfile(FULL_PAGE) + expect(stats.bufferBytes).toBe(0n) + }) + + it("defaults group to 'User' with no VIP badge", () => { + const stats = parseIptProfile(FULL_PAGE) + expect(stats.group).toBe("User") + }) + + it("detects VIP group from the hdr-vip badge title", () => { + const vipPage = ` +
+ vipuser + +
Ratio
1.500
+
Uploaded
1.0 GB
+
Downloaded
500.0 MB
+
+ ` + const stats = parseIptProfile(vipPage) + expect(stats.group).toBe("VIP") + }) + + it("falls back to .up-stat cards when the stats bar has no tTipWrap items", () => { + const upStatPage = ` +
+ carduser +
Uploaded
2.0 GB
+
Downloaded
1.0 GB
+
Ratio
2.000
+
Balance
10.5
+
+ ` + const stats = parseIptProfile(upStatPage) + expect(stats.uploadedBytes).toBe(2_000_000_000n) + expect(stats.downloadedBytes).toBe(1_000_000_000n) + expect(stats.ratio).toBe(2) + expect(stats.seedbonus).toBe(10.5) + }) + + it("throws when the stats bar is missing (unauthenticated page)", () => { + const noStats = `

No stats here.

` + expect(() => parseIptProfile(noStats)).toThrow("stats bar") + }) + + it("detects a session-expired login redirect and throws", () => { + const loginRedirect = `` + expect(() => parseIptProfile(loginRedirect)).toThrow("Session expired") + }) + + it("detects a Cloudflare challenge and throws", () => { + const cfChallenge = `Just a moment...` + expect(() => parseIptProfile(cfChallenge)).toThrow("Cloudflare") + }) +}) + +describe("parseIptCredentials", () => { + it("parses a valid JSON credential blob", () => { + const json = JSON.stringify({ cookies: "uid=123; pass=abc123", userAgent: "Mozilla/5.0" }) + const creds = parseIptCredentials(json) + expect(creds.cookies).toBe("uid=123; pass=abc123") + expect(creds.userAgent).toBe("Mozilla/5.0") + }) + + it("throws on missing cookies field", () => { + const json = JSON.stringify({ userAgent: "Mozilla/5.0" }) + expect(() => parseIptCredentials(json)).toThrow("cookies") + }) + + it("throws on missing userAgent field", () => { + const json = JSON.stringify({ cookies: "uid=123; pass=abc123" }) + expect(() => parseIptCredentials(json)).toThrow() + }) + + it("throws on non-JSON string", () => { + expect(() => parseIptCredentials("not-json")).toThrow() + }) + + it("throws on empty cookies", () => { + const json = JSON.stringify({ cookies: " ", userAgent: "Mozilla/5.0" }) + expect(() => parseIptCredentials(json)).toThrow("cookies") + }) + + it("throws when cookie string has no key=value pairs", () => { + const json = JSON.stringify({ cookies: "some-random-text", userAgent: "Mozilla/5.0" }) + expect(() => parseIptCredentials(json)).toThrow("key=value") + }) + + it("throws when a non-ASCII character is present", () => { + const json = JSON.stringify({ cookies: "uid=123; pass=abc…", userAgent: "Mozilla/5.0" }) + expect(() => parseIptCredentials(json)).toThrow("non-ASCII") + }) +}) + +describe("IptorrentsAdapter.fetchStats — network error classification", () => { + const adapter = new IptorrentsAdapter() + const validToken = JSON.stringify({ cookies: "uid=123; pass=abc123", userAgent: "Mozilla/5.0" }) + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("unwraps TypeError wrapping ECONNREFUSED", async () => { + const cause = Object.assign(new Error("connect ECONNREFUSED 192.0.2.1:443"), { + code: "ECONNREFUSED", + }) + vi.spyOn(global, "fetch").mockRejectedValueOnce(new TypeError("fetch failed", { cause })) + + await expect(adapter.fetchStats("https://iptorrents.com", validToken, "")).rejects.toThrow( + "Failed to connect to iptorrents.com: ECONNREFUSED" + ) + }) + + it("propagates session-expired error when server returns a 302 redirect to login", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 302, + statusText: "Found", + headers: new Headers({ location: "/auth/login" }), + } as Response) + + await expect(adapter.fetchStats("https://iptorrents.com", validToken, "")).rejects.toThrow( + "Session expired" + ) + }) + + it("fetches the homepage with cookie and user-agent headers", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => FULL_PAGE, + } as Response) + + await adapter.fetchStats("https://iptorrents.com", validToken, "") + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [calledUrl, init] = fetchSpy.mock.calls[0] + expect(calledUrl).toBe("https://iptorrents.com/") + const headers = init?.headers as Record + expect(headers.Cookie).toBe("uid=123; pass=abc123") + expect(headers["User-Agent"]).toBe("Mozilla/5.0") + }) +}) + +describe("IptorrentsAdapter - redirect handling", () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("follows 302 to /t instead of throwing session expired", async () => { + const creds = JSON.stringify({ + cookies: "uid=123; pass=abc", + userAgent: "Mozilla/5.0", + }) + const statsHtml = `
testuser
Ratio
1.50
Uploaded
10 GB
Downloaded
5 GB
Active Torrents
3 1
Bonus Points
100
` + + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return new Response(null, { + status: 302, + headers: { location: "/t" }, + }) + } + return new Response(statsHtml, { status: 200 }) + }) + + const adapter = new IptorrentsAdapter() + const stats = await adapter.fetchStats("https://iptorrents.com", creds, "/") + expect(stats.username).toBe("testuser") + expect(stats.uploadedBytes).toBe(10_000_000_000n) + }) + + it("throws session expired when 302 points to /auth/login", async () => { + const creds = JSON.stringify({ + cookies: "uid=123; pass=expired", + userAgent: "Mozilla/5.0", + }) + + vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + return new Response(null, { + status: 302, + headers: { location: "/auth/login" }, + }) + }) + + const adapter = new IptorrentsAdapter() + await expect(adapter.fetchStats("https://iptorrents.com", creds, "/")).rejects.toThrow( + "Session expired" + ) + }) + + it('follows path-relative redirects (e.g. location: "t")', async () => { + const creds = JSON.stringify({ + cookies: "uid=123; pass=abc", + userAgent: "Mozilla/5.0", + }) + const statsHtml = `
testuser
Ratio
1.50
Uploaded
10 GB
Downloaded
5 GB
Active Torrents
3 1
Bonus Points
100
` + + let callCount = 0 + vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { + callCount++ + const reqUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + + if (callCount === 1) { + expect(reqUrl).toBe("https://iptorrents.com/") + return new Response(null, { + status: 302, + headers: { location: "t" }, // Relative path without leading slash + }) + } + + if (callCount === 2) { + expect(reqUrl).toBe("https://iptorrents.com/t") + return new Response(statsHtml, { status: 200 }) + } + + return new Response(null, { status: 404 }) + }) + + const adapter = new IptorrentsAdapter() + const stats = await adapter.fetchStats("https://iptorrents.com", creds, "/") + expect(stats.username).toBe("testuser") + expect(callCount).toBe(2) + }) +}) diff --git a/src/lib/adapters/iptorrents.ts b/src/lib/adapters/iptorrents.ts new file mode 100644 index 00000000..cdefd6ac --- /dev/null +++ b/src/lib/adapters/iptorrents.ts @@ -0,0 +1,366 @@ +// src/lib/adapters/iptorrents.ts +// +// Functions: parseIptCredentials, parseIptProfile, fetchHtml, IptorrentsAdapter + +import { type HTMLElement as ParsedElement, parse as parseHtml } from "node-html-parser" +import { computeBufferBytes } from "@/lib/data-transforms" +import { classifyFetchError, sanitizeNetworkError } from "@/lib/error-utils" +import { ADAPTER_FETCH_TIMEOUT_MS } from "@/lib/limits" +import { parseBytes } from "@/lib/parser" +import type { DebugApiCall, FetchOptions, TrackerAdapter, TrackerStats } from "./types" + +// --------------------------------------------------------------------------- +// Credential handling +// --------------------------------------------------------------------------- + +export interface IptCredentials { + cookies: string + userAgent: string +} + +export function parseIptCredentials(apiToken: string): IptCredentials { + let parsed: unknown + try { + parsed = JSON.parse(apiToken) + } catch { + throw new Error("IPTorrents credentials must be a JSON object with cookies and userAgent") + } + + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as Record).cookies !== "string" || + typeof (parsed as Record).userAgent !== "string" + ) { + throw new Error( + "IPTorrents credentials must contain cookies (string) and userAgent (string)" + ) + } + + const { cookies, userAgent } = parsed as Record + if (!cookies.trim()) throw new Error("IPTorrents credentials: cookies cannot be empty") + if (!userAgent.trim()) throw new Error("IPTorrents credentials: userAgent cannot be empty") + + // Strip "Cookie: " prefix if user copied from raw headers view + const trimmedCookies = cookies.trim().replace(/^Cookie:\s*/i, "") + const cookieNameOnly = /^(cf_clearance|uid|pass|[a-z]+x_session|remember_web_\w+|XSRF-TOKEN)$/i + if (cookieNameOnly.test(trimmedCookies)) { + throw new Error( + `It looks like you pasted a cookie name ("${trimmedCookies}") instead of the full Cookie header value. Copy the entire value after "Cookie:" in DevTools.` + ) + } + + if (!trimmedCookies.includes("=")) { + throw new Error( + "Cookie string doesn't look right — it should contain key=value pairs (i.e. uid=123; pass=abc123)" + ) + } + + // HTTP headers only allow byte-safe characters (0-255). Non-ASCII chars like + // ellipsis (U+2026) appear when DevTools truncates long values during copy. + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional byte-range check + const nonAscii = trimmedCookies.match(/[^\x00-\xFF]/) + if (nonAscii) { + const char = nonAscii[0] + const code = char.codePointAt(0) + const idx = nonAscii.index + throw new Error( + `Cookie string contains a non-ASCII character ("${char}", U+${code?.toString(16).toUpperCase().padStart(4, "0")}) at position ${idx}. ` + + "This usually means the browser truncated a long value when copying. Re-copy the full cookie string from DevTools." + ) + } + + return { cookies: trimmedCookies, userAgent } +} + +// --------------------------------------------------------------------------- +// HTML parsing helpers +// --------------------------------------------------------------------------- + +/** Extracts the value text of a tTipWrap item once its `.tTip` label text is stripped. */ +function valueAfterLabel(wrap: ParsedElement, label: string): string { + const fullText = wrap.textContent ?? "" + return fullText.replace(label, "").replace(/\s+/g, " ").trim() +} + +/** Fallback for the newer `.up-stat` card UI (value in `.up-stat-value`, label in `.up-stat-label`). */ +function parseUpStatCards(doc: ParsedElement): { + uploadedBytes?: bigint + downloadedBytes?: bigint + ratio?: number + seedbonus?: number +} { + const result: { + uploadedBytes?: bigint + downloadedBytes?: bigint + ratio?: number + seedbonus?: number + } = {} + + for (const card of doc.querySelectorAll(".up-stat")) { + const label = card.querySelector(".up-stat-label")?.textContent?.trim() ?? "" + const value = card.querySelector(".up-stat-value")?.textContent?.trim() ?? "" + if (!label || !value) continue + + if (/uploaded/i.test(label)) { + try { + result.uploadedBytes = parseBytes(value) + } catch { + // ignore unparseable value, primary parse path already failed anyway + } + } else if (/downloaded/i.test(label)) { + try { + result.downloadedBytes = parseBytes(value) + } catch { + // ignore + } + } else if (/ratio/i.test(label)) { + const match = value.match(/[\d.]+/) + if (match) result.ratio = parseFloat(match[0]) + } else if (/balance/i.test(label)) { + const match = value.match(/[\d,.]+/) + if (match) result.seedbonus = parseFloat(match[0].replace(/,/g, "")) + } + } + + return result +} + +// --------------------------------------------------------------------------- +// Profile page parser +// --------------------------------------------------------------------------- + +export function parseIptProfile(html: string): TrackerStats { + if (html.includes("/auth/login")) { + throw new Error("Session expired — browser cookies need to be refreshed") + } + + if ( + html.includes("Just a moment...") || + html.includes("cf_chl_opt") || + html.includes("challenges.cloudflare.com/turnstile") + ) { + throw new Error("Cloudflare challenge detected — cookies need refreshing") + } + + const doc = parseHtml(html) + + const statsDiv = doc.querySelector(".stats") + if (!statsDiv) { + throw new Error( + "Could not find stats bar on IPTorrents page — the page may not be authenticated" + ) + } + + const username = doc.querySelector(".uname")?.textContent?.trim() ?? "" + + let ratio = 0 + let uploadedBytes = 0n + let downloadedBytes = 0n + let seedingCount = 0 + let leechingCount = 0 + let seedbonus = 0 + + for (const wrap of statsDiv.querySelectorAll(".tTipWrap")) { + const label = wrap.querySelector(".tTip")?.textContent?.trim() ?? "" + if (!label) continue + const value = valueAfterLabel(wrap, label) + + if (label === "Ratio") { + const match = value.match(/[\d.]+/) + if (match) ratio = parseFloat(match[0]) + } else if (label === "Uploaded") { + try { + uploadedBytes = parseBytes(value) + } catch { + // leave at 0, fallback handled below + } + } else if (label === "Downloaded") { + try { + downloadedBytes = parseBytes(value) + } catch { + // leave at 0, fallback handled below + } + } else if (label === "Active Torrents") { + const nums = value.match(/\d+/g) + if (nums && nums.length >= 2) { + seedingCount = parseInt(nums[0], 10) || 0 + leechingCount = parseInt(nums[1], 10) || 0 + } + } else if (label === "Bonus Points") { + const match = value.match(/[\d,.]+/) + if (match) seedbonus = parseFloat(match[0].replace(/,/g, "")) + } + } + + if (uploadedBytes === 0n && downloadedBytes === 0n) { + const fallback = parseUpStatCards(doc) + if (fallback.uploadedBytes !== undefined) uploadedBytes = fallback.uploadedBytes + if (fallback.downloadedBytes !== undefined) downloadedBytes = fallback.downloadedBytes + if (fallback.ratio !== undefined) ratio = fallback.ratio + if (fallback.seedbonus !== undefined) seedbonus = fallback.seedbonus + } + + let group = "User" + const vipEl = doc.querySelector(".hdr-vip") + if (vipEl && /VIP/i.test(vipEl.getAttribute("title") ?? "")) { + group = "VIP" + } + + return { + username, + group, + uploadedBytes, + downloadedBytes, + ratio, + bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), + seedingCount, + leechingCount, + seedbonus, + hitAndRuns: null, + requiredRatio: null, + warned: null, + freeleechTokens: null, + } +} + +// --------------------------------------------------------------------------- +// HTML fetcher — direct fetch or proxy +// --------------------------------------------------------------------------- + +async function fetchHtml( + url: string, + cookies: string, + userAgent: string, + proxyAgent?: FetchOptions["proxyAgent"] +): Promise { + const headers: Record = { + Cookie: cookies, + "User-Agent": userAgent, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + DNT: "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin", + Connection: "keep-alive", + } + + if (proxyAgent) { + const { proxyFetch } = await import("@/lib/tunnel") + const result = await proxyFetch(url, proxyAgent, { headers }) + if (!result.ok) { + throw new Error( + sanitizeNetworkError( + `${result.status} ${result.statusText}`, + `IPTorrents page fetch failed: ${result.status}` + ) + ) + } + return (await result.buffer()).toString("utf8") + } + + let response: Response + try { + response = await fetch(url, { + headers, + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + redirect: "manual", + }) + } catch (err) { + throw classifyFetchError(err, new URL(url).hostname) + } + + // 302 redirect: could be normal (e.g. / → /t) or session expiry (→ /auth/login) + if (response.status === 302 || response.status === 301) { + const location = response.headers.get("location") ?? "" + // If redirected to a login page, the session has expired + if (/\/auth\/login|\/login/i.test(location)) { + throw new Error("Session expired — browser cookies need to be refreshed") + } + // Otherwise follow the redirect (bounded to 3 hops max) + let currentUrl = new URL(location, url).href + for (let hop = 0; hop < 3; hop++) { + try { + response = await fetch(currentUrl, { + headers, + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + redirect: "manual", + }) + } catch (err) { + throw classifyFetchError(err, new URL(currentUrl).hostname) + } + if (response.status === 302 || response.status === 301) { + const nextLocation = response.headers.get("location") ?? "" + if (/\/auth\/login|\/login/i.test(nextLocation)) { + throw new Error("Session expired — browser cookies need to be refreshed") + } + if (!nextLocation) break + currentUrl = new URL(nextLocation, currentUrl).href + } else { + break + } + } + if (response.status === 302 || response.status === 301) { + throw new Error("Too many redirects from IPTorrents") + } + } + + if (!response.ok) { + throw new Error( + sanitizeNetworkError( + `${response.status} ${response.statusText}`, + `IPTorrents page fetch failed: ${response.status}` + ) + ) + } + + return response.text() +} + +// --------------------------------------------------------------------------- +// Adapter class +// --------------------------------------------------------------------------- + +export class IptorrentsAdapter implements TrackerAdapter { + async fetchStats( + baseUrl: string, + apiToken: string, + _apiPath: string, + options?: FetchOptions + ): Promise { + const creds = parseIptCredentials(apiToken) + // The stats bar is present on every authenticated page, so the homepage + // is used rather than a specific profile URL (which requires the numeric + // uid to build). + const homeUrl = `${baseUrl}/` + const html = await fetchHtml(homeUrl, creds.cookies, creds.userAgent, options?.proxyAgent) + return parseIptProfile(html) + } + + async fetchRaw( + baseUrl: string, + apiToken: string, + _apiPath: string, + options?: FetchOptions + ): Promise { + const calls: DebugApiCall[] = [] + const creds = parseIptCredentials(apiToken) + const homeUrl = `${baseUrl}/` + + try { + const html = await fetchHtml(homeUrl, creds.cookies, creds.userAgent, options?.proxyAgent) + const stats = parseIptProfile(html) + calls.push({ label: "Home Page", endpoint: "/", data: stats, error: null }) + } catch (err) { + calls.push({ + label: "Home Page", + endpoint: "/", + data: null, + error: err instanceof Error ? err.message : "Request failed", + }) + } + + return calls + } +} diff --git a/src/lib/adapters/torrentleech.test.ts b/src/lib/adapters/torrentleech.test.ts new file mode 100644 index 00000000..a56febed --- /dev/null +++ b/src/lib/adapters/torrentleech.test.ts @@ -0,0 +1,158 @@ +// src/lib/adapters/torrentleech.test.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" +import { parseTlCredentials, parseTlProfile, TorrentleechAdapter } from "./torrentleech" + +const PROFILE_HTML = ` +
+ uploaded: + 10.5 GB +
+
+ downloaded: + 5.25 GB +
+
+ ratio: + 2.000 +
` + +const FULL_PROFILE_PAGE = `${PROFILE_HTML}` + +function setCookieResponse(cookies: string[], overrides: Partial = {}): Response { + return { + ok: true, + status: 302, + statusText: "Found", + headers: { getSetCookie: () => cookies } as unknown as Headers, + ...overrides, + } as Response +} + +describe("parseTlProfile", () => { + it("extracts uploaded/downloaded/ratio from the profile page", () => { + const stats = parseTlProfile(FULL_PROFILE_PAGE, "testuser") + expect(stats.username).toBe("testuser") + expect(stats.uploadedBytes).toBe(10_500_000_000n) + expect(stats.downloadedBytes).toBe(5_250_000_000n) + expect(stats.ratio).toBeCloseTo(2.0) + expect(stats.bufferBytes).toBe(10_500_000_000n - 5_250_000_000n) + }) + + it("treats an infinite ratio (∞) as 0", () => { + const infPage = ` +
0 B
+
0 B
+
+ ` + const stats = parseTlProfile(infPage, "testuser") + expect(stats.ratio).toBe(0) + }) + + it("detects session expiry from a login page redirect", () => { + const loginPage = `
login
` + expect(() => parseTlProfile(loginPage, "testuser")).toThrow("Session expired") + }) + + it("detects Cloudflare challenge and throws", () => { + const cfChallenge = `Just a moment...` + expect(() => parseTlProfile(cfChallenge, "testuser")).toThrow("Cloudflare") + }) + + it("throws when profile stats are missing (unauthenticated page)", () => { + const noStats = `

Nothing here

` + expect(() => parseTlProfile(noStats, "testuser")).toThrow("profile stats") + }) +}) + +describe("parseTlCredentials", () => { + it("parses a valid JSON credential blob", () => { + const json = JSON.stringify({ username: "testuser", password: "hunter2" }) + const creds = parseTlCredentials(json) + expect(creds.username).toBe("testuser") + expect(creds.password).toBe("hunter2") + }) + + it("throws on missing username field", () => { + const json = JSON.stringify({ password: "hunter2" }) + expect(() => parseTlCredentials(json)).toThrow() + }) + + it("throws on missing password field", () => { + const json = JSON.stringify({ username: "testuser" }) + expect(() => parseTlCredentials(json)).toThrow() + }) + + it("throws on non-JSON string", () => { + expect(() => parseTlCredentials("not-json")).toThrow() + }) +}) + +describe("TorrentleechAdapter.fetchStats", () => { + const adapter = new TorrentleechAdapter() + const validToken = JSON.stringify({ username: "testuser", password: "hunter2" }) + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("logs in, fetches the profile, and returns parsed stats", async () => { + const fetchSpy = vi + .spyOn(global, "fetch") + .mockResolvedValueOnce(setCookieResponse(["tluid=abc123; Path=/", "tlpass=xyz; Path=/"])) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => FULL_PROFILE_PAGE, + } as Response) + + const stats = await adapter.fetchStats("https://www.torrentleech.org", validToken, "") + + expect(stats.username).toBe("testuser") + expect(stats.uploadedBytes).toBe(10_500_000_000n) + expect(fetchSpy).toHaveBeenCalledTimes(2) + + const loginCall = fetchSpy.mock.calls[0] + expect(loginCall[0]).toBe("https://www.torrentleech.org/user/account/login/") + expect(loginCall[1]?.method).toBe("POST") + + const profileCall = fetchSpy.mock.calls[1] + expect(profileCall[0]).toBe("https://www.torrentleech.org/profile/testuser") + const profileHeaders = profileCall[1]?.headers as Record + expect(profileHeaders.Cookie).toContain("tluid=abc123") + }) + + it("throws 'Invalid TorrentLeech credentials' when login response has no tluid cookie", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce( + setCookieResponse([], { ok: true, status: 200, statusText: "OK" }) + ) + + await expect( + adapter.fetchStats("https://www.torrentleech.org", validToken, "") + ).rejects.toThrow("Invalid TorrentLeech credentials") + }) + + it("detects a Cloudflare challenge on the profile page", async () => { + vi.spyOn(global, "fetch") + .mockResolvedValueOnce(setCookieResponse(["tluid=abc123; Path=/"])) + .mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + `Just a moment...`, + } as Response) + + await expect( + adapter.fetchStats("https://www.torrentleech.org", validToken, "") + ).rejects.toThrow("Cloudflare") + }) + + it("throws a sanitized error on network failure during login", async () => { + const cause = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }) + vi.spyOn(global, "fetch").mockRejectedValueOnce(new TypeError("fetch failed", { cause })) + + await expect( + adapter.fetchStats("https://www.torrentleech.org", validToken, "") + ).rejects.toThrow("Failed to connect to www.torrentleech.org: ECONNREFUSED") + }) +}) diff --git a/src/lib/adapters/torrentleech.ts b/src/lib/adapters/torrentleech.ts new file mode 100644 index 00000000..e2906395 --- /dev/null +++ b/src/lib/adapters/torrentleech.ts @@ -0,0 +1,272 @@ +// src/lib/adapters/torrentleech.ts +// +// Functions: parseTlCredentials, login, parseTlProfile, fetchHtml, TorrentleechAdapter + +import { type HTMLElement as ParsedElement, parse as parseHtml } from "node-html-parser" +import { computeBufferBytes } from "@/lib/data-transforms" +import { classifyFetchError, sanitizeNetworkError } from "@/lib/error-utils" +import { ADAPTER_FETCH_TIMEOUT_MS } from "@/lib/limits" +import { parseBytes } from "@/lib/parser" +import type { DebugApiCall, FetchOptions, TrackerAdapter, TrackerStats } from "./types" + +// --------------------------------------------------------------------------- +// Credential handling +// --------------------------------------------------------------------------- + +export interface TlCredentials { + username: string + password: string +} + +export function parseTlCredentials(apiToken: string): TlCredentials { + let parsed: unknown + try { + parsed = JSON.parse(apiToken) + } catch { + throw new Error("TorrentLeech credentials must be a JSON object with username and password") + } + + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as Record).username !== "string" || + typeof (parsed as Record).password !== "string" + ) { + throw new Error( + "TorrentLeech credentials must contain username (string) and password (string)" + ) + } + + const { username, password } = parsed as Record + if (!username.trim()) throw new Error("TorrentLeech credentials: username cannot be empty") + if (!password.trim()) throw new Error("TorrentLeech credentials: password cannot be empty") + + return { username: username.trim(), password } +} + +// --------------------------------------------------------------------------- +// Login flow +// --------------------------------------------------------------------------- + +/** + * Logs in and returns the Cookie header string built from Set-Cookie response headers. + * tunnel.ts's proxyFetch is GET-only with no body support, so login always goes + * through a direct fetch — only the subsequent profile page fetch honors proxyAgent. + */ +async function login(baseUrl: string, username: string, password: string): Promise { + const loginUrl = `${baseUrl}/user/account/login/` + const body = new URLSearchParams({ username, password }).toString() + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + } + + let response: Response + try { + response = await fetch(loginUrl, { + method: "POST", + headers, + body, + redirect: "manual", + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + }) + } catch (err) { + throw classifyFetchError(err, new URL(baseUrl).hostname) + } + const setCookieHeaders = response.headers.getSetCookie?.() ?? [] + + const cookiePairs = setCookieHeaders + .map((raw) => raw.split(";")[0]?.trim()) + .filter((pair): pair is string => Boolean(pair)) + + const cookieString = cookiePairs.join("; ") + if (!cookieString.includes("tluid=")) { + throw new Error("Invalid TorrentLeech credentials") + } + + return cookieString +} + +// --------------------------------------------------------------------------- +// Profile page parser +// --------------------------------------------------------------------------- + +function textAfterNode(root: ParsedElement, selector: string): string { + return root.querySelector(selector)?.textContent?.trim() ?? "" +} + +export function parseTlProfile(html: string, username: string): TrackerStats { + if (html.includes("/user/account/login")) { + throw new Error("Session expired — TorrentLeech cookies need to be refreshed") + } + + if ( + html.includes("Just a moment...") || + html.includes("cf_chl_opt") || + html.includes("challenges.cloudflare.com/turnstile") + ) { + throw new Error("Cloudflare challenge detected — TorrentLeech session needs refreshing") + } + + const doc = parseHtml(html) + + const uploadedText = textAfterNode(doc, ".profile-uploaded-details") + const downloadedText = textAfterNode(doc, ".profile-downloaded-details") + const ratioText = textAfterNode(doc, ".profile-ratio-details") + + if (!uploadedText && !downloadedText) { + throw new Error( + "Could not find profile stats on TorrentLeech page — the page may not be authenticated" + ) + } + + const uploadedBytes = uploadedText ? parseBytes(uploadedText) : 0n + const downloadedBytes = downloadedText ? parseBytes(downloadedText) : 0n + + let ratio = 0 + if (ratioText && !ratioText.includes("∞") && !/infin/i.test(ratioText)) { + ratio = parseFloat(ratioText) || 0 + } + + // Active seeding/leeching counts appear as header menu items with tooltip + // titles ("Uploaded (Seeding)" / "Downloaded (Leeching)"). + let seedingCount = 0 + let leechingCount = 0 + for (const item of doc.querySelectorAll(".div-menu-item")) { + const title = item.getAttribute("title") ?? "" + const numMatch = item.textContent?.match(/[\d,]+/) + const count = numMatch ? parseInt(numMatch[0].replace(/,/g, ""), 10) : 0 + if (/seeding/i.test(title)) seedingCount = count + else if (/leeching/i.test(title)) leechingCount = count + } + + // TL Points, often shown near a "TL Points:" label. + let seedbonus = 0 + const bodyText = doc.textContent ?? "" + const pointsMatch = bodyText.match(/TL Points:\s*([\d,.]+)/i) + if (pointsMatch) seedbonus = parseFloat(pointsMatch[1].replace(/,/g, "")) + + // Class badge, if present in a profile field/label pair. + let group = "User" + const classMatch = bodyText.match(/Class:?\s*\n?\s*([A-Za-z][A-Za-z ]*)/) + if (classMatch) group = classMatch[1].trim() + + return { + username, + group, + uploadedBytes, + downloadedBytes, + ratio, + bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), + seedingCount, + leechingCount, + seedbonus, + hitAndRuns: null, + requiredRatio: null, + warned: null, + freeleechTokens: null, + } +} + +// --------------------------------------------------------------------------- +// HTML fetcher — direct fetch or proxy +// --------------------------------------------------------------------------- + +async function fetchHtml( + url: string, + cookies: string, + proxyAgent?: FetchOptions["proxyAgent"] +): Promise { + const headers: Record = { + Cookie: cookies, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + } + + if (proxyAgent) { + const { proxyFetch } = await import("@/lib/tunnel") + const result = await proxyFetch(url, proxyAgent, { headers }) + if (!result.ok) { + throw new Error( + sanitizeNetworkError( + `${result.status} ${result.statusText}`, + `TorrentLeech page fetch failed: ${result.status}` + ) + ) + } + return (await result.buffer()).toString("utf8") + } + + let response: Response + try { + response = await fetch(url, { + headers, + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + redirect: "manual", + }) + } catch (err) { + throw classifyFetchError(err, new URL(url).hostname) + } + + if (response.status === 302) { + throw new Error("Session expired — TorrentLeech cookies need to be refreshed") + } + + if (!response.ok) { + throw new Error( + sanitizeNetworkError( + `${response.status} ${response.statusText}`, + `TorrentLeech page fetch failed: ${response.status}` + ) + ) + } + + return response.text() +} + +// --------------------------------------------------------------------------- +// Adapter class +// --------------------------------------------------------------------------- + +export class TorrentleechAdapter implements TrackerAdapter { + async fetchStats( + baseUrl: string, + apiToken: string, + _apiPath: string, + options?: FetchOptions + ): Promise { + const creds = parseTlCredentials(apiToken) + const cookies = await login(baseUrl, creds.username, creds.password) + const profileUrl = `${baseUrl}/profile/${encodeURIComponent(creds.username)}` + const html = await fetchHtml(profileUrl, cookies, options?.proxyAgent) + return parseTlProfile(html, creds.username) + } + + async fetchRaw( + baseUrl: string, + apiToken: string, + _apiPath: string, + options?: FetchOptions + ): Promise { + const calls: DebugApiCall[] = [] + const creds = parseTlCredentials(apiToken) + const endpoint = `/profile/${creds.username}` + + try { + const cookies = await login(baseUrl, creds.username, creds.password) + const profileUrl = `${baseUrl}${endpoint}` + const html = await fetchHtml(profileUrl, cookies, options?.proxyAgent) + const stats = parseTlProfile(html, creds.username) + calls.push({ label: "Profile Page", endpoint, data: stats, error: null }) + } catch (err) { + calls.push({ + label: "Profile Page", + endpoint, + data: null, + error: err instanceof Error ? err.message : "Request failed", + }) + } + + return calls + } +} diff --git a/src/lib/parser.test.ts b/src/lib/parser.test.ts index 0ca0c235..9d9b44c4 100644 --- a/src/lib/parser.test.ts +++ b/src/lib/parser.test.ts @@ -73,3 +73,24 @@ describe("parseBytes - security", () => { expect(() => parseBytes("-100 GiB")).toThrow() }) }) + +describe("parseBytes - infinity values", () => { + it("returns 0n for ∞", () => { + expect(parseBytes("∞")).toBe(0n) + }) + it("returns 0n for Inf", () => { + expect(parseBytes("Inf")).toBe(0n) + }) + it("returns 0n for -∞", () => { + expect(parseBytes("-∞")).toBe(0n) + }) + it("returns 0n for -Inf", () => { + expect(parseBytes("-Inf")).toBe(0n) + }) + it("returns 0n for inf (lowercase)", () => { + expect(parseBytes("inf")).toBe(0n) + }) + it("returns 0n for INF (uppercase)", () => { + expect(parseBytes("INF")).toBe(0n) + }) +}) diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 3a674c3a..01e2e374 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -53,6 +53,13 @@ function multiplyDecimalStringByBigInt(valueStr: string, multiplier: bigint): bi export function parseBytes(formatted: string): bigint { const trimmed = formatted.trim() + + // Handle infinity/unlimited buffer values from some trackers (e.g. Zenith) + const lowerTrimmed = trimmed.toLowerCase() + if (trimmed === "∞" || lowerTrimmed === "inf" || trimmed === "-∞" || lowerTrimmed === "-inf") { + return 0n + } + const match = trimmed.match(/^([\d.]+)\s*([A-Za-z]+)$/) if (!match) { throw new Error(`Invalid byte format: "${formatted}"`)