feat(books): export single-book zip with pattern-based filename

Use the user's configured download pattern to name single-book zip archives instead of defaulting to a generic 'books.zip'. Update the frontend reader cover card to download multi-track audiobooks as zip files directly.
This commit is contained in:
neon
2026-07-07 17:27:25 +00:00
parent 39d9be30f9
commit 0a4dcc192f
8 changed files with 143 additions and 7 deletions
@@ -73,8 +73,10 @@ vi.mock('@/composables/useDisplaySettings', () => ({
}),
}))
const mockDownloadFile = vi.fn<() => void>()
const mockExportBooks = vi.fn<() => void>()
type ExportScope = 'primary' | 'all' | 'audio'
const mockDownloadFile = vi.fn<(fileId: number) => void>()
const mockExportBooks = vi.fn<(bookIds: number[], allFormats: boolean, scopeOverride?: ExportScope) => void>()
vi.mock('@/features/book/composables/useBookDownload', () => ({
useBookDownload: () => ({ downloadFile: mockDownloadFile, exportBooks: mockExportBooks }),
}))
@@ -580,6 +582,49 @@ describe('BookCoverCard', () => {
const wrapper = mountCard({ selectionMode: true })
expect(wrapper.find('[data-testid="dropdown-menu"]').exists()).toBe(false)
})
it('downloads a pure multi-track audiobook as an audio ZIP', async () => {
const book = makeBook({
id: 42,
files: [makeFile({ id: 10, format: 'mp3', role: 'primary' }), makeFile({ id: 11, format: 'mp3', role: 'secondary' })],
})
const wrapper = mountCard({ book })
const downloadItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Download')
expect(downloadItem).toBeDefined()
await downloadItem!.trigger('click')
expect(mockExportBooks).toHaveBeenCalledWith([42], false, 'audio')
expect(mockDownloadFile).not.toHaveBeenCalled()
})
it('downloads the audio entry in a mixed multi-track audiobook as a ZIP while keeping ebook files single-file', async () => {
const book = makeBook({
id: 77,
files: [
makeFile({ id: 20, format: 'mp3', role: 'primary' }),
makeFile({ id: 21, format: 'mp3', role: 'secondary' }),
makeFile({ id: 22, format: 'epub', role: 'secondary' }),
],
})
const wrapper = mountCard({ book })
const audioZipItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Audiobook (ZIP)')
expect(audioZipItem).toBeDefined()
await audioZipItem!.trigger('click')
expect(mockExportBooks).toHaveBeenCalledWith([77], false, 'audio')
expect(mockDownloadFile).not.toHaveBeenCalled()
mockExportBooks.mockClear()
mockDownloadFile.mockClear()
const epubItems = wrapper.findAll('[data-testid="dropdown-item"]').filter((item) => item.text().trim() === 'EPUB')
expect(epubItems).toHaveLength(2)
await epubItems[1]!.trigger('click')
expect(mockDownloadFile).toHaveBeenCalledWith(22)
expect(mockExportBooks).not.toHaveBeenCalled()
})
})
// ── cover aspect ratio ────────────────────────────────────────────────────
@@ -334,7 +334,15 @@ const showBelowCoverLabelArea = computed(() => props.showLabel && cardInfoMode.v
const { downloadFile, exportBooks } = useBookDownload()
function isAudioFile(file: BookFileRef) {
return !!file.format && FORMAT_TO_GROUP[file.format] === 'audio'
}
function handleDownloadFile(file: BookFileRef) {
if (isMultiTrackAudio.value && isAudioFile(file)) {
void exportBooks([props.book.id], false, 'audio')
return
}
void downloadFile(file.id)
}
@@ -614,7 +622,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
<span v-if="isMultiTrackAudio && isAudioFile(file)">Audiobook (ZIP)</span>
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
</DropdownMenuItem>
@@ -748,7 +756,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
<span v-if="isMultiTrackAudio && isAudioFile(file)">Audiobook (ZIP)</span>
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
</DropdownMenuItem>
@@ -51,6 +51,7 @@ describe('useBookDownload', () => {
expect(anchor.href).toContain('/api/v1/books/export/download?')
expect(anchor.href).toContain('bookIds=1%2C2')
expect(anchor.href).toContain('scope=primary')
expect(anchor.download).toBe('')
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
@@ -68,6 +69,7 @@ describe('useBookDownload', () => {
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('scope=all')
expect(anchor.download).toBe('')
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
@@ -85,6 +87,7 @@ describe('useBookDownload', () => {
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('scope=audio')
expect(anchor.download).toBe('')
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
@@ -39,7 +39,7 @@ export function useBookDownload() {
scope,
})
toast.dismiss(toastId)
triggerBrowserDownload(`/api/v1/books/export/download?${params.toString()}`, 'books.zip')
triggerBrowserDownload(`/api/v1/books/export/download?${params.toString()}`)
} catch {
toast.dismiss(toastId)
toast.error('Export failed')
@@ -414,12 +414,16 @@ describe('BookController', () => {
projectedBytes: 30,
bookCount: 2,
scope: 'primary',
archiveFilename: 'Selected Books.zip',
});
await controller.exportBooks({ bookIds: [1, 2], allFormats: false }, makeUser(), reply);
expect(raw.setHeader).toHaveBeenCalledWith('Content-Type', 'application/zip');
expect(raw.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="books.zip"');
expect(raw.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
`attachment; filename="Selected Books.zip"; filename*=UTF-8''Selected%20Books.zip`,
);
const zipArchiveMock = ZipArchive as unknown as vi.Mock;
expect(zipArchiveMock).toHaveBeenCalledWith({ zlib: { level: 0 } });
+1 -1
View File
@@ -281,7 +281,7 @@ export class BookController {
projectedBytes = plan.projectedBytes;
reply.raw.setHeader('Content-Type', 'application/zip');
reply.raw.setHeader('Content-Disposition', 'attachment; filename="books.zip"');
reply.raw.setHeader('Content-Disposition', contentDispositionHeader('attachment', plan.archiveFilename || 'books.zip', 'books.zip'));
archive.pipe(reply.raw);
for (const file of plan.files) {
archive.file(file.absolutePath, { name: file.zipPath });
@@ -461,6 +461,55 @@ describe('BookService', () => {
{ absolutePath: '/books/a.epub', zipPath: 'Duplicate.epub', sizeBytes: 100 },
{ absolutePath: '/books/b.epub', zipPath: 'Duplicate (2).epub', sizeBytes: 200 },
]);
expect(plan.archiveFilename).toBe('books.zip');
});
it('uses the download pattern for single-book export archive filenames', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{authors:first} - {title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/track-01.mp3', format: 'mp3', sizeBytes: 5, sortOrder: 0 },
{ bookId: 1, absolutePath: '/books/track-02.mp3', format: 'mp3', sizeBytes: 6, sortOrder: 1 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Dune', authors: ['Frank Herbert'] })]);
const plan = await service.getExportFiles([1], user, 'audio');
expect(plan.archiveFilename).toBe('Frank Herbert - Dune.zip');
});
it('uses the book title as originalFilename for single-book audiobook archive filenames', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{originalFilename}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/CH01 THE DARK LORD ASCENDING.mp3', format: 'mp3', sizeBytes: 5, sortOrder: 0 },
{ bookId: 1, absolutePath: '/books/CH02 IN MEMORIAM.mp3', format: 'mp3', sizeBytes: 6, sortOrder: 1 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Harry Potter and the Deathly Hallows' })]);
const plan = await service.getExportFiles([1], user, 'audio');
expect(plan.archiveFilename).toBe('Harry Potter and the Deathly Hallows.zip');
});
it('falls back to the original stem when a single-book archive pattern cannot resolve', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{series}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/source.epub', format: 'epub', sizeBytes: 100 }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Standalone' })]);
const plan = await service.getExportFiles([1], user, 'primary');
expect(plan.archiveFilename).toBe('source.zip');
});
it('sanitizes unsafe path segments in generated zip paths', async () => {
+27
View File
@@ -145,6 +145,7 @@ export type ExportPlan = {
projectedBytes: number;
bookCount: number;
scope: ExportScope;
archiveFilename: string;
};
const METADATA_EXPORT_SCHEMA_VERSION = 1 as const;
@@ -1219,6 +1220,30 @@ export class BookService {
return candidate;
}
private resolveExportArchiveFilename(
bookCount: number,
orderedFiles: ExportCandidateFile[],
metadataByBookId: Map<number, Awaited<ReturnType<BookRepository['findPatternMetadataByBookIds']>>[number]>,
pattern: string,
): string {
if (bookCount !== 1) return 'books.zip';
const firstFile = orderedFiles[0];
if (!firstFile) return 'books.zip';
const originalStem = basename(firstFile.absolutePath, extname(firstFile.absolutePath)).trim() || 'book';
const fallback = this.sanitizeFilenameSegment(`${originalStem}.zip`, 'book.zip');
const meta = metadataByBookId.get(firstFile.bookId);
const tokens = {
...this.buildDownloadPatternTokens(firstFile.absolutePath, firstFile.format, meta),
originalFilename: meta?.title?.trim() || originalStem,
extension: 'zip',
};
const resolvedPath = resolveUploadPath(pattern || DEFAULT_DOWNLOAD_PATTERN, tokens, 'zip');
const resolvedName = resolvedPath?.split('/').filter(Boolean).pop() ?? null;
return this.sanitizeFilenameSegment(resolvedName ?? fallback, fallback);
}
private buildDownloadPatternTokens(
absolutePath: string,
format: string | null,
@@ -2738,6 +2763,7 @@ export class BookService {
this.bookRepo.findPatternMetadataByBookIds([...new Set(orderedFiles.map((f) => f.bookId))]),
]);
const metadataByBookId = new Map(metadataRows.map((row) => [row.bookId, row]));
const archiveFilename = this.resolveExportArchiveFilename(uniqueBookIds.length, orderedFiles, metadataByBookId, pattern);
const usedPaths = new Set<string>();
let projectedBytes = 0;
@@ -2766,6 +2792,7 @@ export class BookService {
projectedBytes,
bookCount: uniqueBookIds.length,
scope,
archiveFilename,
};
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';