feat(books): add file upload UI to book detail files tab

This commit is contained in:
neon
2026-06-08 17:18:28 +00:00
parent 8bee4905bb
commit c0a7aef075
13 changed files with 1569 additions and 25 deletions
@@ -0,0 +1,338 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { CheckCircle2, FileUp, Loader2, Plus, Upload, X, XCircle, RotateCcw } from 'lucide-vue-next'
import { Permission } from '@bookorbit/types'
import { SUPPORTED_FORMATS_ACCEPT, useAddBookFile } from '@/features/book/composables/useAddBookFile'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const props = defineProps<{
bookId: number
}>()
const emit = defineEmits<{
close: []
uploaded: []
}>()
const { hasPermission } = usePermissions()
const isDragging = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const renameAfter = ref(false)
const { files, pendingCount, isUploading, doneCount, errorCount, addFiles, removeFile, retryFile, reset, startUpload } = useAddBookFile()
const canUpload = computed(() => pendingCount.value > 0 && !isUploading.value)
const hasFiles = computed(() => files.value.length > 0)
const allDone = computed(() => hasFiles.value && files.value.every((f) => f.status === 'done' || f.status === 'error'))
const allSuccess = computed(() => allDone.value && errorCount.value === 0)
const headerTitle = computed(() => {
if (isUploading.value) return 'Uploading...'
if (allDone.value) return 'Upload complete'
return 'Add File'
})
const fileSummary = computed(() => {
if (!hasFiles.value) return null
const totalBytes = files.value.reduce((sum, f) => sum + f.file.size, 0)
const byFormat: Record<string, number> = {}
for (const f of files.value) {
const ext = f.file.name.split('.').pop()?.toUpperCase() ?? '?'
byFormat[ext] = (byFormat[ext] ?? 0) + 1
}
const formatParts = Object.entries(byFormat).map(([ext, count]) => `${count} ${ext}`)
return { total: files.value.length, totalBytes, formatParts }
})
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
function handleClose() {
if (doneCount.value > 0) emit('uploaded')
emit('close')
}
function onDragOver(e: DragEvent) {
e.preventDefault()
isDragging.value = true
}
function onDragLeave() {
isDragging.value = false
}
function onDrop(e: DragEvent) {
e.preventDefault()
isDragging.value = false
const dropped = Array.from(e.dataTransfer?.files ?? [])
if (dropped.length > 0) addFiles(dropped)
}
function openFilePicker() {
fileInputRef.value?.click()
}
function onFileInputChange(e: Event) {
const input = e.target as HTMLInputElement
const picked = Array.from(input.files ?? [])
if (picked.length > 0) addFiles(picked)
input.value = ''
}
async function handleUpload() {
if (pendingCount.value === 0 || isUploading.value) return
await startUpload(props.bookId, { renameAfter: renameAfter.value })
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function fileRowClass(status: string): string {
if (status === 'done') return 'bg-emerald-500/5 border-emerald-500/20'
if (status === 'error') return 'bg-destructive/5 border-destructive/20'
if (status === 'uploading') return 'bg-primary/5 border-primary/20'
return 'bg-muted/40 border-border/40'
}
function getFormat(filename: string): string {
return filename.split('.').pop()?.toUpperCase() ?? '?'
}
function formatPillClass(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
const map: Record<string, string> = {
epub: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
kepub: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
pdf: 'bg-red-500/15 text-red-600 dark:text-red-400',
mobi: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
azw3: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
cbz: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
cbr: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
cb7: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
fb2: 'bg-teal-500/15 text-teal-600 dark:text-teal-400',
}
return map[ext] ?? 'bg-muted text-muted-foreground/85'
}
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center p-2 sm:p-4">
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="handleClose" />
<div
class="relative flex flex-col w-full max-w-2xl bg-background rounded-lg shadow-2xl border border-border overflow-hidden"
style="max-height: min(92vh, 720px)"
>
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-foreground">{{ headerTitle }}</span>
<span
v-if="hasFiles"
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/15 text-primary text-xs font-semibold tabular-nums"
>
{{ files.length }}
</span>
</div>
<button
class="flex items-center justify-center w-7 h-7 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="handleClose"
>
<X :size="15" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-5 flex flex-col gap-4">
<!-- Shared file input -->
<input ref="fileInputRef" type="file" multiple :accept="SUPPORTED_FORMATS_ACCEPT" class="sr-only" @change="onFileInputChange" />
<!-- Full dropzone (no files queued yet) -->
<div
v-if="!hasFiles"
class="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer"
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-border/80 hover:bg-muted/30'"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@click="openFilePicker"
>
<div class="flex items-center justify-center w-11 h-11 rounded-lg bg-primary/10">
<FileUp :size="20" class="text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Drop files here or click to browse</p>
<p class="text-xs text-muted-foreground mt-0.5">epub, pdf, mobi, cbz, m4b and more - up to 500 MB each</p>
</div>
</div>
<!-- Compact add-more strip (files queued) -->
<div
v-else
class="flex items-center justify-center gap-2 rounded-lg border-2 border-dashed py-2.5 px-4 transition-colors cursor-pointer text-xs font-medium"
:class="
isDragging
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-muted-foreground hover:border-primary/40 hover:text-foreground hover:bg-muted/30'
"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@click="openFilePicker"
>
<Plus :size="13" />
Add more files
</div>
<!-- File list -->
<div v-if="hasFiles" class="flex flex-col gap-2">
<!-- Summary line -->
<div v-if="fileSummary" class="flex items-center gap-1.5 flex-wrap text-xs text-muted-foreground">
<span class="font-medium text-foreground tabular-nums">{{ fileSummary.total }} file{{ fileSummary.total === 1 ? '' : 's' }}</span>
<span>·</span>
<span>{{ formatBytes(fileSummary.totalBytes) }}</span>
<span>·</span>
<span>{{ fileSummary.formatParts.join(', ') }}</span>
<div class="flex-1" />
<button v-if="!isUploading" class="hover:text-foreground transition-colors" @click="reset">Clear all</button>
</div>
<TransitionGroup name="file-row" tag="div" class="relative flex flex-col gap-1">
<div
v-for="item in files"
:key="item.id"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors"
:class="fileRowClass(item.status)"
>
<!-- Status icon -->
<div class="shrink-0">
<Loader2 v-if="item.status === 'uploading'" :size="16" class="text-primary animate-spin" />
<CheckCircle2 v-else-if="item.status === 'done'" :size="16" class="text-emerald-500" />
<XCircle v-else-if="item.status === 'error'" :size="16" class="text-destructive" />
<div v-else class="w-4 h-4 rounded-full border-2 border-border" />
</div>
<!-- File info -->
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-foreground truncate">{{ item.file.name }}</p>
<div class="flex items-center gap-1.5 mt-0.5 flex-wrap">
<span class="text-[11px] text-muted-foreground">{{ formatBytes(item.file.size) }}</span>
<span class="text-[10px] font-semibold px-1.5 py-px rounded uppercase tracking-wide" :class="formatPillClass(item.file.name)">
{{ getFormat(item.file.name) }}
</span>
<span v-if="item.status === 'error'" class="text-[11px] text-destructive truncate">{{ item.error }}</span>
<span v-else-if="item.status === 'uploading'" class="text-[11px] text-primary tabular-nums">{{ item.progress }}%</span>
<span v-else-if="item.status === 'done'" class="text-[11px] text-emerald-600 dark:text-emerald-400">Done</span>
</div>
<!-- Progress bar -->
<div v-if="item.status === 'uploading'" class="mt-1.5 h-1 rounded-full bg-primary/20 overflow-hidden">
<div class="h-full bg-primary rounded-full transition-all duration-150" :style="{ width: `${item.progress}%` }" />
</div>
</div>
<!-- Actions -->
<div class="shrink-0 flex items-center gap-0.5">
<button
v-if="item.status === 'error'"
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/85 hover:text-primary hover:bg-primary/10 transition-colors"
title="Retry"
@click="retryFile(item.id)"
>
<RotateCcw :size="11" />
</button>
<button
v-if="item.status !== 'uploading'"
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors"
@click="removeFile(item.id)"
>
<X :size="12" />
</button>
</div>
</div>
</TransitionGroup>
</div>
</div>
<!-- Footer: all succeeded -->
<div v-if="allSuccess" class="shrink-0 px-5 py-4 border-t border-border flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<CheckCircle2 class="size-4 text-emerald-500 shrink-0" />
<span class="text-sm font-medium text-foreground">{{ doneCount }} file{{ doneCount === 1 ? '' : 's' }} added</span>
</div>
<button
class="px-3 py-1.5 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="handleClose"
>
Close
</button>
</div>
<!-- Footer: normal / partial error state -->
<div v-else class="shrink-0 px-5 py-4 border-t border-border flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span v-if="allDone && errorCount > 0" class="text-xs text-muted-foreground">{{ doneCount }} uploaded, {{ errorCount }} failed</span>
<span v-else-if="isUploading" class="text-xs text-muted-foreground tabular-nums">{{ doneCount }} of {{ files.length }} uploading...</span>
<label v-else-if="hasPermission(Permission.LibraryEditMetadata)" class="flex items-center gap-2 cursor-pointer select-none">
<input
v-model="renameAfter"
type="checkbox"
class="w-3.5 h-3.5 rounded border-border accent-primary cursor-pointer"
:disabled="isUploading"
/>
<span class="text-xs text-muted-foreground">Rename all book files after upload</span>
</label>
<span v-else class="text-xs text-muted-foreground" />
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="handleClose"
>
{{ allDone ? 'Close' : 'Cancel' }}
</button>
<button
class="flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="!canUpload"
@click="handleUpload"
>
<Upload :size="13" />
Upload{{ pendingCount > 0 ? ` (${pendingCount})` : '' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.file-row-enter-active {
transition: all 0.2s ease;
}
.file-row-leave-active {
transition: all 0.15s ease;
position: absolute;
width: 100%;
}
.file-row-enter-from {
opacity: 0;
transform: translateY(-6px);
}
.file-row-leave-to {
opacity: 0;
transform: translateX(6px);
}
.file-row-move {
transition: transform 0.2s ease;
}
</style>
@@ -2,17 +2,19 @@
import { ref, watch, computed } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { BookOpen, Download, Eye, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
import { BookOpen, Download, Eye, FilePlus, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@bookorbit/types'
import { READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { Permission, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { api } from '@/lib/api'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import AddBookFileModal from './AddBookFileModal.vue'
const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{ refetch: [] }>()
const router = useRouter()
const { downloadFile: downloadBookFile } = useBookDownload()
@@ -136,6 +138,17 @@ const renaming = ref(false)
const deleteFileTarget = ref<BookDetailFile | null>(null)
const deletingFile = ref(false)
const addFileModalOpen = ref(false)
function openAddFileModal() {
addFileModalOpen.value = true
}
function onFilesAdded() {
addFileModalOpen.value = false
emit('refetch')
}
function openRenameModal(file: BookDetailFile) {
renameFileTarget.value = file
renameInput.value = file.filename ?? ''
@@ -151,8 +164,8 @@ async function submitRename() {
body: JSON.stringify({ filename: renameInput.value.trim() }),
})
if (!res.ok) throw new Error('Failed to rename file')
// We can emit an event or just reload since we don't have a direct refetch prop.
window.location.reload()
renameFileTarget.value = null
emit('refetch')
} catch (err) {
alert(err instanceof Error ? err.message : String(err))
} finally {
@@ -173,7 +186,7 @@ async function confirmDelete() {
method: 'DELETE',
})
if (!res.ok) throw new Error('Failed to delete file')
window.location.reload()
emit('refetch')
} catch (err) {
alert(err instanceof Error ? err.message : String(err))
} finally {
@@ -217,10 +230,19 @@ async function toggleWriteLog() {
class="sticky top-0 z-20 -mx-4 border-b border-border/70 bg-card/95 px-4 pb-3 pt-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:static sm:mx-0 sm:border-b-0 sm:bg-transparent sm:px-0 sm:py-1 sm:backdrop-blur-none"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p v-if="book.lastWrittenAt" class="text-sm md:text-xs font-medium text-muted-foreground/90 truncate">
Last synced: {{ formatRelative(book.lastWrittenAt) }}
</p>
<span v-else class="hidden sm:inline" />
<div class="flex items-center gap-2">
<button
v-if="hasPermission(Permission.LibraryUpload)"
class="flex items-center gap-1.5 h-8 md:h-6 px-2.5 md:px-2 rounded-md text-sm md:text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap"
@click="openAddFileModal"
>
<FilePlus class="size-4 md:size-3" />
Add File
</button>
<p v-if="book.lastWrittenAt" class="text-sm md:text-xs font-medium text-muted-foreground/90 truncate">
Last synced: {{ formatRelative(book.lastWrittenAt) }}
</p>
</div>
<div class="flex items-center gap-2 overflow-x-auto scrollbar-none pb-1 -mb-1 sm:overflow-visible sm:pb-0 sm:mb-0">
<div class="flex items-center gap-1.5 whitespace-nowrap">
<ArrowUpDown class="size-4 md:size-3 text-muted-foreground" />
@@ -516,4 +538,6 @@ async function toggleWriteLog() {
</div>
</div>
</div>
<AddBookFileModal v-if="addFileModalOpen" :book-id="book.id" @close="addFileModalOpen = false" @uploaded="onFilesAdded" />
</template>
@@ -0,0 +1,404 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/api', () => ({
getAccessToken: vi.fn<() => string>().mockReturnValue('test-token'),
}))
interface MockXhrInstance {
open: ReturnType<typeof vi.fn>
setRequestHeader: ReturnType<typeof vi.fn>
send: ReturnType<typeof vi.fn>
upload: { onprogress: ((e: ProgressEvent) => void) | null }
onload: (() => void) | null
onerror: (() => void) | null
status: number
responseText: string
}
const mockXhrInstances: MockXhrInstance[] = []
const defaultResponseText = JSON.stringify({
id: 55,
format: 'epub',
role: 'content',
sizeBytes: 1024,
absolutePath: '/library/Book/file.epub',
createdAt: '2025-01-01T00:00:00.000Z',
filename: 'file.epub',
durationSeconds: null,
bookStatus: 'present',
})
class MockXMLHttpRequest implements MockXhrInstance {
open = vi.fn<(method: string, url: string) => void>()
setRequestHeader = vi.fn<(name: string, value: string) => void>()
send = vi.fn<() => void>()
upload: { onprogress: ((e: ProgressEvent) => void) | null } = { onprogress: null }
onload: (() => void) | null = null
onerror: (() => void) | null = null
status = 201
responseText = defaultResponseText
constructor() {
mockXhrInstances.push(this)
}
}
vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest)
import { useAddBookFile, SUPPORTED_FORMATS, MAX_FILE_BYTES } from '../useAddBookFile'
function makeFile(name: string, sizeBytes = 1024, lastModified = 1000): File {
const blob = new Blob(['x'])
const file = new File([blob], name, { lastModified })
Object.defineProperty(file, 'size', { value: sizeBytes, configurable: true })
return file
}
describe('useAddBookFile', () => {
let mockFetch: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockXhrInstances.length = 0
mockFetch = vi.fn<() => Promise<{ ok: boolean }>>().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', mockFetch)
})
describe('addFiles', () => {
it('adds a valid file to the queue as pending', () => {
const { files, addFiles } = useAddBookFile()
addFiles([makeFile('book.epub')])
expect(files.value).toHaveLength(1)
expect(files.value[0].status).toBe('pending')
expect(files.value[0].file.name).toBe('book.epub')
})
it('marks unsupported format as error without attempting upload', () => {
const { files, addFiles } = useAddBookFile()
addFiles([makeFile('book.xyz')])
expect(files.value[0].status).toBe('error')
expect(files.value[0].error).toContain('.xyz')
})
it('sets validationError=true for unsupported format', () => {
const { files, addFiles } = useAddBookFile()
addFiles([makeFile('book.xyz')])
expect(files.value[0].validationError).toBe(true)
})
it('sets validationError=false for valid files', () => {
const { files, addFiles } = useAddBookFile()
addFiles([makeFile('book.epub')])
expect(files.value[0].validationError).toBe(false)
})
it('marks file exceeding 500 MB as error', () => {
const { files, addFiles } = useAddBookFile()
const oversized = makeFile('big.epub', MAX_FILE_BYTES + 1)
Object.defineProperty(oversized, 'size', { value: MAX_FILE_BYTES + 1 })
addFiles([oversized])
expect(files.value[0].status).toBe('error')
expect(files.value[0].error).toContain('500 MB')
})
it('accepts all supported formats', () => {
const { files, addFiles } = useAddBookFile()
const validFiles = SUPPORTED_FORMATS.map((fmt) => makeFile(`book.${fmt}`))
addFiles(validFiles)
expect(files.value.every((f) => f.status === 'pending')).toBe(true)
})
it('deduplicates files with same name and size', () => {
const { files, addFiles } = useAddBookFile()
const file = makeFile('book.epub', 1024)
addFiles([file])
addFiles([file])
expect(files.value).toHaveLength(1)
})
it('allows two files with same name but different size', () => {
const { files, addFiles } = useAddBookFile()
addFiles([makeFile('book.epub', 1024), makeFile('book.epub', 2048)])
expect(files.value).toHaveLength(2)
})
})
describe('removeFile', () => {
it('removes file by id', () => {
const { files, addFiles, removeFile } = useAddBookFile()
addFiles([makeFile('book.epub')])
const id = files.value[0].id
removeFile(id)
expect(files.value).toHaveLength(0)
})
it('is a no-op for unknown id', () => {
const { files, addFiles, removeFile } = useAddBookFile()
addFiles([makeFile('book.epub')])
removeFile('non-existent')
expect(files.value).toHaveLength(1)
})
})
describe('retryFile', () => {
it('resets a server/network error file back to pending', async () => {
const { files, addFiles, retryFile, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
xhr.onerror?.()
await uploadPromise
expect(files.value[0].status).toBe('error')
retryFile(files.value[0].id)
expect(files.value[0].status).toBe('pending')
expect(files.value[0].error).toBeUndefined()
expect(files.value[0].progress).toBe(0)
})
it('does not retry files that failed client-side validation', () => {
const { files, addFiles, retryFile } = useAddBookFile()
addFiles([makeFile('book.xyz')]) // validation error
const id = files.value[0].id
retryFile(id)
expect(files.value[0].status).toBe('error') // unchanged
expect(files.value[0].error).toBeDefined() // error message preserved
})
it('is a no-op for files not in error state', () => {
const { files, addFiles, retryFile } = useAddBookFile()
addFiles([makeFile('book.epub')])
retryFile(files.value[0].id)
expect(files.value[0].status).toBe('pending')
})
})
describe('reset', () => {
it('clears all files from the queue', () => {
const { files, addFiles, reset } = useAddBookFile()
addFiles([makeFile('a.epub'), makeFile('b.pdf')])
reset()
expect(files.value).toHaveLength(0)
})
})
describe('computed counts', () => {
it('pendingCount reflects pending files', () => {
const { pendingCount, addFiles } = useAddBookFile()
addFiles([makeFile('a.epub'), makeFile('b.pdf')])
expect(pendingCount.value).toBe(2)
})
it('errorCount reflects error files', () => {
const { errorCount, addFiles } = useAddBookFile()
addFiles([makeFile('bad.xyz'), makeFile('b.pdf')])
expect(errorCount.value).toBe(1)
})
it('isUploading is false when no uploads in flight', () => {
const { isUploading } = useAddBookFile()
expect(isUploading.value).toBe(false)
})
})
describe('startUpload', () => {
it('sends XHR POST to /api/v1/books/:bookId/files', async () => {
const { addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42)
const xhr = mockXhrInstances[0]
xhr.onload?.()
await uploadPromise
expect(xhr.open).toHaveBeenCalledWith('POST', '/api/v1/books/42/files')
})
it('sets Authorization header with token', async () => {
const { addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42)
const xhr = mockXhrInstances[0]
xhr.onload?.()
await uploadPromise
expect(xhr.setRequestHeader).toHaveBeenCalledWith('Authorization', 'Bearer test-token')
})
it('marks file as done on 201 and stores result', async () => {
const { files, addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
xhr.status = 201
xhr.onload?.()
await uploadPromise
expect(files.value[0].status).toBe('done')
expect(files.value[0].result?.bookStatus).toBe('present')
})
it('marks file as error on non-201 response', async () => {
const { files, addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
xhr.status = 409
xhr.responseText = JSON.stringify({ message: 'File already attached' })
xhr.onload?.()
await uploadPromise
expect(files.value[0].status).toBe('error')
expect(files.value[0].error).toBe('File already attached')
})
it('marks file as error on network failure', async () => {
const { files, addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
xhr.onerror?.()
await uploadPromise
expect(files.value[0].status).toBe('error')
expect(files.value[0].error).toBe('Network error')
})
it('skips already-done or error files on startUpload', async () => {
const { files, addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('bad.xyz'), makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
xhr.onload?.()
await uploadPromise
expect(files.value[0].status).toBe('error') // was already error, not re-uploaded
expect(mockXhrInstances).toHaveLength(1) // only 1 XHR created
})
it('updates progress during upload', async () => {
const { files, addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent)
expect(files.value[0].progress).toBe(50)
expect(files.value[0].status).toBe('uploading')
xhr.onload?.()
await uploadPromise
})
it('is a no-op when there are no pending files', async () => {
const { startUpload } = useAddBookFile()
await startUpload(7)
expect(mockXhrInstances).toHaveLength(0)
})
it('falls back gracefully when response body is not valid JSON', async () => {
const { files, addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
xhr.status = 201
xhr.responseText = 'not-json'
xhr.onload?.()
await uploadPromise
expect(files.value[0].status).toBe('done')
expect(files.value[0].result).toBeUndefined()
})
it('shows fallback error message when error response is not JSON', async () => {
const { files, addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(7)
const xhr = mockXhrInstances[0]
xhr.status = 500
xhr.responseText = 'Internal Server Error'
xhr.onload?.()
await uploadPromise
expect(files.value[0].status).toBe('error')
expect(files.value[0].error).toBe('Upload failed (500)')
})
})
describe('rename after upload', () => {
it('calls POST /api/v1/books/:id/rename-files after all uploads complete when renameAfter=true and some succeeded', async () => {
const { addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42, { renameAfter: true })
const xhr = mockXhrInstances[0]
xhr.status = 201
xhr.onload?.()
await uploadPromise
expect(mockFetch).toHaveBeenCalledWith('/api/v1/books/42/rename-files', expect.objectContaining({ method: 'POST' }))
})
it('does not call rename endpoint when renameAfter=false', async () => {
const { addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42, { renameAfter: false })
const xhr = mockXhrInstances[0]
xhr.onload?.()
await uploadPromise
expect(mockFetch).not.toHaveBeenCalled()
})
it('does not call rename endpoint by default (renameAfter omitted)', async () => {
const { addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42)
const xhr = mockXhrInstances[0]
xhr.onload?.()
await uploadPromise
expect(mockFetch).not.toHaveBeenCalled()
})
it('does not call rename endpoint when renameAfter=true but no uploads succeeded', async () => {
const { addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42, { renameAfter: true })
const xhr = mockXhrInstances[0]
xhr.status = 409
xhr.responseText = JSON.stringify({ message: 'Already exists' })
xhr.onload?.()
await uploadPromise
expect(mockFetch).not.toHaveBeenCalled()
})
it('does not fail startUpload if rename endpoint throws', async () => {
mockFetch.mockRejectedValue(new Error('Network error'))
const { addFiles, startUpload } = useAddBookFile()
addFiles([makeFile('book.epub')])
const uploadPromise = startUpload(42, { renameAfter: true })
const xhr = mockXhrInstances[0]
xhr.status = 201
xhr.onload?.()
await expect(uploadPromise).resolves.toBeUndefined()
})
})
})
@@ -0,0 +1,166 @@
import { computed, ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import type { AddBookFileResult } from '@bookorbit/types'
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
export const MAX_FILE_BYTES = 500 * 1024 * 1024
export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error'
export interface BookFileUploadItem {
id: string
file: File
status: FileUploadStatus
progress: number
error?: string
result?: AddBookFileResult
validationError?: boolean
}
const UPLOAD_CONCURRENCY = 3
function validateFile(file: File): string | null {
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!SUPPORTED_FORMATS.includes(ext)) {
return `Unsupported type .${ext}. Allowed: ${SUPPORTED_FORMATS.join(', ')}`
}
if (file.size > MAX_FILE_BYTES) {
return `File exceeds the 500 MB limit`
}
return null
}
function uploadSingle(item: BookFileUploadItem, bookId: number): Promise<void> {
return new Promise((resolve) => {
const formData = new FormData()
formData.append('file', item.file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `/api/v1/books/${bookId}/files`)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
item.progress = Math.round((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status === 201) {
item.status = 'done'
item.progress = 100
try {
item.result = JSON.parse(xhr.responseText) as AddBookFileResult
} catch {
// result is optional — upload still counts as done
}
} else {
item.status = 'error'
try {
const body = JSON.parse(xhr.responseText)
item.error = body.message ?? 'Upload failed'
} catch {
item.error = `Upload failed (${xhr.status})`
}
}
resolve()
}
xhr.onerror = () => {
item.status = 'error'
item.error = 'Network error'
resolve()
}
item.status = 'uploading'
xhr.send(formData)
})
}
async function triggerRename(bookId: number): Promise<void> {
const token = getAccessToken()
await fetch(`/api/v1/books/${bookId}/rename-files`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
}).catch(() => {
// Best-effort — rename failure does not invalidate completed uploads
})
}
export function useAddBookFile() {
const files = ref<BookFileUploadItem[]>([])
const pendingCount = computed(() => files.value.filter((f) => f.status === 'pending').length)
const isUploading = computed(() => files.value.some((f) => f.status === 'uploading'))
const doneCount = computed(() => files.value.filter((f) => f.status === 'done').length)
const errorCount = computed(() => files.value.filter((f) => f.status === 'error').length)
function addFiles(incoming: File[]) {
for (const file of incoming) {
if (files.value.some((f) => f.file.name === file.name && f.file.size === file.size)) continue
const error = validateFile(file)
files.value.push({
id: `${file.name}-${file.size}-${file.lastModified}`,
file,
status: error ? 'error' : 'pending',
progress: 0,
error: error ?? undefined,
validationError: error !== null,
})
}
}
function removeFile(id: string) {
files.value = files.value.filter((f) => f.id !== id)
}
function retryFile(id: string) {
const item = files.value.find((f) => f.id === id)
if (!item || item.status !== 'error' || item.validationError) return
item.status = 'pending'
item.error = undefined
item.progress = 0
}
function reset() {
files.value = []
}
async function startUpload(bookId: number, options: { renameAfter?: boolean } = {}) {
const pending = files.value.filter((f) => f.status === 'pending')
if (pending.length === 0) return
const renameAfter = options.renameAfter ?? false
let index = 0
async function runNext(): Promise<void> {
const item = pending[index++]
if (!item) return
await uploadSingle(item, bookId)
await runNext()
}
const workers = Array.from({ length: Math.min(UPLOAD_CONCURRENCY, pending.length) }, () => runNext())
await Promise.all(workers)
if (renameAfter && doneCount.value > 0) {
await triggerRename(bookId)
}
}
return {
files,
pendingCount,
isUploading,
doneCount,
errorCount,
addFiles,
removeFile,
retryFile,
reset,
startUpload,
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
@locks-changed="onLocksChanged"
@cover-changed="onCoverChanged"
/>
<FilesTab v-else-if="tab === 'files'" :book="detail" />
<FilesTab v-else-if="tab === 'files'" :book="detail" @refetch="fetch(detail.id)" />
<ReadingLogTab v-else-if="tab === 'reading-log'" :book="detail" />
<HighlightsTab v-else-if="tab === 'highlights'" :book="detail" />
</div>
+4
View File
@@ -12,6 +12,10 @@
"lib": [],
"types": ["node", "jsdom"],
// noUncheckedIndexedAccess produces false positives in tests where the
// test author controls the data and index bounds are known.
"noUncheckedIndexedAccess": false,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
+12
View File
@@ -4,3 +4,15 @@ export type UploadResult = {
format: string;
sizeBytes: number;
};
export type AddBookFileResult = {
id: number;
format: string | null;
role: string;
sizeBytes: number | null;
absolutePath: string;
createdAt: string;
filename: string;
durationSeconds: number | null;
bookStatus: string;
};
@@ -0,0 +1,113 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
import { BookFileUploadController } from './book-file-upload.controller';
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
function makeUser(overrides: Record<string, unknown> = {}) {
return { id: 1, isSuperuser: false, permissions: [], ...overrides } as any;
}
describe('BookFileUploadController', () => {
const uploadService = {
addFileToBook: vi.fn(),
renameBookFiles: vi.fn(),
};
const controller = new BookFileUploadController(uploadService as any);
beforeEach(() => {
vi.resetAllMocks();
});
describe('addFileToBook', () => {
it('requires LibraryUpload permission', () => {
const permission = Reflect.getMetadata(PERMISSION_KEY, BookFileUploadController.prototype.addFileToBook);
expect(permission).toBe(Permission.LibraryUpload);
});
it('throws BadRequestException when no multipart file is present', async () => {
const req = { file: vi.fn().mockResolvedValue(undefined) };
await expect(controller.addFileToBook(1, makeUser(), req as any)).rejects.toBeInstanceOf(BadRequestException);
expect(uploadService.addFileToBook).not.toHaveBeenCalled();
});
it('passes 500 MB file size limit to multipart parser', async () => {
const stream = {};
const req = { file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: stream }) };
uploadService.addFileToBook.mockResolvedValue({ id: 1 });
await controller.addFileToBook(5, makeUser(), req as any);
expect(req.file).toHaveBeenCalledWith({ limits: { fileSize: MAX_UPLOAD_BYTES } });
});
it('delegates to uploadService.addFileToBook with correct arguments', async () => {
const stream = {};
const user = makeUser({ id: 3 });
const req = { file: vi.fn().mockResolvedValue({ filename: 'dune.epub', file: stream }) };
const serviceResult = {
id: 55,
format: 'epub',
role: 'content',
sizeBytes: 1024,
absolutePath: '/library/Book/dune.epub',
createdAt: '2025-01-01T00:00:00.000Z',
filename: 'dune.epub',
durationSeconds: null,
bookStatus: 'present',
};
uploadService.addFileToBook.mockResolvedValue(serviceResult);
const result = await controller.addFileToBook(7, user, req as any);
expect(uploadService.addFileToBook).toHaveBeenCalledWith(7, 'dune.epub', stream, user);
expect(result).toEqual(serviceResult);
});
it('propagates NotFoundException from the service', async () => {
const req = { file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: {} }) };
uploadService.addFileToBook.mockRejectedValue(new NotFoundException('Book 999 not found'));
await expect(controller.addFileToBook(999, makeUser(), req as any)).rejects.toBeInstanceOf(NotFoundException);
});
it('propagates ForbiddenException from the service', async () => {
const req = { file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: {} }) };
uploadService.addFileToBook.mockRejectedValue(new ForbiddenException('No access'));
await expect(controller.addFileToBook(1, makeUser(), req as any)).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('renameBookFiles', () => {
it('requires LibraryEditMetadata permission', () => {
const permission = Reflect.getMetadata(PERMISSION_KEY, BookFileUploadController.prototype.renameBookFiles);
expect(permission).toBe(Permission.LibraryEditMetadata);
});
it('delegates to uploadService.renameBookFiles', async () => {
const user = makeUser({ id: 5 });
uploadService.renameBookFiles.mockResolvedValue(undefined);
const result = await controller.renameBookFiles(7, user);
expect(uploadService.renameBookFiles).toHaveBeenCalledWith(7, user);
expect(result).toBeUndefined();
});
it('propagates NotFoundException from the service', async () => {
uploadService.renameBookFiles.mockRejectedValue(new NotFoundException('Book not found'));
await expect(controller.renameBookFiles(999, makeUser())).rejects.toBeInstanceOf(NotFoundException);
});
it('propagates ForbiddenException from the service', async () => {
uploadService.renameBookFiles.mockRejectedValue(new ForbiddenException('No access'));
await expect(controller.renameBookFiles(1, makeUser())).rejects.toBeInstanceOf(ForbiddenException);
});
});
});
@@ -0,0 +1,29 @@
import { BadRequestException, Controller, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { MultipartRequest } from '../../common/types/multipart-request';
import type { RequestUser } from '../../common/types/request-user';
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
import { UploadService } from './upload.service';
@Controller('books')
export class BookFileUploadController {
constructor(private readonly uploadService: UploadService) {}
@Post(':id/files')
@HttpCode(HttpStatus.CREATED)
@RequirePermission(Permission.LibraryUpload)
async addFileToBook(@Param('id', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser, @Req() req: MultipartRequest) {
const data = await req.file({ limits: { fileSize: MAX_UPLOAD_BYTES } });
if (!data) throw new BadRequestException('No file provided');
return this.uploadService.addFileToBook(bookId, data.filename, data.file, user);
}
@Post(':id/rename-files')
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.LibraryEditMetadata)
async renameBookFiles(@Param('id', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser): Promise<void> {
await this.uploadService.renameBookFiles(bookId, user);
}
}
@@ -3,23 +3,33 @@ import 'reflect-metadata';
vi.mock('../app-settings/app-settings.module', () => ({ AppSettingsModule: class AppSettingsModule {} }));
vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule {} }));
vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} }));
vi.mock('../file-write/file-write.module', () => ({ FileWriteModule: class FileWriteModule {} }));
vi.mock('../book-metadata-fetch/book-metadata-fetch.module', () => ({ BookMetadataFetchModule: class BookMetadataFetchModule {} }));
import { BookFileUploadController } from './book-file-upload.controller';
import { UploadController } from './upload.controller';
import { UploadModule } from './upload.module';
import { UploadProcessorService } from './upload-processor.service';
import { UploadService } from './upload.service';
import { UploadStorageService } from './upload-storage.service';
import { UploadValidatorService } from './upload-validator.service';
import { FileWriteModule } from '../file-write/file-write.module';
describe('UploadModule', () => {
it('registers expected controller/providers/exports', () => {
expect(Reflect.getMetadata('controllers', UploadModule)).toEqual([UploadController]);
expect(Reflect.getMetadata('controllers', UploadModule)).toEqual([UploadController, BookFileUploadController]);
expect(Reflect.getMetadata('providers', UploadModule)).toEqual([
UploadService,
UploadValidatorService,
UploadStorageService,
UploadProcessorService,
]);
expect(Reflect.getMetadata('exports', UploadModule)).toEqual([UploadValidatorService, UploadStorageService, UploadProcessorService]);
expect(Reflect.getMetadata('exports', UploadModule)).toEqual([
UploadValidatorService,
UploadStorageService,
UploadProcessorService,
UploadService,
]);
expect(Reflect.getMetadata('imports', UploadModule)).toContain(FileWriteModule);
});
});
+5 -3
View File
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { BookMetadataFetchModule } from '../book-metadata-fetch/book-metadata-fetch.module';
import { FileWriteModule } from '../file-write/file-write.module';
import { LibraryModule } from '../library/library.module';
import { MetadataModule } from '../metadata/metadata.module';
import { BookFileUploadController } from './book-file-upload.controller';
import { UploadController } from './upload.controller';
import { UploadProcessorService } from './upload-processor.service';
import { UploadService } from './upload.service';
@@ -11,9 +13,9 @@ import { UploadStorageService } from './upload-storage.service';
import { UploadValidatorService } from './upload-validator.service';
@Module({
imports: [AppSettingsModule, LibraryModule, MetadataModule, BookMetadataFetchModule],
controllers: [UploadController],
imports: [AppSettingsModule, LibraryModule, MetadataModule, BookMetadataFetchModule, FileWriteModule],
controllers: [UploadController, BookFileUploadController],
providers: [UploadService, UploadValidatorService, UploadStorageService, UploadProcessorService],
exports: [UploadValidatorService, UploadStorageService, UploadProcessorService],
exports: [UploadValidatorService, UploadStorageService, UploadProcessorService, UploadService],
})
export class UploadModule {}
@@ -1,27 +1,35 @@
vi.mock('fs/promises', async () => {
const actual = await vi.importActual('fs/promises');
return { ...actual, access: vi.fn() };
return { ...actual, access: vi.fn(), stat: vi.fn() };
});
vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() }));
vi.mock('../scanner/lib/walk', () => ({ clampIno: vi.fn() }));
vi.mock('../metadata/lib/epub', () => ({ extractEpubMetadata: vi.fn() }));
vi.mock('../metadata/lib/cbz-metadata', () => ({ extractCbzMetadata: vi.fn(), extractCbrMetadata: vi.fn(), extractCb7Metadata: vi.fn() }));
vi.mock('../metadata/lib/mobi-parser', () => ({ parseMobiFile: vi.fn() }));
vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: vi.fn() }));
import { access as fsAccess } from 'fs/promises';
import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException } from '@nestjs/common';
import { access as fsAccess, stat } from 'fs/promises';
import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractCbzMetadata } from '../metadata/lib/cbz-metadata';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile } from '../metadata/lib/pdf-parser';
import { computeFileHash } from '../scanner/lib/hash';
import { clampIno } from '../scanner/lib/walk';
import { UploadService } from './upload.service';
const mockFsAccess = fsAccess as MockedFunction<typeof fsAccess>;
const mockStat = stat as MockedFunction<typeof stat>;
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>;
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
const mockComputeFileHash = computeFileHash as MockedFunction<typeof computeFileHash>;
const mockClampIno = clampIno as MockedFunction<typeof clampIno>;
function selectChain(rows: unknown[]) {
const whereResult: PromiseLike<unknown[]> & { limit: vi.Mock } = {
@@ -37,8 +45,15 @@ function selectChain(rows: unknown[]) {
}
describe('UploadService', () => {
const insertReturning = vi.fn();
const insertValues = vi.fn();
const updateSet = vi.fn();
const updateWhere = vi.fn();
const db = {
select: vi.fn(),
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: updateSet })),
};
const appSettings = { getUploadPattern: vi.fn(), getUploadPatternBookPerFolder: vi.fn(), isCrossPlatformPathSanitizationEnabled: vi.fn() };
@@ -59,6 +74,8 @@ describe('UploadService', () => {
const user = { id: 7, isSuperuser: false, permissions: [] } as any;
const moduleRef = { get: vi.fn().mockReturnValue(null) };
let service: UploadService;
beforeEach(() => {
@@ -67,7 +84,15 @@ describe('UploadService', () => {
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
service = new UploadService(db as any, appSettings as any, libraryService as any, validator as any, storage as any, processor as any);
service = new UploadService(
db as any,
appSettings as any,
libraryService as any,
validator as any,
storage as any,
processor as any,
moduleRef as any,
);
validator.sanitizeFilename.mockReturnValue('book.epub');
validator.validateFormat.mockReturnValue('epub');
@@ -81,6 +106,25 @@ describe('UploadService', () => {
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false);
mockFsAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockExtractEpubMetadata.mockResolvedValue(null);
// addFileToBook defaults
insertValues.mockReturnValue({ returning: insertReturning });
insertReturning.mockResolvedValue([
{
id: 55,
format: 'epub',
role: 'content',
sizeBytes: 456,
absolutePath: '/library/Book/book.epub',
createdAt: new Date('2025-01-01'),
durationSeconds: null,
},
]);
updateSet.mockReturnValue({ where: updateWhere });
updateWhere.mockResolvedValue(undefined);
mockComputeFileHash.mockResolvedValue('hash-abc');
mockClampIno.mockReturnValue(12345);
mockStat.mockResolvedValue({ ino: 12345n, mtime: new Date('2025-01-01') } as any);
});
it('uploads successfully and kicks off async metadata extraction', async () => {
@@ -650,4 +694,220 @@ describe('UploadService', () => {
expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.epub');
});
describe('addFileToBook', () => {
function makeBookRow(overrides: Record<string, unknown> = {}) {
return {
id: 10,
folderPath: '/library/Book Title',
libraryId: 1,
libraryFolderId: 2,
primaryFileId: 99,
status: 'present',
allowedFormats: [] as string[],
organizationMode: 'book_per_folder',
libraryFolderPath: '/library',
...overrides,
};
}
function selectJoinChain(rows: unknown[]) {
const limitMock = vi.fn().mockResolvedValue(rows);
const whereMock = vi.fn().mockReturnValue({ limit: limitMock });
const joinMock2 = vi.fn().mockReturnValue({ where: whereMock });
const joinMock1 = vi.fn().mockReturnValue({ innerJoin: joinMock2, where: whereMock });
return {
from: vi.fn().mockReturnValue({ innerJoin: joinMock1 }),
};
}
function noHashConflict() {
return selectChain([]);
}
it('adds a file successfully to an existing book with a primary file', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(storage.streamToTemp).toHaveBeenCalled();
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Book Title/book.epub');
expect(db.insert).toHaveBeenCalled();
expect(result.filename).toBe('book.epub');
expect(result.format).toBe('epub');
expect(result.bookStatus).toBe('present');
expect(result.role).toBe('content');
});
it('promotes new file to primary when book has no primary file', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: null })])).mockReturnValueOnce(noHashConflict());
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(db.update).toHaveBeenCalled();
expect(updateWhere).toHaveBeenCalled();
expect(result.role).toBe('primary');
});
it('updates book status from missing to present and promotes primary when null', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: null, status: 'missing' })])).mockReturnValueOnce(noHashConflict());
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(db.update).toHaveBeenCalled();
expect(result.bookStatus).toBe('present');
expect(result.role).toBe('primary');
});
it('updates only status when book is missing but already has a primary file', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: 42, status: 'missing' })])).mockReturnValueOnce(noHashConflict());
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(db.update).toHaveBeenCalled();
expect(result.bookStatus).toBe('present');
expect(result.role).toBe('content');
});
it('does not call db.update when book is present and already has a primary', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(db.update).not.toHaveBeenCalled();
});
it('throws NotFoundException when book does not exist', async () => {
db.select.mockReturnValueOnce(selectJoinChain([]));
await expect(service.addFileToBook(999, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(NotFoundException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws ForbiddenException when user has no library access', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access'));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ForbiddenException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws BadRequestException for book_per_file organization mode', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ organizationMode: 'book_per_file' })]));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws BadRequestException when format validation fails', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
validator.validateFormat.mockImplementation(() => {
throw new BadRequestException('Unsupported format');
});
await expect(service.addFileToBook(10, 'bad.xyz', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws BadRequestException for empty file and cleans up temp', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 0 });
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
});
it('throws ConflictException for duplicate content hash and cleans up temp', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(selectChain([{ id: 77 }]));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('throws ConflictException when destination file already exists and cleans up temp', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockFsAccess.mockResolvedValue(undefined);
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('cleans up only temp when DB insert fails after move (file remains for scanner)', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
insertReturning.mockRejectedValue(new Error('DB error'));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('DB error');
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.cleanup).not.toHaveBeenCalledWith('/library/Book Title/book.epub');
});
it('cleans up only temp when move fails (destination not yet written)', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
storage.moveToPath.mockRejectedValue(new Error('disk full'));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('disk full');
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
});
it('propagates access errors other than ENOENT from destinationExists check', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockFsAccess.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('permission denied');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('returns correct createdAt as ISO string', async () => {
const date = new Date('2025-06-01T12:00:00.000Z');
insertReturning.mockResolvedValue([
{
id: 55,
format: 'epub',
role: 'content',
sizeBytes: 456,
absolutePath: '/library/Book Title/book.epub',
createdAt: date,
durationSeconds: null,
},
]);
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(result.createdAt).toBe(date.toISOString());
});
});
describe('renameBookFiles', () => {
it('calls performRename with force=true', async () => {
const mockRenameService = { performRename: vi.fn().mockResolvedValue({ status: 'success', durationMs: 50 }) };
moduleRef.get.mockReturnValue(mockRenameService);
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
await expect(service.renameBookFiles(10, user)).resolves.toBeUndefined();
expect(mockRenameService.performRename).toHaveBeenCalledWith(10, user.id, true, false);
});
it('throws NotFoundException when book does not exist', async () => {
db.select.mockReturnValueOnce(selectChain([]));
await expect(service.renameBookFiles(999, user)).rejects.toBeInstanceOf(NotFoundException);
});
it('throws ForbiddenException when user has no library access', async () => {
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access'));
await expect(service.renameBookFiles(10, user)).rejects.toBeInstanceOf(ForbiddenException);
});
it('throws ServiceUnavailableException when rename service is unavailable', async () => {
moduleRef.get.mockReturnValue(null);
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
await expect(service.renameBookFiles(10, user)).rejects.toThrow(ServiceUnavailableException);
});
});
});
+188 -6
View File
@@ -1,26 +1,30 @@
import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { access as fsAccess } from 'fs/promises';
import { basename, dirname, extname, join } from 'path';
import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { access as fsAccess, stat } from 'fs/promises';
import { basename, dirname, extname, join, relative } from 'path';
import { Readable } from 'stream';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { libraries, libraryFolders } from '../../db/schema';
import { books, bookFiles, libraries, libraryFolders } from '../../db/schema';
import type { RequestUser } from '../../common/types/request-user';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { LibraryService } from '../library/library.service';
import { UploadValidatorService } from './upload-validator.service';
import { UploadStorageService } from './upload-storage.service';
import { UploadProcessorService } from './upload-processor.service';
import { FileRenameService } from '../file-write/file-rename.service';
import { resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types';
import type { UploadResult } from '@bookorbit/types';
import type { AddBookFileResult, UploadResult } from '@bookorbit/types';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
import { computeFileHash } from '../scanner/lib/hash';
import { clampIno } from '../scanner/lib/walk';
type Db = NodePgDatabase<typeof schema>;
@@ -35,8 +39,17 @@ export class UploadService {
private readonly validator: UploadValidatorService,
private readonly storage: UploadStorageService,
private readonly processor: UploadProcessorService,
private readonly moduleRef: ModuleRef,
) {}
private resolveFileRenameService(): FileRenameService | null {
try {
return this.moduleRef.get(FileRenameService, { strict: false });
} catch {
return null;
}
}
async upload(libraryId: number, folderId: number | undefined, rawFilename: string, fileStream: Readable, user: RequestUser): Promise<UploadResult> {
const event = 'upload.book';
const startedAt = Date.now();
@@ -89,6 +102,175 @@ export class UploadService {
}
}
async addFileToBook(bookId: number, rawFilename: string, fileStream: Readable, user: RequestUser): Promise<AddBookFileResult> {
const event = 'book.add_file';
const startedAt = Date.now();
this.logger.log(
`[${event}] [start] bookId=${bookId} userId=${user.id} rawFilename="${sanitizeLogValue(rawFilename)}" - add file to book started`,
);
const [bookRow] = await this.db
.select({
id: books.id,
folderPath: books.folderPath,
libraryId: books.libraryId,
libraryFolderId: books.libraryFolderId,
primaryFileId: books.primaryFileId,
status: books.status,
allowedFormats: libraries.allowedFormats,
organizationMode: libraries.organizationMode,
libraryFolderPath: libraryFolders.path,
})
.from(books)
.innerJoin(libraries, eq(books.libraryId, libraries.id))
.innerJoin(libraryFolders, eq(books.libraryFolderId, libraryFolders.id))
.where(eq(books.id, bookId))
.limit(1);
if (!bookRow) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(user.id, bookRow.libraryId, user.isSuperuser);
if (bookRow.organizationMode === 'book_per_file') {
throw new BadRequestException('Cannot add files to a book in a single-file library. Upload a new book instead.');
}
const filename = this.validator.sanitizeFilename(rawFilename);
const format = this.validator.validateFormat(filename, bookRow.allowedFormats);
const { tempPath, sizeBytes } = await this.storage.streamToTemp(fileStream);
if (sizeBytes === 0) {
await this.storage.cleanup(tempPath);
throw new BadRequestException('File must not be empty');
}
let destination: string | null = null;
let shouldCleanupDestination = false;
try {
const fileHash = await computeFileHash(tempPath);
const [existingWithHash] = await this.db
.select({ id: bookFiles.id })
.from(bookFiles)
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.fileHash, fileHash)))
.limit(1);
if (existingWithHash) {
throw new ConflictException('This file is already attached to this book');
}
destination = join(bookRow.folderPath, filename);
if (await this.destinationExists(destination)) {
throw new ConflictException(`A file named "${filename}" already exists in this book's folder`);
}
shouldCleanupDestination = true;
await this.storage.moveToPath(tempPath, destination);
// File is on disk. Do not delete it on any subsequent failure — the scanner
// will reconcile any orphan. This also prevents the catch block from deleting
// a file that a concurrent upload may have written to the same path.
shouldCleanupDestination = false;
const fileStat = await stat(destination, { bigint: true });
const safeIno = clampIno(fileStat.ino);
const relPath = relative(bookRow.libraryFolderPath, destination);
const [inserted] = await this.db
.insert(bookFiles)
.values({
bookId,
libraryFolderId: bookRow.libraryFolderId,
absolutePath: destination,
relPath,
ino: safeIno,
sizeBytes,
mtime: fileStat.mtime,
fileHash,
format,
role: 'content',
})
.returning({
id: bookFiles.id,
format: bookFiles.format,
role: bookFiles.role,
sizeBytes: bookFiles.sizeBytes,
absolutePath: bookFiles.absolutePath,
createdAt: bookFiles.createdAt,
durationSeconds: bookFiles.durationSeconds,
});
if (!inserted) throw new Error('Failed to insert book file record');
let finalStatus = bookRow.status;
const needsPrimaryPromotion = bookRow.primaryFileId === null;
const needsStatusUpdate = bookRow.status === 'missing';
if (needsPrimaryPromotion || needsStatusUpdate) {
await this.db
.update(books)
.set({
...(needsPrimaryPromotion ? { primaryFileId: inserted.id } : {}),
...(needsStatusUpdate ? { status: 'present' } : {}),
updatedAt: new Date(),
})
.where(eq(books.id, bookId));
if (needsStatusUpdate) finalStatus = 'present';
}
this.logger.log(
`[${event}] [end] bookId=${bookId} userId=${user.id} fileId=${inserted.id} format=${format} sizeBytes=${sizeBytes} durationMs=${Date.now() - startedAt} - add file to book completed`,
);
return {
id: inserted.id,
format: inserted.format,
role: needsPrimaryPromotion ? 'primary' : inserted.role,
sizeBytes: inserted.sizeBytes,
absolutePath: inserted.absolutePath,
createdAt: inserted.createdAt.toISOString(),
filename: basename(destination),
durationSeconds: inserted.durationSeconds,
bookStatus: finalStatus,
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${event}] [fail] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - add file to book failed`,
);
await Promise.allSettled([
this.storage.cleanup(tempPath),
shouldCleanupDestination && destination ? this.storage.cleanup(destination) : Promise.resolve(),
]);
throw err;
}
}
async renameBookFiles(bookId: number, user: RequestUser): Promise<void> {
const event = 'book.rename_files';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] bookId=${bookId} userId=${user.id} - rename book files started`);
const [bookRow] = await this.db.select({ id: books.id, libraryId: books.libraryId }).from(books).where(eq(books.id, bookId)).limit(1);
if (!bookRow) throw new NotFoundException(`Book ${bookId} not found`);
await this.libraryService.verifyUserAccess(user.id, bookRow.libraryId, user.isSuperuser);
const fileRenameService = this.resolveFileRenameService();
if (!fileRenameService) {
throw new ServiceUnavailableException('File rename service is not available');
}
await fileRenameService.performRename(bookId, user.id, true, false);
this.logger.log(`[${event}] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} - rename book files completed`);
}
private async resolveDestination(
library: { fileNamingPattern?: string | null; organizationMode?: string | null },
libraryFolderPath: string,