fix(reader): fall back to the declared narration length for read-along EPUBs

Use the package media:duration when a SMIL clip cannot be measured.
Storyteller 2.x writes a backwards clip at audio track boundaries, which
nulled the total and blocked the audiobook bridge. Stored nulls are
re-measured via a migration, the detail page and an unchanged-folder scan.

Fixes #1619
This commit is contained in:
neon
2026-10-01 18:09:27 +00:00
parent 4701412ef2
commit 068a62b7ab
9 changed files with 23059 additions and 11 deletions
@@ -0,0 +1,5 @@
-- A read-along EPUB's narration length was the sum of its SMIL clips, and a single clip that could
-- not be measured left it null. Storyteller 2.x writes such a clip at most audio track boundaries.
-- The parser now falls back to the package media:duration, so clear the check stamp on every file
-- it could not measure: the next library scan, or the book's detail page, inspects each one again.
UPDATE "book_files" SET "media_overlay_checked_at" = NULL WHERE "media_overlay_available" = true AND "media_overlay_duration_seconds" IS NULL;
File diff suppressed because it is too large Load Diff
@@ -708,6 +708,13 @@
"when": 1790634252132,
"tag": "0100_audio_bookmark_position_ignores_tombstones",
"breakpoints": true
},
{
"idx": 101,
"version": "7",
"when": 1790876695526,
"tag": "0101_refresh_unmeasured_media_overlay_durations",
"breakpoints": true
}
]
}
@@ -16,6 +16,7 @@ import { BulkEditFieldsDto } from './dto/bulk-edit-metadata.dto';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookService } from './book.service';
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
import { inspectEpubMediaOverlayFields } from '../reader/epub/epub-media-overlay-capability';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
vi.mock('fs/promises', async () => {
@@ -54,6 +55,11 @@ vi.mock('../metadata/lib/pdf-parser', () => ({
parsePdfFile: vi.fn(),
}));
vi.mock('../reader/epub/epub-media-overlay-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('../reader/epub/epub-media-overlay-capability')>();
return { ...actual, inspectEpubMediaOverlayFields: vi.fn(actual.inspectEpubMediaOverlayFields) };
});
const mockRm = rm as MockedFunction<typeof rm>;
const mockStat = stat as MockedFunction<typeof stat>;
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
@@ -64,6 +70,7 @@ const mockExtractCb7Metadata = extractCb7Metadata as MockedFunction<typeof extra
const mockParseFb2File = parseFb2File as MockedFunction<typeof parseFb2File>;
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
const mockInspectMediaOverlayFields = vi.mocked(inspectEpubMediaOverlayFields);
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
return {
@@ -4465,6 +4472,102 @@ describe('BookService', () => {
}
});
function mockReadAlongDetail(
bookRepo: ReturnType<typeof makeService>['bookRepo'],
overlay: { mediaOverlayDurationSeconds: number | null; mediaOverlayCheckedAt: Date | null },
) {
bookRepo.findById.mockResolvedValue({
book: {
books: {
id: 31,
libraryId: 3,
primaryFileId: 300,
status: 'present',
folderPath: '/books/me-talk',
addedAt: new Date('2026-02-01T00:00:00.000Z'),
},
libraries: { name: 'Main', formatPriority: null },
book_metadata: { title: 'Me Talk Pretty One Day', chapters: null, durationSeconds: null },
},
authorRows: [],
genreRows: [],
tagRows: [],
fileRows: [
{
id: 300,
format: 'epub',
role: 'primary',
sizeBytes: 10,
absolutePath: '/books/me-talk/readaloud.epub',
createdAt: new Date('2026-02-01T00:00:00.000Z'),
durationSeconds: null,
mediaOverlayAvailable: true,
...overlay,
},
{
id: 301,
format: 'm4b',
role: 'content',
sizeBytes: 20,
absolutePath: '/books/me-talk/audiobook.m4b',
createdAt: new Date('2026-02-01T00:00:00.000Z'),
durationSeconds: 21103,
mediaOverlayAvailable: false,
mediaOverlayDurationSeconds: null,
mediaOverlayCheckedAt: null,
},
],
narratorRows: [],
communityRatingRows: [],
});
bookRepo.findCollectionsByBookId.mockResolvedValue([]);
bookRepo.findRatingByBookAndUser.mockResolvedValue(null);
}
it('inspects a read-along EPUB again once its check is cleared, and bridges to the audiobook with the fresh duration', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
mockReadAlongDetail(bookRepo, { mediaOverlayDurationSeconds: null, mediaOverlayCheckedAt: null });
const inspected = {
mediaOverlayAvailable: true,
mediaOverlayDurationSeconds: 21103.35,
mediaOverlayCheckedAt: new Date('2026-10-01T00:00:00.000Z'),
};
mockInspectMediaOverlayFields.mockResolvedValueOnce(inspected);
const result = await service.getDetail(31, makeUser());
expect(mockInspectMediaOverlayFields).toHaveBeenCalledTimes(1);
expect(mockInspectMediaOverlayFields).toHaveBeenCalledWith('/books/me-talk/readaloud.epub', 'epub', expect.any(Function));
expect(bookRepo.updateBookFile).toHaveBeenCalledWith(300, inspected);
expect(result.files.find((file) => file.id === 300)?.mediaOverlay).toEqual({ available: true, durationSeconds: 21103.35 });
expect(result.readAloudSync).toEqual(
expect.objectContaining({
overlayFileId: 300,
audioDurationSeconds: 21103,
overlayDurationSeconds: 21103.35,
state: 'enabled',
unavailableReason: null,
}),
);
});
it.each([
['an unknown duration', null, 'unavailable', 'missing_duration'],
['a measured duration', 21103.35, 'enabled', null],
] as const)('trusts a checked read-along EPUB with %s without opening it again', async (_case, durationSeconds, state, unavailableReason) => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
mockReadAlongDetail(bookRepo, { mediaOverlayDurationSeconds: durationSeconds, mediaOverlayCheckedAt: new Date('2026-09-30T00:00:00.000Z') });
const result = await service.getDetail(31, makeUser());
expect(mockInspectMediaOverlayFields).not.toHaveBeenCalled();
expect(bookRepo.updateBookFile).not.toHaveBeenCalled();
expect(result.files.find((file) => file.id === 300)?.mediaOverlay).toEqual({ available: true, durationSeconds });
expect(result.readAloudSync).toEqual(expect.objectContaining({ overlayDurationSeconds: durationSeconds, state, unavailableReason }));
});
it('preserves null personal detail fields when no user-specific state exists', async () => {
const { service, bookRepo, userBookStatusService, comicMetadataService } = makeService();
const user = makeUser();
+2 -3
View File
@@ -3352,15 +3352,14 @@ export class BookService {
absolutePath: string;
mediaOverlayAvailable?: boolean | null;
mediaOverlayDurationSeconds?: number | null;
mediaOverlayCheckedAt?: Date | null;
mediaOverlayCheckedAt: Date | null;
}>,
): Promise<Map<number, EpubMediaOverlayCapability | null>> {
const entries = await Promise.all(
fileRows.map(async (file) => {
if (file.format?.toLowerCase() !== 'epub') return [file.id, null] as const;
const stored = mediaOverlayCapabilityFromFields(file);
if (stored) return [file.id, stored] as const;
if (file.mediaOverlayCheckedAt) return [file.id, mediaOverlayCapabilityFromFields(file)] as const;
const fields = await inspectEpubMediaOverlayFields(file.absolutePath, file.format, (err) => {
const error = err instanceof Error ? err : new Error(String(err));
@@ -0,0 +1,275 @@
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { ZipArchive } from 'archiver';
import type { EpubBookInfo } from '@bookorbit/types';
import { inspectEpubMediaOverlayFields } from './epub-media-overlay-capability';
import { buildEpubMediaOverlayPlaylist, buildEpubMediaOverlayPlaylistFromFile, inspectEpubMediaOverlayFile } from './epub-media-overlay';
type Clip = { begin?: string; end?: string };
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="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`;
/** Two chapters, each narrated from its own audio track, laid out the way Storyteller writes them. */
const MEASURABLE_CHAPTERS: [Clip[], Clip[]] = [
[
{ begin: '0.000s', end: '3.620s' },
{ begin: '3.620s', end: '11.960s' },
],
[
{ begin: '0.000s', end: '4.640s' },
{ begin: '4.640s', end: '19.150s' },
],
];
const MEASURABLE_TOTAL_SECONDS = 3.62 + 8.34 + 4.64 + 14.51;
/**
* The end of the first audio track in a Storyteller 2.14.21 book from issue 1619: the last
* sentence was placed past the track's real length, then its clip was ended at that length.
*/
const STORYTELLER_TRACK_BOUNDARY_CHAPTERS: [Clip[], Clip[]] = [
[
{ begin: '7215.150s', end: '7223.150s' },
{ begin: '7223.150s', end: '7198.408s' },
],
MEASURABLE_CHAPTERS[1],
];
const STORYTELLER_PACKAGE_DURATION = '<meta property="media:duration">05:51:43.35</meta>';
const STORYTELLER_PACKAGE_DURATION_SECONDS = 21103.35;
const STORYTELLER_OVERLAY_DURATIONS = [
'<meta property="media:duration" refines="#chapter-1_overlay">00:00:08.00</meta>',
'<meta property="media:duration" refines="#chapter-2_overlay">00:00:19.15</meta>',
].join('\n ');
function audioElement(chapter: number, clip: Clip): string {
const begin = clip.begin === undefined ? '' : ` clipBegin="${clip.begin}"`;
const end = clip.end === undefined ? '' : ` clipEnd="${clip.end}"`;
return `<audio src="../Audio/0000${chapter}.mp4"${begin}${end}/>`;
}
function smil(chapter: number, clips: Clip[]): string {
const pars = clips
.map(
(clip, index) => `
<par id="chapter-${chapter}-s${index}" epub:type="storyteller:matched">
<text src="../chapter-${chapter}.xhtml#chapter-${chapter}-s${index}"/>
${audioElement(chapter, clip)}
</par>`,
)
.join('');
return `<smil xmlns="http://www.w3.org/ns/SMIL" xmlns:epub="http://www.idpf.org/2007/ops" version="3.0">
<body>
<seq id="chapter-${chapter}_overlay" epub:textref="../chapter-${chapter}.xhtml" epub:type="chapter">${pars}
</seq>
</body>
</smil>`;
}
function opf(durationMetas: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:issue-1619</dc:identifier>
<dc:title>Read Along</dc:title>
${durationMetas}
<meta property="media:active-class">-epub-media-overlay-active</meta>
</metadata>
<manifest>
<item id="chapter-1" href="chapter-1.xhtml" media-type="application/xhtml+xml" media-overlay="chapter-1_overlay"/>
<item id="chapter-2" href="chapter-2.xhtml" media-type="application/xhtml+xml" media-overlay="chapter-2_overlay"/>
<item id="chapter-1_overlay" href="MediaOverlays/chapter-1.smil" media-type="application/smil+xml"/>
<item id="chapter-2_overlay" href="MediaOverlays/chapter-2.smil" media-type="application/smil+xml"/>
<item id="audio-1" href="Audio/00001.mp4" media-type="audio/mp4"/>
<item id="audio-2" href="Audio/00002.mp4" media-type="audio/mp4"/>
</manifest>
<spine>
<itemref idref="chapter-1"/>
<itemref idref="chapter-2"/>
</spine>
</package>`;
}
describe('EPUB media overlay duration', () => {
let fixtureRoot!: string;
beforeEach(async () => {
fixtureRoot = await mkdtemp(join(tmpdir(), 'bookorbit-media-overlay-'));
});
afterEach(async () => {
await rm(fixtureRoot, { recursive: true, force: true });
});
async function writeReadAlongEpub(chapters: [Clip[], Clip[]], durationMetas: string): Promise<string> {
const path = join(fixtureRoot, `${Math.random().toString(36).slice(2)}.epub`);
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' });
archive.append(opf(durationMetas), { name: 'OEBPS/content.opf' });
for (const [index, clips] of chapters.entries()) {
const chapter = index + 1;
archive.append(`<html xmlns="http://www.w3.org/1999/xhtml"><body><p>Chapter ${chapter}</p></body></html>`, {
name: `OEBPS/chapter-${chapter}.xhtml`,
});
archive.append(smil(chapter, clips), { name: `OEBPS/MediaOverlays/chapter-${chapter}.smil` });
archive.append(Buffer.alloc(16, chapter), { name: `OEBPS/Audio/0000${chapter}.mp4` });
}
await archive.finalize();
await complete;
await writeFile(path, Buffer.concat(chunks));
return path;
}
function withLastClipOfChapterOne(clip: Clip): [Clip[], Clip[]] {
const [first, second] = MEASURABLE_CHAPTERS;
return [[first[0]!, clip], second];
}
it('sums the clips when every clip can be measured, even when the package declares another total', async () => {
const path = await writeReadAlongEpub(MEASURABLE_CHAPTERS, '<meta property="media:duration">01:00:00.00</meta>');
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
expect(playlist.durationSeconds).toBeCloseTo(MEASURABLE_TOTAL_SECONDS, 6);
expect(await inspectEpubMediaOverlayFile(path)).toEqual({ available: true, durationSeconds: playlist.durationSeconds });
});
it('falls back to the package media:duration when a Storyteller track-boundary clip ends before it begins', async () => {
const path = await writeReadAlongEpub(
STORYTELLER_TRACK_BOUNDARY_CHAPTERS,
`${STORYTELLER_OVERLAY_DURATIONS}\n ${STORYTELLER_PACKAGE_DURATION}`,
);
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
expect(playlist.durationSeconds).toBeCloseTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6);
expect(playlist.items.map((item) => item.durationSeconds)).toEqual([
expect.closeTo(8, 6),
null,
expect.closeTo(4.64, 6),
expect.closeTo(14.51, 6),
]);
expect(playlist.items[1]).toEqual(expect.objectContaining({ clipBeginSeconds: 7223.15, clipEndSeconds: 7198.408 }));
expect(playlist.sections.map((section) => section.durationSeconds)).toEqual([null, expect.closeTo(19.15, 6)]);
});
it('reports the declared total as the stored read-aloud capability', async () => {
const path = await writeReadAlongEpub(STORYTELLER_TRACK_BOUNDARY_CHAPTERS, STORYTELLER_PACKAGE_DURATION);
const capability = await inspectEpubMediaOverlayFile(path);
const fields = await inspectEpubMediaOverlayFields(path, 'epub');
expect(capability).toEqual({ available: true, durationSeconds: expect.closeTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6) });
expect(fields).toEqual({
mediaOverlayAvailable: true,
mediaOverlayDurationSeconds: expect.closeTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6),
mediaOverlayCheckedAt: expect.any(Date),
});
});
it.each([
['omits clipEnd, which the spec reads as the end of the audio file', { begin: '3.620s' }],
['ends at a value that is not a clock value', { begin: '3.620s', end: 'NaNs' }],
['ends at a negative time', { begin: '3.620s', end: '-1.000s' }],
])('falls back to the package media:duration when a clip %s', async (_case, clip) => {
const path = await writeReadAlongEpub(withLastClipOfChapterOne(clip), STORYTELLER_PACKAGE_DURATION);
const playlist = await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2);
expect(playlist.items[1]?.durationSeconds).toBeNull();
expect(playlist.durationSeconds).toBeCloseTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6);
});
it.each([
['a full clock value', '05:51:43.35'],
['a partial clock value', '351:43.35'],
['seconds', '21103.35s'],
['milliseconds', '21103350ms'],
['minutes', '351.7225min'],
['a bare number', '21103.35'],
['a clock value padded with whitespace', '\n 05:51:43.35\n '],
])('reads a declared total written as %s', async (_case, value) => {
const path = await writeReadAlongEpub(STORYTELLER_TRACK_BOUNDARY_CHAPTERS, `<meta property="media:duration">${value}</meta>`);
const capability = await inspectEpubMediaOverlayFile(path);
expect(capability.durationSeconds).toBeCloseTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6);
});
it('uses the package total even when the per-overlay durations are declared before it', async () => {
const path = await writeReadAlongEpub(
STORYTELLER_TRACK_BOUNDARY_CHAPTERS,
`${STORYTELLER_OVERLAY_DURATIONS}\n ${STORYTELLER_PACKAGE_DURATION}`,
);
expect((await inspectEpubMediaOverlayFile(path)).durationSeconds).toBeCloseTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6);
});
it('skips an unusable package total and takes the next valid one', async () => {
const path = await writeReadAlongEpub(
STORYTELLER_TRACK_BOUNDARY_CHAPTERS,
`<meta property="media:duration">unknown</meta>\n ${STORYTELLER_PACKAGE_DURATION}`,
);
expect((await inspectEpubMediaOverlayFile(path)).durationSeconds).toBeCloseTo(STORYTELLER_PACKAGE_DURATION_SECONDS, 6);
});
it('never mistakes a per-overlay duration for the package total', async () => {
const path = await writeReadAlongEpub(STORYTELLER_TRACK_BOUNDARY_CHAPTERS, STORYTELLER_OVERLAY_DURATIONS);
expect(await inspectEpubMediaOverlayFile(path)).toEqual({ available: true, durationSeconds: null });
});
it('keeps the duration unknown when the package declares no total', async () => {
const path = await writeReadAlongEpub(STORYTELLER_TRACK_BOUNDARY_CHAPTERS, '');
expect((await buildEpubMediaOverlayPlaylistFromFile(path, 1, 2)).durationSeconds).toBeNull();
expect(await inspectEpubMediaOverlayFile(path)).toEqual({ available: true, durationSeconds: null });
});
it.each([
['zero', '00:00:00'],
['zero seconds', '0s'],
['negative', '-30s'],
['not a clock value', 'unknown'],
['empty', ''],
['infinite', 'Infinity'],
])('keeps the duration unknown when the declared total is %s', async (_case, value) => {
const path = await writeReadAlongEpub(STORYTELLER_TRACK_BOUNDARY_CHAPTERS, `<meta property="media:duration">${value}</meta>`);
expect(await inspectEpubMediaOverlayFile(path)).toEqual({ available: true, durationSeconds: null });
});
it('keeps the duration unknown when the package document named by the book info is missing', async () => {
const path = await writeReadAlongEpub(STORYTELLER_TRACK_BOUNDARY_CHAPTERS, STORYTELLER_PACKAGE_DURATION);
const manifest: EpubBookInfo['manifest'] = [
{ id: 'chapter-1', href: 'OEBPS/chapter-1.xhtml', mediaType: 'application/xhtml+xml', size: 0, mediaOverlay: 'chapter-1_overlay' },
{ id: 'chapter-1_overlay', href: 'OEBPS/MediaOverlays/chapter-1.smil', mediaType: 'application/smil+xml', size: 0 },
];
const info: EpubBookInfo = {
containerPath: 'OEBPS/missing.opf',
rootPath: 'OEBPS/',
spine: [{ idref: 'chapter-1', href: 'OEBPS/chapter-1.xhtml', mediaType: 'application/xhtml+xml', linear: true }],
manifest,
toc: null,
metadata: {},
coverPath: null,
};
const playlist = await buildEpubMediaOverlayPlaylist(path, info, 1, 2);
expect(playlist.items).toHaveLength(2);
expect(playlist.durationSeconds).toBeNull();
});
});
@@ -110,6 +110,33 @@ function attr(node: unknown, name: string): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/**
* The total narration length every EPUB 3 Media Overlay package must declare, in a
* `media:duration` meta that refines nothing. It stands in when a clip cannot be measured:
* Storyteller 2.x ends the last clip of each audio track at the track's real length, which can
* fall before the clip's start, and one such clip voids the whole clip sum.
*/
async function readPackageMediaDurationSeconds(zip: unzipper.CentralDirectory, opfPath: string): Promise<number | null> {
const entry = opfPath ? findEpubZipEntry(zip.files, opfPath) : undefined;
if (!entry) return null;
let doc: Record<string, unknown>;
try {
doc = smilParser.parse(await entry.buffer()) as Record<string, unknown>;
} catch {
return null;
}
const pkg = (doc['package'] ?? doc) as Record<string, unknown>;
const metadata = pkg['metadata'] as Record<string, unknown> | undefined;
for (const meta of toArray(metadata?.meta)) {
if (attr(meta, 'property') !== 'media:duration' || attr(meta, 'refines') != null) continue;
// fast-xml-parser turns a bare timecount such as "21103.35" into a number.
const value = (meta as Record<string, unknown>)['#text'];
const seconds = typeof value === 'number' ? value : parseClock(value);
if (seconds != null && Number.isFinite(seconds) && seconds > 0) return seconds;
}
return null;
}
function splitHrefFragment(href: string): { href: string; fragment: string | null } {
const [path, fragment] = href.split('#');
return { href: normalizeEpubZipPath(path), fragment: fragment || null };
@@ -313,7 +340,7 @@ export async function buildEpubMediaOverlayPlaylist(
return {
bookId,
fileId,
durationSeconds: hasUnknownDuration ? null : totalKnownDuration,
durationSeconds: hasUnknownDuration ? await readPackageMediaDurationSeconds(zip, info.containerPath) : totalKnownDuration,
items,
sections,
resources,
@@ -2,6 +2,10 @@ vi.mock('./lib/walk');
vi.mock('./lib/hash');
vi.mock('../../common/utils/fs-stability.utils', () => ({ waitForStability: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../common/utils/path-identity.utils', () => ({ pathsReferToSameEntry: vi.fn() }));
vi.mock('../reader/epub/epub-media-overlay-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('../reader/epub/epub-media-overlay-capability')>();
return { ...actual, inspectEpubMediaOverlayFields: vi.fn(actual.inspectEpubMediaOverlayFields) };
});
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
return {
@@ -26,6 +30,7 @@ import type { BookCandidate, FileStat } from './lib/walk';
import { findBookCandidates, findLooseFileCandidates, buildSingleBookCandidate } from './lib/walk';
import { computeFileHash } from './lib/hash';
import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils';
import { inspectEpubMediaOverlayFields } from '../reader/epub/epub-media-overlay-capability';
import * as assembleBookCardsModule from '../book/utils/assemble-book-cards';
const mockFindCandidates = findBookCandidates as MockedFunction<typeof findBookCandidates>;
@@ -33,6 +38,7 @@ const mockFindLooseCandidates = findLooseFileCandidates as MockedFunction<typeof
const mockBuildSingleCandidate = buildSingleBookCandidate as MockedFunction<typeof buildSingleBookCandidate>;
const mockFingerprint = computeFileHash as MockedFunction<typeof computeFileHash>;
const mockPathsReferToSameEntry = pathsReferToSameEntry as MockedFunction<typeof pathsReferToSameEntry>;
const mockInspectMediaOverlayFields = vi.mocked(inspectEpubMediaOverlayFields);
const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockStat = stat as MockedFunction<typeof stat>;
@@ -2279,6 +2285,100 @@ describe('incremental scan — no re-extraction on unchanged winner', () => {
});
});
describe('unchecked read-along EPUBs in unchanged folders', () => {
const READ_ALONG = '/library/Author/Book/readaloud.epub';
const MEASURED = {
mediaOverlayAvailable: true,
mediaOverlayDurationSeconds: 21103.35,
mediaOverlayCheckedAt: new Date('2026-10-01T00:00:00.000Z'),
};
async function scanUnchangedFolder(files: ReturnType<typeof makeBookFile>[], repoOverrides: Record<string, unknown> = {}) {
const repo = makeRepo({
findBooksByLibraryFolder: vi
.fn()
.mockResolvedValue([{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Author/Book', status: 'present' }]),
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue(files),
...repoOverrides,
});
mockFindCandidates.mockResolvedValue({
candidates: [],
skippedDirs: new Set(),
unchangedDirs: new Set(['/library/Author/Book']),
dirMtimes: new Map(),
});
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
return repo;
}
function readAlongFile(overrides: Record<string, unknown> = {}) {
return makeBookFile({
id: 7,
absolutePath: READ_ALONG,
relPath: 'Author/Book/readaloud.epub',
durationSeconds: null,
mediaOverlayAvailable: true,
mediaOverlayDurationSeconds: null,
mediaOverlayCheckedAt: null,
...overrides,
});
}
it('measures a read-along EPUB whose check was cleared, even though the incremental scan skips its folder', async () => {
mockInspectMediaOverlayFields.mockResolvedValueOnce(MEASURED);
const repo = await scanUnchangedFolder([readAlongFile()]);
expect(mockInspectMediaOverlayFields).toHaveBeenCalledOnce();
expect(mockInspectMediaOverlayFields).toHaveBeenCalledWith(READ_ALONG, 'epub', expect.any(Function));
expect(repo.updateBookFile).toHaveBeenCalledWith(7, MEASURED);
expect(repo.completeScanJob).toHaveBeenCalled();
});
it('leaves checked read-along EPUBs, never-checked plain EPUBs and other formats unopened', async () => {
await scanUnchangedFolder([
readAlongFile({ mediaOverlayCheckedAt: new Date('2026-09-30T00:00:00.000Z') }),
makeBookFile({ id: 8, absolutePath: '/library/Author/Book/plain.epub', mediaOverlayAvailable: false, mediaOverlayCheckedAt: null }),
makeBookFile({ id: 9, absolutePath: '/library/Author/Book/book.m4b', format: 'm4b', mediaOverlayAvailable: true, mediaOverlayCheckedAt: null }),
]);
expect(mockInspectMediaOverlayFields).not.toHaveBeenCalled();
});
it('leaves a read-along EPUB outside the unchanged folders to the regular scan', async () => {
await scanUnchangedFolder([readAlongFile({ bookId: 2, absolutePath: '/library/Other/Book/readaloud.epub' })]);
expect(mockInspectMediaOverlayFields).not.toHaveBeenCalled();
});
it('does not demote a read-along EPUB the repair cannot read', async () => {
mockInspectMediaOverlayFields.mockResolvedValueOnce({
mediaOverlayAvailable: false,
mediaOverlayDurationSeconds: null,
mediaOverlayCheckedAt: new Date('2026-10-01T00:00:00.000Z'),
});
const repo = await scanUnchangedFolder([readAlongFile()]);
expect(mockInspectMediaOverlayFields).toHaveBeenCalledOnce();
expect(repo.updateBookFile).not.toHaveBeenCalled();
});
it('finishes the scan when storing a repaired capability fails', async () => {
mockInspectMediaOverlayFields.mockResolvedValueOnce(MEASURED);
const repo = await scanUnchangedFolder([readAlongFile()], { updateBookFile: vi.fn().mockRejectedValue(new Error('connection reset')) });
expect(repo.completeScanJob).toHaveBeenCalled();
expect(repo.failScanJob).not.toHaveBeenCalled();
expect(Logger.prototype.warn).toHaveBeenCalledWith(expect.stringContaining('[scanner.media_overlay_repair] [fail] fileId=7'));
});
});
describe('missing book restoration', () => {
it('restores a missing book to present when its folder is found again', async () => {
const repo = makeRepo({
+51 -7
View File
@@ -128,8 +128,16 @@ const WATCHER_NOTIFY_DEBOUNCE_MS = 30_000;
const TARGETED_BOOK_SCAN_MAX_CONCURRENCY = 8;
const MISSING_FILE_STAT_BATCH_SIZE = 50;
const AUDIO_DURATION_PROBE_CONCURRENCY = 4;
const MEDIA_OVERLAY_REPAIR_CONCURRENCY = 4;
type OrganizationMode = 'book_per_file' | 'book_per_folder';
function isUnderAnyDir(absolutePath: string, dirs: Set<string>): boolean {
for (const dir of dirs) {
if (absolutePath === dir || absolutePath.startsWith(dir + sep)) return true;
}
return false;
}
interface ScanCounts {
addedCount: number;
updatedCount: number;
@@ -1600,6 +1608,7 @@ export class ScannerService implements OnApplicationBootstrap {
}
await this.repairMissingAudioDurations(knownFiles, unchangedDirs, seenBookIds);
await this.repairUncheckedMediaOverlays(knownFiles, unchangedDirs, seenBookIds);
// Deferred prune: delete book files not retained by any candidate.
// Must happen after ALL batches so cross-book file moves are visible.
@@ -1668,19 +1677,13 @@ export class ScannerService implements OnApplicationBootstrap {
): Promise<void> {
if (unchangedDirs.size === 0) return;
const isUnderUnchangedDir = (absolutePath: string) => {
for (const dir of unchangedDirs) {
if (absolutePath === dir || absolutePath.startsWith(dir + sep)) return true;
}
return false;
};
const filesToRepair = knownFiles.filter(
(file) =>
!processedBookIds.has(file.bookId) &&
file.format !== null &&
isAudioFormat(file.format) &&
(file.durationSeconds === null || file.durationSeconds <= 0) &&
isUnderUnchangedDir(file.absolutePath) &&
isUnderAnyDir(file.absolutePath, unchangedDirs) &&
!this.selfWriteRegistry.isSuppressed(file.absolutePath),
);
const repairedBookIds = new Set<number>();
@@ -1707,6 +1710,47 @@ export class ScannerService implements OnApplicationBootstrap {
});
}
/**
* Measures read-along EPUBs whose media-overlay check was cleared so a fixed parser reads them
* again. An incremental scan never opens a file in an unchanged folder, so they are inspected
* here. A failed read does not demote the file; the next scan or its detail page tries again.
*/
private async repairUncheckedMediaOverlays(
knownFiles: Array<{
id: number;
bookId: number;
absolutePath: string;
format: string | null;
mediaOverlayAvailable: boolean;
mediaOverlayCheckedAt: Date | null;
}>,
unchangedDirs: Set<string>,
processedBookIds: Set<number>,
): Promise<void> {
if (unchangedDirs.size === 0) return;
const filesToRepair = knownFiles.filter(
(file) =>
!processedBookIds.has(file.bookId) &&
file.format?.toLowerCase() === 'epub' &&
file.mediaOverlayAvailable &&
file.mediaOverlayCheckedAt == null &&
isUnderAnyDir(file.absolutePath, unchangedDirs) &&
!this.selfWriteRegistry.isSuppressed(file.absolutePath),
);
await mapWithConcurrency(filesToRepair, MEDIA_OVERLAY_REPAIR_CONCURRENCY, async (file) => {
try {
const fields = await this.inspectMediaOverlayFields(file.absolutePath, file.format);
if (fields.mediaOverlayAvailable) await this.scannerRepo.updateBookFile(file.id, fields);
} catch (err) {
this.logger.warn(
`[scanner.media_overlay_repair] [fail] fileId=${file.id} path="${sanitizeLogValue(file.absolutePath)}" errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - unchecked media overlay repair failed`,
);
}
});
}
private async processCandidate(
candidate: BookCandidate,
libraryId: number,