mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
4d453c82ca
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
305 lines
11 KiB
TypeScript
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
}
|