From 44269bc4eb356a00e15af06c129f8648ed589df3 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sat, 25 Jul 2026 17:22:39 -0400 Subject: [PATCH 01/10] feat: add Zenith, fix BTN, add IPTorrents & TorrentLeech adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Zenith (znth.cx) - New registry entry (UNIT3D platform) - Uses existing Unit3dAdapter ### BroadcasTheNet (BTN) - Fixed: was listed as Gazelle 'Unvalidated' — never worked - New custom BTN JSON-RPC adapter (platform: 'btn') - POST JSON-RPC to api.broadcasthe.net, maps Lumens→seedbonus - Un-stucks the BTN entry — now fully functional ### IPTorrents (IPT) - New custom adapter (cookie + HTML scrape) - Parses .tTipWrap/.tTip header stats and .up-stat cards - Credential format: {cookies, userAgent} JSON ### TorrentLeech (TL) - New custom adapter (username/password login + HTML scrape) - POSTs to /user/account/login/, captures tluid/tlpass cookies - Parses .profile-*-details spans from profile page - Credential format: {username, password} JSON ### Files - 7 new files, 6 edited files - All 2848 tests pass (78 new) - TypeScript and biome lint clean --- src/data/trackers/broadcasthenet.ts | 9 +- src/data/trackers/index.ts | 3 + src/data/trackers/iptorrents.ts | 12 +- src/data/trackers/torrentleech.ts | 10 +- src/data/trackers/zenith.ts | 51 ++++ src/lib/adapters/btn.test.ts | 200 +++++++++++++++ src/lib/adapters/btn.ts | 140 +++++++++++ src/lib/adapters/constants.ts | 6 + src/lib/adapters/index.ts | 6 + src/lib/adapters/iptorrents.test.ts | 172 +++++++++++++ src/lib/adapters/iptorrents.ts | 336 ++++++++++++++++++++++++++ src/lib/adapters/torrentleech.test.ts | 158 ++++++++++++ src/lib/adapters/torrentleech.ts | 272 +++++++++++++++++++++ 13 files changed, 1359 insertions(+), 16 deletions(-) create mode 100644 src/data/trackers/zenith.ts create mode 100644 src/lib/adapters/btn.test.ts create mode 100644 src/lib/adapters/btn.ts create mode 100644 src/lib/adapters/iptorrents.test.ts create mode 100644 src/lib/adapters/iptorrents.ts create mode 100644 src/lib/adapters/torrentleech.test.ts create mode 100644 src/lib/adapters/torrentleech.ts 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..8179a287 --- /dev/null +++ b/src/lib/adapters/btn.test.ts @@ -0,0 +1,200 @@ +// 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("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..3eb26084 --- /dev/null +++ b/src/lib/adapters/btn.ts @@ -0,0 +1,140 @@ +// 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 + 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(parseInt(result.Upload, 10) || 0) + const downloadedBytes = BigInt(parseInt(result.Download, 10) || 0) + + let ratio = 0 + if (downloadedBytes > 0n) { + ratio = Number(uploadedBytes) / Number(downloadedBytes) + } + + const joinTimestamp = parseInt(result.JoinDate, 10) + const joinedDate = + Number.isFinite(joinTimestamp) && joinTimestamp > 0 + ? localDateStr(new Date(joinTimestamp * 1000)) + : undefined + + return { + username: result.Username, + group: result.Class, + uploadedBytes, + downloadedBytes, + ratio, + bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), + seedingCount: 0, + leechingCount: 0, + seedbonus: parseFloat(result.Lumens) || 0, + hitAndRuns: parseInt(result.HnR, 10) || 0, + requiredRatio: 0, + warned: false, + freeleechTokens: parseFloat(result.Bonus) || 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..9d259af4 --- /dev/null +++ b/src/lib/adapters/iptorrents.test.ts @@ -0,0 +1,172 @@ +// 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", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: false, + status: 302, + statusText: "Found", + } 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") + }) +}) diff --git a/src/lib/adapters/iptorrents.ts b/src/lib/adapters/iptorrents.ts new file mode 100644 index 00000000..8f76a300 --- /dev/null +++ b/src/lib/adapters/iptorrents.ts @@ -0,0 +1,336 @@ +// 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 usually means the session expired and the server redirected to login + if (response.status === 302) { + throw new Error("Session expired — browser cookies need to be refreshed") + } + + 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 + } +} From 3cceb56b9cf6bf38472b5e897275eaa0b0971528 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sat, 25 Jul 2026 17:59:34 -0400 Subject: [PATCH 02/10] fix(tracker-adapters): btn defensive optional fields + bigint precision H1: mark Class, Lumens, Bonus, HnR, JoinDate as optional in BtnUserInfoResult. use nullish coalescing fallbacks in mapBtnResult so adapter degrades gracefully if BTN changes API response shape. H2: replace BigInt(parseInt()) with BigInt(string) to preserve full precision for byte values exceeding Number.MAX_SAFE_INTEGER. add test for missing optional fields scenario. Fixes: QA H1+H2 --- src/lib/adapters/btn.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/adapters/btn.ts | 25 +++++++++++++------------ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/lib/adapters/btn.test.ts b/src/lib/adapters/btn.test.ts index 8179a287..5eca185b 100644 --- a/src/lib/adapters/btn.test.ts +++ b/src/lib/adapters/btn.test.ts @@ -61,6 +61,36 @@ describe("BtnAdapter", () => { 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, diff --git a/src/lib/adapters/btn.ts b/src/lib/adapters/btn.ts index 3eb26084..54aa508b 100644 --- a/src/lib/adapters/btn.ts +++ b/src/lib/adapters/btn.ts @@ -11,11 +11,12 @@ interface BtnUserInfoResult { Username: string Upload: string Download: string - Class: string - Lumens: string - Bonus: string - HnR: string - JoinDate: 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 { @@ -25,15 +26,15 @@ interface BtnJsonRpcResponse { } function mapBtnResult(result: BtnUserInfoResult): TrackerStats { - const uploadedBytes = BigInt(parseInt(result.Upload, 10) || 0) - const downloadedBytes = BigInt(parseInt(result.Download, 10) || 0) + 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 = parseInt(result.JoinDate, 10) + const joinTimestamp = result.JoinDate ? parseInt(result.JoinDate, 10) : NaN const joinedDate = Number.isFinite(joinTimestamp) && joinTimestamp > 0 ? localDateStr(new Date(joinTimestamp * 1000)) @@ -41,18 +42,18 @@ function mapBtnResult(result: BtnUserInfoResult): TrackerStats { return { username: result.Username, - group: result.Class, + group: result.Class ?? "Unknown", uploadedBytes, downloadedBytes, ratio, bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), seedingCount: 0, leechingCount: 0, - seedbonus: parseFloat(result.Lumens) || 0, - hitAndRuns: parseInt(result.HnR, 10) || 0, + seedbonus: parseFloat(result.Lumens ?? "0") || 0, + hitAndRuns: parseInt(result.HnR ?? "0", 10) || 0, requiredRatio: 0, warned: false, - freeleechTokens: parseFloat(result.Bonus) || 0, + freeleechTokens: parseFloat(result.Bonus ?? "0") || 0, remoteUserId: parseInt(result.UserID, 10) || undefined, joinedDate, } From cfb1a40f617bd23aa48fdc0761473420414a2e90 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sun, 26 Jul 2026 14:01:07 -0400 Subject: [PATCH 03/10] =?UTF-8?q?fix(tracker-adapters):=20handle=20zenith?= =?UTF-8?q?=20=E2=88=9E=20buffer=20+=20ipt=20302=20redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseBytes: return 0n for ∞/Inf/-∞/-Inf values so UNIT3D trackers (e.g. zenith) that return infinite buffer don't crash the poll with 'Invalid byte format' error. iptorrents: inspect Location header on 302 rather than throwing session-expired on every redirect. iptorrents 302s logged-in users from / to /t — only throw when redirected to a login page. follow non-login redirects up to 3 hops. add 6 new tests (4 parseBytes ∞ variants, 2 ipt redirect). 2855 total tests pass, tsc clean. Fixes: 86bae41991a748 --- src/lib/adapters/iptorrents.test.ts | 53 ++++++++++++++++++++++++++++- src/lib/adapters/iptorrents.ts | 39 +++++++++++++++++++-- src/lib/parser.test.ts | 15 ++++++++ src/lib/parser.ts | 6 ++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/lib/adapters/iptorrents.test.ts b/src/lib/adapters/iptorrents.test.ts index 9d259af4..e05805a1 100644 --- a/src/lib/adapters/iptorrents.test.ts +++ b/src/lib/adapters/iptorrents.test.ts @@ -141,11 +141,12 @@ describe("IptorrentsAdapter.fetchStats — network error classification", () => ) }) - it("propagates session-expired error when server returns a 302 redirect", async () => { + 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( @@ -170,3 +171,53 @@ describe("IptorrentsAdapter.fetchStats — network error classification", () => 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" + ) + }) +}) diff --git a/src/lib/adapters/iptorrents.ts b/src/lib/adapters/iptorrents.ts index 8f76a300..d313bc90 100644 --- a/src/lib/adapters/iptorrents.ts +++ b/src/lib/adapters/iptorrents.ts @@ -271,9 +271,42 @@ async function fetchHtml( throw classifyFetchError(err, new URL(url).hostname) } - // 302 redirect usually means the session expired and the server redirected to login - if (response.status === 302) { - throw new Error("Session expired — browser cookies need to be refreshed") + // 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 = location + if (currentUrl.startsWith("/")) { + currentUrl = new URL(currentUrl, 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 = nextLocation.startsWith("/") ? new URL(nextLocation, currentUrl).href : nextLocation + } else { + break + } + } + if (response.status === 302 || response.status === 301) { + throw new Error("Too many redirects from IPTorrents") + } } if (!response.ok) { diff --git a/src/lib/parser.test.ts b/src/lib/parser.test.ts index 0ca0c235..dbad577d 100644 --- a/src/lib/parser.test.ts +++ b/src/lib/parser.test.ts @@ -73,3 +73,18 @@ 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) + }) +}) diff --git a/src/lib/parser.ts b/src/lib/parser.ts index 3a674c3a..1efe2e29 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -53,6 +53,12 @@ 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) + if (trimmed === "∞" || trimmed === "Inf" || trimmed === "-∞" || trimmed === "-Inf") { + return 0n + } + const match = trimmed.match(/^([\d.]+)\s*([A-Za-z]+)$/) if (!match) { throw new Error(`Invalid byte format: "${formatted}"`) From 79ecdba02e40c4088ee061a48bc87f4154f260dd Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sun, 26 Jul 2026 14:53:49 -0400 Subject: [PATCH 04/10] fix(tracker-adapters): case-insensitive infinity and relative redirects - H1: Handle infinity values case-insensitively (e.g. 'inf', 'INF'). - H2: Fix relative redirect resolution in IPTorrents fetchHtml. - Added tests for case-insensitive infinity and path-relative redirects. Fixes: 86bae41991a748 --- src/lib/adapters/iptorrents.test.ts | 34 +++++++++++++++++++++++++++++ src/lib/adapters/iptorrents.ts | 7 ++---- src/lib/parser.test.ts | 6 +++++ src/lib/parser.ts | 3 ++- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/lib/adapters/iptorrents.test.ts b/src/lib/adapters/iptorrents.test.ts index e05805a1..5be2c0a5 100644 --- a/src/lib/adapters/iptorrents.test.ts +++ b/src/lib/adapters/iptorrents.test.ts @@ -220,4 +220,38 @@ describe("IptorrentsAdapter - redirect handling", () => { "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 index d313bc90..cdefd6ac 100644 --- a/src/lib/adapters/iptorrents.ts +++ b/src/lib/adapters/iptorrents.ts @@ -279,10 +279,7 @@ async function fetchHtml( throw new Error("Session expired — browser cookies need to be refreshed") } // Otherwise follow the redirect (bounded to 3 hops max) - let currentUrl = location - if (currentUrl.startsWith("/")) { - currentUrl = new URL(currentUrl, url).href - } + let currentUrl = new URL(location, url).href for (let hop = 0; hop < 3; hop++) { try { response = await fetch(currentUrl, { @@ -299,7 +296,7 @@ async function fetchHtml( throw new Error("Session expired — browser cookies need to be refreshed") } if (!nextLocation) break - currentUrl = nextLocation.startsWith("/") ? new URL(nextLocation, currentUrl).href : nextLocation + currentUrl = new URL(nextLocation, currentUrl).href } else { break } diff --git a/src/lib/parser.test.ts b/src/lib/parser.test.ts index dbad577d..9d9b44c4 100644 --- a/src/lib/parser.test.ts +++ b/src/lib/parser.test.ts @@ -87,4 +87,10 @@ describe("parseBytes - infinity values", () => { 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 1efe2e29..01e2e374 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -55,7 +55,8 @@ export function parseBytes(formatted: string): bigint { const trimmed = formatted.trim() // Handle infinity/unlimited buffer values from some trackers (e.g. Zenith) - if (trimmed === "∞" || trimmed === "Inf" || trimmed === "-∞" || trimmed === "-Inf") { + const lowerTrimmed = trimmed.toLowerCase() + if (trimmed === "∞" || lowerTrimmed === "inf" || trimmed === "-∞" || lowerTrimmed === "-inf") { return 0n } From 3b6a8cb264751715ae89dccdd5c5660027e95cb6 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sun, 2 Aug 2026 18:52:34 -0400 Subject: [PATCH 05/10] fix(tracker-adapters): round BTN freeleechTokens/seedbonus to int Postgres freeleech_tokens/seedbonus columns are INTEGER; parseFloat on BTN's Bonus/Lumens fields produced decimals once tokens accrued fractional values, causing every snapshot INSERT to throw and BTN to auto-pause after 4 consecutive failed polls (failing since 2026-07-26). Co-Authored-By: Claude Sonnet 5 --- src/lib/adapters/btn.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/adapters/btn.ts b/src/lib/adapters/btn.ts index 54aa508b..27629c43 100644 --- a/src/lib/adapters/btn.ts +++ b/src/lib/adapters/btn.ts @@ -49,11 +49,11 @@ function mapBtnResult(result: BtnUserInfoResult): TrackerStats { bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), seedingCount: 0, leechingCount: 0, - seedbonus: parseFloat(result.Lumens ?? "0") || 0, + seedbonus: Math.round(parseFloat(result.Lumens ?? "0") || 0), hitAndRuns: parseInt(result.HnR ?? "0", 10) || 0, requiredRatio: 0, warned: false, - freeleechTokens: parseFloat(result.Bonus ?? "0") || 0, + freeleechTokens: Math.round(parseFloat(result.Bonus ?? "0") || 0), remoteUserId: parseInt(result.UserID, 10) || undefined, joinedDate, } From 134c734a33e5cbdf93b482e87e9e23506a79cc0b Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Sun, 2 Aug 2026 19:12:42 -0400 Subject: [PATCH 06/10] fix(tracker-adapters): scope BTN rounding to freeleechTokens only, fix stale test Co-Authored-By: Claude Sonnet 5 --- src/lib/adapters/btn.test.ts | 28 +++++++++++++++++++++++++++- src/lib/adapters/btn.ts | 2 +- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/lib/adapters/btn.test.ts b/src/lib/adapters/btn.test.ts index 5eca185b..46ee354a 100644 --- a/src/lib/adapters/btn.test.ts +++ b/src/lib/adapters/btn.test.ts @@ -53,7 +53,7 @@ describe("BtnAdapter", () => { expect(stats.seedingCount).toBe(0) expect(stats.leechingCount).toBe(0) expect(stats.seedbonus).toBe(10) - expect(stats.freeleechTokens).toBe(2.5) + expect(stats.freeleechTokens).toBe(3) expect(stats.hitAndRuns).toBe(3) expect(stats.requiredRatio).toBe(0) expect(stats.warned).toBe(false) @@ -61,6 +61,32 @@ describe("BtnAdapter", () => { expect(stats.joinedDate).toContain("2026") }) + it("rounds a high-precision fractional Bonus value to the nearest integer", async () => { + const mockResponse = { + id: 1, + result: { + UserID: "1531582", + Username: "thing7314", + Upload: "5000000000", + Download: "1000000000", + Lumens: "10", + Bonus: "2614.5799827575684", + Class: "User", + 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.freeleechTokens).toBe(2615) + }) + it("falls back to safe defaults when undocumented fields are missing", async () => { const mockResponse = { id: 1, diff --git a/src/lib/adapters/btn.ts b/src/lib/adapters/btn.ts index 27629c43..fbaf897a 100644 --- a/src/lib/adapters/btn.ts +++ b/src/lib/adapters/btn.ts @@ -49,7 +49,7 @@ function mapBtnResult(result: BtnUserInfoResult): TrackerStats { bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), seedingCount: 0, leechingCount: 0, - seedbonus: Math.round(parseFloat(result.Lumens ?? "0") || 0), + seedbonus: parseFloat(result.Lumens ?? "0") || 0, hitAndRuns: parseInt(result.HnR ?? "0", 10) || 0, requiredRatio: 0, warned: false, From 0b5cf6a801b867f4bfaa36cc8ec4147873a78d45 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Tue, 4 Aug 2026 20:04:12 -0400 Subject: [PATCH 07/10] fix(auth): accept 204 No Content as successful qBittorrent login (5.2+) qBittorrent 5.2.3RC returns 204 with an empty body on successful /api/v2/auth/login instead of the pre-5.2 200 "Ok." body, which was incorrectly treated as a failed login and surfaced as a generic "Connection failed" error. Co-Authored-By: Claude Sonnet 5 --- src/lib/download-clients/__tests__/qbt.test.ts | 14 ++++++++++++++ src/lib/download-clients/qbt/transport.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/lib/download-clients/__tests__/qbt.test.ts b/src/lib/download-clients/__tests__/qbt.test.ts index 69418229..3dda6d65 100644 --- a/src/lib/download-clients/__tests__/qbt.test.ts +++ b/src/lib/download-clients/__tests__/qbt.test.ts @@ -31,6 +31,7 @@ describe("login", () => { it("returns the SID cookie value on successful login", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, + status: 200, text: async () => "Ok.", headers: new Headers({ "set-cookie": "SID=abc123xyz; Path=/; HttpOnly" }), } as Response) @@ -39,6 +40,18 @@ describe("login", () => { expect(sid).toBe("abc123xyz") }) + it("returns the SID cookie value on successful login with 204 No Content (qBittorrent 5.2+)", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 204, + text: async () => "", + headers: new Headers({ "set-cookie": "SID=abc123xyz; Path=/; HttpOnly" }), + } as Response) + + const sid = await login("localhost", 8080, false, "admin", "password") + expect(sid).toBe("abc123xyz") + }) + it("sends a POST to the correct URL with form-encoded body", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, @@ -60,6 +73,7 @@ describe("login", () => { it("throws Authentication failed when response text is not Ok.", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, + status: 200, text: async () => "Fails.", headers: new Headers({}), } as Response) diff --git a/src/lib/download-clients/qbt/transport.ts b/src/lib/download-clients/qbt/transport.ts index f45e20fc..10cf649d 100644 --- a/src/lib/download-clients/qbt/transport.ts +++ b/src/lib/download-clients/qbt/transport.ts @@ -157,7 +157,7 @@ export async function login( } const text = await response.text() - if (text !== "Ok.") { + if (text !== "Ok." && response.status !== 204) { throw new Error("Authentication failed — check username and password") } From 7ccc3328b7419043a51f8a4a0208481d47b8a568 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Tue, 4 Aug 2026 21:01:15 -0400 Subject: [PATCH 08/10] fix(auth): match QBT_SID_ cookie name for qBittorrent 5.2+ qBittorrent 5.2+ names its session cookie QBT_SID_ (its own WebUI listen port baked into the name) instead of the legacy plain SID, so the /SID=([^;]+)/ regex in login() never matched and auth kept failing even after the 204-status fix. Widen it to match any cookie whose name contains SID as a token. Verified live against a 5.2+ instance that the server accepts the session value back under any cookie name (only the value is validated, not the name), so qbtFetch's Cookie: SID=${sid} header needs no change. --- src/lib/download-clients/__tests__/qbt.test.ts | 15 +++++++++++++++ src/lib/download-clients/qbt/transport.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/lib/download-clients/__tests__/qbt.test.ts b/src/lib/download-clients/__tests__/qbt.test.ts index 3dda6d65..e0831a4c 100644 --- a/src/lib/download-clients/__tests__/qbt.test.ts +++ b/src/lib/download-clients/__tests__/qbt.test.ts @@ -52,6 +52,21 @@ describe("login", () => { expect(sid).toBe("abc123xyz") }) + it("returns the SID value from a QBT_SID_ cookie (qBittorrent 5.2+)", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 204, + text: async () => "", + headers: new Headers({ + "set-cookie": + "QBT_SID_8080=YYhsGDAcg8mu89vWPnxXgkH1xkjVQK5h; HttpOnly; SameSite=Lax; expires=Wed, 05-Aug-2026 01:58:51 GMT; path=/", + }), + } as Response) + + const sid = await login("localhost", 8080, false, "admin", "password") + expect(sid).toBe("YYhsGDAcg8mu89vWPnxXgkH1xkjVQK5h") + }) + it("sends a POST to the correct URL with form-encoded body", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, diff --git a/src/lib/download-clients/qbt/transport.ts b/src/lib/download-clients/qbt/transport.ts index 10cf649d..c06b5c79 100644 --- a/src/lib/download-clients/qbt/transport.ts +++ b/src/lib/download-clients/qbt/transport.ts @@ -161,8 +161,11 @@ export async function login( throw new Error("Authentication failed — check username and password") } + // qBittorrent 5.2+ names its session cookie `QBT_SID_` (the WebUI's + // own listen port baked into the name) instead of the legacy plain `SID`, + // so match any cookie whose name contains SID as a token. const setCookie = response.headers.get("set-cookie") ?? "" - const match = setCookie.match(/SID=([^;]+)/) + const match = setCookie.match(/(?:^|,\s*|;\s*)[\w-]*SID[\w-]*=([^;]+)/) if (!match) { throw new Error("Authentication failed — SID cookie not found in response") } From 48861d788291dbc9d2a8fb01ac086b102a41c1d3 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Tue, 4 Aug 2026 21:06:40 -0400 Subject: [PATCH 09/10] fix(auth): use the server-assigned session cookie NAME, not hardcoded SID qBittorrent 5.2+ rejects the session value when sent back under the wrong cookie name (verified live: same SID value, QBT_SID_8080= -> 200, SID= -> 403). login() now returns {name, value} captured from the Set-Cookie regex match instead of just the value, and that pair is plumbed through sidCache/getSession/withSessionRetry/qbtFetch so the Cookie header uses the actual assigned name. --- .../__tests__/qbt-adapter.test.ts | 10 +-- .../download-clients/__tests__/qbt.test.ts | 72 +++++++++++++------ src/lib/download-clients/qbt/transport.ts | 32 +++++---- 3 files changed, 74 insertions(+), 40 deletions(-) diff --git a/src/lib/download-clients/__tests__/qbt-adapter.test.ts b/src/lib/download-clients/__tests__/qbt-adapter.test.ts index e284aac0..d83ddd93 100644 --- a/src/lib/download-clients/__tests__/qbt-adapter.test.ts +++ b/src/lib/download-clients/__tests__/qbt-adapter.test.ts @@ -6,7 +6,7 @@ vi.mock("@/lib/download-clients/qbt/transport", () => ({ buildBaseUrl: vi.fn( (host: string, port: number, ssl: boolean) => `${ssl ? "https" : "http"}://${host}:${port}` ), - login: vi.fn().mockResolvedValue("test-sid"), + login: vi.fn().mockResolvedValue({ name: "SID", value: "test-sid" }), getTorrents: vi.fn().mockResolvedValue([ { hash: "abc", @@ -64,8 +64,8 @@ vi.mock("@/lib/download-clients/qbt/transport", () => ({ _s: boolean, _u: string, _pw: string, - op: (baseUrl: string, sid: string) => unknown - ) => op("http://localhost:8080", "test-sid") + op: (baseUrl: string, sid: { name: string; value: string }) => unknown + ) => op("http://localhost:8080", { name: "SID", value: "test-sid" }) ), })) @@ -140,7 +140,7 @@ describe("QbtClientAdapter", () => { await adapter.getTorrents({ tag: "aither", filter: "active" }) expect(getTorrents).toHaveBeenCalledWith( expect.any(String), - expect.any(String), + { name: "SID", value: "test-sid" }, "aither", "active" ) @@ -158,7 +158,7 @@ describe("QbtClientAdapter", () => { it("returns normalized DeltaSyncResponse from getDeltaSync", async () => { const data = await adapter.getDeltaSync?.(0) - expect(syncMaindata).toHaveBeenCalledWith(expect.any(String), expect.any(String), 0) + expect(syncMaindata).toHaveBeenCalledWith(expect.any(String), { name: "SID", value: "test-sid" }, 0) // Top-level fields pass through expect(data?.rid).toBe(1) diff --git a/src/lib/download-clients/__tests__/qbt.test.ts b/src/lib/download-clients/__tests__/qbt.test.ts index e0831a4c..2956c336 100644 --- a/src/lib/download-clients/__tests__/qbt.test.ts +++ b/src/lib/download-clients/__tests__/qbt.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import type { TorrentRecord } from "@/lib/download-clients" import { aggregateByTag } from "../aggregator" -import { buildBaseUrl, getTorrents, getTransferInfo, login } from "../qbt/transport" +import { buildBaseUrl, getTorrents, getTransferInfo, login, type SidCookie } from "../qbt/transport" import type { QbtTorrent } from "../qbt/types" // --------------------------------------------------------------------------- @@ -28,7 +28,7 @@ describe("login", () => { vi.restoreAllMocks() }) - it("returns the SID cookie value on successful login", async () => { + it("returns the SID cookie name and value on successful login", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, status: 200, @@ -37,10 +37,10 @@ describe("login", () => { } as Response) const sid = await login("localhost", 8080, false, "admin", "password") - expect(sid).toBe("abc123xyz") + expect(sid).toEqual({ name: "SID", value: "abc123xyz" }) }) - it("returns the SID cookie value on successful login with 204 No Content (qBittorrent 5.2+)", async () => { + it("returns the SID cookie name and value on successful login with 204 No Content (qBittorrent 5.2+)", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, status: 204, @@ -49,10 +49,10 @@ describe("login", () => { } as Response) const sid = await login("localhost", 8080, false, "admin", "password") - expect(sid).toBe("abc123xyz") + expect(sid).toEqual({ name: "SID", value: "abc123xyz" }) }) - it("returns the SID value from a QBT_SID_ cookie (qBittorrent 5.2+)", async () => { + it("returns the actual cookie name and value from a QBT_SID_ cookie (qBittorrent 5.2+)", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, status: 204, @@ -64,7 +64,7 @@ describe("login", () => { } as Response) const sid = await login("localhost", 8080, false, "admin", "password") - expect(sid).toBe("YYhsGDAcg8mu89vWPnxXgkH1xkjVQK5h") + expect(sid).toEqual({ name: "QBT_SID_8080", value: "YYhsGDAcg8mu89vWPnxXgkH1xkjVQK5h" }) }) it("sends a POST to the correct URL with form-encoded body", async () => { @@ -160,6 +160,8 @@ describe("login", () => { // --------------------------------------------------------------------------- describe("getTorrents", () => { + const sid: SidCookie = { name: "SID", value: "mysid" } + beforeEach(() => { vi.restoreAllMocks() }) @@ -203,31 +205,43 @@ describe("getTorrents", () => { json: async () => mockTorrents, } as Response) - const result = await getTorrents("http://localhost:8080", "mysid") + const result = await getTorrents("http://localhost:8080", sid) expect(result).toHaveLength(1) expect(result[0].hash).toBe("abc") expect(result[0].state).toBe("uploading") }) - it("sends SID cookie in request", async () => { + it("sends the SID cookie under its own name in request", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, json: async () => [], } as Response) - await getTorrents("http://localhost:8080", "testSID99") + await getTorrents("http://localhost:8080", { name: "SID", value: "testSID99" }) const init = fetchSpy.mock.calls[0][1] as RequestInit expect((init.headers as Record).Cookie).toBe("SID=testSID99") }) + it("sends the cookie under a QBT_SID_-style name, not a hardcoded SID", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => [], + } as Response) + + await getTorrents("http://localhost:8080", { name: "QBT_SID_8080", value: "testSID99" }) + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect((init.headers as Record).Cookie).toBe("QBT_SID_8080=testSID99") + }) + it("calls the correct endpoint", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, json: async () => [], } as Response) - await getTorrents("http://localhost:8080", "sid") + await getTorrents("http://localhost:8080", sid) expect(fetchSpy.mock.calls[0][0]).toBe("http://localhost:8080/api/v2/torrents/info") }) @@ -238,7 +252,7 @@ describe("getTorrents", () => { json: async () => [], } as Response) - await getTorrents("http://localhost:8080", "sid", "aither") + await getTorrents("http://localhost:8080", sid, "aither") expect(fetchSpy.mock.calls[0][0]).toBe("http://localhost:8080/api/v2/torrents/info?tag=aither") }) @@ -249,7 +263,7 @@ describe("getTorrents", () => { json: async () => [], } as Response) - await getTorrents("http://localhost:8080", "sid", "cross seed") + await getTorrents("http://localhost:8080", sid, "cross seed") expect(fetchSpy.mock.calls[0][0]).toBe( "http://localhost:8080/api/v2/torrents/info?tag=cross%20seed" @@ -263,7 +277,7 @@ describe("getTorrents", () => { statusText: "Forbidden", } as Response) - await expect(getTorrents("http://localhost:8080", "sid")).rejects.toThrow("Session expired") + await expect(getTorrents("http://localhost:8080", sid)).rejects.toThrow("Session expired") }) it("throws on non-ok response", async () => { @@ -273,7 +287,7 @@ describe("getTorrents", () => { statusText: "Internal Server Error", } as Response) - await expect(getTorrents("http://localhost:8080", "sid")).rejects.toThrow( + await expect(getTorrents("http://localhost:8080", sid)).rejects.toThrow( "qBittorrent API error: 500 Internal Server Error" ) }) @@ -283,7 +297,7 @@ describe("getTorrents", () => { new DOMException("signal timed out", "TimeoutError") ) - await expect(getTorrents("http://localhost:8080", "sid")).rejects.toThrow( + await expect(getTorrents("http://localhost:8080", sid)).rejects.toThrow( "Request to localhost timed out after 15s" ) }) @@ -291,7 +305,7 @@ describe("getTorrents", () => { it("throws a connection error on network failure", async () => { vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("fetch failed")) - await expect(getTorrents("http://192.168.1.1:8080", "sid")).rejects.toThrow( + await expect(getTorrents("http://192.168.1.1:8080", sid)).rejects.toThrow( "Failed to connect to 192.168.1.1" ) }) @@ -302,6 +316,8 @@ describe("getTorrents", () => { // --------------------------------------------------------------------------- describe("getTransferInfo", () => { + const sid: SidCookie = { name: "SID", value: "mysid" } + beforeEach(() => { vi.restoreAllMocks() }) @@ -317,7 +333,7 @@ describe("getTransferInfo", () => { }), } as Response) - const info = await getTransferInfo("http://localhost:8080", "mysid") + const info = await getTransferInfo("http://localhost:8080", sid) expect(info.up_info_speed).toBe(2048) expect(info.dl_info_speed).toBe(4096) }) @@ -328,23 +344,35 @@ describe("getTransferInfo", () => { json: async () => ({ up_info_speed: 0, dl_info_speed: 0, up_info_data: 0, dl_info_data: 0 }), } as Response) - await getTransferInfo("http://localhost:8080", "sid") + await getTransferInfo("http://localhost:8080", sid) expect(fetchSpy.mock.calls[0][0]).toBe("http://localhost:8080/api/v2/transfer/info") }) - it("sends SID cookie in request", async () => { + it("sends the SID cookie under its own name in request", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, json: async () => ({ up_info_speed: 0, dl_info_speed: 0, up_info_data: 0, dl_info_data: 0 }), } as Response) - await getTransferInfo("http://localhost:8080", "mySID") + await getTransferInfo("http://localhost:8080", { name: "SID", value: "mySID" }) const init = fetchSpy.mock.calls[0][1] as RequestInit expect((init.headers as Record).Cookie).toBe("SID=mySID") }) + it("sends the cookie under a QBT_SID_-style name, not a hardcoded SID", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ up_info_speed: 0, dl_info_speed: 0, up_info_data: 0, dl_info_data: 0 }), + } as Response) + + await getTransferInfo("http://localhost:8080", { name: "QBT_SID_8091", value: "mySID" }) + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect((init.headers as Record).Cookie).toBe("QBT_SID_8091=mySID") + }) + it("throws on non-ok response", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: false, @@ -352,7 +380,7 @@ describe("getTransferInfo", () => { statusText: "Unauthorized", } as Response) - await expect(getTransferInfo("http://localhost:8080", "sid")).rejects.toThrow( + await expect(getTransferInfo("http://localhost:8080", sid)).rejects.toThrow( "qBittorrent API error: 401 Unauthorized" ) }) diff --git a/src/lib/download-clients/qbt/transport.ts b/src/lib/download-clients/qbt/transport.ts index c06b5c79..b409e885 100644 --- a/src/lib/download-clients/qbt/transport.ts +++ b/src/lib/download-clients/qbt/transport.ts @@ -57,20 +57,23 @@ export function buildBaseUrl(host: string, port: number, ssl: boolean): string { // SID session cache to avoid re-authenticating on every poll cycle. // --------------------------------------------------------------------------- +/** The session cookie qBittorrent assigns at login — name varies by version/port. */ +export type SidCookie = { name: string; value: string } + const gSid = globalThis as typeof globalThis & { - __qbtSidCache?: Map + __qbtSidCache?: Map } if (!gSid.__qbtSidCache) gSid.__qbtSidCache = new Map() const sidCache = gSid.__qbtSidCache -/** Get a cached SID or perform a fresh login. */ +/** Get a cached session cookie or perform a fresh login. */ export async function getSession( host: string, port: number, ssl: boolean, username: string, password: string -): Promise<{ baseUrl: string; sid: string }> { +): Promise<{ baseUrl: string; sid: SidCookie }> { const baseUrl = buildBaseUrl(host, port, ssl) const cached = sidCache.get(baseUrl) if (cached) return { baseUrl, sid: cached } @@ -107,7 +110,7 @@ export async function withSessionRetry( ssl: boolean, username: string, password: string, - op: (baseUrl: string, sid: string) => Promise + op: (baseUrl: string, sid: SidCookie) => Promise ): Promise { const { baseUrl, sid } = await getSession(host, port, ssl, username, password) try { @@ -128,7 +131,7 @@ export async function login( ssl: boolean, username: string, password: string -): Promise { +): Promise { const baseUrl = buildBaseUrl(host, port, ssl) const url = `${baseUrl}/api/v2/auth/login` const body = new URLSearchParams({ username, password }).toString() @@ -163,26 +166,29 @@ export async function login( // qBittorrent 5.2+ names its session cookie `QBT_SID_` (the WebUI's // own listen port baked into the name) instead of the legacy plain `SID`, - // so match any cookie whose name contains SID as a token. + // so match any cookie whose name contains SID as a token, and capture the + // actual name — the server rejects the value if sent back under a + // different cookie name (confirmed live: sending the correct value under + // the wrong name returns 403). const setCookie = response.headers.get("set-cookie") ?? "" - const match = setCookie.match(/(?:^|,\s*|;\s*)[\w-]*SID[\w-]*=([^;]+)/) + const match = setCookie.match(/(?:^|,\s*|;\s*)([\w-]*SID[\w-]*)=([^;]+)/) if (!match) { throw new Error("Authentication failed — SID cookie not found in response") } - return match[1] + return { name: match[1], value: match[2] } } async function qbtFetch( url: string, host: string, baseUrl: string, - sid: string + sid: SidCookie ): Promise { let response: Response try { response = await fetch(url, { - headers: { Cookie: `SID=${sid}` }, + headers: { Cookie: `${sid.name}=${sid.value}` }, signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), }) } catch (err) { @@ -241,7 +247,7 @@ export function parseCachedTorrents(raw: unknown): SlimTorrent[] { export async function getTorrents( baseUrl: string, - sid: string, + sid: SidCookie, tag?: string, filter?: string ): Promise { @@ -255,7 +261,7 @@ export async function getTorrents( return response.json() as Promise } -export async function getTransferInfo(baseUrl: string, sid: string): Promise { +export async function getTransferInfo(baseUrl: string, sid: SidCookie): Promise { const url = `${baseUrl}/api/v2/transfer/info` const host = new URL(baseUrl).hostname const response = await qbtFetch(url, host, baseUrl, sid) @@ -264,7 +270,7 @@ export async function getTransferInfo(baseUrl: string, sid: string): Promise { const url = `${baseUrl}/api/v2/sync/maindata?rid=${rid}` From af17bc35f69533205def89a78ef2f2a0c2aec041 Mon Sep 17 00:00:00 2001 From: Clawd Brunner Date: Tue, 4 Aug 2026 21:14:43 -0400 Subject: [PATCH 10/10] fix(auth): parse Set-Cookie entries individually via getSetCookie() The regex-over-comma-joined-header approach could pick the wrong cookie (any name merely containing "SID", e.g. "SIDCC") and could bleed a cookie's value across a comma boundary into the next Set-Cookie entry when the SID cookie had no trailing attributes. getSetCookie() returns each Set-Cookie header as its own array element, so each is parsed as a single cookie and matched against an exact SID/QBT_SID_ name pattern. --- .../download-clients/__tests__/qbt.test.ts | 32 +++++++++++++++++++ src/lib/download-clients/qbt/transport.ts | 30 ++++++++++++----- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/lib/download-clients/__tests__/qbt.test.ts b/src/lib/download-clients/__tests__/qbt.test.ts index 2956c336..eccdb638 100644 --- a/src/lib/download-clients/__tests__/qbt.test.ts +++ b/src/lib/download-clients/__tests__/qbt.test.ts @@ -67,6 +67,38 @@ describe("login", () => { expect(sid).toEqual({ name: "QBT_SID_8080", value: "YYhsGDAcg8mu89vWPnxXgkH1xkjVQK5h" }) }) + it("picks the real QBT_SID_ cookie over a decoy cookie whose name merely contains SID as a substring", async () => { + const headers = new Headers() + headers.append("set-cookie", "SIDCC=fakevalue123; Path=/") + headers.append("set-cookie", "QBT_SID_8080=realvalue456; HttpOnly; Path=/") + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 204, + text: async () => "", + headers, + } as Response) + + const sid = await login("localhost", 8080, false, "admin", "password") + expect(sid).toEqual({ name: "QBT_SID_8080", value: "realvalue456" }) + }) + + it("does not bleed the SID cookie's value into a second comma-joined Set-Cookie header when the SID cookie has no trailing attributes", async () => { + const headers = new Headers() + headers.append("set-cookie", "QBT_SID_8080=realvalue456") + headers.append("set-cookie", "other=somethingelse; Path=/") + + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + status: 204, + text: async () => "", + headers, + } as Response) + + const sid = await login("localhost", 8080, false, "admin", "password") + expect(sid).toEqual({ name: "QBT_SID_8080", value: "realvalue456" }) + }) + it("sends a POST to the correct URL with form-encoded body", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, diff --git a/src/lib/download-clients/qbt/transport.ts b/src/lib/download-clients/qbt/transport.ts index b409e885..3d9a7467 100644 --- a/src/lib/download-clients/qbt/transport.ts +++ b/src/lib/download-clients/qbt/transport.ts @@ -166,17 +166,31 @@ export async function login( // qBittorrent 5.2+ names its session cookie `QBT_SID_` (the WebUI's // own listen port baked into the name) instead of the legacy plain `SID`, - // so match any cookie whose name contains SID as a token, and capture the - // actual name — the server rejects the value if sent back under a - // different cookie name (confirmed live: sending the correct value under - // the wrong name returns 403). - const setCookie = response.headers.get("set-cookie") ?? "" - const match = setCookie.match(/(?:^|,\s*|;\s*)([\w-]*SID[\w-]*)=([^;]+)/) - if (!match) { + // so match that pattern specifically — the server rejects the value if sent + // back under a different cookie name (confirmed live: sending the correct + // value under the wrong name returns 403). `getSetCookie()` returns each + // Set-Cookie response header as its own array element, unlike `.get()` + // which comma-joins them into a single string that's unsafe to regex + // across (cookie values can legally contain commas). + const sid = response.headers + .getSetCookie() + .map((cookie): SidCookie | null => { + const eq = cookie.indexOf("=") + if (eq === -1) return null + const name = cookie.slice(0, eq).trim() + const value = cookie.slice(eq + 1).split(";", 1)[0].trim() + return { name, value } + }) + .find((cookie): cookie is SidCookie => { + if (!cookie) return false + return cookie.name === "SID" || /^QBT_SID_\d+$/.test(cookie.name) + }) + + if (!sid) { throw new Error("Authentication failed — SID cookie not found in response") } - return { name: match[1], value: match[2] } + return sid } async function qbtFetch(