fix(metadata): read narrators from OPF and keep them for audiobooks

Creators and contributors with MARC role nrt now populate narrators, in both
EPUB2 opf:role and EPUB3 refines form. The audio-specific pass also stops being
skipped when a sidecar OPF outranks an audio winner, so chapters and narrators
survive an opfFile-first library, and it runs first so an OPF that names
narrators wins over the composer tag.

Closes #1081
This commit is contained in:
neon
2026-08-24 03:44:08 +00:00
parent 10152999d1
commit 6a441f4452
8 changed files with 369 additions and 21 deletions
@@ -123,6 +123,7 @@ describe('metadata format extractors', () => {
seriesName: 'Dune Chronicles',
seriesIndex: 1,
authors: [{ name: 'Frank Herbert', sortName: null }],
narrators: [],
genres: ['Science Fiction'],
tags: ['SF', 'Classic'],
rating: 9,
@@ -169,6 +170,7 @@ describe('metadata format extractors', () => {
seriesName: null,
seriesIndex: null,
authors: [{ name: 'Frank Herbert', sortName: null }],
narrators: [],
genres: [],
tags: ['Science Fiction'],
rating: null,
@@ -231,6 +233,51 @@ describe('metadata format extractors', () => {
expect(mockReadFile).toHaveBeenCalledWith('/books/metadata.opf', 'utf8');
});
it('opf extractor carries narrators declared by role nrt', async () => {
mockReadFile.mockResolvedValue(`
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Audiobook Title</dc:title>
<dc:creator opf:role="aut">Sidecar Author</dc:creator>
<dc:creator opf:role="nrt">Sidecar Narrator</dc:creator>
</metadata>
</package>
`);
await expect(new OpfFormatExtractor().extract('/books/book.opf')).resolves.toEqual(
expect.objectContaining({
authors: [{ name: 'Sidecar Author', sortName: null }],
narrators: ['Sidecar Narrator'],
}),
);
});
it('opf extractor treats a narrator-only sidecar as usable metadata', async () => {
mockReadFile.mockResolvedValue(`
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:contributor opf:role="nrt">Sidecar Narrator</dc:contributor>
</metadata>
</package>
`);
await expect(new OpfFormatExtractor().extract('/books/book.opf')).resolves.toEqual(expect.objectContaining({ narrators: ['Sidecar Narrator'] }));
});
it('opf extractor leaves narrators undefined when the sidecar names none, so nothing is wiped', async () => {
mockReadFile.mockResolvedValue(`
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Sidecar Title</dc:title>
<dc:creator opf:role="aut">Sidecar Author</dc:creator>
</metadata>
</package>
`);
const parsed = await new OpfFormatExtractor().extract('/books/book.opf');
expect(parsed?.narrators).toBeUndefined();
});
it('opf extractor returns null for sidecars without usable metadata', async () => {
mockReadFile.mockResolvedValue('<package><metadata></metadata></package>');
@@ -479,6 +526,7 @@ describe('metadata format extractors', () => {
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
@@ -17,6 +17,7 @@ export function hasOpfMetadata(metadata: ParsedOpf): boolean {
metadata.seriesName !== null ||
metadata.seriesIndex !== null ||
metadata.authors.length > 0 ||
metadata.narrators.length > 0 ||
metadata.genres.length > 0 ||
metadata.tags.length > 0 ||
metadata.googleBooksId !== null ||
@@ -47,6 +48,9 @@ export function mapOpfMetadata(metadata: ParsedOpf, cover: Buffer | null): Parse
seriesName: metadata.seriesName,
seriesIndex: metadata.seriesIndex,
authors: metadata.authors,
// Left undefined when the OPF names none, so a sidecar without a narrator role never wipes
// narrators that the audio tags or a provider supplied.
narrators: metadata.narrators.length > 0 ? metadata.narrators : undefined,
genres: metadata.genres,
tags: metadata.tags,
rating: metadata.rating,
@@ -175,6 +175,90 @@ describe('parseOpf', () => {
});
});
describe('narrators', () => {
it('reads an EPUB2 creator with role nrt as a narrator, not an author', () => {
const xml = epub2Opf(`
<dc:creator opf:role="aut">Author Name</dc:creator>
<dc:creator opf:role="nrt">Narrator Name</dc:creator>
`);
const r = parseOpf(xml);
expect(r.narrators).toEqual(['Narrator Name']);
expect(r.authors).toEqual([{ name: 'Author Name', sortName: null }]);
});
it('reads an EPUB2 contributor with role nrt as a narrator', () => {
const xml = epub2Opf(`
<dc:creator opf:role="aut">Author Name</dc:creator>
<dc:contributor opf:role="nrt">Narrator Name</dc:contributor>
`);
const r = parseOpf(xml);
expect(r.narrators).toEqual(['Narrator Name']);
expect(r.authors).toEqual([{ name: 'Author Name', sortName: null }]);
});
it('reads an EPUB3 narrator declared through refines', () => {
const xml = epub3Opf(`
<dc:creator id="cr1">Neil Gaiman</dc:creator>
<meta refines="#cr1" property="role" scheme="marc:relators">aut</meta>
<dc:contributor id="ct1">Lenny Henry</dc:contributor>
<meta refines="#ct1" property="role" scheme="marc:relators">nrt</meta>
`);
const r = parseOpf(xml);
expect(r.narrators).toEqual(['Lenny Henry']);
expect(r.authors).toEqual([{ name: 'Neil Gaiman', sortName: null }]);
});
it('accepts a narrator role in full relator URI form', () => {
const xml = epub3Opf(`
<dc:contributor id="ct1">Rosamund Pike</dc:contributor>
<meta refines="#ct1" property="role">http://id.loc.gov/vocabulary/relators/nrt</meta>
`);
expect(parseOpf(xml).narrators).toEqual(['Rosamund Pike']);
});
it('accepts uppercase role codes and the spelled-out word', () => {
expect(parseOpf(epub2Opf(`<dc:creator opf:role="NRT">Jim Dale</dc:creator>`)).narrators).toEqual(['Jim Dale']);
expect(parseOpf(epub2Opf(`<dc:contributor opf:role="narrator">Stephen Fry</dc:contributor>`)).narrators).toEqual(['Stephen Fry']);
});
it('keeps multiple narrators in document order', () => {
const xml = epub2Opf(`
<dc:creator opf:role="nrt">First Narrator</dc:creator>
<dc:contributor opf:role="nrt">Second Narrator</dc:contributor>
`);
expect(parseOpf(xml).narrators).toEqual(['First Narrator', 'Second Narrator']);
});
it('deduplicates a narrator repeated as both creator and contributor', () => {
const xml = epub2Opf(`
<dc:creator opf:role="nrt">Bahni Turpin</dc:creator>
<dc:contributor opf:role="nrt">bahni turpin</dc:contributor>
`);
expect(parseOpf(xml).narrators).toEqual(['Bahni Turpin']);
});
it('ignores contributors that declare no role, so packaging tools never become people', () => {
const xml = epub2Opf(`
<dc:creator opf:role="aut">Author Name</dc:creator>
<dc:contributor>Some Tool</dc:contributor>
<dc:contributor opf:role="bkp">calibre (7.16.0)</dc:contributor>
`);
const r = parseOpf(xml);
expect(r.narrators).toEqual([]);
expect(r.authors).toEqual([{ name: 'Author Name', sortName: null }]);
});
it('skips narrator entries with empty text', () => {
const xml = epub2Opf(`<dc:contributor opf:role="nrt"> </dc:contributor>`);
expect(parseOpf(xml).narrators).toEqual([]);
});
it('returns an empty narrators array when the OPF declares none', () => {
const xml = epub2Opf(`<dc:creator opf:role="aut">Author Name</dc:creator>`);
expect(parseOpf(xml).narrators).toEqual([]);
});
});
describe('ISBN parsing', () => {
it('detects bare ISBN-13 from unique identifier with no scheme', () => {
const xml = epub3Opf(`<dc:identifier id="bookid">9780008337193</dc:identifier>`);
+32 -10
View File
@@ -17,6 +17,7 @@ export interface ParsedOpf {
seriesName: string | null;
seriesIndex: number | null;
authors: { name: string; sortName: string | null }[];
narrators: string[];
genres: string[];
tags: string[];
googleBooksId: string | null;
@@ -40,7 +41,7 @@ const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
isArray: (name) => ['creator', 'identifier', 'subject', 'title', 'meta', 'item', 'reference'].includes(name),
isArray: (name) => ['creator', 'contributor', 'identifier', 'subject', 'title', 'meta', 'item', 'reference'].includes(name),
textNodeName: '#text',
allowBooleanAttributes: true,
parseTagValue: false, // keep all values as strings to preserve ISBNs with leading zeroes
@@ -208,6 +209,9 @@ function parseCalibreUserMetadata(raw: string | null): { pageCount: number | nul
return { pageCount, subtitle, extraTags };
}
// MARC relator `nrt`, plus the spelled-out word some writers emit instead of the code.
const NARRATOR_ROLES = new Set(['nrt', 'narrator']);
function normalizeCreatorRole(role: string | null | undefined): string {
if (!role) return '';
const trimmed = role.trim().toLowerCase();
@@ -298,28 +302,45 @@ export function parseOpf(xml: string): ParsedOpf {
subtitle ??= namedMeta('bookorbit:subtitle');
subtitle ??= calibreUser.subtitle;
// ── Authors ────────────────────────────────────────────────────────────────
// ── Authors and narrators ──────────────────────────────────────────────────
const authors: { name: string; sortName: string | null }[] = [];
const rawCreators = toArray(metadata['creator']);
const narrators: string[] = [];
const seenNarrators = new Set<string>();
for (const c of rawCreators) {
const mo = (typeof c === 'object' && c !== null ? c : {}) as Record<string, unknown>;
const name = getText(c);
if (!name) continue;
// A roleless <dc:creator> is an author by EPUB 2 convention, but a roleless <dc:contributor> is
// not: contributors carry packaging tools (`bkp`) and other incidentals, so only an explicit
// narrator role is taken from them.
const collectPerson = (node: unknown, defaults: { role: string; allowAuthor: boolean }): void => {
const mo = (typeof node === 'object' && node !== null ? node : {}) as Record<string, unknown>;
const name = getText(node);
if (!name) return;
const id = mo['@_id'] as string | undefined;
// EPUB 3: role via refines
const role3 = id ? getRefineValue(refineMap, id, 'role') : null;
// EPUB 2: opf:role attribute
const role2 = (mo['@_opf:role'] ?? mo['@_role']) as string | undefined;
const role = normalizeCreatorRole(role3 ?? role2 ?? 'aut');
if (role !== 'aut' && role !== '') continue; // skip editors, illustrators, etc.
const role = normalizeCreatorRole(role3 ?? role2 ?? defaults.role);
if (NARRATOR_ROLES.has(role)) {
const key = name.toLowerCase();
if (seenNarrators.has(key)) return;
seenNarrators.add(key);
narrators.push(name);
return;
}
if (!defaults.allowAuthor) return;
if (role !== 'aut' && role !== '') return; // skip editors, illustrators, etc.
// EPUB 3: file-as via refines; EPUB 2: opf:file-as attribute
const sortName = (id ? getRefineValue(refineMap, id, 'file-as') : null) ?? ((mo['@_opf:file-as'] ?? mo['@_file-as'] ?? null) as string | null);
authors.push({ name, sortName: sortName?.trim() || null });
}
};
for (const creator of toArray(metadata['creator'])) collectPerson(creator, { role: 'aut', allowAuthor: true });
for (const contributor of toArray(metadata['contributor'])) collectPerson(contributor, { role: '', allowAuthor: false });
// ── Identifiers → ISBN + provider IDs ─────────────────────────────────────
let isbn10: string | null = null;
@@ -538,6 +559,7 @@ export function parseOpf(xml: string): ParsedOpf {
seriesName: seriesName || null,
seriesIndex,
authors,
narrators,
genres,
tags,
googleBooksId,
@@ -299,6 +299,7 @@ describe('MetadataService', () => {
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
@@ -435,6 +436,7 @@ describe('MetadataService', () => {
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
@@ -489,6 +491,7 @@ describe('MetadataService', () => {
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
@@ -537,6 +540,7 @@ describe('MetadataService', () => {
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
rating: null,
@@ -577,6 +581,7 @@ describe('MetadataService', () => {
seriesName: 'Series Name',
seriesIndex: 2,
authors: [{ name: 'Author A', sortName: null }],
narrators: [],
genres: ['Fantasy'],
tags: ['Shelf'],
rating: 4.6,
@@ -1114,6 +1119,104 @@ describe('MetadataService', () => {
expect(insertedTags).toEqual(['Shelf', 'Y'.repeat(200)]);
});
describe('narrators from a sidecar OPF', () => {
const opfXml = (metadataBody: string) => `
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Sidecar Title</dc:title>
${metadataBody}
</metadata>
</package>`;
it('persists narrators declared by role nrt on a non-audio source', async () => {
const { db } = makeDb();
const narratorService = { replaceForBook: vi.fn().mockResolvedValue(undefined) };
const service = makeService(db, undefined, { narratorService });
mockReadFile.mockResolvedValue(
opfXml(`
<dc:creator opf:role="nrt">First Narrator</dc:creator>
<dc:contributor opf:role="nrt">Second Narrator</dc:contributor>
`),
);
await service.extractAndSave(31, '/books/Book/book.opf', 'opf');
expect(narratorService.replaceForBook).toHaveBeenCalledWith(31, ['First Narrator', 'Second Narrator']);
});
it('leaves narrators untouched when the OPF declares none', async () => {
const { db } = makeDb();
const narratorService = { replaceForBook: vi.fn().mockResolvedValue(undefined) };
const service = makeService(db, undefined, { narratorService });
mockReadFile.mockResolvedValue(opfXml(`<dc:publisher>Ace</dc:publisher>`));
await service.extractAndSave(32, '/books/Book/book.opf', 'opf');
expect(narratorService.replaceForBook).not.toHaveBeenCalled();
});
it('skips narrators when the field is locked', async () => {
const { db } = makeDb();
const narratorService = { replaceForBook: vi.fn().mockResolvedValue(undefined) };
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: Record<string, unknown>) => {
const rest = { ...dto };
delete rest.audioMetadata;
return Promise.resolve({ dto: rest, skippedFields: ['narrators'] });
}),
};
const service = makeService(db, undefined, { narratorService, bookMetadataLockService: lockService });
mockReadFile.mockResolvedValue(opfXml(`<dc:creator opf:role="nrt">First Narrator</dc:creator>`));
await service.extractAndSave(33, '/books/Book/book.opf', 'opf');
expect(lockService.filterAutomatedBookUpdate).toHaveBeenCalledWith(
33,
expect.objectContaining({ audioMetadata: { narrators: ['First Narrator'] } }),
);
expect(narratorService.replaceForBook).not.toHaveBeenCalled();
});
});
it('extractAudioChaptersAndNarrators leaves narrators alone when the audio tags name none', async () => {
const { db, updateSet } = makeDb();
const narratorService = { replaceForBook: vi.fn().mockResolvedValue(undefined) };
const lockService = {
isFieldLocked: vi.fn().mockResolvedValue(false),
filterAutomatedBookUpdate: vi.fn().mockImplementation((_bookId: number, dto: unknown) => Promise.resolve({ dto, skippedFields: [] })),
};
const service = makeService(db, undefined, { narratorService, bookMetadataLockService: lockService });
mockExtractAudioMetadata.mockResolvedValueOnce({
title: null,
subtitle: null,
authors: [],
narrators: [],
publisher: null,
publishedYear: null,
description: null,
language: null,
seriesName: null,
seriesIndex: null,
genres: [],
audibleId: null,
librofmId: null,
durationSeconds: null,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
coverBytes: null,
} as never);
await service.extractAudioChaptersAndNarrators(73, '/tmp/audio.m4b', 'm4b');
expect(lockService.filterAutomatedBookUpdate).toHaveBeenCalledWith(
73,
expect.objectContaining({ audioMetadata: { chapters: [{ title: 'Chapter 1', startMs: 0 }] } }),
);
expect(narratorService.replaceForBook).not.toHaveBeenCalled();
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ chapters: [{ title: 'Chapter 1', startMs: 0 }] }));
});
it('extractAudioChaptersAndNarrators updates filtered audio fields and supports early exits', async () => {
const { db, updateSet } = makeDb();
const narratorService = {
@@ -1211,6 +1314,7 @@ describe('MetadataService', () => {
seriesName: null,
seriesIndex: null,
authors: [],
narrators: [],
genres: [],
tags: [],
customMetadata: {},
@@ -145,7 +145,9 @@ export class MetadataService {
audibleId: boundProviderId('audibleId', data.audibleId),
librofmId: boundProviderId('librofmId', data.librofmId),
audioMetadata: {
narrators: data.narrators,
// Omitted when the tags name none: this pass fills in fields the shared metadata source
// cannot carry, so an untagged file must not erase narrators another source already set.
...(data.narrators && data.narrators.length > 0 ? { narrators: data.narrators } : {}),
chapters: data.chapters && data.chapters.length > 0 ? data.chapters : null,
},
});
@@ -687,6 +689,9 @@ export class MetadataService {
aladinId: boundProviderId('aladinId', data.aladinId),
itunesId: boundProviderId('itunesId', data.itunesId),
comicMetadata: data.comicMetadata ?? undefined,
// A sidecar can name narrators (OPF role="nrt"); only sent when it did, so an extractor that
// has no narrator concept never reaches the replace below.
audioMetadata: data.narrators && data.narrators.length > 0 ? { narrators: data.narrators } : undefined,
});
const scalarFields: Partial<typeof schema.bookMetadata.$inferInsert> = {};
@@ -749,6 +754,10 @@ export class MetadataService {
await this.comicMetadataRepository.upsert(bookId, filtered.comicMetadata);
}
if (filtered.audioMetadata?.narrators !== undefined) {
await this.narratorService.replaceForBook(bookId, filtered.audioMetadata.narrators);
}
// ComicInfo Count describes the series the file names, so it is read from the parsed file
// rather than the lock-filtered patch: a locked seriesName still leaves the count truthful.
await this.seriesExpectedCount?.record(data.seriesName, data.seriesTotalBooks);
@@ -736,6 +736,78 @@ describe('genuinely new primary file', () => {
expect(mockMetadata.extractAndSave).toHaveBeenCalledWith(expect.any(Number), '/library/Book/metadata.opf', 'opf');
});
it('still reads audio chapters and narrators when a sidecar OPF outranks the audio winner', async () => {
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', format: 'm4b', role: 'content' });
const opf = makeFileStat({
absolutePath: '/library/Book/book.opf',
relPath: 'Book/book.opf',
ino: 1002n,
format: 'opf',
role: 'metadata',
});
const candidate = makeCandidate('/library/Book', [m4b, opf]);
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
const calls: string[] = [];
mockMetadata.extractAudioChaptersAndNarrators.mockImplementation(() => {
calls.push('audio');
return Promise.resolve(undefined);
});
mockMetadata.extractAndSave.mockImplementation(() => {
calls.push('shared');
return Promise.resolve(undefined);
});
const repo = makeRepo({
findLibrarySettings: vi.fn().mockResolvedValue({
allowedFormats: [],
formatPriority: DEFAULT_FORMAT_PRIORITY,
metadataPrecedence: ['opfFile', 'embedded'],
excludePatterns: [],
organizationMode: 'book_per_folder',
}),
});
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(mockMetadata.extractAudioChaptersAndNarrators).toHaveBeenCalledWith(expect.any(Number), m4b.absolutePath, 'm4b');
expect(mockMetadata.extractAndSave).toHaveBeenCalledWith(expect.any(Number), opf.absolutePath, 'opf');
// Audio first, so an OPF that names narrators overwrites the composer tag rather than losing to it.
expect(calls).toEqual(['audio', 'shared']);
});
it('does not re-read the audio winner when embedded metadata already leads', async () => {
const m4b = makeFileStat({ absolutePath: '/library/Book/book.m4b', relPath: 'Book/book.m4b', format: 'm4b', role: 'content' });
const opf = makeFileStat({
absolutePath: '/library/Book/book.opf',
relPath: 'Book/book.opf',
ino: 1002n,
format: 'opf',
role: 'metadata',
});
const candidate = makeCandidate('/library/Book', [m4b, opf]);
mockFindCandidates.mockResolvedValue({ candidates: [candidate], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
const repo = makeRepo({
findLibrarySettings: vi.fn().mockResolvedValue({
allowedFormats: [],
formatPriority: DEFAULT_FORMAT_PRIORITY,
metadataPrecedence: ['embedded', 'opfFile'],
excludePatterns: [],
organizationMode: 'book_per_folder',
}),
});
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(mockMetadata.extractAndSave).toHaveBeenCalledWith(expect.any(Number), m4b.absolutePath, 'm4b');
expect(mockMetadata.extractAudioChaptersAndNarrators).not.toHaveBeenCalled();
});
it('keeps embedded metadata ahead of sidecar OPF when embedded precedes opfFile', async () => {
const primary = makeFileStat({ absolutePath: '/library/Book/book.epub', relPath: 'Book/book.epub', format: 'epub', role: 'content' });
const opf = makeFileStat({
+15 -10
View File
@@ -1567,7 +1567,8 @@ export class ScannerService implements OnApplicationBootstrap {
//
// Design rules:
// - Text metadata (title, authors, cover, etc.) comes from the first available configured source.
// - Audio-specific fields (chapters, narrators, duration) always come from audio if present.
// - Audio-specific fields (chapters, narrators, duration) come from audio if present, but a
// configured source that names narrators itself outranks the audio tags.
// - Extraction only fires when at least one configured metadata source is new, reassigned, or changed.
const metadataSources = this.buildMetadataExtractionSources(registeredFiles, winner, metadataPrecedence);
@@ -1577,15 +1578,14 @@ export class ScannerService implements OnApplicationBootstrap {
const changedAudioFiles = audioContentFiles.filter(hasMetadataSourceChanged);
const winnerIsAudio = winner !== null && winner.format !== null && isAudioFormat(winner.format);
// 3a: Extract shared metadata from the first available configured source.
if (shouldExtractMetadata) {
await this.extractFirstAvailableMetadataSource(book.id, metadataSources);
}
// 3b: When winner is not audio, extract audio-specific fields (chapters, narrators)
// from the first audio file if any audio file is new, reassigned, or changed.
// Cover is intentionally skipped here - shared metadata already owns it from step 3a.
if (!winnerIsAudio && changedAudioFiles.length > 0) {
// 3a: Extract audio-specific fields (chapters, narrators) from the first audio file if any audio
// file is new, reassigned, or changed. Skipped when the audio winner is itself the leading
// metadata source, because shared extraction already reads the same tags from it.
// Cover is intentionally skipped here - shared metadata owns it from step 3b.
// Runs before shared extraction so a source that declares narrators (an OPF carrying
// role="nrt") overwrites the composer tag rather than being overwritten by it.
const audioWinnerLeadsMetadata = winnerIsAudio && metadataSources[0]?.key === 'embedded';
if (!audioWinnerLeadsMetadata && changedAudioFiles.length > 0) {
const sortedAudio = [...audioContentFiles].sort((a, b) =>
basename(a.absolutePath).localeCompare(basename(b.absolutePath), undefined, { numeric: true }),
);
@@ -1599,6 +1599,11 @@ export class ScannerService implements OnApplicationBootstrap {
}
}
// 3b: Extract shared metadata from the first available configured source.
if (shouldExtractMetadata) {
await this.extractFirstAvailableMetadataSource(book.id, metadataSources);
}
// 3c: Write per-file duration to bookFiles for every new/reassigned/changed audio file.
// Running this for all new audio files (including the winner) ensures
// aggregateAudioDuration has accurate per-file data for the total.