Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .versionrc.cjs
Original file line number Diff line number Diff line change
@@ -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 },
],
}
17 changes: 0 additions & 17 deletions .versionrc.json

This file was deleted.

6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions scripts/regen-changelog.cjs
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions src/lib/adapters/unit3d.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
})
54 changes: 41 additions & 13 deletions src/lib/adapters/unit3d.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -127,8 +155,8 @@ export class Unit3dAdapter implements TrackerAdapter {

const data = await unit3dFetch<Unit3dApiResponse>(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,
Expand All @@ -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,
Expand Down
Loading