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.
This commit is contained in:
kikootwo
2026-05-15 06:42:17 -04:00
parent f4fe6f936f
commit 071c788ead
2 changed files with 74 additions and 0 deletions
+8
View File
@@ -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');
}
+66
View File
@@ -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);