From a35e222cb24ed0b0c24c676a180c38c3e98420dd Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:38:37 +0000 Subject: [PATCH] feat(types): expose the book file id on audiobook manifest assets Clients that download an audiobook get its files from the book detail and its playback order from the manifest, and had to pair the two by position. The two lists are ordered differently, so an index pairing can attach the wrong file to an asset. Add the book_files id to each manifest asset so the pairing can be made by id. The field is additive and the server always sends it. The manifest revision is hashed from file and chapter data, not from the response, so existing revisions do not change and clients holding one do not see a 412 from this deploy. The iOS app consumes this field to pair downloaded files with manifest assets; the web client does not read it yet, so a grep of the client alone will show it as unused. --- .../__tests__/AudiobookReaderView.spec.ts | 2 +- packages/types/src/audiobook.ts | 2 ++ .../modules/audiobook/audiobook.service.test.ts | 16 ++++++++++++++-- .../src/modules/audiobook/audiobook.service.ts | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/client/src/features/reader/audiobook/__tests__/AudiobookReaderView.spec.ts b/client/src/features/reader/audiobook/__tests__/AudiobookReaderView.spec.ts index 5a6fc8010..57dc9211b 100644 --- a/client/src/features/reader/audiobook/__tests__/AudiobookReaderView.spec.ts +++ b/client/src/features/reader/audiobook/__tests__/AudiobookReaderView.spec.ts @@ -196,7 +196,7 @@ function audiobookManifest(): AudiobookManifest { schemaVersion: 2, revision: 'manifest-rev-1', book: { id: BOOK_ID, title: 'The Long Orbit', authors: ['Ada Vance'], narrators: ['Sam Reed'], hasCover: true }, - assets: [{ assetId: 'asset-1', sequence: 0, format: 'm4b', durationMs: 3_600_000, sizeBytes: 4096, etag: 'etag-1' }], + assets: [{ assetId: 'asset-1', fileId: FILE_ID, sequence: 0, format: 'm4b', durationMs: 3_600_000, sizeBytes: 4096, etag: 'etag-1' }], chapters: [{ id: 'chapter-1', title: 'Launch', assetId: 'asset-1', sequence: 0, startMs: 0, endMs: 3_600_000, assetOffsetMs: 0 }], totalDurationMs: 3_600_000, } diff --git a/packages/types/src/audiobook.ts b/packages/types/src/audiobook.ts index bfce4714f..6e3a562ea 100644 --- a/packages/types/src/audiobook.ts +++ b/packages/types/src/audiobook.ts @@ -8,6 +8,8 @@ export const AUDIOBOOK_MANIFEST_VERSION = 2 as const; export interface AudiobookManifestAsset { assetId: string; + /** The book file id, matching `BookDetail.files[].id`, so clients can pair an asset with its file. */ + fileId: number; sequence: number; format: string; durationMs: number | null; diff --git a/server/src/modules/audiobook/audiobook.service.test.ts b/server/src/modules/audiobook/audiobook.service.test.ts index a8ec7fec1..70272b8ca 100644 --- a/server/src/modules/audiobook/audiobook.service.test.ts +++ b/server/src/modules/audiobook/audiobook.service.test.ts @@ -102,8 +102,8 @@ describe('AudiobookService', () => { expect(manifest.schemaVersion).toBe(AUDIOBOOK_MANIFEST_VERSION); expect(manifest.revision).toMatch(/^[0-9a-f]{64}$/); expect(manifest.assets).toEqual([ - expect.objectContaining({ assetId: `aud_${FIRST_PUBLIC_ID}`, sequence: 0, durationMs: 10_125 }), - expect.objectContaining({ assetId: `aud_${SECOND_PUBLIC_ID}`, sequence: 1, durationMs: 20_000 }), + expect.objectContaining({ assetId: `aud_${FIRST_PUBLIC_ID}`, fileId: 11, sequence: 0, durationMs: 10_125 }), + expect.objectContaining({ assetId: `aud_${SECOND_PUBLIC_ID}`, fileId: 12, sequence: 1, durationMs: 20_000 }), ]); expect(manifest.chapters).toEqual([ expect.objectContaining({ sequence: 0, startMs: 0, endMs: 10_125, assetId: `aud_${FIRST_PUBLIC_ID}`, assetOffsetMs: 0 }), @@ -111,6 +111,18 @@ describe('AudiobookService', () => { ]); }); + it('keeps the manifest revision independent of the file ids it exposes', async () => { + const { service, repo } = makeFixture(); + const before = await service.getManifest(30, makeUser()); + const files = await repo.findAudioFiles(); + repo.findAudioFiles.mockResolvedValue(files.map((file) => ({ ...file, id: file.id + 900 }))); + + const after = await service.getManifest(30, makeUser()); + + expect(after.assets.map((asset) => asset.fileId)).toEqual([911, 912]); + expect(after.revision).toBe(before.revision); + }); + it('derives percentage and creates revision one from a matching manifest write', async () => { const { service, repo, bookService } = makeFixture(); const manifest = await service.getManifest(30, makeUser()); diff --git a/server/src/modules/audiobook/audiobook.service.ts b/server/src/modules/audiobook/audiobook.service.ts index 5eb3b67e8..5547f7693 100644 --- a/server/src/modules/audiobook/audiobook.service.ts +++ b/server/src/modules/audiobook/audiobook.service.ts @@ -212,6 +212,7 @@ export class AudiobookService { .digest('hex'); const assets = files.map((file, sequence) => ({ assetId: this.assetId(file.publicId), + fileId: file.id, sequence, format: file.format!, durationMs: file.durationSeconds === null ? null : Math.round(file.durationSeconds * 1000),