Files
bookorbit/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts
T
neon 4d453c82ca fix(books): measure audiobook durations when Book Dock files them
Book Dock finalize, which also files book requests, left each audio file's
duration empty until the next library scan. The player derives progress only
from those, so a filed audiobook stayed at 0% and never reached Continue
Listening. Finalize now measures them once metadata is applied.

Refs #1757
2026-10-12 03:44:40 +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; durationSeconds?: number }): 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=${input.durationSeconds ?? 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;');
}