mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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', () => ({
|
||||
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', () => ({
|
||||
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> = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
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 = this.resolveRelativeDestination(lib, row, format, pattern, sanitizeForCrossPlatform);
|
||||
const newName = await this.resolveRelativeDestination(lib, row, format, pattern, sanitizeForCrossPlatform);
|
||||
|
||||
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]);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}' }]))
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user