feat(reader): synchronize Storyteller reading progress

Keep audiobook, EPUB, Kobo, and KOReader positions coherent while serving each client the safest suitable book variant.
This commit is contained in:
neon
2026-09-19 21:27:53 +00:00
parent 4dabacb2b3
commit d3f3382bfa
38 changed files with 1722 additions and 174 deletions
@@ -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<ReadAloudProgressSync>(props.book.readAloudSync)
const readAloudSyncSaving = ref(false)
const readAloudSyncError = ref<string | null>(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(
</div>
</div>
<section v-if="showReadAloudSync" data-test="read-aloud-sync" class="rounded-lg border border-border bg-card px-3 py-2.5">
<div class="flex items-start gap-3">
<Headphones class="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<p class="text-xs font-semibold text-foreground">{{ t('book.detail.details.readAloudSync.title') }}</p>
<span class="text-[11px] text-muted-foreground">{{ readAloudSyncStatus }}</span>
</div>
<p class="mt-0.5 text-xs text-muted-foreground">{{ readAloudSyncDescription }}</p>
<p v-if="readAloudSyncError" class="mt-1 text-xs text-destructive" role="status" aria-live="polite">
{{ readAloudSyncError }}
</p>
</div>
<button
type="button"
data-test="read-aloud-sync-toggle"
class="shrink-0 rounded-md border border-input px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
:disabled="readAloudSyncSaving"
@click="handleToggleReadAloudSync"
>
{{
readAloudSyncSaving
? t('book.detail.details.readAloudSync.saving')
: readAloudSync.mode === 'disabled'
? t('book.detail.details.readAloudSync.enable')
: t('book.detail.details.readAloudSync.disable')
}}
</button>
</div>
</section>
<!-- Genres + Tags -->
<div v-if="book.genres.length || book.tags.length" class="space-y-1">
<div v-if="book.genres.length" class="relative">
@@ -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()
})
})
+20
View File
@@ -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",
@@ -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
+2
View File
@@ -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;
@@ -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);
});
});
@@ -0,0 +1,28 @@
export type PrimaryFileCandidate = {
id: number;
format: string | null;
sizeBytes: number | null;
mediaOverlayAvailable?: boolean | null;
};
export function selectPrimaryFile<T extends PrimaryFileCandidate>(
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;
}
@@ -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 () => {
@@ -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,
@@ -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();
});
});
@@ -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<boolean> {
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<boolean> {
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 };
+103 -5
View File
@@ -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 () => {
+86 -4
View File
@@ -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<boolean> {
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,
@@ -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;
+34 -3
View File
@@ -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<void> {
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<void> {
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,
});
}
@@ -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 () => {
@@ -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 {
@@ -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<typeof makeDb>) {
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 () => {
@@ -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<void> {
@@ -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<string, unknown> = {}) {
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<string, unknown> = {}) {
{
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 () => {
@@ -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<KoreaderCatalogManifestFile>((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<KoreaderCatalogFile>((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<KoreaderCatalogRelatedSection[]> {
const [seriesBooks, authorBooks, similarBooks] = await Promise.all([
this.safeRelatedLookup(() => this.recommendationService.getSeriesBooks(bookId, user)),
@@ -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');
@@ -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<typeof vi.fn>;
clearUnmatchedBooks: ReturnType<typeof vi.fn>;
restoreDevice: ReturnType<typeof vi.fn>;
upsertBookHashLink: ReturnType<typeof vi.fn>;
};
let pluginRepo: {
getRatings: ReturnType<typeof vi.fn>;
@@ -52,6 +54,7 @@ describe('KoreaderPluginService', () => {
normalizeNote: (value: string | null | undefined) => string | null;
};
let achievementEvents: { emit: ReturnType<typeof vi.fn> };
let bookService: { verifyFileAccess: ReturnType<typeof vi.fn> };
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(),
@@ -318,9 +318,9 @@ export class KoreaderPluginService {
): Promise<Map<string, ResolvedBookFile>> {
const linked = new Map<string, ResolvedBookFile>();
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;
@@ -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([
@@ -25,6 +25,7 @@ type KoreaderHashLinkMetadata = {
};
type HashedResolvedBookFile = ResolvedBookFileByHashes & { hash: string | null };
type CountedResolvedBookFile = ResolvedBookFileByHash & { matchingFileCount: number };
function resolveUniqueBook<T extends { bookId: number }>(rows: T[]): T | null {
const first = rows[0];
@@ -32,6 +33,14 @@ function resolveUniqueBook<T extends { bookId: number }>(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<string, ResolvedBookFileByHashes>): Set<string> {
const rowsByHash = new Map<string, HashedResolvedBookFile[]>();
@@ -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<ResolvedBookFileByHash | null> {
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<number>`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<number>`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<string>();
const directCounts = new Map<string, number>();
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<string, number>();
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;
@@ -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;
@@ -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.
@@ -0,0 +1,100 @@
import { parseChapterDocument } from './position-converter.core';
import { PositionConverterService } from './position-converter.service';
const STORYTELLER_CHAPTER = parseChapterDocument(`
<html xmlns="http://www.w3.org/1999/xhtml">
<body><p><span id="sentence-1">The first sentence.</span> <span id="sentence-2">The second sentence.</span></p></body>
</html>
`);
const SOURCE_CHAPTER = parseChapterDocument(`
<html xmlns="http://www.w3.org/1999/xhtml">
<body><p>The first sentence. The second sentence.</p></body>
</html>
`);
const DIFFERENT_CHAPTER = parseChapterDocument(`
<html xmlns="http://www.w3.org/1999/xhtml">
<body><p>This is a different edition.</p></body>
</html>
`);
const DIFFERENT_ANCHORED_CHAPTER = parseChapterDocument(`
<html xmlns="http://www.w3.org/1999/xhtml">
<body><p><span id="sentence-1">Unrelated text with a reused identifier.</span></p></body>
</html>
`);
function makeService(chapters: Record<number, ReturnType<typeof parseChapterDocument>>) {
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 });
});
});
@@ -45,7 +45,7 @@ export interface CfiToXPointerOutcome extends Record<string, unknown> {
}
export interface FragmentToPositionsOutcome extends Record<string, unknown> {
status: 'exact' | 'failed';
status: 'exact' | 'repaired' | 'failed';
cfi?: string;
koreaderProgress?: string | null;
chapterIndex?: number;
@@ -53,7 +53,7 @@ export interface FragmentToPositionsOutcome extends Record<string, unknown> {
}
export interface NearestFragmentOutcome extends Record<string, unknown> {
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<FragmentToPositionsOutcome> {
async fragmentToPositions(params: {
bookFileId: number;
chapterIndex: number;
fragment: string;
sourceBookFileId?: number;
}): Promise<FragmentToPositionsOutcome> {
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<NearestFragmentOutcome> {
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(
@@ -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' },
@@ -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<FileEventResult> {
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<T extends { id: number; format: string | null; sizeBytes: number | null }>(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<T | null>((found, fmt) => found ?? pool.find((f) => f.format === fmt) ?? null, null) ?? pool[0] ?? null;
private pickPrimaryFile<T extends { id: number; format: string | null; sizeBytes: number | null; mediaOverlayAvailable?: boolean | null }>(
files: T[],
formatPriority: string[],
): T | null {
return selectPrimaryFile(files, formatPriority, {
allowZeroByteFallback: true,
});
}
private async refreshPrimaryFile(bookId: number, libraryId: number): Promise<void> {
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);
}
@@ -22,7 +22,7 @@ import {
type Db = NodePgDatabase<typeof schema>;
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
type MoveBookToLibraryResult = Pick<typeof books.$inferSelect, 'id' | 'libraryId' | 'libraryFolderId' | 'folderPath' | 'status'> & {
type MoveBookToLibraryResult = Pick<typeof books.$inferSelect, 'id' | 'libraryId' | 'libraryFolderId' | 'folderPath' | 'status' | 'primaryFileId'> & {
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;
});
@@ -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 ──────────────────────────────────────────────────
+63 -10
View File
@@ -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<string, BookEntry>(
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<string, BookEntry[]>();
@@ -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<string, FileByPathEntry>(
@@ -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<RegisteredFile | null>((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<EpubMediaOverlayColumnFields> =
!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) {
@@ -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());
+13 -11
View File
@@ -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<typeof schema>;
type StoredUploadResult = UploadResult & { absolutePath: string; created: boolean; libraryId: number };
type PrimaryFileCandidate = Pick<typeof bookFiles.$inferSelect, 'id' | 'format' | 'sizeBytes'>;
type PrimaryFileCandidate = Pick<typeof bookFiles.$inferSelect, 'id' | 'format' | 'sizeBytes' | 'mediaOverlayAvailable'>;
@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<void> {
@@ -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);