From e32a8544f8d4e601dc09e341deee94bfaa71dad0 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Thu, 15 Oct 2026 11:11:59 +0300 Subject: [PATCH] Normalize invalid track and disc positions before publication --- .github/workflows/tests.yml | 2 +- apps/api/src/lib/recordPosition.test.ts | 61 +++++++++++++++++++ apps/api/src/lib/recordPosition.ts | 9 +++ apps/api/src/nowplaying/nowplaying.service.ts | 10 +-- 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/lib/recordPosition.test.ts create mode 100644 apps/api/src/lib/recordPosition.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 18219ab1..d70efb7c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -89,7 +89,7 @@ jobs: run: bun test src/xrpc/app/rocksky/song/matchingService.test.ts src/xrpc/app/rocksky/song/matchSongUris.test.ts - name: Test durable submission and record ownership boundaries working-directory: apps/api - run: bun test src/lib/durableSubmission.test.ts src/lib/durableRecord.test.ts src/lib/recordOwnership.test.ts + run: bun test src/lib/durableSubmission.test.ts src/lib/durableRecord.test.ts src/lib/recordOwnership.test.ts src/lib/recordPosition.test.ts submissions: needs: changes diff --git a/apps/api/src/lib/recordPosition.test.ts b/apps/api/src/lib/recordPosition.test.ts new file mode 100644 index 00000000..2deb30f8 --- /dev/null +++ b/apps/api/src/lib/recordPosition.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "bun:test"; +import * as Scrobble from "lexicon/types/app/rocksky/scrobble"; +import * as Song from "lexicon/types/app/rocksky/song"; +import { recordPosition } from "./recordPosition"; + +for (const [type, validate] of [ + ["app.rocksky.song", Song.validateRecord], + ["app.rocksky.scrobble", Scrobble.validateRecord], +] as const) { + const record = { + $type: type, + title: "Mirage", + artist: "Test artist", + albumArtist: "Test artist", + album: "Test album", + duration: 180000, + createdAt: "2026-10-15T08:00:00.000Z", + }; + + it(`${type} repairs zero positions rejected by the publication schema`, () => { + expect(validate({ ...record, trackNumber: 0, discNumber: 0 }).success).toBe( + false, + ); + const repaired = { + ...record, + trackNumber: recordPosition(0), + discNumber: recordPosition(0), + }; + expect(repaired.trackNumber).toBe(1); + expect(repaired.discNumber).toBe(1); + expect(validate(repaired).success).toBe(true); + }); + + it(`${type} defaults missing or invalid positions without rounding them`, () => { + for (const value of [ + undefined, + null, + -1, + 1.5, + NaN, + Infinity, + Number.MAX_SAFE_INTEGER + 1, + ]) { + const position = recordPosition(value); + expect(position).toBe(1); + expect( + validate({ ...record, trackNumber: position, discNumber: position }) + .success, + ).toBe(true); + } + }); + + it(`${type} preserves valid track and disc numbers`, () => { + const positions = { + trackNumber: recordPosition(12), + discNumber: recordPosition(2), + }; + expect(positions).toEqual({ trackNumber: 12, discNumber: 2 }); + expect(validate({ ...record, ...positions }).success).toBe(true); + }); +} diff --git a/apps/api/src/lib/recordPosition.ts b/apps/api/src/lib/recordPosition.ts new file mode 100644 index 00000000..a51b08bb --- /dev/null +++ b/apps/api/src/lib/recordPosition.ts @@ -0,0 +1,9 @@ +/** Track/disc positions in published records must be positive integers. + * Clients and catalog providers sometimes use zero to mean unknown. + * Normalize at publication so already-queued submissions are repaired too. + */ +export function recordPosition(value: number | null | undefined): number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 + ? value + : 1; +} diff --git a/apps/api/src/nowplaying/nowplaying.service.ts b/apps/api/src/nowplaying/nowplaying.service.ts index 9ae9f9b3..66f237e5 100644 --- a/apps/api/src/nowplaying/nowplaying.service.ts +++ b/apps/api/src/nowplaying/nowplaying.service.ts @@ -14,6 +14,7 @@ import { deepSnakeCaseKeys, withFallbackAlbumArt } from "lib"; import { bumpAllFeedVersions, bumpScrobblesVersion } from "lib/feedCache"; import { ownsRecord, putRecordOnce } from "lib/recordOwnership"; import { findDurableRecord, publishDurableRecord } from "lib/durableRecord"; +import { recordPosition } from "lib/recordPosition"; import { assertNotBotFlagged, assertNotScrobbleBlocked, @@ -153,9 +154,8 @@ export async function putSongRecord( albumArtUrl: withFallbackAlbumArt(track.albumArt), composer: track.composer ? track.composer : undefined, lyrics: track.lyrics ? track.lyrics : undefined, - trackNumber: track.trackNumber ?? undefined, - discNumber: - !track.discNumber || track.discNumber === 0 ? 1 : track.discNumber, + trackNumber: recordPosition(track.trackNumber), + discNumber: recordPosition(track.discNumber), copyrightMessage: track.copyrightMessage ? track.copyrightMessage : undefined, @@ -205,8 +205,8 @@ async function putScrobbleRecord( artists: track.artists === null ? undefined : track.artists, album: track.album, duration: track.duration, - trackNumber: track.trackNumber ?? 1, - discNumber: track.discNumber ? track.discNumber : 1, + trackNumber: recordPosition(track.trackNumber), + discNumber: recordPosition(track.discNumber), releaseDate: track.releaseDate ? track.releaseDate.toISOString() : undefined, -- 2.54.0