chore: improve download feature

This commit is contained in:
neon
2026-04-08 22:52:52 -06:00
parent 26d1e6ebc8
commit b0ed068fd6
13 changed files with 589 additions and 93 deletions
@@ -44,7 +44,7 @@ function formatFileSize(bytes: number | null | undefined): string {
function handleSingleDownload() {
if (isMultiTrackAudio.value) {
exportBooks([props.bookId], false)
exportBooks([props.bookId], false, 'audio')
} else if (primaryFile.value) {
downloadFile(primaryFile.value.id)
}
@@ -59,6 +59,10 @@ function handleExportAll() {
}
function handleExportPrimary() {
if (isMultiTrackAudio.value) {
exportBooks([props.bookId], false, 'audio')
return
}
exportBooks([props.bookId], false)
}
</script>
@@ -989,7 +989,7 @@ watch(
<span
v-for="(genre, index) in displayedGenres"
:key="`${genre}-${index}`"
class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80"
class="text-xs px-2.5 py-0.5 rounded-full border border-primary/40 text-primary/85"
>
{{ genre }}
</span>
@@ -1011,7 +1011,7 @@ watch(
v-for="(genre, index) in book.genres"
:key="`measure-${genre}-${index}`"
data-genre-pill="true"
class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80"
class="text-xs px-2.5 py-0.5 rounded-full border border-primary/40 text-primary/85"
>
{{ genre }}
</span>
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const toastMock = vi.hoisted(() => ({
loading: vi.fn<(message: string) => string>().mockReturnValue('toast-id'),
dismiss: vi.fn<(id: string) => void>(),
error: vi.fn<(message: string) => void>(),
}))
vi.mock('vue-sonner', () => ({
toast: toastMock,
}))
describe('useBookDownload', () => {
beforeEach(() => {
vi.resetModules()
toastMock.loading.mockClear()
toastMock.dismiss.mockClear()
toastMock.error.mockClear()
})
it('triggers browser-native single-file download endpoint', async () => {
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const appendSpy = vi.spyOn(document.body, 'append')
const removeSpy = vi.spyOn(HTMLElement.prototype, 'remove')
const { useBookDownload } = await import('../useBookDownload')
const { downloadFile } = useBookDownload()
await downloadFile(42)
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('/api/v1/books/files/42/download')
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(removeSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
appendSpy.mockRestore()
removeSpy.mockRestore()
})
it('uses primary export scope by default', async () => {
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const appendSpy = vi.spyOn(document.body, 'append')
const { useBookDownload } = await import('../useBookDownload')
const { exportBooks } = useBookDownload()
await exportBooks([1, 2], false)
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('/api/v1/books/export/download?')
expect(anchor.href).toContain('bookIds=1%2C2')
expect(anchor.href).toContain('scope=primary')
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
appendSpy.mockRestore()
})
it('uses all-formats scope when requested', async () => {
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const appendSpy = vi.spyOn(document.body, 'append')
const { useBookDownload } = await import('../useBookDownload')
const { exportBooks } = useBookDownload()
await exportBooks([5], true)
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('scope=all')
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
appendSpy.mockRestore()
})
it('supports audio-only export scope override', async () => {
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const appendSpy = vi.spyOn(document.body, 'append')
const { useBookDownload } = await import('../useBookDownload')
const { exportBooks } = useBookDownload()
await exportBooks([9], false, 'audio')
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('scope=audio')
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
appendSpy.mockRestore()
})
})
@@ -1,6 +1,17 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
type ExportScope = 'primary' | 'all' | 'audio'
function triggerBrowserDownload(url: string, filename?: string): void {
const anchor = document.createElement('a')
anchor.href = url
if (filename) anchor.download = filename
anchor.rel = 'noopener'
document.body.append(anchor)
anchor.click()
anchor.remove()
}
export function useBookDownload() {
const isDownloading = ref(false)
@@ -8,21 +19,7 @@ export function useBookDownload() {
async function downloadFile(fileId: number): Promise<void> {
isDownloading.value = true
try {
const res = await api(`/api/v1/books/files/${fileId}/serve?download=1`)
if (!res.ok) {
toast.error('Download failed')
return
}
const disposition = res.headers.get('Content-Disposition') ?? ''
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)["']?/i)
const filename = match?.[1] ? decodeURIComponent(match[1].trim()) : 'download'
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
triggerBrowserDownload(`/api/v1/books/files/${fileId}/download`)
} catch {
toast.error('Download failed')
} finally {
@@ -30,29 +27,19 @@ export function useBookDownload() {
}
}
async function exportBooks(bookIds: number[], allFormats: boolean): Promise<void> {
async function exportBooks(bookIds: number[], allFormats: boolean, scopeOverride?: ExportScope): Promise<void> {
if (bookIds.length === 0) return
const label = `${bookIds.length} book${bookIds.length === 1 ? '' : 's'}`
const toastId = toast.loading(`Preparing ${label} for download...`)
isDownloading.value = true
try {
const res = await api('/api/v1/books/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bookIds, allFormats }),
const scope = scopeOverride ?? (allFormats ? 'all' : 'primary')
const params = new URLSearchParams({
bookIds: bookIds.join(','),
scope,
})
toast.dismiss(toastId)
if (!res.ok) {
toast.error('Export failed')
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'books.zip'
a.click()
URL.revokeObjectURL(url)
triggerBrowserDownload(`/api/v1/books/export/download?${params.toString()}`, 'books.zip')
} catch {
toast.dismiss(toastId)
toast.error('Export failed')
@@ -239,7 +239,7 @@ function handleApplyZoomPreset(value: AcceptableValue) {
<Tooltip v-if="hasPermission('library_download')">
<TooltipTrigger as-child>
<a :href="`/api/v1/books/files/${fileId}/serve?download=1`" class="viewer-btn flex items-center justify-center">
<a :href="`/api/v1/books/files/${fileId}/download`" class="viewer-btn flex items-center justify-center">
<Download :size="14" />
</a>
</TooltipTrigger>
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import type { MockedFunction } from 'vitest';
import archiver from 'archiver';
import { createReadStream } from 'fs';
@@ -117,6 +117,7 @@ function makeController() {
bulkRefreshMetadata: vi.fn(),
bulkReExtractCover: vi.fn(),
getExportFiles: vi.fn(),
acquireExportSlot: vi.fn().mockReturnValue(vi.fn()),
getCoverPath: vi.fn(),
getThumbnailPath: vi.fn(),
getFileInfo: vi.fn(),
@@ -199,7 +200,7 @@ describe('BookController', () => {
});
bookService.resolveDownloadFilename.mockResolvedValue('caf\u00e9.epub');
await controller.serveFile(1, makeUser(), undefined, '1', reply);
await controller.downloadFile(1, makeUser(), reply);
expect(headers['Accept-Ranges']).toBe('bytes');
expect(headers['Content-Disposition']).toBe(`attachment; filename="caf_.epub"; filename*=UTF-8''caf%C3%A9.epub`);
@@ -207,6 +208,22 @@ describe('BookController', () => {
expect(mockCreateReadStream).toHaveBeenCalledWith('/tmp/book.epub');
});
it('serves inline content-disposition for file stream route', async () => {
const { controller, bookService } = makeController();
const { reply, headers } = makeReply();
bookService.getFileInfo.mockResolvedValue({
path: '/tmp/book.epub',
size: 100,
format: 'epub',
bookId: 5,
originalFilename: 'book.epub',
});
await controller.serveFile(1, makeUser(), undefined, reply);
expect(headers['Content-Disposition']).toBe(`inline; filename="book.epub"; filename*=UTF-8''book.epub`);
});
it('serves partial content for valid byte ranges', async () => {
const { controller, bookService } = makeController();
const { reply, headers } = makeReply();
@@ -218,7 +235,7 @@ describe('BookController', () => {
originalFilename: 'book.pdf',
});
await controller.serveFile(1, makeUser(), 'bytes=10-19', undefined, reply);
await controller.serveFile(1, makeUser(), 'bytes=10-19', reply);
expect(reply.status).toHaveBeenCalledWith(206);
expect(headers['Content-Range']).toBe('bytes 10-19/500');
@@ -237,7 +254,7 @@ describe('BookController', () => {
originalFilename: 'book.epub',
});
await controller.serveFile(1, makeUser(), 'bytes=120-130', undefined, reply);
await controller.serveFile(1, makeUser(), 'bytes=120-130', reply);
expect(reply.status).toHaveBeenCalledWith(416);
expect(headers['Content-Range']).toBe('bytes */100');
@@ -286,10 +303,15 @@ describe('BookController', () => {
it('archives exported files into a zip stream', async () => {
const { controller, bookService } = makeController();
const { reply, raw } = makeReply();
bookService.getExportFiles.mockResolvedValue([
{ absolutePath: '/books/a.epub', zipPath: 'A.epub' },
{ absolutePath: '/books/b.epub', zipPath: 'B.epub' },
]);
bookService.getExportFiles.mockResolvedValue({
files: [
{ absolutePath: '/books/a.epub', zipPath: 'A.epub', sizeBytes: 10 },
{ absolutePath: '/books/b.epub', zipPath: 'B.epub', sizeBytes: 20 },
],
projectedBytes: 30,
bookCount: 2,
scope: 'primary',
});
await controller.exportBooks({ bookIds: [1, 2], allFormats: false }, makeUser(), reply);
@@ -306,10 +328,38 @@ describe('BookController', () => {
expect(archive.finalize).toHaveBeenCalled();
});
it('parses export download query args and delegates with scope', async () => {
const { controller, bookService } = makeController();
const { reply } = makeReply();
bookService.getExportFiles.mockResolvedValue({
files: [{ absolutePath: '/books/a.epub', zipPath: 'A.epub', sizeBytes: 10 }],
projectedBytes: 10,
bookCount: 1,
scope: 'audio',
});
await controller.exportBooksDownload('9,10', 'audio', makeUser(), reply);
expect(bookService.getExportFiles).toHaveBeenCalledWith([9, 10], expect.any(Object), 'audio');
});
it('rejects invalid export download query args', async () => {
const { controller } = makeController();
const { reply } = makeReply();
await expect(controller.exportBooksDownload(undefined, 'primary', makeUser(), reply)).rejects.toThrow(BadRequestException);
await expect(controller.exportBooksDownload('1', 'invalid', makeUser(), reply)).rejects.toThrow(BadRequestException);
});
it('aborts archive export and swallows finalize errors after client disconnect', async () => {
const { controller, bookService } = makeController();
const { reply, emitRawEvent } = makeReply();
bookService.getExportFiles.mockResolvedValue([{ absolutePath: '/books/a.epub', zipPath: 'A.epub' }]);
bookService.getExportFiles.mockResolvedValue({
files: [{ absolutePath: '/books/a.epub', zipPath: 'A.epub', sizeBytes: 10 }],
projectedBytes: 10,
bookCount: 1,
scope: 'primary',
});
const archive = {
pipe: vi.fn(),
file: vi.fn(),
+129 -15
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -6,6 +7,7 @@ import {
Headers,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
ParseIntPipe,
@@ -74,12 +76,31 @@ const AUDIO_MIME_TYPES: Record<string, string> = {
flac: 'audio/flac',
};
const BOOK_MIME_TYPES: Record<string, string> = {
epub: 'application/epub+zip',
kepub: 'application/epub+zip',
pdf: 'application/pdf',
mobi: 'application/x-mobipocket-ebook',
azw: 'application/vnd.amazon.ebook',
azw3: 'application/vnd.amazon.ebook',
fb2: 'application/x-fictionbook+xml',
cbz: 'application/vnd.comicbook+zip',
cbr: 'application/vnd.comicbook-rar',
cb7: 'application/x-cb7',
};
function resolveAudioMimeType(format: string | null): string | null {
return format ? (AUDIO_MIME_TYPES[format.toLowerCase()] ?? null) : null;
}
function resolveBookMimeType(format: string): string {
return resolveAudioMimeType(format) ?? BOOK_MIME_TYPES[format.toLowerCase()] ?? 'application/octet-stream';
}
@Controller('books')
export class BookController {
private readonly logger = new Logger(BookController.name);
constructor(
private readonly bookService: BookService,
private readonly fileWriteService: FileWriteService,
@@ -184,7 +205,30 @@ export class BookController {
@Post('export')
@RequirePermission(Permission.LibraryDownload)
async exportBooks(@Body() dto: ExportBooksDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
const files = await this.bookService.getExportFiles(dto.bookIds, user, dto.allFormats ?? false);
const scope = dto.audioOnly ? 'audio' : dto.allFormats ? 'all' : 'primary';
await this.streamBookExport(dto.bookIds, scope, user, reply);
}
@Get('export/download')
@RequirePermission(Permission.LibraryDownload)
async exportBooksDownload(
@Query('bookIds') bookIdsQuery: string | undefined,
@Query('scope') scopeQuery: string | undefined,
@CurrentUser() user: RequestUser,
@Res() reply: FastifyReply,
) {
const bookIds = this.parseBookIdsQuery(bookIdsQuery);
const scope = this.parseExportScopeQuery(scopeQuery);
await this.streamBookExport(bookIds, scope, user, reply);
}
private async streamBookExport(bookIds: number[], scope: 'primary' | 'all' | 'audio', user: RequestUser, reply: FastifyReply) {
const event = 'book.export_books';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} scope=${scope} - export books started`);
const releaseExportSlot = this.bookService.acquireExportSlot(user.id);
let plannedFiles = 0;
let projectedBytes = 0;
const archive = archiver('zip', { zlib: { level: 0 } });
let clientDisconnected = false;
const handleDisconnect = () => {
@@ -193,23 +237,44 @@ export class BookController {
};
reply.raw.on('close', handleDisconnect);
reply.raw.on('aborted', handleDisconnect);
reply.raw.setHeader('Content-Type', 'application/zip');
reply.raw.setHeader('Content-Disposition', 'attachment; filename="books.zip"');
const archiveFailure = new Promise<never>((_, reject) => {
archive.on('warning', reject);
archive.on('error', reject);
});
try {
const plan = await this.bookService.getExportFiles(bookIds, user, scope);
plannedFiles = plan.files.length;
projectedBytes = plan.projectedBytes;
reply.raw.setHeader('Content-Type', 'application/zip');
reply.raw.setHeader('Content-Disposition', 'attachment; filename="books.zip"');
archive.pipe(reply.raw);
for (const file of files) {
for (const file of plan.files) {
archive.file(file.absolutePath, { name: file.zipPath });
}
await Promise.race([archive.finalize(), archiveFailure]);
} catch (err) {
if (!clientDisconnected) {
throw err;
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} scope=${scope} files=${plannedFiles} projectedBytes=${projectedBytes} durationMs=${Date.now() - startedAt} - export books completed`,
);
}
} catch (err) {
if (clientDisconnected) {
this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} scope=${scope} files=${plannedFiles} projectedBytes=${projectedBytes} durationMs=${Date.now() - startedAt} disconnected=true - export books disconnected`,
);
return;
}
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
this.logger.warn(
`[${event}] [fail] userId=${user.id} count=${bookIds.length} scope=${scope} files=${plannedFiles} projectedBytes=${projectedBytes} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - export books failed`,
);
throw err;
} finally {
releaseExportSlot();
reply.raw.off('close', handleDisconnect);
reply.raw.off('aborted', handleDisconnect);
}
@@ -271,21 +336,16 @@ export class BookController {
@Param('fileId', ParseIntPipe) fileId: number,
@CurrentUser() user: RequestUser,
@Headers('range') rangeHeader: string | undefined,
@Query('download') download: string | undefined,
@Res() reply: FastifyReply,
) {
const { path, size, format, bookId, originalFilename } = await this.bookService.getFileInfo(fileId, user);
const mimeType =
resolveAudioMimeType(format) ?? (format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip');
const isDownload = download === '1' || download === 'true';
const filename = isDownload
? await this.bookService.resolveDownloadFilename({ bookId, absolutePath: path, format: format === 'unknown' ? null : format })
: originalFilename;
const { path, size, format, originalFilename } = await this.bookService.getFileInfo(fileId, user);
const mimeType = resolveBookMimeType(format);
const filename = originalFilename;
const asciiFilename = filename.replace(/[^\x20-\x7E]|["\\]/g, '_') || 'download';
const encodedFilename = encodeFilenameStar(filename);
reply.header('Accept-Ranges', 'bytes');
const disposition = `${isDownload ? 'attachment' : 'inline'}; filename="${asciiFilename}"`;
const disposition = `inline; filename="${asciiFilename}"`;
reply.header('Content-Disposition', encodedFilename ? `${disposition}; filename*=UTF-8''${encodedFilename}` : disposition);
reply.type(mimeType);
@@ -312,6 +372,38 @@ export class BookController {
reply.send(createReadStream(path));
}
@Get('files/:fileId/download')
@RequirePermission(Permission.LibraryDownload)
async downloadFile(@Param('fileId', ParseIntPipe) fileId: number, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
const event = 'book.download_file';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] fileId=${fileId} userId=${user.id} - download file started`);
try {
const { path, size, format, bookId } = await this.bookService.getFileInfo(fileId, user);
const mimeType = resolveBookMimeType(format);
const filename = await this.bookService.resolveDownloadFilename({ bookId, absolutePath: path, format: format === 'unknown' ? null : format });
const asciiFilename = filename.replace(/[^\x20-\x7E]|["\\]/g, '_') || 'download';
const encodedFilename = encodeFilenameStar(filename);
reply.header('Accept-Ranges', 'bytes');
const disposition = `attachment; filename="${asciiFilename}"`;
reply.header('Content-Disposition', encodedFilename ? `${disposition}; filename*=UTF-8''${encodedFilename}` : disposition);
reply.type(mimeType);
reply.header('Content-Length', size);
reply.send(createReadStream(path));
this.logger.log(
`[${event}] [end] fileId=${fileId} userId=${user.id} durationMs=${Date.now() - startedAt} sizeBytes=${size} - download file completed`,
);
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
this.logger.warn(
`[${event}] [fail] fileId=${fileId} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - download file failed`,
);
throw err;
}
}
@Get('files/:fileId/progress')
async getFileProgress(@Param('fileId', ParseIntPipe) fileId: number, @CurrentUser() user: RequestUser) {
return (await this.bookService.getProgress(user.id, fileId, user)) ?? { cfi: null, pageNumber: null, percentage: 0 };
@@ -441,4 +533,26 @@ export class BookController {
return { send, isClosed, close };
}
private parseBookIdsQuery(raw: string | undefined): number[] {
if (!raw) {
throw new BadRequestException('bookIds query parameter is required');
}
const parsed = raw
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => Number.parseInt(part, 10));
if (parsed.length === 0 || parsed.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
throw new BadRequestException('bookIds must be a comma-separated list of positive integers');
}
return parsed;
}
private parseExportScopeQuery(raw: string | undefined): 'primary' | 'all' | 'audio' {
if (!raw) return 'primary';
if (raw === 'primary' || raw === 'all' || raw === 'audio') return raw;
throw new BadRequestException('scope must be one of: primary, all, audio');
}
}
+18 -6
View File
@@ -448,21 +448,33 @@ export class BookRepository {
return row ?? null;
}
async findPrimaryFilesByBookIds(bookIds: number[]): Promise<{ bookId: number; absolutePath: string; format: string | null }[]> {
async findPrimaryFilesByBookIds(
bookIds: number[],
): Promise<{ bookId: number; absolutePath: string; format: string | null; sizeBytes: number | null }[]> {
if (bookIds.length === 0) return [];
return this.db
.select({ bookId: books.id, absolutePath: bookFiles.absolutePath, format: bookFiles.format })
.select({ bookId: books.id, absolutePath: bookFiles.absolutePath, format: bookFiles.format, sizeBytes: bookFiles.sizeBytes })
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(inArray(books.id, bookIds));
.where(inArray(books.id, bookIds))
.orderBy(asc(books.id));
}
async findAllFilesByBookIds(bookIds: number[]): Promise<{ bookId: number; absolutePath: string; format: string | null }[]> {
async findAllFilesByBookIds(
bookIds: number[],
): Promise<{ bookId: number; absolutePath: string; format: string | null; sizeBytes: number | null; sortOrder: number }[]> {
if (bookIds.length === 0) return [];
return this.db
.select({ bookId: bookFiles.bookId, absolutePath: bookFiles.absolutePath, format: bookFiles.format })
.select({
bookId: bookFiles.bookId,
absolutePath: bookFiles.absolutePath,
format: bookFiles.format,
sizeBytes: bookFiles.sizeBytes,
sortOrder: bookFiles.sortOrder,
})
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds));
.where(inArray(bookFiles.bookId, bookIds))
.orderBy(asc(bookFiles.bookId), asc(bookFiles.sortOrder), asc(bookFiles.id));
}
async deleteByIds(bookIds: number[]): Promise<void> {
+104 -11
View File
@@ -235,7 +235,7 @@ describe('BookService', () => {
it('throws when export is requested with no books', async () => {
const { service } = makeService();
await expect(service.getExportFiles([], makeUser(), false)).rejects.toThrow(BadRequestException);
await expect(service.getExportFiles([], makeUser(), 'primary')).rejects.toThrow(BadRequestException);
});
it('applies pattern to export zip paths and de-duplicates collisions', async () => {
@@ -248,17 +248,18 @@ describe('BookService', () => {
{ id: 2, libraryId: 77 },
]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub' },
{ bookId: 2, absolutePath: '/books/b.epub', format: 'epub' },
{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 100 },
{ bookId: 2, absolutePath: '/books/b.epub', format: 'epub', sizeBytes: 200 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Duplicate' }), metaRow(2, { title: 'Duplicate' })]);
const files = await service.getExportFiles([1, 2], user, false);
const plan = await service.getExportFiles([1, 2], user, 'primary');
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 77, false);
expect(files).toEqual([
{ absolutePath: '/books/a.epub', zipPath: 'Duplicate.epub' },
{ absolutePath: '/books/b.epub', zipPath: 'Duplicate (2).epub' },
expect(plan.projectedBytes).toBe(300);
expect(plan.files).toEqual([
{ absolutePath: '/books/a.epub', zipPath: 'Duplicate.epub', sizeBytes: 100 },
{ absolutePath: '/books/b.epub', zipPath: 'Duplicate (2).epub', sizeBytes: 200 },
]);
});
@@ -268,11 +269,12 @@ describe('BookService', () => {
appSettings.getDownloadPattern.mockResolvedValue('../{title}/..//bad:name');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/source.epub', format: 'epub' }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/source.epub', format: 'epub', sizeBytes: 100 }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: '..' })]);
const [file] = await service.getExportFiles([1], user, false);
const plan = await service.getExportFiles([1], user, 'primary');
const [file] = plan.files;
expect(file.zipPath).toBe('download/download/download/bad_name.epub');
});
@@ -282,14 +284,97 @@ describe('BookService', () => {
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub' }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 1, sortOrder: 0 }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'One' })]);
await service.getExportFiles([1], user, true);
await service.getExportFiles([1], user, 'all');
expect(bookRepo.findAllFilesByBookIds).toHaveBeenCalledWith([1]);
expect(bookRepo.findPrimaryFilesByBookIds).not.toHaveBeenCalled();
});
it('uses audio-only export scope when requested', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/a.mp3', format: 'mp3', sizeBytes: 5, sortOrder: 0 },
{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 10, sortOrder: 1 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'One' })]);
const plan = await service.getExportFiles([1], user, 'audio');
expect(plan.files).toHaveLength(1);
expect(plan.files[0]?.absolutePath).toBe('/books/a.mp3');
});
it('throws when selected book ids include missing records', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
await expect(service.getExportFiles([1, 2], makeUser(), 'primary')).rejects.toThrow(BadRequestException);
});
it('throws when selected books have no exportable files', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([]);
await expect(service.getExportFiles([1], makeUser(), 'primary')).rejects.toThrow(BadRequestException);
});
it('throws when selected books exceed configured limit', async () => {
const { service } = makeService();
const tooManyBookIds = Array.from({ length: 251 }, (_, i) => i + 1);
await expect(service.getExportFiles(tooManyBookIds, makeUser(), 'primary')).rejects.toThrow(BadRequestException);
});
it('throws when selected export files exceed configured limit', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const fileRows = Array.from({ length: 2001 }, (_, i) => ({
bookId: 1,
absolutePath: `/books/${i}.epub`,
format: 'epub',
sizeBytes: 1,
sortOrder: i,
}));
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue(fileRows);
await expect(service.getExportFiles([1], user, 'all')).rejects.toThrow(BadRequestException);
});
it('throws when projected export size exceeds limit', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 9_000_000_000 }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Huge' })]);
await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow(BadRequestException);
});
});
describe('export concurrency slots', () => {
it('enforces max concurrent exports per user', () => {
const { service } = makeService();
const releaseOne = service.acquireExportSlot(7);
const releaseTwo = service.acquireExportSlot(7);
expect(() => service.acquireExportSlot(7)).toThrow();
releaseOne();
const releaseThree = service.acquireExportSlot(7);
releaseTwo();
releaseThree();
});
});
describe('access + file/cover helpers', () => {
@@ -361,6 +446,14 @@ describe('BookService', () => {
originalFilename: 'test.book',
});
});
it('throws NotFoundException when file exists in DB but is missing on disk', async () => {
const { service, bookRepo } = makeService();
bookRepo.findFileById.mockResolvedValue({ id: 10, absolutePath: '/books/missing.book', format: null, bookId: 1, libraryId: 7 });
mockStat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
await expect(service.getFileInfo(10, makeUser())).rejects.toThrow(NotFoundException);
});
});
describe('metadata refresh + update', () => {
+144 -14
View File
@@ -1,4 +1,13 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger, NotFoundException, Optional } from '@nestjs/common';
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { access, readdir, rm, stat } from 'fs/promises';
@@ -44,6 +53,29 @@ const METADATA_UPDATE_FAILPOINTS = [
] as const;
export type MetadataUpdateFailpoint = (typeof METADATA_UPDATE_FAILPOINTS)[number];
export type ExportScope = 'primary' | 'all' | 'audio';
const EXPORT_LIMITS = {
MAX_BOOKS: 250,
MAX_FILES: 2000,
MAX_PROJECTED_BYTES: 8 * 1024 * 1024 * 1024,
MAX_CONCURRENT_PER_USER: 2,
} as const;
type ExportCandidateFile = {
bookId: number;
absolutePath: string;
format: string | null;
sizeBytes: number | null;
sortOrder?: number;
};
export type ExportPlan = {
files: { absolutePath: string; zipPath: string; sizeBytes: number }[];
projectedBytes: number;
bookCount: number;
scope: ExportScope;
};
@Injectable()
export class BookService {
@@ -51,6 +83,7 @@ export class BookService {
private readonly appDataPath: string;
private embeddingRun: Promise<void> | null = null;
private metadataUpdateFailpoint: MetadataUpdateFailpoint | null = null;
private readonly activeExportCounts = new Map<number, number>();
constructor(
private readonly bookRepo: BookRepository,
@@ -144,6 +177,30 @@ export class BookService {
return file;
}
acquireExportSlot(userId: number): () => void {
const current = this.activeExportCounts.get(userId) ?? 0;
if (current >= EXPORT_LIMITS.MAX_CONCURRENT_PER_USER) {
throw new HttpException(
`Too many concurrent exports. Limit is ${EXPORT_LIMITS.MAX_CONCURRENT_PER_USER} active exports per user.`,
HttpStatus.TOO_MANY_REQUESTS,
);
}
this.activeExportCounts.set(userId, current + 1);
let released = false;
return () => {
if (released) return;
released = true;
const next = (this.activeExportCounts.get(userId) ?? 1) - 1;
if (next <= 0) {
this.activeExportCounts.delete(userId);
} else {
this.activeExportCounts.set(userId, next);
}
};
}
async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<BooksPage> {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId, userId: user.id });
@@ -329,7 +386,15 @@ export class BookService {
user: RequestUser,
): Promise<{ path: string; size: number; format: string; bookId: number; originalFilename: string }> {
const file = await this.verifyFileAccess(fileId, user);
const { size } = await stat(file.absolutePath);
let size: number;
try {
({ size } = await stat(file.absolutePath));
} catch (err) {
if (this.isMissingFilesystemEntry(err)) {
throw new NotFoundException(`File ${fileId} not found on disk`);
}
throw err;
}
const originalFilename = basename(file.absolutePath);
return { path: file.absolutePath, size, format: file.format ?? 'unknown', bookId: file.bookId, originalFilename };
}
@@ -961,40 +1026,105 @@ export class BookService {
}
}
async getExportFiles(bookIds: number[], user: RequestUser, allFormats: boolean): Promise<{ absolutePath: string; zipPath: string }[]> {
private async resolveExportFileSize(file: ExportCandidateFile): Promise<number> {
if (typeof file.sizeBytes === 'number' && Number.isFinite(file.sizeBytes) && file.sizeBytes >= 0) {
return file.sizeBytes;
}
try {
const { size } = await stat(file.absolutePath);
return size;
} catch (err) {
if (this.isMissingFilesystemEntry(err)) {
throw new NotFoundException(`File for book ${file.bookId} is missing on disk`);
}
throw err;
}
}
async getExportFiles(bookIds: number[], user: RequestUser, scope: ExportScope): Promise<ExportPlan> {
const event = 'book.get_export_files';
const startedAt = Date.now();
this.logger.log(`[${event}] [start] count=${bookIds.length} userId=${user.id} allFormats=${allFormats} - get export files started`);
this.logger.log(`[${event}] [start] count=${bookIds.length} userId=${user.id} scope=${scope} - get export files started`);
try {
if (bookIds.length === 0) throw new BadRequestException('No books selected');
await this.verifyLibraryAccessForBookIds(bookIds, user);
const uniqueBookIds = [...new Set(bookIds)];
if (uniqueBookIds.length === 0) throw new BadRequestException('No books selected');
if (uniqueBookIds.length > EXPORT_LIMITS.MAX_BOOKS) {
throw new BadRequestException(`Too many books selected. Limit is ${EXPORT_LIMITS.MAX_BOOKS}.`);
}
const files = allFormats ? await this.bookRepo.findAllFilesByBookIds(bookIds) : await this.bookRepo.findPrimaryFilesByBookIds(bookIds);
const rows = await this.bookRepo.findLibraryIdsByBookIds(uniqueBookIds);
const foundBookIds = new Set(rows.map((row) => row.id));
const missingBookIds = uniqueBookIds.filter((id) => !foundBookIds.has(id));
if (missingBookIds.length > 0) {
throw new BadRequestException(`Some selected books were not found: ${missingBookIds.join(', ')}`);
}
const uniqueLibraryIds = [...new Set(rows.map((row) => row.libraryId))];
await Promise.all(uniqueLibraryIds.map((libraryId) => this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user))));
const bookOrder = new Map(uniqueBookIds.map((id, index) => [id, index]));
const fetchedFiles: ExportCandidateFile[] =
scope === 'primary' ? await this.bookRepo.findPrimaryFilesByBookIds(uniqueBookIds) : await this.bookRepo.findAllFilesByBookIds(uniqueBookIds);
const files = scope === 'audio' ? fetchedFiles.filter((file) => !!file.format && isAudioFormat(file.format)) : fetchedFiles;
const booksWithFiles = new Set(files.map((file) => file.bookId));
const booksWithoutExportFiles = uniqueBookIds.filter((id) => !booksWithFiles.has(id));
if (booksWithoutExportFiles.length > 0) {
throw new BadRequestException(`Some selected books have no exportable files: ${booksWithoutExportFiles.join(', ')}`);
}
if (files.length > EXPORT_LIMITS.MAX_FILES) {
throw new BadRequestException(`Too many files selected for export. Limit is ${EXPORT_LIMITS.MAX_FILES}.`);
}
const orderedFiles = files.sort((a, b) => {
const aOrder = bookOrder.get(a.bookId) ?? Number.MAX_SAFE_INTEGER;
const bOrder = bookOrder.get(b.bookId) ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) return aOrder - bOrder;
const aSortOrder = a.sortOrder ?? 0;
const bSortOrder = b.sortOrder ?? 0;
if (aSortOrder !== bSortOrder) return aSortOrder - bSortOrder;
return a.absolutePath.localeCompare(b.absolutePath);
});
const [pattern, metadataRows] = await Promise.all([
this.appSettings.getDownloadPattern(),
this.bookRepo.findPatternMetadataByBookIds([...new Set(files.map((f) => f.bookId))]),
this.bookRepo.findPatternMetadataByBookIds([...new Set(orderedFiles.map((f) => f.bookId))]),
]);
const metadataByBookId = new Map(metadataRows.map((row) => [row.bookId, row]));
const usedPaths = new Set<string>();
let projectedBytes = 0;
const result: ExportPlan['files'] = [];
for (const file of orderedFiles) {
const sizeBytes = await this.resolveExportFileSize(file);
projectedBytes += sizeBytes;
if (projectedBytes > EXPORT_LIMITS.MAX_PROJECTED_BYTES) {
throw new BadRequestException(`Export exceeds projected size limit of ${EXPORT_LIMITS.MAX_PROJECTED_BYTES} bytes.`);
}
const result = files.map((file) => {
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, metadataByBookId.get(file.bookId));
const resolvedPath = resolveUploadPath(pattern || DEFAULT_DOWNLOAD_PATTERN, tokens, tokens.extension);
const fallbackFilename = basename(file.absolutePath);
const rawZipPath = resolvedPath ?? fallbackFilename;
const safeZipPath = this.sanitizeZipPath(rawZipPath, fallbackFilename);
const zipPath = this.makeUniqueZipPath(safeZipPath, usedPaths);
return { absolutePath: file.absolutePath, zipPath };
});
result.push({ absolutePath: file.absolutePath, zipPath, sizeBytes });
}
this.logger.log(
`[${event}] [end] count=${bookIds.length} durationMs=${Date.now() - startedAt} files=${result.length} allFormats=${allFormats} - get export files completed`,
`[${event}] [end] count=${uniqueBookIds.length} durationMs=${Date.now() - startedAt} files=${result.length} projectedBytes=${projectedBytes} scope=${scope} - get export files completed`,
);
return result;
return {
files: result,
projectedBytes,
bookCount: uniqueBookIds.length,
scope,
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = (err instanceof Error ? err.message : String(err)).replace(/"/g, '\\"');
this.logger.warn(
`[${event}] [fail] count=${bookIds.length} userId=${user.id} allFormats=${allFormats} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - get export files failed`,
`[${event}] [fail] count=${bookIds.length} userId=${user.id} scope=${scope} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - get export files failed`,
);
throw err;
}
@@ -32,7 +32,9 @@ describe('Book DTO validation', () => {
it('validates export options and boolean allFormats flag', async () => {
expect((await errorsFor(ExportBooksDto, { bookIds: [1], allFormats: true })).length).toBe(0);
expect((await errorsFor(ExportBooksDto, { bookIds: [1], audioOnly: true })).length).toBe(0);
expect((await errorsFor(ExportBooksDto, { bookIds: [1], allFormats: 'true' })).length).toBeGreaterThan(0);
expect((await errorsFor(ExportBooksDto, { bookIds: [1], audioOnly: 'true' })).length).toBeGreaterThan(0);
});
it('coerces GetBooksDto numeric query values and enforces bounds', async () => {
@@ -9,4 +9,8 @@ export class ExportBooksDto {
@IsOptional()
@IsBoolean()
allFormats?: boolean;
@IsOptional()
@IsBoolean()
audioOnly?: boolean;
}
+9 -2
View File
@@ -485,10 +485,17 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expect(invalidRange.statusCode).toBe(416);
expect(invalidRange.headers['content-range']).toBe(`bytes */${inlinePdf.headers['content-length']}`);
const forbiddenDownloadEpub = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visibleEpub.bookFileId}/download`,
headers: authHeader(limitedUser.accessToken),
});
expect(forbiddenDownloadEpub.statusCode).toBe(403);
const downloadEpub = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/files/${visibleEpub.bookFileId}/serve?download=1`,
headers: authHeader(limitedUser.accessToken),
url: `/api/v1/books/files/${visibleEpub.bookFileId}/download`,
headers: authHeader(downloadUser.accessToken),
});
expect(downloadEpub.statusCode).toBe(200);