From 071c788ead97209a5b6af34be9bb5b0be08345c9 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 15 May 2026 06:42:17 -0400 Subject: [PATCH] Add series metadata tagging and tests Include series and seriesPart metadata when tagging audio files. For m4b output the code uses show and episode_id; for mp3 and flac it writes SERIES and SERIES-PART. Adds unit tests verifying tag output for .m4b, .mp3, and .flac and that tags are omitted when fields are absent. --- src/lib/utils/metadata-tagger.ts | 8 ++++ tests/utils/metadata-tagger.test.ts | 66 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/lib/utils/metadata-tagger.ts b/src/lib/utils/metadata-tagger.ts index 50192ba..ea04be1 100644 --- a/src/lib/utils/metadata-tagger.ts +++ b/src/lib/utils/metadata-tagger.ts @@ -118,6 +118,14 @@ export async function tagAudioFileMetadata( args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`); } + if (metadata.series) { + args.push('-metadata', `SERIES="${escapeMetadata(metadata.series)}"`); + } + + if (metadata.seriesPart) { + args.push('-metadata', `SERIES-PART="${escapeMetadata(metadata.seriesPart)}"`); + } + // Explicitly specify output format args.push('-f', 'flac'); } diff --git a/tests/utils/metadata-tagger.test.ts b/tests/utils/metadata-tagger.test.ts index 92ebc84..9a03104 100644 --- a/tests/utils/metadata-tagger.test.ts +++ b/tests/utils/metadata-tagger.test.ts @@ -114,6 +114,72 @@ describe('metadata tagger', () => { await expect(checkFfmpegAvailable()).resolves.toBe(false); }); + describe('series metadata', () => { + it('writes show/episode_id for m4b when series/seriesPart provided', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book', + author: 'Author', + series: 'The Mistborn Saga', + seriesPart: '1', + }); + + const command = execMock.mock.calls[0][0] as string; + expect(command).toContain('-metadata show="The Mistborn Saga"'); + expect(command).toContain('-metadata episode_id="1"'); + }); + + it('writes SERIES/SERIES-PART for mp3 when series/seriesPart provided', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.mp3', { + title: 'Book', + author: 'Author', + series: 'The Mistborn Saga', + seriesPart: '1.5', + }); + + const command = execMock.mock.calls[0][0] as string; + expect(command).toContain('-metadata SERIES="The Mistborn Saga"'); + expect(command).toContain('-metadata SERIES-PART="1.5"'); + }); + + it('writes SERIES/SERIES-PART for flac when series/seriesPart provided', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.flac', { + title: 'Book', + author: 'Author', + series: 'The Mistborn Saga', + seriesPart: '2', + }); + + const command = execMock.mock.calls[0][0] as string; + expect(command).toContain('-metadata SERIES="The Mistborn Saga"'); + expect(command).toContain('-metadata SERIES-PART="2"'); + }); + + it('omits series tags when fields are absent', async () => { + fsMock.access.mockResolvedValue(undefined); + mockExecSuccess('done'); + + await tagAudioFileMetadata('/tmp/book.m4b', { + title: 'Book', + author: 'Author', + }); + + const command = execMock.mock.calls[0][0] as string; + expect(command).not.toContain('show='); + expect(command).not.toContain('episode_id='); + expect(command).not.toContain('SERIES='); + expect(command).not.toContain('SERIES-PART='); + }); + }); + describe('metadata escaping', () => { it('does NOT escape single quotes (they are literal in double-quoted shell strings)', async () => { fsMock.access.mockResolvedValue(undefined);