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:
neon
2026-07-06 23:03:39 +00:00
parent 55967fe977
commit db5f17f8de
4 changed files with 28 additions and 32 deletions
@@ -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);
+11 -7
View File
@@ -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));