mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user