feat(settings): add read-aloud filename token

Expose media-overlay-aware naming across file creation and delivery paths while preserving existing client contracts. Keep settings router mocks compatible with the application router exports.

Closes #1590
This commit is contained in:
neon
2026-09-29 01:29:25 +00:00
parent acce1e15ac
commit d66f853cad
28 changed files with 543 additions and 41 deletions
@@ -147,6 +147,18 @@ describe('FileNamingSettings', () => {
}
})
it('offers the readaloud token and previews read-aloud and plain EPUB outcomes', async () => {
const wrapper = await mountPage()
expect(buttonWith(wrapper, '{readaloud}')).toBeDefined()
await patternField(wrapper).setValue('{title}< ({readaloud})>')
expect(wrapper.text()).toContain('Read-aloud comparison')
expect(wrapper.text()).toContain('Read-aloud EPUB')
expect(wrapper.text()).toContain('Plain EPUB')
expect(wrapper.text()).toContain('Neuromancer (readaloud)')
})
it('saves the cross-platform toggle immediately without a separate save button', async () => {
const wrapper = await mountPage()
@@ -14,10 +14,14 @@ const routeState = { name: 'settings-appearance-theme' }
const push = vi.fn<(to: { name: string }) => void>()
vi.mock('vue-router', () => ({
useRoute: () => ({ name: routeState.name }),
useRouter: () => ({ push }),
}))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRoute: () => ({ name: routeState.name }),
useRouter: () => ({ push }),
}
})
const navStatus = vi.hoisted(() => ({ scanning: false }))
@@ -10,10 +10,14 @@ const permState = {
demoRestricted: false,
}
vi.mock('vue-router', () => ({
useRoute: () => ({ name: 'settings-appearance-theme' }),
useRouter: () => ({ push: vi.fn<(to: { name: string }) => void>() }),
}))
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRoute: () => ({ name: 'settings-appearance-theme' }),
useRouter: () => ({ push: vi.fn<(to: { name: string }) => void>() }),
}
})
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
@@ -36,6 +36,8 @@ function resolve(metadata: Record<string, string>): string {
}
const resolved = computed(() => resolve(EXAMPLE_PATTERN_METADATA))
const usesReadaloudToken = computed(() => props.pattern.includes('{readaloud'))
const plainEpubResult = computed(() => resolve({ ...EXAMPLE_PATTERN_METADATA, readaloud: '' }))
const fallbackCases = computed(() =>
MISSING_METADATA_CASES.map((previewCase) => ({
@@ -113,6 +115,22 @@ function handleSanitize(value: boolean) {
</dl>
</div>
<div v-if="usesReadaloudToken" class="border-t border-border px-3.5 py-3">
<h4 class="mb-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('settings.reader.fileNaming.readaloudComparison') }}
</h4>
<dl class="divide-y divide-dashed divide-border">
<div class="py-1.5">
<dt class="text-[11px] font-semibold text-muted-foreground">{{ t('settings.reader.fileNaming.readaloudEpub') }}</dt>
<dd class="mt-0.5 break-words font-mono text-[11px] text-foreground">{{ resolved }}</dd>
</div>
<div class="py-1.5">
<dt class="text-[11px] font-semibold text-muted-foreground">{{ t('settings.reader.fileNaming.plainEpub') }}</dt>
<dd class="mt-0.5 break-words font-mono text-[11px] text-foreground">{{ plainEpubResult }}</dd>
</div>
</dl>
</div>
<div class="flex items-center gap-3 border-t border-border bg-muted/30 px-3.5 py-3">
<div class="min-w-0 flex-1">
<p class="settings-label">{{ t('settings.reader.fileNaming.crossPlatform') }}</p>
@@ -24,6 +24,7 @@ const TOKEN_DESCRIPTION_KEYS: Record<PatternToken, string> = {
library: 'settings.reader.fileNaming.tokenLibrary',
originalFilename: 'settings.reader.fileNaming.tokenOriginalFilename',
extension: 'settings.reader.fileNaming.tokenExtension',
readaloud: 'settings.reader.fileNaming.tokenReadaloud',
}
const MODIFIER_DESCRIPTION_KEYS: Record<PatternModifier, string> = {
+4
View File
@@ -2324,6 +2324,7 @@
"tokenLibrary": "Library name",
"tokenOriginalFilename": "Original filename (without extension)",
"tokenExtension": "File extension (without dot)",
"tokenReadaloud": "The word readaloud for an EPUB with media overlays",
"patternHelpIntro": "Patterns use tokens like {titleToken} and {authorsToken}, plus modifiers and optional segments.",
"browseTokensAndExamples": "Browse tokens and examples",
"subtitleShort": "Folder and filename patterns for stored files.",
@@ -2386,6 +2387,9 @@
"resultCopyFailed": "Failed to copy the resolved result",
"sampleBookNote": "Sample book used for every preview",
"missingMetadata": "If metadata is missing",
"readaloudComparison": "Read-aloud comparison",
"readaloudEpub": "Read-aloud EPUB",
"plainEpub": "Plain EPUB",
"caseNoSeriesLabel": "No series",
"caseNoYearLabel": "No year",
"caseNoAuthorLabel": "No author",
+2
View File
@@ -19,6 +19,7 @@ export const EXAMPLE_PATTERN_METADATA: Record<string, string> = {
library: "Books",
originalFilename: "neuromancer",
extension: "epub",
readaloud: "readaloud",
};
export const PATTERN_TOKENS = [
@@ -35,6 +36,7 @@ export const PATTERN_TOKENS = [
{ token: "library", description: "Library name" },
{ token: "originalFilename", description: "Original filename (without extension)" },
{ token: "extension", description: "File extension (without dot)" },
{ token: "readaloud", description: "The word readaloud for an EPUB with media overlays" },
] as const;
export type PatternToken = (typeof PATTERN_TOKENS)[number]["token"];
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildPatternTokens } from './pattern-tokens.utils';
import { buildPatternTokens, patternReferencesToken } from './pattern-tokens.utils';
describe('pattern-tokens.utils', () => {
describe('buildPatternTokens', () => {
@@ -108,5 +108,25 @@ describe('pattern-tokens.utils', () => {
expect(legacy['seriesIndex']).toBe('03');
expect(malformed['seriesIndex']).toBeUndefined();
});
it('emits readaloud only for an EPUB with media overlays', () => {
expect(buildPatternTokens({ metadata, originalStem: 'book', format: 'epub', mediaOverlayAvailable: true })['readaloud']).toBe('readaloud');
expect(buildPatternTokens({ metadata, originalStem: 'book', format: 'EPUB', mediaOverlayAvailable: true })['readaloud']).toBe('readaloud');
expect(buildPatternTokens({ metadata, originalStem: 'book', format: 'epub', mediaOverlayAvailable: false })['readaloud']).toBeUndefined();
expect(buildPatternTokens({ metadata, originalStem: 'book', format: 'm4b', mediaOverlayAvailable: true })['readaloud']).toBeUndefined();
});
});
describe('patternReferencesToken', () => {
it('matches a token with or without a modifier', () => {
expect(patternReferencesToken('{title}< ({readaloud})>', 'readaloud')).toBe(true);
expect(patternReferencesToken('{readaloud:upper}', 'readaloud')).toBe(true);
});
it('does not match a prefix, literal text, or another token', () => {
expect(patternReferencesToken('{readaloudEdition}', 'readaloud')).toBe(false);
expect(patternReferencesToken('readaloud {title}', 'readaloud')).toBe(false);
expect(patternReferencesToken('{title}', 'readaloud')).toBe(false);
});
});
});
@@ -24,6 +24,14 @@ export interface PatternTokenInput {
originalStem: string;
format: string;
libraryName?: string | null;
mediaOverlayAvailable?: boolean | null;
}
export function patternReferencesToken(pattern: string, token: string): boolean {
for (const match of pattern.matchAll(/\{([^}:]+)(?::[^}]+)?}/g)) {
if (match[1] === token) return true;
}
return false;
}
/**
@@ -36,7 +44,7 @@ export interface PatternTokenInput {
* treats the two identically, and omitting them keeps the map readable in logs.
*/
export function buildPatternTokens(input: PatternTokenInput): Record<string, string> {
const { metadata, authors = [], narrators = [], originalStem, format, libraryName } = input;
const { metadata, authors = [], narrators = [], originalStem, format, libraryName, mediaOverlayAvailable } = input;
const tokens: Record<string, string> = { originalFilename: originalStem, extension: format };
if (libraryName) tokens['library'] = libraryName;
@@ -52,6 +60,7 @@ export function buildPatternTokens(input: PatternTokenInput): Record<string, str
if (seriesIndex) tokens['seriesIndex'] = seriesIndex;
if (authors.length > 0) tokens['authors'] = authors.join(', ');
if (narrators.length > 0) tokens['narrators'] = narrators.join(', ');
if (format.toLowerCase() === 'epub' && mediaOverlayAvailable === true) tokens['readaloud'] = 'readaloud';
return tokens;
}
@@ -0,0 +1,161 @@
import { mkdtemp, mkdir, readdir, rename, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { basename, dirname, join } from 'path';
import { ZipArchive } from 'archiver';
import { afterEach, describe, expect, it } from 'vitest';
import { resolveBookFileTargets } from '../../modules/file-write/book-file-targets';
import { inspectEpubMediaOverlayFields } from '../../modules/reader/epub/epub-media-overlay-capability';
const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="EPUB/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`;
const CHAPTER_XHTML = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><body><p id="line">Dune</p></body></html>`;
const PLAIN_OPF = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="chapter"/></spine>
</package>`;
const READALOUD_OPF = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" media-overlay="overlay"/>
<item id="overlay" href="chapter.smil" media-type="application/smil+xml"/>
<item id="audio" href="audio.mp3" media-type="audio/mpeg"/>
</manifest>
<spine><itemref idref="chapter"/></spine>
</package>`;
const CHAPTER_SMIL = `<?xml version="1.0" encoding="UTF-8"?>
<smil xmlns="http://www.w3.org/ns/SMIL" version="3.0">
<body><seq><par><text src="chapter.xhtml#line"/><audio src="audio.mp3" clipBegin="0s" clipEnd="3s"/></par></seq></body>
</smil>`;
type ZipEntry = { name: string; content: string | Buffer; store?: boolean };
async function writeEpub(path: string, entries: ZipEntry[]): Promise<void> {
const archive = new ZipArchive({ zlib: { level: 0 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
const complete = new Promise<void>((resolve, reject) => {
archive.on('end', resolve);
archive.on('error', reject);
});
archive.append('application/epub+zip', { name: 'mimetype', store: true });
archive.append(CONTAINER_XML, { name: 'META-INF/container.xml' });
for (const entry of entries) archive.append(entry.content, { name: entry.name, store: entry.store });
await archive.finalize();
await complete;
await writeFile(path, Buffer.concat(chunks));
}
describe('readaloud naming integration', () => {
let fixtureRoot: string | null = null;
afterEach(async () => {
if (fixtureRoot) await rm(fixtureRoot, { recursive: true, force: true });
fixtureRoot = null;
});
it('detects real EPUB media overlays and moves mixed formats to distinct resolved names', async () => {
fixtureRoot = await mkdtemp(join(tmpdir(), 'bookorbit-readaloud-pattern-'));
const incoming = join(fixtureRoot, 'incoming');
const library = join(fixtureRoot, 'library');
await Promise.all([mkdir(incoming), mkdir(library)]);
const plainPath = join(incoming, 'plain.epub');
const readaloudPath = join(incoming, 'combined.epub');
const audiobookPath = join(incoming, 'audio.m4b');
await Promise.all([
writeEpub(plainPath, [
{ name: 'EPUB/content.opf', content: PLAIN_OPF },
{ name: 'EPUB/chapter.xhtml', content: CHAPTER_XHTML },
]),
writeEpub(readaloudPath, [
{ name: 'EPUB/content.opf', content: READALOUD_OPF },
{ name: 'EPUB/chapter.xhtml', content: CHAPTER_XHTML },
{ name: 'EPUB/chapter.smil', content: CHAPTER_SMIL },
{ name: 'EPUB/audio.mp3', content: Buffer.alloc(32, 1) },
]),
writeFile(audiobookPath, Buffer.from('m4b fixture')),
]);
const [plainCapability, readaloudCapability, audiobookCapability] = await Promise.all([
inspectEpubMediaOverlayFields(plainPath, 'epub'),
inspectEpubMediaOverlayFields(readaloudPath, 'EPUB'),
inspectEpubMediaOverlayFields(audiobookPath, 'm4b'),
]);
expect(plainCapability.mediaOverlayAvailable).toBe(false);
expect(plainCapability.mediaOverlayCheckedAt).toBeInstanceOf(Date);
expect(readaloudCapability.mediaOverlayAvailable).toBe(true);
expect(readaloudCapability.mediaOverlayDurationSeconds).toBe(3);
expect(audiobookCapability.mediaOverlayAvailable).toBe(false);
expect(audiobookCapability.mediaOverlayCheckedAt).toBeNull();
const files = [
{
id: 1,
absolutePath: plainPath,
format: 'epub',
role: 'content',
sortOrder: null,
mediaOverlayAvailable: plainCapability.mediaOverlayAvailable,
},
{
id: 2,
absolutePath: readaloudPath,
format: 'EPUB',
role: 'content',
sortOrder: null,
mediaOverlayAvailable: readaloudCapability.mediaOverlayAvailable,
},
{
id: 3,
absolutePath: audiobookPath,
format: 'm4b',
role: 'content',
sortOrder: null,
mediaOverlayAvailable: true,
},
];
const targets = resolveBookFileTargets({
primaryFileId: 1,
files,
metadata: {
title: 'Dune',
subtitle: null,
publisher: null,
language: null,
isbn13: null,
publishedYear: null,
seriesName: null,
seriesIndex: null,
},
authors: ['Frank Herbert'],
narrators: [],
libraryName: 'Books',
libraryFolderPath: library,
bookFolderPath: incoming,
organizationMode: 'book_per_folder',
pattern: '{title}/{title}< ({readaloud})>',
sanitizeForCrossPlatform: false,
});
for (const file of files) {
const target = targets.get(file.id);
expect(target).toBeDefined();
await mkdir(dirname(target!), { recursive: true });
await rename(file.absolutePath, target!);
}
const filenames = (await readdir(join(library, 'Dune'))).sort();
expect(filenames).toEqual(['Dune (readaloud).epub', 'Dune.epub', 'Dune.m4b']);
expect([...targets.values()].map((target) => basename(target)).sort()).toEqual(filenames);
});
});
@@ -1197,6 +1197,26 @@ describe('BookDockFinalizeService', () => {
expect(result[0].newName).toBe('Dune.epub');
});
it('previewNames marks a read-aloud EPUB and uses the same destination for finalization', async () => {
const { service, repo, db } = makeService();
const row = makeRow({ id: 1, targetLibraryId: 10, selectedMetadata: { title: 'Dune' } as BookDockMetadata });
const library = { id: 10, name: 'Books', fileNamingPattern: '{title}< ({readaloud})>', organizationMode: 'book_per_file' };
repo.findByIds.mockResolvedValue([row]);
db.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([library]),
}),
});
const inspect = vi.spyOn(service as any, 'inspectMediaOverlayAvailable').mockResolvedValue(true);
const [preview] = await service.previewNames([1], false, [], undefined, 1, true);
const destination = await (service as any).resolveDestination(library, '/library', row, 'epub');
expect(preview.newName).toBe('Dune (readaloud).epub');
expect(destination).toBe('/library/Dune (readaloud).epub');
expect(inspect).toHaveBeenCalledTimes(2);
});
it('previewNames sanitizes generated names when cross-platform mode is enabled', async () => {
const { service, repo, appSettings, db } = makeService();
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true);
@@ -48,7 +48,7 @@ import { SeriesMembershipService } from '../../common/services/series-membership
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils';
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
import { buildPatternTokens, patternReferencesToken } from '../../common/utils/pattern-tokens.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookMetadata, libraries, libraryFolders } from '../../db/schema';
@@ -60,6 +60,7 @@ import { MetadataScoreService } from '../metadata-score/metadata-score.service';
import { UploadProcessorService, type UnitBookFileInput, type UnitBookRecords } from '../upload/upload-processor.service';
import { UploadStorageService } from '../upload/upload-storage.service';
import { UploadValidatorService } from '../upload/upload-validator.service';
import { inspectEpubMediaOverlayFields } from '../reader/epub/epub-media-overlay-capability';
import { BookDockRepository } from './book-dock.repository';
import { BookDockEventsService, BOOK_DOCK_FILE_INGESTED } from './book-dock-events.service';
import { BookDockGateway } from './book-dock.gateway';
@@ -1056,15 +1057,17 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
? new Map((await this.db.select().from(libraries).where(inArray(libraries.id, libraryIds))).map((lib) => [lib.id, lib]))
: new Map<number, typeof libraries.$inferSelect>();
return rows.map((row) => {
const format = row.format ?? extname(row.fileName).toLowerCase().slice(1);
const effectiveLibraryId = row.targetLibraryId ?? defaultLibraryId ?? null;
const lib = effectiveLibraryId !== null ? libraryMap.get(effectiveLibraryId) : undefined;
const pattern = lib?.fileNamingPattern ?? (lib?.organizationMode === 'book_per_folder' ? appPatternFolder : appPatternFile);
const newName = this.resolveRelativeDestination(lib, row, format, pattern, sanitizeForCrossPlatform);
return Promise.all(
rows.map(async (row) => {
const format = row.format ?? extname(row.fileName).toLowerCase().slice(1);
const effectiveLibraryId = row.targetLibraryId ?? defaultLibraryId ?? null;
const lib = effectiveLibraryId !== null ? libraryMap.get(effectiveLibraryId) : undefined;
const pattern = lib?.fileNamingPattern ?? (lib?.organizationMode === 'book_per_folder' ? appPatternFolder : appPatternFile);
const newName = await this.resolveRelativeDestination(lib, row, format, pattern, sanitizeForCrossPlatform);
return { fileId: row.id, fileName: row.fileName, newName };
});
return { fileId: row.id, fileName: row.fileName, newName };
}),
);
}
private async resolveDestination(library: NamingLibrary, folderPath: string, row: BookDockFileRow, format: string): Promise<string> {
@@ -1075,7 +1078,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
: await this.appSettings.getUploadPattern());
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
return join(folderPath, this.resolveRelativeDestination(library, row, format, pattern, sanitizeForCrossPlatform));
return join(folderPath, await this.resolveRelativeDestination(library, row, format, pattern, sanitizeForCrossPlatform));
}
/**
@@ -1086,16 +1089,19 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
* services; `book_per_file` differs only in that the file itself is the book, so without a
* pattern it stays where it is instead of gaining a folder of its own.
*/
private resolveRelativeDestination(
private async resolveRelativeDestination(
library: NamingLibrary | undefined,
row: BookDockFileRow,
format: string,
pattern: string | null,
sanitizeForCrossPlatform: boolean,
): string {
): Promise<string> {
if (pattern) {
const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {};
const tokens = this.buildFilePatternTokens(meta, row.fileName, format, library?.name);
const mediaOverlayAvailable = patternReferencesToken(pattern, 'readaloud')
? await this.inspectMediaOverlayAvailable(row.absolutePath, format)
: false;
const tokens = this.buildFilePatternTokens(meta, row.fileName, format, library?.name, mediaOverlayAvailable);
const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolved) return resolved;
}
@@ -1107,7 +1113,13 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
return row.fileName;
}
private buildFilePatternTokens(meta: BookDockMetadata, fileName: string, format: string, libraryName?: string | null): Record<string, string> {
private buildFilePatternTokens(
meta: BookDockMetadata,
fileName: string,
format: string,
libraryName?: string | null,
mediaOverlayAvailable = false,
): Record<string, string> {
return buildPatternTokens({
metadata: meta,
authors: meta.authors,
@@ -1115,9 +1127,21 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
originalStem: basename(fileName, extname(fileName)),
format,
libraryName,
mediaOverlayAvailable,
});
}
private async inspectMediaOverlayAvailable(absolutePath: string, format: string): Promise<boolean> {
const startedAt = Date.now();
const fields = await inspectEpubMediaOverlayFields(absolutePath, format, (err) => {
const error = err instanceof Error ? err : new Error(String(err));
this.logger.warn(
`[book_dock.media_overlay_capability] [fail] path="${sanitizeLogValue(absolutePath)}" durationMs=${Date.now() - startedAt} errorClass=${error.constructor.name} error="${sanitizeLogValue(error.message)}" - EPUB media-overlay inspection failed`,
);
});
return fields.mediaOverlayAvailable;
}
/**
* The staged cover was read from the unit's primary file, so it fills only that file's medium on
* the book that owns it. The other books of a loose-file unit get their own art from reconcile.
@@ -220,6 +220,7 @@ export class BookMovePlannerService {
originalStem,
format,
libraryName: input.target.libraryName,
mediaOverlayAvailable: file.mediaOverlayAvailable,
});
return resolveUploadPath(input.pattern!, tokens, format, { sanitizeForCrossPlatform: input.sanitizeForCrossPlatform });
}
@@ -32,6 +32,7 @@ export interface MoveBookFile {
format: string | null;
fileHash: string | null;
sortOrder: number | null;
mediaOverlayAvailable: boolean;
}
export interface MoveBookData {
@@ -161,6 +162,7 @@ export class BookMoveRepository {
format: bookFiles.format,
fileHash: bookFiles.fileHash,
sortOrder: bookFiles.sortOrder,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(bookFiles)
.where(inArray(bookFiles.bookId, batchIds))
@@ -192,6 +194,7 @@ export class BookMoveRepository {
format: file.format,
fileHash: file.fileHash,
sortOrder: file.sortOrder,
mediaOverlayAvailable: file.mediaOverlayAvailable,
};
if (list) list.push(entry);
else filesByBook.set(file.bookId, [entry]);
+7 -2
View File
@@ -439,9 +439,14 @@ export class BookController {
const startedAt = Date.now();
this.logger.log(`[${event}] [start] fileId=${fileId} userId=${user.id} - download file started`);
try {
const { path, size, format, bookId } = await this.bookService.getFileInfo(fileId, user);
const { path, size, format, bookId, mediaOverlayAvailable } = await this.bookService.getFileInfo(fileId, user);
const mimeType = resolveBookMimeType(format);
const filename = await this.bookService.resolveDownloadFilename({ bookId, absolutePath: path, format: format === 'unknown' ? null : format });
const filename = await this.bookService.resolveDownloadFilename({
bookId,
absolutePath: path,
format: format === 'unknown' ? null : format,
mediaOverlayAvailable,
});
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Disposition', contentDispositionHeader('attachment', filename, 'download'));
+19 -5
View File
@@ -2055,10 +2055,16 @@ export class BookRepository {
async findPrimaryFilesByBookIds(
bookIds: number[],
): Promise<{ bookId: number; absolutePath: string; format: string | null; sizeBytes: number | null }[]> {
): Promise<{ bookId: number; absolutePath: string; format: string | null; sizeBytes: number | null; mediaOverlayAvailable: boolean }[]> {
if (bookIds.length === 0) return [];
return this.db
.select({ bookId: books.id, absolutePath: bookFiles.absolutePath, format: bookFiles.format, sizeBytes: bookFiles.sizeBytes })
.select({
bookId: books.id,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(inArray(books.id, bookIds))
@@ -2084,9 +2090,16 @@ export class BookRepository {
.orderBy(asc(books.id));
}
async findAllFilesByBookIds(
bookIds: number[],
): Promise<{ bookId: number; absolutePath: string; format: string | null; sizeBytes: number | null; sortOrder: number }[]> {
async findAllFilesByBookIds(bookIds: number[]): Promise<
{
bookId: number;
absolutePath: string;
format: string | null;
sizeBytes: number | null;
sortOrder: number;
mediaOverlayAvailable: boolean;
}[]
> {
if (bookIds.length === 0) return [];
return this.db
.select({
@@ -2095,6 +2108,7 @@ export class BookRepository {
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
sortOrder: bookFiles.sortOrder,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds))
@@ -449,6 +449,29 @@ describe('BookService', () => {
expect(filename).toBe('Dune.pdf.epub');
});
it('adds the readaloud marker only to a media-overlay EPUB', async () => {
const { service, appSettings, bookRepo } = makeService();
appSettings.getDownloadPattern.mockResolvedValue('{title}< ({readaloud})>');
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(10, { title: 'Dune' })]);
await expect(
service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/read-along.epub',
format: 'epub',
mediaOverlayAvailable: true,
}),
).resolves.toBe('Dune (readaloud).epub');
await expect(
service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/plain.epub',
format: 'epub',
mediaOverlayAvailable: false,
}),
).resolves.toBe('Dune.epub');
});
});
describe('export files', () => {
@@ -501,6 +524,29 @@ describe('BookService', () => {
expect(plan.archiveFilename).toBe('Frank Herbert - Dune.zip');
});
it('keeps the readaloud marker on an EPUB inside an export but not on the ZIP archive', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}< ({readaloud})>');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([
{
bookId: 1,
absolutePath: '/books/read-aloud.epub',
format: 'epub',
sizeBytes: 100,
mediaOverlayAvailable: true,
},
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Dune' })]);
const plan = await service.getExportFiles([1], user, 'primary');
expect(plan.archiveFilename).toBe('Dune.zip');
expect(plan.files).toEqual([{ absolutePath: '/books/read-aloud.epub', zipPath: 'Dune (readaloud).epub', sizeBytes: 100 }]);
});
it('uses the book title as originalFilename for single-book audiobook archive filenames', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
+27 -7
View File
@@ -182,6 +182,7 @@ type ExportCandidateFile = {
format: string | null;
sizeBytes: number | null;
sortOrder?: number;
mediaOverlayAvailable: boolean;
};
export type ExportPlan = {
@@ -1371,7 +1372,7 @@ export class BookService {
const fallback = this.sanitizeFilenameSegment(`${originalStem}.zip`, 'book.zip');
const meta = metadataByBookId.get(firstFile.bookId);
const tokens = {
...this.buildDownloadPatternTokens(firstFile.absolutePath, firstFile.format, meta),
...this.buildDownloadPatternTokens(firstFile.absolutePath, firstFile.format, false, meta),
originalFilename: meta?.title?.trim() || originalStem,
extension: 'zip',
};
@@ -1389,6 +1390,7 @@ export class BookService {
private buildDownloadPatternTokens(
absolutePath: string,
format: string | null,
mediaOverlayAvailable: boolean,
meta?: Awaited<ReturnType<BookRepository['findPatternMetadataByBookIds']>>[number],
): Record<string, string> {
const pathExtension = extname(absolutePath).toLowerCase().slice(1);
@@ -1402,17 +1404,23 @@ export class BookService {
originalStem: stem,
format: extension,
libraryName: meta?.libraryName,
mediaOverlayAvailable,
});
}
private async resolveDownloadFilenameForFile(file: { bookId: number; absolutePath: string; format: string | null }): Promise<string> {
private async resolveDownloadFilenameForFile(file: {
bookId: number;
absolutePath: string;
format: string | null;
mediaOverlayAvailable: boolean;
}): Promise<string> {
const originalFilename = basename(file.absolutePath);
try {
const [pattern, metaRows] = await Promise.all([
this.appSettings.getDownloadPattern(),
this.bookRepo.findPatternMetadataByBookIds([file.bookId]),
]);
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, metaRows[0]);
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, file.mediaOverlayAvailable, metaRows[0]);
const resolvedPath = resolveUploadPath(pattern || DEFAULT_DOWNLOAD_PATTERN, tokens, tokens.extension);
const resolvedName = resolvedPath?.split('/').filter(Boolean).pop() ?? null;
return this.sanitizeFilenameSegment(resolvedName ?? originalFilename, originalFilename);
@@ -1430,7 +1438,7 @@ export class BookService {
async getFileInfo(
fileId: number,
user: RequestUser,
): Promise<{ path: string; size: number; format: string; bookId: number; originalFilename: string }> {
): Promise<{ path: string; size: number; format: string; bookId: number; originalFilename: string; mediaOverlayAvailable: boolean }> {
const file = await this.verifyFileAccess(fileId, user);
let size: number;
try {
@@ -1442,10 +1450,22 @@ export class BookService {
throw err;
}
const originalFilename = basename(file.absolutePath);
return { path: file.absolutePath, size, format: file.format ?? 'unknown', bookId: file.bookId, originalFilename };
return {
path: file.absolutePath,
size,
format: file.format ?? 'unknown',
bookId: file.bookId,
originalFilename,
mediaOverlayAvailable: file.mediaOverlayAvailable,
};
}
async resolveDownloadFilename(file: { bookId: number; absolutePath: string; format: string | null }): Promise<string> {
async resolveDownloadFilename(file: {
bookId: number;
absolutePath: string;
format: string | null;
mediaOverlayAvailable: boolean;
}): Promise<string> {
return this.resolveDownloadFilenameForFile(file);
}
@@ -3179,7 +3199,7 @@ export class BookService {
throw new BadRequestException(`Export exceeds projected size limit of ${EXPORT_LIMITS.MAX_PROJECTED_BYTES} bytes.`);
}
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, metadataByBookId.get(file.bookId));
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, file.mediaOverlayAvailable, metadataByBookId.get(file.bookId));
const resolvedPath = resolveUploadPath(pattern || DEFAULT_DOWNLOAD_PATTERN, tokens, tokens.extension);
const fallbackFilename = basename(file.absolutePath);
const rawZipPath = resolvedPath ?? fallbackFilename;
@@ -63,6 +63,66 @@ describe('resolveBookFileTargets', () => {
});
});
describe('read-aloud EPUBs', () => {
it('separates a plain EPUB, read-aloud EPUB, and audiobook using the file-specific token', () => {
const files: TargetBookFile[] = [
{
id: 1,
absolutePath: '/library/old/plain.epub',
format: 'epub',
role: 'content',
sortOrder: null,
mediaOverlayAvailable: false,
},
{
id: 2,
absolutePath: '/library/old/read-aloud.epub',
format: 'epub',
role: 'content',
sortOrder: null,
mediaOverlayAvailable: true,
},
{
id: 3,
absolutePath: '/library/old/audio.m4b',
format: 'm4b',
role: 'content',
sortOrder: null,
mediaOverlayAvailable: false,
},
];
const targets = resolveBookFileTargets(
input({
files,
bookFolderPath: '/library/old',
organizationMode: 'book_per_folder',
pattern: '{title}/{title}< ({readaloud})>',
}),
);
expect(targets.get(1)).toBe('/library/Book/Book.epub');
expect(targets.get(2)).toBe('/library/Book/Book (readaloud).epub');
expect(targets.get(3)).toBe('/library/Book/Book.m4b');
expect(new Set(targets.values()).size).toBe(3);
});
it('does not trust the overlay flag on a non-EPUB file', () => {
const files: TargetBookFile[] = [
{
id: 1,
absolutePath: '/library/old/book.m4b',
format: 'm4b',
role: 'content',
sortOrder: null,
mediaOverlayAvailable: true,
},
];
expect(resolvePrimaryFileTarget(input({ files, pattern: '{title}< ({readaloud})>' }))).toBe('/library/Book.m4b');
});
});
/**
* The bug this module exists for. Every part of an audiobook shares its metadata, so the pattern
* resolves all of them onto one filename and the track suffix has to come back. A preview that
@@ -10,6 +10,7 @@ export interface TargetBookFile {
format: string | null;
role: string | null;
sortOrder: number | null;
mediaOverlayAvailable?: boolean | null;
}
export interface BookFileTargetsInput {
@@ -141,6 +142,7 @@ function resolveRelPathFor(file: TargetBookFile, input: BookFileTargetsInput): s
originalStem: basename(file.absolutePath, extension),
format,
libraryName: input.libraryName,
mediaOverlayAvailable: file.mediaOverlayAvailable,
});
return resolveUploadPath(input.pattern, tokens, format, { sanitizeForCrossPlatform: input.sanitizeForCrossPlatform });
}
@@ -106,6 +106,7 @@ export class BulkRenameRepository {
format: bookFiles.format,
role: bookFiles.role,
sortOrder: bookFiles.sortOrder,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(bookFiles)
.where(inArray(bookFiles.bookId, libraryBookIds))
@@ -120,6 +121,7 @@ export class BulkRenameRepository {
format: row.format,
role: row.role,
sortOrder: row.sortOrder,
mediaOverlayAvailable: row.mediaOverlayAvailable,
};
const existing = filesByBook.get(row.bookId);
if (existing) existing.push(entry);
@@ -18,6 +18,7 @@ describe('FileRenameRepository', () => {
relPath: 'old-folder/Old Title.epub',
format: 'epub',
role: 'primary',
mediaOverlayAvailable: true,
libraryFolderId: 20,
libraryFolderPath: '/library',
libraryId: 30,
@@ -66,6 +67,7 @@ describe('FileRenameRepository', () => {
relPath: 'old-folder/Old Title.epub',
format: 'epub',
role: 'primary',
mediaOverlayAvailable: true,
},
libraryId: 30,
libraryFolderId: 20,
@@ -15,6 +15,7 @@ export interface BookRenameData {
relPath: string | null;
format: string | null;
role: string;
mediaOverlayAvailable: boolean;
};
libraryId: number;
libraryName: string;
@@ -56,6 +57,7 @@ export class FileRenameRepository {
relPath: bookFiles.relPath,
format: bookFiles.format,
role: bookFiles.role,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
libraryFolderId: bookFiles.libraryFolderId,
libraryFolderPath: libraryFolders.path,
libraryId: books.libraryId,
@@ -104,6 +106,7 @@ export class FileRenameRepository {
relPath: row.relPath,
format: row.format,
role: row.role,
mediaOverlayAvailable: row.mediaOverlayAvailable,
},
libraryId: row.libraryId,
libraryName: row.libraryName,
@@ -137,6 +140,7 @@ export class FileRenameRepository {
role: bookFiles.role,
format: bookFiles.format,
sortOrder: bookFiles.sortOrder,
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, bookId))
@@ -123,6 +123,7 @@ export class FileRenameService implements OnModuleDestroy {
originalStem,
format,
libraryName: data.libraryName,
mediaOverlayAvailable: data.file.mediaOverlayAvailable,
});
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
@@ -528,6 +528,7 @@ export class KoreaderCatalogService {
bookId: file.bookId,
absolutePath: file.absolutePath,
format: file.format,
mediaOverlayAvailable: false,
});
try {
@@ -299,6 +299,7 @@ export class OpdsController {
bookId,
absolutePath,
format: format === 'unknown' ? null : format,
mediaOverlayAvailable: false,
});
reply.header('Content-Disposition', contentDispositionHeader('attachment', filename, 'download'));
@@ -213,6 +213,62 @@ describe('UploadService', () => {
expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/Frank Herbert/Dune.epub', 'epub');
});
it('inspects and marks a read-aloud EPUB when the upload pattern references the token', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}< ({readaloud})>' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
const inspect = vi.spyOn(service as any, 'inspectMediaOverlayFields').mockResolvedValue({
mediaOverlayAvailable: true,
mediaOverlayDurationSeconds: 3600,
mediaOverlayCheckedAt: new Date(),
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result.filename).toBe('Dune (readaloud).epub');
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Dune (readaloud).epub');
expect(inspect).toHaveBeenCalledOnce();
});
it('does not inspect media overlays for naming when the upload pattern omits the token', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
const inspect = vi.spyOn(service as any, 'inspectMediaOverlayFields');
await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(inspect).not.toHaveBeenCalled();
});
it('stores the directory-entry spelling when an upload targets a case-insensitive parent path', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{authors:first}/{title}.{extension}' }]))
+8 -3
View File
@@ -6,7 +6,7 @@ import { Readable } from 'stream';
import { and, asc, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { buildPatternTokens } from '../../common/utils/pattern-tokens.utils';
import { buildPatternTokens, patternReferencesToken } from '../../common/utils/pattern-tokens.utils';
import { selectPrimaryFileKeepingCurrent } from '../../common/utils/primary-file-selection.utils';
import { DB } from '../../db';
@@ -417,7 +417,10 @@ export class UploadService {
if (pattern) {
const stem = basename(filename, extname(filename));
const tokens = await this.buildUploadPatternTokens(tempPath, format, stem, library.name);
const mediaOverlayAvailable = patternReferencesToken(pattern, 'readaloud')
? (await this.inspectMediaOverlayFields(tempPath, format)).mediaOverlayAvailable
: false;
const tokens = await this.buildUploadPatternTokens(tempPath, format, stem, library.name, mediaOverlayAvailable);
const resolved = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (resolved) {
@@ -441,8 +444,9 @@ export class UploadService {
format: string,
stem: string,
libraryName?: string | null,
mediaOverlayAvailable = false,
): Promise<Record<string, string>> {
const fallback = buildPatternTokens({ metadata: {}, originalStem: stem, format, libraryName });
const fallback = buildPatternTokens({ metadata: {}, originalStem: stem, format, libraryName, mediaOverlayAvailable });
const event = 'upload.pattern_tokens';
const startedAt = Date.now();
@@ -506,6 +510,7 @@ export class UploadService {
originalStem: stem,
format,
libraryName,
mediaOverlayAvailable,
});
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);