From 4770032ce7587419384fc7a32fb4c5fb7cf23d3d Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:56:20 +0000 Subject: [PATCH] fix(metadata): import ComicInfo Tags as book tags Map to genres and to tags instead of using tags as a genre fallback, which dropped them whenever a Genre was present. Read the ComicBookInfo genre field so those comics keep their genres. Fixes #1572 --- .../extractors/comic-format.extractor.ts | 3 +- .../extractors/format-extractors.test.ts | 17 ++++++ .../modules/metadata/lib/cbz-metadata.test.ts | 22 +++++++ .../src/modules/metadata/lib/cbz-metadata.ts | 3 +- .../metadata-lock/metadata-lock-harness.ts | 7 ++- .../metadata-write-fixture-builder.ts | 8 ++- server/test/metadata-lock.e2e-spec.ts | 58 ++++++++++++++++++- 7 files changed, 113 insertions(+), 5 deletions(-) diff --git a/server/src/modules/metadata/extractors/comic-format.extractor.ts b/server/src/modules/metadata/extractors/comic-format.extractor.ts index 3f82d6243..3fea54632 100644 --- a/server/src/modules/metadata/extractors/comic-format.extractor.ts +++ b/server/src/modules/metadata/extractors/comic-format.extractor.ts @@ -42,7 +42,8 @@ export class ComicFormatExtractor implements FormatExtractor { seriesIndex: comicMetadata?.seriesIndex ?? null, seriesTotalBooks: comicMetadata?.seriesTotalBooks ?? null, authors: comicMetadata?.authors ?? [], - genres: comicMetadata?.genres?.length ? comicMetadata.genres : (comicMetadata?.tags ?? []), + genres: comicMetadata?.genres ?? [], + tags: comicMetadata?.tags ?? [], googleBooksId: comicMetadata?.googleBooksId ?? null, goodreadsId: comicMetadata?.goodreadsId ?? null, amazonId: comicMetadata?.amazonId ?? null, diff --git a/server/src/modules/metadata/extractors/format-extractors.test.ts b/server/src/modules/metadata/extractors/format-extractors.test.ts index 488c6e958..e52ca5acc 100644 --- a/server/src/modules/metadata/extractors/format-extractors.test.ts +++ b/server/src/modules/metadata/extractors/format-extractors.test.ts @@ -427,6 +427,7 @@ describe('metadata format extractors', () => { publishedYear: 2014, authors: [], genres: [], + tags: [], comicvineId: null, }), ); @@ -454,6 +455,7 @@ describe('metadata format extractors', () => { expect.objectContaining({ title: 'Batman', genres: ['Comics'], + tags: ['Superhero'], ranobedbId: 'comic-ranobe', comicvineId: '140529', comicMetadata: { issueNumber: '55', volumeName: 'Batman' }, @@ -461,6 +463,21 @@ describe('metadata format extractors', () => { ); }); + it('comic extractor keeps Tags as tags and never promotes them to genres', async () => { + mockExtractCbzMetadata.mockResolvedValue({ + title: 'Ninja Scroll', + authors: [], + genres: [], + tags: ['ninja', 'school life'], + } as any); + mockExtractCbzCover.mockResolvedValue(null); + + const result = await new ComicFormatExtractor('cbz').extract('/books/ninja.cbz'); + + expect(result?.genres).toEqual([]); + expect(result?.tags).toEqual(['ninja', 'school life']); + }); + it('comic extractor uses CBR reader when a CBZ file is actually a RAR archive', async () => { mockDetectComicContainerFormat.mockResolvedValue('cbr'); mockExtractCbrMetadata.mockResolvedValue({ diff --git a/server/src/modules/metadata/lib/cbz-metadata.test.ts b/server/src/modules/metadata/lib/cbz-metadata.test.ts index af1c52d01..035326231 100644 --- a/server/src/modules/metadata/lib/cbz-metadata.test.ts +++ b/server/src/modules/metadata/lib/cbz-metadata.test.ts @@ -422,6 +422,28 @@ describe('extractCbzMetadata', () => { expect(r?.tags).toEqual(['superhero', 'action']); }); + it('extracts the genre string into deduplicated genres kept apart from tags', async () => { + const comment = JSON.stringify({ + 'ComicBookInfo/1.0': { genre: 'Superhero, Science Fiction, Superhero', tags: ['action'], credits: [] }, + }); + mockZipFile(buildZipCommentOnly(comment)); + + const r = await extractCbzMetadata('/book.cbz'); + expect(r?.genres).toEqual(['Superhero', 'Science Fiction']); + expect(r?.tags).toEqual(['action']); + }); + + it('leaves genres empty when genre is missing or not a string', async () => { + for (const genre of [undefined, null, 42, ['Superhero']]) { + const comment = JSON.stringify({ 'ComicBookInfo/1.0': { genre, tags: ['action'], credits: [] } }); + mockZipFile(buildZipCommentOnly(comment)); + + const r = await extractCbzMetadata('/book.cbz'); + expect(r?.genres, `genre=${JSON.stringify(genre)}`).toEqual([]); + expect(r?.tags).toEqual(['action']); + } + }); + it('preserves issue 0 as a valid series index (origin issues)', async () => { const comment = JSON.stringify({ 'ComicBookInfo/1.0': { issue: 0, credits: [], tags: [] }, diff --git a/server/src/modules/metadata/lib/cbz-metadata.ts b/server/src/modules/metadata/lib/cbz-metadata.ts index 849cb3b83..6c9579695 100644 --- a/server/src/modules/metadata/lib/cbz-metadata.ts +++ b/server/src/modules/metadata/lib/cbz-metadata.ts @@ -259,6 +259,7 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null { .filter((c) => c.role === 'Writer') .map((c) => ({ name: c.person, sortName: null })); + const genres = [...new Set(splitDelimited(typeof cbi['genre'] === 'string' ? cbi['genre'] : null))]; const tags = ((cbi['tags'] as string[]) ?? []).filter(Boolean); const publicationYear = parsePublishedYear(cbi['publicationYear']) ?? null; @@ -286,7 +287,7 @@ function parseComicBookInfoJson(comment: string): ParsedCbzMetadata | null { isbn10: null, isbn13: null, authors: writers, - genres: [], + genres, tags, googleBooksId: null, goodreadsId: null, diff --git a/server/test/e2e/metadata-lock/metadata-lock-harness.ts b/server/test/e2e/metadata-lock/metadata-lock-harness.ts index 4c0056e2f..27644d13e 100644 --- a/server/test/e2e/metadata-lock/metadata-lock-harness.ts +++ b/server/test/e2e/metadata-lock/metadata-lock-harness.ts @@ -22,6 +22,7 @@ import { bookMetadata, bookMetadataFetchQueue, bookNarrators, + bookTags, books, comicMetadata, genres, @@ -29,6 +30,7 @@ import { libraryFolders, narrators, scanJobs, + tags, } from '../../../src/db/schema'; import { COVER_CUSTOM_FILE_PREFIX, COVER_EXTRACTED_FILE_PREFIX } from '../../../src/modules/cover/constants'; import { coverDirPath } from '../../../src/modules/metadata/lib/cover'; @@ -106,6 +108,7 @@ export interface BookMutationState { lastMetadataFetchAt: Date | null; } | null; genres: string[]; + tags: string[]; narrators: string[]; comicMetadata: { issueNumber: string | null; @@ -269,7 +272,7 @@ export async function locateBookFileByRelPath(ctx: MetadataLockE2EContext, libra } export async function readBookMutationState(ctx: MetadataLockE2EContext, bookId: number): Promise { - const [metadataRows, genreRows, narratorRows, comicRows] = await Promise.all([ + const [metadataRows, genreRows, tagRows, narratorRows, comicRows] = await Promise.all([ ctx.db .select({ title: bookMetadata.title, @@ -294,6 +297,7 @@ export async function readBookMutationState(ctx: MetadataLockE2EContext, bookId: .where(eq(bookMetadata.bookId, bookId)) .limit(1), ctx.db.select({ name: genres.name }).from(bookGenres).innerJoin(genres, eq(genres.id, bookGenres.genreId)).where(eq(bookGenres.bookId, bookId)), + ctx.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, bookId)), ctx.db .select({ name: narrators.name }) .from(bookNarrators) @@ -328,6 +332,7 @@ export async function readBookMutationState(ctx: MetadataLockE2EContext, bookId: } : null, genres: genreRows.map((row) => row.name).sort(), + tags: tagRows.map((row) => row.name).sort(), narrators: narratorRows.map((row) => row.name), comicMetadata: comic ? { diff --git a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts index b26f4f2ac..6062a18f4 100644 --- a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts +++ b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts @@ -42,6 +42,8 @@ interface ComicFixtureInput { author?: string; publisher?: string; year?: number; + genre?: string; + tags?: string; } const execFile = promisify(execFileCallback); @@ -288,11 +290,15 @@ function buildComicInfoXml(input: ComicFixtureInput): string { ${title} ${author} - ${publisher} + ${publisher}${optionalXmlElement('Genre', input.genre)}${optionalXmlElement('Tags', input.tags)} ${year} `; } +function optionalXmlElement(name: string, value: string | undefined): string { + return value === undefined ? '' : `\n <${name}>${escapeXml(value)}`; +} + function escapeXml(value: string): string { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } diff --git a/server/test/metadata-lock.e2e-spec.ts b/server/test/metadata-lock.e2e-spec.ts index eab180257..6081d25ee 100644 --- a/server/test/metadata-lock.e2e-spec.ts +++ b/server/test/metadata-lock.e2e-spec.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; -import { readFile } from 'fs/promises'; +import { readFile, rename } from 'fs/promises'; +import { join } from 'path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { MetadataProviderKey } from '@bookorbit/types'; @@ -391,6 +392,53 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => { await expect(readFile(extractedCoverPath!)).resolves.toEqual(BLUE_PNG_BYTES); }, 60_000); + it('imports ComicInfo Genre and Tags as separate genres and tags on scan', async () => { + const library = await createLibraryWithFolder(ctx, { + mode: 'book_per_file', + allowedFormats: ['cbz'], + name: `metadata-lock-comic-tags-${randomUUID()}`, + }); + const folder = `comic-tags-${randomUUID()}`; + await createCbzFixture(library.folderPath, `${folder}/tagged.cbz`, { title: 'Tagged Comic', genre: 'Science Fiction', tags: 'Foo, Bar' }); + await createCbzFixture(library.folderPath, `${folder}/tags-only.cbz`, { title: 'Tags Only Comic', tags: 'Ninja, School Life' }); + await triggerAndWaitForLibraryScan(ctx, library.libraryId); + + const tagged = await locateBookFileByRelPath(ctx, library.libraryId, `${folder}/tagged.cbz`); + const tagsOnly = await locateBookFileByRelPath(ctx, library.libraryId, `${folder}/tags-only.cbz`); + + await expect(readBookMutationState(ctx, tagged.bookId)).resolves.toMatchObject({ genres: ['Science Fiction'], tags: ['Bar', 'Foo'] }); + await expect(readBookMutationState(ctx, tagsOnly.bookId)).resolves.toMatchObject({ genres: [], tags: ['Ninja', 'School Life'] }); + }); + + it('replaces comic genres and tags from a re-tagged file on rescan, except locked tags', async () => { + const library = await createLibraryWithFolder(ctx, { + mode: 'book_per_file', + allowedFormats: ['cbz'], + name: `metadata-lock-comic-retag-${randomUUID()}`, + }); + const folder = `comic-retag-${randomUUID()}`; + const lockedRelPath = `${folder}/locked.cbz`; + const unlockedRelPath = `${folder}/unlocked.cbz`; + await createCbzFixture(library.folderPath, lockedRelPath, { title: 'Locked Tags Comic', genre: 'Science Fiction', tags: 'Time Travel' }); + await createCbzFixture(library.folderPath, unlockedRelPath, { title: 'Unlocked Tags Comic', genre: 'Science Fiction', tags: 'Time Travel' }); + await triggerAndWaitForLibraryScan(ctx, library.libraryId); + + const locked = await locateBookFileByRelPath(ctx, library.libraryId, lockedRelPath); + const unlocked = await locateBookFileByRelPath(ctx, library.libraryId, unlockedRelPath); + expect((await patchLocks(locked.bookId, ['tags'])).statusCode).toBe(200); + + // Filesystem timestamp resolution is 1s on many OSes; wait so the re-tagged files get a newer mtime. + await pause(1_100); + await retagComic(library.folderPath, lockedRelPath, { title: 'Locked Tags Comic', tags: 'Found Family' }); + await retagComic(library.folderPath, unlockedRelPath, { title: 'Unlocked Tags Comic', tags: 'Found Family' }); + await triggerAndWaitForLibraryScan(ctx, library.libraryId); + + expect((await locateBookFileByRelPath(ctx, library.libraryId, lockedRelPath)).bookId).toBe(locked.bookId); + expect((await locateBookFileByRelPath(ctx, library.libraryId, unlockedRelPath)).bookId).toBe(unlocked.bookId); + await expect(readBookMutationState(ctx, locked.bookId)).resolves.toMatchObject({ genres: [], tags: ['Time Travel'] }); + await expect(readBookMutationState(ctx, unlocked.bookId)).resolves.toMatchObject({ genres: [], tags: ['Found Family'] }); + }, 60_000); + it('refreshes extracted covers only for unlocked books during scanner cover refresh', async () => { const library = await createLibraryWithFolder(ctx, { mode: 'book_per_file', @@ -498,6 +546,14 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => { }; } + // Taggers write a new archive and rename it over the old one. The rename moves the folder's mtime, + // which is what makes an incremental scan look inside the folder again. + async function retagComic(folderPath: string, relPath: string, input: Parameters[2]) { + const stagingRelPath = `${relPath}.retag`; + await createCbzFixture(folderPath, stagingRelPath, input); + await rename(join(folderPath, stagingRelPath), join(folderPath, relPath)); + } + function uniqueRelPath(prefix: string, fileName: string): string { return `${prefix}-${randomUUID()}/${fileName}`; }