mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user