openreader/src/lib/server/audiobook.ts

224 lines
6 KiB
TypeScript

import { spawn } from 'child_process';
import path from 'path';
import { readdir } from 'fs/promises';
export type StoredChapter = {
index: number;
title: string;
durationSec?: number;
format: 'mp3' | 'm4b';
filePath: string;
};
function sanitizeTagValue(value: string): string {
return value.replaceAll('\u0000', '').replaceAll(/\r?\n/g, ' ').trim();
}
function sanitizeFileStem(value: string): string {
return sanitizeTagValue(value)
.replaceAll(/[\\/]/g, ' ')
.replaceAll(/[<>:"|?*\u0000]/g, '')
.replaceAll(/\s+/g, ' ')
.trim()
.slice(0, 180);
}
export function escapeFFMetadata(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/=/g, '\\=')
.replace(/;/g, '\\;')
.replace(/#/g, '\\#')
.replace(/\r|\n/g, ' ');
}
export function encodeChapterTitleTag(index: number, title: string): string {
const safeTitle = sanitizeTagValue(title) || `Chapter ${index + 1}`;
const prefix = String(index + 1).padStart(4, '0');
return `${prefix} - ${safeTitle}`;
}
export function decodeChapterTitleTag(tag: string): { index: number; title: string } | null {
const raw = sanitizeTagValue(tag);
if (!raw) return null;
const match = /^(\d{1,6})\s*[-.:]\s*(.+)$/.exec(raw);
if (!match) return null;
const oneBased = Number(match[1]);
if (!Number.isFinite(oneBased) || !Number.isInteger(oneBased) || oneBased <= 0) return null;
return { index: oneBased - 1, title: match[2].trim() || `Chapter ${oneBased}` };
}
export function encodeChapterFileName(index: number, title: string, format: 'mp3' | 'm4b'): string {
const oneBased = String(index + 1).padStart(4, '0');
const safeTitle = sanitizeFileStem(title) || `Chapter ${index + 1}`;
return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`;
}
export function decodeChapterFileName(fileName: string): { index: number; title: string; format: 'mp3' | 'm4b' } | null {
const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName);
if (!match) return null;
const oneBased = Number(match[1]);
if (!Number.isInteger(oneBased) || oneBased <= 0) return null;
const format = match[3].toLowerCase() as 'mp3' | 'm4b';
try {
const title = decodeURIComponent(match[2]);
return { index: oneBased - 1, title: title || `Chapter ${oneBased}`, format };
} catch {
return { index: oneBased - 1, title: match[2], format };
}
}
type ProbeResult = {
durationSec?: number;
titleTag?: string;
};
export async function ffprobeAudio(filePath: string, signal?: AbortSignal): Promise<ProbeResult> {
return new Promise<ProbeResult>((resolve, reject) => {
const ffprobe = spawn('ffprobe', [
'-v',
'quiet',
'-print_format',
'json',
'-show_entries',
'format=duration:format_tags=title',
filePath,
]);
let output = '';
let finished = false;
const onAbort = () => {
if (finished) return;
finished = true;
try {
ffprobe.kill('SIGKILL');
} catch {}
reject(new Error('ABORTED'));
};
const cleanup = () => {
if (finished) return;
finished = true;
signal?.removeEventListener('abort', onAbort);
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.on('close', (code) => {
if (finished) return;
cleanup();
if (code !== 0) {
reject(new Error(`ffprobe process exited with code ${code}`));
return;
}
try {
const parsed = JSON.parse(output) as {
format?: { duration?: string; tags?: { title?: string } };
};
const durationStr = parsed.format?.duration;
const durationSec = durationStr ? Number(durationStr) : undefined;
resolve({
durationSec: Number.isFinite(durationSec) ? durationSec : undefined,
titleTag: parsed.format?.tags?.title,
});
} catch (error) {
reject(error);
}
});
ffprobe.on('error', (err) => {
if (finished) return;
cleanup();
reject(err);
});
});
}
export async function listStoredChapters(dir: string, signal?: AbortSignal): Promise<StoredChapter[]> {
let files: string[] = [];
try {
files = await readdir(dir);
} catch {
return [];
}
const candidates = files.filter((file) => !file.startsWith('.')).filter((file) => !file.startsWith('complete.'));
const results: StoredChapter[] = [];
for (const file of candidates) {
const decodedFromName = decodeChapterFileName(file);
if (!decodedFromName) continue;
const filePath = path.join(dir, file);
let durationSec: number | undefined;
try {
const probe = await ffprobeAudio(filePath, signal);
durationSec = probe.durationSec;
} catch {}
results.push({
index: decodedFromName.index,
title: decodedFromName.title,
durationSec,
format: decodedFromName.format,
filePath,
});
}
results.sort((a, b) => a.index - b.index);
return results;
}
export async function findStoredChapterByIndex(
dir: string,
index: number,
signal?: AbortSignal,
): Promise<StoredChapter | null> {
let files: string[] = [];
try {
files = await readdir(dir);
} catch {
return null;
}
const oneBasedPrefix = String(index + 1).padStart(4, '0') + '__';
const candidate = files.find((file) => file.startsWith(oneBasedPrefix) && (file.endsWith('.mp3') || file.endsWith('.m4b')));
if (!candidate) {
const chapters = await listStoredChapters(dir, signal);
return chapters.find((chapter) => chapter.index === index) ?? null;
}
const decoded = decodeChapterFileName(candidate);
if (!decoded) return null;
const filePath = path.join(dir, candidate);
let durationSec: number | undefined;
try {
const probe = await ffprobeAudio(filePath, signal);
durationSec = probe.durationSec;
} catch {}
return {
index: decoded.index,
title: decoded.title,
durationSec,
format: decoded.format,
filePath,
};
}