diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index 5b629896a..ce77296d9 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -32,7 +32,7 @@ import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors' import { useCoverVersions } from '@/features/book/composables/useCoverVersions' import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio' import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types' -import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadStatus, UserBookStatus } from '@bookorbit/types' +import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadAloudProgressSync, ReadStatus, UserBookStatus } from '@bookorbit/types' import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus' import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue' import DiscoverRow from '@/features/book/components/detail/DiscoverRow.vue' @@ -350,6 +350,7 @@ const detailCoverAspectRatio = computed(() => { }) const primaryFile = computed(() => props.book.files.find((f) => f.role === 'primary') ?? props.book.files[0] ?? null) const readAlongFile = computed(() => props.book.files.find((file) => hasReadAlong(file)) ?? null) +const hasAudioFile = computed(() => props.book.files.some((file) => file.format != null && FORMAT_TO_GROUP[file.format] === 'audio')) const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio') const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx') const readableFiles = computed(() => props.book.files.filter((f) => f.format && READER_OPENABLE_FORMATS.has(f.format))) @@ -368,6 +369,53 @@ const openableFiles = computed(() => { return readableFiles.value }) const hasMultipleFiles = computed(() => openableFiles.value.length > 1) +const readAloudSync = ref(props.book.readAloudSync) +const readAloudSyncSaving = ref(false) +const readAloudSyncError = ref(null) +const showReadAloudSync = computed(() => readAlongFile.value != null || hasAudioFile.value) +const readAloudSyncStatus = computed(() => t(`book.detail.details.readAloudSync.state.${readAloudSync.value.state}`)) +const readAloudSyncDescription = computed(() => { + if (readAloudSync.value.state === 'enabled') return t('book.detail.details.readAloudSync.enabledDescription') + if (readAloudSync.value.state === 'disabled') return t('book.detail.details.readAloudSync.disabledDescription') + const reason = readAloudSync.value.unavailableReason ?? 'missing_duration' + if (reason === 'duration_mismatch') { + return t('book.detail.details.readAloudSync.reason.durationMismatch', { + audio: formatDuration(readAloudSync.value.audioDurationSeconds), + overlay: formatDuration(readAloudSync.value.overlayDurationSeconds), + }) + } + return t(`book.detail.details.readAloudSync.reason.${reason}`) +}) + +watch( + () => props.book.readAloudSync, + (value) => { + readAloudSync.value = value + readAloudSyncError.value = null + }, +) + +async function handleToggleReadAloudSync() { + if (readAloudSyncSaving.value) return + const mode = readAloudSync.value.mode === 'disabled' ? 'auto' : 'disabled' + readAloudSyncSaving.value = true + readAloudSyncError.value = null + try { + const res = await api(`/api/v1/books/${props.book.id}/read-aloud-sync`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }) + if (!res.ok) throw new Error('Failed to update read-aloud sync') + const updated = (await res.json()) as BookDetail + readAloudSync.value = updated.readAloudSync + emit('saved', updated) + } catch { + readAloudSyncError.value = t('book.detail.details.readAloudSync.saveFailed') + } finally { + readAloudSyncSaving.value = false + } +} const authorLinks = computed(() => props.book.authors.filter((author) => author.name.trim().length > 0)) const narratorLine = computed(() => props.book.audioMetadata?.narrators?.map((n) => n.name).join(', ') || null) const formats = computed(() => { @@ -1725,6 +1773,37 @@ watch( +
+
+
+
+
diff --git a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts index 536668a6c..909763d1d 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts @@ -748,4 +748,102 @@ describe('DetailsTab cover surface', () => { expect(wrapper.find('button[aria-label="Edit date added"]').exists()).toBe(false) }) + + it('shows read-aloud sync state and persists the toggle', async () => { + const book = makeBook({ + files: [ + { + ...makeBook().files[0]!, + mediaOverlay: { available: true, durationSeconds: 3600 }, + }, + { + id: 102, + format: 'm4b', + role: 'content', + sizeBytes: 5000, + absolutePath: '/books/cover-behavior-test.m4b', + createdAt: '2026-01-01T00:00:00.000Z', + filename: 'cover-behavior-test.m4b', + durationSeconds: 3600, + }, + ], + readAloudSync: { + mode: 'auto', + state: 'enabled', + unavailableReason: null, + overlayFileId: 101, + audioDurationSeconds: 3600, + overlayDurationSeconds: 3600, + durationDifferenceSeconds: 0, + durationDifferenceRatio: 0, + koreaderDownloadAvailable: true, + }, + }) + const updated = makeBook({ ...book, readAloudSync: { ...book.readAloudSync, mode: 'disabled', state: 'disabled' } }) + mocks.api.mockImplementation(async (input) => { + const url = String(input) + if (url.endsWith('/read-aloud-sync')) return response(updated) + if (url.includes('/metadata-preferences/provider-links')) return response({ amazonDomain: 'amazon.com' }) + if (url.includes('/metadata-score/weights')) return response({}) + if (url.includes('/playback-state')) return response(null) + if (url.includes('/collections/membership')) return response([]) + if (url.includes('/kobo-state')) return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] }) + if (url.includes('/koreader/books/')) return response(null) + if (url.includes('/progress')) return response([]) + return response({}) + }) + const wrapper = mountDetails(book) + await flushPromises() + + expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('Enabled') + await wrapper.get('[data-test="read-aloud-sync-toggle"]').trigger('click') + await flushPromises() + + expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/12/read-aloud-sync', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'disabled' }), + }) + expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('Disabled') + expect(wrapper.emitted('saved')).toEqual([[updated]]) + }) + + it('explains a duration mismatch and exposes a localized save failure', async () => { + const book = makeBook({ + files: [ + { ...makeBook().files[0]!, mediaOverlay: { available: true, durationSeconds: 3600 } }, + { + id: 102, + format: 'mp3', + role: 'content', + sizeBytes: 5000, + absolutePath: '/books/audio.mp3', + createdAt: '2026-01-01T00:00:00.000Z', + filename: 'audio.mp3', + durationSeconds: 4000, + }, + ], + readAloudSync: { + mode: 'auto', + state: 'unavailable', + unavailableReason: 'duration_mismatch', + overlayFileId: 101, + audioDurationSeconds: 4000, + overlayDurationSeconds: 3600, + durationDifferenceSeconds: 400, + durationDifferenceRatio: 400 / 3600, + koreaderDownloadAvailable: true, + }, + }) + const wrapper = mountDetails(book) + await flushPromises() + + expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('audiobook 1h 6m, read-along EPUB 1h') + mocks.api.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) } as Response) + await wrapper.get('[data-test="read-aloud-sync-toggle"]').trigger('click') + await flushPromises() + + expect(wrapper.get('[role="status"]').text()).toBe('Could not update read-aloud progress sync.') + expect(wrapper.emitted('saved')).toBeUndefined() + }) }) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e5b5d6789..cbad2a938 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -5142,6 +5142,26 @@ "untitled": "Untitled", "metadataScore": "Metadata Score", "primaryFormat": "Primary format", + "readAloudSync": { + "title": "Read-aloud progress sync", + "state": { + "enabled": "Enabled", + "disabled": "Disabled", + "unavailable": "Unavailable" + }, + "enabledDescription": "Audiobook, web reader, Kobo, and KOReader positions stay in sync.", + "disabledDescription": "Each format keeps its own reading position.", + "reason": { + "no_media_overlay_epub": "A read-along EPUB with media overlays is required.", + "no_audio_files": "Matching standalone audiobook files are required.", + "missing_duration": "Audio duration metadata is incomplete.", + "durationMismatch": "Durations do not match closely enough: audiobook {audio}, read-along EPUB {overlay}." + }, + "enable": "Enable", + "disable": "Disable", + "saving": "Saving...", + "saveFailed": "Could not update read-aloud progress sync." + }, "openIn": "Open in {provider}", "showLess": "Show less", "showMore": "Show more", diff --git a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua index 3ad536a61..d67eeb6d2 100644 --- a/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua +++ b/koreader-plugin/bookorbit.koplugin/bookorbit_catalog_detail.lua @@ -299,8 +299,16 @@ function CatalogDetail.detailSeriesLine(detail) return text .. " \u{203A}" end -local function fileInfoLabel(file) +local function fileFormatLabel(file) local label = string.upper(file.format or "file") + if file.downloadVariant == "audioless_epub" then + label = label .. " - " .. _("Read Along (audio removed)") + end + return label +end + +local function fileInfoLabel(file) + local label = fileFormatLabel(file) local extras = {} local size = formatBytes(file.sizeBytes) if size ~= "" then table.insert(extras, size) end @@ -865,17 +873,18 @@ function CatalogDetail:downloadButtonLabel(supported_files) local file = self:nextDownloadFile(supported_files) local format = cleanInlineText(file and file.format) if not format then return _("Download") end + local format_label = fileFormatLabel(file) -- Size is what decides whether a download is worth starting over a slow -- connection, so it rides the button when the server knows it. local size = formatBytes(file and file.sizeBytes) if size ~= "" then - return T(_("Download %1 - %2"), string.upper(format), size) + return T(_("Download %1 - %2"), format_label, size) end - return T(_("Download (%1)"), string.upper(format)) + return T(_("Download (%1)"), format_label) end function CatalogDetail:fileLabel(file, show_support) - local label = string.upper(file.format or "file") + local label = fileFormatLabel(file) local extras = {} local size = formatBytes(file.sizeBytes) if size ~= "" then table.insert(extras, size) end diff --git a/packages/types/src/koreader.ts b/packages/types/src/koreader.ts index 129940ad9..91363f37b 100644 --- a/packages/types/src/koreader.ts +++ b/packages/types/src/koreader.ts @@ -196,6 +196,7 @@ export interface KoreaderCatalogFile { id: number; format: string; role: string; + downloadVariant: "original" | "audioless_epub"; sizeBytes: number | null; durationSeconds: number | null; downloadUrl: string; @@ -368,6 +369,7 @@ export interface KoreaderCatalogDashboardSectionResponse { export interface KoreaderCatalogManifestFile { id: number; format: string; + downloadVariant: "original" | "audioless_epub"; sizeBytes: number | null; contentVersion: string; fileHash: string | null; diff --git a/server/src/common/utils/primary-file-selection.utils.test.ts b/server/src/common/utils/primary-file-selection.utils.test.ts new file mode 100644 index 000000000..954e312c4 --- /dev/null +++ b/server/src/common/utils/primary-file-selection.utils.test.ts @@ -0,0 +1,21 @@ +import { selectPrimaryFile } from './primary-file-selection.utils'; + +describe('selectPrimaryFile', () => { + const plainEpub = { id: 1, format: 'epub', sizeBytes: 100, mediaOverlayAvailable: false }; + const readAlongEpub = { id: 2, format: 'epub', sizeBytes: 200, mediaOverlayAvailable: true }; + const audiobook = { id: 3, format: 'm4b', sizeBytes: 300, mediaOverlayAvailable: false }; + + it('prefers a read-aloud EPUB over a plain EPUB of the same preferred format', () => { + expect(selectPrimaryFile([plainEpub, readAlongEpub, audiobook], ['epub', 'm4b'])).toBe(readAlongEpub); + }); + + it('still honors format priority before read-aloud capability', () => { + expect(selectPrimaryFile([plainEpub, readAlongEpub, audiobook], ['m4b', 'epub'])).toBe(audiobook); + }); + + it('rejects zero-byte candidates unless fallback is explicitly enabled', () => { + const empty = { ...readAlongEpub, sizeBytes: 0 }; + expect(selectPrimaryFile([empty], ['epub'])).toBeNull(); + expect(selectPrimaryFile([empty], ['epub'], { allowZeroByteFallback: true })).toBe(empty); + }); +}); diff --git a/server/src/common/utils/primary-file-selection.utils.ts b/server/src/common/utils/primary-file-selection.utils.ts new file mode 100644 index 000000000..43af705d4 --- /dev/null +++ b/server/src/common/utils/primary-file-selection.utils.ts @@ -0,0 +1,28 @@ +export type PrimaryFileCandidate = { + id: number; + format: string | null; + sizeBytes: number | null; + mediaOverlayAvailable?: boolean | null; +}; + +export function selectPrimaryFile( + files: readonly T[], + formatPriority: readonly string[], + options: { + allowZeroByteFallback?: boolean; + } = {}, +): T | null { + if (files.length === 0) return null; + + const nonEmpty = files.filter((file) => (file.sizeBytes ?? 0) > 0); + const pool = nonEmpty.length > 0 ? nonEmpty : options.allowZeroByteFallback ? [...files] : []; + if (pool.length === 0) return null; + + const normalizedPriority = formatPriority.map((format) => format.toLowerCase()); + const preferredFormat = normalizedPriority.find((format) => pool.some((file) => file.format?.toLowerCase() === format)); + const formatPool = preferredFormat ? pool.filter((file) => file.format?.toLowerCase() === preferredFormat) : pool; + const first = formatPool[0] ?? null; + if (first?.format?.toLowerCase() !== 'epub') return first; + + return formatPool.find((file) => file.mediaOverlayAvailable === true) ?? first; +} diff --git a/server/src/modules/audiobook/audiobook.service.test.ts b/server/src/modules/audiobook/audiobook.service.test.ts index 8136dcbc6..1386406c7 100644 --- a/server/src/modules/audiobook/audiobook.service.test.ts +++ b/server/src/modules/audiobook/audiobook.service.test.ts @@ -82,6 +82,7 @@ function makeFixture() { }, }), autoUpdateReadStatusForProgress: vi.fn().mockResolvedValue(undefined), + syncEbookProgressForAudiobookPlayback: vi.fn().mockResolvedValue(undefined), }; return { repo, @@ -139,6 +140,74 @@ describe('AudiobookService', () => { expect.objectContaining({ currentFileId: 12, positionSeconds: 5, percentage: saved.percentage }), ); expect(bookService.autoUpdateReadStatusForProgress).toHaveBeenCalledWith(7, { bookId: 30, libraryId: 4 }, saved.percentage, {}); + expect(bookService.syncEbookProgressForAudiobookPlayback).toHaveBeenCalledWith( + makeUser(), + 30, + 12, + 5, + saved.percentage, + new Date('2026-02-01T00:00:00.000Z'), + ); + }); + + it('syncs EPUB progress only after the revision-controlled write is accepted', async () => { + const { service, repo, bookService } = makeFixture(); + const manifest = await service.getManifest(30, makeUser()); + const dto = { + assetId: manifest.assets[0]!.assetId, + positionMs: 2_000, + capturedAt: '2026-02-01T00:00:00.000Z', + operationId: '77777777-7777-4777-8777-777777777777', + baseRevision: 0, + manifestRevision: manifest.revision, + }; + + repo.createPlaybackState.mockResolvedValue(null); + await expect(service.putPlaybackState(30, dto, makeUser())).rejects.toThrow(ConflictException); + expect(bookService.syncEbookProgressForAudiobookPlayback).not.toHaveBeenCalled(); + + repo.createPlaybackState.mockResolvedValue({ capturedAt: new Date(dto.capturedAt), revision: 1 }); + await service.putPlaybackState(30, dto, makeUser()); + + expect(bookService.syncEbookProgressForAudiobookPlayback).toHaveBeenCalledTimes(1); + expect(bookService.syncEbookProgressForAudiobookPlayback.mock.invocationCallOrder[0]!).toBeGreaterThan( + repo.createPlaybackState.mock.invocationCallOrder[1]!, + ); + }); + + it('retries EPUB sync for an idempotent replay without writing a second audiobook revision', async () => { + const { service, repo, bookService } = makeFixture(); + const manifest = await service.getManifest(30, makeUser()); + const capturedAt = new Date('2026-02-01T00:00:00.000Z'); + repo.findPlaybackState.mockResolvedValue({ + userId: 7, + bookId: 30, + currentFileId: 11, + positionSeconds: 2, + percentage: 6.63, + capturedAt, + revision: 4, + operationId: '88888888-8888-4888-8888-888888888888', + manifestRevision: manifest.revision, + }); + + const result = await service.putPlaybackState( + 30, + { + assetId: manifest.assets[0]!.assetId, + positionMs: 2_000, + capturedAt: capturedAt.toISOString(), + operationId: '88888888-8888-4888-8888-888888888888', + baseRevision: 3, + manifestRevision: manifest.revision, + }, + makeUser(), + ); + + expect(result).toMatchObject({ revision: 4, positionMs: 2_000 }); + expect(repo.createPlaybackState).not.toHaveBeenCalled(); + expect(repo.updatePlaybackState).not.toHaveBeenCalled(); + expect(bookService.syncEbookProgressForAudiobookPlayback).toHaveBeenCalledWith(makeUser(), 30, 11, 2, 6.63, capturedAt); }); it('rejects stale manifests, unknown assets, and revision conflicts', async () => { diff --git a/server/src/modules/audiobook/audiobook.service.ts b/server/src/modules/audiobook/audiobook.service.ts index a3fbbbf1b..c72245397 100644 --- a/server/src/modules/audiobook/audiobook.service.ts +++ b/server/src/modules/audiobook/audiobook.service.ts @@ -80,7 +80,16 @@ export class AudiobookService { const previous = await this.repo.findPlaybackState(user.id, bookId); if (previous?.operationId === dto.operationId) { - return (await this.getPlaybackState(bookId, user))!; + const existing = (await this.getPlaybackState(bookId, user))!; + await this.bookService.syncEbookProgressForAudiobookPlayback( + user, + bookId, + previous.currentFileId, + previous.positionSeconds, + previous.percentage, + previous.capturedAt, + ); + return existing; } const asset = context.manifest.assets[fileIndex]!; @@ -112,6 +121,14 @@ export class AudiobookService { percentage, strongRereadEvidence ? { origin: 'bookorbit', strongRereadEvidence: true } : {}, ); + await this.bookService.syncEbookProgressForAudiobookPlayback( + user, + bookId, + values.currentFileId, + values.positionSeconds, + percentage, + saved.capturedAt, + ); return { assetId: dto.assetId, positionMs: dto.positionMs, diff --git a/server/src/modules/book/audiobook-ebook-progress-sync.service.test.ts b/server/src/modules/book/audiobook-ebook-progress-sync.service.test.ts new file mode 100644 index 000000000..74922b835 --- /dev/null +++ b/server/src/modules/book/audiobook-ebook-progress-sync.service.test.ts @@ -0,0 +1,262 @@ +import { stat } from 'node:fs/promises'; + +import { buildEpubMediaOverlayPlaylistFromFile } from '../reader/epub/epub-media-overlay'; +import { AudiobookEbookProgressSyncService } from './audiobook-ebook-progress-sync.service'; + +vi.mock('node:fs/promises', () => ({ stat: vi.fn() })); +vi.mock('../reader/epub/epub-media-overlay', () => ({ buildEpubMediaOverlayPlaylistFromFile: vi.fn() })); + +const mockStat = vi.mocked(stat); +const mockBuildPlaylist = vi.mocked(buildEpubMediaOverlayPlaylistFromFile); + +const SOURCE_TIME = new Date('2026-09-19T12:00:00.000Z'); + +function makeFiles(audioDuration = 100) { + return { + primaryFileId: 20, + files: [ + { + id: 10, + absolutePath: '/library/audio.mp3', + format: 'mp3', + role: 'content', + sortOrder: 0, + durationSeconds: audioDuration, + mediaOverlayAvailable: false, + mediaOverlayDurationSeconds: null, + }, + { + id: 20, + absolutePath: '/library/standard.epub', + format: 'epub', + role: 'content', + sortOrder: 1, + durationSeconds: null, + mediaOverlayAvailable: false, + mediaOverlayDurationSeconds: null, + }, + { + id: 30, + absolutePath: '/library/read-along.epub', + format: 'epub', + role: 'content', + sortOrder: 2, + durationSeconds: null, + mediaOverlayAvailable: true, + mediaOverlayDurationSeconds: 100, + }, + ], + }; +} + +function makePlaylist() { + return { + bookId: 5, + fileId: 30, + durationSeconds: 100, + sections: [], + resources: [], + items: [ + { + index: 0, + sectionIndex: 0, + smilHref: 'OPS/chapter.smil', + textHref: 'OPS/chapter.xhtml', + textFragment: 'first', + audioHref: 'OPS/audio.mp3', + audioMimeType: 'audio/mpeg', + clipBeginSeconds: 0, + clipEndSeconds: 50, + durationSeconds: 50, + label: null, + }, + { + index: 1, + sectionIndex: 0, + smilHref: 'OPS/chapter.smil', + textHref: 'OPS/chapter.xhtml', + textFragment: 'second', + audioHref: 'OPS/audio.mp3', + audioMimeType: 'audio/mpeg', + clipBeginSeconds: 50, + clipEndSeconds: 100, + durationSeconds: 50, + label: null, + }, + ], + }; +} + +function makeFixture(audioDuration = 100) { + const bookRepo = { + findReadAloudSyncMode: vi.fn().mockResolvedValue('auto'), + findAudioEbookProgressSyncFiles: vi.fn().mockResolvedValue(makeFiles(audioDuration)), + upsertSyncedEpubProgressIfNewer: vi.fn().mockResolvedValue(true), + syncKoboReadingStateFromProgress: vi.fn().mockResolvedValue(undefined), + upsertAudioProgress: vi.fn().mockResolvedValue({ revision: 2 }), + }; + const positionConverter = { + fragmentToPositions: vi.fn(({ bookFileId, fragment }: { bookFileId: number; fragment: string }) => + Promise.resolve({ status: 'exact', cfi: `epubcfi(${bookFileId}-${fragment})`, koreaderProgress: `/body/${bookFileId}/${fragment}` }), + ), + nearestFragmentForPosition: vi.fn().mockResolvedValue({ status: 'exact', fragment: 'second', chapterIndex: 0 }), + }; + return { + bookRepo, + positionConverter, + service: new AudiobookEbookProgressSyncService(bookRepo as never, positionConverter as never), + }; +} + +describe('AudiobookEbookProgressSyncService', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStat.mockResolvedValue({ mtimeMs: 1234 } as never); + mockBuildPlaylist.mockResolvedValue(makePlaylist()); + }); + + it('maps accepted audiobook progress to both the normal and read-along EPUB', async () => { + const { service, bookRepo, positionConverter } = makeFixture(); + + await expect( + service.syncFromAudioProgress({ + userId: 7, + bookId: 5, + currentFileId: 10, + positionSeconds: 75, + percentage: 75, + syncKobo: true, + sourceUpdatedAt: SOURCE_TIME, + }), + ).resolves.toBe(true); + + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledTimes(2); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ userId: 7, fileId: 20, percentage: 75, positionSeconds: 75, sourceUpdatedAt: SOURCE_TIME }), + ); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ userId: 7, fileId: 30, percentage: 75, positionSeconds: 75, sourceUpdatedAt: SOURCE_TIME }), + ); + expect(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(7, 20, 75, null, null, null, null); + expect(positionConverter.fragmentToPositions).toHaveBeenCalledWith( + expect.objectContaining({ bookFileId: 20, sourceBookFileId: 30, fragment: 'second' }), + ); + }); + + it('does not replace or forward a newer EPUB position', async () => { + const { service, bookRepo } = makeFixture(); + bookRepo.upsertSyncedEpubProgressIfNewer.mockResolvedValue(false); + + await expect( + service.syncFromAudioProgress({ + userId: 7, + bookId: 5, + currentFileId: 10, + positionSeconds: 25, + percentage: 25, + syncKobo: true, + sourceUpdatedAt: SOURCE_TIME, + }), + ).resolves.toBe(false); + + expect(bookRepo.syncKoboReadingStateFromProgress).not.toHaveBeenCalled(); + }); + + it('maps an EPUB position to revision-safe audiobook progress and the sibling EPUB', async () => { + const { service, bookRepo, positionConverter } = makeFixture(); + + await expect( + service.syncFromEbookProgress({ + userId: 7, + bookId: 5, + bookFileId: 20, + percentage: 75, + cfi: 'epubcfi(/6/4)', + sourceUpdatedAt: SOURCE_TIME, + }), + ).resolves.toBe(true); + + expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 50, 50, SOURCE_TIME); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).toHaveBeenCalledWith( + expect.objectContaining({ fileId: 30, percentage: 50, positionSeconds: 50, sourceUpdatedAt: SOURCE_TIME }), + ); + expect(positionConverter.nearestFragmentForPosition).toHaveBeenCalledWith(expect.objectContaining({ bookFileId: 20, sourceBookFileId: 30 })); + }); + + it('stops sibling propagation when a newer audiobook state rejects the source write', async () => { + const { service, bookRepo } = makeFixture(); + bookRepo.upsertAudioProgress.mockResolvedValue(undefined); + + await expect( + service.syncFromEbookProgress({ + userId: 7, + bookId: 5, + bookFileId: 20, + percentage: 75, + cfi: 'epubcfi(/6/4)', + sourceUpdatedAt: SOURCE_TIME, + }), + ).resolves.toBe(false); + + expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled(); + }); + + it('syncs narration to the audiobook without moving a sibling text position', async () => { + const { service, bookRepo } = makeFixture(); + + await expect( + service.syncFromEbookProgress({ + userId: 7, + bookId: 5, + bookFileId: 30, + percentage: 32, + positionSeconds: 10, + mediaOverlayFragment: 'OPS/chapter.xhtml#first', + mediaOverlaySectionIndex: 0, + sourceUpdatedAt: SOURCE_TIME, + syncSiblingEpubs: false, + }), + ).resolves.toBe(true); + + expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(7, 5, 10, 10, 10, SOURCE_TIME); + expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled(); + }); + + it('rejects mapping when audiobook and overlay durations exceed tolerance', async () => { + const { service, bookRepo } = makeFixture(120); + + await expect( + service.syncFromAudioProgress({ + userId: 7, + bookId: 5, + currentFileId: 10, + positionSeconds: 50, + percentage: 50, + syncKobo: false, + }), + ).resolves.toBe(false); + + expect(bookRepo.upsertSyncedEpubProgressIfNewer).not.toHaveBeenCalled(); + }); + + it('does no mapping work when read-aloud sync is disabled', async () => { + const { service, bookRepo } = makeFixture(); + bookRepo.findReadAloudSyncMode.mockResolvedValue('disabled'); + + await expect( + service.syncFromAudioProgress({ + userId: 7, + bookId: 5, + currentFileId: 10, + positionSeconds: 50, + percentage: 50, + syncKobo: false, + }), + ).resolves.toBe(false); + + expect(bookRepo.findAudioEbookProgressSyncFiles).not.toHaveBeenCalled(); + expect(mockBuildPlaylist).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/modules/book/audiobook-ebook-progress-sync.service.ts b/server/src/modules/book/audiobook-ebook-progress-sync.service.ts index 51c4fe752..e747cbab4 100644 --- a/server/src/modules/book/audiobook-ebook-progress-sync.service.ts +++ b/server/src/modules/book/audiobook-ebook-progress-sync.service.ts @@ -26,6 +26,10 @@ type PlaylistCacheEntry = { }; type SyncResolution = { + targets: EpubProgressResolution[]; +}; + +type EpubProgressResolution = { targetFile: SyncFile; cfi: string; koreaderProgress: string | null; @@ -39,6 +43,7 @@ type AudioProgressResolution = { currentFileId: number; positionSeconds: number; percentage: number; + siblingTargets: EpubProgressResolution[]; }; @Injectable() @@ -58,6 +63,7 @@ export class AudiobookEbookProgressSyncService { positionSeconds: number; percentage: number; syncKobo: boolean; + sourceUpdatedAt?: Date; }): Promise { const startedAt = Date.now(); try { @@ -66,24 +72,31 @@ export class AudiobookEbookProgressSyncService { const resolution = await this.resolveProgress(params); if (!resolution) return false; - await this.bookRepo.upsertProgress( - params.userId, - resolution.targetFile.id, - resolution.cfi, - null, - resolution.percentage, - resolution.positionSeconds, - resolution.mediaOverlayFragment, - resolution.mediaOverlaySectionIndex, - null, - null, - null, - null, - resolution.koreaderProgress, - ); + const sourceUpdatedAt = params.sourceUpdatedAt ?? new Date(); + const acceptedTargets: EpubProgressResolution[] = []; + for (const target of resolution.targets) { + if ( + await this.bookRepo.upsertSyncedEpubProgressIfNewer({ + userId: params.userId, + fileId: target.targetFile.id, + cfi: target.cfi, + percentage: target.percentage, + positionSeconds: target.positionSeconds, + mediaOverlayFragment: target.mediaOverlayFragment, + mediaOverlaySectionIndex: target.mediaOverlaySectionIndex, + koreaderProgress: target.koreaderProgress, + sourceUpdatedAt, + }) + ) { + acceptedTargets.push(target); + } + } + + if (acceptedTargets.length === 0) return false; if (params.syncKobo) { - await this.bookRepo.syncKoboReadingStateFromProgress(params.userId, resolution.targetFile.id, resolution.percentage, null, null, null, null); + const target = acceptedTargets[0]!; + await this.bookRepo.syncKoboReadingStateFromProgress(params.userId, target.targetFile.id, target.percentage, null, null, null, null); } return true; @@ -109,6 +122,8 @@ export class AudiobookEbookProgressSyncService { positionSeconds?: number | null; mediaOverlayFragment?: string | null; mediaOverlaySectionIndex?: number | null; + sourceUpdatedAt?: Date; + syncSiblingEpubs?: boolean; }): Promise { const startedAt = Date.now(); try { @@ -117,13 +132,30 @@ export class AudiobookEbookProgressSyncService { const resolution = await this.resolveAudioProgress(params); if (!resolution) return false; - await this.bookRepo.upsertAudioProgress( + const sourceUpdatedAt = params.sourceUpdatedAt ?? new Date(); + const saved = await this.bookRepo.upsertAudioProgress( params.userId, params.bookId, resolution.currentFileId, resolution.positionSeconds, resolution.percentage, + sourceUpdatedAt, ); + if (!saved) return false; + + for (const target of params.syncSiblingEpubs === false ? [] : resolution.siblingTargets) { + await this.bookRepo.upsertSyncedEpubProgressIfNewer({ + userId: params.userId, + fileId: target.targetFile.id, + cfi: target.cfi, + percentage: target.percentage, + positionSeconds: target.positionSeconds, + mediaOverlayFragment: target.mediaOverlayFragment, + mediaOverlaySectionIndex: target.mediaOverlaySectionIndex, + koreaderProgress: target.koreaderProgress, + sourceUpdatedAt, + }); + } return true; } catch (error: unknown) { const errorClass = error instanceof Error ? error.constructor.name : 'UnknownError'; @@ -153,7 +185,6 @@ export class AudiobookEbookProgressSyncService { const overlaySourceFile = this.selectOverlaySourceFile(syncFiles.files, syncFiles.primaryFileId); if (!overlaySourceFile) return null; - const targetFile = this.selectTargetFile(syncFiles.files, syncFiles.primaryFileId, overlaySourceFile); const audioAbsoluteSeconds = this.computeAudioAbsoluteSeconds(audioFiles, currentAudioIndex, params.positionSeconds); const audioTotalSeconds = this.computeAudioTotalSeconds(audioFiles); if (audioAbsoluteSeconds === null || audioTotalSeconds === null) return null; @@ -167,19 +198,25 @@ export class AudiobookEbookProgressSyncService { const item = this.findItemBySeconds(playlist, overlaySeconds); if (!item?.textFragment) return null; - const targetPositions = await this.resolveFragmentPositions(targetFile, item); - const resolved = - targetPositions ?? (targetFile.id !== overlaySourceFile.id ? await this.resolveFragmentPositions(overlaySourceFile, item) : null); - if (!resolved) return null; + const targetFile = this.selectTargetFile(syncFiles.files, syncFiles.primaryFileId, overlaySourceFile); + const epubFiles = syncFiles.files.filter((file) => file.format?.toLowerCase() === 'epub'); + const orderedTargets = [targetFile, ...epubFiles.filter((file) => file.id !== targetFile.id)]; + const targets: EpubProgressResolution[] = []; + for (const candidate of orderedTargets) { + const resolved = await this.resolveFragmentPositions(candidate, item, overlaySourceFile.id); + if (!resolved) continue; + targets.push({ + ...resolved, + percentage: this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100), + positionSeconds: overlaySeconds, + mediaOverlayFragment: this.itemFragment(item), + mediaOverlaySectionIndex: item.sectionIndex, + }); + } + if (targets.length === 0) return null; return { - targetFile: resolved.targetFile, - cfi: resolved.cfi, - koreaderProgress: resolved.koreaderProgress, - percentage: this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100), - positionSeconds: overlaySeconds, - mediaOverlayFragment: this.itemFragment(item), - mediaOverlaySectionIndex: item.sectionIndex, + targets, }; } @@ -208,7 +245,7 @@ export class AudiobookEbookProgressSyncService { const playlist = await this.getPlaylist(overlaySourceFile, params.bookId); if (playlist.items.length === 0 || playlist.durationSeconds == null || playlist.durationSeconds <= 0) return null; - const overlaySeconds = await this.resolveOverlaySecondsFromEbookPosition(ebookFile, playlist, params); + const overlaySeconds = await this.resolveOverlaySecondsFromEbookPosition(ebookFile, overlaySourceFile.id, playlist, params); if (overlaySeconds === null) return null; const audioSeconds = this.mapOverlaySecondsToAudioSeconds(overlaySeconds, playlist.durationSeconds, audioTotalSeconds); @@ -217,10 +254,28 @@ export class AudiobookEbookProgressSyncService { const filePosition = this.audioFilePositionForSeconds(audioFiles, audioSeconds); if (!filePosition) return null; + const item = this.findItemBySeconds(playlist, overlaySeconds); + const siblingTargets: EpubProgressResolution[] = []; + if (item?.textFragment) { + for (const candidate of syncFiles.files) { + if (candidate.id === ebookFile.id || candidate.format?.toLowerCase() !== 'epub') continue; + const resolved = await this.resolveFragmentPositions(candidate, item, overlaySourceFile.id); + if (!resolved) continue; + siblingTargets.push({ + ...resolved, + percentage: this.clampPercentage((overlaySeconds / playlist.durationSeconds) * 100), + positionSeconds: overlaySeconds, + mediaOverlayFragment: this.itemFragment(item), + mediaOverlaySectionIndex: item.sectionIndex, + }); + } + } + return { currentFileId: filePosition.file.id, positionSeconds: filePosition.positionSeconds, percentage: this.clampPercentage((audioSeconds / audioTotalSeconds) * 100), + siblingTargets, }; } @@ -274,6 +329,7 @@ export class AudiobookEbookProgressSyncService { private async resolveOverlaySecondsFromEbookPosition( ebookFile: SyncFile, + overlaySourceFileId: number, playlist: EpubMediaOverlayPlaylist, params: { cfi?: string | null; @@ -305,8 +361,9 @@ export class AudiobookEbookProgressSyncService { cfi: params.cfi ?? null, xpointer: params.koreaderProgress ?? null, candidates, + sourceBookFileId: overlaySourceFileId, }); - if (nearest.status === 'exact' && nearest.fragment) { + if (nearest.status !== 'failed' && nearest.fragment) { const match = this.findItemStartByFragmentId(playlist, nearest.fragment, nearest.chapterIndex ?? null); if (match) return match.startSeconds; } @@ -382,12 +439,14 @@ export class AudiobookEbookProgressSyncService { private async resolveFragmentPositions( targetFile: SyncFile, item: EpubMediaOverlayPlaylistItem, + sourceBookFileId: number, ): Promise<{ targetFile: SyncFile; cfi: string; koreaderProgress: string | null } | null> { if (!item.textFragment) return null; const outcome = await this.positionConverter.fragmentToPositions({ bookFileId: targetFile.id, chapterIndex: item.sectionIndex, fragment: item.textFragment, + sourceBookFileId, }); if (outcome.status === 'failed' || !outcome.cfi) return null; return { targetFile, cfi: outcome.cfi, koreaderProgress: outcome.koreaderProgress ?? null }; diff --git a/server/src/modules/book/book.repository.test.ts b/server/src/modules/book/book.repository.test.ts index 0ae1b0dd2..755bde91b 100644 --- a/server/src/modules/book/book.repository.test.ts +++ b/server/src/modules/book/book.repository.test.ts @@ -658,12 +658,10 @@ describe('BookRepository', () => { }); it('reads and upserts audiobook progress', async () => { + const returning = vi.fn().mockResolvedValue([{ bookId: 10, percentage: 33 }]); + const onConflictDoUpdate = vi.fn().mockReturnValue({ returning }); const audioInsert = { - values: vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([{ bookId: 10, percentage: 33 }]), - }), - }), + values: vi.fn().mockReturnValue({ onConflictDoUpdate }), }; const db = { select: vi @@ -678,6 +676,106 @@ describe('BookRepository', () => { await expect(repo.findAudioProgress(1, 11)).resolves.toBeNull(); await expect(repo.upsertAudioProgress(1, 10, 4, 120, 33)).resolves.toEqual({ bookId: 10, percentage: 33 }); expect(db.insert).toHaveBeenCalledTimes(1); + expect(audioInsert.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + bookId: 10, + currentFileId: 4, + positionSeconds: 120, + percentage: 33, + capturedAt: expect.any(Date), + operationId: null, + manifestRevision: null, + updatedAt: expect.any(Date), + }), + ); + expect(onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ + currentFileId: 4, + positionSeconds: 120, + percentage: 33, + revision: expect.anything(), + capturedAt: expect.any(Date), + operationId: null, + manifestRevision: null, + updatedAt: expect.any(Date), + }), + setWhere: expect.anything(), + }), + ); + }); + + it('writes bridged EPUB progress only when the source is not older', async () => { + const returning = vi.fn().mockResolvedValue([{ fileId: 20 }]); + const onConflictDoUpdate = vi.fn().mockReturnValue({ returning }); + const values = vi.fn().mockReturnValue({ onConflictDoUpdate }); + const resetWhere = vi.fn().mockResolvedValue(undefined); + const db = { + insert: vi.fn().mockReturnValue({ values }), + delete: vi.fn().mockReturnValue({ where: resetWhere }), + }; + const repo = new BookRepository(db as never); + const sourceUpdatedAt = new Date('2026-09-19T12:00:00.000Z'); + + await expect( + repo.upsertSyncedEpubProgressIfNewer({ + userId: 7, + fileId: 20, + cfi: 'epubcfi(/6/4)', + percentage: 42, + positionSeconds: 120, + mediaOverlayFragment: 'OPS/chapter.xhtml#p2', + mediaOverlaySectionIndex: 1, + koreaderProgress: '/body/p[2]', + sourceUpdatedAt, + }), + ).resolves.toBe(true); + + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 7, + bookFileId: 20, + percentage: 42, + updatedAt: sourceUpdatedAt, + lastReadAt: sourceUpdatedAt, + textUpdatedAt: sourceUpdatedAt, + }), + ); + expect(onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ percentage: 42, updatedAt: sourceUpdatedAt }), + setWhere: expect.anything(), + }), + ); + expect(resetWhere).toHaveBeenCalledOnce(); + }); + + it('does not clear reset protection when a newer EPUB row rejects the bridge', async () => { + const returning = vi.fn().mockResolvedValue([]); + const db = { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockReturnValue({ returning }) }), + }), + delete: vi.fn(), + }; + const repo = new BookRepository(db as never); + + await expect( + repo.upsertSyncedEpubProgressIfNewer({ + userId: 7, + fileId: 20, + cfi: 'epubcfi(/6/4)', + percentage: 42, + positionSeconds: 120, + mediaOverlayFragment: 'OPS/chapter.xhtml#p2', + mediaOverlaySectionIndex: 1, + koreaderProgress: null, + sourceUpdatedAt: new Date('2026-09-19T12:00:00.000Z'), + }), + ).resolves.toBe(false); + + expect(db.delete).not.toHaveBeenCalled(); }); it('maps hasCover from coverSource and aggregates authors per book in recommendation rows', async () => { diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index 85a8d1128..61c4d0980 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -1,5 +1,5 @@ import { BadRequestException, Inject, Injectable, Optional } from '@nestjs/common'; -import { SQL, and, asc, count, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm'; +import { SQL, and, asc, count, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from 'drizzle-orm'; import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; @@ -1593,6 +1593,9 @@ export class BookRepository { .select({ id: bookFiles.id, role: bookFiles.role, + format: bookFiles.format, + sizeBytes: bookFiles.sizeBytes, + mediaOverlayAvailable: bookFiles.mediaOverlayAvailable, }) .from(bookFiles) .where(eq(bookFiles.bookId, bookId)); @@ -1681,14 +1684,41 @@ export class BookRepository { return row ?? null; } - async upsertAudioProgress(userId: number, bookId: number, currentFileId: number, positionSeconds: number, percentage: number) { + async upsertAudioProgress( + userId: number, + bookId: number, + currentFileId: number, + positionSeconds: number, + percentage: number, + sourceUpdatedAt = new Date(), + ) { const now = new Date(); const [row] = await this.db .insert(audiobookProgress) - .values({ userId, bookId, currentFileId, positionSeconds, percentage, updatedAt: now }) + .values({ + userId, + bookId, + currentFileId, + positionSeconds, + percentage, + capturedAt: sourceUpdatedAt, + operationId: null, + manifestRevision: null, + updatedAt: now, + }) .onConflictDoUpdate({ target: [audiobookProgress.userId, audiobookProgress.bookId], - set: { currentFileId, positionSeconds, percentage, updatedAt: now }, + set: { + currentFileId, + positionSeconds, + percentage, + revision: sql`${audiobookProgress.revision} + 1`, + capturedAt: sourceUpdatedAt, + operationId: null, + manifestRevision: null, + updatedAt: now, + }, + setWhere: lte(audiobookProgress.capturedAt, sourceUpdatedAt), }) .returning(); return row; @@ -2333,6 +2363,58 @@ export class BookRepository { await this.db.delete(koreaderProgressResets).where(and(eq(koreaderProgressResets.userId, userId), eq(koreaderProgressResets.bookFileId, fileId))); } + async upsertSyncedEpubProgressIfNewer(params: { + userId: number; + fileId: number; + cfi: string; + percentage: number; + positionSeconds: number; + mediaOverlayFragment: string; + mediaOverlaySectionIndex: number; + koreaderProgress: string | null; + sourceUpdatedAt: Date; + }): Promise { + const rows = await this.db + .insert(readingProgress) + .values({ + userId: params.userId, + bookFileId: params.fileId, + cfi: params.cfi, + pageNumber: null, + percentage: params.percentage, + positionSeconds: params.positionSeconds, + mediaOverlayFragment: params.mediaOverlayFragment, + mediaOverlaySectionIndex: params.mediaOverlaySectionIndex, + koreaderProgress: this.normalizeKoreaderProgress(params.koreaderProgress), + updatedAt: params.sourceUpdatedAt, + lastReadAt: params.sourceUpdatedAt, + textUpdatedAt: params.sourceUpdatedAt, + }) + .onConflictDoUpdate({ + target: [readingProgress.bookFileId, readingProgress.userId], + set: { + cfi: params.cfi, + pageNumber: null, + percentage: params.percentage, + positionSeconds: params.positionSeconds, + mediaOverlayFragment: params.mediaOverlayFragment, + mediaOverlaySectionIndex: params.mediaOverlaySectionIndex, + koreaderProgress: this.normalizeKoreaderProgress(params.koreaderProgress), + updatedAt: params.sourceUpdatedAt, + lastReadAt: params.sourceUpdatedAt, + textUpdatedAt: params.sourceUpdatedAt, + }, + setWhere: lte(readingProgress.updatedAt, params.sourceUpdatedAt), + }) + .returning({ fileId: readingProgress.bookFileId }); + + if (rows.length === 0) return false; + await this.db + .delete(koreaderProgressResets) + .where(and(eq(koreaderProgressResets.userId, params.userId), eq(koreaderProgressResets.bookFileId, params.fileId))); + return true; + } + async syncKoboReadingStateFromProgress( userId: number, fileId: number, diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index e69150c82..197a565e0 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -126,6 +126,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) { findKoboSyncCollectionNamesForBook: vi.fn(), findFileById: vi.fn(), updateBookFile: vi.fn().mockResolvedValue(undefined), + updateBookPrimaryFile: vi.fn().mockResolvedValue(undefined), findLibraryIdByBookId: vi.fn(), findProgress: vi.fn(), findProgressByBook: vi.fn(), @@ -5282,6 +5283,35 @@ describe('BookService', () => { expect(bookRepo.updateBookPrimaryFile).toHaveBeenCalledWith(10, 101); }); + it('uses library format priority and read-aloud capability when replacing a deleted primary', async () => { + const { service, bookRepo, libraryService } = makeService(); + const user = makeUser({ id: 1 }); + const fileId = 100; + const file = { absolutePath: '/path/to/old.epub', bookId: 10, libraryId: 1 }; + + bookRepo.findFileById = vi.fn().mockResolvedValue(file); + libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true); + libraryService.findOne.mockResolvedValue({ + readingThreshold: 1, + markAsFinishedPercentComplete: 99, + formatPriority: ['epub', 'm4b'], + }); + vi.mocked(rm).mockResolvedValue(undefined); + bookRepo.deleteBookFile = vi.fn().mockResolvedValue(undefined); + bookRepo.findFilesForBook = vi.fn().mockResolvedValue([ + { id: 100, role: 'content', format: 'epub', sizeBytes: 100, mediaOverlayAvailable: false }, + { id: 101, role: 'content', format: 'epub', sizeBytes: 100, mediaOverlayAvailable: false }, + { id: 102, role: 'content', format: 'epub', sizeBytes: 100, mediaOverlayAvailable: true }, + { id: 103, role: 'content', format: 'm4b', sizeBytes: 100, mediaOverlayAvailable: false }, + ]); + bookRepo.findBookBase = vi.fn().mockResolvedValue({ id: 10, primaryFileId: 100 }); + + await service.deleteFile(fileId, user); + + expect(libraryService.findOne).toHaveBeenCalledWith(1); + expect(bookRepo.updateBookPrimaryFile).toHaveBeenCalledWith(10, 102); + }); + it('preserves the database row and primary file when disk deletion fails', async () => { const { service, bookRepo } = makeService(); const fileId = 100; diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 3825bad2f..42218e4cd 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -17,6 +17,7 @@ import { bookCoverDirPath, bookThumbnailPath, findPreferredBookCoverFileName } f import { MAX_BOOK_QUERY_OFFSET_ROWS, isBookQueryOffsetWithinLimit } from '../../common/constants/pagination.constants'; import { resolveIsAudiobook } from '../../common/utils/book-media.utils'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; +import { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils'; import { normalizeMetadataText, normalizeMetadataTextKey } from '../../common/utils/metadata-text-normalize.utils'; import { naturalCompare } from '../../common/utils/natural-sort.utils'; import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils'; @@ -36,6 +37,7 @@ import { tmpdir } from 'os'; import { BOOK_METADATA_LOCK_FIELDS, DEFAULT_DOWNLOAD_PATTERN, + DEFAULT_FORMAT_PRIORITY, MetadataProviderKey, Permission, customSortFieldIds, @@ -1587,8 +1589,10 @@ export class BookService { // mark book as missing if no files left await this.bookRepo.updateBookPrimaryFile(file.bookId, null); } else if (wasPrimary) { - // pick the first remaining content file or just the first remaining - const newPrimary = remaining.find((f) => f.role === 'content') || remaining[0]; + const contentFiles = remaining.filter((candidate) => candidate.role === 'content'); + const library = await this.libraryService.findOne(file.libraryId); + const newPrimary = + selectPrimaryFile(contentFiles, library.formatPriority ?? DEFAULT_FORMAT_PRIORITY, { allowZeroByteFallback: true }) ?? remaining[0]; await this.bookRepo.updateBookPrimaryFile(file.bookId, newPrimary?.id ?? null); } @@ -2138,7 +2142,8 @@ export class BookService { throw new BadRequestException(`currentFileId ${dto.currentFileId} does not belong to book ${bookId}`); } const previous = await this.bookRepo.findAudioProgress(userId, bookId); - await this.bookRepo.upsertAudioProgress(userId, bookId, dto.currentFileId, dto.positionSeconds, dto.percentage); + const saved = await this.bookRepo.upsertAudioProgress(userId, bookId, dto.currentFileId, dto.positionSeconds, dto.percentage); + if (!saved) return; await this.audiobookEbookProgressSync?.syncFromAudioProgress({ userId, bookId, @@ -2146,6 +2151,7 @@ export class BookService { positionSeconds: dto.positionSeconds, percentage: dto.percentage, syncKobo: this.hasPermission(user, Permission.KoboSync), + sourceUpdatedAt: saved.capturedAt, }); const strongRereadEvidence = previous != null && previous.percentage - dto.percentage >= 10; await this.autoUpdateReadStatusForProgress( @@ -2156,6 +2162,25 @@ export class BookService { ); } + async syncEbookProgressForAudiobookPlayback( + user: RequestUser, + bookId: number, + currentFileId: number, + positionSeconds: number, + percentage: number, + sourceUpdatedAt: Date, + ): Promise { + await this.audiobookEbookProgressSync?.syncFromAudioProgress({ + userId: user.id, + bookId, + currentFileId, + positionSeconds, + percentage, + syncKobo: this.hasPermission(user, Permission.KoboSync), + sourceUpdatedAt, + }); + } + async autoUpdateReadStatusForProgress( userId: number, file: ProgressStatusFileContext, @@ -2261,6 +2286,8 @@ export class BookService { positionSeconds: dto.positionSeconds ?? null, mediaOverlayFragment: dto.mediaOverlayFragment ?? null, mediaOverlaySectionIndex: dto.mediaOverlaySectionIndex ?? null, + sourceUpdatedAt: now, + syncSiblingEpubs: text.moved, }); const strongRereadEvidence = previous != null && previous.percentage - text.percentage >= 10; await this.autoUpdateReadStatusForProgress( @@ -2282,6 +2309,8 @@ export class BookService { positionSeconds?: number | null; mediaOverlayFragment?: string | null; mediaOverlaySectionIndex?: number | null; + sourceUpdatedAt?: Date; + syncSiblingEpubs?: boolean; } = {}, ): Promise { await this.audiobookEbookProgressSync?.syncFromEbookProgress({ @@ -2294,6 +2323,8 @@ export class BookService { positionSeconds: locators.positionSeconds ?? null, mediaOverlayFragment: locators.mediaOverlayFragment ?? null, mediaOverlaySectionIndex: locators.mediaOverlaySectionIndex ?? null, + sourceUpdatedAt: locators.sourceUpdatedAt, + syncSiblingEpubs: locators.syncSiblingEpubs, }); } diff --git a/server/src/modules/kobo/services/kepub-conversion.service.test.ts b/server/src/modules/kobo/services/kepub-conversion.service.test.ts index 77e7eaa46..4324f83fa 100644 --- a/server/src/modules/kobo/services/kepub-conversion.service.test.ts +++ b/server/src/modules/kobo/services/kepub-conversion.service.test.ts @@ -71,7 +71,7 @@ describe('KepubConversionService', () => { // Same book and same file hash as the full archive; only the audioless flag separates them. await expect( service.getKepubPath({ sourcePath: '/tmp/kobo-epub/book.epub', fileHash: 'abc', bookId: 44, hyphenate: true, audioless: true }), - ).resolves.toBe('/app-data/.kepub-cache/44/abc-noaudio-hyph.kepub.epub'); + ).resolves.toBe('/app-data/.kepub-cache/44/abc-noaudio-v1-hyph.kepub.epub'); }); it('uses a stable nohash cache key when file hash is unavailable', async () => { diff --git a/server/src/modules/kobo/services/kepub-conversion.service.ts b/server/src/modules/kobo/services/kepub-conversion.service.ts index e3283e3c7..322ec0b8a 100644 --- a/server/src/modules/kobo/services/kepub-conversion.service.ts +++ b/server/src/modules/kobo/services/kepub-conversion.service.ts @@ -10,6 +10,7 @@ import { KepubifyBinaryService } from './kepubify-binary.service'; const execFileAsync = promisify(execFile); const KEPUBIFY_TIMEOUT_MS = 60_000; +const AUDIOLESS_EPUB_CACHE_VERSION = 1; interface KepubConversionInput { sourcePath: string; @@ -38,7 +39,7 @@ export class KepubConversionService { const cacheDir = join(this.kepubCachePath, String(input.bookId)); const fileHash = input.fileHash ?? 'nohash'; // Keep the original two key shapes byte for byte so existing cache entries still hit. - const cacheKey = `${fileHash}${input.audioless ? '-noaudio' : ''}${input.hyphenate ? '-hyph' : ''}`; + const cacheKey = `${fileHash}${input.audioless ? `-noaudio-v${AUDIOLESS_EPUB_CACHE_VERSION}` : ''}${input.hyphenate ? '-hyph' : ''}`; const cachedPath = join(cacheDir, `${cacheKey}.kepub.epub`); try { diff --git a/server/src/modules/kobo/services/kobo-reading-state.service.test.ts b/server/src/modules/kobo/services/kobo-reading-state.service.test.ts index 8c6840d64..b53f8f6ae 100644 --- a/server/src/modules/kobo/services/kobo-reading-state.service.test.ts +++ b/server/src/modules/kobo/services/kobo-reading-state.service.test.ts @@ -65,6 +65,7 @@ describe('KoboReadingStateService', () => { const achievementEvents = { emit: vi.fn() }; const analyticsResolver = { resolveBookFileId: vi.fn() }; const readingSessions = { recordCumulativeSyncedSession: vi.fn() }; + const bookService = { syncAudioProgressForExternalEbookProgress: vi.fn() }; function makeService(db: ReturnType) { return new KoboReadingStateService( @@ -77,6 +78,7 @@ describe('KoboReadingStateService', () => { achievementEvents as never, analyticsResolver as never, readingSessions as never, + bookService as never, ); } @@ -89,6 +91,7 @@ describe('KoboReadingStateService', () => { settingsService.getSettings.mockResolvedValue({ twoWayProgressSync: false }); analyticsResolver.resolveBookFileId.mockResolvedValue({ kind: 'resolved', bookFileId: 53 }); readingSessions.recordCumulativeSyncedSession.mockResolvedValue({ kind: 'baseline' }); + bookService.syncAudioProgressForExternalEbookProgress.mockResolvedValue(undefined); bookIdentityService.ensureForBook.mockImplementation((_userId: number, bookId: number) => ({ bookId, entitlementId: `entitlement-${bookId}`, @@ -353,6 +356,11 @@ describe('KoboReadingStateService', () => { }), ); expect(db.execute).toHaveBeenCalledTimes(1); + expect(bookService.syncAudioProgressForExternalEbookProgress).toHaveBeenCalledWith(1, 5, 55, 42.5, { + cfi: null, + koreaderProgress: null, + sourceUpdatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); }); it('does not treat ContentSourceProgressPercent as whole-book progress', async () => { @@ -474,6 +482,7 @@ describe('KoboReadingStateService', () => { await makeService(db).upsertState(1, 6, { CurrentBookmark: { LastModified: '2026-01-01T00:00:00.000Z', ProgressPercent: 60 } }, 1, 99, true, 77); expect(db.insert).toHaveBeenCalledTimes(1); + expect(bookService.syncAudioProgressForExternalEbookProgress).not.toHaveBeenCalled(); }); it('does not call autoUpdate when bookmark has no percent', async () => { diff --git a/server/src/modules/kobo/services/kobo-reading-state.service.ts b/server/src/modules/kobo/services/kobo-reading-state.service.ts index 996888ab2..aa457841d 100644 --- a/server/src/modules/kobo/services/kobo-reading-state.service.ts +++ b/server/src/modules/kobo/services/kobo-reading-state.service.ts @@ -8,6 +8,7 @@ import * as schema from '../../../db/schema'; import { UserBookStatusService } from '../../user-book-status/user-book-status.service'; import { ReadingSessionService } from '../../reading-session/reading-session.service'; import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../../achievement/achievement-events.service'; +import { BookService } from '../../book/book.service'; import { KOBO_STATISTICS_CURSOR_SOURCE, koboSourceDeviceKey, @@ -89,6 +90,7 @@ export class KoboReadingStateService { private readonly achievementEvents: AchievementEventsService, private readonly analyticsResolver: KoboAnalyticsResolverService, private readonly readingSessions: ReadingSessionService, + private readonly bookService: BookService, ) {} async upsertState( @@ -603,6 +605,12 @@ export class KoboReadingStateService { lastReadAt: sourceUpdatedAt, }, }); + + await this.bookService.syncAudioProgressForExternalEbookProgress(userId, bookId, primaryFile.fileId, percentage, { + cfi: nextCfi, + koreaderProgress: nextXpointer, + sourceUpdatedAt, + }); } private async markSnapshotBookUnsyncedForOtherDevices(userId: number, bookId: number, sourceDeviceId: number): Promise { diff --git a/server/src/modules/koreader/koreader-catalog.service.test.ts b/server/src/modules/koreader/koreader-catalog.service.test.ts index 9a7302e05..b0f1c7e4e 100644 --- a/server/src/modules/koreader/koreader-catalog.service.test.ts +++ b/server/src/modules/koreader/koreader-catalog.service.test.ts @@ -9,7 +9,7 @@ vi.mock('fs/promises', () => ({ import { createReadStream } from 'fs'; import { join } from 'path'; import { stat } from 'fs/promises'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { DEFAULT_KOREADER_DEVICE_PATTERN } from '@bookorbit/types'; import type { MockedFunction } from 'vitest'; @@ -72,6 +72,7 @@ function makeDetail(overrides: Record = {}) { createdAt: new Date('2026-01-01T00:00:00.000Z'), filename: 'dune.epub', durationSeconds: null, + mediaOverlay: null, }, ], lastWrittenAt: null, @@ -105,6 +106,7 @@ function makeManifestRow(overrides: Record = {}) { { id: 100, format: 'EPUB', + mediaOverlayAvailable: false, sizeBytes: 1234, fileHash: 'abcdef0123456789', filename: 'dune.epub', @@ -720,6 +722,7 @@ describe('KoreaderCatalogService', () => { id: 100, format: 'epub', role: 'primary', + downloadVariant: 'original', sizeBytes: 1234, durationSeconds: null, downloadUrl: '/api/v1/koreader/plugin/catalog/files/100/download', @@ -997,6 +1000,7 @@ describe('KoreaderCatalogService', () => { expect(file).toMatchObject({ id: 100, format: 'epub', + downloadVariant: 'original', sizeBytes: 1234, fileHash: 'abcdef0123456789', contentVersion: '2026-02-01T00:00:00.000Z', @@ -1006,6 +1010,98 @@ describe('KoreaderCatalogService', () => { expect(JSON.stringify(result)).not.toContain('absolutePath'); }); + it('marks read-along EPUBs as derived and does not publish metadata for the original bytes', async () => { + const { service, opdsBookService } = makeService(); + opdsBookService.getBookManifestPage.mockResolvedValueOnce({ + rows: [ + makeManifestRow({ + files: [ + { + id: 100, + format: 'epub', + mediaOverlayAvailable: false, + sizeBytes: 1234, + fileHash: 'standard-hash', + filename: 'dune.epub', + contentVersion: new Date('2026-02-01T00:00:00.000Z'), + }, + { + id: 101, + format: 'epub', + mediaOverlayAvailable: true, + sizeBytes: 9_999_999, + fileHash: 'audio-archive-hash', + filename: 'dune-read-along.epub', + contentVersion: new Date('2026-02-02T00:00:00.000Z'), + }, + ], + }), + ], + hasNext: false, + }); + + const result = await service.getBulkManifest(makeUser({ id: 7 }), makeQuery()); + + expect(result.items[0]!.files).toEqual([ + expect.objectContaining({ id: 100, downloadVariant: 'original', sizeBytes: 1234, fileHash: 'standard-hash' }), + expect.objectContaining({ + id: 101, + downloadVariant: 'audioless_epub', + sizeBytes: null, + fileHash: null, + devicePath: 'Series/Dune/01.00 - Dune - Read Along.epub', + }), + ]); + }); + + it('keeps detail and manifest variants and collision-safe paths identical', async () => { + const files = [ + { + id: 100, + format: 'epub', + role: 'primary', + sizeBytes: 1234, + absolutePath: '/books/dune.epub', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + filename: 'dune.epub', + durationSeconds: null, + mediaOverlay: null, + }, + { + id: 101, + format: 'epub', + role: 'content', + sizeBytes: 9_999_999, + absolutePath: '/books/dune-read-along.epub', + createdAt: new Date('2026-01-02T00:00:00.000Z'), + filename: 'dune-read-along.epub', + durationSeconds: null, + mediaOverlay: { available: true, durationSeconds: 100 }, + }, + ]; + const manifestFiles = files.map((file) => ({ + id: file.id, + format: file.format, + mediaOverlayAvailable: file.mediaOverlay?.available === true, + sizeBytes: file.sizeBytes, + fileHash: `${file.id}-hash`, + filename: file.filename, + contentVersion: file.createdAt, + })); + const { service, opdsBookService, bookService } = makeService(); + opdsBookService.getBookManifestPage.mockResolvedValueOnce({ rows: [makeManifestRow({ files: manifestFiles })], hasNext: false }); + bookService.getDetail.mockResolvedValueOnce(makeDetail({ files })); + + const [manifest, detail] = await Promise.all([ + service.getBulkManifest(makeUser({ id: 7 }), makeQuery()), + service.getBookDetail(makeUser({ id: 7 }), 10), + ]); + + expect(detail.files.map(({ id, downloadVariant, sizeBytes, devicePath }) => ({ id, downloadVariant, sizeBytes, devicePath }))).toEqual( + manifest.items[0]!.files.map(({ id, downloadVariant, sizeBytes, devicePath }) => ({ id, downloadVariant, sizeBytes, devicePath })), + ); + }); + it('scopes the query to the requesting user and forwards the filter', async () => { const { service, opdsBookService } = makeService(); const user = makeUser({ id: 7 }); @@ -1265,17 +1361,16 @@ describe('KoreaderCatalogService', () => { expect(reply.type).toHaveBeenCalledWith('application/epub+zip'); }); - it('falls back to the original EPUB when the audioless rebuild fails', async () => { + it('does not expose the original audio archive when the audioless rebuild fails', async () => { const { service, bookService } = makeService(); bookService.verifyFileAccess.mockResolvedValueOnce(readAlongFile); bookService.createAudiolessEpubDownload.mockRejectedValueOnce(new Error('corrupt central directory')); const reply = makeReply(); - await service.streamFile(makeUser({ id: 7 }), 100, reply as never); + await expect(service.streamFile(makeUser({ id: 7 }), 100, reply as never)).rejects.toBeInstanceOf(InternalServerErrorException); - expect(mockCreateReadStream).toHaveBeenCalledWith('/books/dune.epub'); - expect(reply.header).toHaveBeenCalledWith('Content-Length', 1234); - expect(reply.send).toHaveBeenCalled(); + expect(mockCreateReadStream).not.toHaveBeenCalledWith('/books/dune.epub'); + expect(reply.send).not.toHaveBeenCalled(); }); it('serves the stored file untouched when there is no media overlay', async () => { diff --git a/server/src/modules/koreader/koreader-catalog.service.ts b/server/src/modules/koreader/koreader-catalog.service.ts index 57d6fb158..3bac810d8 100644 --- a/server/src/modules/koreader/koreader-catalog.service.ts +++ b/server/src/modules/koreader/koreader-catalog.service.ts @@ -3,7 +3,7 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import { basename } from 'path'; -import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import type { FastifyReply } from 'fastify'; @@ -357,37 +357,41 @@ export class KoreaderCatalogService { private mapManifestBook(row: OpdsManifestBookRow, pattern: string, sanitizeForCrossPlatform: boolean): KoreaderCatalogManifestBook { const files = row.files.map((file) => { const extension = this.normalizeFormat(file.format); + const downloadVariant = extension === 'epub' && file.mediaOverlayAvailable ? 'audioless_epub' : 'original'; + const hasSameFormatSibling = row.files.some((candidate) => candidate.id !== file.id && this.normalizeFormat(candidate.format) === extension); + const devicePath = + resolveUploadPath( + pattern, + buildPatternTokens({ + metadata: { + title: row.title, + subtitle: row.subtitle, + publisher: row.publisher, + language: row.language, + isbn13: row.isbn13 ?? row.isbn10, + publishedYear: row.publishedYear, + seriesName: row.seriesName, + seriesIndex: row.seriesIndex, + }, + authors: row.authors, + originalStem: basename(file.filename ?? row.title, `.${extension}`), + format: extension, + libraryName: row.libraryName, + }), + extension, + { sanitizeForCrossPlatform }, + ) ?? + file.filename ?? + `${row.title}.${extension}`; return { id: file.id, format: extension, - sizeBytes: file.sizeBytes, + downloadVariant, + sizeBytes: downloadVariant === 'audioless_epub' ? null : file.sizeBytes, contentVersion: file.contentVersion.toISOString(), - fileHash: file.fileHash, + fileHash: downloadVariant === 'audioless_epub' ? null : file.fileHash, downloadUrl: `${CATALOG_BASE}/files/${file.id}/download`, - devicePath: - resolveUploadPath( - pattern, - buildPatternTokens({ - metadata: { - title: row.title, - subtitle: row.subtitle, - publisher: row.publisher, - language: row.language, - isbn13: row.isbn13 ?? row.isbn10, - publishedYear: row.publishedYear, - seriesName: row.seriesName, - seriesIndex: row.seriesIndex, - }, - authors: row.authors, - originalStem: basename(file.filename ?? row.title, `.${extension}`), - format: extension, - libraryName: row.libraryName, - }), - extension, - { sanitizeForCrossPlatform }, - ) ?? - file.filename ?? - `${row.title}.${extension}`, + devicePath: this.variantDevicePath(devicePath, downloadVariant, hasSameFormatSibling), }; }); @@ -518,8 +522,9 @@ export class KoreaderCatalogService { const errorClass = err instanceof Error ? err.name : 'Error'; const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err)); this.logger.warn( - `[koreader.stream_file] [fail] fileId=${fileId} userId=${user.id} errorClass=${errorClass} error="${errorMessage}" - audioless rebuild failed, serving original EPUB`, + `[koreader.stream_file] [fail] fileId=${fileId} userId=${user.id} errorClass=${errorClass} error="${errorMessage}" - audioless rebuild failed`, ); + throw new InternalServerErrorException('Unable to create audio-free EPUB'); } } const format = this.normalizeFormat(file.format); @@ -728,37 +733,43 @@ export class KoreaderCatalogService { .filter((file) => file.role === 'primary' || file.role === 'content') .map((file) => { const extension = this.normalizeFormat(file.format); + const downloadVariant = extension === 'epub' && file.mediaOverlay?.available === true ? 'audioless_epub' : 'original'; + const hasSameFormatSibling = detail.files.some( + (candidate) => candidate.id !== file.id && this.normalizeFormat(candidate.format) === extension, + ); + const devicePath = + resolveUploadPath( + filePattern, + buildPatternTokens({ + metadata: { + title, + subtitle: detail.subtitle, + publisher: detail.publisher, + language: detail.language, + isbn13: detail.isbn13 ?? detail.isbn10, + publishedYear: detail.publishedYear, + seriesName: detail.seriesName, + seriesIndex: detail.seriesIndex, + }, + authors: detail.authors.map((author) => author.name), + originalStem: basename(file.filename ?? title, `.${extension}`), + format: extension, + libraryName: detail.libraryName, + }), + extension, + { sanitizeForCrossPlatform }, + ) ?? + file.filename ?? + `${title}.${extension}`; return { id: file.id, format: extension, role: file.role, - sizeBytes: file.sizeBytes, + downloadVariant, + sizeBytes: downloadVariant === 'audioless_epub' ? null : file.sizeBytes, durationSeconds: file.durationSeconds, downloadUrl: `${CATALOG_BASE}/files/${file.id}/download`, - devicePath: - resolveUploadPath( - filePattern, - buildPatternTokens({ - metadata: { - title, - subtitle: detail.subtitle, - publisher: detail.publisher, - language: detail.language, - isbn13: detail.isbn13 ?? detail.isbn10, - publishedYear: detail.publishedYear, - seriesName: detail.seriesName, - seriesIndex: detail.seriesIndex, - }, - authors: detail.authors.map((author) => author.name), - originalStem: basename(file.filename ?? title, `.${extension}`), - format: extension, - libraryName: detail.libraryName, - }), - extension, - { sanitizeForCrossPlatform }, - ) ?? - file.filename ?? - `${title}.${extension}`, + devicePath: this.variantDevicePath(devicePath, downloadVariant, hasSameFormatSibling), }; }); @@ -799,6 +810,11 @@ export class KoreaderCatalogService { }; } + private variantDevicePath(path: string, variant: KoreaderCatalogFile['downloadVariant'], hasSameFormatSibling: boolean): string { + if (variant !== 'audioless_epub' || !hasSameFormatSibling) return path; + return /\.epub$/i.test(path) ? path.replace(/\.epub$/i, ' - Read Along.epub') : `${path} - Read Along.epub`; + } + private async buildRelatedSections(user: RequestUser, bookId: number): Promise { const [seriesBooks, authorBooks, similarBooks] = await Promise.all([ this.safeRelatedLookup(() => this.recommendationService.getSeriesBooks(bookId, user)), diff --git a/server/src/modules/koreader/koreader-plugin-source.test.ts b/server/src/modules/koreader/koreader-plugin-source.test.ts index c8c44b092..21746560d 100644 --- a/server/src/modules/koreader/koreader-plugin-source.test.ts +++ b/server/src/modules/koreader/koreader-plugin-source.test.ts @@ -114,6 +114,15 @@ describe('KOReader plugin update source wiring', () => { expect(detail).toContain('if section_focus.is_grid then'); }); + it('labels derived read-along EPUB downloads without showing an original archive size', async () => { + const detail = await readPluginFile('bookorbit_catalog_detail.lua'); + + expect(detail).toContain('file.downloadVariant == "audioless_epub"'); + expect(detail).toContain('_("Read Along (audio removed)")'); + expect(detail).toContain('local label = fileFormatLabel(file)'); + expect(detail).toContain('local format_label = fileFormatLabel(file)'); + }); + it('reconciles remote progress before manual book sync uploads progress', async () => { const main = await readPluginFile('main.lua'); const menu = await readPluginFile('bookorbit_main_menu.lua'); diff --git a/server/src/modules/koreader/koreader-plugin.service.test.ts b/server/src/modules/koreader/koreader-plugin.service.test.ts index 2ad9a5dae..872f21995 100644 --- a/server/src/modules/koreader/koreader-plugin.service.test.ts +++ b/server/src/modules/koreader/koreader-plugin.service.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RequestUser } from '../../common/types/request-user'; import { ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, type AchievementEventsService } from '../achievement/achievement-events.service'; +import type { BookService } from '../book/book.service'; import type { UserBookNoteService } from '../user-book-note/user-book-note.service'; import type { UserBookStatusService } from '../user-book-status/user-book-status.service'; import type { BookStatesUploadDto, BulkProgressDto, MatchCheckDto, SweepCompleteDto } from './dto'; @@ -30,6 +31,7 @@ describe('KoreaderPluginService', () => { upsertUnmatchedBooks: ReturnType; clearUnmatchedBooks: ReturnType; restoreDevice: ReturnType; + upsertBookHashLink: ReturnType; }; let pluginRepo: { getRatings: ReturnType; @@ -52,6 +54,7 @@ describe('KoreaderPluginService', () => { normalizeNote: (value: string | null | undefined) => string | null; }; let achievementEvents: { emit: ReturnType }; + let bookService: { verifyFileAccess: ReturnType }; let service: KoreaderPluginService; beforeEach(() => { @@ -65,6 +68,7 @@ describe('KoreaderPluginService', () => { upsertUnmatchedBooks: vi.fn().mockResolvedValue(undefined), clearUnmatchedBooks: vi.fn().mockResolvedValue(undefined), restoreDevice: vi.fn().mockResolvedValue(undefined), + upsertBookHashLink: vi.fn().mockResolvedValue(undefined), }; pluginRepo = { getRatings: vi.fn().mockResolvedValue(new Map()), @@ -90,6 +94,7 @@ describe('KoreaderPluginService', () => { }, }; achievementEvents = { emit: vi.fn() }; + bookService = { verifyFileAccess: vi.fn() }; service = new KoreaderPluginService( koreaderRepo as unknown as KoreaderRepository, pluginRepo as unknown as KoreaderPluginRepository, @@ -97,6 +102,7 @@ describe('KoreaderPluginService', () => { userBookStatusService as unknown as UserBookStatusService, userBookNoteService as unknown as UserBookNoteService, achievementEvents as unknown as AchievementEventsService, + bookService as unknown as BookService, ); }); @@ -113,6 +119,38 @@ describe('KoreaderPluginService', () => { expect(result.libraryVersion).toMatch(/^[0-9a-f]{16}$/); }); + it('links an explicitly downloaded catalog file when its hash resolved to another file', async () => { + bookService.verifyFileAccess.mockResolvedValue({ id: 11, bookId: 20, libraryId: 1, format: 'epub', role: 'content' }); + const dto = { + ...deviceFields(), + hashes: [HASH_A], + books: [{ hash: HASH_A, source: 'file', bookFileId: 11, title: 'Read-along edition' }], + } as MatchCheckDto; + + const result = await service.matchCheck(makeUser(), dto); + + expect(bookService.verifyFileAccess).toHaveBeenCalledWith(11, makeUser()); + expect(koreaderRepo.upsertBookHashLink).toHaveBeenCalledWith(7, HASH_A, 11, { + title: 'Read-along edition', + authors: null, + lastOpen: null, + }); + expect(result.matches).toEqual([{ hash: HASH_A, bookId: 20, bookFileId: 11 }]); + }); + + it('does not rewrite an existing link when the explicit catalog file already resolves', async () => { + const dto = { + ...deviceFields(), + hashes: [HASH_A], + books: [{ hash: HASH_A, source: 'file', bookFileId: 10 }], + } as MatchCheckDto; + + await service.matchCheck(makeUser(), dto); + + expect(bookService.verifyFileAccess).not.toHaveBeenCalled(); + expect(koreaderRepo.upsertBookHashLink).not.toHaveBeenCalled(); + }); + it('persists unmatched candidate metadata from the device statistics database', async () => { const dto = { ...deviceFields(), diff --git a/server/src/modules/koreader/koreader-plugin.service.ts b/server/src/modules/koreader/koreader-plugin.service.ts index a30280181..2460ed803 100644 --- a/server/src/modules/koreader/koreader-plugin.service.ts +++ b/server/src/modules/koreader/koreader-plugin.service.ts @@ -318,9 +318,9 @@ export class KoreaderPluginService { ): Promise> { const linked = new Map(); for (const hash of hashes) { - if (resolved.has(hash)) continue; const candidate = candidates.get(hash); if (!candidate?.bookFileId || candidate.source !== 'file') continue; + if (resolved.get(hash)?.bookFileId === candidate.bookFileId) continue; const file = await this.bookService.verifyFileAccess(candidate.bookFileId, user).catch(() => null); if (!file || file.role !== 'content') continue; diff --git a/server/src/modules/koreader/koreader.repository.test.ts b/server/src/modules/koreader/koreader.repository.test.ts index 8b0f3cc1e..46b8c726f 100644 --- a/server/src/modules/koreader/koreader.repository.test.ts +++ b/server/src/modules/koreader/koreader.repository.test.ts @@ -152,6 +152,24 @@ describe('KoreaderRepository', () => { expect(db.select).toHaveBeenCalledTimes(2); }); + it('prefers a user-scoped link when a current hash matches multiple files in one book', async () => { + const linkedFile = { id: 11, bookId: 20, libraryId: 1, format: 'epub' }; + db.select + .mockReturnValueOnce(makeQueryChain([{ id: 10, bookId: 20, libraryId: 1, format: 'epub', matchingFileCount: 2 }])) + .mockReturnValueOnce(makeQueryChain([linkedFile])); + + await expect(repo.resolveBookFileByHash('abc123', null, 7)).resolves.toEqual(linkedFile); + expect(db.select).toHaveBeenCalledTimes(2); + }); + + it('keeps the deterministic first file when a same-book duplicate has no user-scoped link', async () => { + const firstFile = { id: 10, bookId: 20, libraryId: 1, format: 'epub', matchingFileCount: 2 }; + db.select.mockReturnValueOnce(makeQueryChain([firstFile])).mockReturnValueOnce(makeQueryChain([])); + + await expect(repo.resolveBookFileByHash('abc123', null, 7)).resolves.toEqual({ id: 10, bookId: 20, libraryId: 1, format: 'epub' }); + expect(db.select).toHaveBeenCalledTimes(2); + }); + it('falls back to hash history when current hash lookup returns nothing', async () => { const file = { id: 10, bookId: 20, libraryId: 1, format: 'pdf' }; db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(makeQueryChain([file])); @@ -294,6 +312,38 @@ describe('KoreaderRepository', () => { expect(db.select).toHaveBeenCalledTimes(2); }); + it('prefers a user-scoped link for duplicate current hashes within one book', async () => { + db.select + .mockReturnValueOnce( + makeQueryChain([ + { hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' }, + { hash: 'current', bookFileId: 12, bookId: 21, libraryId: 31, format: 'epub' }, + ]), + ) + .mockReturnValueOnce(makeQueryChain([{ hash: 'current', bookFileId: 12, bookId: 21, libraryId: 31, format: 'epub' }])); + + const result = await repo.resolveBookFilesByHashes(['current'], null, 7); + + expect(result.get('current')).toEqual({ bookFileId: 12, bookId: 21, libraryId: 31, format: 'epub' }); + expect(db.select).toHaveBeenCalledTimes(2); + }); + + it('keeps the deterministic first file when a same-book duplicate has no user-scoped bulk link', async () => { + db.select + .mockReturnValueOnce( + makeQueryChain([ + { hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' }, + { hash: 'current', bookFileId: 12, bookId: 21, libraryId: 31, format: 'epub' }, + ]), + ) + .mockReturnValueOnce(makeQueryChain([])); + + const result = await repo.resolveBookFilesByHashes(['current'], null, 7); + + expect(result.get('current')).toEqual({ bookFileId: 11, bookId: 21, libraryId: 31, format: 'epub' }); + expect(db.select).toHaveBeenCalledTimes(2); + }); + it('omits a historical hash that matches different books', async () => { db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce( makeQueryChain([ diff --git a/server/src/modules/koreader/koreader.repository.ts b/server/src/modules/koreader/koreader.repository.ts index 4dbfdb91b..9f7ab1b41 100644 --- a/server/src/modules/koreader/koreader.repository.ts +++ b/server/src/modules/koreader/koreader.repository.ts @@ -25,6 +25,7 @@ type KoreaderHashLinkMetadata = { }; type HashedResolvedBookFile = ResolvedBookFileByHashes & { hash: string | null }; +type CountedResolvedBookFile = ResolvedBookFileByHash & { matchingFileCount: number }; function resolveUniqueBook(rows: T[]): T | null { const first = rows[0]; @@ -32,6 +33,14 @@ function resolveUniqueBook(rows: T[]): T | null { return rows.every((row) => row.bookId === first.bookId) ? first : null; } +function hasDuplicateFileMatches(rows: CountedResolvedBookFile[]): boolean { + return rows.length > 1 || rows.some((row) => Number(row.matchingFileCount) > 1); +} + +function withoutMatchCount(row: CountedResolvedBookFile): ResolvedBookFileByHash { + return { id: row.id, bookId: row.bookId, libraryId: row.libraryId, format: row.format }; +} + function addUnambiguousHashMatches(rows: HashedResolvedBookFile[], result: Map): Set { const rowsByHash = new Map(); @@ -111,8 +120,8 @@ export class KoreaderRepository { await this.db.delete(schema.koreaderUsers).where(eq(schema.koreaderUsers.userId, userId)); } - // KOReader's partial hash can collide for different files. Multiple matching files are safe only - // when they belong to the same book; otherwise a user-scoped manual link must disambiguate them. + // KOReader's partial hash can collide for different files. Same-book matches have a safe + // deterministic fallback, while a user-scoped hash link preserves the exact downloaded file. async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null, userId?: number): Promise { if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null; @@ -124,6 +133,7 @@ export class KoreaderRepository { bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId, format: schema.bookFiles.format, + matchingFileCount: sql`count(*) over (partition by ${schema.bookFiles.fileHash})`, }) .from(schema.bookFiles) .innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId)) @@ -132,7 +142,10 @@ export class KoreaderRepository { .limit(2); if (byFileHash.length > 0) { - return resolveUniqueBook(byFileHash) ?? (userId === undefined ? null : this.resolveManualBookFileByHash(hash, accessibleLibraryIds, userId)); + const resolved = resolveUniqueBook(byFileHash); + if (resolved && (!hasDuplicateFileMatches(byFileHash) || userId === undefined)) return withoutMatchCount(resolved); + if (userId === undefined) return null; + return (await this.resolveManualBookFileByHash(hash, accessibleLibraryIds, userId)) ?? (resolved ? withoutMatchCount(resolved) : null); } const byFileHashHistory = await this.db @@ -141,6 +154,7 @@ export class KoreaderRepository { bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId, format: schema.bookFiles.format, + matchingFileCount: sql`count(*) over (partition by ${schema.bookFileHashHistory.fileHash})`, }) .from(schema.bookFileHashHistory) .innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.bookFileHashHistory.bookFileId)) @@ -150,9 +164,10 @@ export class KoreaderRepository { .limit(2); if (byFileHashHistory.length > 0) { - return ( - resolveUniqueBook(byFileHashHistory) ?? (userId === undefined ? null : this.resolveManualBookFileByHash(hash, accessibleLibraryIds, userId)) - ); + const resolved = resolveUniqueBook(byFileHashHistory); + if (resolved && (!hasDuplicateFileMatches(byFileHashHistory) || userId === undefined)) return withoutMatchCount(resolved); + if (userId === undefined) return null; + return (await this.resolveManualBookFileByHash(hash, accessibleLibraryIds, userId)) ?? (resolved ? withoutMatchCount(resolved) : null); } return userId === undefined ? null : this.resolveManualBookFileByHash(hash, accessibleLibraryIds, userId); @@ -183,6 +198,15 @@ export class KoreaderRepository { .orderBy(asc(schema.bookFiles.id)); const directHashes = addUnambiguousHashMatches(direct, result); + const duplicateHashes = new Set(); + const directCounts = new Map(); + for (const row of direct) { + if (!row.hash) continue; + directCounts.set(row.hash, (directCounts.get(row.hash) ?? 0) + 1); + } + for (const [hash, count] of directCounts) { + if (count > 1) duplicateHashes.add(hash); + } const missing = hashes.filter((hash) => !directHashes.has(hash)); if (missing.length > 0) { @@ -201,10 +225,18 @@ export class KoreaderRepository { .orderBy(asc(schema.bookFiles.id)); addUnambiguousHashMatches(history, result); + const historyCounts = new Map(); + for (const row of history) { + if (!row.hash) continue; + historyCounts.set(row.hash, (historyCounts.get(row.hash) ?? 0) + 1); + } + for (const [hash, count] of historyCounts) { + if (count > 1) duplicateHashes.add(hash); + } } - const stillMissing = hashes.filter((hash) => !result.has(hash)); - if (stillMissing.length === 0 || userId === undefined) return result; + const linkCandidates = hashes.filter((hash) => !result.has(hash) || duplicateHashes.has(hash)); + if (linkCandidates.length === 0 || userId === undefined) return result; const manualLinks = await this.db .select({ @@ -217,12 +249,10 @@ export class KoreaderRepository { .from(schema.koreaderBookHashLinks) .innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.koreaderBookHashLinks.bookFileId)) .innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId)) - .where(and(eq(schema.koreaderBookHashLinks.userId, userId), inArray(schema.koreaderBookHashLinks.hash, stillMissing), libraryFilter)); + .where(and(eq(schema.koreaderBookHashLinks.userId, userId), inArray(schema.koreaderBookHashLinks.hash, linkCandidates), libraryFilter)); for (const row of manualLinks) { - if (!result.has(row.hash)) { - result.set(row.hash, { bookFileId: row.bookFileId, bookId: row.bookId, libraryId: row.libraryId, format: row.format }); - } + result.set(row.hash, { bookFileId: row.bookFileId, bookId: row.bookId, libraryId: row.libraryId, format: row.format }); } return result; diff --git a/server/src/modules/koreader/koreader.service.ts b/server/src/modules/koreader/koreader.service.ts index 244726055..15d31ad0a 100644 --- a/server/src/modules/koreader/koreader.service.ts +++ b/server/src/modules/koreader/koreader.service.ts @@ -427,6 +427,7 @@ export class KoreaderService { .syncAudioProgressForExternalEbookProgress(userId, bookFile.bookId, bookFile.id, bookorbitPercentage, { cfi, koreaderProgress: data.progress ?? null, + sourceUpdatedAt: data.timestamp ? new Date(data.timestamp * 1000) : undefined, }) .catch(() => undefined); const strongRereadEvidence = previousPercentage !== null && previousPercentage - bookorbitPercentage >= 10; diff --git a/server/src/modules/opds/opds-book.service.ts b/server/src/modules/opds/opds-book.service.ts index 09e6d56bb..edeb44c9d 100644 --- a/server/src/modules/opds/opds-book.service.ts +++ b/server/src/modules/opds/opds-book.service.ts @@ -110,6 +110,7 @@ export interface OpdsBookEntry { export interface OpdsManifestFileRow { id: number; format: string; + mediaOverlayAvailable: boolean; sizeBytes: number | null; fileHash: string | null; filename: string | null; @@ -331,6 +332,7 @@ export class OpdsBookService { bookId: books.id, id: bookFiles.id, format: bookFiles.format, + mediaOverlayAvailable: bookFiles.mediaOverlayAvailable, sizeBytes: bookFiles.sizeBytes, fileHash: bookFiles.fileHash, absolutePath: bookFiles.absolutePath, @@ -355,6 +357,7 @@ export class OpdsBookService { list.push({ id: row.id, format: row.format ?? 'unknown', + mediaOverlayAvailable: row.mediaOverlayAvailable, sizeBytes: row.sizeBytes, fileHash: row.fileHash, // Only the basename leaves the server; the stored absolute path never does. diff --git a/server/src/modules/position-converter/position-converter.service.test.ts b/server/src/modules/position-converter/position-converter.service.test.ts new file mode 100644 index 000000000..e45ef60ef --- /dev/null +++ b/server/src/modules/position-converter/position-converter.service.test.ts @@ -0,0 +1,100 @@ +import { parseChapterDocument } from './position-converter.core'; +import { PositionConverterService } from './position-converter.service'; + +const STORYTELLER_CHAPTER = parseChapterDocument(` + +

The first sentence. The second sentence.

+ +`); + +const SOURCE_CHAPTER = parseChapterDocument(` + +

The first sentence. The second sentence.

+ +`); + +const DIFFERENT_CHAPTER = parseChapterDocument(` + +

This is a different edition.

+ +`); + +const DIFFERENT_ANCHORED_CHAPTER = parseChapterDocument(` + +

Unrelated text with a reused identifier.

+ +`); + +function makeService(chapters: Record>) { + const epubDom = { + getChapter: vi.fn((bookFileId: number) => Promise.resolve(chapters[bookFileId] ?? null)), + }; + return { service: new PositionConverterService(epubDom as never), epubDom }; +} + +describe('PositionConverterService cross-edition read-aloud positions', () => { + it('resolves a Storyteller fragment directly in the Storyteller EPUB', async () => { + const { service } = makeService({ 1: STORYTELLER_CHAPTER }); + + await expect(service.fragmentToPositions({ bookFileId: 1, chapterIndex: 0, fragment: 'sentence-2' })).resolves.toEqual( + expect.objectContaining({ status: 'exact', chapterIndex: 0, cfi: expect.any(String), koreaderProgress: expect.any(String) }), + ); + }); + + it('maps a Storyteller fragment into an anchor-free source EPUB with identical chapter text', async () => { + const { service } = makeService({ 1: STORYTELLER_CHAPTER, 2: SOURCE_CHAPTER }); + + await expect(service.fragmentToPositions({ bookFileId: 2, sourceBookFileId: 1, chapterIndex: 0, fragment: 'sentence-2' })).resolves.toEqual( + expect.objectContaining({ status: 'repaired', chapterIndex: 0, cfi: expect.any(String), koreaderProgress: expect.any(String) }), + ); + }); + + it('maps an anchor-free source EPUB position back to the nearest Storyteller fragment', async () => { + const { service } = makeService({ 1: STORYTELLER_CHAPTER, 2: SOURCE_CHAPTER }); + const mapped = await service.fragmentToPositions({ bookFileId: 2, sourceBookFileId: 1, chapterIndex: 0, fragment: 'sentence-2' }); + expect(mapped.status).toBe('repaired'); + + await expect( + service.nearestFragmentForPosition({ + bookFileId: 2, + sourceBookFileId: 1, + cfi: mapped.cfi, + candidates: [ + { chapterIndex: 0, fragment: 'sentence-1' }, + { chapterIndex: 0, fragment: 'sentence-2' }, + ], + }), + ).resolves.toEqual({ status: 'repaired', fragment: 'sentence-2', chapterIndex: 0 }); + }); + + it('refuses to map a Storyteller fragment into a different chapter text', async () => { + const { service } = makeService({ 1: STORYTELLER_CHAPTER, 3: DIFFERENT_CHAPTER }); + + await expect(service.fragmentToPositions({ bookFileId: 3, sourceBookFileId: 1, chapterIndex: 0, fragment: 'sentence-1' })).resolves.toEqual({ + status: 'failed', + reason: 'chapter_text_mismatch', + chapterIndex: 0, + }); + }); + + it('refuses coincidentally matching fragment ids when the chapter text differs', async () => { + const { service } = makeService({ 1: STORYTELLER_CHAPTER, 4: DIFFERENT_ANCHORED_CHAPTER }); + + await expect(service.fragmentToPositions({ bookFileId: 4, sourceBookFileId: 1, chapterIndex: 0, fragment: 'sentence-1' })).resolves.toEqual({ + status: 'failed', + reason: 'chapter_text_mismatch', + chapterIndex: 0, + }); + + const local = await service.fragmentToPositions({ bookFileId: 4, chapterIndex: 0, fragment: 'sentence-1' }); + expect(local.status).toBe('exact'); + await expect( + service.nearestFragmentForPosition({ + bookFileId: 4, + sourceBookFileId: 1, + cfi: local.cfi, + candidates: [{ chapterIndex: 0, fragment: 'sentence-1' }], + }), + ).resolves.toEqual({ status: 'failed', reason: 'chapter_text_mismatch', chapterIndex: 0 }); + }); +}); diff --git a/server/src/modules/position-converter/position-converter.service.ts b/server/src/modules/position-converter/position-converter.service.ts index 11685657d..fda2cd064 100644 --- a/server/src/modules/position-converter/position-converter.service.ts +++ b/server/src/modules/position-converter/position-converter.service.ts @@ -45,7 +45,7 @@ export interface CfiToXPointerOutcome extends Record { } export interface FragmentToPositionsOutcome extends Record { - status: 'exact' | 'failed'; + status: 'exact' | 'repaired' | 'failed'; cfi?: string; koreaderProgress?: string | null; chapterIndex?: number; @@ -53,7 +53,7 @@ export interface FragmentToPositionsOutcome extends Record { } export interface NearestFragmentOutcome extends Record { - status: 'exact' | 'failed'; + status: 'exact' | 'repaired' | 'failed'; fragment?: string; chapterIndex?: number; reason?: string; @@ -130,7 +130,12 @@ export class PositionConverterService { return { status: result.status, pos0: result.pos0, pos1: result.pos1, chapterIndex }; } - async fragmentToPositions(params: { bookFileId: number; chapterIndex: number; fragment: string }): Promise { + async fragmentToPositions(params: { + bookFileId: number; + chapterIndex: number; + fragment: string; + sourceBookFileId?: number; + }): Promise { const fragment = params.fragment.replace(/^#/, '').trim(); if (!fragment) return { status: 'failed', reason: 'missing_fragment' }; if (params.chapterIndex < 0) return { status: 'failed', reason: 'invalid_chapter_index' }; @@ -138,19 +143,35 @@ export class PositionConverterService { const doc = await this.epubDom.getChapter(params.bookFileId, params.chapterIndex); if (!doc) return { status: 'failed', reason: 'chapter_unavailable', chapterIndex: params.chapterIndex }; - const element = findElementById(doc.root, fragment); - if (!element) return { status: 'failed', reason: 'fragment_not_found', chapterIndex: params.chapterIndex }; + let cp: number | null; + let status: 'exact' | 'repaired' = 'exact'; + if (params.sourceBookFileId != null && params.sourceBookFileId !== params.bookFileId) { + const sourceDoc = await this.epubDom.getChapter(params.sourceBookFileId, params.chapterIndex); + const sourceElement = sourceDoc ? findElementById(sourceDoc.root, fragment) : null; + const sourceRun = sourceElement && sourceDoc ? sourceDoc.index.firstRunWithin(sourceElement) : null; + if (!sourceDoc || !sourceRun || sourceRun.collapsedLength <= 0) { + return { status: 'failed', reason: 'source_fragment_not_found', chapterIndex: params.chapterIndex }; + } + cp = this.mapEquivalentChapterPoint(sourceDoc, doc, sourceRun.collapsedStart); + if (cp == null) return { status: 'failed', reason: 'chapter_text_mismatch', chapterIndex: params.chapterIndex }; + status = 'repaired'; + } else { + const element = findElementById(doc.root, fragment); + const run = element ? doc.index.firstRunWithin(element) : null; + if (element && (!run || run.collapsedLength <= 0)) { + return { status: 'failed', reason: 'fragment_has_no_text', chapterIndex: params.chapterIndex }; + } + cp = run?.collapsedStart ?? null; + } + if (cp == null) return { status: 'failed', reason: 'fragment_not_found', chapterIndex: params.chapterIndex }; - const run = doc.index.firstRunWithin(element); - if (!run || run.collapsedLength <= 0) return { status: 'failed', reason: 'fragment_has_no_text', chapterIndex: params.chapterIndex }; - - const cfi = collapsedPointToCfi(doc, params.chapterIndex, run.collapsedStart); + const cfi = collapsedPointToCfi(doc, params.chapterIndex, cp); if (!cfi) return { status: 'failed', reason: 'cfi_generation_failed', chapterIndex: params.chapterIndex }; return { - status: 'exact', + status, cfi, - koreaderProgress: collapsedPointToXPointer(doc, params.chapterIndex, run.collapsedStart), + koreaderProgress: collapsedPointToXPointer(doc, params.chapterIndex, cp), chapterIndex: params.chapterIndex, }; } @@ -160,6 +181,7 @@ export class PositionConverterService { cfi?: string | null; xpointer?: string | null; candidates: Array<{ chapterIndex: number; fragment: string }>; + sourceBookFileId?: number; }): Promise { const resolved = await this.resolvePositionCp(params.bookFileId, params.cfi ?? null, params.xpointer ?? null); if (resolved.status === 'failed') return resolved; @@ -167,14 +189,27 @@ export class PositionConverterService { const candidates = params.candidates.filter((candidate) => candidate.chapterIndex === resolved.chapterIndex); if (candidates.length === 0) return { status: 'failed', reason: 'no_candidate_fragments', chapterIndex: resolved.chapterIndex }; + let candidateDoc = resolved.doc; + let candidateCp = resolved.cp; + let status: 'exact' | 'repaired' = 'exact'; + if (params.sourceBookFileId != null && params.sourceBookFileId !== params.bookFileId) { + const sourceDoc = await this.epubDom.getChapter(params.sourceBookFileId, resolved.chapterIndex); + if (!sourceDoc) return { status: 'failed', reason: 'source_chapter_unavailable', chapterIndex: resolved.chapterIndex }; + const mappedCp = this.mapEquivalentChapterPoint(resolved.doc, sourceDoc, resolved.cp); + if (mappedCp == null) return { status: 'failed', reason: 'chapter_text_mismatch', chapterIndex: resolved.chapterIndex }; + candidateDoc = sourceDoc; + candidateCp = mappedCp; + status = 'repaired'; + } + let bestBefore: { fragment: string; distance: number } | null = null; let bestAfter: { fragment: string; distance: number } | null = null; for (const candidate of candidates) { - const element = findElementById(resolved.doc.root, candidate.fragment); + const element = findElementById(candidateDoc.root, candidate.fragment); if (!element) continue; - const run = resolved.doc.index.firstRunWithin(element); + const run = candidateDoc.index.firstRunWithin(element); if (!run || run.collapsedLength <= 0) continue; - const distance = resolved.cp - run.collapsedStart; + const distance = candidateCp - run.collapsedStart; if (distance >= 0) { if (!bestBefore || distance < bestBefore.distance) bestBefore = { fragment: candidate.fragment, distance }; } else { @@ -185,7 +220,12 @@ export class PositionConverterService { const best = bestBefore ?? bestAfter; if (!best) return { status: 'failed', reason: 'candidate_fragment_not_found', chapterIndex: resolved.chapterIndex }; - return { status: 'exact', fragment: best.fragment, chapterIndex: resolved.chapterIndex }; + return { status, fragment: best.fragment, chapterIndex: resolved.chapterIndex }; + } + + private mapEquivalentChapterPoint(source: ChapterDocument, target: ChapterDocument, sourceCp: number): number | null { + if (source.index.collapsed !== target.index.collapsed) return null; + return Math.max(0, Math.min(sourceCp, target.index.collapsedCpLength)); } private async resolvePositionCp( diff --git a/server/src/modules/scanner/file-event-processor.service.test.ts b/server/src/modules/scanner/file-event-processor.service.test.ts index 14f31b22f..5c1203837 100644 --- a/server/src/modules/scanner/file-event-processor.service.test.ts +++ b/server/src/modules/scanner/file-event-processor.service.test.ts @@ -258,6 +258,24 @@ describe('handleUnlink', () => { expect(result).toEqual({ type: 'book-restored', libraryId: 5, bookIds: [12] }); }); + it('prefers the remaining read-aloud EPUB when the selected file is deleted', async () => { + mockRepo.findBookFileByAbsolutePath.mockResolvedValue({ + file: { id: 99, bookId: 12, role: 'content' }, + libraryId: 5, + primaryFileId: 99, + } as any); + mockRepo.findBookFilesByBookId.mockResolvedValue([ + { id: 99, bookId: 12, role: 'content', format: 'epub', sizeBytes: 1000, mediaOverlayAvailable: false }, + { id: 100, bookId: 12, role: 'content', format: 'epub', sizeBytes: 1000, mediaOverlayAvailable: false }, + { id: 101, bookId: 12, role: 'content', format: 'epub', sizeBytes: 2000, mediaOverlayAvailable: true }, + ] as any); + mockRepo.findLibrarySettings.mockResolvedValue({ formatPriority: ['epub'] } as any); + + await makeService().handleUnlink('/books/Solo/selected.epub'); + + expect(mockRepo.updateBookPrimaryFile).toHaveBeenCalledWith(12, 101); + }); + it('falls back to DEFAULT_FORMAT_PRIORITY when library settings are null', async () => { mockRepo.findBookFileByAbsolutePath.mockResolvedValue({ file: { id: 99, bookId: 12, role: 'content' }, diff --git a/server/src/modules/scanner/file-event-processor.service.ts b/server/src/modules/scanner/file-event-processor.service.ts index a972a9662..1ab1b7129 100644 --- a/server/src/modules/scanner/file-event-processor.service.ts +++ b/server/src/modules/scanner/file-event-processor.service.ts @@ -4,6 +4,7 @@ import type { BigIntStats } from 'fs'; import { dirname, join, relative } from 'path'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; +import { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils'; import { classifyFile, DEFAULT_FORMAT_PRIORITY } from './lib/classify'; import { ScannerRepository } from './scanner.repository'; @@ -58,7 +59,14 @@ export class FileEventProcessorService { const allFiles = await this.scannerRepo.findBookFilesByBookId(file.bookId); const remaining = allFiles.filter((f) => f.id !== file.id); - const remainingContent = remaining.filter((f) => f.role === 'content').map((f) => ({ id: f.id, format: f.format, sizeBytes: f.sizeBytes })); + const remainingContent = remaining + .filter((f) => f.role === 'content') + .map((f) => ({ + id: f.id, + format: f.format, + sizeBytes: f.sizeBytes, + mediaOverlayAvailable: f.mediaOverlayAvailable, + })); if (remainingContent.length === 0) { // Keep the file record so inode-based rename detection in handleCreate still works. @@ -243,7 +251,15 @@ export class FileEventProcessorService { const cached = settingsCache.get(book.libraryId)!; const result = (await this.tryResolveDuplicateMove(book.id)) ?? - (await this.tryRestoreBook(book as { id: number; libraryId: number; libraryFolderId: number; folderPath: string }, cached.formatPriority)); + (await this.tryRestoreBook( + book as { + id: number; + libraryId: number; + libraryFolderId: number; + folderPath: string; + }, + cached.formatPriority, + )); if (result.type !== 'noop') results.push(result); } @@ -262,12 +278,23 @@ export class FileEventProcessorService { } private async tryRestoreBook( - book: { id: number; libraryId: number; libraryFolderId: number; folderPath: string }, + book: { + id: number; + libraryId: number; + libraryFolderId: number; + folderPath: string; + }, cachedFormatPriority?: string[], ): Promise { const files = (await this.scannerRepo.findBookFilesByBookId(book.id)) .filter((f) => f.role === 'content') - .map((f) => ({ id: f.id, absolutePath: f.absolutePath, format: f.format, sizeBytes: f.sizeBytes })); + .map((f) => ({ + id: f.id, + absolutePath: f.absolutePath, + format: f.format, + sizeBytes: f.sizeBytes, + mediaOverlayAvailable: f.mediaOverlayAvailable, + })); const existingContent: ((typeof files)[number] & { stat: FsStat })[] = []; for (const file of files) { @@ -279,10 +306,12 @@ export class FileEventProcessorService { if (existingContent.length === 0) return { type: 'noop' }; for (const file of existingContent) { + const mediaOverlayFields = await this.inspectMediaOverlayFields(file.absolutePath, file.format); await this.scannerRepo.updateBookFile(file.id, { ...this.statToFileInfo(file.stat), - ...(await this.inspectMediaOverlayFields(file.absolutePath, file.format)), + ...mediaOverlayFields, }); + file.mediaOverlayAvailable = mediaOverlayFields.mediaOverlayAvailable; } // Use cached formatPriority when available @@ -486,17 +515,24 @@ export class FileEventProcessorService { return { type: 'book-moved', libraryId: detectedLibraryId, bookIds: movedBookIds }; } - private pickPrimaryFile(files: T[], formatPriority: string[]): T | null { - if (files.length === 0) return null; - const candidates = files.filter((f) => (f.sizeBytes ?? 0) > 0); - const pool = candidates.length > 0 ? candidates : files; - return formatPriority.reduce((found, fmt) => found ?? pool.find((f) => f.format === fmt) ?? null, null) ?? pool[0] ?? null; + private pickPrimaryFile( + files: T[], + formatPriority: string[], + ): T | null { + return selectPrimaryFile(files, formatPriority, { + allowZeroByteFallback: true, + }); } private async refreshPrimaryFile(bookId: number, libraryId: number): Promise { const files = (await this.scannerRepo.findBookFilesByBookId(bookId)) .filter((f) => f.role === 'content') - .map((f) => ({ id: f.id, format: f.format, sizeBytes: f.sizeBytes })); + .map((f) => ({ + id: f.id, + format: f.format, + sizeBytes: f.sizeBytes, + mediaOverlayAvailable: f.mediaOverlayAvailable, + })); const settings = await this.scannerRepo.findLibrarySettings(libraryId); const formatPriority = settings?.formatPriority ?? DEFAULT_FORMAT_PRIORITY; const winner = this.pickPrimaryFile(files, formatPriority); @@ -510,7 +546,14 @@ export class FileEventProcessorService { const matchedLibraryId = missingBooks[0].libraryId; for (const book of missingBooks) { - const result = await this.tryRestoreBook(book as { id: number; libraryId: number; libraryFolderId: number; folderPath: string }); + const result = await this.tryRestoreBook( + book as { + id: number; + libraryId: number; + libraryFolderId: number; + folderPath: string; + }, + ); if (result.type !== 'noop') restoredIds.push(book.id); } diff --git a/server/src/modules/scanner/scanner.repository.ts b/server/src/modules/scanner/scanner.repository.ts index 179e32619..46872eccf 100644 --- a/server/src/modules/scanner/scanner.repository.ts +++ b/server/src/modules/scanner/scanner.repository.ts @@ -22,7 +22,7 @@ import { type Db = NodePgDatabase; type DbTransaction = Parameters[0]>[0]; -type MoveBookToLibraryResult = Pick & { +type MoveBookToLibraryResult = Pick & { previousLibraryId: number; libraryChanged: boolean; }; @@ -245,6 +245,7 @@ export class ScannerRepository { mtime: bookFiles.mtime, fileHash: bookFiles.fileHash, sortOrder: bookFiles.sortOrder, + mediaOverlayAvailable: bookFiles.mediaOverlayAvailable, mediaOverlayCheckedAt: bookFiles.mediaOverlayCheckedAt, }) .from(bookFiles) @@ -285,7 +286,12 @@ export class ScannerRepository { const whereClause = libraryId == null ? eq(bookFiles.absolutePath, absolutePath) : and(eq(bookFiles.absolutePath, absolutePath), eq(books.libraryId, libraryId)); const [row] = await this.db - .select({ file: bookFiles, libraryId: books.libraryId, primaryFileId: books.primaryFileId, libraryFolderPath: libraryFolders.path }) + .select({ + file: bookFiles, + libraryId: books.libraryId, + primaryFileId: books.primaryFileId, + libraryFolderPath: libraryFolders.path, + }) .from(bookFiles) .innerJoin(books, eq(books.id, bookFiles.bookId)) .innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId)) @@ -340,6 +346,7 @@ export class ScannerRepository { libraryFolderId: books.libraryFolderId, folderPath: books.folderPath, status: books.status, + primaryFileId: books.primaryFileId, }) .from(books) .where(eq(books.id, bookId)) @@ -368,6 +375,7 @@ export class ScannerRepository { libraryFolderId: books.libraryFolderId, folderPath: books.folderPath, status: books.status, + primaryFileId: books.primaryFileId, }); return book ? { ...book, previousLibraryId: current.libraryId, libraryChanged } : null; }); diff --git a/server/src/modules/scanner/scanner.service.test.ts b/server/src/modules/scanner/scanner.service.test.ts index 04770badb..7160f7a15 100644 --- a/server/src/modules/scanner/scanner.service.test.ts +++ b/server/src/modules/scanner/scanner.service.test.ts @@ -1542,6 +1542,54 @@ describe('format priority', () => { // epub comes before mobi in DEFAULT_FORMAT_PRIORITY expect(repo.updateBookPrimaryFile).toHaveBeenCalledWith(expect.any(Number), 11); }); + + it('prefers a detected read-aloud EPUB over a plain EPUB on a full rescan', async () => { + const plain = makeFileStat({ absolutePath: '/library/Book/plain.epub', relPath: 'Book/plain.epub', ino: 11n }); + const readAlong = makeFileStat({ absolutePath: '/library/Book/read-along.epub', relPath: 'Book/read-along.epub', ino: 12n }); + mockFindCandidates.mockResolvedValue({ + candidates: [makeCandidate('/library/Book', [plain, readAlong])], + skippedDirs: new Set(), + unchangedDirs: new Set(), + dirMtimes: new Map(), + }); + + const repo = makeRepo({ + findBooksByLibraryFolder: vi.fn().mockResolvedValue([ + { + id: 1, + status: 'present', + folderPath: '/library/Book', + primaryFileId: 11, + }, + ]), + findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([ + makeBookFile({ + id: 11, + absolutePath: plain.absolutePath, + relPath: plain.relPath, + ino: plain.ino, + mediaOverlayAvailable: false, + mediaOverlayCheckedAt: new Date('2024-01-01'), + }), + makeBookFile({ + id: 12, + absolutePath: readAlong.absolutePath, + relPath: readAlong.relPath, + ino: readAlong.ino, + sortOrder: 1, + mediaOverlayAvailable: true, + mediaOverlayCheckedAt: new Date('2024-01-01'), + }), + ]), + }); + const done = awaitScan(repo); + const { service } = makeService(repo); + + await service.startScan(1, 'manual'); + await done; + + expect(repo.updateBookPrimaryFile).toHaveBeenCalledWith(1, 12); + }); }); // ── allowedFormats filtering ────────────────────────────────────────────────── diff --git a/server/src/modules/scanner/scanner.service.ts b/server/src/modules/scanner/scanner.service.ts index 5cfcede6f..9945bf35d 100644 --- a/server/src/modules/scanner/scanner.service.ts +++ b/server/src/modules/scanner/scanner.service.ts @@ -3,6 +3,7 @@ import { createHash } from 'crypto'; import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils'; import { naturalCompare } from '../../common/utils/natural-sort.utils'; import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils'; +import { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils'; import { SelfWriteRegistry } from '../../common/services/self-write-registry.service'; import type { @@ -61,6 +62,7 @@ interface FileByPathEntry { mtime: Date | null; fileHash: string | null; sortOrder: number | null; + mediaOverlayAvailable: boolean; mediaOverlayCheckedAt: Date | null; } @@ -134,9 +136,11 @@ interface RegisteredFile { format: string | null; role: FileRole; absolutePath: string; + sizeBytes: number; isNew: boolean; wasReassigned: boolean; wasChanged: boolean; + mediaOverlayAvailable: boolean; } interface MetadataExtractionSource { @@ -303,7 +307,12 @@ export class ScannerService implements OnApplicationBootstrap { } private static buildLookupMaps( - knownBooks: Array<{ id: number; status: string; folderPath: string; primaryFileId?: number | null }>, + knownBooks: Array<{ + id: number; + status: string; + folderPath: string; + primaryFileId?: number | null; + }>, knownFiles: Array<{ id: number; bookId: number; @@ -314,11 +323,20 @@ export class ScannerService implements OnApplicationBootstrap { mtime: Date | null; fileHash: string | null; sortOrder?: number | null; + mediaOverlayAvailable?: boolean | null; mediaOverlayCheckedAt?: Date | null; }>, ): ScanLookupMaps { const bookByFolderPath = new Map( - knownBooks.map((b) => [b.folderPath, { id: b.id, status: b.status, folderPath: b.folderPath, primaryFileId: b.primaryFileId }]), + knownBooks.map((b) => [ + b.folderPath, + { + id: b.id, + status: b.status, + folderPath: b.folderPath, + primaryFileId: b.primaryFileId, + }, + ]), ); const booksByParentDir = new Map(); @@ -329,7 +347,12 @@ export class ScannerService implements OnApplicationBootstrap { arr = []; booksByParentDir.set(parentDir, arr); } - arr.push({ id: b.id, status: b.status, folderPath: b.folderPath, primaryFileId: b.primaryFileId }); + arr.push({ + id: b.id, + status: b.status, + folderPath: b.folderPath, + primaryFileId: b.primaryFileId, + }); } const fileByPath = new Map( @@ -344,6 +367,7 @@ export class ScannerService implements OnApplicationBootstrap { mtime: f.mtime, fileHash: f.fileHash, sortOrder: f.sortOrder ?? null, + mediaOverlayAvailable: f.mediaOverlayAvailable === true, mediaOverlayCheckedAt: f.mediaOverlayCheckedAt ?? null, }, ]), @@ -994,7 +1018,12 @@ export class ScannerService implements OnApplicationBootstrap { const knownBooks = await this.scannerRepo.findBooksByFolderPath(folderPath, libraryId); const knownFiles = await this.scannerRepo.findBookFilesByBookIds(knownBooks.map((b) => b.id)); return ScannerService.buildLookupMaps( - knownBooks.map((b) => ({ id: b.id, status: b.status, folderPath: b.folderPath, primaryFileId: b.primaryFileId })), + knownBooks.map((b) => ({ + id: b.id, + status: b.status, + folderPath: b.folderPath, + primaryFileId: b.primaryFileId, + })), knownFiles.map((f) => ({ id: f.id, bookId: f.bookId, @@ -1005,6 +1034,8 @@ export class ScannerService implements OnApplicationBootstrap { mtime: f.mtime, fileHash: f.fileHash, sortOrder: f.sortOrder, + mediaOverlayAvailable: f.mediaOverlayAvailable, + mediaOverlayCheckedAt: f.mediaOverlayCheckedAt, })), ); } @@ -1615,9 +1646,11 @@ export class ScannerService implements OnApplicationBootstrap { format, role, absolutePath: fileStat.absolutePath, + sizeBytes: fileStat.sizeBytes, isNew: processResult.isNew, wasReassigned: processResult.reassigned, wasChanged: processResult.changed, + mediaOverlayAvailable: fileByPath.get(fileStat.absolutePath)?.mediaOverlayAvailable === true, }); retainedFileIds.add(processResult.fileId); } @@ -1626,10 +1659,10 @@ export class ScannerService implements OnApplicationBootstrap { // Phase 2: Pick winner (primary file) from all registered content files. const contentFiles = registeredFiles.filter((f) => f.role === 'content'); - const winner = - formatPriority.reduce((found, fmt) => found ?? contentFiles.find((f) => f.format === fmt) ?? null, null) ?? - contentFiles[0] ?? - null; + const winner = selectPrimaryFile( + contentFiles.map((file) => ({ ...file, id: file.fileId })), + formatPriority, + ); await this.scannerRepo.updateBookPrimaryFile(book.id, winner?.fileId ?? null); @@ -1881,7 +1914,12 @@ export class ScannerService implements OnApplicationBootstrap { addedAt, }); counts.addedCount++; - const entry = { id: book.id, status: book.status, folderPath: book.folderPath, primaryFileId: book.primaryFileId ?? null }; + const entry = { + id: book.id, + status: book.status, + folderPath: book.folderPath, + primaryFileId: book.primaryFileId ?? null, + }; bookByFolderPath.set(candidate.folderPath, entry); return { ...entry, created: true }; } @@ -2152,7 +2190,12 @@ export class ScannerService implements OnApplicationBootstrap { bookIds: [moved.id], } satisfies BookTransferredEvent); } - const transferred = { id: moved.id, status: moved.status, folderPath: moved.folderPath }; + const transferred = { + id: moved.id, + status: moved.status, + folderPath: moved.folderPath, + primaryFileId: moved.primaryFileId, + }; bookByFolderPath.set(candidate.folderPath, transferred); this.logger.log( `[scanner.upsert_book] [end] libraryId=${libraryId} bookId=${moved.id} folder="${sanitizeLogValue(candidate.folderPath)}" action=transfer_missing_book - missing book transferred into destination library`, @@ -2220,12 +2263,14 @@ export class ScannerService implements OnApplicationBootstrap { const relPathUnchanged = fileStat.relPath === byPath.relPath; const reassigned = byPath.bookId !== bookId; const sortOrderUnchanged = sortOrder === byPath.sortOrder; + let mediaOverlayAvailable = byPath.mediaOverlayAvailable; let mediaOverlayCheckedAt = byPath.mediaOverlayCheckedAt; if (sizeUnchanged && mtimeUnchanged && inoUnchanged && relPathUnchanged && !reassigned && sortOrderUnchanged) { if (format?.toLowerCase() === 'epub' && byPath.mediaOverlayCheckedAt == null) { const mediaOverlayFields = await this.inspectMediaOverlayFields(fileStat.absolutePath, format); await this.scannerRepo.updateBookFile(byPath.id, mediaOverlayFields); + byPath.mediaOverlayAvailable = mediaOverlayFields.mediaOverlayAvailable; byPath.mediaOverlayCheckedAt = mediaOverlayFields.mediaOverlayCheckedAt; counts.updatedCount++; } @@ -2237,6 +2282,7 @@ export class ScannerService implements OnApplicationBootstrap { const mediaOverlayFields: Partial = !sizeUnchanged || !mtimeUnchanged || reassigned ? await this.inspectMediaOverlayFields(fileStat.absolutePath, format) : {}; if ('mediaOverlayCheckedAt' in mediaOverlayFields) { + mediaOverlayAvailable = mediaOverlayFields.mediaOverlayAvailable === true; mediaOverlayCheckedAt = mediaOverlayFields.mediaOverlayCheckedAt ?? null; } @@ -2272,6 +2318,7 @@ export class ScannerService implements OnApplicationBootstrap { mtime: fileStat.mtime, fileHash: byPath.fileHash, sortOrder, + mediaOverlayAvailable, mediaOverlayCheckedAt, }); if (fileStat.ino !== 0n) { @@ -2331,6 +2378,7 @@ export class ScannerService implements OnApplicationBootstrap { mtime: fileStat.mtime, fileHash: oldPathEntry?.fileHash ?? null, sortOrder, + mediaOverlayAvailable: mediaOverlayFields.mediaOverlayAvailable, mediaOverlayCheckedAt: mediaOverlayFields.mediaOverlayCheckedAt, }); fileByIno.set(fileStat.ino, { id: byIno.id, bookId, absolutePath: fileStat.absolutePath, sizeBytes: fileStat.sizeBytes, mtime: fileStat.mtime }); @@ -2401,6 +2449,7 @@ export class ScannerService implements OnApplicationBootstrap { mtime: fileStat.mtime, fileHash: globalByIno.file.fileHash, sortOrder, + mediaOverlayAvailable: mediaOverlayFields.mediaOverlayAvailable, mediaOverlayCheckedAt: mediaOverlayFields.mediaOverlayCheckedAt, }); fileByIno.set(fileStat.ino, { @@ -2480,6 +2529,7 @@ export class ScannerService implements OnApplicationBootstrap { mtime: fileStat.mtime, fileHash: byHash.fileHash, sortOrder, + mediaOverlayAvailable: mediaOverlayFields.mediaOverlayAvailable, mediaOverlayCheckedAt: mediaOverlayFields.mediaOverlayCheckedAt, }); if (fileStat.ino !== 0n) { @@ -2544,6 +2594,7 @@ export class ScannerService implements OnApplicationBootstrap { mtime: fileStat.mtime, fileHash, sortOrder, + mediaOverlayAvailable: mediaOverlayFields.mediaOverlayAvailable, mediaOverlayCheckedAt: mediaOverlayFields.mediaOverlayCheckedAt, }); if (fileStat.ino !== 0n) { @@ -2590,6 +2641,7 @@ export class ScannerService implements OnApplicationBootstrap { mtime: concurrent.file.mtime, fileHash: concurrent.file.fileHash, sortOrder: concurrent.file.sortOrder, + mediaOverlayAvailable: concurrent.file.mediaOverlayAvailable, mediaOverlayCheckedAt: concurrent.file.mediaOverlayCheckedAt ?? null, }, fileStat, @@ -2613,6 +2665,7 @@ export class ScannerService implements OnApplicationBootstrap { mtime: fileStat.mtime, fileHash, sortOrder, + mediaOverlayAvailable: mediaOverlayFields.mediaOverlayAvailable, mediaOverlayCheckedAt: mediaOverlayFields.mediaOverlayCheckedAt, }); if (fileStat.ino !== 0n) { diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index 38e573c30..dee94ba61 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -901,7 +901,7 @@ describe('UploadService', () => { function mockElection( lockedBook: { primaryFileId: number | null; status: string; formatPriority: string[] }, - files: Array<{ id: number; format: string | null; sizeBytes: number | null }>, + files: Array<{ id: number; format: string | null; sizeBytes: number | null; mediaOverlayAvailable?: boolean }>, ) { tx.select .mockReset() @@ -1025,6 +1025,19 @@ describe('UploadService', () => { expect(result.role).toBe('content'); }); + it('promotes an uploaded read-aloud EPUB over an automatically selected plain EPUB', async () => { + db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict()); + mockElection({ primaryFileId: 99, status: 'present', formatPriority: ['epub'] }, [ + { id: 55, format: 'epub', sizeBytes: 456, mediaOverlayAvailable: true }, + { id: 99, format: 'epub', sizeBytes: 1000, mediaOverlayAvailable: false }, + ]); + + const result = await service.addFileToBook(10, 'book.epub', {} as any, user); + + expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 55 })); + expect(result.role).toBe('primary'); + }); + it('repairs a stale primary reference using the highest-priority eligible file', async () => { mockUploadedFormat('m4b'); db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: 777 })])).mockReturnValueOnce(noHashConflict()); diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index b06eafc72..0ca196a4e 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -7,6 +7,7 @@ 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 { selectPrimaryFile } from '../../common/utils/primary-file-selection.utils'; import { DB } from '../../db'; import * as schema from '../../db/schema'; @@ -36,7 +37,7 @@ import { uploadError } from './upload-errors'; type Db = NodePgDatabase; type StoredUploadResult = UploadResult & { absolutePath: string; created: boolean; libraryId: number }; -type PrimaryFileCandidate = Pick; +type PrimaryFileCandidate = Pick; @Injectable() export class UploadService { @@ -304,7 +305,12 @@ export class UploadService { if (!inserted) throw new Error('Failed to insert book file record'); const contentFiles = await tx - .select({ id: bookFiles.id, format: bookFiles.format, sizeBytes: bookFiles.sizeBytes }) + .select({ + id: bookFiles.id, + format: bookFiles.format, + sizeBytes: bookFiles.sizeBytes, + mediaOverlayAvailable: bookFiles.mediaOverlayAvailable, + }) .from(bookFiles) .where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'content'))) .orderBy(asc(bookFiles.id)); @@ -369,15 +375,11 @@ export class UploadService { } private pickPrimaryFile(files: PrimaryFileCandidate[], currentPrimaryFileId: number | null, formatPriority: string[]): PrimaryFileCandidate | null { - const candidates = files.filter((file) => (file.sizeBytes ?? 0) > 0); - if (candidates.length === 0) return null; - - const currentPrimary = candidates.find((file) => file.id === currentPrimaryFileId) ?? null; - const preferredFormat = formatPriority.find((candidateFormat) => candidates.some((file) => file.format === candidateFormat)); - - if (preferredFormat === undefined) return currentPrimary ?? candidates[0] ?? null; - if (currentPrimary?.format === preferredFormat) return currentPrimary; - return candidates.find((file) => file.format === preferredFormat) ?? null; + const ordered = + currentPrimaryFileId == null + ? files + : [...files.filter((file) => file.id === currentPrimaryFileId), ...files.filter((file) => file.id !== currentPrimaryFileId)]; + return selectPrimaryFile(ordered, formatPriority); } async renameBookFiles(bookId: number, user: RequestUser): Promise { diff --git a/server/test/koreader-progress-position.e2e-spec.ts b/server/test/koreader-progress-position.e2e-spec.ts index 5f037be93..31fb20d53 100644 --- a/server/test/koreader-progress-position.e2e-spec.ts +++ b/server/test/koreader-progress-position.e2e-spec.ts @@ -259,6 +259,64 @@ describe('KOReader progress position routing (e2e)', { timeout: 180_000 }, () => expect(remaining.map((row) => row.sessionId).sort()).toEqual([measuredSessionId, webSessionId].sort()); }); + it('uses a user-scoped link when the same book has two files with the same document hash', async () => { + const [sourceFile] = await ctx.db + .select({ + bookId: schema.bookFiles.bookId, + libraryFolderId: schema.bookFiles.libraryFolderId, + absolutePath: schema.bookFiles.absolutePath, + relPath: schema.bookFiles.relPath, + ino: schema.bookFiles.ino, + sizeBytes: schema.bookFiles.sizeBytes, + mtime: schema.bookFiles.mtime, + format: schema.bookFiles.format, + }) + .from(schema.bookFiles) + .where(eq(schema.bookFiles.id, epub.bookFileId)); + const [koreaderUser] = await ctx.db + .select({ userId: schema.koreaderUsers.userId }) + .from(schema.koreaderUsers) + .where(eq(schema.koreaderUsers.username, KOREADER_USERNAME)); + expect(sourceFile).toBeDefined(); + expect(koreaderUser).toBeDefined(); + + const suffix = randomUUID(); + const [linkedFile] = await ctx.db + .insert(schema.bookFiles) + .values({ + ...sourceFile!, + absolutePath: `${sourceFile!.absolutePath}.same-hash-${suffix}`, + relPath: sourceFile!.relPath ? `${sourceFile!.relPath}.same-hash-${suffix}` : null, + fileHash: epubHash, + role: 'content', + }) + .returning({ id: schema.bookFiles.id }); + + try { + await ctx.db.insert(schema.koreaderBookHashLinks).values({ + userId: koreaderUser!.userId, + hash: epubHash, + bookFileId: linkedFile!.id, + }); + + await syncFromDevice(epubHash, 0.61, XPOINTER); + + const [deviceProgress] = await ctx.db + .select({ bookFileId: schema.koreaderDeviceProgress.bookFileId, percentage: schema.koreaderDeviceProgress.percentage }) + .from(schema.koreaderDeviceProgress) + .where( + and( + eq(schema.koreaderDeviceProgress.bookFileId, linkedFile!.id), + eq(schema.koreaderDeviceProgress.userId, koreaderUser!.userId), + eq(schema.koreaderDeviceProgress.deviceId, DEVICE_ID), + ), + ); + expect(deviceProgress).toEqual({ bookFileId: linkedFile!.id, percentage: 0.61 }); + } finally { + await ctx.db.delete(schema.bookFiles).where(eq(schema.bookFiles.id, linkedFile!.id)); + } + }); + it('does not route progress when the document hash matches different books', async () => { const comicProgressBefore = await storedProgress(comic.bookFileId); const epubProgressBefore = await storedProgress(epub.bookFileId);