Files
bookorbit/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts
T
neon 4770032ce7 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
2026-10-11 05:58:02 +00:00

305 lines
11 KiB
TypeScript

import { ZipArchive } from 'archiver';
import { execFile as execFileCallback } from 'child_process';
import { randomUUID } from 'crypto';
import { createWriteStream } from 'fs';
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import { createRequire } from 'module';
import { tmpdir } from 'os';
import { dirname, isAbsolute, join, normalize } from 'path';
import { PDFDocument } from 'pdf-lib';
import { promisify } from 'util';
import { getSevenZip } from '../../../src/common/sevenzip';
export interface MetadataWriteFixtureRoot {
rootPath: string;
booksPath: string;
cleanup: () => Promise<void>;
}
export interface ZipEntryInput {
path: string;
content: string | Buffer;
store?: boolean;
}
interface EpubFixtureInput {
title?: string;
language?: string;
uid?: string;
/** Adds a stored entry of this many bytes, for a file genuinely over a size limit on disk. */
paddingBytes?: number;
}
interface Fb2FixtureInput {
title?: string;
language?: string;
bodyText?: string;
}
interface ComicFixtureInput {
title?: string;
author?: string;
publisher?: string;
year?: number;
genre?: string;
tags?: string;
}
const execFile = promisify(execFileCallback);
const requireInstaller = createRequire(__filename);
/**
* The FFmpeg and FFprobe builds the test suite ships with, so audio scenarios do not depend on
* whatever the machine running them has installed.
*/
export function bundledFfmpegPaths(): { ffmpeg: string; ffprobe: string } {
const ffmpeg = requireInstaller('@ffmpeg-installer/ffmpeg') as { path: string };
const ffprobe = requireInstaller('@ffprobe-installer/ffprobe') as { path: string };
return { ffmpeg: ffmpeg.path, ffprobe: ffprobe.path };
}
export async function createM4bFixture(rootPath: string, relativePath: string, input: { title: string }): Promise<string> {
assertRelativePath(relativePath);
const absolutePath = join(rootPath, relativePath);
await mkdir(dirname(absolutePath), { recursive: true });
await execFile(bundledFfmpegPaths().ffmpeg, [
'-hide_banner',
'-loglevel',
'error',
'-f',
'lavfi',
'-i',
'sine=frequency=440:sample_rate=8000:duration=1',
'-c:a',
'aac',
'-b:a',
'16k',
'-metadata',
`title=${input.title}`,
'-metadata',
`album=${input.title}`,
'-f',
'ipod',
absolutePath,
]);
return absolutePath;
}
export async function readAudioTags(absolutePath: string): Promise<Record<string, string>> {
const { stdout } = await execFile(bundledFfmpegPaths().ffprobe, ['-v', 'error', '-show_entries', 'format_tags', '-of', 'json', absolutePath]);
const tags = (JSON.parse(String(stdout)) as { format?: { tags?: Record<string, string> } }).format?.tags ?? {};
return Object.fromEntries(Object.entries(tags).map(([key, value]) => [key.toLowerCase(), value]));
}
function assertRelativePath(relativePath: string): void {
const normalized = normalize(relativePath);
if (isAbsolute(relativePath) || normalized === '..' || normalized.startsWith('../') || normalized.startsWith('..\\')) {
throw new Error(`Fixture paths must be relative. Received "${relativePath}"`);
}
}
export async function createMetadataWriteFixtureRoot(prefix = 'metadata-write-e2e-'): Promise<MetadataWriteFixtureRoot> {
const rootPath = await mkdtemp(join(tmpdir(), prefix));
const booksPath = join(rootPath, 'books');
await mkdir(booksPath, { recursive: true });
return {
rootPath,
booksPath,
cleanup: async () => {
await rm(rootPath, { recursive: true, force: true });
},
};
}
export async function writeFixtureFile(rootPath: string, relativePath: string, content: string | Buffer): Promise<string> {
assertRelativePath(relativePath);
const absolutePath = join(rootPath, relativePath);
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
return absolutePath;
}
export async function createPdfFixture(rootPath: string, relativePath: string, title = 'Fixture PDF Title'): Promise<string> {
const pdf = await PDFDocument.create();
const page = pdf.addPage([360, 240]);
page.drawText('metadata write fixture');
pdf.setTitle(title);
const bytes = await pdf.save();
return writeFixtureFile(rootPath, relativePath, Buffer.from(bytes));
}
export async function createEpubFixture(rootPath: string, relativePath: string, input: EpubFixtureInput = {}): Promise<string> {
assertRelativePath(relativePath);
const absolutePath = join(rootPath, relativePath);
await mkdir(dirname(absolutePath), { recursive: true });
const uid = input.uid ?? `urn:uuid:${randomUUID()}`;
const title = input.title ?? 'Fixture EPUB Title';
const language = input.language ?? 'en';
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml" />
</rootfiles>
</container>`;
const opfXml = `<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${escapeXml(uid)}</dc:identifier>
<dc:title>${escapeXml(title)}</dc:title>
<dc:language>${escapeXml(language)}</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="chapter" />
</spine>
</package>`;
const chapterXml = `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeXml(title)}</title></head><body><p>fixture</p></body></html>`;
await writeZipArchive(absolutePath, [
{ path: 'mimetype', content: 'application/epub+zip', store: true },
{ path: 'META-INF/container.xml', content: containerXml },
{ path: 'OPS/content.opf', content: opfXml },
{ path: 'OPS/chapter.xhtml', content: chapterXml },
...(input.paddingBytes ? [{ path: 'OPS/padding.bin', content: Buffer.alloc(input.paddingBytes), store: true }] : []),
]);
return absolutePath;
}
export async function createFb2Fixture(rootPath: string, relativePath: string, input: Fb2FixtureInput = {}): Promise<string> {
const title = input.title ?? 'Fixture FB2 Title';
const language = input.language ?? 'en';
const bodyText = input.bodyText ?? 'fixture body text';
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">',
'<description>',
' <title-info>',
' <genre>antique</genre>',
' <author><first-name>Fixture</first-name><last-name>Author</last-name></author>',
` <book-title>${escapeXml(title)}</book-title>`,
` <lang>${escapeXml(language)}</lang>`,
' </title-info>',
' <document-info><id>fixture-doc</id></document-info>',
'</description>',
`<body><section><p>${escapeXml(bodyText)}</p></section></body>`,
'</FictionBook>',
'',
].join('\n');
return writeFixtureFile(rootPath, relativePath, Buffer.from(xml, 'utf8'));
}
export async function createCbzFixture(rootPath: string, relativePath: string, input: ComicFixtureInput = {}): Promise<string> {
assertRelativePath(relativePath);
const absolutePath = join(rootPath, relativePath);
await mkdir(dirname(absolutePath), { recursive: true });
const xml = buildComicInfoXml(input);
await writeZipArchive(absolutePath, [
{ path: 'ComicInfo.xml', content: xml },
{ path: 'page-001.txt', content: 'fixture page' },
]);
return absolutePath;
}
export async function createCb7Fixture(rootPath: string, relativePath: string, input: ComicFixtureInput = {}): Promise<string> {
const xml = buildComicInfoXml(input);
const archiveBytes = await buildCb7Archive(xml);
return writeFixtureFile(rootPath, relativePath, archiveBytes);
}
export async function createZipArchiveFixture(rootPath: string, relativePath: string, entries: ZipEntryInput[]): Promise<string> {
assertRelativePath(relativePath);
const absolutePath = join(rootPath, relativePath);
await mkdir(dirname(absolutePath), { recursive: true });
await writeZipArchive(absolutePath, entries);
return absolutePath;
}
async function writeZipArchive(absolutePath: string, entries: ZipEntryInput[]): Promise<void> {
const output = createWriteStream(absolutePath);
const archive = new ZipArchive({ zlib: { level: 6 } });
await new Promise<void>((resolve, reject) => {
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
archive.pipe(output);
for (const entry of entries) {
archive.append(typeof entry.content === 'string' ? Buffer.from(entry.content, 'utf8') : entry.content, {
name: entry.path,
store: entry.store ?? false,
});
}
void archive.finalize();
});
}
async function buildCb7Archive(comicInfoXml: string): Promise<Buffer> {
const sevenZip = await getSevenZip();
const archivePath = `/metadata-write-${randomUUID().replaceAll('-', '')}.cb7`;
const xmlPath = '/ComicInfo.xml';
const xmlBytes = Buffer.from(comicInfoXml, 'utf8');
try {
safeVfsUnlink(sevenZip.FS, xmlPath);
safeVfsUnlink(sevenZip.FS, archivePath);
const fd = sevenZip.FS.open(xmlPath, 'w+');
sevenZip.FS.write(fd, xmlBytes, 0, xmlBytes.length);
sevenZip.FS.close(fd);
sevenZip.callMain(['a', archivePath, xmlPath]);
return Buffer.from(sevenZip.FS.readFile(archivePath));
} finally {
safeVfsUnlink(sevenZip.FS, xmlPath);
safeVfsUnlink(sevenZip.FS, archivePath);
}
}
function safeVfsUnlink(vfs: { unlink: (path: string) => void }, path: string): void {
try {
vfs.unlink(path);
} catch {
// no-op: path may not exist
}
}
function buildComicInfoXml(input: ComicFixtureInput): string {
const title = escapeXml(input.title ?? 'Fixture Comic Title');
const author = escapeXml(input.author ?? 'Fixture Author');
const publisher = escapeXml(input.publisher ?? 'Fixture Publisher');
const year = input.year ?? 2024;
return `<?xml version="1.0" encoding="UTF-8"?>
<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>${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;');
}