diff --git a/.versionrc.cjs b/.versionrc.cjs new file mode 100644 index 00000000..9d5aa02e --- /dev/null +++ b/.versionrc.cjs @@ -0,0 +1,56 @@ +// .versionrc.cjs +// +// Config for commit-and-tag-version (`pnpm release:*`). +// +// This is .cjs rather than .json because `writerOpts.commitPartial` has to be a +// FUNCTION. commit-and-tag-version 13 moved to conventional-changelog-writer 9, +// which dropped Handlebars in favour of plain template functions +// (@conventional-changelog/template). The old Handlebars string this file used +// to carry got called as a function and the release died on +// "commitPartial is not a function" after bumping package.json but before +// writing anything else. +// +// Signature and commit fields come from the conventionalcommits preset: +// conventional-changelog-conventionalcommits/src/templates.js. + +/** + * One changelog bullet: `**scope:** subject`, or the bare subject when a commit + * has no scope. + * + * Deliberately narrower than the preset default, which appends a commit-hash + * link and `, closes #n` / `, references #n` trailers. This changelog is read + * in-app (see src/app/api/changelog/route.ts) where a wall of hashes is noise, + * so it renders the human half only. Issue links already inlined in the subject + * by the writer are left alone. + * + * The list marker itself is not ours to pick — writer 9 renders every bullet + * through its own `list()` helper, which hardcodes `*`. Older entries in + * CHANGELOG.md use `-`; the file has mixed markers either way. + * + * Also not ours: writer 9 trims each release section and appends a single + * newline, so a new section butts straight up against the previous version + * heading with no blank line between them. Cosmetic only — an ATX heading + * interrupts a list in CommonMark, so it still renders as a heading. Accepted + * rather than papered over with a postchangelog hook. + */ +function commitPartial(_context, commit) { + const { scope, subject, header } = commit + const text = subject || header || "" + return scope ? `**${scope}:** ${text}` : text +} + +module.exports = { + header: "# Changelog\n", + writerOpts: { commitPartial }, + types: [ + { type: "feat", section: "Features" }, + { type: "fix", section: "Bug Fixes" }, + { type: "perf", section: "Performance" }, + { type: "refactor", section: "Refactoring" }, + { type: "chore", hidden: true }, + { type: "docs", hidden: true }, + { type: "test", hidden: true }, + { type: "ci", hidden: true }, + { type: "style", hidden: true }, + ], +} diff --git a/.versionrc.json b/.versionrc.json deleted file mode 100644 index 6631fcce..00000000 --- a/.versionrc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "header": "# Changelog\n", - "writerOpts": { - "commitPartial": "- {{#if scope}}**{{scope}}:** {{/if}}{{#if subject}}{{{subject}}}{{else}}{{{header}}}{{/if}}\n" - }, - "types": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "perf", "section": "Performance" }, - { "type": "refactor", "section": "Refactoring" }, - { "type": "chore", "hidden": true }, - { "type": "docs", "hidden": true }, - { "type": "test", "hidden": true }, - { "type": "ci", "hidden": true }, - { "type": "style", "hidden": true } - ] -} diff --git a/CHANGELOG.md b/CHANGELOG.md index 065e3e39..b3110438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [2.10.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.10.0...v2.10.1) (2026-08-19) + +### Bug Fixes + +* **release:** port changelog template to writer 9 function form +* **tracker-adapters:** api responses now parsed as either strings or ints ## [2.10.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.8.9...v2.10.0) (2026-08-18) diff --git a/package.json b/package.json index 90a58240..12c43070 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "private-tracker-tracker", - "version": "2.10.0", + "version": "2.10.1", "description": "Self-hosted dashboard for monitoring private tracker stats over time", "license": "GPL-3.0", "repository": { diff --git a/scripts/regen-changelog.cjs b/scripts/regen-changelog.cjs index bb9e99fd..45ab5d3a 100644 --- a/scripts/regen-changelog.cjs +++ b/scripts/regen-changelog.cjs @@ -1,6 +1,6 @@ // scripts/regen-changelog.cjs // Regenerates CHANGELOG.md using the conventionalcommits preset, -// then post-processes to match .versionrc.json preferences. +// then post-processes to match .versionrc.cjs preferences. const { execFileSync } = require("node:child_process") const { readFileSync, writeFileSync } = require("node:fs") @@ -19,7 +19,7 @@ execFileSync( } ) -// 2. Post-process to match .versionrc.json preferences +// 2. Post-process to match .versionrc.cjs preferences let content = readFileSync(changelog, "utf-8") // Add header diff --git a/src/lib/adapters/unit3d.test.ts b/src/lib/adapters/unit3d.test.ts index 3616bdd7..31d26c49 100644 --- a/src/lib/adapters/unit3d.test.ts +++ b/src/lib/adapters/unit3d.test.ts @@ -314,3 +314,99 @@ describe("Unit3dAdapter - auth fallback", () => { expect(spy).toHaveBeenCalledTimes(1) }) }) + +// --------------------------------------------------------------------------- +// Newer UNIT3D builds (Blutopia, Upload.cx) return raw byte INTEGERS from +// /api/user instead of the humanized strings ("500.25 GiB") older builds send. +// parseBytes calls .trim() on its argument, so a numeric payload used to blow +// up the whole poll with "formatted.trim is not a function" — the raw debug +// fetch succeeded while the normalized one never produced a snapshot. +// --------------------------------------------------------------------------- +describe("Unit3dAdapter - numeric byte payloads", () => { + const adapter = new Unit3dAdapter() + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("parses a build that reports bytes as numbers", async () => { + // Verbatim from a live Blutopia /api/user response. + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "thesneakyrobot", + group: "BluSeeder", + uploaded: 2114704480460, + downloaded: 1041023858647, + ratio: 2.03, + buffer: 4245737342503, + seeding: 600, + leeching: 0, + seedbonus: "964533.23", + hit_and_runs: 0, + }), + } as Response) + + const stats = await adapter.fetchStats("https://blutopia.cc", "fake-token", "/api/user") + + expect(stats.username).toBe("thesneakyrobot") + expect(stats.group).toBe("BluSeeder") + expect(stats.uploadedBytes).toBe(BigInt(2_114_704_480_460)) + expect(stats.downloadedBytes).toBe(BigInt(1_041_023_858_647)) + expect(stats.bufferBytes).toBe(BigInt(4_245_737_342_503)) + expect(stats.ratio).toBeCloseTo(2.031, 3) + expect(stats.seedingCount).toBe(600) + expect(stats.leechingCount).toBe(0) + expect(stats.seedbonus).toBe(964533.23) + expect(stats.hitAndRuns).toBe(0) + }) + + it("keeps the sign on a numeric deficit buffer", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "DeficitUser", + group: "User", + uploaded: 10_737_418_240, + downloaded: 1_362_999_349_248, + ratio: 0.01, + buffer: -1_352_399_302_164, + seeding: 5, + leeching: 1, + seedbonus: 100, + hit_and_runs: 0, + }), + } as Response) + + const stats = await adapter.fetchStats("https://blutopia.cc", "fake-token", "/api/user") + + expect(stats.bufferBytes).toBe(BigInt(-1_352_399_302_164)) + expect(stats.seedbonus).toBe(100) + // The rest of the poll survives alongside it. + expect(stats.username).toBe("DeficitUser") + expect(stats.seedingCount).toBe(5) + }) + + it("clamps a nonsensical negative uploaded/downloaded rather than failing the poll", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "OddUser", + group: "User", + uploaded: -1, + downloaded: -1, + ratio: 0, + buffer: 0, + seeding: 0, + leeching: 0, + seedbonus: 0, + hit_and_runs: 0, + }), + } as Response) + + const stats = await adapter.fetchStats("https://blutopia.cc", "fake-token", "/api/user") + + expect(stats.uploadedBytes).toBe(BigInt(0)) + expect(stats.downloadedBytes).toBe(BigInt(0)) + }) +}) diff --git a/src/lib/adapters/unit3d.ts b/src/lib/adapters/unit3d.ts index 63391c97..0bd79eef 100644 --- a/src/lib/adapters/unit3d.ts +++ b/src/lib/adapters/unit3d.ts @@ -1,8 +1,13 @@ // src/lib/adapters/unit3d.ts // -// Functions: isUnlimitedBuffer, Unit3dAdapter - -import { computeBufferBytes, computeRatio } from "@/lib/data-transforms" +// Functions: isUnlimitedBuffer, toBytes, toNumber, toSignedBytes, Unit3dAdapter + +import { + computeBufferBytes, + computeRatio, + floatBytesToBigInt, + signedFloatBytesToBigInt, +} from "@/lib/data-transforms" import { parseBytes, parseSignedBytes } from "@/lib/parser" import { adapterFetch } from "./adapter-fetch" import type { @@ -14,21 +19,44 @@ import type { } from "./types" /** True when a UNIT3D build reports an unbounded buffer rather than a byte value. */ -function isUnlimitedBuffer(raw: string): boolean { +function isUnlimitedBuffer(raw: string | number): boolean { + // Only the humanized string form can say "unbounded" — JSON has no literal + // for Infinity, so a numeric buffer is always a real byte count. + if (typeof raw !== "string") return false const trimmed = raw?.trim().toLowerCase() ?? "" return trimmed === "∞" || trimmed === "-∞" || trimmed === "inf" || trimmed === "-inf" } +// --------------------------------------------------------------------------- +// Older UNIT3D builds humanize them ("500.25 GiB"); newer ones (Blutopia, +// Upload.cx) send raw byte integers. +// --------------------------------------------------------------------------- + +/** Unsigned byte field (uploaded, downloaded) — clamped at zero. */ +function toBytes(value: string | number): bigint { + return typeof value === "number" ? floatBytesToBigInt(value) : parseBytes(value) +} + +/** Decimal field (seedbonus) — a bare number on newer builds, "964533.23" on older ones. */ +function toNumber(value: string | number): number { + return (typeof value === "number" ? value : parseFloat(value)) || 0 +} + +/** Signed byte field (buffer only) — a deficit account must keep its sign. */ +function toSignedBytes(value: string | number): bigint { + return typeof value === "number" ? signedFloatBytesToBigInt(value) : parseSignedBytes(value) +} + interface Unit3dApiResponse { username: string group: string - uploaded: string - downloaded: string - ratio: string - buffer: string + uploaded: string | number + downloaded: string | number + ratio: string | number + buffer: string | number seeding: number leeching: number - seedbonus: string + seedbonus: string | number hit_and_runs: number } @@ -127,8 +155,8 @@ export class Unit3dAdapter implements TrackerAdapter { const data = await unit3dFetch(baseUrl, apiPath, apiToken, hostname, options) - const uploadedBytes = parseBytes(data.uploaded) - const downloadedBytes = parseBytes(data.downloaded) + const uploadedBytes = toBytes(data.uploaded) + const downloadedBytes = toBytes(data.downloaded) return { username: data.username, @@ -149,10 +177,10 @@ export class Unit3dAdapter implements TrackerAdapter { // parseBytes stays strict for every other caller. bufferBytes: isUnlimitedBuffer(data.buffer) ? computeBufferBytes(uploadedBytes, downloadedBytes) - : parseSignedBytes(data.buffer), + : toSignedBytes(data.buffer), seedingCount: data.seeding, leechingCount: data.leeching, - seedbonus: parseFloat(data.seedbonus) || 0, + seedbonus: toNumber(data.seedbonus), hitAndRuns: data.hit_and_runs, requiredRatio: null, warned: null,