mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(opds): honor configured download file pattern for downloads
Update KOReader and OPDS catalog download endpoints to resolve filenames using the settings-configured download file pattern via BookService. This ensures consistent filenames across all download paths. Closes #569
This commit is contained in:
@@ -106,6 +106,7 @@ function makeService() {
|
||||
absolutePath: '/books/dune.epub',
|
||||
format: 'epub',
|
||||
}),
|
||||
resolveDownloadFilename: vi.fn().mockResolvedValue('Dune - Frank Herbert.epub'),
|
||||
};
|
||||
const bookReadService = {
|
||||
findProgressByBook: vi.fn().mockResolvedValue([
|
||||
@@ -484,12 +485,7 @@ describe('KoreaderCatalogService', () => {
|
||||
it('encodes non-ASCII content file download filenames for Content-Disposition', async () => {
|
||||
const { service, bookService } = makeService();
|
||||
const reply = makeReply();
|
||||
bookService.getDetail.mockResolvedValueOnce(
|
||||
makeDetail({
|
||||
title: 'Dune’s Café',
|
||||
authors: [{ id: 1, name: 'Frank Herbert', sortName: 'Herbert, Frank' }],
|
||||
}),
|
||||
);
|
||||
bookService.resolveDownloadFilename.mockResolvedValueOnce('Dune’s Café - Frank Herbert.epub');
|
||||
|
||||
await service.streamFile(makeUser({ permissions: [] }), 100, reply as never);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createReadStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import { basename, extname } from 'path';
|
||||
import { basename } from 'path';
|
||||
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
@@ -249,9 +249,12 @@ export class KoreaderCatalogService {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
const detail = await this.bookService.getDetail(file.bookId, user);
|
||||
const format = this.normalizeFormat(file.format);
|
||||
const filename = this.downloadFilename(detail, fileId, format);
|
||||
const filename = await this.bookService.resolveDownloadFilename({
|
||||
bookId: file.bookId,
|
||||
absolutePath: file.absolutePath,
|
||||
format: file.format,
|
||||
});
|
||||
|
||||
try {
|
||||
const { size } = await stat(file.absolutePath);
|
||||
@@ -627,18 +630,4 @@ export class KoreaderCatalogService {
|
||||
private uniqueFormats(formats: string[]): string[] {
|
||||
return [...new Set(formats.map((format) => this.normalizeFormat(format)).filter(Boolean))];
|
||||
}
|
||||
|
||||
private downloadFilename(detail: BookDetailDto, fileId: number, format: string): string {
|
||||
const title = detail.title ?? (basename(detail.folderPath) || `book-${detail.id}`);
|
||||
const author = detail.authors[0]?.name;
|
||||
const base = [title, author].filter(Boolean).join(' - ');
|
||||
const safeBase =
|
||||
base
|
||||
.replace(/["\\/:*?<>|]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || `book-${detail.id}-file-${fileId}`;
|
||||
const ext = extname(safeBase).replace(/^\./, '').toLowerCase();
|
||||
if (ext === format) return safeBase;
|
||||
return `${safeBase}.${format}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,15 @@ function makeController() {
|
||||
const config = {
|
||||
get: vi.fn().mockReturnValue('/books'),
|
||||
} as never;
|
||||
const bookService = {
|
||||
resolveDownloadFilename: vi.fn().mockResolvedValue('BadTitle - Author.epub'),
|
||||
} as never;
|
||||
|
||||
return {
|
||||
controller: new OpdsController(opdsService, opdsBookService, config),
|
||||
controller: new OpdsController(opdsService, opdsBookService, config, bookService),
|
||||
opdsService,
|
||||
opdsBookService,
|
||||
bookService,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,7 +342,10 @@ describe('OpdsController', () => {
|
||||
|
||||
await controller.download(99, 0, { userId: 2, isSuperuser: false } as never, reply);
|
||||
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="BadTitle - Author.epub"');
|
||||
expect(reply.header).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="BadTitle - Author.epub"; filename*=UTF-8\'\'BadTitle%20-%20Author.epub',
|
||||
);
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Length', 12345);
|
||||
expect(reply.type).toHaveBeenCalledWith('application/epub+zip');
|
||||
expect(reply.send).toHaveBeenCalledWith(stream);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { bookCoverDirPath, bookThumbnailPath, findPreferredBookCoverFileName } f
|
||||
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { imageContentTypeFromPath } from '../../common/image-content-type';
|
||||
import { contentDispositionHeader } from '../../common/utils/content-disposition.utils';
|
||||
import { OPDS_MIME_ACQ, OPDS_MIME_NAV, OPDS_MIME_SEARCH, fileMimeType } from './opds-xml.helpers';
|
||||
import { OpdsAuthGuard } from './opds-auth.guard';
|
||||
import type { OpdsRequestUser } from './opds-auth.guard';
|
||||
@@ -28,6 +29,7 @@ import { OpdsEnabledGuard } from './opds-enabled.guard';
|
||||
import { OpdsUser } from './opds-user.decorator';
|
||||
import { OpdsBookService } from './opds-book.service';
|
||||
import { OpdsService } from './opds.service';
|
||||
import { BookService } from '../book/book.service';
|
||||
|
||||
@Controller('opds')
|
||||
@Public()
|
||||
@@ -39,6 +41,7 @@ export class OpdsController {
|
||||
private readonly opdsService: OpdsService,
|
||||
private readonly opdsBookService: OpdsBookService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly bookService: BookService,
|
||||
) {
|
||||
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
|
||||
}
|
||||
@@ -271,16 +274,17 @@ export class OpdsController {
|
||||
const bookFiles = await this.opdsBookService.getBookFiles(bookId, fileId);
|
||||
if (!bookFiles) throw new NotFoundException('File not found');
|
||||
|
||||
const { absolutePath, format, title, authorName } = bookFiles;
|
||||
const { absolutePath, format } = bookFiles;
|
||||
const { size: fileSize } = await stat(absolutePath);
|
||||
const mime = fileMimeType(format);
|
||||
|
||||
const safeName =
|
||||
[title, authorName]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
.replace(/[^\w\s.-]/g, '') || `book-${bookId}`;
|
||||
reply.header('Content-Disposition', `attachment; filename="${safeName}.${format}"`);
|
||||
const filename = await this.bookService.resolveDownloadFilename({
|
||||
bookId,
|
||||
absolutePath,
|
||||
format: format === 'unknown' ? null : format,
|
||||
});
|
||||
|
||||
reply.header('Content-Disposition', contentDispositionHeader('attachment', filename, 'download'));
|
||||
reply.header('Content-Length', fileSize);
|
||||
reply.type(mime);
|
||||
reply.send(createReadStream(absolutePath));
|
||||
|
||||
Reference in New Issue
Block a user