fix(metadata): import ComicInfo Tags as book tags

Map <Genre> to genres and <Tags> 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
This commit is contained in:
neon
2026-10-11 05:56:20 +00:00
parent b83a181128
commit 4770032ce7
7 changed files with 113 additions and 5 deletions
@@ -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<BookMutationState> {
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
? {
@@ -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 {
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Title>${title}</Title>
<Writer>${author}</Writer>
<Publisher>${publisher}</Publisher>
<Publisher>${publisher}</Publisher>${optionalXmlElement('Genre', input.genre)}${optionalXmlElement('Tags', input.tags)}
<Year>${year}</Year>
</ComicInfo>`;
}
function optionalXmlElement(name: string, value: string | undefined): string {
return value === undefined ? '' : `\n <${name}>${escapeXml(value)}</${name}>`;
}
function escapeXml(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
+57 -1
View File
@@ -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<typeof createCbzFixture>[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}`;
}