mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): open DjVu documents in the web reader
Scanned books are commonly distributed as DjVu, which the scanner only stored as a supplement. Decode pages on the server with DjVuLibre so DjVu imports with a cover and page count, opens in the image reader with its embedded OCR text, and can be the preferred format of an ordinary email recipient. Migration 0113 widens the recipient format check to allow it. Decoding runs on validated private snapshots behind a bounded, reader-first queue, and revalidation and cached pages are answered without a decoder. Released iOS builds cannot open DjVu, so series continuation offers it only to callers sending includeDjvu=true, and the dashboard and notebook read file never name it. The first full scan after upgrading promotes stored .djvu supplements to content.
This commit is contained in:
@@ -72,6 +72,10 @@ jobs:
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
prefetch: "true"
|
||||
|
||||
- name: Install DjVuLibre tools
|
||||
if: inputs.suite-id == 'djvu-reader' || inputs.suite-id == 'all'
|
||||
run: sudo apt-get update && sudo apt-get install -y djvulibre-bin util-linux
|
||||
|
||||
- name: Run e2e suite
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ COPY server/requirements/kobo-cloudscraper.txt /tmp/kobo-cloudscraper-requiremen
|
||||
# pip is build-only here. Leaving it installed also leaves pip/_vendor/vendor.txt,
|
||||
# which Trivy reads as installed msgpack and setuptools and fails the image scan on.
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache poppler-utils su-exec ffmpeg python3 py3-pip tini tzdata && \
|
||||
apk add --no-cache poppler-utils djvulibre util-linux su-exec ffmpeg python3 py3-pip tini tzdata && \
|
||||
python3 -m venv /opt/bookorbit-python && \
|
||||
/opt/bookorbit-python/bin/python -m pip install --no-cache-dir -r /tmp/kobo-cloudscraper-requirements.txt && \
|
||||
/opt/bookorbit-python/bin/python -m pip uninstall -y pip && \
|
||||
|
||||
@@ -115,6 +115,14 @@ describe('useBookDockUpload client-side validation', () => {
|
||||
expect(upload.failures.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends DjVu to the Book Dock instead of rejecting it as unsupported', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
upload.addFiles([book('DjVuLibre-book.DJVU')])
|
||||
await flushPromises()
|
||||
expect(FakeXhr.sent).toHaveLength(1)
|
||||
expect(upload.failures.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a file over the configured upload limit before sending it', async () => {
|
||||
const { upload, appInfo } = await loadComposable()
|
||||
appInfo.maxUploadSizeMb.value = 1
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { getValidToken } from '@/lib/api'
|
||||
import type { BookDockFile } from '@bookorbit/types'
|
||||
import { UPLOAD_SUPPORTED_FORMATS, type BookDockFile } from '@bookorbit/types'
|
||||
|
||||
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
||||
import { isRetryableUploadFailure, type BookDockUploadFailure } from '../lib/upload-failure'
|
||||
|
||||
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
|
||||
export const SUPPORTED_FORMATS: readonly string[] = UPLOAD_SUPPORTED_FORMATS
|
||||
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
|
||||
|
||||
export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error'
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useCoverVersions } from '../composables/useCoverVersions'
|
||||
import BookFormatChip from './BookFormatChip.vue'
|
||||
import { bookFormatEntries, fileFormatKey } from '../lib/book-formats'
|
||||
import { displayPublishedDate } from '../lib/published-date'
|
||||
import { FORMAT_TO_GROUP, getPrimaryBookFile } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, getPrimaryBookFile, isComicFormat } from '@bookorbit/types'
|
||||
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -98,7 +98,7 @@ const quickViewCoverAspectRatio = computed(() => {
|
||||
|
||||
const primaryFile = computed(() => (detail.value ? getPrimaryBookFile(detail.value.files) : null))
|
||||
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 isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
|
||||
const formatEntries = computed(() => (detail.value ? bookFormatEntries(detail.value.files, detail.value.formatPriority) : []))
|
||||
const publishedDisplay = computed(() => (detail.value ? displayPublishedDate(detail.value.publishedDate, detail.value.publishedYear) : null))
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { readingDateToDateKey } from '@/features/book/lib/reading-date'
|
||||
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { useBookCoverAspectRatio } from '@/features/book/composables/useBookCoverAspectRatio'
|
||||
import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS, isComicFormat } 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'
|
||||
@@ -364,7 +364,7 @@ const isFaceAudio = computed(() => {
|
||||
if (!props.book.covers.ebook && !props.book.covers.audio) return isPrimaryAudio.value
|
||||
return faceMedium(props.book, coverAspectRatio.value) === 'audio'
|
||||
})
|
||||
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
|
||||
const isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
|
||||
const formatEntries = computed(() => bookFormatEntries(props.book.files, props.book.formatPriority))
|
||||
const isMultiTrackAudio = computed(() => formatEntries.value.some((entry) => entry.audio && entry.files.length > 1))
|
||||
// A multi-file audiobook opens from its first track, so it is one entry in the menu.
|
||||
|
||||
@@ -25,7 +25,14 @@ import type {
|
||||
MetadataProviderInfo,
|
||||
WriteResult,
|
||||
} from '@bookorbit/types'
|
||||
import { BOOK_FILE_WRITE_FIELD_LABELS, FORMAT_TO_GROUP, getPrimaryBookFile, isValidSeriesIndex, parseSeriesIndex } from '@bookorbit/types'
|
||||
import {
|
||||
BOOK_FILE_WRITE_FIELD_LABELS,
|
||||
FORMAT_TO_GROUP,
|
||||
getPrimaryBookFile,
|
||||
isValidSeriesIndex,
|
||||
parseSeriesIndex,
|
||||
isComicFormat,
|
||||
} from '@bookorbit/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -119,7 +126,7 @@ const COMIC_FIELD_MAP = {
|
||||
|
||||
const primaryFile = computed(() => getPrimaryBookFile(props.book.files))
|
||||
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 isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
|
||||
const fileWriteStatus = computed(() => props.book.fileWriteStatus ?? null)
|
||||
const fileWriteWritableFormats = computed(() => fileWriteStatus.value?.writableFormats ?? [])
|
||||
const fileWriteFieldLabels = computed(() => (fileWriteStatus.value?.writableFields ?? []).map((field) => BOOK_FILE_WRITE_FIELD_LABELS[field]))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Lock, LockOpen } from '@lucide/vue'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, isComicFormat, type BookCard } from '@bookorbit/types'
|
||||
import BookTableCoverCell from './BookTableCoverCell.vue'
|
||||
import BookTableTextCell from './BookTableTextCell.vue'
|
||||
import BookTableNumberCell from './BookTableNumberCell.vue'
|
||||
@@ -97,7 +97,7 @@ function handleToggleAllLocks() {
|
||||
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
|
||||
const isComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronRight, LibraryBig } from '@lucide/vue'
|
||||
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
|
||||
import { FORMAT_TO_GROUP, isComicFormat, type BookCard } from '@bookorbit/types'
|
||||
import BookCoverSurface from '../BookCoverSurface.vue'
|
||||
import BookTableChipsCell from './BookTableChipsCell.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -27,7 +27,7 @@ const bookCount = computed(() => collapsed.value.bookCount)
|
||||
const coverIds = computed(() => collapsed.value.coverBookIds.filter((id) => id > 0).slice(0, 4))
|
||||
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
|
||||
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
|
||||
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
|
||||
const isComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
|
||||
const { coverUrl } = useCoverVersions()
|
||||
|
||||
function thumbnailUrl(bookId: number): string {
|
||||
|
||||
@@ -122,8 +122,8 @@ describe('BookTableCellDispatcher lock row', () => {
|
||||
expect(wrapper.find('[data-testid="cover-cell"]').attributes('data-comic')).toBe('true')
|
||||
})
|
||||
|
||||
it('does not flag non-comic primary files as comics in the cover cell', () => {
|
||||
const book = makeBook({ files: [{ id: 1, format: 'epub', role: 'primary', sizeBytes: null }] })
|
||||
it.each(['epub', 'djvu'])('does not flag %s primary files as comics in the cover cell', (format) => {
|
||||
const book = makeBook({ files: [{ id: 1, format, role: 'primary', sizeBytes: null }] })
|
||||
const wrapper = mountDispatcher({ book, colId: 'cover' as const, cellType: 'cover' as const })
|
||||
|
||||
expect(wrapper.find('[data-testid="cover-cell"]').attributes('data-comic')).toBe('false')
|
||||
|
||||
@@ -86,6 +86,13 @@ describe('useAddBookFile', () => {
|
||||
expect(files.value[0].file.name).toBe('book.epub')
|
||||
})
|
||||
|
||||
it('accepts DjVu as an additional edition regardless of extension case', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('scanned-book.DJVU')])
|
||||
expect(files.value[0].status).toBe('pending')
|
||||
expect(files.value[0].validationError).toBe(false)
|
||||
})
|
||||
|
||||
it('marks unsupported format as error without attempting upload', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('book.xyz')])
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { getValidToken } from '@/lib/api'
|
||||
import type { AddBookFileResult } from '@bookorbit/types'
|
||||
import { UPLOAD_SUPPORTED_FORMATS, type AddBookFileResult } from '@bookorbit/types'
|
||||
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
||||
|
||||
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
|
||||
export const SUPPORTED_FORMATS: readonly string[] = UPLOAD_SUPPORTED_FORMATS
|
||||
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
|
||||
export const MAX_FILE_BYTES = 500 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ function groupOf(file: BookDetailFile): FileGroupKey {
|
||||
if (!isContentBookFile(file)) return 'extras'
|
||||
const format = file.format!.toLowerCase()
|
||||
if (isAudioFormat(format)) return 'audio'
|
||||
if (format === 'pdf') return 'document'
|
||||
if (format === 'pdf' || format === 'djvu') return 'document'
|
||||
if (isComicFormat(format)) return 'comic'
|
||||
return 'ebook'
|
||||
}
|
||||
|
||||
@@ -67,6 +67,6 @@ describe('format labels', () => {
|
||||
it('names formats from the translated catalog', () => {
|
||||
expect(formatKeyName('epub')).toBe('EPUB e-book')
|
||||
expect(formatKeyName(READ_ALONG_FORMAT_PRIORITY)).toBe('Read-along EPUB')
|
||||
expect(formatKeyName('djvu')).toBe('DJVU')
|
||||
expect(formatKeyName('djvu')).toBe('DjVu document')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { BookOpen, Play } from '@lucide/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { FORMAT_TO_GROUP } from '@bookorbit/types'
|
||||
import { isComicFormat } from '@bookorbit/types'
|
||||
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
|
||||
@@ -19,7 +19,7 @@ function goToBook(bookId: number) {
|
||||
}
|
||||
|
||||
function isComic(fileFormat: string | null): boolean {
|
||||
return fileFormat != null && FORMAT_TO_GROUP[fileFormat] === 'cbx'
|
||||
return fileFormat != null && isComicFormat(fileFormat)
|
||||
}
|
||||
|
||||
function continueReading(bookId: number, fileId: number | null, fileFormat: string | null) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Clock, Play } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { FORMAT_TO_GROUP } from '@bookorbit/types'
|
||||
import { isComicFormat } from '@bookorbit/types'
|
||||
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
|
||||
@@ -17,7 +17,7 @@ const { coverUrl } = useCoverVersions()
|
||||
|
||||
const isComic = computed(() => {
|
||||
const format = data.value?.fileFormat
|
||||
return format != null && FORMAT_TO_GROUP[format] === 'cbx'
|
||||
return format != null && isComicFormat(format)
|
||||
})
|
||||
|
||||
function goToBook() {
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('email file rules', () => {
|
||||
it('offers a Kindle only the formats Amazon takes by email', () => {
|
||||
expect(formatsFor('kindle')).toEqual(['epub', 'pdf'])
|
||||
expect(formatsFor(null)).toContain('azw3')
|
||||
expect(formatsFor(null)).toContain('djvu')
|
||||
expect(kindleAccepts('djvu')).toBe(false)
|
||||
expect(kindleAccepts('EPUB')).toBe(true)
|
||||
expect(kindleAccepts('mobi')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { hasReadAlong } from '@/features/book/lib/file-capabilities'
|
||||
|
||||
/** What Amazon's Send to Kindle takes by email since 2023, among the formats a recipient can prefer. */
|
||||
export const KINDLE_EMAIL_FORMATS = ['epub', 'pdf'] as const
|
||||
export const RECIPIENT_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr'] as const
|
||||
export const RECIPIENT_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'djvu'] as const
|
||||
|
||||
const MB = 1024 * 1024
|
||||
const KINDLE_LIMIT_BYTES = 50 * MB
|
||||
|
||||
@@ -37,7 +37,7 @@ const ADDED_AT_SOURCES: AddedAtSource[] = ['imported', 'file_modified', 'file_cr
|
||||
const FORMAT_GROUPS: { id: 'ebook' | 'kindle' | 'document' | 'comic' | 'audio'; formats: string[] }[] = [
|
||||
{ id: 'ebook', formats: ['epub', 'kepub', 'fb2'] },
|
||||
{ id: 'kindle', formats: ['mobi', 'azw3', 'azw'] },
|
||||
{ id: 'document', formats: ['pdf'] },
|
||||
{ id: 'document', formats: ['pdf', 'djvu'] },
|
||||
{ id: 'comic', formats: ['cbz', 'cbr', 'cb7'] },
|
||||
{ id: 'audio', formats: ['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'] },
|
||||
]
|
||||
|
||||
@@ -63,6 +63,16 @@ describe('LibraryCreatorScanner', () => {
|
||||
expect(wrapper.emitted('update:allowedFormats')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows DjVu to be selected when imports are restricted to specific formats', async () => {
|
||||
const wrapper = mountScanner({ allowedFormats: ['pdf'] })
|
||||
await wrapper
|
||||
.findAll('button[aria-pressed]')
|
||||
.find((button) => button.text() === 'DJVU')!
|
||||
.trigger('click')
|
||||
expect(wrapper.emitted('update:allowedFormats')).toEqual([[['pdf', 'djvu']]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('adds skip patterns on Enter and removes them by name', async () => {
|
||||
const wrapper = mountScanner({ excludePatterns: ['**/samples/**'] })
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { useBookUpload, SUPPORTED_FORMATS_ACCEPT } from '../useBookUpload'
|
||||
|
||||
vi.mock('@/lib/api', () => ({ getValidToken: vi.fn<() => Promise<string>>().mockResolvedValue('test-token') }))
|
||||
vi.mock('@/features/settings/composables/useAppInfo', () => ({ useAppInfo: () => ({ maxUploadSizeMb: ref(500) }) }))
|
||||
|
||||
describe('library DjVu upload', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('accepts DjVu in file selection and drag-and-drop validation regardless of case', () => {
|
||||
const upload = useBookUpload()
|
||||
upload.addFiles([new File(['document'], 'scanned-book.djvu'), new File(['document'], 'second.DJVU'), new File(['document'], 'notes.docx')])
|
||||
expect(SUPPORTED_FORMATS_ACCEPT.split(',')).toContain('.djvu')
|
||||
expect(upload.files.value.map((file) => [file.file.name, file.status])).toEqual([
|
||||
['scanned-book.djvu', 'pending'],
|
||||
['second.DJVU', 'pending'],
|
||||
['notes.docx', 'error'],
|
||||
])
|
||||
expect(upload.pendingCount.value).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { getValidToken } from '@/lib/api'
|
||||
import type { UploadResult } from '@bookorbit/types'
|
||||
import { UPLOAD_SUPPORTED_FORMATS, type UploadResult } from '@bookorbit/types'
|
||||
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
||||
|
||||
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
|
||||
export const SUPPORTED_FORMATS: readonly string[] = UPLOAD_SUPPORTED_FORMATS
|
||||
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
|
||||
export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024 // 500 MB
|
||||
|
||||
|
||||
@@ -70,7 +70,9 @@ useReaderPageTitle(bookId)
|
||||
const normalizedFileFormat = fileFormat.toLowerCase()
|
||||
const isAudioFormat = getFormatGroup(fileFormat) === 'audio'
|
||||
const isPdfFormat = fileFormat === 'pdf'
|
||||
const isComicFormat = fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7'
|
||||
const isDjvuFormat = normalizedFileFormat === 'djvu'
|
||||
const isComicFormat = fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7' || isDjvuFormat
|
||||
const pageImageSource = isDjvuFormat ? 'djvu' : 'cbz'
|
||||
const isPeekMode = computed(() => {
|
||||
const mode = route.query.mode
|
||||
return (Array.isArray(mode) ? mode[0] : mode) === 'peek'
|
||||
@@ -1206,7 +1208,7 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<PdfV4ReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
|
||||
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
|
||||
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" :source="pageImageSource" />
|
||||
<AudiobookReaderView v-else-if="isAudioFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -170,6 +170,22 @@ describe('CbzReaderView', () => {
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn<(handle: number) => void>())
|
||||
})
|
||||
|
||||
it('offers DjVu embedded text and recovers from a failed page image', async () => {
|
||||
mocks.savedMode = 'paginated'
|
||||
mocks.savedPageNumber = 1
|
||||
mocks.pageCount = 2
|
||||
const wrapper = shallowMount(CbzReaderView, { props: { bookId: 11, fileId: 22, source: 'djvu' } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[aria-label="reader.djvu.pageText"]').exists()).toBe(true)
|
||||
await wrapper.get('img').trigger('error')
|
||||
expect(wrapper.text()).toContain('reader.pageImages.pageFailed')
|
||||
const retry = wrapper.findAll('button').find((button) => button.text() === 'common.retry')!
|
||||
await retry.trigger('click')
|
||||
expect(wrapper.text()).not.toContain('reader.pageImages.pageFailed')
|
||||
expect(wrapper.get('img').attributes('alt')).toBe('reader.pageImages.pageAlt')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['infinite', 'long-strip'] as const)('restores %s mode by index while rendering only a bounded page window', async (mode) => {
|
||||
mocks.savedMode = mode
|
||||
const wrapper = shallowMount(CbzReaderView, {
|
||||
@@ -347,6 +363,29 @@ describe('CbzReaderView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps arrow keys turning pages after a reader button is clicked, leaving activation keys to the button', async () => {
|
||||
mocks.savedMode = 'paginated'
|
||||
mocks.pageCount = 5
|
||||
mocks.savedPageNumber = 2
|
||||
const wrapper = shallowMount(CbzReaderView, { props: { bookId: 11, fileId: 22 }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
const pages = () => wrapper.findAll('img').map((image) => image.attributes('src'))
|
||||
const opened = pages()
|
||||
const button = wrapper.get('button').element as HTMLButtonElement
|
||||
button.focus()
|
||||
|
||||
button.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
|
||||
await nextTick()
|
||||
expect(pages()).toEqual(opened)
|
||||
|
||||
button.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
|
||||
await nextTick()
|
||||
expect(pages()).not.toEqual(opened)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('leaves the page turn inert past the last page while auto-advance is off', async () => {
|
||||
mocks.savedMode = 'paginated'
|
||||
mocks.pageCount = 3
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PinOff,
|
||||
Plus,
|
||||
Settings,
|
||||
FileText,
|
||||
} from '@lucide/vue'
|
||||
import { useVisibility } from '../shared/composables/useVisibility'
|
||||
import { useReaderProgress } from '../shared/composables/useReaderProgress'
|
||||
@@ -33,6 +34,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import ReaderSettingsSheet from '@/features/reader/shared/components/ReaderSettingsSheet.vue'
|
||||
import CbzSettingsPanel from './components/CbzSettingsPanel.vue'
|
||||
import NextIssueCard from './components/NextIssueCard.vue'
|
||||
import DjvuTextSheet from '../djvu/DjvuTextSheet.vue'
|
||||
|
||||
const TWO_PAGE_BREAKPOINT = 900
|
||||
// A page turn that only just landed on the last page must not carry through into the next book:
|
||||
@@ -44,7 +46,7 @@ const ZOOM_STEP = 0.25
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
|
||||
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean; source?: 'cbz' | 'djvu' }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const trackingEnabled = computed(() => !props.peekMode)
|
||||
@@ -61,7 +63,7 @@ const { onActivity, elapsedMinutes } = useReadingSession(
|
||||
{ trackingEnabled },
|
||||
)
|
||||
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
|
||||
const { pageCount, bookTitle, seriesId, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
|
||||
const { pageCount, bookTitle, seriesId, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId, props.source ?? 'cbz')
|
||||
const {
|
||||
fitMode,
|
||||
viewMode,
|
||||
@@ -77,9 +79,47 @@ const {
|
||||
imgFitClass,
|
||||
} = useCbzSettings()
|
||||
const { nextBook, load: loadNextBook } = useSeriesNextBook('cbx')
|
||||
const bookSettings = useReaderSettings(props.fileId, 'cbz')
|
||||
const bookSettings = useReaderSettings(props.fileId, props.source === 'djvu' ? 'djvu' : 'cbz')
|
||||
|
||||
const currentPage = ref(0)
|
||||
const imageError = ref(false)
|
||||
const imageGeneration = ref(0)
|
||||
const showPageText = ref(false)
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
function firstPage() {
|
||||
goToPage(0)
|
||||
}
|
||||
function lastPage() {
|
||||
goToPage(pageCount.value - 1)
|
||||
}
|
||||
function onPageSlider(event: Event) {
|
||||
goToPage(Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
function openPageText() {
|
||||
showPageText.value = true
|
||||
}
|
||||
function onTextOpenChange(open: boolean) {
|
||||
showPageText.value = open
|
||||
}
|
||||
function onImageError() {
|
||||
imageError.value = true
|
||||
}
|
||||
async function retryImages() {
|
||||
await load()
|
||||
if (disposed || error.value || pageCount.value <= 0) return
|
||||
currentPage.value = Math.min(currentPage.value, pageCount.value - 1)
|
||||
imageError.value = false
|
||||
currentImageLoaded.value = false
|
||||
loadedImageCount.value = 0
|
||||
imageGeneration.value++
|
||||
preloadCache.clear()
|
||||
}
|
||||
function onPaginatedLoad(event: Event) {
|
||||
const image = event.target
|
||||
if (image instanceof HTMLImageElement) onPaginatedImageLoad(Number(image.dataset.page), event)
|
||||
}
|
||||
const showSettings = ref(false)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const paginatedViewport = ref<HTMLElement | null>(null)
|
||||
@@ -93,7 +133,20 @@ const pageDimensions = ref<Array<{ width: number; height: number } | undefined>>
|
||||
const stripImagesReady = ref(false)
|
||||
const zoomScale = ref(1)
|
||||
|
||||
watch(showSettings, setVisibilityLock)
|
||||
// Only keyboard focus holds the controls open; a clicked or tapped button keeps focus too, and
|
||||
// must not stop the bars from hiding.
|
||||
const controlsFocused = ref(false)
|
||||
function onControlsFocus(event: FocusEvent) {
|
||||
controlsFocused.value = event.target instanceof HTMLElement && event.target.matches(':focus-visible')
|
||||
}
|
||||
function onControlsBlur(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (!(next instanceof HTMLElement && next.closest('[data-reader-controls]'))) controlsFocused.value = false
|
||||
}
|
||||
watch([showSettings, showPageText, controlsFocused], ([settings, text, focused]) => {
|
||||
setVisibilityLock(settings || text || focused)
|
||||
if (focused) footerVisible.value = true
|
||||
})
|
||||
|
||||
// The settings surface is one panel in two containers: an anchored popover where there
|
||||
// is room beside the page, a bottom sheet where the thumb is and the page must stay visible.
|
||||
@@ -331,6 +384,7 @@ const canGoNext = computed(() => {
|
||||
})
|
||||
|
||||
watch(renderKey, () => {
|
||||
imageError.value = false
|
||||
const spread = currentSpread.value
|
||||
const expected = spread?.pages.length ?? 0
|
||||
pendingImageLoads.value = expected
|
||||
@@ -361,12 +415,16 @@ function preload(n: number) {
|
||||
preloadCache.set(n, img)
|
||||
}
|
||||
|
||||
// Every DjVu page is rendered on the server by a small shared decoder pool, so it looks one spread
|
||||
// ahead instead of four and leaves the pool free for the page on screen.
|
||||
const PRELOAD_SPREAD_OFFSETS = props.source === 'djvu' ? [0, 1] : [-1, 0, 1, 2]
|
||||
|
||||
function schedulePreload(anchorPage: number) {
|
||||
const layout = spreadLayout.value
|
||||
const centerSpreadIndex = layout.spreadIndexForPage(anchorPage)
|
||||
const pagesToPreload = new Set<number>()
|
||||
|
||||
for (const offset of [-1, 0, 1, 2]) {
|
||||
for (const offset of PRELOAD_SPREAD_OFFSETS) {
|
||||
const spread = layout.spreads[centerSpreadIndex + offset]
|
||||
if (!spread) continue
|
||||
for (const page of spread.pages) pagesToPreload.add(page)
|
||||
@@ -567,7 +625,14 @@ function onWheel(e: WheelEvent) {
|
||||
// ── Keyboard ───────────────────────────────────────────────────────────────────
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const target = (e.composedPath?.()[0] || e.target) as HTMLElement | null
|
||||
if (target?.tagName === 'INPUT') return
|
||||
if (e.defaultPrevented || showPageText.value) return
|
||||
if (showSettings.value) {
|
||||
if (e.key === 'Escape') showSettings.value = false
|
||||
return
|
||||
}
|
||||
if (target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable=true], [role=dialog]')) return
|
||||
// A clicked button keeps focus, so arrows must still turn pages; only its own activation keys are left to it.
|
||||
if ((e.key === ' ' || e.key === 'Enter') && target instanceof HTMLElement && target.closest('button, a')) return
|
||||
const isRtl = direction.value === 'rtl'
|
||||
|
||||
switch (e.key) {
|
||||
@@ -596,9 +661,6 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
e.preventDefault()
|
||||
goToPage(pageCount.value - 1)
|
||||
break
|
||||
case 'Escape':
|
||||
showSettings.value = false
|
||||
break
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault()
|
||||
@@ -616,6 +678,7 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
let readerReady = false
|
||||
let disposed = false
|
||||
let restoringContinuousPosition = false
|
||||
let stripScrollFrame: number | null = null
|
||||
|
||||
@@ -741,38 +804,52 @@ async function startTrackedReading() {
|
||||
}
|
||||
|
||||
// ── Mount / unmount ────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
async function initializeReader() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
viewportHeight.value = window.innerHeight
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await progress.load()
|
||||
if (disposed) return
|
||||
await bookSettings.load()
|
||||
if (disposed) return
|
||||
|
||||
applySettings(bookSettings.effective.value as CbxReaderSettings)
|
||||
|
||||
await load()
|
||||
if (disposed || error.value || pageCount.value <= 0) return
|
||||
const saved = progress.pageNumber.value
|
||||
if (saved && saved > 1) {
|
||||
currentPage.value = Math.min(saved - 1, pageCount.value - 1)
|
||||
} else if (progress.percentage.value > 0 && pageCount.value > 1) {
|
||||
const estimated = Math.round((progress.percentage.value / 100) * pageCount.value)
|
||||
currentPage.value = Math.max(0, Math.min(estimated - 1, pageCount.value - 1))
|
||||
}
|
||||
|
||||
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
|
||||
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
|
||||
}
|
||||
|
||||
if (seriesFlowEnabled.value) void loadNextBook(seriesId.value, props.bookId)
|
||||
|
||||
readerReady = true
|
||||
if (scrollMode.value !== 'paginated') {
|
||||
await scrollContinuousToPage(currentPage.value, true)
|
||||
stripImagesReady.value = true
|
||||
}
|
||||
schedulePreload(currentPage.value)
|
||||
} catch {
|
||||
error.value = 'reader.pageImages.loadFailed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
await progress.load()
|
||||
await bookSettings.load()
|
||||
|
||||
applySettings(bookSettings.effective.value as CbxReaderSettings)
|
||||
|
||||
await load()
|
||||
const saved = progress.pageNumber.value
|
||||
if (saved && saved > 1) {
|
||||
currentPage.value = Math.min(saved - 1, pageCount.value - 1)
|
||||
} else if (progress.percentage.value > 0 && pageCount.value > 1) {
|
||||
const estimated = Math.round((progress.percentage.value / 100) * pageCount.value)
|
||||
currentPage.value = Math.max(0, Math.min(estimated - 1, pageCount.value - 1))
|
||||
}
|
||||
|
||||
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
|
||||
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
|
||||
}
|
||||
|
||||
if (seriesFlowEnabled.value) void loadNextBook(seriesId.value, props.bookId)
|
||||
|
||||
readerReady = true
|
||||
if (scrollMode.value !== 'paginated') {
|
||||
await scrollContinuousToPage(currentPage.value, true)
|
||||
stripImagesReady.value = true
|
||||
}
|
||||
schedulePreload(currentPage.value)
|
||||
void initializeReader()
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
@@ -782,6 +859,9 @@ onBeforeRouteLeave(async () => {
|
||||
// Before unmount, because useReadingSession ends the session from its own unmounted hook, which
|
||||
// runs first and would otherwise miss a page turn still inside the save debounce.
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
for (const image of preloadCache.values()) image.onload = null
|
||||
preloadCache.clear()
|
||||
void flushPendingProgress()
|
||||
})
|
||||
|
||||
@@ -794,13 +874,23 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 select-none overflow-hidden" :style="{ background: bgValue }">
|
||||
<span class="sr-only" role="status" aria-live="polite">{{ pageLabel }}</span>
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<div
|
||||
data-reader-controls
|
||||
@focusin="onControlsFocus"
|
||||
@focusout="onControlsBlur"
|
||||
class="absolute top-0 inset-x-0 z-50 transition-all duration-300"
|
||||
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
|
||||
>
|
||||
<div class="h-12 flex items-center gap-1 px-3 bg-background/90 backdrop-blur-md border-b border-border">
|
||||
<button class="viewer-btn" @click="router.back()"><ArrowLeft :size="16" /></button>
|
||||
<button
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:aria-label="t('common.back')"
|
||||
@click="goBack"
|
||||
>
|
||||
<ArrowLeft :size="16" />
|
||||
</button>
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-center px-2">
|
||||
<span v-if="bookTitle" class="text-sm font-serif text-foreground truncate leading-tight">{{ bookTitle }}</span>
|
||||
<span class="text-xs text-muted-foreground tabular-nums">{{ pageLabel }}</span>
|
||||
@@ -817,7 +907,12 @@ onUnmounted(() => {
|
||||
<div class="hidden items-center md:flex" :aria-label="t('reader.cbz.zoomControls')" role="group">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" :disabled="!canZoomOut" :aria-label="t('reader.cbz.zoomOut')" @click="zoomOut">
|
||||
<button
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:disabled="!canZoomOut"
|
||||
:aria-label="t('reader.cbz.zoomOut')"
|
||||
@click="zoomOut"
|
||||
>
|
||||
<Minus :size="15" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -826,7 +921,12 @@ onUnmounted(() => {
|
||||
<span class="min-w-12 text-center text-xs tabular-nums text-muted-foreground" aria-live="polite">{{ zoomPercent }}%</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" :disabled="!canZoomIn" :aria-label="t('reader.cbz.zoomIn')" @click="zoomIn">
|
||||
<button
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:disabled="!canZoomIn"
|
||||
:aria-label="t('reader.cbz.zoomIn')"
|
||||
@click="zoomIn"
|
||||
>
|
||||
<Plus :size="15" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -835,7 +935,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" :aria-label="fullscreenLabel" @click="toggleFullscreen">
|
||||
<button
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:aria-label="fullscreenLabel"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<Minimize v-if="isFullscreen" :size="15" />
|
||||
<Maximize v-else :size="15" />
|
||||
</button>
|
||||
@@ -846,20 +950,30 @@ onUnmounted(() => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="viewer-btn"
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:class="isPinned ? '!bg-muted !text-primary' : ''"
|
||||
:aria-label="isPinned ? 'Unpin menu' : 'Pin menu'"
|
||||
:aria-label="t(isPinned ? 'reader.pageImages.unpin' : 'reader.pageImages.pin')"
|
||||
@click="togglePinned"
|
||||
>
|
||||
<PinOff v-if="isPinned" :size="15" />
|
||||
<Pin v-else :size="15" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ isPinned ? 'Unpin menu' : 'Pin menu' }}</TooltipContent>
|
||||
<TooltipContent>{{ t(isPinned ? 'reader.pageImages.unpin' : 'reader.pageImages.pin') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
v-if="props.source === 'djvu'"
|
||||
type="button"
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:aria-label="t('reader.djvu.pageText')"
|
||||
:title="t('reader.djvu.pageText')"
|
||||
@click="openPageText"
|
||||
>
|
||||
<FileText :size="16" />
|
||||
</button>
|
||||
<template v-if="isCompact">
|
||||
<button
|
||||
class="viewer-btn"
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:class="showSettings ? '!bg-muted !text-foreground' : ''"
|
||||
:title="t('reader.settings.title')"
|
||||
:aria-label="t('reader.settings.ariaLabel')"
|
||||
@@ -881,7 +995,7 @@ onUnmounted(() => {
|
||||
<Popover v-else :open="showSettings" @update:open="onSettingsOpenChange">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="viewer-btn"
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:class="showSettings ? '!bg-muted !text-foreground' : ''"
|
||||
:title="t('reader.settings.title')"
|
||||
:aria-label="t('reader.settings.ariaLabel')"
|
||||
@@ -933,11 +1047,14 @@ onUnmounted(() => {
|
||||
<img
|
||||
v-if="renderLeftPage !== null"
|
||||
:src="pageUrl(renderLeftPage)"
|
||||
:key="`${renderLeftPage}:${imageGeneration}`"
|
||||
:data-page="renderLeftPage"
|
||||
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
|
||||
:style="{ maxWidth: '100%', maxHeight: '100%' }"
|
||||
alt=""
|
||||
:alt="t('reader.pageImages.pageAlt', { page: renderLeftPage + 1 })"
|
||||
draggable="false"
|
||||
@load="onPaginatedImageLoad(renderLeftPage, $event)"
|
||||
@load="onPaginatedLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
|
||||
</div>
|
||||
@@ -945,11 +1062,14 @@ onUnmounted(() => {
|
||||
<img
|
||||
v-if="renderRightPage !== null"
|
||||
:src="pageUrl(renderRightPage)"
|
||||
:key="`${renderRightPage}:${imageGeneration}`"
|
||||
:data-page="renderRightPage"
|
||||
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
|
||||
:style="{ maxWidth: '100%', maxHeight: '100%' }"
|
||||
alt=""
|
||||
:alt="t('reader.pageImages.pageAlt', { page: renderRightPage + 1 })"
|
||||
draggable="false"
|
||||
@load="onPaginatedImageLoad(renderRightPage, $event)"
|
||||
@load="onPaginatedLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
|
||||
</div>
|
||||
@@ -958,10 +1078,13 @@ onUnmounted(() => {
|
||||
<img
|
||||
v-else-if="renderSinglePage !== null"
|
||||
:src="pageUrl(renderSinglePage)"
|
||||
:key="`${renderSinglePage}:${imageGeneration}`"
|
||||
:data-page="renderSinglePage"
|
||||
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
|
||||
alt=""
|
||||
:alt="t('reader.pageImages.pageAlt', { page: renderSinglePage + 1 })"
|
||||
draggable="false"
|
||||
@load="onPaginatedImageLoad(renderSinglePage, $event)"
|
||||
@load="onPaginatedLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -982,12 +1105,14 @@ onUnmounted(() => {
|
||||
>
|
||||
<img
|
||||
:src="pageUrl(page.index)"
|
||||
:key="`${page.index}:${imageGeneration}`"
|
||||
:class="stripImageClass"
|
||||
:style="stripImageStyle(page.index)"
|
||||
alt=""
|
||||
:alt="t('reader.pageImages.pageAlt', { page: page.index + 1 })"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
@load="onStripImageLoad(page.index, $event)"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1003,6 +1128,9 @@ onUnmounted(() => {
|
||||
|
||||
<!-- ── Footer ──────────────────────────────────────────────────────────── -->
|
||||
<div
|
||||
data-reader-controls
|
||||
@focusin="onControlsFocus"
|
||||
@focusout="onControlsBlur"
|
||||
class="absolute bottom-0 inset-x-0 z-50 transition-all duration-300"
|
||||
:class="footerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-full pointer-events-none'"
|
||||
>
|
||||
@@ -1010,12 +1138,25 @@ onUnmounted(() => {
|
||||
<div class="hidden sm:block">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" @click="goToPage(0)"><ChevronsLeft :size="16" /></button>
|
||||
<button
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:aria-label="t('reader.cbz.firstPage')"
|
||||
@click="firstPage"
|
||||
>
|
||||
<ChevronsLeft :size="16" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('reader.cbz.firstPage') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button class="viewer-btn" :disabled="!canGoPrev" @click="prevPage"><ChevronLeft :size="16" /></button>
|
||||
<button
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:disabled="!canGoPrev"
|
||||
:aria-label="t('reader.pageImages.previousPage')"
|
||||
@click="prevPage"
|
||||
>
|
||||
<ChevronLeft :size="16" />
|
||||
</button>
|
||||
|
||||
<div class="relative flex-1 min-w-0 flex items-center h-6">
|
||||
<input
|
||||
@@ -1029,18 +1170,32 @@ onUnmounted(() => {
|
||||
accentColor: 'var(--primary)',
|
||||
background: `linear-gradient(to right, var(--primary) ${sliderFillPercent}%, var(--border) ${sliderFillPercent}%)`,
|
||||
}"
|
||||
@input="goToPage(Number(($event.target as HTMLInputElement).value))"
|
||||
:aria-label="t('reader.pageImages.goToPage')"
|
||||
@input="onPageSlider"
|
||||
/>
|
||||
<datalist id="cbz-ticks">
|
||||
<option v-for="t in sliderTicks" :key="t" :value="t" />
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<button class="viewer-btn" :disabled="!canGoNext" @click="nextPage"><ChevronRight :size="16" /></button>
|
||||
<button
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:disabled="!canGoNext"
|
||||
:aria-label="t('reader.pageImages.nextPage')"
|
||||
@click="nextPage"
|
||||
>
|
||||
<ChevronRight :size="16" />
|
||||
</button>
|
||||
<div class="hidden sm:block">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
|
||||
<button
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
:aria-label="t('reader.cbz.lastPage')"
|
||||
@click="lastPage"
|
||||
>
|
||||
<ChevronsRight :size="16" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('reader.cbz.lastPage') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -1061,9 +1216,44 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="absolute inset-0 flex items-center justify-center z-50 p-8 text-center bg-background">
|
||||
<p class="text-sm text-foreground">{{ error }}</p>
|
||||
<div class="flex flex-col items-center gap-4" role="alert">
|
||||
<p class="text-sm text-foreground">{{ t(error) }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
@click="initializeReader"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
<button type="button" class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2" @click="goBack">
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="imageError && !error && !loading" class="absolute inset-0 z-40 flex items-center justify-center bg-background/95 p-8">
|
||||
<div class="flex flex-col items-center gap-4 text-center" role="alert">
|
||||
<p class="text-sm text-foreground">{{ t('reader.pageImages.pageFailed') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
|
||||
@click="retryImages"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
<button type="button" class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2" @click="goBack">
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DjvuTextSheet
|
||||
v-if="props.source === 'djvu'"
|
||||
:open="showPageText"
|
||||
:file-id="props.fileId"
|
||||
:page-index="currentPage"
|
||||
@update:open="onTextOpenChange"
|
||||
/>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="!loading && !error && pageCount > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-border z-30">
|
||||
<div class="h-full bg-primary/60 transition-all duration-300" :style="{ width: `${progressPercent}%` }" />
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { effectScope } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '@/lib/api'
|
||||
import { useCbz } from './useCbz'
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
|
||||
|
||||
describe('page image document loading', () => {
|
||||
const scopes: ReturnType<typeof effectScope>[] = []
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
afterEach(() => {
|
||||
for (const scope of scopes.splice(0)) scope.stop()
|
||||
})
|
||||
|
||||
function reader(source: 'djvu' | 'cbz' = 'djvu') {
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
return scope.run(() => useCbz(22, 11, source))!
|
||||
}
|
||||
|
||||
it('uses DjVu routes and loads title and primary series', async () => {
|
||||
vi.mocked(api)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ pageCount: 2 })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ title: 'Scanned book', seriesMemberships: [{ displayOrder: 1, seriesId: 9 }] })))
|
||||
const document = reader()
|
||||
await document.load()
|
||||
expect(document.loading.value).toBe(false)
|
||||
expect(document.error.value).toBeNull()
|
||||
expect(document.pageCount.value).toBe(2)
|
||||
expect(document.bookTitle.value).toBe('Scanned book')
|
||||
expect(document.seriesId.value).toBe(9)
|
||||
expect(document.pageUrl(1)).toBe('/api/v1/djvu/files/22/pages/1?size=2560')
|
||||
})
|
||||
|
||||
it('opens the pages when only the book detail request fails', async () => {
|
||||
vi.mocked(api)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ pageCount: 4 })))
|
||||
.mockResolvedValueOnce(new Response('', { status: 500 }))
|
||||
const document = reader('cbz')
|
||||
await document.load()
|
||||
expect(document.error.value).toBeNull()
|
||||
expect(document.pageCount.value).toBe(4)
|
||||
expect(document.bookTitle.value).toBe('')
|
||||
expect(document.seriesId.value).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps existing comic routes', () => {
|
||||
expect(reader('cbz').pageUrl(0)).toBe('/api/v1/cbz/files/22/pages/0')
|
||||
})
|
||||
|
||||
it('exposes a translated error key and allows retry after failed requests', async () => {
|
||||
vi.mocked(api).mockRejectedValue(new Error('offline'))
|
||||
const document = reader()
|
||||
await document.load()
|
||||
expect(document.loading.value).toBe(false)
|
||||
expect(document.error.value).toBe('reader.pageImages.loadFailed')
|
||||
vi.mocked(api)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ pageCount: 3 })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ title: 'Restored book' })))
|
||||
await document.load()
|
||||
expect(document.error.value).toBeNull()
|
||||
expect(document.pageCount.value).toBe(3)
|
||||
})
|
||||
|
||||
it('aborts outstanding requests when disposed and ignores late responses', async () => {
|
||||
const releases: Array<(value: Response) => void> = []
|
||||
vi.mocked(api).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releases.push(resolve)
|
||||
}),
|
||||
)
|
||||
const document = reader()
|
||||
const loading = document.load()
|
||||
const firstSignal = vi.mocked(api).mock.calls[0]?.[1]?.signal
|
||||
scopes[0]!.stop()
|
||||
expect(firstSignal?.aborted).toBe(true)
|
||||
releases[0]!(new Response(JSON.stringify({ pageCount: 2 })))
|
||||
releases[1]!(new Response(JSON.stringify({ title: 'late' })))
|
||||
await loading
|
||||
expect(document.bookTitle.value).toBe('')
|
||||
expect(document.pageCount.value).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
import type { BookDetail, ComicPageCountResponse } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
@@ -9,29 +9,51 @@ function primarySeriesId(book: BookDetail | null): number | null {
|
||||
return primary?.seriesId ?? book?.seriesId ?? null
|
||||
}
|
||||
|
||||
export function useCbz(fileId: number, bookId: number) {
|
||||
export function useCbz(fileId: number, bookId: number, source: 'cbz' | 'djvu' = 'cbz') {
|
||||
const pageCount = ref(0)
|
||||
const bookTitle = ref('')
|
||||
const seriesId = ref<number | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let controller: AbortController | null = null
|
||||
let disposed = false
|
||||
onScopeDispose(() => {
|
||||
disposed = true
|
||||
controller?.abort()
|
||||
})
|
||||
|
||||
function pageUrl(n: number): string {
|
||||
return `/api/v1/cbz/files/${fileId}/pages/${n}`
|
||||
return `/api/v1/${source}/files/${fileId}/pages/${n}${source === 'djvu' ? '?size=2560' : ''}`
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [pagesRes, bookRes] = await Promise.all([api(`/api/v1/cbz/files/${fileId}/pages`), api(`/api/v1/books/${bookId}`)])
|
||||
if (!pagesRes.ok) {
|
||||
error.value = 'Failed to load comic'
|
||||
loading.value = false
|
||||
return
|
||||
controller?.abort()
|
||||
const request = new AbortController()
|
||||
controller = request
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// The book detail only supplies the title and series; the pages open without it.
|
||||
const [pagesRes, bookRes] = await Promise.all([
|
||||
api(`/api/v1/${source}/files/${fileId}/pages`, { signal: request.signal }),
|
||||
api(`/api/v1/books/${bookId}`, { signal: request.signal }).catch(() => null),
|
||||
])
|
||||
if (!pagesRes.ok) throw new Error('Reader request failed')
|
||||
const [pagesData, bookData] = await Promise.all([
|
||||
pagesRes.json() as Promise<ComicPageCountResponse>,
|
||||
bookRes?.ok ? (bookRes.json() as Promise<BookDetail>).catch(() => null) : Promise.resolve(null),
|
||||
])
|
||||
if (!Number.isInteger(pagesData.pageCount) || pagesData.pageCount < 1) throw new Error('Invalid page count')
|
||||
if (disposed || controller !== request) return
|
||||
pageCount.value = pagesData.pageCount
|
||||
bookTitle.value = bookData?.title || bookData?.files?.find((file) => file.id === fileId)?.filename?.replace(/\.[^.]+$/, '') || ''
|
||||
seriesId.value = primarySeriesId(bookData)
|
||||
} catch {
|
||||
if (!disposed && controller === request && !request.signal.aborted) error.value = 'reader.pageImages.loadFailed'
|
||||
} finally {
|
||||
if (!disposed && controller === request) loading.value = false
|
||||
}
|
||||
const [pagesData, bookData] = await Promise.all([pagesRes.json() as Promise<ComicPageCountResponse>, bookRes.ok ? bookRes.json() : null])
|
||||
pageCount.value = pagesData.pageCount
|
||||
bookTitle.value = bookData?.title ?? ''
|
||||
seriesId.value = primarySeriesId(bookData)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return { pageCount, bookTitle, seriesId, loading, error, pageUrl, load }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDjvuPageText } from './composables/useDjvuPageText'
|
||||
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
|
||||
const props = defineProps<{ open: boolean; fileId: number; pageIndex: number }>()
|
||||
const emit = defineEmits<{ 'update:open': [open: boolean] }>()
|
||||
const { t } = useI18n()
|
||||
const { text, loading, failed, loadText } = useDjvuPageText(props)
|
||||
|
||||
// Announces state changes only; the page text itself is left for the reader to navigate.
|
||||
const statusMessage = computed(() => {
|
||||
if (loading.value) return t('common.loading')
|
||||
if (failed.value) return t('reader.djvu.textFailed')
|
||||
if (!text.value) return t('reader.djvu.noText')
|
||||
return ''
|
||||
})
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
emit('update:open', open)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet :open="open" @update:open="onOpenChange">
|
||||
<SheetContent side="bottom" hide-close class="max-h-[85dvh] flex flex-col bg-card text-foreground">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ t('reader.djvu.pageText') }}</SheetTitle>
|
||||
<SheetDescription>{{ t('reader.djvu.textDescription') }}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetClose as-child>
|
||||
<button type="button" class="self-end rounded-md border border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-primary">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</SheetClose>
|
||||
<p class="sr-only" role="status" aria-live="polite">{{ statusMessage }}</p>
|
||||
<div class="min-h-0 overflow-auto select-text p-4">
|
||||
<p v-if="loading" class="text-muted-foreground">{{ t('common.loading') }}</p>
|
||||
<div v-else-if="failed" class="space-y-3">
|
||||
<p>{{ t('reader.djvu.textFailed') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-primary"
|
||||
@click="loadText"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="!text" class="text-muted-foreground">{{ t('reader.djvu.noText') }}</p>
|
||||
<p v-else class="whitespace-pre-wrap break-words" dir="auto">{{ text }}</p>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
import { effectScope, nextTick, reactive } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '@/lib/api'
|
||||
import { useDjvuPageText } from './useDjvuPageText'
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
|
||||
|
||||
describe('DjVu embedded page text', () => {
|
||||
let scope: ReturnType<typeof effectScope>
|
||||
beforeEach(() => {
|
||||
scope = effectScope()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
afterEach(() => scope.stop())
|
||||
|
||||
it('loads only while open and preserves multilingual text', async () => {
|
||||
vi.mocked(api).mockResolvedValue(new Response(JSON.stringify({ text: '日本語の本' })))
|
||||
const props = reactive({ open: false, fileId: 22, pageIndex: 0 })
|
||||
const reader = scope.run(() => useDjvuPageText(props))!
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
props.open = true
|
||||
await flushPromises()
|
||||
expect(api).toHaveBeenCalledWith('/api/v1/djvu/files/22/pages/0/text', expect.objectContaining({ signal: expect.any(AbortSignal) }))
|
||||
expect(reader.text.value).toBe('日本語の本')
|
||||
expect(reader.loading.value).toBe(false)
|
||||
expect(reader.failed.value).toBe(false)
|
||||
})
|
||||
|
||||
it('allows retry when decoding is unavailable and accepts pages without OCR', async () => {
|
||||
vi.mocked(api).mockResolvedValue(new Response('', { status: 503 }))
|
||||
const reader = scope.run(() => useDjvuPageText(reactive({ open: true, fileId: 22, pageIndex: 0 })))!
|
||||
await flushPromises()
|
||||
expect(reader.failed.value).toBe(true)
|
||||
vi.mocked(api).mockResolvedValue(new Response(JSON.stringify({ text: '' })))
|
||||
await reader.loadText()
|
||||
expect(reader.failed.value).toBe(false)
|
||||
expect(reader.text.value).toBe('')
|
||||
})
|
||||
|
||||
it('aborts stale requests and ignores text from an earlier page', async () => {
|
||||
let release!: (response: Response) => void
|
||||
vi.mocked(api)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ text: 'Current page' })))
|
||||
const props = reactive({ open: true, fileId: 22, pageIndex: 0 })
|
||||
const reader = scope.run(() => useDjvuPageText(props))!
|
||||
const oldSignal = vi.mocked(api).mock.calls[0]![1]!.signal
|
||||
props.pageIndex = 1
|
||||
await nextTick()
|
||||
expect(oldSignal?.aborted).toBe(true)
|
||||
await flushPromises()
|
||||
release(new Response(JSON.stringify({ text: 'Stale page' })))
|
||||
await flushPromises()
|
||||
expect(reader.text.value).toBe('Current page')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { onScopeDispose, ref, watch } from 'vue'
|
||||
import type { DjvuPageTextResponse } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export function useDjvuPageText(props: Readonly<{ open: boolean; fileId: number; pageIndex: number }>) {
|
||||
const text = ref('')
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
let request: AbortController | null = null
|
||||
|
||||
async function loadText() {
|
||||
request?.abort()
|
||||
if (!props.open) return
|
||||
text.value = ''
|
||||
failed.value = false
|
||||
const controller = new AbortController()
|
||||
request = controller
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api(`/api/v1/djvu/files/${props.fileId}/pages/${props.pageIndex}/text`, { signal: controller.signal })
|
||||
if (!response.ok) throw new Error('DjVu text request failed')
|
||||
const data = (await response.json()) as DjvuPageTextResponse
|
||||
if (typeof data.text !== 'string') throw new Error('Invalid DjVu text response')
|
||||
if (request === controller && !controller.signal.aborted) text.value = data.text
|
||||
} catch {
|
||||
if (request === controller && !controller.signal.aborted) failed.value = true
|
||||
} finally {
|
||||
if (request === controller) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.open, props.fileId, props.pageIndex], loadText, { immediate: true })
|
||||
onScopeDispose(() => request?.abort())
|
||||
|
||||
return { text, loading, failed, loadText }
|
||||
}
|
||||
@@ -21,7 +21,7 @@ describe('useSeriesNextBook', () => {
|
||||
|
||||
await load(42, 90)
|
||||
|
||||
expect(api).toHaveBeenCalledWith('/api/v1/series/42/books/90/next?formatGroup=cbx')
|
||||
expect(api).toHaveBeenCalledWith('/api/v1/series/42/books/90/next?formatGroup=cbx&includeDjvu=true')
|
||||
})
|
||||
|
||||
it('exposes the resolved next book', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export function useSeriesNextBook(formatGroup: ReaderFormatGroup) {
|
||||
if (seriesId === null) return
|
||||
|
||||
try {
|
||||
const res = await api(`/api/v1/series/${seriesId}/books/${bookId}/next?formatGroup=${formatGroup}`)
|
||||
const res = await api(`/api/v1/series/${seriesId}/books/${bookId}/next?formatGroup=${formatGroup}&includeDjvu=true`)
|
||||
if (!res.ok) return
|
||||
|
||||
const data = (await res.json()) as SeriesNextBookResponse
|
||||
|
||||
@@ -65,7 +65,8 @@ describe('formatFamily', () => {
|
||||
|
||||
it('is case insensitive and falls back for unknown extensions', () => {
|
||||
expect(formatFamily('EPUB')).toBe('ebook')
|
||||
expect(formatFamily('djvu')).toBe('other')
|
||||
expect(formatFamily('DJVU')).toBe('document')
|
||||
expect(formatFamily('unknown')).toBe('other')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const FAMILY_BY_FORMAT: Record<string, FormatFamily> = {
|
||||
azw3: 'kindle',
|
||||
azw: 'kindle',
|
||||
pdf: 'document',
|
||||
djvu: 'document',
|
||||
cbz: 'comic',
|
||||
cbr: 'comic',
|
||||
cb7: 'comic',
|
||||
|
||||
@@ -5099,6 +5099,7 @@
|
||||
"audiobook": "Audiobook"
|
||||
},
|
||||
"formats": {
|
||||
"djvu": "DjVu document",
|
||||
"epub": "EPUB e-book",
|
||||
"kepub": "KEPUB e-book",
|
||||
"pdf": "PDF document",
|
||||
@@ -10472,6 +10473,22 @@
|
||||
}
|
||||
},
|
||||
"reader": {
|
||||
"pageImages": {
|
||||
"loadFailed": "Could not open this document. Check your access or try again.",
|
||||
"pageFailed": "This page could not be loaded. Retry when the server is available.",
|
||||
"pageAlt": "Page {page}",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"goToPage": "Go to page",
|
||||
"pin": "Pin reader menu",
|
||||
"unpin": "Unpin reader menu"
|
||||
},
|
||||
"djvu": {
|
||||
"pageText": "Embedded page text",
|
||||
"textDescription": "Text stored in the DjVu document. OCR may contain mistakes.",
|
||||
"noText": "This page has no embedded text.",
|
||||
"textFailed": "Could not load this page's embedded text."
|
||||
},
|
||||
"retry": "Retry",
|
||||
"pdf": {
|
||||
"openError": "Unable to open this PDF",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# DjVu reader
|
||||
|
||||
BookOrbit opens standalone and bundled DjVu documents with server-side DjVuLibre decoding.
|
||||
The web reader supports page navigation, fit modes, zoom, spreads, virtualized continuous
|
||||
scrolling, per-account progress and view preferences, and copying embedded OCR text from
|
||||
the page-text panel. Text is the document's embedded OCR, not newly generated recognition.
|
||||
|
||||
DjVuLibre is included in the production image. Native development requires `ddjvu` and
|
||||
`djvused` on PATH: `brew install djvulibre` on macOS or
|
||||
`sudo apt-get install djvulibre-bin util-linux` on Debian/Ubuntu. Linux also requires `prlimit`.
|
||||
DjVuLibre is a separately installed executable dependency, invoked without a shell.
|
||||
Its source and license are available from [DjVuLibre upstream](https://djvu.sourceforge.net/).
|
||||
When redistributing the container, preserve the distribution's package license notices and
|
||||
provide corresponding DjVuLibre source as required by its GPL license.
|
||||
|
||||
## Bounds and caching
|
||||
|
||||
The configuration names and defaults are documented in `server/.env.example` and validated
|
||||
in `server/src/config/env.validation.ts`. By default there are two concurrent operations,
|
||||
a 30-second native-process timeout, a 256 MiB source limit and a 64 MiB rendered-page cache.
|
||||
Reader requests are served first and queue at most 16 deep; overflow and reader requests
|
||||
still queued after the timeout return HTTP 503. Import, upload, Book Dock and cover work wait
|
||||
for a free decoder instead, because their callers already bound how many run at once.
|
||||
Missing DjVuLibre tools return HTTP 503. Decoder failures and unsupported documents return
|
||||
HTTP 422. The reader offers retry controls and preloads only the next spread.
|
||||
|
||||
Private source snapshots are limited to four documents and 512 MiB total. They are removed
|
||||
on eviction and graceful shutdown. Each server process owns its caches; no library-wide
|
||||
pre-rendering is performed. Source device, inode, size, modification time and change time
|
||||
identify a snapshot. All access checks precede cache access or conditional responses.
|
||||
Pages use private revalidation with ETags and bounded WebP output at quality 85. Revalidation,
|
||||
cached pages and repeat page counts are answered from the file's identity and a remembered
|
||||
page count, without a decoder slot or a source snapshot.
|
||||
|
||||
Maximum supported source dimensions are 40 million pixels per page, with at most 20,000 pages.
|
||||
Rendering uses 1280, 1920 or 2560 pixels on the longest edge; the web reader requests 2560
|
||||
so fit-width and zoom stay sharp. Linux decoder processes run under `prlimit` with 768 MiB
|
||||
address space and 64 MiB output-file limits, and a CPU limit matching `DJVU_TIMEOUT_MS`.
|
||||
macOS has the same queue, input, image-output and wall-clock limits, but does not use `prlimit`.
|
||||
Production deployments should also set a container memory limit sized for the Node process,
|
||||
image encoding and configured decoder concurrency.
|
||||
|
||||
## Supported files and API
|
||||
|
||||
Library scanning and uploads recognize `.djvu`. Uploaded content must match the DjVu magic
|
||||
and form type; full structural and resource validation occurs before native reader decoding.
|
||||
Library upload, adding an edition and Book Dock use the shared upload-format contract.
|
||||
DjVu is available in library format restrictions, priorities, filters and document groups.
|
||||
Duplicate detection classifies DjVu alongside other ebooks. Book pages and covers do not treat
|
||||
DjVu as a comic, although it shares the image reader and its `cbx` settings.
|
||||
Files stored before DjVu was a content format were recorded as supplements; the first scan after
|
||||
upgrading is a full scan, which imports DjVu-only folders and promotes those files to content.
|
||||
Scanning, uploads, Book Dock and cover re-extraction generate a first-page cover and page count
|
||||
through the same bounded decoder as the reader. Titles fall back to the filename; OPF sidecars
|
||||
and metadata providers remain available for bibliographic metadata.
|
||||
Existing imports can use cover re-extraction to generate missing artwork. DjVu metadata
|
||||
write-back is not supported.
|
||||
Indirect documents and standalone pages requiring external dictionaries are rejected.
|
||||
Bundle offsets must refer to validated internal components. Reference names containing path
|
||||
separators, traversal or URL syntax are rejected. Files are copied through an opened descriptor
|
||||
to private temporary directories, preventing subsequent source replacement from changing the
|
||||
validated bytes decoded by the native tools.
|
||||
|
||||
Authenticated routes, with zero-based page indices:
|
||||
|
||||
- `GET /api/v1/djvu/files/:fileId/pages`: `{ pageCount }`
|
||||
- `GET /api/v1/djvu/files/:fileId/pages/:pageIndex?size=1920`: WebP image; supported sizes are 1280, 1920 and 2560
|
||||
- `GET /api/v1/djvu/files/:fileId/pages/:pageIndex/text`: `{ text }`; empty when the page has no OCR
|
||||
|
||||
Progress uses the existing one-based `pageNumber` contract. DjVu uses the existing `cbx`
|
||||
settings group; no new serialized settings enum is introduced. KOReader DjVu progress is
|
||||
treated as paged. Released iOS builds cannot open DjVu, so answers that pick a file for a client
|
||||
leave it out unless the caller opts in: series continuation
|
||||
(`GET /api/v1/series/:seriesId/books/:bookId/next`) offers DjVu only with `includeDjvu=true`,
|
||||
which the web reader sends, and the Currently Reading and notebook `readFileId` never name a
|
||||
DjVu file. Native iOS DjVu reading is outside this web-reader implementation.
|
||||
Original downloads, OPDS and KOReader serve `image/vnd.djvu`. DjVu can be selected for ordinary
|
||||
email recipients; Kindle email and native Kobo sync retain their supported-format restrictions.
|
||||
Apply the generated database migration to enable saving DjVu email format preferences.
|
||||
Page text is available for selection and copying; a positioned text layer, full-document
|
||||
search, outlines and annotations are not implemented.
|
||||
|
||||
Run focused tests with `pnpm --dir server exec vitest run src/modules/reader/djvu` and
|
||||
`pnpm --dir server exec vitest run src/modules/djvu`, plus
|
||||
`pnpm e2e:run -- djvu-reader`. The E2E suite requires native tools and verifies real decoding,
|
||||
OCR, authorization, conditional responses, progress, settings isolation, uploads and covers.
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface DjvuPageTextResponse {
|
||||
text: string;
|
||||
}
|
||||
@@ -88,4 +88,5 @@ export * from "./tts";
|
||||
export * from "./watch-download";
|
||||
export * from "./podcast";
|
||||
export * from "./delivery";
|
||||
export * from "./djvu";
|
||||
export * from "./email";
|
||||
|
||||
@@ -20,6 +20,7 @@ export const DEFAULT_FORMAT_PRIORITY = [
|
||||
"azw3",
|
||||
"azw",
|
||||
"fb2",
|
||||
"djvu",
|
||||
"m4b",
|
||||
"mp3",
|
||||
"m4a",
|
||||
|
||||
@@ -29,6 +29,7 @@ export const READER_OPENABLE_FORMATS = new Set([
|
||||
"cbz",
|
||||
"cbr",
|
||||
"cb7",
|
||||
"djvu",
|
||||
// audio reader
|
||||
"m4b",
|
||||
"mp3",
|
||||
@@ -50,6 +51,7 @@ export const FORMAT_TO_GROUP: Record<string, ReaderFormatGroup> = {
|
||||
cbz: "cbx",
|
||||
cbr: "cbx",
|
||||
cb7: "cbx",
|
||||
djvu: "cbx",
|
||||
m4b: "audio",
|
||||
mp3: "audio",
|
||||
m4a: "audio",
|
||||
@@ -62,6 +64,16 @@ export function getFormatGroup(format: string): ReaderFormatGroup {
|
||||
return FORMAT_TO_GROUP[format.toLowerCase()] ?? "epub";
|
||||
}
|
||||
|
||||
/**
|
||||
* Openable formats that released native clients cannot open. An answer that picks a file for a
|
||||
* client offers one of these only to a caller that says it can open it.
|
||||
*/
|
||||
export const OPT_IN_READER_FORMATS: ReadonlySet<string> = new Set(["djvu"]);
|
||||
|
||||
export function isOptInReaderFormat(format: string | null | undefined): boolean {
|
||||
return OPT_IN_READER_FORMATS.has((format ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
/** Formats a given reader can open, so a caller can ask for "another file this same reader handles". */
|
||||
export function getOpenableFormatsForGroup(group: ReaderFormatGroup): string[] {
|
||||
return Object.entries(FORMAT_TO_GROUP)
|
||||
|
||||
@@ -4,6 +4,7 @@ export const UPLOAD_SUPPORTED_FORMATS = [
|
||||
"epub",
|
||||
"kepub",
|
||||
"pdf",
|
||||
"djvu",
|
||||
"mobi",
|
||||
"azw",
|
||||
"azw3",
|
||||
|
||||
@@ -16,6 +16,26 @@ const SHARED_AUTH_TYPE_PATHS = Object.freeze(["packages/types/src/auth.ts", "pac
|
||||
const SHARED_TYPES_PATHS = Object.freeze(["packages/types/src/**"]);
|
||||
|
||||
export const E2E_SUITES = Object.freeze({
|
||||
"djvu-reader": {
|
||||
id: "djvu-reader",
|
||||
name: "DjVuLibre Reader",
|
||||
timeout: 30,
|
||||
lane: "full",
|
||||
description: "Native decoding, OCR, authenticated cache revalidation, paged progress and reader preference isolation",
|
||||
vitestTarget: "test/djvu-reader.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/djvu-reader-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
changedPaths: [
|
||||
"server/src/modules/reader/djvu/**",
|
||||
"server/src/modules/djvu/**",
|
||||
"client/src/features/reader/**",
|
||||
"server/test/djvu-reader.e2e-spec.ts",
|
||||
"server/test/e2e/djvu-fixture.ts",
|
||||
...SHARED_TYPES_PATHS,
|
||||
...SHARED_DB_AND_HELPER_PATHS,
|
||||
],
|
||||
},
|
||||
"read-aloud-delivery": {
|
||||
id: "read-aloud-delivery",
|
||||
name: "Read-aloud Delivery",
|
||||
|
||||
@@ -48,6 +48,12 @@ COVER_SLOTS_BACKFILL_MODE=background
|
||||
# FILE_WRITE_DEBOUNCE_MS=3000
|
||||
# FILE_WRITE_MAX_CONCURRENT_WRITES=2
|
||||
|
||||
# DjVuLibre reader limits. ddjvu and djvused must be on PATH.
|
||||
# DJVU_MAX_CONCURRENT=2
|
||||
# DJVU_TIMEOUT_MS=30000
|
||||
# DJVU_MAX_SOURCE_BYTES=268435456
|
||||
# DJVU_CACHE_BYTES=67108864
|
||||
|
||||
# Podcast security and resource limits
|
||||
# Required in production. Development falls back to JWT_SECRET when unset.
|
||||
# Existing production installs must initially use their current JWT_SECRET so encrypted feed URLs remain readable.
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
bookRequestConfig,
|
||||
coverSlotsConfig,
|
||||
dbConfig,
|
||||
djvuConfig,
|
||||
emailConfig,
|
||||
fileWriteConfig,
|
||||
migrationConfig,
|
||||
@@ -33,6 +34,7 @@ import { AppSettingsModule } from './modules/app-settings/app-settings.module';
|
||||
import { CollectionModule } from './modules/collection/collection.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CbzModule } from './modules/reader/cbz/cbz.module';
|
||||
import { DjvuModule } from './modules/reader/djvu/djvu.module';
|
||||
import { BookmarkModule } from './modules/bookmark/bookmark.module';
|
||||
import { BookJournalModule } from './modules/book-journal/book-journal.module';
|
||||
import { NotebookModule } from './modules/notebook/notebook.module';
|
||||
@@ -114,6 +116,7 @@ import { PodcastModule } from './modules/podcast/podcast.module';
|
||||
oidcRuntimeConfig,
|
||||
podcastConfig,
|
||||
audiolessEpubConfig,
|
||||
djvuConfig,
|
||||
],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
@@ -162,6 +165,7 @@ import { PodcastModule } from './modules/podcast/podcast.module';
|
||||
DashboardModule,
|
||||
HealthModule,
|
||||
CbzModule,
|
||||
DjvuModule,
|
||||
ReaderPreferencesModule,
|
||||
UserPreferencesModule,
|
||||
EpubModule,
|
||||
|
||||
@@ -53,6 +53,13 @@ export const fileWriteConfig = registerAs('fileWrite', () => ({
|
||||
maxConcurrentWrites: parsePositiveInteger(process.env.FILE_WRITE_MAX_CONCURRENT_WRITES, 2),
|
||||
}));
|
||||
|
||||
export const djvuConfig = registerAs('djvu', () => ({
|
||||
maxConcurrent: parsePositiveInteger(process.env.DJVU_MAX_CONCURRENT, 2),
|
||||
timeoutMs: parsePositiveInteger(process.env.DJVU_TIMEOUT_MS, 30_000),
|
||||
maxSourceBytes: parsePositiveInteger(process.env.DJVU_MAX_SOURCE_BYTES, 256 * 1024 * 1024),
|
||||
cacheBytes: parsePositiveInteger(process.env.DJVU_CACHE_BYTES, 64 * 1024 * 1024),
|
||||
}));
|
||||
|
||||
export const audiolessEpubConfig = registerAs('audiolessEpub', () => ({
|
||||
maxConcurrentBuilds: parsePositiveInteger(process.env.AUDIOLESS_EPUB_MAX_CONCURRENT_BUILDS, 2),
|
||||
maxSourceEntries: parsePositiveInteger(process.env.AUDIOLESS_EPUB_MAX_SOURCE_ENTRIES, 50_000),
|
||||
|
||||
@@ -79,6 +79,20 @@ const envSchema = z.object({
|
||||
.optional(),
|
||||
FILE_WRITE_DEBOUNCE_MS: z.coerce.number().int().positive().optional(),
|
||||
FILE_WRITE_MAX_CONCURRENT_WRITES: z.coerce.number().int().positive().optional(),
|
||||
DJVU_MAX_CONCURRENT: z.coerce.number().int().positive().max(8).optional(),
|
||||
DJVU_TIMEOUT_MS: z.coerce.number().int().positive().max(120_000).optional(),
|
||||
DJVU_MAX_SOURCE_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(512 * 1024 * 1024)
|
||||
.optional(),
|
||||
DJVU_CACHE_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(512 * 1024 * 1024)
|
||||
.optional(),
|
||||
AUDIOLESS_EPUB_MAX_CONCURRENT_BUILDS: z.coerce.number().int().positive().max(32).optional(),
|
||||
AUDIOLESS_EPUB_MAX_SOURCE_ENTRIES: z.coerce.number().int().positive().optional(),
|
||||
AUDIOLESS_EPUB_MAX_METADATA_BYTES: z.coerce.number().int().positive().optional(),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "email_recipients" DROP CONSTRAINT "email_recipients_preferred_format_chk";--> statement-breakpoint
|
||||
ALTER TABLE "email_recipients" ADD CONSTRAINT "email_recipients_preferred_format_chk" CHECK ("email_recipients"."preferred_format" is null or "email_recipients"."preferred_format" in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'djvu'));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -792,6 +792,13 @@
|
||||
"when": 1791941743914,
|
||||
"tag": "0112_add_server_connection_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 113,
|
||||
"version": "7",
|
||||
"when": 1792002996105,
|
||||
"tag": "0113_add_djvu_email_format",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export const emailRecipients = pgTable(
|
||||
check('email_recipients_device_type_chk', sql`${t.deviceType} is null or ${t.deviceType} in ('kindle', 'kobo', 'other')`),
|
||||
check(
|
||||
'email_recipients_preferred_format_chk',
|
||||
sql`${t.preferredFormat} is null or ${t.preferredFormat} in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr')`,
|
||||
sql`${t.preferredFormat} is null or ${t.preferredFormat} in ('epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'djvu')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('book duplicate normalization', () => {
|
||||
|
||||
it('maps formats into duplicate media families', () => {
|
||||
expect(mediaFamilyForFormat('EPUB')).toBe('ebook');
|
||||
expect(mediaFamilyForFormat('DJVU')).toBe('ebook');
|
||||
expect(mediaFamilyForFormat('cbz')).toBe('comic');
|
||||
expect(mediaFamilyForFormat('m4b')).toBe('audiobook');
|
||||
expect(mediaFamilyForFormat(null)).toBe('unknown');
|
||||
|
||||
@@ -46,6 +46,6 @@ export function mediaFamilyForFormat(format: string | null): 'ebook' | 'comic' |
|
||||
if (!normalized) return 'unknown';
|
||||
if (['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'].includes(normalized)) return 'audiobook';
|
||||
if (['cbz', 'cbr', 'cb7', 'cbx'].includes(normalized)) return 'comic';
|
||||
if (['epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'kepub'].includes(normalized)) return 'ebook';
|
||||
if (['epub', 'pdf', 'djvu', 'mobi', 'azw', 'azw3', 'fb2', 'kepub'].includes(normalized)) return 'ebook';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ describe('BookDuplicatesRepository', () => {
|
||||
"'exact_metadata', md5(eligible.title_key || chr(31) || array_to_string(eligible.author_keys, chr(30)) || chr(31) || family)",
|
||||
);
|
||||
expect(query.match(/\bmd5\(/g)).toHaveLength(1);
|
||||
expect(query).toContain("'pdf', 'djvu'");
|
||||
});
|
||||
|
||||
it('limits identical-file signatures to content files in accessible, non-processing books', async () => {
|
||||
@@ -139,6 +140,7 @@ describe('BookDuplicatesRepository', () => {
|
||||
expect(query).toContain('blocked AS');
|
||||
expect(query).toContain('a.author_key = b.author_key');
|
||||
expect(query).toContain('a.family = b.family');
|
||||
expect(query).toContain("'pdf', 'djvu'");
|
||||
expect(query).toContain("author_key <> ''");
|
||||
expect(query).toContain('GROUP BY a.book_id, b.book_id');
|
||||
expect(rendered.params).toContain(0.85);
|
||||
|
||||
@@ -224,7 +224,7 @@ export class BookDuplicatesRepository {
|
||||
SELECT DISTINCT CASE
|
||||
WHEN lower(coalesce(f.format, '')) IN ('m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac') THEN 'audiobook'
|
||||
WHEN lower(coalesce(f.format, '')) IN ('cbz', 'cbr', 'cb7', 'cbx') THEN 'comic'
|
||||
WHEN lower(coalesce(f.format, '')) IN ('epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'kepub') THEN 'ebook'
|
||||
WHEN lower(coalesce(f.format, '')) IN ('epub', 'pdf', 'djvu', 'mobi', 'azw', 'azw3', 'fb2', 'kepub') THEN 'ebook'
|
||||
ELSE 'unknown'
|
||||
END AS family
|
||||
FROM book_files f
|
||||
@@ -308,7 +308,7 @@ export class BookDuplicatesRepository {
|
||||
SELECT DISTINCT CASE
|
||||
WHEN lower(coalesce(f.format, '')) IN ('m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac') THEN 'audiobook'
|
||||
WHEN lower(coalesce(f.format, '')) IN ('cbz', 'cbr', 'cb7', 'cbx') THEN 'comic'
|
||||
WHEN lower(coalesce(f.format, '')) IN ('epub', 'pdf', 'mobi', 'azw', 'azw3', 'fb2', 'kepub') THEN 'ebook'
|
||||
WHEN lower(coalesce(f.format, '')) IN ('epub', 'pdf', 'djvu', 'mobi', 'azw', 'azw3', 'fb2', 'kepub') THEN 'ebook'
|
||||
ELSE 'unknown'
|
||||
END AS family
|
||||
FROM book_files f
|
||||
|
||||
@@ -79,6 +79,7 @@ const BOOK_MIME_TYPES: Record<string, string> = {
|
||||
epub: 'application/epub+zip',
|
||||
kepub: 'application/epub+zip',
|
||||
pdf: 'application/pdf',
|
||||
djvu: 'image/vnd.djvu',
|
||||
mobi: 'application/x-mobipocket-ebook',
|
||||
azw: 'application/vnd.amazon.ebook',
|
||||
azw3: 'application/vnd.amazon.ebook',
|
||||
|
||||
@@ -85,6 +85,7 @@ import { UpdateBookFileDto } from './dto/update-book-file.dto';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { BookEmbedderService } from '../embedding/book-embedder.service';
|
||||
import { MetadataService } from '../metadata/metadata.service';
|
||||
import { MetadataExtractionService } from '../metadata/metadata-extraction.service';
|
||||
import { MetadataScoreService } from '../metadata-score/metadata-score.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import { books } from '../../db/schema';
|
||||
@@ -316,6 +317,7 @@ export class BookService {
|
||||
@Optional() private readonly coverReconciler?: CoverSlotReconciler,
|
||||
@Optional() private readonly readingAttemptEvents?: ReadingAttemptEventsService,
|
||||
@Optional() private readonly deliverySelection?: DeliverySelectionService,
|
||||
@Optional() private readonly metadataExtraction?: MetadataExtractionService,
|
||||
) {
|
||||
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
|
||||
}
|
||||
@@ -3626,6 +3628,11 @@ export class BookService {
|
||||
if (!format) return {};
|
||||
|
||||
switch (format) {
|
||||
case 'djvu': {
|
||||
const parsed = await this.metadataExtraction?.extractDjvuDetails(absolutePath);
|
||||
if (!parsed) return {};
|
||||
return { title: parsed.title, publishedYear: parsed.publishedYear, pageCount: parsed.pageCount };
|
||||
}
|
||||
case 'epub': {
|
||||
const parsed = await extractEpubMetadata(absolutePath);
|
||||
if (!parsed) return {};
|
||||
|
||||
@@ -153,6 +153,12 @@ describe('resolveResumeModes', () => {
|
||||
expect(resolveResumeModes(files, 1)).toMatchObject({ readFileId: 1, readFileFormat: 'pdf' });
|
||||
});
|
||||
|
||||
it('never offers DjVu, which released iOS builds cannot open, and keeps a readable edition beside it', () => {
|
||||
expect(resolveResumeModes([file(1, 'djvu')], 1)).toMatchObject({ readFileId: null, readFileFormat: null });
|
||||
expect(resolveResumeModes([file(1, 'djvu'), file(2, 'cbz')], 1)).toMatchObject({ readFileId: 2, readFileFormat: 'cbz' });
|
||||
expect(resolveResumeModes([file(1, 'm4b'), file(2, 'djvu')], 1)).toMatchObject({ readFileId: null, hasAudio: true });
|
||||
});
|
||||
|
||||
it("falls back to the library's best readable file when the primary is an audiobook", () => {
|
||||
const files = [file(1, 'm4b'), file(2, 'pdf'), file(3, 'mobi')];
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdtemp, open, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { djvuBundleFixture, djvuPageFixture } from '../../../test/e2e/djvu-fixture';
|
||||
import { validateDjvuContainer } from './djvu-container';
|
||||
|
||||
describe('DjVu container containment', () => {
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'djvu-container-test-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function validate(buffer: Buffer) {
|
||||
const path = join(directory, 'document.djvu');
|
||||
await writeFile(path, buffer);
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
return await validateDjvuContainer(handle, buffer.length);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
it('accepts standalone and bundled pages', async () => {
|
||||
expect(await validate(djvuPageFixture())).toBe(1);
|
||||
expect(await validate(djvuBundleFixture())).toBe(2);
|
||||
});
|
||||
|
||||
it('accepts bytes after the top-level form, which DjVuLibre ignores', async () => {
|
||||
expect(await validate(Buffer.concat([djvuPageFixture(), Buffer.alloc(3)]))).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects a top-level form that claims more bytes than the file holds', async () => {
|
||||
const buffer = djvuPageFixture();
|
||||
buffer.writeUInt32BE(buffer.length, 8);
|
||||
await expect(validate(buffer)).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('rejects indirect document directories', async () => {
|
||||
const buffer = djvuBundleFixture();
|
||||
buffer[24] = 1;
|
||||
await expect(validate(buffer)).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('rejects directory offsets into unvalidated payloads', async () => {
|
||||
const buffer = djvuBundleFixture();
|
||||
buffer.writeUInt32BE(80, 27);
|
||||
await expect(validate(buffer)).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it.each(['../secret', '/path/to/component', 'file:///secret', 'https://example.com/component', 'dictionary'])(
|
||||
'rejects standalone references: %s',
|
||||
async (name) => {
|
||||
const source = djvuPageFixture();
|
||||
const ref = Buffer.from(name);
|
||||
const chunk = Buffer.alloc(8 + ref.length);
|
||||
chunk.write('INCL');
|
||||
chunk.writeUInt32BE(ref.length, 4);
|
||||
ref.copy(chunk, 8);
|
||||
const buffer = Buffer.concat([source, Buffer.alloc(1), chunk]);
|
||||
buffer.writeUInt32BE(buffer.length - 12, 8);
|
||||
await expect(validate(buffer)).rejects.toMatchObject({ status: 422 });
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects source dimensions that exceed the decode bound', async () => {
|
||||
const buffer = djvuPageFixture();
|
||||
buffer.writeUInt16BE(65535, 24);
|
||||
buffer.writeUInt16BE(65535, 26);
|
||||
await expect(validate(buffer)).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('rejects malformed sizes and truncated chunks', async () => {
|
||||
await expect(validate(Buffer.from('not djvu'))).rejects.toMatchObject({ status: 422 });
|
||||
const buffer = djvuPageFixture().subarray(0, 60);
|
||||
buffer.writeUInt32BE(buffer.length - 12, 8);
|
||||
await expect(validate(buffer)).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { UnprocessableEntityException } from '@nestjs/common';
|
||||
import type { FileHandle } from 'fs/promises';
|
||||
|
||||
export const DJVU_LIMITS = {
|
||||
maxPages: 20_000,
|
||||
maxChunks: 200_000,
|
||||
maxSourcePixels: 40_000_000,
|
||||
maxRenderSize: 2560,
|
||||
maxOutputBytes: 64 * 1024 * 1024,
|
||||
maxDocuments: 4,
|
||||
maxSourceCacheBytes: 512 * 1024 * 1024,
|
||||
maxPageCacheEntries: 128,
|
||||
maxKnownPageCounts: 1024,
|
||||
maxQueuedRequests: 16,
|
||||
coverRenderSize: 1280,
|
||||
} as const;
|
||||
|
||||
function invalid(): never {
|
||||
throw new UnprocessableEntityException({ code: 'DJVU_INVALID', message: 'Unsupported or invalid self-contained DjVu document' });
|
||||
}
|
||||
|
||||
async function bytes(handle: FileHandle, position: number, length: number): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const result = await handle.read(buffer, 0, length, position);
|
||||
if (result.bytesRead !== length) invalid();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Validate the IFF structure before native decoding; component references must stay internal. */
|
||||
export async function validateDjvuContainer(handle: FileHandle, size: number): Promise<number> {
|
||||
if (size < 16) invalid();
|
||||
const header = await bytes(handle, 0, 16);
|
||||
const kind = header.toString('ascii', 12, 16);
|
||||
// DjVuLibre ignores bytes after the top-level form, and some encoders leave padding there.
|
||||
const formEnd = header.readUInt32BE(8) + 12;
|
||||
if (header.toString('ascii', 0, 8) !== 'AT&TFORM' || !['DJVU', 'DJVM'].includes(kind) || formEnd > size) invalid();
|
||||
let pages = 0;
|
||||
let chunks = 0;
|
||||
let directory = false;
|
||||
let references = 0;
|
||||
const componentOffsets: number[] = [];
|
||||
const directoryOffsets: number[] = [];
|
||||
|
||||
async function scan(start: number, end: number, form: string, depth: number): Promise<void> {
|
||||
let info = false;
|
||||
if (depth > 1) invalid();
|
||||
for (let offset = start; offset < end;) {
|
||||
if (++chunks > DJVU_LIMITS.maxChunks || end - offset < 8) invalid();
|
||||
const chunk = await bytes(handle, offset, 8);
|
||||
const id = chunk.toString('ascii', 0, 4);
|
||||
const length = chunk.readUInt32BE(4);
|
||||
const payload = offset + 8;
|
||||
const next = payload + length;
|
||||
if (next > end) invalid();
|
||||
if (id === 'FORM') {
|
||||
if (form !== 'DJVM' || length < 4) invalid();
|
||||
const child = (await bytes(handle, payload, 4)).toString('ascii');
|
||||
if (!['DJVU', 'DJVI', 'THUM'].includes(child)) invalid();
|
||||
componentOffsets.push(offset);
|
||||
await scan(payload + 4, next, child, depth + 1);
|
||||
} else if (id === 'DIRM') {
|
||||
if (form !== 'DJVM' || directory || length < 3) invalid();
|
||||
const flags = await bytes(handle, payload, 3);
|
||||
if ((flags[0] & 0x80) === 0 || (flags[0] & 0x7f) !== 1) invalid();
|
||||
const count = flags.readUInt16BE(1);
|
||||
if (!count || count > DJVU_LIMITS.maxPages * 2 || length < 3 + count * 4) invalid();
|
||||
const offsets = await bytes(handle, payload + 3, count * 4);
|
||||
for (let index = 0; index < count; index++) directoryOffsets.push(offsets.readUInt32BE(index * 4));
|
||||
directory = true;
|
||||
} else if (id === 'INCL') {
|
||||
if (length === 0 || length > 255 || form === 'THUM') invalid();
|
||||
const name = (await bytes(handle, payload, length)).toString('utf8');
|
||||
if (
|
||||
name === '.' ||
|
||||
name === '..' ||
|
||||
[...name].some((character) => '/\\:'.includes(character) || character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)
|
||||
)
|
||||
invalid();
|
||||
references++;
|
||||
} else if (id === 'INFO') {
|
||||
if (form !== 'DJVU' || info || length < 10) invalid();
|
||||
const dimensions = await bytes(handle, payload, 4);
|
||||
const width = dimensions.readUInt16BE(0);
|
||||
const height = dimensions.readUInt16BE(2);
|
||||
if (!width || !height || width * height > DJVU_LIMITS.maxSourcePixels) invalid();
|
||||
info = true;
|
||||
}
|
||||
offset = next + (length % 2 && next < end ? 1 : 0);
|
||||
}
|
||||
if (form === 'DJVU') {
|
||||
if (!info || ++pages > DJVU_LIMITS.maxPages) invalid();
|
||||
}
|
||||
}
|
||||
|
||||
await scan(16, formEnd, kind, 0);
|
||||
if (!pages || (kind === 'DJVM' && !directory) || (kind === 'DJVU' && references)) invalid();
|
||||
const components = new Set(componentOffsets);
|
||||
if (
|
||||
kind === 'DJVM' &&
|
||||
(directoryOffsets.length !== componentOffsets.length ||
|
||||
new Set(directoryOffsets).size !== directoryOffsets.length ||
|
||||
directoryOffsets.some((offset) => !components.has(offset)))
|
||||
)
|
||||
invalid();
|
||||
return pages;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PathModule } from '../path/path.module';
|
||||
import { DjvuDocumentService } from './djvu-document.service';
|
||||
import { DjvuProcess } from './djvu-process';
|
||||
|
||||
@Module({
|
||||
imports: [PathModule],
|
||||
providers: [DjvuDocumentService, DjvuProcess],
|
||||
exports: [DjvuDocumentService],
|
||||
})
|
||||
export class DjvuDocumentModule {}
|
||||
@@ -0,0 +1,409 @@
|
||||
import {
|
||||
Inject,
|
||||
HttpException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
UnprocessableEntityException,
|
||||
type OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import { createHash } from 'crypto';
|
||||
import { constants, createWriteStream, type Stats } from 'fs';
|
||||
import { mkdtemp, open, realpath, rm, stat, type FileHandle } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { dirname, join } from 'path';
|
||||
import { Transform } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import sharp from 'sharp';
|
||||
import { djvuConfig } from '../../config/config';
|
||||
import { PathPolicyService } from '../path/path-policy.service';
|
||||
import { DJVU_LIMITS, validateDjvuContainer } from './djvu-container';
|
||||
import { DjvuProcess } from './djvu-process';
|
||||
|
||||
interface Source {
|
||||
directory: string;
|
||||
path: string;
|
||||
pageCount: number;
|
||||
}
|
||||
interface SourceEntry {
|
||||
bytes: number;
|
||||
users: number;
|
||||
source: Promise<Source>;
|
||||
}
|
||||
interface OpenedSource {
|
||||
handle: FileHandle;
|
||||
stats: Stats;
|
||||
key: string;
|
||||
}
|
||||
interface Waiter {
|
||||
resolve: () => void;
|
||||
reject: (error: ServiceUnavailableException) => void;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
interface SourceWork<T> {
|
||||
/** Answers from caches without a decoder slot or source snapshot; undefined falls through to `use`. */
|
||||
cached?: (key: string) => T | undefined;
|
||||
use: (source: Source, key: string) => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader requests are interactive: they are served first, and a full queue answers 503 so the
|
||||
* reader can retry. Imports and cover work are background: they wait for a free decoder instead of
|
||||
* failing, because their callers already bound how many run at once.
|
||||
*/
|
||||
type Priority = 'interactive' | 'background';
|
||||
|
||||
export type DjvuPageResult = { etag: string; notModified: true } | { etag: string; notModified: false; buffer: Buffer };
|
||||
|
||||
const RENDER_SIZES = [1280, 1920, 2560];
|
||||
const RENDER_VERSION = 'webp-q85-v2';
|
||||
|
||||
function stamp(stats: Stats): string {
|
||||
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`;
|
||||
}
|
||||
|
||||
function assertPage(pageIndex: number, pageCount: number): void {
|
||||
if (!Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount) throw new NotFoundException('DjVu page does not exist');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DjvuDocumentService implements OnModuleDestroy {
|
||||
private readonly sources = new Map<string, SourceEntry>();
|
||||
private sourceBytes = 0;
|
||||
private sourceLock: Promise<unknown> = Promise.resolve();
|
||||
private readonly pageCounts = new Map<string, number>();
|
||||
private readonly pages = new Map<string, Buffer>();
|
||||
private pageBytes = 0;
|
||||
private readonly pendingPages = new Map<string, Promise<Buffer>>();
|
||||
private readonly waiters: Waiter[] = [];
|
||||
private readonly backgroundWaiters: Waiter[] = [];
|
||||
private readonly operations = new Set<Promise<unknown>>();
|
||||
private active = 0;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly pathPolicy: PathPolicyService,
|
||||
private readonly decoder: DjvuProcess,
|
||||
@Inject(djvuConfig.KEY) private readonly config: ConfigType<typeof djvuConfig>,
|
||||
) {}
|
||||
|
||||
async getPageCount(absolutePath: string, authorizedRoot: string): Promise<number> {
|
||||
return this.withSource(absolutePath, authorizedRoot, 'interactive', {
|
||||
cached: (key) => this.knownPageCount(key),
|
||||
use: (source) => Promise.resolve(source.pageCount),
|
||||
});
|
||||
}
|
||||
|
||||
async getPage(absolutePath: string, authorizedRoot: string, pageIndex: number, size: number, ifNoneMatch?: string): Promise<DjvuPageResult> {
|
||||
if (!RENDER_SIZES.includes(size)) throw new UnprocessableEntityException('Unsupported DjVu render size');
|
||||
return this.withSource(absolutePath, authorizedRoot, 'interactive', {
|
||||
cached: (key) => {
|
||||
const pageCount = this.knownPageCount(key);
|
||||
return pageCount === undefined ? undefined : this.cachedPage(key, pageIndex, size, pageCount, ifNoneMatch);
|
||||
},
|
||||
use: async (source, key) => {
|
||||
const hit = this.cachedPage(key, pageIndex, size, source.pageCount, ifNoneMatch);
|
||||
if (hit) return hit;
|
||||
const { renderKey, etag } = this.renderIdentity(key, pageIndex, size);
|
||||
let pending = this.pendingPages.get(renderKey);
|
||||
if (!pending) {
|
||||
pending = this.render(source, pageIndex, size)
|
||||
.then((buffer) => {
|
||||
this.cachePage(renderKey, buffer);
|
||||
return buffer;
|
||||
})
|
||||
.finally(() => this.pendingPages.delete(renderKey));
|
||||
this.pendingPages.set(renderKey, pending);
|
||||
}
|
||||
return { etag, notModified: false, buffer: await pending };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getPageText(absolutePath: string, authorizedRoot: string, pageIndex: number): Promise<{ text: string }> {
|
||||
return this.withSource(absolutePath, authorizedRoot, 'interactive', {
|
||||
cached: (key) => {
|
||||
const pageCount = this.knownPageCount(key);
|
||||
if (pageCount !== undefined) assertPage(pageIndex, pageCount);
|
||||
return undefined;
|
||||
},
|
||||
use: async (source) => {
|
||||
assertPage(pageIndex, source.pageCount);
|
||||
const text = await this.decoder.run('djvused', [source.path, '-u', '-e', `select ${pageIndex + 1}; print-pure-txt`], source.directory);
|
||||
return {
|
||||
text: [...text]
|
||||
.filter((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127);
|
||||
})
|
||||
.join('')
|
||||
.trim(),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Page count without decoding a page, for a trusted path an internal caller already authorized. */
|
||||
async readPageCount(absolutePath: string): Promise<number> {
|
||||
return this.withTrustedSource(absolutePath, 'interactive', {
|
||||
cached: (key) => this.knownPageCount(key),
|
||||
use: (source) => Promise.resolve(source.pageCount),
|
||||
});
|
||||
}
|
||||
|
||||
/** Import-time page count and first-page cover, for a trusted path an internal caller already authorized. */
|
||||
async extractMetadata(absolutePath: string): Promise<{ pageCount: number; cover: Buffer }> {
|
||||
return this.withTrustedSource(absolutePath, 'background', {
|
||||
use: async (source) => ({ pageCount: source.pageCount, cover: await this.render(source, 0, DJVU_LIMITS.coverRenderSize) }),
|
||||
});
|
||||
}
|
||||
|
||||
async extractCover(absolutePath: string): Promise<Buffer> {
|
||||
return (await this.extractMetadata(absolutePath)).cover;
|
||||
}
|
||||
|
||||
// Internal callers pass paths they discovered under a library root. The file's own resolved
|
||||
// folder is the root here, so a library entry that is a symlink still decodes.
|
||||
private async withTrustedSource<T>(absolutePath: string, priority: Priority, work: SourceWork<T>): Promise<T> {
|
||||
const path = await realpath(absolutePath).catch(() => {
|
||||
throw new NotFoundException('DjVu file is missing');
|
||||
});
|
||||
return this.withSource(path, dirname(path), priority, work);
|
||||
}
|
||||
|
||||
private async withSource<T>(absolutePath: string, authorizedRoot: string, priority: Priority, work: SourceWork<T>): Promise<T> {
|
||||
const operation = (async () => {
|
||||
if (this.stopped) throw new ServiceUnavailableException('DjVu reader is stopping');
|
||||
const opened = await this.openSource(absolutePath, authorizedRoot);
|
||||
try {
|
||||
const hit = work.cached?.(opened.key);
|
||||
if (hit !== undefined) return hit;
|
||||
return await this.run(priority, () => this.useSource(opened, work.use));
|
||||
} finally {
|
||||
await opened.handle.close();
|
||||
}
|
||||
})();
|
||||
this.operations.add(operation);
|
||||
try {
|
||||
return await operation;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new ServiceUnavailableException({ code: 'DJVU_STORAGE_UNAVAILABLE', message: 'DjVu source storage is unavailable' });
|
||||
} finally {
|
||||
this.operations.delete(operation);
|
||||
}
|
||||
}
|
||||
|
||||
private async openSource(absolutePath: string, authorizedRoot: string): Promise<OpenedSource> {
|
||||
await this.pathPolicy.assertWithinRoot(absolutePath, authorizedRoot);
|
||||
const path = await realpath(absolutePath).catch(() => {
|
||||
throw new NotFoundException('DjVu file is missing');
|
||||
});
|
||||
const root = await realpath(authorizedRoot);
|
||||
await this.pathPolicy.assertWithinRoot(path, root);
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
await this.pathPolicy.assertWithinRoot(await realpath(path), root);
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile() || !stats.size || stats.size > this.config.maxSourceBytes) {
|
||||
throw new UnprocessableEntityException({ code: 'DJVU_SOURCE_LIMIT', message: 'DjVu source exceeds the supported file size' });
|
||||
}
|
||||
if (stamp(stats) !== stamp(await stat(path))) throw new UnprocessableEntityException('DjVu source changed while opening');
|
||||
const key = createHash('sha256')
|
||||
.update(`${path}:${stamp(stats)}`)
|
||||
.digest('hex');
|
||||
return { handle, stats, key };
|
||||
} catch (error) {
|
||||
await handle.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async useSource<T>({ handle, stats, key }: OpenedSource, use: (source: Source, key: string) => Promise<T>): Promise<T> {
|
||||
let entry: SourceEntry | undefined;
|
||||
try {
|
||||
const allocation = this.sourceLock.then(async () => {
|
||||
let allocated = this.sources.get(key);
|
||||
if (!allocated) {
|
||||
await this.makeSourceRoom(stats.size);
|
||||
allocated = { bytes: stats.size, users: 0, source: this.snapshot(handle, stats.size, stamp(stats)) };
|
||||
this.sources.set(key, allocated);
|
||||
this.sourceBytes += stats.size;
|
||||
}
|
||||
allocated.users++;
|
||||
this.sources.delete(key);
|
||||
this.sources.set(key, allocated);
|
||||
return allocated;
|
||||
});
|
||||
this.sourceLock = allocation.catch(() => undefined);
|
||||
entry = await allocation;
|
||||
let source: Source;
|
||||
try {
|
||||
source = await entry.source;
|
||||
} catch (error) {
|
||||
if (this.sources.get(key) === entry) {
|
||||
this.sources.delete(key);
|
||||
this.sourceBytes -= entry.bytes;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.rememberPageCount(key, source.pageCount);
|
||||
return await use(source, key);
|
||||
} finally {
|
||||
if (entry) entry.users--;
|
||||
}
|
||||
}
|
||||
|
||||
private renderIdentity(key: string, pageIndex: number, size: number): { renderKey: string; etag: string } {
|
||||
const renderKey = `${key}:${pageIndex}:${size}:${RENDER_VERSION}`;
|
||||
return { renderKey, etag: `"${createHash('sha256').update(renderKey).digest('base64url')}"` };
|
||||
}
|
||||
|
||||
private cachedPage(key: string, pageIndex: number, size: number, pageCount: number, ifNoneMatch?: string): DjvuPageResult | undefined {
|
||||
assertPage(pageIndex, pageCount);
|
||||
const { renderKey, etag } = this.renderIdentity(key, pageIndex, size);
|
||||
if (ifNoneMatch?.split(',').some((value) => value.trim() === '*' || value.trim().replace(/^W\//, '') === etag))
|
||||
return { etag, notModified: true };
|
||||
const cached = this.pages.get(renderKey);
|
||||
if (!cached) return undefined;
|
||||
this.pages.delete(renderKey);
|
||||
this.pages.set(renderKey, cached);
|
||||
return { etag, notModified: false, buffer: cached };
|
||||
}
|
||||
|
||||
private knownPageCount(key: string): number | undefined {
|
||||
const count = this.pageCounts.get(key);
|
||||
if (count !== undefined) {
|
||||
this.pageCounts.delete(key);
|
||||
this.pageCounts.set(key, count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private rememberPageCount(key: string, count: number): void {
|
||||
this.pageCounts.delete(key);
|
||||
this.pageCounts.set(key, count);
|
||||
if (this.pageCounts.size > DJVU_LIMITS.maxKnownPageCounts) this.pageCounts.delete(this.pageCounts.keys().next().value!);
|
||||
}
|
||||
|
||||
private async snapshot(handle: FileHandle, size: number, originalStamp: string): Promise<Source> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'bookorbit-djvu-'));
|
||||
const path = join(directory, 'document.djvu');
|
||||
try {
|
||||
let copied = 0;
|
||||
const limit = new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
copied += chunk.length;
|
||||
callback(copied > size ? new UnprocessableEntityException('DjVu source changed while copying') : null, chunk);
|
||||
},
|
||||
});
|
||||
await pipeline(handle.createReadStream({ autoClose: false, start: 0 }), limit, createWriteStream(path, { flags: 'wx', mode: 0o600 }));
|
||||
if (copied !== size || stamp(await handle.stat()) !== originalStamp)
|
||||
throw new UnprocessableEntityException('DjVu source changed while copying');
|
||||
const snapshot = await open(path, 'r');
|
||||
let validatedPages: number;
|
||||
try {
|
||||
validatedPages = await validateDjvuContainer(snapshot, size);
|
||||
} finally {
|
||||
await snapshot.close();
|
||||
}
|
||||
const count = (await this.decoder.run('djvused', [path, '-e', 'n'], directory)).trim();
|
||||
if (!/^\d+$/.test(count) || Number(count) !== validatedPages) throw new UnprocessableEntityException('Invalid DjVu page directory');
|
||||
return { directory, path, pageCount: Number(count) };
|
||||
} catch (error) {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async makeSourceRoom(bytes: number): Promise<void> {
|
||||
for (const [key, entry] of this.sources) {
|
||||
if (this.sourceBytes + bytes <= DJVU_LIMITS.maxSourceCacheBytes && this.sources.size < DJVU_LIMITS.maxDocuments) break;
|
||||
if (entry.users) continue;
|
||||
this.sources.delete(key);
|
||||
this.sourceBytes -= entry.bytes;
|
||||
const source = await entry.source;
|
||||
await rm(source.directory, { recursive: true, force: true });
|
||||
}
|
||||
if (this.sourceBytes + bytes > DJVU_LIMITS.maxSourceCacheBytes || this.sources.size >= DJVU_LIMITS.maxDocuments) {
|
||||
throw new ServiceUnavailableException({ code: 'DJVU_BUSY', message: 'DjVu reader is busy; retry shortly' });
|
||||
}
|
||||
}
|
||||
|
||||
private async render(source: Source, pageIndex: number, size: number): Promise<Buffer> {
|
||||
const directory = await mkdtemp(join(source.directory, 'render-'));
|
||||
const output = join(directory, 'page.tiff');
|
||||
try {
|
||||
await this.decoder.run('ddjvu', [`-page=${pageIndex + 1}`, `-size=${size}x${size}`, '-format=tiff', source.path, output], directory);
|
||||
const stats = await stat(output);
|
||||
if (stats.size > DJVU_LIMITS.maxOutputBytes) throw new UnprocessableEntityException('DjVu output exceeds the supported size');
|
||||
return await sharp(output, { limitInputPixels: DJVU_LIMITS.maxRenderSize ** 2 })
|
||||
.timeout({ seconds: 15 })
|
||||
.webp({ quality: 85 })
|
||||
.toBuffer();
|
||||
} catch (error) {
|
||||
if (error instanceof UnprocessableEntityException || error instanceof ServiceUnavailableException) throw error;
|
||||
throw new UnprocessableEntityException({ code: 'DJVU_DECODE_FAILED', message: 'DjVu page could not be decoded' });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private cachePage(key: string, buffer: Buffer): void {
|
||||
if (buffer.length > this.config.cacheBytes) return;
|
||||
for (const [oldKey, value] of this.pages) {
|
||||
if (this.pageBytes + buffer.length <= this.config.cacheBytes && this.pages.size < DJVU_LIMITS.maxPageCacheEntries) break;
|
||||
this.pages.delete(oldKey);
|
||||
this.pageBytes -= value.length;
|
||||
}
|
||||
this.pages.set(key, buffer);
|
||||
this.pageBytes += buffer.length;
|
||||
}
|
||||
|
||||
private async run<T>(priority: Priority, work: () => Promise<T>): Promise<T> {
|
||||
if (this.stopped) throw new ServiceUnavailableException('DjVu reader is stopping');
|
||||
if (this.active >= this.config.maxConcurrent) {
|
||||
if (priority === 'interactive' && this.waiters.length >= DJVU_LIMITS.maxQueuedRequests) {
|
||||
throw new ServiceUnavailableException({ code: 'DJVU_BUSY', message: 'DjVu reader is busy; retry shortly' });
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const waiter: Waiter = { resolve, reject };
|
||||
if (priority === 'interactive') {
|
||||
waiter.timer = setTimeout(() => {
|
||||
const index = this.waiters.indexOf(waiter);
|
||||
if (index !== -1) this.waiters.splice(index, 1);
|
||||
reject(new ServiceUnavailableException('DjVu reader queue timed out'));
|
||||
}, this.config.timeoutMs);
|
||||
this.waiters.push(waiter);
|
||||
} else this.backgroundWaiters.push(waiter);
|
||||
});
|
||||
} else this.active++;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
const next = this.waiters.shift() ?? this.backgroundWaiters.shift();
|
||||
if (next) {
|
||||
clearTimeout(next.timer);
|
||||
next.resolve();
|
||||
} else this.active--;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.stopped = true;
|
||||
this.decoder.onModuleDestroy();
|
||||
for (const waiter of [...this.waiters.splice(0), ...this.backgroundWaiters.splice(0)]) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new ServiceUnavailableException('DjVu reader is stopping'));
|
||||
}
|
||||
await Promise.allSettled(this.operations);
|
||||
for (const entry of this.sources.values()) {
|
||||
const source = await entry.source.catch(() => null);
|
||||
if (source) await rm(source.directory, { recursive: true, force: true });
|
||||
}
|
||||
this.sources.clear();
|
||||
this.pageCounts.clear();
|
||||
this.pages.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { EventEmitter } from 'events';
|
||||
import { PassThrough } from 'stream';
|
||||
import { spawn } from 'child_process';
|
||||
import { DjvuProcess } from './djvu-process';
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: vi.fn() }));
|
||||
|
||||
describe('DjVu process resource controls', () => {
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'djvu-process-test-'));
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function child() {
|
||||
const process = Object.assign(new EventEmitter(), {
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: vi.fn(() => {
|
||||
queueMicrotask(() => process.emit('close', null));
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
vi.mocked(spawn).mockReturnValue(process as unknown as ReturnType<typeof spawn>);
|
||||
return process;
|
||||
}
|
||||
function runner() {
|
||||
return new DjvuProcess({ maxConcurrent: 2, timeoutMs: 100, maxSourceBytes: 1024, cacheBytes: 1024 });
|
||||
}
|
||||
|
||||
it('kills a decoder exceeding the deadline', async () => {
|
||||
vi.useFakeTimers();
|
||||
const process = child();
|
||||
const result = runner().run('ddjvu', ['source.djvu'], directory);
|
||||
const rejected = expect(result).rejects.toMatchObject({ status: 422 });
|
||||
await vi.advanceTimersByTimeAsync(101);
|
||||
await rejected;
|
||||
expect(process.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
|
||||
it('kills a decoder producing excessive output', async () => {
|
||||
const process = child();
|
||||
const result = runner().run('djvused', ['source.djvu'], directory);
|
||||
process.stdout.write(Buffer.alloc(1024 * 1024 + 1));
|
||||
await expect(result).rejects.toMatchObject({ status: 422 });
|
||||
expect(process.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
|
||||
it('maps missing executable errors without exposing command paths', async () => {
|
||||
const process = child();
|
||||
const result = runner().run('ddjvu', ['source.djvu'], directory);
|
||||
process.emit('error', new Error('ENOENT'));
|
||||
await expect(result).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
|
||||
it('stops active decoders on shutdown and rejects new work', async () => {
|
||||
const process = child();
|
||||
const decoder = runner();
|
||||
const result = decoder.run('ddjvu', ['source.djvu'], directory);
|
||||
decoder.onModuleDestroy();
|
||||
await expect(result).rejects.toMatchObject({ status: 422 });
|
||||
await expect(decoder.run('ddjvu', [], directory)).rejects.toMatchObject({ status: 503 });
|
||||
expect(process.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
|
||||
it('reports a decoder prlimit cannot execute as unavailable and bounds CPU by the configured timeout', async () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
try {
|
||||
const decoder = child();
|
||||
const result = new DjvuProcess({ maxConcurrent: 2, timeoutMs: 45_000, maxSourceBytes: 1024, cacheBytes: 1024 }).run(
|
||||
'ddjvu',
|
||||
['source.djvu'],
|
||||
directory,
|
||||
);
|
||||
decoder.emit('close', 127);
|
||||
await expect(result).rejects.toMatchObject({ status: 503 });
|
||||
expect(vi.mocked(spawn).mock.calls[0]?.[0]).toBe('prlimit');
|
||||
expect(vi.mocked(spawn).mock.calls[0]?.[1]).toContain('--cpu=45');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
}
|
||||
});
|
||||
|
||||
it('passes document paths as arguments without shell execution', async () => {
|
||||
const process = child();
|
||||
const path = join(directory, 'book;touch marker.djvu');
|
||||
await writeFile(path, 'fixture');
|
||||
const result = runner().run('djvused', [path, '-e', 'n'], directory);
|
||||
process.stdout.end('1\n');
|
||||
process.emit('close', 0);
|
||||
expect(await result).toBe('1\n');
|
||||
expect(vi.mocked(spawn).mock.calls[0]?.[1]).toContain(path);
|
||||
expect(vi.mocked(spawn).mock.calls[0]?.[2]).not.toHaveProperty('shell');
|
||||
});
|
||||
|
||||
it('preserves multilingual OCR when UTF-8 characters span process output chunks', async () => {
|
||||
const process = child();
|
||||
const result = runner().run('djvused', ['source.djvu'], directory);
|
||||
const text = Buffer.from('日本語の本');
|
||||
process.stdout.write(text.subarray(0, 2));
|
||||
process.stdout.write(text.subarray(2));
|
||||
process.emit('close', 0);
|
||||
expect(await result).toBe('日本語の本');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Inject, Injectable, Logger, ServiceUnavailableException, UnprocessableEntityException, type OnModuleDestroy } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { djvuConfig } from '../../config/config';
|
||||
import { DJVU_LIMITS } from './djvu-container';
|
||||
|
||||
@Injectable()
|
||||
export class DjvuProcess implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(DjvuProcess.name);
|
||||
private readonly children = new Set<ChildProcess>();
|
||||
private stopped = false;
|
||||
|
||||
constructor(@Inject(djvuConfig.KEY) private readonly config: ConfigType<typeof djvuConfig>) {}
|
||||
|
||||
run(binary: 'ddjvu' | 'djvused', args: string[], cwd: string): Promise<string> {
|
||||
if (this.stopped) return Promise.reject(new ServiceUnavailableException('DjVu reader is stopping'));
|
||||
const limited = process.platform === 'linux';
|
||||
const executable = limited ? 'prlimit' : binary;
|
||||
const cpuSeconds = Math.ceil(this.config.timeoutMs / 1000);
|
||||
const commandArgs = limited ? ['--as=805306368', `--fsize=${DJVU_LIMITS.maxOutputBytes}`, `--cpu=${cpuSeconds}`, '--', binary, ...args] : args;
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const child = spawn(executable, commandArgs, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
this.children.add(child);
|
||||
const stdout: Buffer[] = [];
|
||||
let outputBytes = 0;
|
||||
let failed = false;
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
failed = true;
|
||||
child.kill('SIGKILL');
|
||||
}, this.config.timeoutMs);
|
||||
function collect(chunk: Buffer, keep: boolean) {
|
||||
outputBytes += chunk.length;
|
||||
if (outputBytes > 1024 * 1024) {
|
||||
failed = true;
|
||||
child.kill('SIGKILL');
|
||||
} else if (keep) stdout.push(chunk);
|
||||
}
|
||||
child.stdout?.on('data', (chunk: Buffer) => collect(chunk, true));
|
||||
child.stderr?.on('data', (chunk: Buffer) => collect(chunk, false));
|
||||
const unavailable = () => {
|
||||
this.logger.warn(
|
||||
`[djvu.decode] [fail] tool=${binary} durationMs=${Date.now() - started} errorClass=SpawnError error="DjVuLibre tools are unavailable" - decoder could not start`,
|
||||
);
|
||||
reject(new ServiceUnavailableException({ code: 'DJVU_UNAVAILABLE', message: 'DjVuLibre tools are unavailable' }));
|
||||
};
|
||||
child.once('error', () => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
this.children.delete(child);
|
||||
unavailable();
|
||||
});
|
||||
child.once('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
this.children.delete(child);
|
||||
if (settled) return;
|
||||
// prlimit exits 126 or 127 when it cannot execute the decoder, which is a missing tool, not a bad file.
|
||||
if (limited && !failed && (code === 126 || code === 127)) unavailable();
|
||||
else if (failed || code !== 0) {
|
||||
this.logger.warn(
|
||||
`[djvu.decode] [fail] tool=${binary} durationMs=${Date.now() - started} errorClass=DecodeError error="Decode failed or reached its limits" exitCode=${code} - decoder stopped`,
|
||||
);
|
||||
reject(new UnprocessableEntityException({ code: 'DJVU_DECODE_FAILED', message: 'DjVu decoding failed or exceeded its resource limits' }));
|
||||
} else resolve(Buffer.concat(stdout).toString('utf8'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.stopped = true;
|
||||
for (const child of this.children) child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
export const RECIPIENT_DEVICE_TYPES = ['kindle', 'kobo', 'other'] as const;
|
||||
export const RECIPIENT_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr'] as const;
|
||||
export const RECIPIENT_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'djvu'] as const;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { isPagedReadingFormat, parseKoreaderPageNumber } from './koreader-progress-position.util';
|
||||
|
||||
describe('isPagedReadingFormat', () => {
|
||||
it.each(['pdf', 'PDF', 'cbz', 'cbr', 'cb7', 'cbx', 'CBZ'])('treats %s as paged', (format) => {
|
||||
it.each(['pdf', 'PDF', 'cbz', 'cbr', 'cb7', 'cbx', 'CBZ', 'djvu', 'DJVU'])('treats %s as paged', (format) => {
|
||||
expect(isPagedReadingFormat(format)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('isPagedReadingFormat', () => {
|
||||
expect(isPagedReadingFormat(format)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([null, undefined, '', ' ', 'djvu'])('falls back to non-paged for %s', (format) => {
|
||||
it.each([null, undefined, '', ' '])('falls back to non-paged for %s', (format) => {
|
||||
expect(isPagedReadingFormat(format)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,7 +236,25 @@ describe('LibraryService', () => {
|
||||
displayOrder: 0,
|
||||
watch: false,
|
||||
metadataPrecedence: ['folderStructure', 'embedded', 'nfoFile', 'opfFile', 'sidecar'],
|
||||
formatPriority: ['epub', 'kepub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'm4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'],
|
||||
formatPriority: [
|
||||
'epub',
|
||||
'kepub',
|
||||
'pdf',
|
||||
'cbz',
|
||||
'cbr',
|
||||
'cb7',
|
||||
'mobi',
|
||||
'azw3',
|
||||
'azw',
|
||||
'fb2',
|
||||
'djvu',
|
||||
'm4b',
|
||||
'mp3',
|
||||
'm4a',
|
||||
'opus',
|
||||
'ogg',
|
||||
'flac',
|
||||
],
|
||||
organizationMode: 'book_per_folder',
|
||||
coverAspectRatio: '2/3',
|
||||
}),
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('CoverSlotReconciler on real files', () => {
|
||||
const { CoverSlotReconciler } = await import('./cover-slot-reconciler.service');
|
||||
repository = new MemoryCoverRepository(helpers);
|
||||
const store = new BookCoverStore({ getOrThrow: () => dataPath } as never, repository as never, { emitChanged: vi.fn() } as never);
|
||||
reconciler = new CoverSlotReconciler(store, new MetadataExtractionService());
|
||||
reconciler = new CoverSlotReconciler(store, new MetadataExtractionService({ extractMetadata: vi.fn(), extractCover: vi.fn() } as never));
|
||||
});
|
||||
|
||||
async function image(width: number, height: number, color = { r: 30, g: 90, b: 160 }): Promise<Buffer> {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ParsedBookData } from './extractors/format-extractor.interface';
|
||||
import { MetadataExtractionService } from './metadata-extraction.service';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
djvu: vi.fn(),
|
||||
djvuCover: vi.fn(),
|
||||
audio: vi.fn(),
|
||||
cover: vi.fn(),
|
||||
comic: {
|
||||
@@ -124,7 +126,7 @@ describe('MetadataExtractionService', () => {
|
||||
['ogg', mocks.audio],
|
||||
['flac', mocks.audio],
|
||||
])('routes %s through its canonical extractor', async (format, extractor) => {
|
||||
const service = new MetadataExtractionService();
|
||||
const service = new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never);
|
||||
|
||||
await expect(service.extract(`/books/book.${format}`, format)).resolves.toBe(extracted);
|
||||
|
||||
@@ -133,7 +135,7 @@ describe('MetadataExtractionService', () => {
|
||||
});
|
||||
|
||||
it('returns null for unsupported formats without invoking an extractor', async () => {
|
||||
const service = new MetadataExtractionService();
|
||||
const service = new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never);
|
||||
|
||||
await expect(service.extract('/books/book.txt', 'txt')).resolves.toBeNull();
|
||||
|
||||
@@ -142,8 +144,44 @@ describe('MetadataExtractionService', () => {
|
||||
expect(mocks.audio).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts DjVu page count and first-page cover with a filename title fallback', async () => {
|
||||
const service = new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never);
|
||||
const cover = Buffer.from('djvu-cover');
|
||||
mocks.djvu.mockResolvedValue({ pageCount: 57, cover });
|
||||
await expect(service.extract('/books/DjVuLibre_Book_(2007).djvu', 'djvu')).resolves.toEqual({
|
||||
title: 'DjVuLibre Book',
|
||||
publishedYear: 2007,
|
||||
authors: [],
|
||||
genres: [],
|
||||
pageCount: 57,
|
||||
cover,
|
||||
});
|
||||
expect(service.supports('djvu')).toBe(true);
|
||||
expect(mocks.djvu).toHaveBeenCalledWith('/books/DjVuLibre_Book_(2007).djvu');
|
||||
mocks.djvuCover.mockResolvedValue(cover);
|
||||
await expect(service.extractEmbeddedCover('/books/book.djvu', 'DJVU')).resolves.toEqual(cover);
|
||||
});
|
||||
|
||||
it('reads DjVu details for the file view without rendering a cover', async () => {
|
||||
const readPageCount = vi.fn().mockResolvedValue(12);
|
||||
const service = new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover, readPageCount } as never);
|
||||
await expect(service.extractDjvuDetails('/books/Scanned_Atlas_(1998).djvu')).resolves.toEqual({
|
||||
title: 'Scanned Atlas',
|
||||
publishedYear: 1998,
|
||||
pageCount: 12,
|
||||
});
|
||||
expect(readPageCount).toHaveBeenCalledWith('/books/Scanned_Atlas_(1998).djvu');
|
||||
expect(mocks.djvu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates bounded DjVu decoder failures to the import failure handler', async () => {
|
||||
const service = new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never);
|
||||
mocks.djvu.mockRejectedValue(new Error('Decoder unavailable'));
|
||||
await expect(service.extract('/books/book.djvu', 'djvu')).rejects.toThrow('Decoder unavailable');
|
||||
});
|
||||
|
||||
it('preserves null extraction results and propagates extractor failures', async () => {
|
||||
const service = new MetadataExtractionService();
|
||||
const service = new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never);
|
||||
mocks.epub.mockResolvedValueOnce(null);
|
||||
mocks.pdf.mockRejectedValueOnce(new Error('pdf failed'));
|
||||
|
||||
@@ -152,7 +190,7 @@ describe('MetadataExtractionService', () => {
|
||||
});
|
||||
|
||||
it('uses the shared cover fallback only when canonical metadata is unavailable', async () => {
|
||||
const service = new MetadataExtractionService();
|
||||
const service = new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never);
|
||||
mocks.epub.mockResolvedValueOnce(null);
|
||||
mocks.cover.mockResolvedValueOnce(Buffer.from('fallback-cover'));
|
||||
|
||||
@@ -171,7 +209,7 @@ describe('MetadataExtractionService', () => {
|
||||
|
||||
it('enables PDF cover extraction and sanitizes parser warnings', () => {
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
new MetadataExtractionService();
|
||||
new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never);
|
||||
|
||||
expect(mocks.pdfOptions?.extractCover).toBe(true);
|
||||
mocks.pdfOptions?.onWarning?.({
|
||||
@@ -203,25 +241,50 @@ describe('MetadataExtractionService.detectFixedLayout', () => {
|
||||
it('reads the declaration for an epub', async () => {
|
||||
mocks.epubFixedLayout.mockResolvedValue(true);
|
||||
|
||||
await expect(new MetadataExtractionService().detectFixedLayout('/books/manga.epub', 'epub')).resolves.toBe(true);
|
||||
await expect(
|
||||
new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never).detectFixedLayout(
|
||||
'/books/manga.epub',
|
||||
'epub',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
expect(mocks.epubFixedLayout).toHaveBeenCalledWith('/books/manga.epub');
|
||||
});
|
||||
|
||||
it('reads the declaration for a kepub, which carries the same OPF', async () => {
|
||||
mocks.epubFixedLayout.mockResolvedValue(true);
|
||||
|
||||
await expect(new MetadataExtractionService().detectFixedLayout('/books/manga.kepub', 'kepub')).resolves.toBe(true);
|
||||
await expect(
|
||||
new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never).detectFixedLayout(
|
||||
'/books/manga.kepub',
|
||||
'kepub',
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('passes a reflowable answer through unchanged', async () => {
|
||||
mocks.epubFixedLayout.mockResolvedValue(false);
|
||||
|
||||
await expect(new MetadataExtractionService().detectFixedLayout('/books/novel.epub', 'epub')).resolves.toBe(false);
|
||||
await expect(
|
||||
new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never).detectFixedLayout(
|
||||
'/books/novel.epub',
|
||||
'epub',
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('returns null without touching the filesystem for a format that cannot declare a layout', async () => {
|
||||
await expect(new MetadataExtractionService().detectFixedLayout('/books/comic.cbz', 'cbz')).resolves.toBeNull();
|
||||
await expect(new MetadataExtractionService().detectFixedLayout('/books/scan.pdf', 'pdf')).resolves.toBeNull();
|
||||
await expect(
|
||||
new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never).detectFixedLayout(
|
||||
'/books/comic.cbz',
|
||||
'cbz',
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
new MetadataExtractionService({ extractMetadata: mocks.djvu, extractCover: mocks.djvuCover } as never).detectFixedLayout(
|
||||
'/books/scan.pdf',
|
||||
'pdf',
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(mocks.epubFixedLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DjvuDocumentService } from '../djvu/djvu-document.service';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { extractAudioCover } from './extractors/audio.extractor';
|
||||
@@ -12,6 +13,7 @@ import { OpfFormatExtractor } from './extractors/opf-format.extractor';
|
||||
import { PdfFormatExtractor } from './extractors/pdf-format.extractor';
|
||||
import { extractCover } from './lib/cover';
|
||||
import { extractEpubFixedLayout } from './lib/epub';
|
||||
import { parseBookFilename } from './lib/filename-parser';
|
||||
import type { PdfParseWarning } from './lib/pdf-parser';
|
||||
|
||||
export const METADATA_AUDIO_FORMATS = ['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'] as const;
|
||||
@@ -26,7 +28,7 @@ export class MetadataExtractionService {
|
||||
private readonly logger = new Logger(MetadataExtractionService.name);
|
||||
private readonly extractors: ReadonlyMap<string, FormatExtractor>;
|
||||
|
||||
constructor() {
|
||||
constructor(private readonly djvu: DjvuDocumentService) {
|
||||
const audio = new AudioFormatExtractor();
|
||||
const mobi = new MobiFormatExtractor();
|
||||
const epub = new EpubFormatExtractor();
|
||||
@@ -51,13 +53,31 @@ export class MetadataExtractionService {
|
||||
}
|
||||
|
||||
supports(format: string): boolean {
|
||||
return this.extractors.has(format);
|
||||
return format === 'djvu' || this.extractors.has(format);
|
||||
}
|
||||
|
||||
async extract(absolutePath: string, format: string): Promise<ParsedBookData | null> {
|
||||
if (format === 'djvu') {
|
||||
const { pageCount, cover } = await this.djvu.extractMetadata(absolutePath);
|
||||
const filename = parseBookFilename(absolutePath);
|
||||
return {
|
||||
title: filename.title,
|
||||
authors: [],
|
||||
publishedYear: filename.publishedYear,
|
||||
genres: [],
|
||||
pageCount,
|
||||
cover,
|
||||
};
|
||||
}
|
||||
return (await this.extractors.get(format)?.extract(absolutePath)) ?? null;
|
||||
}
|
||||
|
||||
/** What a DjVu file supplies, read without rendering its cover. */
|
||||
async extractDjvuDetails(absolutePath: string): Promise<{ title: string; publishedYear: number | null; pageCount: number }> {
|
||||
const filename = parseBookFilename(absolutePath);
|
||||
return { title: filename.title, publishedYear: filename.publishedYear, pageCount: await this.djvu.readPageCount(absolutePath) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads only the fixed-layout declaration from an EPUB, without the full metadata parse.
|
||||
* Returns null when the format cannot carry the declaration or the file cannot be read, so
|
||||
@@ -79,6 +99,7 @@ export class MetadataExtractionService {
|
||||
/** Reads only a file's embedded cover. KEPUB is an EPUB container, and audio art comes from ffmpeg. */
|
||||
async extractEmbeddedCover(absolutePath: string, format: string): Promise<Buffer | null> {
|
||||
const normalized = format.toLowerCase();
|
||||
if (normalized === 'djvu') return this.djvu.extractCover(absolutePath);
|
||||
if ((METADATA_AUDIO_FORMATS as readonly string[]).includes(normalized)) return extractAudioCover(absolutePath);
|
||||
return extractCover(absolutePath, normalized === 'kepub' ? 'epub' : normalized);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DjvuDocumentModule } from '../djvu/djvu-document.module';
|
||||
|
||||
import { BookMetadataLockModule } from '../book-metadata-lock/book-metadata-lock.module';
|
||||
import { BookCoverStoreModule } from '../book-cover-store/book-cover-store.module';
|
||||
@@ -12,7 +13,7 @@ import { MetadataEventsService } from './metadata-events.service';
|
||||
import { MetadataService } from './metadata.service';
|
||||
|
||||
@Module({
|
||||
imports: [BookMetadataLockModule, BookCoverStoreModule, EmbeddingModule, MetadataScoreModule, NarratorModule],
|
||||
imports: [BookMetadataLockModule, BookCoverStoreModule, EmbeddingModule, MetadataScoreModule, NarratorModule, DjvuDocumentModule],
|
||||
providers: [MetadataService, MetadataExtractionService, MetadataEventsService, ComicMetadataRepository, CoverSlotReconciler],
|
||||
exports: [MetadataService, MetadataExtractionService, MetadataEventsService, ComicMetadataRepository, CoverSlotReconciler],
|
||||
})
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('MetadataService', () => {
|
||||
) {
|
||||
return new MetadataService(
|
||||
db as never,
|
||||
new MetadataExtractionService(),
|
||||
new MetadataExtractionService({ extractMetadata: vi.fn(), extractCover: vi.fn() } as never),
|
||||
(overrides?.scoreService ?? { calculateAndSave: vi.fn().mockResolvedValue(undefined) }) as never,
|
||||
(overrides?.narratorService ?? { replaceForBook: vi.fn().mockResolvedValue(undefined) }) as never,
|
||||
(overrides?.comicMetadataRepository ?? { upsert: vi.fn().mockResolvedValue(undefined) }) as never,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, desc, eq, inArray, isNull, sql, type SQL } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { AUDIO_FORMAT_LIST, BOOK_FORMATS, isAudioFormat, type NotebookEntryKind } from '@bookorbit/types';
|
||||
import { AUDIO_FORMAT_LIST, BOOK_FORMATS, isAudioFormat, isOptInReaderFormat, type NotebookEntryKind } from '@bookorbit/types';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
@@ -73,7 +73,8 @@ export interface NotebookBookActivityRow {
|
||||
|
||||
type RawKeyRow = { kind_rank: number; id: number; book_id: number; sort_key: string };
|
||||
|
||||
const READABLE_FORMATS = BOOK_FORMATS.filter((format) => !isAudioFormat(format));
|
||||
// Only iOS reads the label's read file, and released builds cannot open opt-in formats such as DjVu.
|
||||
const READABLE_FORMATS = BOOK_FORMATS.filter((format) => !isAudioFormat(format) && !isOptInReaderFormat(format));
|
||||
|
||||
function sqlList(values: readonly string[]): SQL {
|
||||
return sql.join(
|
||||
|
||||
@@ -88,6 +88,8 @@ describe('fileMimeType', () => {
|
||||
it.each([
|
||||
['epub', 'application/epub+zip'],
|
||||
['pdf', 'application/pdf'],
|
||||
['djvu', 'image/vnd.djvu'],
|
||||
['DJVU', 'image/vnd.djvu'],
|
||||
['mobi', 'application/x-mobipocket-ebook'],
|
||||
['azw3', 'application/vnd.amazon.ebook'],
|
||||
['fb2', 'application/x-fictionbook+xml'],
|
||||
|
||||
@@ -39,6 +39,8 @@ export function fileMimeType(format: string): string {
|
||||
return 'application/kepub+zip';
|
||||
case 'pdf':
|
||||
return 'application/pdf';
|
||||
case 'djvu':
|
||||
return 'image/vnd.djvu';
|
||||
case 'mobi':
|
||||
return 'application/x-mobipocket-ebook';
|
||||
case 'azw3':
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Header, Headers, Param, ParseIntPipe, Query, Res } from '@nestjs/common';
|
||||
import type { ComicPageCountResponse, DjvuPageTextResponse } from '@bookorbit/types';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import { DjvuPageQuery } from './dto/djvu-page-query.dto';
|
||||
import { DjvuService } from './djvu.service';
|
||||
|
||||
@Controller('djvu')
|
||||
export class DjvuController {
|
||||
constructor(private readonly djvuService: DjvuService) {}
|
||||
|
||||
@Get('files/:fileId/pages')
|
||||
@Header('Cache-Control', 'private, no-store')
|
||||
async getPageCount(@Param('fileId', ParseIntPipe) fileId: number, @CurrentUser() user: RequestUser): Promise<ComicPageCountResponse> {
|
||||
return { pageCount: await this.djvuService.getPageCount(fileId, user) };
|
||||
}
|
||||
|
||||
@Get('files/:fileId/pages/:pageIndex')
|
||||
async getPage(
|
||||
@Param('fileId', ParseIntPipe) fileId: number,
|
||||
@Param('pageIndex', ParseIntPipe) pageIndex: number,
|
||||
@Query() query: DjvuPageQuery,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Res() reply: FastifyReply,
|
||||
@Headers('if-none-match') ifNoneMatch?: string,
|
||||
): Promise<void> {
|
||||
const page = await this.djvuService.getPage(fileId, pageIndex, query.size, user, ifNoneMatch);
|
||||
reply.header('Cache-Control', 'private, no-cache');
|
||||
reply.header('ETag', page.etag);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
if (page.notModified) reply.status(304).send();
|
||||
else reply.type('image/webp').send(page.buffer);
|
||||
}
|
||||
|
||||
@Get('files/:fileId/pages/:pageIndex/text')
|
||||
@Header('Cache-Control', 'private, no-store')
|
||||
getPageText(
|
||||
@Param('fileId', ParseIntPipe) fileId: number,
|
||||
@Param('pageIndex', ParseIntPipe) pageIndex: number,
|
||||
@CurrentUser() user: RequestUser,
|
||||
): Promise<DjvuPageTextResponse> {
|
||||
return this.djvuService.getPageText(fileId, pageIndex, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BookModule } from '../../book/book.module';
|
||||
import { DjvuDocumentModule } from '../../djvu/djvu-document.module';
|
||||
import { DjvuController } from './djvu.controller';
|
||||
import { DjvuService } from './djvu.service';
|
||||
|
||||
@Module({
|
||||
imports: [BookModule, DjvuDocumentModule],
|
||||
controllers: [DjvuController],
|
||||
providers: [DjvuService],
|
||||
})
|
||||
export class DjvuModule {}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { mkdtemp, rename, rm, stat, symlink, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { djvuBundleFixture, djvuPageFixture } from '../../../../test/e2e/djvu-fixture';
|
||||
import { djvuConfig, storageConfig } from '../../../config/config';
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import { BookService } from '../../book/book.service';
|
||||
import { PathPolicyService } from '../../path/path-policy.service';
|
||||
import { DjvuProcess } from '../../djvu/djvu-process';
|
||||
import { DjvuDocumentService } from '../../djvu/djvu-document.service';
|
||||
import { DjvuService } from './djvu.service';
|
||||
|
||||
describe('DjVu reader service', () => {
|
||||
let module: TestingModule;
|
||||
let service: DjvuService;
|
||||
let directory: string;
|
||||
let path: string;
|
||||
const access = vi.fn();
|
||||
const run = vi.fn();
|
||||
const user = { id: 7 } as RequestUser;
|
||||
const config = { maxConcurrent: 2, timeoutMs: 200, maxSourceBytes: 1024 * 1024, cacheBytes: 128 * 1024 };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
directory = await mkdtemp(join(tmpdir(), 'djvu-service-test-'));
|
||||
path = join(directory, 'source.djvu');
|
||||
await writeFile(path, djvuPageFixture());
|
||||
access.mockResolvedValue({ absolutePath: path, libraryFolderPath: directory, format: 'djvu' });
|
||||
run.mockImplementation(async (binary: string, args: string[]) => {
|
||||
if (binary === 'djvused') return '1\n';
|
||||
await sharp({ create: { width: 80, height: 120, channels: 3, background: 'white' } })
|
||||
.tiff()
|
||||
.toFile(args.at(-1)!);
|
||||
return '';
|
||||
});
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
DjvuService,
|
||||
DjvuDocumentService,
|
||||
PathPolicyService,
|
||||
{ provide: BookService, useValue: { verifyFileAccess: access } },
|
||||
{ provide: DjvuProcess, useValue: { run, onModuleDestroy: vi.fn() } },
|
||||
{ provide: djvuConfig.KEY, useValue: config },
|
||||
{ provide: storageConfig.KEY, useValue: { libraryBrowseRoot: directory } },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(DjvuService);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await module.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('coalesces metadata and page work and returns bounded WebP', async () => {
|
||||
const pages = await Promise.all([service.getPage(1, 0, 1280, user), service.getPage(1, 0, 1280, user)]);
|
||||
expect(access).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls.filter(([binary]) => binary === 'djvused')).toHaveLength(1);
|
||||
expect(run.mock.calls.filter(([binary]) => binary === 'ddjvu')).toHaveLength(1);
|
||||
expect(pages[0]).toEqual(pages[1]);
|
||||
const page = pages[0]!;
|
||||
if (page.notModified) throw new Error('Expected rendered page');
|
||||
expect(await sharp(page.buffer).metadata()).toMatchObject({ format: 'webp', width: 80, height: 120 });
|
||||
});
|
||||
|
||||
it('rechecks ownership before cached and conditional responses', async () => {
|
||||
const page = await service.getPage(1, 0, 1280, user);
|
||||
expect(await service.getPage(1, 0, 1280, user, `W/${page.etag}`)).toEqual({ notModified: true, etag: page.etag });
|
||||
access.mockRejectedValue(new ForbiddenException());
|
||||
await expect(service.getPage(1, 0, 1280, user, page.etag)).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.getPageCount(1, user)).rejects.toMatchObject({ status: 403 });
|
||||
await expect(service.getPageText(1, 0, user)).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('invalidates metadata and validators when the source is replaced', async () => {
|
||||
const first = await service.getPage(1, 0, 1280, user);
|
||||
const replacement = join(directory, 'replacement.djvu');
|
||||
await writeFile(replacement, djvuBundleFixture());
|
||||
await rename(replacement, path);
|
||||
run.mockResolvedValue('2\n');
|
||||
expect(await service.getPageCount(1, user)).toBe(2);
|
||||
const conditional = await service.getPage(1, 0, 1280, user, '*');
|
||||
expect(conditional.etag).not.toBe(first.etag);
|
||||
});
|
||||
|
||||
it('rejects missing pages, render sizes, and invalid magic before decoding', async () => {
|
||||
await expect(service.getPage(1, -1, 1280, user)).rejects.toMatchObject({ status: 404 });
|
||||
await expect(service.getPage(1, 1, 1280, user)).rejects.toMatchObject({ status: 404 });
|
||||
await expect(service.getPage(1, 0, 65535, user)).rejects.toMatchObject({ status: 422 });
|
||||
await writeFile(path, 'not a djvu');
|
||||
await expect(service.getPageCount(1, user)).rejects.toMatchObject({ status: 422 });
|
||||
expect(run.mock.calls.filter(([binary]) => binary === 'ddjvu')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a symlink escaping the authorized folder', async () => {
|
||||
const external = await mkdtemp(join(tmpdir(), 'djvu-external-test-'));
|
||||
try {
|
||||
const outside = join(external, 'external.djvu');
|
||||
await writeFile(outside, djvuPageFixture());
|
||||
const link = join(directory, 'link.djvu');
|
||||
await symlink(outside, link);
|
||||
access.mockResolvedValue({ absolutePath: link, libraryFolderPath: directory, format: 'djvu' });
|
||||
await expect(service.getPageCount(1, user)).rejects.toMatchObject({ status: 403 });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(external, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects excess queued work and drains the admitted requests', async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
run.mockImplementation(async () => {
|
||||
await blocked;
|
||||
return '1';
|
||||
});
|
||||
const requests = Array.from({ length: 19 }, () => service.getPageCount(1, user));
|
||||
const completed = Promise.allSettled(requests);
|
||||
await vi.waitFor(() => expect(access).toHaveBeenCalledTimes(19));
|
||||
release();
|
||||
const results = await completed;
|
||||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(18);
|
||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('evicts idle source snapshots and removes private files on shutdown', async () => {
|
||||
const snapshotPaths: string[] = [];
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const source = join(directory, `book-${index}.djvu`);
|
||||
await writeFile(source, djvuPageFixture());
|
||||
access.mockResolvedValue({ absolutePath: source, libraryFolderPath: directory, format: 'djvu' });
|
||||
expect(await service.getPageCount(index + 1, user)).toBe(1);
|
||||
snapshotPaths.push(run.mock.calls.at(-1)![1][0]);
|
||||
}
|
||||
await expect(stat(snapshotPaths[0]!)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
await module.close();
|
||||
for (const path of snapshotPaths) await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('answers revalidation, cached pages and page counts without a decoder slot, even after the source is evicted', async () => {
|
||||
const page = await service.getPage(1, 0, 1280, user);
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const other = join(directory, `other-${index}.djvu`);
|
||||
await writeFile(other, djvuPageFixture());
|
||||
access.mockResolvedValueOnce({ absolutePath: other, libraryFolderPath: directory, format: 'djvu' });
|
||||
await service.getPageCount(index + 2, user);
|
||||
}
|
||||
run.mockReset();
|
||||
run.mockRejectedValue(new Error('decoder must not run'));
|
||||
expect(await service.getPage(1, 0, 1280, user, page.etag)).toEqual({ notModified: true, etag: page.etag });
|
||||
expect(await service.getPageCount(1, user)).toBe(1);
|
||||
await expect(service.getPage(1, 1, 1280, user, page.etag)).rejects.toMatchObject({ status: 404 });
|
||||
await expect(service.getPageText(1, 3, user)).rejects.toMatchObject({ status: 404 });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admits background extraction beyond the reader queue', async () => {
|
||||
const documents = module.get(DjvuDocumentService);
|
||||
const queues = documents as unknown as { waiters: unknown[]; backgroundWaiters: unknown[] };
|
||||
const render = run.getMockImplementation()!;
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
run.mockImplementation(async (...args: unknown[]) => {
|
||||
await blocked;
|
||||
return render(...args);
|
||||
});
|
||||
const readers = Array.from({ length: 18 }, () => service.getPageCount(1, user));
|
||||
await vi.waitFor(() => expect(queues.waiters).toHaveLength(16));
|
||||
const background = Array.from({ length: 4 }, () => documents.extractMetadata(path));
|
||||
await vi.waitFor(() => expect(queues.backgroundWaiters).toHaveLength(4));
|
||||
await expect(service.getPageCount(1, user)).rejects.toMatchObject({ status: 503 });
|
||||
release();
|
||||
const results = await Promise.allSettled([...readers, ...background]);
|
||||
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('expires queued requests without starting more decoders', async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
run.mockImplementation(async () => {
|
||||
await blocked;
|
||||
return '1';
|
||||
});
|
||||
const first = service.getPageCount(1, user);
|
||||
const second = service.getPageCount(1, user);
|
||||
const third = service.getPageCount(1, user);
|
||||
const rejected = expect(third).rejects.toMatchObject({ status: 503 });
|
||||
await rejected;
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import { BookService } from '../../book/book.service';
|
||||
import { DjvuDocumentService, type DjvuPageResult } from '../../djvu/djvu-document.service';
|
||||
|
||||
@Injectable()
|
||||
export class DjvuService {
|
||||
constructor(
|
||||
private readonly bookService: BookService,
|
||||
private readonly documents: DjvuDocumentService,
|
||||
) {}
|
||||
|
||||
async getPageCount(fileId: number, user: RequestUser): Promise<number> {
|
||||
const file = await this.authorizedFile(fileId, user);
|
||||
return this.documents.getPageCount(file.absolutePath, file.libraryFolderPath);
|
||||
}
|
||||
|
||||
async getPage(fileId: number, pageIndex: number, size: number, user: RequestUser, ifNoneMatch?: string): Promise<DjvuPageResult> {
|
||||
const file = await this.authorizedFile(fileId, user);
|
||||
return this.documents.getPage(file.absolutePath, file.libraryFolderPath, pageIndex, size, ifNoneMatch);
|
||||
}
|
||||
|
||||
async getPageText(fileId: number, pageIndex: number, user: RequestUser): Promise<{ text: string }> {
|
||||
const file = await this.authorizedFile(fileId, user);
|
||||
return this.documents.getPageText(file.absolutePath, file.libraryFolderPath, pageIndex);
|
||||
}
|
||||
|
||||
private async authorizedFile(fileId: number, user: RequestUser) {
|
||||
// Access is checked on every request, including cached pages and conditional responses.
|
||||
const file = await this.bookService.verifyFileAccess(fileId, user);
|
||||
if (file.format?.toLowerCase() !== 'djvu') throw new NotFoundException('File is not a DjVu document');
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsIn, IsInt, IsOptional } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class DjvuPageQuery {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsIn([1280, 1920, 2560])
|
||||
size: number = 1920;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { classifyFile, isPrimaryFormat, isAudioFormat, AUDIO_FORMATS, DEFAULT_FO
|
||||
// ── PRIMARY FORMATS ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('classifyFile — primary formats', () => {
|
||||
it.each(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2'])('classifies .%s as primary', (ext) => {
|
||||
it.each(['epub', 'pdf', 'cbz', 'cbr', 'cb7', 'mobi', 'azw3', 'azw', 'fb2', 'djvu'])('classifies .%s as primary', (ext) => {
|
||||
const result = classifyFile(`/books/Author/Book/book.${ext}`);
|
||||
expect(result).toEqual({ format: ext, role: 'content' });
|
||||
});
|
||||
|
||||
@@ -359,7 +359,25 @@ function formatRank(format: string | null): number {
|
||||
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||
}
|
||||
|
||||
const FORMAT_RANK: string[] = ['epub', 'kepub', 'azw3', 'azw', 'mobi', 'fb2', 'pdf', 'cbz', 'cbr', 'cb7', 'm4b', 'm4a', 'flac', 'opus', 'ogg', 'mp3'];
|
||||
const FORMAT_RANK: string[] = [
|
||||
'epub',
|
||||
'kepub',
|
||||
'azw3',
|
||||
'azw',
|
||||
'mobi',
|
||||
'fb2',
|
||||
'pdf',
|
||||
'djvu',
|
||||
'cbz',
|
||||
'cbr',
|
||||
'cb7',
|
||||
'm4b',
|
||||
'm4a',
|
||||
'flac',
|
||||
'opus',
|
||||
'ogg',
|
||||
'mp3',
|
||||
];
|
||||
|
||||
function sumSizes(files: ReleasePlanFile[]): number | null {
|
||||
let total = 0;
|
||||
|
||||
@@ -259,6 +259,7 @@ export class ScannerRepository {
|
||||
mtime: bookFiles.mtime,
|
||||
fileHash: bookFiles.fileHash,
|
||||
format: bookFiles.format,
|
||||
role: bookFiles.role,
|
||||
sortOrder: bookFiles.sortOrder,
|
||||
durationSeconds: bookFiles.durationSeconds,
|
||||
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
|
||||
|
||||
@@ -1186,6 +1186,46 @@ describe('file identity resolution', () => {
|
||||
expect(repo.createBookFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('promotes an unchanged file whose format became a content format, such as DjVu stored as a supplement', async () => {
|
||||
const mtime = new Date('2024-01-01');
|
||||
const epub = makeFileStat({ mtime });
|
||||
const djvu = makeFileStat({ absolutePath: '/library/Author/Book/scan.djvu', relPath: 'Author/Book/scan.djvu', ino: 1002n, mtime });
|
||||
const repo = makeRepo({
|
||||
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([
|
||||
makeBookFile({ mtime, sizeBytes: epub.sizeBytes, mediaOverlayCheckedAt: mtime }),
|
||||
makeBookFile({
|
||||
id: 2,
|
||||
absolutePath: djvu.absolutePath,
|
||||
relPath: djvu.relPath,
|
||||
ino: djvu.ino,
|
||||
mtime,
|
||||
sizeBytes: djvu.sizeBytes,
|
||||
format: 'djvu',
|
||||
role: 'supplement',
|
||||
sortOrder: 1,
|
||||
}),
|
||||
]),
|
||||
findBooksByLibraryFolder: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Author/Book', status: 'present' }]),
|
||||
});
|
||||
mockFindCandidates.mockResolvedValue({
|
||||
candidates: [makeCandidate('/library/Author/Book', [epub, djvu])],
|
||||
skippedDirs: new Set(),
|
||||
unchangedDirs: new Set(),
|
||||
dirMtimes: new Map(),
|
||||
});
|
||||
|
||||
const done = awaitScan(repo);
|
||||
const { service } = makeService(repo);
|
||||
await service.startScan(1, 'manual');
|
||||
await done;
|
||||
|
||||
expect(repo.updateBookFile).toHaveBeenCalledWith(2, expect.objectContaining({ format: 'djvu', role: 'content' }));
|
||||
expect(repo.updateBookFile).not.toHaveBeenCalledWith(1, expect.objectContaining({ role: expect.anything() }));
|
||||
expect(repo.createBookFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('repairs a stale relPath when the absolute path and file state are unchanged', async () => {
|
||||
const mtime = new Date('2024-01-01');
|
||||
const fileStat = makeFileStat({ absolutePath: '/library/Author/Book/new.epub', relPath: 'Author/Book/new.epub', mtime });
|
||||
|
||||
@@ -70,6 +70,8 @@ interface FileByPathEntry {
|
||||
sortOrder: number | null;
|
||||
mediaOverlayAvailable: boolean;
|
||||
mediaOverlayCheckedAt: Date | null;
|
||||
/** Absent when the caller did not load it, which leaves the stored role alone. */
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
interface FileByInoEntry {
|
||||
@@ -110,6 +112,7 @@ const METADATA_FORMATS = new Set([
|
||||
'cb7',
|
||||
'fb2',
|
||||
'pdf',
|
||||
'djvu',
|
||||
'm4b',
|
||||
'mp3',
|
||||
'm4a',
|
||||
@@ -353,6 +356,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
sortOrder?: number | null;
|
||||
mediaOverlayAvailable?: boolean | null;
|
||||
mediaOverlayCheckedAt?: Date | null;
|
||||
role?: string | null;
|
||||
}>,
|
||||
): ScanLookupMaps {
|
||||
const bookByFolderPath = new Map<string, BookEntry>(
|
||||
@@ -398,6 +402,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
sortOrder: f.sortOrder ?? null,
|
||||
mediaOverlayAvailable: f.mediaOverlayAvailable === true,
|
||||
mediaOverlayCheckedAt: f.mediaOverlayCheckedAt ?? null,
|
||||
role: f.role,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -1126,6 +1131,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
sortOrder: f.sortOrder,
|
||||
mediaOverlayAvailable: f.mediaOverlayAvailable,
|
||||
mediaOverlayCheckedAt: f.mediaOverlayCheckedAt,
|
||||
role: f.role,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -2539,10 +2545,13 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
const reassigned = byPath.bookId !== bookId;
|
||||
const previousBookId = reassigned ? byPath.bookId : undefined;
|
||||
const sortOrderUnchanged = sortOrder === byPath.sortOrder;
|
||||
// A file stored under an older classification, such as DjVu before it was a content format,
|
||||
// is promoted even though the file itself is unchanged.
|
||||
const roleUnchanged = byPath.role == null || byPath.role === role;
|
||||
let mediaOverlayAvailable = byPath.mediaOverlayAvailable;
|
||||
let mediaOverlayCheckedAt = byPath.mediaOverlayCheckedAt;
|
||||
|
||||
if (sizeUnchanged && mtimeUnchanged && inoUnchanged && relPathUnchanged && !reassigned && sortOrderUnchanged) {
|
||||
if (sizeUnchanged && mtimeUnchanged && inoUnchanged && relPathUnchanged && !reassigned && sortOrderUnchanged && roleUnchanged) {
|
||||
if (format?.toLowerCase() === 'epub' && byPath.mediaOverlayCheckedAt == null) {
|
||||
const mediaOverlayFields = await this.inspectMediaOverlayFields(fileStat.absolutePath, format);
|
||||
await this.scannerRepo.updateBookFile(byPath.id, mediaOverlayFields);
|
||||
@@ -2564,7 +2573,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
mediaOverlayCheckedAt = mediaOverlayFields.mediaOverlayCheckedAt ?? null;
|
||||
}
|
||||
|
||||
if (!sizeUnchanged || !mtimeUnchanged || !inoUnchanged || !relPathUnchanged || reassigned) {
|
||||
if (!sizeUnchanged || !mtimeUnchanged || !inoUnchanged || !relPathUnchanged || reassigned || !roleUnchanged) {
|
||||
await this.scannerRepo.updateBookFile(byPath.id, {
|
||||
...(reassigned && { bookId }),
|
||||
libraryFolderId,
|
||||
@@ -2598,6 +2607,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
sortOrder,
|
||||
mediaOverlayAvailable,
|
||||
mediaOverlayCheckedAt,
|
||||
role,
|
||||
});
|
||||
if (fileStat.ino !== 0n) {
|
||||
fileByIno.set(fileStat.ino, {
|
||||
@@ -2608,7 +2618,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
mtime: fileStat.mtime,
|
||||
});
|
||||
}
|
||||
return { isNew: false, reassigned, changed: !sizeUnchanged || !mtimeUnchanged, fileId: byPath.id, previousBookId };
|
||||
return { isNew: false, reassigned, changed: !sizeUnchanged || !mtimeUnchanged || !roleUnchanged, fileId: byPath.id, previousBookId };
|
||||
}
|
||||
|
||||
private async resolveByLocalIno(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { FindNextSeriesBookDto } from './find-next-series-book.dto';
|
||||
|
||||
describe('FindNextSeriesBookDto', () => {
|
||||
it('keeps the query a released iOS build sends valid and leaves DjVu out', async () => {
|
||||
const dto = plainToInstance(FindNextSeriesBookDto, { formatGroup: 'cbx' });
|
||||
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
expect(dto.includeDjvu).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads the DjVu opt-in from a query string', async () => {
|
||||
const dto = plainToInstance(FindNextSeriesBookDto, { formatGroup: 'cbx', includeDjvu: 'true' });
|
||||
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
expect(dto.includeDjvu).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
import { IsBoolean, IsIn, IsOptional } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
import { READER_GROUP_DEFAULTS, type ReaderFormatGroup } from '@bookorbit/types';
|
||||
|
||||
@@ -9,4 +10,10 @@ export class FindNextSeriesBookDto {
|
||||
@IsOptional()
|
||||
@IsIn(READER_FORMAT_GROUPS)
|
||||
formatGroup?: ReaderFormatGroup;
|
||||
|
||||
/** Released native builds cannot open DjVu, so it is offered only to a caller that asks for it. */
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === 'true' || value === '1')
|
||||
@IsBoolean()
|
||||
includeDjvu?: boolean;
|
||||
}
|
||||
|
||||
@@ -616,13 +616,24 @@ describe('SeriesService', () => {
|
||||
expect([...params.formats].sort()).toEqual(['cb7', 'cbr', 'cbz']);
|
||||
});
|
||||
|
||||
it('allows every readable format when no group is requested', async () => {
|
||||
it('offers DjVu only to a caller that says it can open it', async () => {
|
||||
seriesRepo.findNextReadableBook.mockResolvedValue(null);
|
||||
|
||||
await service.findNextBook(reqUser(), 42, 90, { formatGroup: 'cbx', includeDjvu: true });
|
||||
await service.findNextBook(reqUser(), 42, 90, { includeDjvu: true });
|
||||
|
||||
expect([...seriesRepo.findNextReadableBook.mock.calls[0]![0].formats].sort()).toEqual(['cb7', 'cbr', 'cbz', 'djvu']);
|
||||
expect(seriesRepo.findNextReadableBook.mock.calls[1]![0].formats).toContain('djvu');
|
||||
});
|
||||
|
||||
it('allows every readable format except opt-in ones when no group is requested', async () => {
|
||||
seriesRepo.findNextReadableBook.mockResolvedValue(null);
|
||||
|
||||
await service.findNextBook(reqUser(), 42, 90, {});
|
||||
|
||||
const params = seriesRepo.findNextReadableBook.mock.calls[0]![0];
|
||||
expect(params.formats).toEqual(expect.arrayContaining(['epub', 'pdf', 'cbz', 'm4b']));
|
||||
expect(params.formats).not.toContain('djvu');
|
||||
});
|
||||
|
||||
it('applies the user content filters and exempts superusers', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import type { BooksPage, SeriesBooksPage, SeriesDetail, SeriesNextBookResponse, SeriesPage, SeriesSummary } from '@bookorbit/types';
|
||||
import { READER_OPENABLE_FORMATS, SERIES_GAP_PREVIEW_LIMIT, getOpenableFormatsForGroup } from '@bookorbit/types';
|
||||
import { READER_OPENABLE_FORMATS, SERIES_GAP_PREVIEW_LIMIT, getOpenableFormatsForGroup, isOptInReaderFormat } from '@bookorbit/types';
|
||||
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { normalizeSeriesTotalBooks } from '../../common/utils/series-total-books.utils';
|
||||
@@ -207,7 +207,9 @@ export class SeriesService {
|
||||
seriesId,
|
||||
bookId,
|
||||
libraryIds,
|
||||
formats: dto.formatGroup ? getOpenableFormatsForGroup(dto.formatGroup) : [...READER_OPENABLE_FORMATS],
|
||||
formats: (dto.formatGroup ? getOpenableFormatsForGroup(dto.formatGroup) : [...READER_OPENABLE_FORMATS]).filter(
|
||||
(format) => dto.includeDjvu === true || !isOptInReaderFormat(format),
|
||||
),
|
||||
contentFilters: user.isSuperuser ? undefined : user.contentFilters,
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ const METADATA_FORMATS = new Set([
|
||||
'cb7',
|
||||
'fb2',
|
||||
'pdf',
|
||||
'djvu',
|
||||
'm4b',
|
||||
'm4a',
|
||||
'mp3',
|
||||
|
||||
@@ -3,6 +3,8 @@ import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { djvuPageFixture } from '../../../test/e2e/djvu-fixture';
|
||||
|
||||
import { UploadValidatorService } from './upload-validator.service';
|
||||
|
||||
describe('UploadValidatorService', () => {
|
||||
@@ -125,6 +127,7 @@ describe('UploadValidatorService', () => {
|
||||
['kepub', Buffer.from([0x50, 0x4b, 0x03, 0x04])],
|
||||
['cbz', Buffer.from([0x50, 0x4b, 0x03, 0x04])],
|
||||
['pdf', Buffer.from('%PDF-1.7')],
|
||||
['djvu', djvuPageFixture()],
|
||||
['cbr', Buffer.from('Rar!\x1a\x07\x01')],
|
||||
['cb7', Buffer.from([0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c])],
|
||||
[
|
||||
|
||||
@@ -85,6 +85,8 @@ function matchesFormatSignature(bytes: Buffer, format: string): boolean {
|
||||
return bytes[0] === 0x50 && bytes[1] === 0x4b;
|
||||
case 'pdf':
|
||||
return ascii.startsWith('%PDF-');
|
||||
case 'djvu':
|
||||
return bytes.length >= 16 && ascii.startsWith('AT&TFORM') && ['DJVU', 'DJVM'].includes(ascii.slice(12, 16));
|
||||
case 'cbr':
|
||||
return ascii.startsWith('Rar!\x1a\x07');
|
||||
case 'cb7':
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import sharp from 'sharp';
|
||||
import { djvuBundleFixture, djvuPageFixture } from './e2e/djvu-fixture';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { waitForCondition } from './e2e/app-harness';
|
||||
import {
|
||||
authHeader,
|
||||
closeReaderStateIsolationE2EContext,
|
||||
createLibraryWithFolder,
|
||||
createReaderStateIsolationE2EContext,
|
||||
createUserAndLogin,
|
||||
grantLibraryAccess,
|
||||
locateBookByAbsolutePath,
|
||||
triggerAndWaitForLibraryScan,
|
||||
type LocatedBookFile,
|
||||
type ReaderStateIsolationE2EContext,
|
||||
type TestUserSession,
|
||||
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
|
||||
|
||||
describe('DjVuLibre reader (e2e)', { timeout: 60_000 }, () => {
|
||||
let ctx: ReaderStateIsolationE2EContext;
|
||||
let book: LocatedBookFile;
|
||||
let reader: TestUserSession;
|
||||
let secondReader: TestUserSession;
|
||||
let outsider: TestUserSession;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createReaderStateIsolationE2EContext({ realMetadata: true, multipart: true });
|
||||
const library = await createLibraryWithFolder(ctx);
|
||||
const path = join(library.folderPath, 'scanned-book.djvu');
|
||||
await writeFile(path, djvuBundleFixture());
|
||||
await writeFile(join(library.folderPath, 'ocr.txt'), '(page 0 0 80 120 (line 0 0 80 20 (word 0 0 80 20 "Embedded sample text")))');
|
||||
await promisify(execFile)('djvused', [path, '-s', '-e', 'select 1; set-txt ocr.txt'], { cwd: library.folderPath });
|
||||
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
|
||||
book = await locateBookByAbsolutePath(ctx, path);
|
||||
reader = await createUserAndLogin(ctx);
|
||||
secondReader = await createUserAndLogin(ctx);
|
||||
outsider = await createUserAndLogin(ctx);
|
||||
await grantLibraryAccess(ctx, reader.userId, library.libraryId);
|
||||
await grantLibraryAccess(ctx, secondReader.userId, library.libraryId);
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
if (ctx) await closeReaderStateIsolationE2EContext(ctx);
|
||||
});
|
||||
|
||||
function get(suffix: string, token = reader.accessToken, extraHeaders: Record<string, string> = {}) {
|
||||
return ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/djvu/files/${book.bookFileId}/pages${suffix}`,
|
||||
headers: { ...authHeader(token), ...extraHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
function upload(url: string, filename: string, bytes: Buffer, token = ctx.adminToken, extraHeaders: Record<string, string> = {}) {
|
||||
const boundary = `djvu-${randomUUID()}`;
|
||||
return ctx.app.inject({
|
||||
method: 'POST',
|
||||
url,
|
||||
headers: { ...authHeader(token), 'content-type': `multipart/form-data; boundary=${boundary}`, ...extraHeaders },
|
||||
payload: Buffer.concat([
|
||||
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: image/vnd.djvu\r\n\r\n`),
|
||||
bytes,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
it('extracts page count and first-page covers during scanning and exposes file metadata', async () => {
|
||||
const detail = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${book.bookId}`, headers: authHeader(reader.accessToken) });
|
||||
expect(detail.json()).toMatchObject({ pageCount: 2, title: 'scanned-book' });
|
||||
const cover = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${book.bookId}/cover`, headers: authHeader(reader.accessToken) });
|
||||
expect(cover.statusCode).toBe(200);
|
||||
expect((await sharp(cover.rawPayload).metadata()).width).toBeGreaterThan(0);
|
||||
const metadata = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${book.bookId}/metadata-from-file`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(metadata.json()).toMatchObject({ title: 'scanned-book', pageCount: 2 });
|
||||
});
|
||||
|
||||
it('imports DjVu through library upload and keeps library format restrictions', async () => {
|
||||
const library = await createLibraryWithFolder(ctx, { allowedFormats: ['djvu'] });
|
||||
const result = await upload(`/api/v1/libraries/${library.libraryId}/upload`, 'uploaded.DJVU', djvuBundleFixture());
|
||||
expect(result.statusCode).toBe(201);
|
||||
expect(result.json()).toMatchObject({ format: 'djvu' });
|
||||
const uploadedId = result.json<{ bookId: number }>().bookId;
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${uploadedId}`, headers: authHeader(ctx.adminToken) });
|
||||
expect(detail.json<{ pageCount: number | null }>().pageCount).toBe(2);
|
||||
const cover = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${uploadedId}/cover`, headers: authHeader(ctx.adminToken) });
|
||||
expect(cover.statusCode).toBe(200);
|
||||
},
|
||||
15_000,
|
||||
100,
|
||||
);
|
||||
expect((await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${uploadedId}/cover`, headers: authHeader(ctx.adminToken) })).statusCode).toBe(
|
||||
200,
|
||||
);
|
||||
const restricted = await createLibraryWithFolder(ctx, { allowedFormats: ['epub'] });
|
||||
expect((await upload(`/api/v1/libraries/${restricted.libraryId}/upload`, 'rejected.djvu', djvuPageFixture())).statusCode).toBe(400);
|
||||
expect((await upload(`/api/v1/libraries/${library.libraryId}/upload`, 'corrupt.djvu', Buffer.from('not a DjVu document'))).statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('advertises DjVu and imports a resumed upload without losing document bytes', async () => {
|
||||
const capabilities = await ctx.app.inject({ method: 'GET', url: '/api/v1/uploads/capabilities', headers: authHeader(ctx.adminToken) });
|
||||
expect(capabilities.statusCode).toBe(200);
|
||||
expect(capabilities.json<{ supportedFormats: string[] }>().supportedFormats).toContain('djvu');
|
||||
const library = await createLibraryWithFolder(ctx, { allowedFormats: ['djvu'] });
|
||||
const bytes = djvuBundleFixture();
|
||||
const created = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/uploads',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: {
|
||||
filename: 'resumed.djvu',
|
||||
sizeBytes: bytes.length,
|
||||
idempotencyKey: randomUUID(),
|
||||
target: { kind: 'library', libraryId: library.libraryId },
|
||||
contentType: 'image/vnd.djvu',
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const id = created.json<{ id: string }>().id;
|
||||
const split = Math.floor(bytes.length / 2);
|
||||
const first = await upload(`/api/v1/uploads/${id}/chunks`, 'chunk', bytes.subarray(0, split), ctx.adminToken, { 'upload-offset': '0' });
|
||||
expect(first.statusCode).toBe(201);
|
||||
const resumed = await ctx.app.inject({ method: 'GET', url: `/api/v1/uploads/${id}`, headers: authHeader(ctx.adminToken) });
|
||||
expect(resumed.json()).toMatchObject({ receivedBytes: split, status: 'receiving' });
|
||||
const second = await upload(`/api/v1/uploads/${id}/chunks`, 'chunk', bytes.subarray(split), ctx.adminToken, { 'upload-offset': String(split) });
|
||||
expect(second.statusCode).toBe(201);
|
||||
const completed = await ctx.app.inject({ method: 'POST', url: `/api/v1/uploads/${id}/complete`, headers: authHeader(ctx.adminToken) });
|
||||
expect(completed.statusCode).toBe(202);
|
||||
let importedId = 0;
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const status = await ctx.app.inject({ method: 'GET', url: `/api/v1/uploads/${id}`, headers: authHeader(ctx.adminToken) });
|
||||
const result = status.json<{ status: string; bookId: number }>();
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.bookId).toBeGreaterThan(0);
|
||||
importedId = result.bookId;
|
||||
},
|
||||
15_000,
|
||||
100,
|
||||
);
|
||||
const files = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/${importedId}`, headers: authHeader(ctx.adminToken) });
|
||||
const fileId = files.json<{ files: { id: number }[] }>().files[0]!.id;
|
||||
const download = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/files/${fileId}/download`, headers: authHeader(ctx.adminToken) });
|
||||
expect(download.statusCode).toBe(200);
|
||||
expect(download.rawPayload).toEqual(bytes);
|
||||
const pages = await ctx.app.inject({ method: 'GET', url: `/api/v1/djvu/files/${fileId}/pages`, headers: authHeader(ctx.adminToken) });
|
||||
expect(pages.json()).toEqual({ pageCount: 2 });
|
||||
});
|
||||
|
||||
it('accepts DjVu in the Book Dock and extracts its cover and page count', async () => {
|
||||
const result = await upload('/api/v1/book-dock/upload', 'dock.djvu', djvuPageFixture());
|
||||
expect(result.statusCode).toBe(201);
|
||||
const id = result.json<{ id: number }>().id;
|
||||
await waitForCondition(
|
||||
async () => {
|
||||
const detail = await ctx.app.inject({ method: 'GET', url: `/api/v1/book-dock/files/${id}`, headers: authHeader(ctx.adminToken) });
|
||||
const data = detail.json<{ status: string; embeddedMetadata: { pageCount?: number } }>();
|
||||
expect(data.status).toBe('ready');
|
||||
expect(data.embeddedMetadata?.pageCount).toBe(1);
|
||||
const cover = await ctx.app.inject({ method: 'GET', url: `/api/v1/book-dock/files/${id}/cover`, headers: authHeader(ctx.adminToken) });
|
||||
expect(cover.statusCode).toBe(200);
|
||||
},
|
||||
15_000,
|
||||
100,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a DjVu edition to an existing folder book and delivers its original MIME type', async () => {
|
||||
const library = await createLibraryWithFolder(ctx, { mode: 'book_per_folder' });
|
||||
const first = await upload(`/api/v1/libraries/${library.libraryId}/upload`, 'base.djvu', djvuPageFixture());
|
||||
expect(first.statusCode).toBe(201);
|
||||
const bookId = first.json<{ bookId: number }>().bookId;
|
||||
const added = await upload(`/api/v1/books/${bookId}/files`, 'bundle.djvu', djvuBundleFixture());
|
||||
expect(added.statusCode).toBe(201);
|
||||
expect(added.json()).toMatchObject({ format: 'djvu', role: 'content' });
|
||||
const fileId = added.json<{ id: number }>().id;
|
||||
const download = await ctx.app.inject({ method: 'GET', url: `/api/v1/books/files/${fileId}/download`, headers: authHeader(ctx.adminToken) });
|
||||
expect(download.statusCode).toBe(200);
|
||||
expect(download.headers['content-type']).toBe('image/vnd.djvu');
|
||||
expect(download.rawPayload).toEqual(djvuBundleFixture());
|
||||
const pages = await ctx.app.inject({ method: 'GET', url: `/api/v1/djvu/files/${fileId}/pages`, headers: authHeader(ctx.adminToken) });
|
||||
expect(pages.json()).toEqual({ pageCount: 2 });
|
||||
});
|
||||
|
||||
it('renders real bundled pages and returns embedded OCR', async () => {
|
||||
const count = await get('');
|
||||
expect(count.statusCode).toBe(200);
|
||||
expect(count.json()).toEqual({ pageCount: 2 });
|
||||
for (const index of [0, 1]) {
|
||||
const page = await get(`/${index}?size=1280`);
|
||||
expect(page.statusCode).toBe(200);
|
||||
expect(page.headers['content-type']).toBe('image/webp');
|
||||
expect(page.headers['cache-control']).toBe('private, no-cache');
|
||||
const metadata = await sharp(page.rawPayload).metadata();
|
||||
expect(metadata.format).toBe('webp');
|
||||
expect(Math.max(metadata.width!, metadata.height!)).toBeLessThanOrEqual(1280);
|
||||
}
|
||||
const text = await get('/0/text');
|
||||
expect(text.statusCode).toBe(200);
|
||||
expect(text.json()).toEqual({ text: 'Embedded sample text' });
|
||||
expect((await get('/1/text')).json()).toEqual({ text: '' });
|
||||
});
|
||||
|
||||
it('enforces authentication and library access even after cache warming', async () => {
|
||||
const warm = await get('/0?size=1280');
|
||||
const etag = String(warm.headers.etag);
|
||||
expect((await get('/0?size=1280', reader.accessToken, { 'if-none-match': etag })).statusCode).toBe(304);
|
||||
for (const suffix of ['', '/0?size=1280', '/0/text']) {
|
||||
expect((await get(suffix, outsider.accessToken, { 'if-none-match': etag })).statusCode).toBe(403);
|
||||
}
|
||||
const noAuth = await ctx.app.inject({ method: 'GET', url: `/api/v1/djvu/files/${book.bookFileId}/pages/0` });
|
||||
expect(noAuth.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('validates pages and render sizes at the HTTP boundary', async () => {
|
||||
expect((await get('/2')).statusCode).toBe(404);
|
||||
expect((await get('/-1')).statusCode).toBe(404);
|
||||
expect((await get('/0?size=99999')).statusCode).toBe(400);
|
||||
expect((await get('/0?size=not-a-number')).statusCode).toBe(400);
|
||||
expect((await get('/0?unknown=1')).statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('saves paged progress and image-reader preferences separately for each account', async () => {
|
||||
const saved = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/books/files/${book.bookFileId}/progress`,
|
||||
headers: authHeader(reader.accessToken),
|
||||
payload: { pageNumber: 2, percentage: 60 },
|
||||
});
|
||||
expect(saved.statusCode).toBe(201);
|
||||
const progress = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${book.bookFileId}/progress`,
|
||||
headers: authHeader(reader.accessToken),
|
||||
});
|
||||
expect(progress.json()).toMatchObject({ pageNumber: 2, percentage: 60 });
|
||||
const second = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${book.bookFileId}/progress`,
|
||||
headers: authHeader(secondReader.accessToken),
|
||||
});
|
||||
expect(second.json()?.pageNumber ?? null).toBeNull();
|
||||
const settings = await ctx.app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/reader/preferences/${book.bookFileId}`,
|
||||
headers: authHeader(reader.accessToken),
|
||||
payload: { set: { fitMode: 'fit-width' } },
|
||||
});
|
||||
expect(settings.statusCode).toBe(204);
|
||||
const preference = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/reader/preferences/${book.bookFileId}`,
|
||||
headers: authHeader(reader.accessToken),
|
||||
});
|
||||
expect(preference.json()).toMatchObject({ settings: { fitMode: 'fit-width' }, isCustomized: true });
|
||||
const other = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/reader/preferences/${book.bookFileId}`,
|
||||
headers: authHeader(secondReader.accessToken),
|
||||
});
|
||||
expect(other.json()).toEqual({ settings: null, isCustomized: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// Synthetic 80x120 color page generated with c44; no downloaded document or metadata.
|
||||
export function djvuPageFixture(): Buffer {
|
||||
return Buffer.from(
|
||||
'QVQmVEZPUk0AAAB5REpWVUlORk8AAAAKAFAAeBgAZAAWAUJHNDQAAAA5AEoBAgBQAHiK/4/WFVL9KZ/P+U/5tbAAX5D9Z9/+vMGwH////04eo4Tb/zvtSoSRsZHVE+e9Q+trAEJHNDQAAAAJAQ///w5zz75/AEJHNDQAAAAHAgr/8OMWzg==',
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
export function djvuBundleFixture(): Buffer {
|
||||
return Buffer.from(
|
||||
'QVQmVEZPUk0AAAE3REpWTURJUk0AAAAngQACAAAAQAAAAML//+C/ih/u82uWgGzIJJ4n4vgfzH1u5mMh7gAeAEZPUk0AAAB5REpWVUlORk8AAAAKAFAAeBgAZAAWAUJHNDQAAAA5AEoBAgBQAHiK/4/WFVL9KZ/P+U/5tbAAX5D9Z9/+vMGwH////04eo4Tb/zvtSoSRsZHVE+e9Q+trAEJHNDQAAAAJAQ///w5zz75/AEJHNDQAAAAHAgr/8OMWzgBGT1JNAAAAeURKVlVJTkZPAAAACgBQAHgYAGQAFgFCRzQ0AAAAOQBKAQIAUAB4iv+P1hVS/Smfz/lP+bWwAF+Q/Wff/rzBsB////9OHqOE2/877UqEkbGR1RPnvUPrawBCRzQ0AAAACQEP//8Oc8++fwBCRzQ0AAAABwIK//DjFs4=',
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { hash } from 'bcryptjs';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
@@ -65,7 +66,9 @@ export function authHeader(token: string): Record<string, string> {
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function createReaderStateIsolationE2EContext(): Promise<ReaderStateIsolationE2EContext> {
|
||||
export async function createReaderStateIsolationE2EContext(
|
||||
options: { realMetadata?: boolean; multipart?: boolean } = {},
|
||||
): Promise<ReaderStateIsolationE2EContext> {
|
||||
const fixture = await createReaderStateIsolationFixtureRoot();
|
||||
const envSnapshot: EnvSnapshot = {
|
||||
appDataPath: process.env.APP_DATA_PATH,
|
||||
@@ -73,12 +76,11 @@ export async function createReaderStateIsolationE2EContext(): Promise<ReaderStat
|
||||
|
||||
process.env.APP_DATA_PATH = fixture.booksPath;
|
||||
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
const builder = Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(MetadataService)
|
||||
.useValue(makeMetadataNoopMock())
|
||||
.compile();
|
||||
});
|
||||
if (!options.realMetadata) builder.overrideProvider(MetadataService).useValue(makeMetadataNoopMock());
|
||||
const moduleFixture = await builder.compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.setGlobalPrefix('api/v1');
|
||||
@@ -91,6 +93,7 @@ export async function createReaderStateIsolationE2EContext(): Promise<ReaderStat
|
||||
);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
await app.register(fastifyCookie as never);
|
||||
if (options.multipart) await app.register(fastifyMultipart as never);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
|
||||
@@ -365,6 +365,21 @@ describe('Email lifecycle (e2e)', { timeout: 180_000 }, () => {
|
||||
});
|
||||
|
||||
describe('recipients/groups/preferences ownership and conflicts', () => {
|
||||
it('persists DjVu format preferences for ordinary recipients', async () => {
|
||||
const created = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
||||
name: 'DjVu documents',
|
||||
email: 'djvu-reader@example.com',
|
||||
deviceType: 'other',
|
||||
preferredFormat: 'djvu',
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const recipient = created.json() as { id: number; preferredFormat: string };
|
||||
expect(recipient.preferredFormat).toBe('djvu');
|
||||
const loaded = await apiAs(senderOnly, 'GET', `/api/v1/email/recipients/${recipient.id}`);
|
||||
expect(loaded.statusCode).toBe(200);
|
||||
expect(loaded.json()).toMatchObject({ preferredFormat: 'djvu', deviceType: 'other' });
|
||||
});
|
||||
|
||||
it('enforces recipient ownership and duplicate-email conflicts', async () => {
|
||||
const recipientResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
|
||||
name: 'Sender Kindle',
|
||||
|
||||
@@ -94,8 +94,11 @@ describe('Series next readable book (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
|
||||
let issue10!: SeededBook;
|
||||
let unnumbered!: SeededBook;
|
||||
|
||||
async function requestNext(bookId: number, options: { formatGroup?: string; series?: number; token?: string } = {}) {
|
||||
const query = options.formatGroup ? `?formatGroup=${options.formatGroup}` : '';
|
||||
async function requestNext(bookId: number, options: { formatGroup?: string; series?: number; token?: string; includeDjvu?: boolean } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (options.formatGroup) params.set('formatGroup', options.formatGroup);
|
||||
if (options.includeDjvu) params.set('includeDjvu', 'true');
|
||||
const query = params.size > 0 ? `?${params}` : '';
|
||||
return ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/series/${options.series ?? seriesId}/books/${bookId}/next${query}`,
|
||||
@@ -190,6 +193,26 @@ describe('Series next readable book (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
|
||||
});
|
||||
});
|
||||
|
||||
it('offers a DjVu continuation only to a caller that opts in, so released iOS builds never receive one', async () => {
|
||||
const library = await createLibraryWithFolder(ctx, { name: `series-next-djvu-${randomUUID()}` });
|
||||
await grantLibraryAccess(ctx, reader.userId, library.libraryId);
|
||||
const [scanned] = await ctx.db
|
||||
.insert(schema.bookSeries)
|
||||
.values({ name: 'Scanned Run', normalizedName: `scanned run ${randomUUID()}` })
|
||||
.returning({ id: schema.bookSeries.id });
|
||||
const first = await seedBook(ctx, library, scanned!.id, { title: 'Scan 1', seriesIndex: '1', formats: ['cbz'] });
|
||||
const djvu = await seedBook(ctx, library, scanned!.id, { title: 'Scan 2', seriesIndex: '2', formats: ['djvu'] });
|
||||
const third = await seedBook(ctx, library, scanned!.id, { title: 'Scan 3', seriesIndex: '3', formats: ['cbz'] });
|
||||
|
||||
const released = await requestNext(first.bookId, { formatGroup: 'cbx', series: scanned!.id });
|
||||
const anyFormat = await requestNext(first.bookId, { series: scanned!.id });
|
||||
const web = await requestNext(first.bookId, { formatGroup: 'cbx', series: scanned!.id, includeDjvu: true });
|
||||
|
||||
expect(released.json()).toEqual({ next: expect.objectContaining({ bookId: third.bookId, format: 'cbz' }) });
|
||||
expect(anyFormat.json()).toEqual({ next: expect.objectContaining({ bookId: third.bookId, format: 'cbz' }) });
|
||||
expect(web.json()).toEqual({ next: expect.objectContaining({ bookId: djvu.bookId, fileId: djvu.fileIdsByFormat.get('djvu'), format: 'djvu' }) });
|
||||
});
|
||||
|
||||
it('returns no next book when the book is not in the requested series', async () => {
|
||||
const response = await requestNext(issue1.bookId, { formatGroup: 'cbx', series: otherSeriesId });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user