From d66f853cad0b467d1add7988f74adc996937aa70 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:29:25 +0000 Subject: [PATCH] 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 --- .../__tests__/FileNamingSettings.spec.ts | 12 ++ .../settings/__tests__/SettingsNav.spec.ts | 12 +- .../__tests__/SettingsSidebar.spec.ts | 12 +- .../components/NamingResultPanel.vue | 18 ++ .../components/PatternTokenPalette.vue | 1 + client/src/locales/en.json | 4 + packages/types/src/pattern-resolver.ts | 2 + .../common/utils/pattern-tokens.utils.test.ts | 22 ++- .../src/common/utils/pattern-tokens.utils.ts | 11 +- .../readaloud-pattern.integration.test.ts | 161 ++++++++++++++++++ .../book-dock-finalize.service.test.ts | 20 +++ .../book-dock/book-dock-finalize.service.ts | 52 ++++-- .../book-move/book-move-planner.service.ts | 1 + .../modules/book-move/book-move.repository.ts | 3 + server/src/modules/book/book.controller.ts | 9 +- server/src/modules/book/book.repository.ts | 24 ++- server/src/modules/book/book.service.test.ts | 46 +++++ server/src/modules/book/book.service.ts | 34 +++- .../file-write/book-file-targets.test.ts | 60 +++++++ .../modules/file-write/book-file-targets.ts | 2 + .../file-write/bulk-rename.repository.ts | 2 + .../file-write/file-rename.repository.test.ts | 2 + .../file-write/file-rename.repository.ts | 4 + .../modules/file-write/file-rename.service.ts | 1 + .../koreader/koreader-catalog.service.ts | 1 + server/src/modules/opds/opds.controller.ts | 1 + .../src/modules/upload/upload.service.test.ts | 56 ++++++ server/src/modules/upload/upload.service.ts | 11 +- 28 files changed, 543 insertions(+), 41 deletions(-) create mode 100644 server/src/common/utils/readaloud-pattern.integration.test.ts diff --git a/client/src/features/settings/__tests__/FileNamingSettings.spec.ts b/client/src/features/settings/__tests__/FileNamingSettings.spec.ts index 8354600e2..fc812d442 100644 --- a/client/src/features/settings/__tests__/FileNamingSettings.spec.ts +++ b/client/src/features/settings/__tests__/FileNamingSettings.spec.ts @@ -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() diff --git a/client/src/features/settings/__tests__/SettingsNav.spec.ts b/client/src/features/settings/__tests__/SettingsNav.spec.ts index d046290eb..e7b7d47f0 100644 --- a/client/src/features/settings/__tests__/SettingsNav.spec.ts +++ b/client/src/features/settings/__tests__/SettingsNav.spec.ts @@ -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() + return { + ...actual, + useRoute: () => ({ name: routeState.name }), + useRouter: () => ({ push }), + } +}) const navStatus = vi.hoisted(() => ({ scanning: false })) diff --git a/client/src/features/settings/__tests__/SettingsSidebar.spec.ts b/client/src/features/settings/__tests__/SettingsSidebar.spec.ts index 66951ea35..150b4f3b9 100644 --- a/client/src/features/settings/__tests__/SettingsSidebar.spec.ts +++ b/client/src/features/settings/__tests__/SettingsSidebar.spec.ts @@ -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() + return { + ...actual, + useRoute: () => ({ name: 'settings-appearance-theme' }), + useRouter: () => ({ push: vi.fn<(to: { name: string }) => void>() }), + } +}) vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ diff --git a/client/src/features/settings/file-naming/components/NamingResultPanel.vue b/client/src/features/settings/file-naming/components/NamingResultPanel.vue index 4094501da..7e4e5d475 100644 --- a/client/src/features/settings/file-naming/components/NamingResultPanel.vue +++ b/client/src/features/settings/file-naming/components/NamingResultPanel.vue @@ -36,6 +36,8 @@ function resolve(metadata: Record): 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) { +
+

+ {{ t('settings.reader.fileNaming.readaloudComparison') }} +

+
+
+
{{ t('settings.reader.fileNaming.readaloudEpub') }}
+
{{ resolved }}
+
+
+
{{ t('settings.reader.fileNaming.plainEpub') }}
+
{{ plainEpubResult }}
+
+
+
+

{{ t('settings.reader.fileNaming.crossPlatform') }}

diff --git a/client/src/features/settings/file-naming/components/PatternTokenPalette.vue b/client/src/features/settings/file-naming/components/PatternTokenPalette.vue index 4bda07dde..9404fa30c 100644 --- a/client/src/features/settings/file-naming/components/PatternTokenPalette.vue +++ b/client/src/features/settings/file-naming/components/PatternTokenPalette.vue @@ -24,6 +24,7 @@ const TOKEN_DESCRIPTION_KEYS: Record = { 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 = { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e1170f25a..26e0dd6dc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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", diff --git a/packages/types/src/pattern-resolver.ts b/packages/types/src/pattern-resolver.ts index bb9202324..c882bc56a 100644 --- a/packages/types/src/pattern-resolver.ts +++ b/packages/types/src/pattern-resolver.ts @@ -19,6 +19,7 @@ export const EXAMPLE_PATTERN_METADATA: Record = { 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"]; diff --git a/server/src/common/utils/pattern-tokens.utils.test.ts b/server/src/common/utils/pattern-tokens.utils.test.ts index 5e092fcd6..f83003038 100644 --- a/server/src/common/utils/pattern-tokens.utils.test.ts +++ b/server/src/common/utils/pattern-tokens.utils.test.ts @@ -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); + }); }); }); diff --git a/server/src/common/utils/pattern-tokens.utils.ts b/server/src/common/utils/pattern-tokens.utils.ts index 51a839001..8ba89e690 100644 --- a/server/src/common/utils/pattern-tokens.utils.ts +++ b/server/src/common/utils/pattern-tokens.utils.ts @@ -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 { - const { metadata, authors = [], narrators = [], originalStem, format, libraryName } = input; + const { metadata, authors = [], narrators = [], originalStem, format, libraryName, mediaOverlayAvailable } = input; const tokens: Record = { originalFilename: originalStem, extension: format }; if (libraryName) tokens['library'] = libraryName; @@ -52,6 +60,7 @@ export function buildPatternTokens(input: PatternTokenInput): Record 0) tokens['authors'] = authors.join(', '); if (narrators.length > 0) tokens['narrators'] = narrators.join(', '); + if (format.toLowerCase() === 'epub' && mediaOverlayAvailable === true) tokens['readaloud'] = 'readaloud'; return tokens; } diff --git a/server/src/common/utils/readaloud-pattern.integration.test.ts b/server/src/common/utils/readaloud-pattern.integration.test.ts new file mode 100644 index 000000000..b971a351b --- /dev/null +++ b/server/src/common/utils/readaloud-pattern.integration.test.ts @@ -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 = ` + + +`; + +const CHAPTER_XHTML = ` +

Dune

`; + +const PLAIN_OPF = ` + + + +`; + +const READALOUD_OPF = ` + + + + + + + +`; + +const CHAPTER_SMIL = ` + + +`; + +type ZipEntry = { name: string; content: string | Buffer; store?: boolean }; + +async function writeEpub(path: string, entries: ZipEntry[]): Promise { + const archive = new ZipArchive({ zlib: { level: 0 } }); + const chunks: Buffer[] = []; + archive.on('data', (chunk: Buffer) => chunks.push(chunk)); + const complete = new Promise((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); + }); +}); diff --git a/server/src/modules/book-dock/book-dock-finalize.service.test.ts b/server/src/modules/book-dock/book-dock-finalize.service.test.ts index 7c2db276e..53f229ec4 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.test.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.test.ts @@ -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); diff --git a/server/src/modules/book-dock/book-dock-finalize.service.ts b/server/src/modules/book-dock/book-dock-finalize.service.ts index 17fab6736..ee1f5e7ce 100644 --- a/server/src/modules/book-dock/book-dock-finalize.service.ts +++ b/server/src/modules/book-dock/book-dock-finalize.service.ts @@ -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(); - 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 { @@ -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 { 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 { + private buildFilePatternTokens( + meta: BookDockMetadata, + fileName: string, + format: string, + libraryName?: string | null, + mediaOverlayAvailable = false, + ): Record { 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 { + 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. diff --git a/server/src/modules/book-move/book-move-planner.service.ts b/server/src/modules/book-move/book-move-planner.service.ts index 53fbf887f..05340e05c 100644 --- a/server/src/modules/book-move/book-move-planner.service.ts +++ b/server/src/modules/book-move/book-move-planner.service.ts @@ -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 }); } diff --git a/server/src/modules/book-move/book-move.repository.ts b/server/src/modules/book-move/book-move.repository.ts index d96d27cfa..e2c53c97f 100644 --- a/server/src/modules/book-move/book-move.repository.ts +++ b/server/src/modules/book-move/book-move.repository.ts @@ -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]); diff --git a/server/src/modules/book/book.controller.ts b/server/src/modules/book/book.controller.ts index 0b7a18c7c..4cff31a14 100644 --- a/server/src/modules/book/book.controller.ts +++ b/server/src/modules/book/book.controller.ts @@ -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')); diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index c270937d3..92684ae98 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -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)) diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index 70456a558..a9a498ff8 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -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(); diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index ab4e307be..b770a7ada 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -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>[number], ): Record { 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 { + private async resolveDownloadFilenameForFile(file: { + bookId: number; + absolutePath: string; + format: string | null; + mediaOverlayAvailable: boolean; + }): Promise { 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 { + async resolveDownloadFilename(file: { + bookId: number; + absolutePath: string; + format: string | null; + mediaOverlayAvailable: boolean; + }): Promise { 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; diff --git a/server/src/modules/file-write/book-file-targets.test.ts b/server/src/modules/file-write/book-file-targets.test.ts index 6a0867b90..ad9d03151 100644 --- a/server/src/modules/file-write/book-file-targets.test.ts +++ b/server/src/modules/file-write/book-file-targets.test.ts @@ -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 diff --git a/server/src/modules/file-write/book-file-targets.ts b/server/src/modules/file-write/book-file-targets.ts index b30063ba9..19228f696 100644 --- a/server/src/modules/file-write/book-file-targets.ts +++ b/server/src/modules/file-write/book-file-targets.ts @@ -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 }); } diff --git a/server/src/modules/file-write/bulk-rename.repository.ts b/server/src/modules/file-write/bulk-rename.repository.ts index c94bffdba..70aee6a80 100644 --- a/server/src/modules/file-write/bulk-rename.repository.ts +++ b/server/src/modules/file-write/bulk-rename.repository.ts @@ -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); diff --git a/server/src/modules/file-write/file-rename.repository.test.ts b/server/src/modules/file-write/file-rename.repository.test.ts index c05974202..75db6d943 100644 --- a/server/src/modules/file-write/file-rename.repository.test.ts +++ b/server/src/modules/file-write/file-rename.repository.test.ts @@ -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, diff --git a/server/src/modules/file-write/file-rename.repository.ts b/server/src/modules/file-write/file-rename.repository.ts index 1c18cb7f4..a1d9dc813 100644 --- a/server/src/modules/file-write/file-rename.repository.ts +++ b/server/src/modules/file-write/file-rename.repository.ts @@ -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)) diff --git a/server/src/modules/file-write/file-rename.service.ts b/server/src/modules/file-write/file-rename.service.ts index 7ca00aa77..c21eec795 100644 --- a/server/src/modules/file-write/file-rename.service.ts +++ b/server/src/modules/file-write/file-rename.service.ts @@ -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 }); diff --git a/server/src/modules/koreader/koreader-catalog.service.ts b/server/src/modules/koreader/koreader-catalog.service.ts index c55309519..21062f4e8 100644 --- a/server/src/modules/koreader/koreader-catalog.service.ts +++ b/server/src/modules/koreader/koreader-catalog.service.ts @@ -528,6 +528,7 @@ export class KoreaderCatalogService { bookId: file.bookId, absolutePath: file.absolutePath, format: file.format, + mediaOverlayAvailable: false, }); try { diff --git a/server/src/modules/opds/opds.controller.ts b/server/src/modules/opds/opds.controller.ts index 927e975c6..daa831c79 100644 --- a/server/src/modules/opds/opds.controller.ts +++ b/server/src/modules/opds/opds.controller.ts @@ -299,6 +299,7 @@ export class OpdsController { bookId, absolutePath, format: format === 'unknown' ? null : format, + mediaOverlayAvailable: false, }); reply.header('Content-Disposition', contentDispositionHeader('attachment', filename, 'download')); diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index eea661db1..7757ae5f0 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -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}' }])) diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index f559ba4dc..51ab96bb6 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -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> { - 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);