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