mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
chore: improve download feature
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user