diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 289ef317..c1e32d73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,6 @@ permissions: env: REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} jobs: release: @@ -24,6 +23,9 @@ jobs: fetch-depth: 0 fetch-tags: true + - name: Compute lowercase image name + run: echo "IMAGE_NAME=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + - name: Check for unreleased version id: tag env: @@ -69,13 +71,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Log in to Docker Hub - if: steps.tag.outputs.should_release == 'true' - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up QEMU (for arm64 cross-compilation) if: steps.tag.outputs.should_release == 'true' uses: docker/setup-qemu-action@v4 @@ -94,8 +89,6 @@ jobs: tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.version }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - docker.io/jordyjordy/tracker-tracker:${{ steps.tag.outputs.version }} - docker.io/jordyjordy/tracker-tracker:latest cache-from: | type=gha,scope=buildx-amd64 type=gha,scope=buildx-arm64 @@ -149,11 +142,3 @@ jobs: body: ${{ steps.changelog.outputs.notes }} generate_release_notes: false files: sbom-tracker-tracker.spdx.json - - - name: Sync README to Docker Hub - if: steps.tag.outputs.should_release == 'true' - uses: peter-evans/dockerhub-description@v5 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: jordyjordy/tracker-tracker diff --git a/src/app/api/clients/client-routes.test.ts b/src/app/api/clients/client-routes.test.ts index 4043607c..77f572bf 100644 --- a/src/app/api/clients/client-routes.test.ts +++ b/src/app/api/clients/client-routes.test.ts @@ -269,7 +269,8 @@ describe("GET /api/clients/[id]/torrents", () => { const data = await response.json() expect(response.status).toBe(502) - expect(data.error).toBe("Failed to fetch torrents") + // Auth failures are surfaced distinctly rather than masked as a generic fetch failure. + expect(data.error).toBe("Authentication failed") }) it("returns 502 when qBT connection times out", async () => { diff --git a/src/data/trackers/index.ts b/src/data/trackers/index.ts index 1af77289..1e242d95 100644 --- a/src/data/trackers/index.ts +++ b/src/data/trackers/index.ts @@ -30,6 +30,7 @@ export * from "./greatposterwall" export * from "./hawkeuno" export * from "./hdbits" export * from "./hdtorrents" +export * from "./infinityhd" export * from "./iptorrents" export * from "./lst" export * from "./luminarr" @@ -45,14 +46,17 @@ export * from "./privatehd" export * from "./racing4everyone" export * from "./redacted" export * from "./reelflix" +export * from "./rockethd" export * from "./secretcinema" export * from "./seedpool" export * from "./skipthecommercials" export * from "./sportscult" +export * from "./theleachzone" export * from "./torrentleech" export * from "./tvvault" export * from "./uhdbits" export * from "./uploadcx" +export * from "./yuscene" import { pier720 } from "./720pier" import { abtorrents } from "./abtorrents" @@ -82,6 +86,7 @@ import { greatposterwall } from "./greatposterwall" import { hawkeuno } from "./hawkeuno" import { hdbits } from "./hdbits" import { hdtorrents } from "./hdtorrents" +import { infinityhd } from "./infinityhd" import { iptorrents } from "./iptorrents" import { lst } from "./lst" import { luminarr } from "./luminarr" @@ -97,14 +102,17 @@ import { privatehd } from "./privatehd" import { racing4everyone } from "./racing4everyone" import { redacted } from "./redacted" import { reelflix } from "./reelflix" +import { rockethd } from "./rockethd" import { secretcinema } from "./secretcinema" import { seedpool } from "./seedpool" import { skipthecommercials } from "./skipthecommercials" import { sportscult } from "./sportscult" +import { theleachzone } from "./theleachzone" import { torrentleech } from "./torrentleech" import { tvvault } from "./tvvault" import { uhdbits } from "./uhdbits" import { uploadcx } from "./uploadcx" +import { yuscene } from "./yuscene" export const ALL_TRACKERS: TrackerRegistryEntry[] = [ pier720, @@ -135,6 +143,7 @@ export const ALL_TRACKERS: TrackerRegistryEntry[] = [ hawkeuno, hdbits, hdtorrents, + infinityhd, iptorrents, lst, luminarr, @@ -150,12 +159,15 @@ export const ALL_TRACKERS: TrackerRegistryEntry[] = [ racing4everyone, redacted, reelflix, + rockethd, secretcinema, seedpool, skipthecommercials, sportscult, + theleachzone, torrentleech, tvvault, uhdbits, uploadcx, + yuscene, ] diff --git a/src/data/trackers/infinityhd.ts b/src/data/trackers/infinityhd.ts new file mode 100644 index 00000000..2c7b3ec1 --- /dev/null +++ b/src/data/trackers/infinityhd.ts @@ -0,0 +1,259 @@ +// src/data/trackers/_template.ts +// +// Copy this file to add a new tracker to the registry. +// +// 1. Duplicate this file and rename it to your tracker's slug (e.g. mytracker.ts) +// 2. Fill in all fields below — every field must be present (use "" / [] / false +// rather than omitting). See inline comments for guidance. +// 3. Export from src/data/trackers/index.ts (add to the barrel + ALL_TRACKERS array) +// 4. Run `pnpm test` to validate your entry +// +// Set draft: true while the entry is incomplete. Draft trackers skip strict +// validation in CI, so you can submit a PR with partial data. +// +// Allowed content categories: +// Movies, TV, Music, Games, Apps, Sports, Books, Audiobooks, Comics, +// Manga, Anime, XXX, Documentaries, Education, Tutorials, Fanres, +// +// Validator checks: +// - slug: lowercase letters and hyphens only +// - platform: "unit3d" | "gazelle" | "ggn" | "nebulance" | "mam" | "avistaz" | "custom" +// - apiPath must match platform default: +// unit3d → "/api/user" +// gazelle → "/ajax.php" +// ggn → "/api.php" +// avistaz → "/profile" +// - url: https only +// - contentCategories: values must come from the allowed list above +// - language: required +// - rules: required (minimumRatio, seedTimeHours, loginIntervalDays as numbers) + +import type { TrackerRegistryEntry } from "@/data/tracker-registry" + +export const infinityhd: TrackerRegistryEntry = { + // ── Identity ──────────────────────────────────────────────────────── + slug: "infinityhd", // lowercase, hyphens only (e.g. "my-tracker") + name: "InfinityHD", // display name + abbreviation: "IHD", // short code (e.g. "ATH", "RED") — "" if none + url: "https://infinityhd.net", // base URL (https only) + description: + "InfinityHD is a community-built Movie/TV/FANRES database. Every piece of data has been added by our amazing members. InfinityHDs strong focus is on HD content, a proactive userbase, an awesome/secure codebase and a helpful and friendly staff team.", // 1-2 sentence overview + + // ── Platform & API ────────────────────────────────────────────────── + platform: "unit3d", // "unit3d" | "gazelle" | "ggn" | "nebulance" | "mam" | "avistaz" | "custom" + // Platform-specific fields (uncomment for your platform): + // gazelleAuthStyle: "token", // gazelle only — "token" | "raw" + // gazelleEnrich: true, // gazelle only — enables enrichment call + // unit3dAuthStyle: "bearer", // unit3d only — "bearer" | "query" + apiPath: "/api/user", // unit3d: "/api/user" | gazelle: "/ajax.php" | ggn: "/api.php" + + // ── Content ───────────────────────────────────────────────────────── + specialty: "", // what the tracker is known for (e.g. "HD Movies", "Anime") + contentCategories: ["Movies", "TV", "Music", "Anime"], // see allowed list in header + language: "English", + + // ── Visual ────────────────────────────────────────────────────────── + color: "#000000", // hex accent color for the tracker's detail page + logo: "", // "/tracker-logos/mytracker_logo.svg" — file must exist in public/ — "" if none + + // ── External Links ────────────────────────────────────────────────── + trackerHubSlug: "", // slug on trackerhub.xyz, if listed — "" if none + statusPageUrl: "", // external status page URL — "" if none + + // ── Community ─────────────────────────────────────────────────────── + userClasses: [ + { + name: "User", + }, + { + name: "Adept", + requirements: "1 week, 100 GiB upload, ratio => 0.50, 3 days avg seedtime", + }, + { + name: "Spellbinder", + requirements: "1 week, 250 GiB upload, 50 GiB seedsize, ratio => 1.00, 5 days avg seedtime", + }, + { + name: "Conjurer", + requirements: "2 weeks, 750 GiB upload, 150 GiB seedsize, ratio => 1.10, 1 week avg seedtime", + }, + { + name: "Sorcerer", + requirements: "1 month, 1 TiB upload, 250 GiB seedsize, ratio => 1.25, 2 weeks avg seedtime.", + }, + { + name: "Ascendant", + requirements: "2 months, 2 TiB upload, 500 GiB seedsize, ratio => 1.50, 3 weeks avg seedtime", + }, + { + name: "Channeler", + requirements: + "3 months, 3 TiB upload, 1 TiB seedsize, ratio => 2.50, 1 month avg seedtime, 25 uploads", + }, + { + name: "Lorekeeper", + requirements: + "6 months, 5 TiB upload, 1 TiB seedsize, ratio => 3.00, 1 month 2 week avg seedtime, 50 uploads.", + }, + ], // [{ name: "Power User", requirements: "Upload ≥ 100 GiB" }] + releaseGroups: [], // [{ name: "GrpName", description: "Encodes" }] or ["GrpName"] + bannedGroups: [ + "1000", + "24xHD", + "41RGB", + "4K4U", + "AG", + "AOC", + "AROMA", + "aXXo", + "AZAZE", + "BARC0DE", + "BAUCKLEY", + "BdC", + "beAst", + "BONE", + "BRiNK", + "BRrip", + "BTM", + "C1NEM4", + "C4K", + "CDDHD", + "CHAOS", + "CHD", + "CHX", + "CiNE", + "COLLECTiVE", + "CREATiVE24", + "CrEwSaDe", + "CTFOH", + "d3g", + "DDR", + "DepraveD", + "DNL", + "DRX", + "EDITH", + "EPiC", + "EuReKA", + "EVO", + "FaNGDiNG0", + "Feranki1980", + "FGT", + "FiSTER", + "FMD", + "FRDS", + "FZHD", + "GalaxyRG", + "GalaxyTV", + "GHD", + "GHOSTS", + "GPTHD", + "GRACE", + "HDHUB4U", + "HDS", + "HDT", + "HDTime", + "HDWinG", + "HiQVE", + "iHYTECH", + "in", + "iNTENSO", + "iPlanet", + "ION10", + "iVy", + "jennaortegaUHD", + "JFF", + "KC", + "KiNGDOM", + "KIRA", + "L0SERNIGHT", + "LAMA", + "Leffe", + "Liber8", + "LiGaS", + "LT", + "LUCY", + "MarkII", + "MeGusta", + "Mesc", + "mHD", + "MTeam", + "mSD", + "MT", + "MySiLU", + "NaNi", + "NhaNc3", + "nhanc3", + "nHD", + "nikt0", + "nSD", + "OFT", + "Paheph", + "PATOMiEL", + "POKE", + "PRODJi", + "PSA", + "PTNK", + "RARBG", + "RDN", + "Rifftrax", + "RU4HD", + "SANTi", + "SasukeducK", + "Scene", + "SHD", + "ShieldBearer", + "SM737", + "STUTTERSHIT", + "SUNSCREEN", + "TBS", + "TEKNO3D", + "TG", + "TGx", + "Tigole", + "TIKO", + "TORRENTGALAXY", + "UNiON", + "VIDEOHOLE", + "VISIONPLUSHDR-X", + "VXT", + "WAF", + "WiKi", + "WKS", + "worldmkv", + "x0r", + "XLF", + "YIFY", + "YTSMX", + "Zero00", + "Zeus", + ], // ["GroupName"] — groups explicitly banned by the tracker + notableMembers: [], // ["handle"] — notable community figures + + // ── Rules ─────────────────────────────────────────────────────────── + rules: { + minimumRatio: 0.4, // 0 = no minimum + seedTimeHours: 120, // 0 = no minimum + loginIntervalDays: 90, // 0 = no login interval policy + // fulfillmentPeriodHours: 72, // optional — hours to complete H&R seeding + hnrBanLimit: 3, // optional — number of H&Rs before ban + // fullRulesMarkdown: [], // optional — detailed rules as markdown string[] + }, + + // ── Status ────────────────────────────────────────────────────────── + warning: false, // true if the tracker has a known issue or is at risk + warningNote: "", // short description of the warning — "" if none + + // ── Flags ─────────────────────────────────────────────────────────── + draft: true, // remove (or set false) once all required fields are filled in + supportsTransitPapers: false, // true if the tracker supports transit papers export + profileUrlPattern: "", // e.g. "/user.php?id={id}" — required when supportsTransitPapers: true + + // ── Stats (omit this block entirely if no real data is available) ─── + // stats: { + // userCount: undefined, + // activeUsers: undefined, + // torrentCount: undefined, + // seedSize: undefined, // e.g. "500 TiB" + // statsUpdatedAt: undefined, // ISO 8601 date string + // }, +} diff --git a/src/data/trackers/rockethd.ts b/src/data/trackers/rockethd.ts new file mode 100644 index 00000000..fd585224 --- /dev/null +++ b/src/data/trackers/rockethd.ts @@ -0,0 +1,109 @@ +// src/data/trackers/rockethd.ts + +import type { TrackerRegistryEntry } from "@/data/tracker-registry" + +export const rockethd: TrackerRegistryEntry = { + // ── Identity ──────────────────────────────────────────────────────── + slug: "rockethd", + name: "RocketHD", + abbreviation: "", + url: "https://rocket-hd.cc", + description: "TODO", + + // ── Platform & API ────────────────────────────────────────────────── + platform: "unit3d", + apiPath: "/api/user", + + // ── Content ───────────────────────────────────────────────────────── + specialty: "", + contentCategories: ["Movies", "TV"], + language: "English", + + // ── Visual ────────────────────────────────────────────────────────── + color: "", + logo: "", + + // ── External Links ────────────────────────────────────────────────── + trackerHubSlug: "", + statusPageUrl: "", + + // ── Community ─────────────────────────────────────────────────────── + // Requirement thresholds not yet confirmed — only group names are known. + userClasses: [ + { name: "Leech" }, + { name: "Lost Soul" }, + { name: "User" }, + { name: "Lunar Explorer" }, + { name: "Deep Space Scout" }, + { name: "Star Seeker" }, + { name: "Star Navigator" }, + { name: "Galactic Voyager" }, + { name: "Galactic Pioneer" }, + { name: "Supply Shuttle" }, + { name: "Data Capsule" }, + { name: "Nebula Vault" }, + { name: "Allied Officer" }, + { name: "Honored Traveler" }, + { name: "Payload Commander" }, + { name: "Payload Specialist" }, + { name: "Launch Technician" }, + { name: "Flight Assistant" }, + { name: "Flight Director" }, + { name: "Chief Engineer" }, + { name: "Systems Engineer" }, + { name: "Mission Commander" }, + { name: "Mission Control" }, + ], + releaseGroups: [], + bannedGroups: [ + "1XBET", + "MEGA", + "Whistler", + "WOTT", + "HELD", + "FSX", + "FuN", + "w00t", + "BB", + "266ers", + "JellyfinPlex", + "2BA", + "FritzBox", + "FUNXDTV", + "MTZ", + "Taylor.D", + "MagicX", + "PaTroL", + "GTF", + "PiKACHU", + ], + notableMembers: [], + + // ── Rules ─────────────────────────────────────────────────────────── + rules: { + minimumRatio: 1.0, + seedTimeHours: 168, + loginIntervalDays: 0, + hnrBanLimit: 3, + fullRulesMarkdown: [ + "## Seeding / Hit & Run", + "", + "- Ratio below 1.0 can result in a warning, and in extreme cases, exclusion", + "- Required seed time is 7 days (168 hours), applying after 5% of the torrent has downloaded", + "- Partial downloads are not permitted — seed time starts once the torrent reaches 100%", + "- 3+ active HnR warnings may result in exclusion", + "- Clients must be connectable (open port); non-connectable clients earn no bonus points", + "- Only VPN providers offering port forwarding are allowed", + "- Not connectable after 7 days → downgraded to Leech (download ban), or in severe cases, exclusion", + ], + }, + + // ── Status ────────────────────────────────────────────────────────── + warning: false, + warningNote: "", + + // ── Flags ─────────────────────────────────────────────────────────── + draft: true, + supportsTransitPapers: false, + profileUrlPattern: "", +} diff --git a/src/data/trackers/theleachzone.ts b/src/data/trackers/theleachzone.ts new file mode 100644 index 00000000..148eefd2 --- /dev/null +++ b/src/data/trackers/theleachzone.ts @@ -0,0 +1,64 @@ +// src/data/trackers/theleachzone.ts + +import type { TrackerRegistryEntry } from "@/data/tracker-registry" + +export const theleachzone: TrackerRegistryEntry = { + // ── Identity ──────────────────────────────────────────────────────── + slug: "theleachzone", + name: "TheLeachZone", + abbreviation: "TLZ", + url: "https://tlzdigital.com", + description: "TODO", + + // ── Platform & API ────────────────────────────────────────────────── + platform: "unit3d", + apiPath: "/api/user", + + // ── Content ───────────────────────────────────────────────────────── + specialty: "", + contentCategories: ["Movies", "TV", "Music", "Books", "Games", "Apps"], + language: "English", + + // ── Visual ────────────────────────────────────────────────────────── + color: "", + logo: "", + + // ── External Links ────────────────────────────────────────────────── + trackerHubSlug: "", + statusPageUrl: "", + + // ── Community ─────────────────────────────────────────────────────── + userClasses: [ + { name: "Leech", requirements: "Ratio < site minimum (0.50)" }, + { name: "User", requirements: "Upload ≥ starting group requirement, ratio ≥ 0.50" }, + { name: "PowerUser", requirements: "Upload > 150 GiB, account age > 30 days, ratio > 1.00" }, + { name: "SuperUser", requirements: "Upload > 750 GiB, account age > 120 days, ratio > 1.05" }, + { name: "MightyUser", requirements: "Upload > 3 TiB, account age > 240 days, ratio > 1.10" }, + { name: "MegaUser", requirements: "Upload > 9 TiB, account age > 360 days, ratio > 1.15" }, + { name: "SupremeUser", requirements: "Upload > 18 TiB, account age > 540 days, ratio > 1.20" }, + ], + releaseGroups: [], + bannedGroups: [], + notableMembers: [], + + // ── Rules ─────────────────────────────────────────────────────────── + rules: { + minimumRatio: 0.5, + seedTimeHours: 96, + loginIntervalDays: 0, + fullRulesMarkdown: [ + "## Seeding", + "", + "- Seed for as long as possible, or a minimum of 4 days (96 hours), or until a ratio of 1:1 is reached", + ], + }, + + // ── Status ────────────────────────────────────────────────────────── + warning: false, + warningNote: "", + + // ── Flags ─────────────────────────────────────────────────────────── + draft: true, + supportsTransitPapers: false, + profileUrlPattern: "", +} diff --git a/src/data/trackers/yuscene.ts b/src/data/trackers/yuscene.ts new file mode 100644 index 00000000..4eed0d24 --- /dev/null +++ b/src/data/trackers/yuscene.ts @@ -0,0 +1,144 @@ +// src/data/trackers/yuscene.ts + +import type { TrackerRegistryEntry } from "@/data/tracker-registry" + +export const yuscene: TrackerRegistryEntry = { + // ── Identity ──────────────────────────────────────────────────────── + slug: "yuscene", + name: "YU-Scene", + abbreviation: "YUS", + url: "https://yu-scene.net", + description: "TODO", + + // ── Platform & API ────────────────────────────────────────────────── + platform: "unit3d", + apiPath: "/api/user", + + // ── Content ───────────────────────────────────────────────────────── + specialty: "", + contentCategories: ["Movies", "TV", "Games", "Music", "Apps", "Sports", "Books", "Audiobooks"], + language: "English", + + // ── Visual ────────────────────────────────────────────────────────── + color: "", + logo: "", + + // ── External Links ────────────────────────────────────────────────── + trackerHubSlug: "", + statusPageUrl: "", + + // ── Community ─────────────────────────────────────────────────────── + userClasses: [ + { name: "Leech", requirements: "Ratio ≥ 0.00, 0 download slots" }, + { name: "User", requirements: "Ratio ≥ 0.70. 20 download slots" }, + { name: "PowerUser", requirements: "Upload ≥ 1 TiB, ratio ≥ 0.70, age ≥ 1 month" }, + { name: "SuperUser", requirements: "Upload ≥ 5 TiB, ratio ≥ 0.70, age ≥ 2 months" }, + { name: "ExtremeUser", requirements: "Upload ≥ 20 TiB, ratio ≥ 0.70, age ≥ 3 months" }, + { name: "InsaneUser", requirements: "Upload ≥ 50 TiB, ratio ≥ 0.70, age ≥ 6 months" }, + { + name: "Veteran", + requirements: "Upload ≥ 100 TiB, ratio ≥ 0.70, age ≥ 1 year. Freeleech", + }, + { + name: "Seeder", + requirements: "Ratio ≥ 0.70, age ≥ 1 month, seed size ≥ 4 TiB. Freeleech", + }, + { + name: "Curator", + requirements: "Ratio ≥ 0.70, age ≥ 2 months, avg seedtime ≥ 1 month, seed size ≥ 10 TiB. Freeleech", + }, + { + name: "Archivist", + requirements: + "Ratio ≥ 2.00, age ≥ 6 months, avg seedtime ≥ 3 months, seed size ≥ 20 TiB. Freeleech, double upload", + }, + { + name: "Titan", + requirements: + "Ratio ≥ 5.00, age ≥ 12 months, avg seedtime ≥ 6 months, seed size ≥ 40 TiB. Freeleech, double upload", + }, + ], + releaseGroups: [], + bannedGroups: [ + "ADDICTION", + "BANDOLEROS", + "BigEasy", + "CINEMAXIS", + "CREATiVE24", + "DUMMESCHWEDEN", + "d3g", + "D3US/B3LLUM", + "FGT", + "GRANiTEN", + "KiNGDOM", + "Lama", + "Lootera", + "MeGusta", + "MezRips", + "mHD", + "mRS", + "msd", + "NeXus", + "NhaNc3", + "nHD", + "NorTekst", + "NORViNE", + "PANDEMONiUM", + "PiTBULL", + "RAPiDCOWS", + "RARBG", + "Radarr", + "RCDiVX", + "RDN", + "ROCKETRACCOON", + "SANTi", + "SHOWTiME", + "SOOSi", + "SUXWIC", + "TWA", + "TOXVIO", + "VXT", + "Will1869", + "x0r", + "XS", + "YIFY", + "YOLAND", + "YTS", + "ZKBL", + "ZmN", + "ZMNT", + ], + notableMembers: [], + + // ── Rules ─────────────────────────────────────────────────────────── + rules: { + minimumRatio: 0.7, + seedTimeHours: 120, + loginIntervalDays: 0, + fullRulesMarkdown: [ + "## Seeding / Hit & Run", + "", + "- Falling below a 0.70 ratio disables downloads", + "- All torrents require a minimum of 5 days (120 hours) seedtime, regardless of ratio", + "- Seedtime starts only after 100% completion — avoid partial downloads", + "- A 15% download buffer exists before HnR tracking begins", + "- Freeleech torrents still require seeding", + "- Disconnected > 3 days without meeting seedtime → pre-warning PM", + "- Disconnected > 5 days without meeting seedtime → HnR warning", + "", + "## Banned Groups", + "", + "- MeGusta — avoid anything linked to the IPT network", + "- Radarr — only allowed if above 4 GB and minimum 4000 kb/s bitrate", + ], + }, + + // ── Status ────────────────────────────────────────────────────────── + warning: false, + warningNote: "", + + // ── Flags ─────────────────────────────────────────────────────────── + draft: true, + supportsTransitPapers: false, + profileUrlPattern: "", +} diff --git a/src/lib/download-clients/__tests__/qbt-adapter.test.ts b/src/lib/download-clients/__tests__/qbt-adapter.test.ts index e284aac0..02fdf153 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: string, cookieName: string) => unknown + ) => op("http://localhost:8080", "test-sid", "SID") ), })) @@ -142,7 +142,8 @@ describe("QbtClientAdapter", () => { expect.any(String), expect.any(String), "aither", - "active" + "active", + "SID" ) }) @@ -158,7 +159,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), expect.any(String), 0, "SID") // 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 69418229..46088c46 100644 --- a/src/lib/download-clients/__tests__/qbt.test.ts +++ b/src/lib/download-clients/__tests__/qbt.test.ts @@ -28,15 +28,28 @@ 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 (WebUI < 5.2)", async () => { vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, text: async () => "Ok.", headers: new Headers({ "set-cookie": "SID=abc123xyz; Path=/; HttpOnly" }), } as Response) - const sid = await login("localhost", 8080, false, "admin", "password") - expect(sid).toBe("abc123xyz") + const cookie = await login("localhost", 8080, false, "admin", "password") + expect(cookie.name).toBe("SID") + expect(cookie.value).toBe("abc123xyz") + }) + + it("returns the QBT_SID_ cookie name and value on successful login (WebUI >= 5.2)", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + text: async () => "Ok.", + headers: new Headers({ "set-cookie": "QBT_SID_7070=abc123xyz; Path=/; HttpOnly" }), + } as Response) + + const cookie = await login("localhost", 7070, false, "admin", "password") + expect(cookie.name).toBe("QBT_SID_7070") + expect(cookie.value).toBe("abc123xyz") }) it("sends a POST to the correct URL with form-encoded body", async () => { @@ -192,6 +205,18 @@ describe("getTorrents", () => { expect((init.headers as Record).Cookie).toBe("SID=testSID99") }) + it("sends the QBT_SID_ cookie name when provided (WebUI >= 5.2)", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => [], + } as Response) + + await getTorrents("http://localhost:7070", "testSID99", undefined, undefined, "QBT_SID_7070") + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect((init.headers as Record).Cookie).toBe("QBT_SID_7070=testSID99") + }) + it("calls the correct endpoint", async () => { const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ ok: true, diff --git a/src/lib/download-clients/adapters/qbt.ts b/src/lib/download-clients/adapters/qbt.ts index 0e6ec749..443a0516 100644 --- a/src/lib/download-clients/adapters/qbt.ts +++ b/src/lib/download-clients/adapters/qbt.ts @@ -33,8 +33,8 @@ export class QbtClientAdapter implements ClientAdapter { async testConnection(): Promise { invalidateSession(this.baseUrl) - const sid = await login(this.host, this.port, this.ssl, this.username, this.password) - await qbtGetTransferInfo(this.baseUrl, sid) + const { name, value } = await login(this.host, this.port, this.ssl, this.username, this.password) + await qbtGetTransferInfo(this.baseUrl, value, name) } async getTorrents(options?: { tag?: string; filter?: string }): Promise { @@ -44,7 +44,8 @@ export class QbtClientAdapter implements ClientAdapter { this.ssl, this.username, this.password, - (baseUrl, sid) => qbtGetTorrents(baseUrl, sid, options?.tag, options?.filter) + (baseUrl, sid, cookieName) => + qbtGetTorrents(baseUrl, sid, options?.tag, options?.filter, cookieName) ) return raw.map((t) => mapQbtTorrent(t as unknown as Record)) } @@ -56,7 +57,7 @@ export class QbtClientAdapter implements ClientAdapter { this.ssl, this.username, this.password, - (baseUrl, sid) => qbtGetTransferInfo(baseUrl, sid) + (baseUrl, sid, cookieName) => qbtGetTransferInfo(baseUrl, sid, cookieName) ) return { uploadSpeed: info.up_info_speed, downloadSpeed: info.dl_info_speed } } @@ -68,7 +69,7 @@ export class QbtClientAdapter implements ClientAdapter { this.ssl, this.username, this.password, - (baseUrl, sid) => syncMaindata(baseUrl, sid, rid) + (baseUrl, sid, cookieName) => syncMaindata(baseUrl, sid, rid, cookieName) ) return { rid: raw.rid, diff --git a/src/lib/download-clients/qbt/transport.ts b/src/lib/download-clients/qbt/transport.ts index f45e20fc..f1eb8e6d 100644 --- a/src/lib/download-clients/qbt/transport.ts +++ b/src/lib/download-clients/qbt/transport.ts @@ -2,8 +2,8 @@ // // Available functions: // buildBaseUrl - Construct base URL from host/port/ssl -// login - Authenticate with qBittorrent Web API, returns SID cookie -// getSession - Return cached SID or perform a fresh login +// login - Authenticate with qBittorrent Web API, returns session cookie {name, value} +// getSession - Return cached session cookie or perform a fresh login // invalidateSession - Remove a cached SID (i.e after 403) // clearAllSessions - Remove all cached SIDs (called on logout) // withSessionRetry - Run an operation with automatic session retry on expiry @@ -54,30 +54,40 @@ export function buildBaseUrl(host: string, port: number, ssl: boolean): string { } // --------------------------------------------------------------------------- -// SID session cache to avoid re-authenticating on every poll cycle. +// Session cookie cache to avoid re-authenticating on every poll cycle. +// +// qBittorrent's WebUI cookie is named "SID" on WebUI < 5.2 and +// "QBT_SID_" on WebUI >= 5.2 (see qbittorrent/qBittorrent#24190), so +// the actual cookie name has to be detected at login time and threaded +// through to every subsequent authenticated request. // --------------------------------------------------------------------------- +interface SessionCookie { + 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: string; cookieName: string }> { const baseUrl = buildBaseUrl(host, port, ssl) const cached = sidCache.get(baseUrl) - if (cached) return { baseUrl, sid: cached } + if (cached) return { baseUrl, sid: cached.value, cookieName: cached.name } - const sid = await login(host, port, ssl, username, password) - sidCache.set(baseUrl, sid) - return { baseUrl, sid } + const cookie = await login(host, port, ssl, username, password) + sidCache.set(baseUrl, cookie) + return { baseUrl, sid: cookie.value, cookieName: cookie.name } } /** Invalidate a cached SID */ @@ -107,28 +117,33 @@ export async function withSessionRetry( ssl: boolean, username: string, password: string, - op: (baseUrl: string, sid: string) => Promise + op: (baseUrl: string, sid: string, cookieName: string) => Promise ): Promise { - const { baseUrl, sid } = await getSession(host, port, ssl, username, password) + const { baseUrl, sid, cookieName } = await getSession(host, port, ssl, username, password) try { - return await op(baseUrl, sid) + return await op(baseUrl, sid, cookieName) } catch (err) { if (err instanceof Error && err.message === "Session expired") { invalidateSession(baseUrl) const fresh = await getSession(host, port, ssl, username, password) - return await op(baseUrl, fresh.sid) + return await op(baseUrl, fresh.sid, fresh.cookieName) } throw err } } +// qBittorrent WebUI < 5.2 names its session cookie "SID"; WebUI >= 5.2 names +// it "QBT_SID_" (qbittorrent/qBittorrent#24190). Match either, keeping +// the actual name so it can be sent back on subsequent requests. +const SESSION_COOKIE_PATTERN = /(?:^|;\s*)(SID|QBT_SID_\d+)=([^;]+)/ + export async function login( host: string, port: number, 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() @@ -162,24 +177,25 @@ export async function login( } const setCookie = response.headers.get("set-cookie") ?? "" - const match = setCookie.match(/SID=([^;]+)/) + const match = setCookie.match(SESSION_COOKIE_PATTERN) if (!match) { - throw new Error("Authentication failed — SID cookie not found in response") + throw new Error("Authentication failed — session 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: string, + cookieName = "SID" ): Promise { let response: Response try { response = await fetch(url, { - headers: { Cookie: `SID=${sid}` }, + headers: { Cookie: `${cookieName}=${sid}` }, signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), }) } catch (err) { @@ -240,7 +256,8 @@ export async function getTorrents( baseUrl: string, sid: string, tag?: string, - filter?: string + filter?: string, + cookieName = "SID" ): Promise { const parts: string[] = [] if (tag) parts.push(`tag=${encodeURIComponent(tag)}`) @@ -248,25 +265,30 @@ export async function getTorrents( const qs = parts.join("&") const url = `${baseUrl}/api/v2/torrents/info${qs ? `?${qs}` : ""}` const host = new URL(baseUrl).hostname - const response = await qbtFetch(url, host, baseUrl, sid) + const response = await qbtFetch(url, host, baseUrl, sid, cookieName) return response.json() as Promise } -export async function getTransferInfo(baseUrl: string, sid: string): Promise { +export async function getTransferInfo( + baseUrl: string, + sid: string, + cookieName = "SID" +): Promise { const url = `${baseUrl}/api/v2/transfer/info` const host = new URL(baseUrl).hostname - const response = await qbtFetch(url, host, baseUrl, sid) + const response = await qbtFetch(url, host, baseUrl, sid, cookieName) return response.json() as Promise } export async function syncMaindata( baseUrl: string, sid: string, - rid: number + rid: number, + cookieName = "SID" ): Promise { const url = `${baseUrl}/api/v2/sync/maindata?rid=${rid}` const host = new URL(baseUrl).hostname - const response = await qbtFetch(url, host, baseUrl, sid) + const response = await qbtFetch(url, host, baseUrl, sid, cookieName) const data: unknown = await response.json() if (!isQbtMaindataResponse(data)) { throw new Error("Invalid maindata response from qBittorrent") diff --git a/src/lib/error-utils.ts b/src/lib/error-utils.ts index 7267fb95..dc0a644e 100644 --- a/src/lib/error-utils.ts +++ b/src/lib/error-utils.ts @@ -30,7 +30,7 @@ export function sanitizeNetworkError(raw: string, fallback = "Connection failed" if (/EHOSTUNREACH/i.test(raw)) return "Host unreachable" if (/ECONNRESET/i.test(raw)) return "Connection reset" if (/ip.*ban|ban.*ip|rate.?limit/i.test(raw)) return "IP temporarily banned by tracker" - if (/401|403|Unauthorized|Forbidden/i.test(raw)) return "Authentication failed" + if (/401|403|Unauthorized|Forbidden|Authentication failed/i.test(raw)) return "Authentication failed" if (/Session expired/i.test(raw)) return "Session expired" if (/Credentials.*missing|invalid/i.test(raw)) return "Invalid credentials" if (/proxy/i.test(raw)) return "Proxy connection failed"