mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(books): list a book's formats from one ranked, read-along aware source
Every format chip, edition row and format menu now builds from shared helpers: content files only, a read-along EPUB kept apart from a plain one, in the library's format priority, which gains a read-along entry. Labels, colours and the chip each have one source, and cards, OPDS, email, Kobo and the dashboard rank files the same way. Book detail file order is unchanged because iOS builds its track queue from it.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { selectPrimaryFile } from './primary-file-selection.utils';
|
||||
import { READ_ALONG_FORMAT_PRIORITY, withoutImplicitReadAlongFormatPriority, withReadAlongFormatPriority } from '@bookorbit/types';
|
||||
|
||||
import { rankFileRowsByBook, rankFilesByFormatPriority, selectPrimaryFile } from './primary-file-selection.utils';
|
||||
|
||||
describe('selectPrimaryFile', () => {
|
||||
const plainEpub = { id: 1, format: 'epub', sizeBytes: 100, mediaOverlayAvailable: false };
|
||||
@@ -13,9 +15,107 @@ describe('selectPrimaryFile', () => {
|
||||
expect(selectPrimaryFile([plainEpub, readAlongEpub, audiobook], ['m4b', 'epub'])).toBe(audiobook);
|
||||
});
|
||||
|
||||
describe('with the read-along entry', () => {
|
||||
const readAlongFirst = [READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub'];
|
||||
|
||||
it('ranks a read-along EPUB above audio when the entry is placed first', () => {
|
||||
expect(selectPrimaryFile([plainEpub, audiobook, readAlongEpub], readAlongFirst)).toBe(readAlongEpub);
|
||||
});
|
||||
|
||||
it('ranks a plain EPUB by its own entry, below audio', () => {
|
||||
expect(selectPrimaryFile([plainEpub, audiobook], readAlongFirst)).toBe(audiobook);
|
||||
});
|
||||
|
||||
it('prefers a plain EPUB over a read-along one when plain EPUB is ranked higher', () => {
|
||||
expect(selectPrimaryFile([readAlongEpub, plainEpub], ['epub', READ_ALONG_FORMAT_PRIORITY])).toBe(plainEpub);
|
||||
});
|
||||
|
||||
it('falls through to the read-along entry when no plain EPUB exists', () => {
|
||||
expect(selectPrimaryFile([readAlongEpub, audiobook], ['epub', 'pdf', READ_ALONG_FORMAT_PRIORITY, 'm4b'])).toBe(readAlongEpub);
|
||||
});
|
||||
|
||||
it('treats an EPUB whose overlay state is unknown as a plain EPUB', () => {
|
||||
const unknownEpub = { id: 4, format: 'epub', sizeBytes: 100 };
|
||||
expect(selectPrimaryFile([unknownEpub, audiobook], readAlongFirst)).toBe(audiobook);
|
||||
});
|
||||
|
||||
it('matches the stored list without the entry when it sits directly before epub', () => {
|
||||
const files = [plainEpub, readAlongEpub, audiobook];
|
||||
expect(selectPrimaryFile(files, ['m4b', READ_ALONG_FORMAT_PRIORITY, 'epub'])).toBe(selectPrimaryFile(files, ['m4b', 'epub']));
|
||||
expect(selectPrimaryFile([plainEpub, readAlongEpub], [READ_ALONG_FORMAT_PRIORITY, 'epub'])).toBe(
|
||||
selectPrimaryFile([plainEpub, readAlongEpub], ['epub']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects zero-byte candidates unless fallback is explicitly enabled', () => {
|
||||
const empty = { ...readAlongEpub, sizeBytes: 0 };
|
||||
expect(selectPrimaryFile([empty], ['epub'])).toBeNull();
|
||||
expect(selectPrimaryFile([empty], ['epub'], { allowZeroByteFallback: true })).toBe(empty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-along format priority helpers', () => {
|
||||
it('shows the read-along entry directly before epub when a stored list lacks it', () => {
|
||||
expect(withReadAlongFormatPriority(['m4b', 'epub', 'pdf'])).toEqual(['m4b', READ_ALONG_FORMAT_PRIORITY, 'epub', 'pdf']);
|
||||
});
|
||||
|
||||
it('keeps a stored read-along position and appends the entry when epub is missing', () => {
|
||||
expect(withReadAlongFormatPriority([READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub'])).toEqual([READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub']);
|
||||
expect(withReadAlongFormatPriority(['pdf'])).toEqual(['pdf', READ_ALONG_FORMAT_PRIORITY]);
|
||||
});
|
||||
|
||||
it('saves the list without the entry only while it sits in its implicit slot', () => {
|
||||
expect(withoutImplicitReadAlongFormatPriority(['m4b', READ_ALONG_FORMAT_PRIORITY, 'epub'])).toEqual(['m4b', 'epub']);
|
||||
expect(withoutImplicitReadAlongFormatPriority([READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub'])).toEqual([READ_ALONG_FORMAT_PRIORITY, 'm4b', 'epub']);
|
||||
expect(withoutImplicitReadAlongFormatPriority(['m4b', 'epub'])).toEqual(['m4b', 'epub']);
|
||||
});
|
||||
|
||||
it('round-trips a legacy list unchanged', () => {
|
||||
const legacy = ['m4b', 'm4a', 'mp3', 'epub', 'pdf'];
|
||||
expect(withoutImplicitReadAlongFormatPriority(withReadAlongFormatPriority(legacy))).toEqual(legacy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankFilesByFormatPriority', () => {
|
||||
const files = [
|
||||
{ id: 1, format: 'jpg', role: 'cover', sizeBytes: 10 },
|
||||
{ id: 2, format: 'mp3', role: 'content', sizeBytes: 10 },
|
||||
{ id: 3, format: 'epub', role: 'content', sizeBytes: 10, mediaOverlayAvailable: true },
|
||||
{ id: 4, format: 'mp3', role: 'content', sizeBytes: 10 },
|
||||
{ id: 5, format: 'epub', role: 'content', sizeBytes: 10 },
|
||||
];
|
||||
|
||||
it('puts the primary first, then content by priority, then covers and sidecars', () => {
|
||||
const ranked = rankFilesByFormatPriority(files, ['m4b', 'mp3', 'epub'], 5);
|
||||
expect(ranked.map((file) => file.id)).toEqual([5, 2, 4, 3, 1]);
|
||||
});
|
||||
|
||||
it('ranks a read-along EPUB by its own entry and keeps audio tracks in order', () => {
|
||||
const ranked = rankFilesByFormatPriority(files, [READ_ALONG_FORMAT_PRIORITY, 'mp3', 'epub'], null);
|
||||
expect(ranked.map((file) => file.id)).toEqual([3, 2, 4, 5, 1]);
|
||||
});
|
||||
|
||||
it('ranks by the default priority when the library has none', () => {
|
||||
expect(rankFilesByFormatPriority(files, null).map((file) => file.id)).toEqual([3, 5, 2, 4, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankFileRowsByBook', () => {
|
||||
it("ranks each book's rows with that book's own priority", () => {
|
||||
const rows = [
|
||||
{ bookId: 1, id: 10, format: 'epub', role: 'content', sizeBytes: 1 },
|
||||
{ bookId: 1, id: 11, format: 'm4b', role: 'content', sizeBytes: 1 },
|
||||
{ bookId: 2, id: 20, format: 'epub', role: 'content', sizeBytes: 1 },
|
||||
{ bookId: 2, id: 21, format: 'm4b', role: 'content', sizeBytes: 1 },
|
||||
];
|
||||
const ranked = rankFileRowsByBook(
|
||||
rows,
|
||||
new Map([
|
||||
[1, { formatPriority: ['m4b', 'epub'], primaryFileId: null }],
|
||||
[2, { formatPriority: ['epub', 'm4b'], primaryFileId: null }],
|
||||
]),
|
||||
);
|
||||
expect(ranked.map((row) => row.id)).toEqual([11, 10, 20, 21]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { bookFormatKey, formatKeyRank, isContentBookFile, normalizeFormatPriority, READ_ALONG_FORMAT_PRIORITY } from '@bookorbit/types';
|
||||
|
||||
export type PrimaryFileCandidate = {
|
||||
id: number;
|
||||
format: string | null;
|
||||
@@ -5,6 +7,16 @@ export type PrimaryFileCandidate = {
|
||||
mediaOverlayAvailable?: boolean | null;
|
||||
};
|
||||
|
||||
function isReadAlong(file: PrimaryFileCandidate): boolean {
|
||||
return file.format?.toLowerCase() === 'epub' && file.mediaOverlayAvailable === true;
|
||||
}
|
||||
|
||||
function matchesPriorityEntry(file: PrimaryFileCandidate, entry: string, readAlongRankedApart: boolean): boolean {
|
||||
if (entry === READ_ALONG_FORMAT_PRIORITY) return isReadAlong(file);
|
||||
if (entry === 'epub' && readAlongRankedApart) return file.format?.toLowerCase() === 'epub' && !isReadAlong(file);
|
||||
return file.format?.toLowerCase() === entry;
|
||||
}
|
||||
|
||||
export function selectPrimaryFile<T extends PrimaryFileCandidate>(
|
||||
files: readonly T[],
|
||||
formatPriority: readonly string[],
|
||||
@@ -19,10 +31,56 @@ export function selectPrimaryFile<T extends PrimaryFileCandidate>(
|
||||
if (pool.length === 0) return null;
|
||||
|
||||
const normalizedPriority = formatPriority.map((format) => format.toLowerCase());
|
||||
const preferredFormat = normalizedPriority.find((format) => pool.some((file) => file.format?.toLowerCase() === format));
|
||||
const formatPool = preferredFormat ? pool.filter((file) => file.format?.toLowerCase() === preferredFormat) : pool;
|
||||
const first = formatPool[0] ?? null;
|
||||
const readAlongRankedApart = normalizedPriority.includes(READ_ALONG_FORMAT_PRIORITY);
|
||||
const preferredEntry = normalizedPriority.find((entry) => pool.some((file) => matchesPriorityEntry(file, entry, readAlongRankedApart)));
|
||||
const entryPool = preferredEntry ? pool.filter((file) => matchesPriorityEntry(file, preferredEntry, readAlongRankedApart)) : pool;
|
||||
const first = entryPool[0] ?? null;
|
||||
if (readAlongRankedApart && preferredEntry) return first;
|
||||
if (first?.format?.toLowerCase() !== 'epub') return first;
|
||||
|
||||
return formatPool.find((file) => file.mediaOverlayAvailable === true) ?? first;
|
||||
return entryPool.find((file) => file.mediaOverlayAvailable === true) ?? first;
|
||||
}
|
||||
|
||||
/**
|
||||
* A book's files in edition order: the primary first, then content files by the library's format
|
||||
* priority (a read-along EPUB ranked apart from a plain one), then covers and sidecars. The sort is
|
||||
* stable, so files of one format, an audiobook's tracks, keep the order they came in.
|
||||
*/
|
||||
export function rankFilesByFormatPriority<T extends PrimaryFileCandidate & { role: string }>(
|
||||
files: readonly T[],
|
||||
formatPriority: readonly string[] | null | undefined,
|
||||
primaryFileId?: number | null,
|
||||
): T[] {
|
||||
const priority = normalizeFormatPriority(formatPriority);
|
||||
return files
|
||||
.map((file, index) => {
|
||||
const content = isContentBookFile(file);
|
||||
const rank = content ? formatKeyRank(bookFormatKey(file.format!, file.mediaOverlayAvailable === true), priority) : Number.MAX_SAFE_INTEGER;
|
||||
return { file, index, content, rank, primary: primaryFileId != null && file.id === primaryFileId };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.primary !== b.primary) return a.primary ? -1 : 1;
|
||||
if (a.content !== b.content) return a.content ? -1 : 1;
|
||||
return a.rank - b.rank || a.index - b.index;
|
||||
})
|
||||
.map((entry) => entry.file);
|
||||
}
|
||||
|
||||
export type FileRankingContext = { formatPriority: readonly string[] | null | undefined; primaryFileId: number | null };
|
||||
|
||||
/** File rows for many books, each book's files ranked with its own library priority and primary. */
|
||||
export function rankFileRowsByBook<T extends PrimaryFileCandidate & { bookId: number; role: string }>(
|
||||
rows: readonly T[],
|
||||
contextByBook: ReadonlyMap<number, FileRankingContext>,
|
||||
): T[] {
|
||||
const rowsByBook = new Map<number, T[]>();
|
||||
for (const row of rows) {
|
||||
const list = rowsByBook.get(row.bookId) ?? [];
|
||||
list.push(row);
|
||||
rowsByBook.set(row.bookId, list);
|
||||
}
|
||||
return [...rowsByBook.entries()].flatMap(([bookId, bookRows]) => {
|
||||
const context = contextByBook.get(bookId);
|
||||
return rankFilesByFormatPriority(bookRows, context?.formatPriority, context?.primaryFileId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,9 +30,10 @@ import type {
|
||||
MetadataSeriesMembership,
|
||||
} from '@bookorbit/types';
|
||||
import {
|
||||
DEFAULT_FORMAT_PRIORITY,
|
||||
formatKeyRank,
|
||||
isAudioFormat,
|
||||
MetadataProviderKey,
|
||||
normalizeFormatPriority,
|
||||
NotificationType,
|
||||
parseSeriesIndex,
|
||||
Permission,
|
||||
@@ -190,16 +191,15 @@ function reduceUnitForLibrary(
|
||||
|
||||
if (importFormats === 'all') return { files: kept };
|
||||
|
||||
const priority = library.formatPriority?.length ? library.formatPriority : [...DEFAULT_FORMAT_PRIORITY];
|
||||
const priority = normalizeFormatPriority(library.formatPriority);
|
||||
const best = [...content].sort((a, b) => formatRank(a.format, priority) - formatRank(b.format, priority))[0]!;
|
||||
// The chosen format keeps the artwork and sidecars that came with the unit; the other formats go.
|
||||
return { files: looseFileLibrary ? [best] : [best, ...unitFiles.filter((file) => file.role !== 'content')] };
|
||||
}
|
||||
|
||||
function formatRank(format: string | null, priority: string[]): number {
|
||||
if (!format) return Number.MAX_SAFE_INTEGER;
|
||||
const index = priority.indexOf(format.toLowerCase());
|
||||
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||
/** Dock files are not inspected for media overlays yet, so an EPUB ranks by the plain `epub` entry. */
|
||||
function formatRank(format: string | null, priority: readonly string[]): number {
|
||||
return format ? formatKeyRank(format.toLowerCase(), priority) : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -238,8 +238,9 @@ describe('BookRepository', () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('offset', rows))
|
||||
.mockReturnValueOnce(makeSelectChain('orderBy', authorRows))
|
||||
.mockReturnValueOnce(makeSelectChain('where', fileRows))
|
||||
.mockReturnValueOnce(makeSelectChain('orderBy', fileRows))
|
||||
.mockReturnValueOnce(makeSelectChain('where', genreRows))
|
||||
.mockReturnValueOnce(makeSelectChain('where', [{ bookId: 10, formatPriority: null }]))
|
||||
.mockReturnValueOnce(makeSelectChain('where', tagRows))
|
||||
.mockReturnValueOnce(makeSelectChain('orderBy', narratorRows))
|
||||
.mockReturnValueOnce(makeSelectChain('orderBy', seriesMembershipRows))
|
||||
@@ -276,7 +277,7 @@ describe('BookRepository', () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('offset', rows))
|
||||
.mockReturnValueOnce(makeSelectChain('orderBy', []))
|
||||
.mockReturnValueOnce(makeSelectChain('where', []))
|
||||
.mockReturnValueOnce(makeSelectChain('orderBy', []))
|
||||
.mockReturnValueOnce(makeSelectChain('where', []))
|
||||
.mockReturnValueOnce(makeSelectChain('where', []))
|
||||
.mockReturnValueOnce(makeSelectChain('orderBy', []))
|
||||
@@ -305,7 +306,7 @@ describe('BookRepository', () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce(makeSelectChain('offset', rows))
|
||||
.mockReturnValueOnce(tracked('orderBy', 0))
|
||||
.mockReturnValueOnce(tracked('where', 0))
|
||||
.mockReturnValueOnce(tracked('orderBy', 0))
|
||||
.mockReturnValueOnce(tracked('where', 0))
|
||||
.mockReturnValueOnce(tracked('where', 1))
|
||||
.mockReturnValueOnce(tracked('orderBy', 1))
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import type { UnscopedBookRecommendation } from '@bookorbit/types';
|
||||
import { isAudioFormat, isComicFormat, normalizeCoverAspectRatio } from '@bookorbit/types';
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
import { rankFileRowsByBook } from '../../common/utils/primary-file-selection.utils';
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { advanceIsoTimestamp } from '../../common/utils/iso-timestamp.utils';
|
||||
import { parsePgTimestamptz } from '../../common/utils/pg-timestamp.utils';
|
||||
@@ -608,6 +609,27 @@ export class BookRepository {
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card files in edition order, so every card, row and search result lists a book's formats the
|
||||
* way its library ranks them. Libraries are few, so their priorities come in one small query.
|
||||
*/
|
||||
private async rankCardFileRows<
|
||||
T extends { bookId: number; id: number; format: string | null; role: string; sizeBytes: number | null; mediaOverlayAvailable: boolean },
|
||||
>(fileRows: T[], bookRefs: Array<{ id: number; primaryFileId: number | null }>): Promise<T[]> {
|
||||
if (fileRows.length === 0) return fileRows;
|
||||
const bookIds = [...new Set(fileRows.map((row) => row.bookId))];
|
||||
const priorityRows = await this.db
|
||||
.select({ bookId: books.id, formatPriority: libraries.formatPriority })
|
||||
.from(books)
|
||||
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.where(inArray(books.id, bookIds));
|
||||
const priorityByBook = new Map(priorityRows.map((row) => [row.bookId, row.formatPriority as string[] | null]));
|
||||
const contextByBook = new Map(
|
||||
bookRefs.map((book) => [book.id, { formatPriority: priorityByBook.get(book.id), primaryFileId: book.primaryFileId }]),
|
||||
);
|
||||
return rankFileRowsByBook(fileRows, contextByBook);
|
||||
}
|
||||
|
||||
private async enrichBookIds(bookRefs: Array<{ id: number; primaryFileId: number | null }>, userId: number) {
|
||||
const bookIds = bookRefs.map((book) => book.id);
|
||||
const primaryFileIds = bookRefs.map((book) => book.primaryFileId).filter((id): id is number => id != null);
|
||||
@@ -650,7 +672,7 @@ export class BookRepository {
|
||||
|
||||
// Keep book-card hydration below the database pool's capacity when list requests overlap.
|
||||
// Three small batches retain useful parallelism without allowing one request to claim nine connections.
|
||||
const [authorRows, fileRows, genreRows] = await Promise.all([
|
||||
const [authorRows, unrankedFileRows, genreRows] = await Promise.all([
|
||||
this.db
|
||||
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
||||
.from(bookAuthors)
|
||||
@@ -669,7 +691,8 @@ export class BookRepository {
|
||||
mediaOverlayCheckedAt: bookFiles.mediaOverlayCheckedAt,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.where(inArray(bookFiles.bookId, bookIds)),
|
||||
.where(inArray(bookFiles.bookId, bookIds))
|
||||
.orderBy(asc(bookFiles.bookId), asc(bookFiles.sortOrder), asc(bookFiles.id)),
|
||||
this.db
|
||||
.select({ bookId: bookGenres.bookId, name: genres.name })
|
||||
.from(bookGenres)
|
||||
@@ -677,6 +700,8 @@ export class BookRepository {
|
||||
.where(inArray(bookGenres.bookId, bookIds)),
|
||||
]);
|
||||
|
||||
const fileRows = await this.rankCardFileRows(unrankedFileRows, bookRefs);
|
||||
|
||||
const [tagRows, narratorRows, seriesMembershipRows] = await Promise.all([
|
||||
this.db
|
||||
.select({ bookId: bookTags.bookId, name: tags.name })
|
||||
|
||||
@@ -1573,8 +1573,7 @@ export class BookService {
|
||||
} else if (wasPrimary) {
|
||||
const contentFiles = remaining.filter((candidate) => candidate.role === 'content');
|
||||
const library = await this.libraryService.findOne(file.libraryId);
|
||||
const newPrimary =
|
||||
selectPrimaryFile(contentFiles, library.formatPriority ?? DEFAULT_FORMAT_PRIORITY, { allowZeroByteFallback: true }) ?? remaining[0];
|
||||
const newPrimary = selectPrimaryFile(contentFiles, library.formatPriority ?? DEFAULT_FORMAT_PRIORITY, { allowZeroByteFallback: true });
|
||||
await this.bookRepo.updateBookPrimaryFile(file.bookId, newPrimary?.id ?? null);
|
||||
}
|
||||
void this.coverReconciler?.enqueue([file.bookId], { filesChanged: true });
|
||||
|
||||
@@ -139,14 +139,31 @@ describe('assembleBookCards', () => {
|
||||
expect(card.readingProgress).toBe(45);
|
||||
});
|
||||
|
||||
it('falls back to first file for progress when no primary file exists', () => {
|
||||
it('falls back to the first content file for progress when no primary file exists', () => {
|
||||
const rows = [makeBookRow(1)];
|
||||
const fileRows = [{ bookId: 1, id: 11, format: 'pdf', role: 'supplemental', sizeBytes: null }];
|
||||
const fileRows = [
|
||||
{ bookId: 1, id: 10, format: 'jpg', role: 'cover', sizeBytes: null },
|
||||
{ bookId: 1, id: 11, format: 'pdf', role: 'content', sizeBytes: null },
|
||||
];
|
||||
const progressRows = [{ bookFileId: 11, percentage: 30 }];
|
||||
|
||||
const [card] = assembleBookCards(rows, [], fileRows, [], progressRows);
|
||||
|
||||
expect(card.readingProgress).toBe(30);
|
||||
expect(card.files.find((file) => file.role === 'primary')?.id).toBe(11);
|
||||
});
|
||||
|
||||
it('never makes a cover or sidecar the primary', () => {
|
||||
const rows = [makeBookRow(1)];
|
||||
const fileRows = [
|
||||
{ bookId: 1, id: 10, format: 'jpg', role: 'cover', sizeBytes: null },
|
||||
{ bookId: 1, id: 11, format: 'opf', role: 'metadata', sizeBytes: null },
|
||||
];
|
||||
|
||||
const [card] = assembleBookCards(rows, [], fileRows, [], [{ bookFileId: 10, percentage: 30 }]);
|
||||
|
||||
expect(card.files.some((file) => file.role === 'primary')).toBe(false);
|
||||
expect(card.readingProgress).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null readingProgress when there are no files', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { basename } from 'path';
|
||||
|
||||
import type { BookCard, BookMetadataLockField, CollapsedSeriesInfo, CustomMetadataBookValue, UserBookStatus } from '@bookorbit/types';
|
||||
import { BOOK_METADATA_LOCK_FIELDS, compareSeriesIndices, normalizeCoverAspectRatio } from '@bookorbit/types';
|
||||
import { BOOK_METADATA_LOCK_FIELDS, compareSeriesIndices, isContentBookFile, normalizeCoverAspectRatio } from '@bookorbit/types';
|
||||
import { mediaOverlayCapabilityFromFields } from '../../reader/epub/epub-media-overlay-capability';
|
||||
|
||||
const LOCK_FIELD_SET = new Set<string>(BOOK_METADATA_LOCK_FIELDS);
|
||||
@@ -190,11 +190,10 @@ export function assembleBookCards(
|
||||
|
||||
return rows.map((row) => {
|
||||
const rawFiles = filesByBook.get(row.id) ?? [];
|
||||
// A cover or sidecar is never the primary: a book with no content file has none.
|
||||
const primaryFile =
|
||||
(row.primaryFileId != null ? rawFiles.find((f) => f.id === row.primaryFileId) : undefined) ??
|
||||
rawFiles.find((f) => f.role === 'primary') ??
|
||||
rawFiles.find((f) => f.role === 'content') ??
|
||||
rawFiles[0] ??
|
||||
rawFiles.find((f) => isContentBookFile(f)) ??
|
||||
null;
|
||||
const files = rawFiles.map((f) => {
|
||||
const mediaOverlay = mediaOverlayCapabilityFromFields(f);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isAudioFormat } from '@bookorbit/types';
|
||||
import { formatKeyRank, isAudioFormat, normalizeFormatPriority } from '@bookorbit/types';
|
||||
import type {
|
||||
ChallengeType,
|
||||
DiversityScoreWidgetData,
|
||||
@@ -432,11 +432,11 @@ export function computeRhythm(days: { readingSeconds: number }[]): Omit<ReadingR
|
||||
// ── Currently Reading resume modes ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Formats a BookOrbit reader opens, best first. KEPUB is deliberately absent: it is a Kobo
|
||||
* delivery format that no reader in the product opens directly, so offering it as the read file
|
||||
* would hand a client a file it cannot display.
|
||||
* Formats a BookOrbit reader opens. KEPUB is deliberately absent: it is a Kobo delivery format that
|
||||
* no reader in the product opens directly, so offering it as the read file would hand a client a
|
||||
* file it cannot display. Which of them wins is the library's format priority.
|
||||
*/
|
||||
const READABLE_FORMAT_PRIORITY = ['epub', 'mobi', 'azw3', 'azw', 'fb2', 'pdf', 'cbz', 'cbr', 'cb7'];
|
||||
const READABLE_FORMATS = new Set(['epub', 'mobi', 'azw3', 'azw', 'fb2', 'pdf', 'cbz', 'cbr', 'cb7']);
|
||||
|
||||
export type ResumeModeFile = {
|
||||
id: number;
|
||||
@@ -451,13 +451,8 @@ export type ResumeModes = {
|
||||
hasAudio: boolean;
|
||||
};
|
||||
|
||||
function readableRank(format: string | null): number {
|
||||
const index = READABLE_FORMAT_PRIORITY.indexOf((format ?? '').toLowerCase());
|
||||
return index === -1 ? READABLE_FORMAT_PRIORITY.length : index;
|
||||
}
|
||||
|
||||
function isReadable(format: string | null): boolean {
|
||||
return readableRank(format) < READABLE_FORMAT_PRIORITY.length;
|
||||
return READABLE_FORMATS.has((format ?? '').toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,16 +460,21 @@ function isReadable(format: string | null): boolean {
|
||||
*
|
||||
* The primary file wins the read slot when it is readable at all, so the resumed file matches the
|
||||
* one the book's own page leads with; otherwise the best readable file stands in, which is what
|
||||
* lets an audiobook-primary book still be read. Read-along needs an EPUB with media overlays, and
|
||||
* prefers the primary for the same reason.
|
||||
* lets an audiobook-primary book still be read. That stand-in follows the library's format priority,
|
||||
* and a plain EPUB beats a read-along copy, which has a slot of its own. Read-along needs an EPUB with
|
||||
* media overlays, and prefers the primary for the same reason.
|
||||
*/
|
||||
export function resolveResumeModes(files: ResumeModeFile[], primaryFileId: number | null): ResumeModes {
|
||||
export function resolveResumeModes(files: ResumeModeFile[], primaryFileId: number | null, formatPriority?: readonly string[] | null): ResumeModes {
|
||||
const primary = files.find((file) => file.id === primaryFileId) ?? null;
|
||||
const priority = normalizeFormatPriority(formatPriority);
|
||||
const rank = (file: ResumeModeFile) => formatKeyRank((file.format ?? '').toLowerCase(), priority);
|
||||
|
||||
const readFile =
|
||||
primary && isReadable(primary.format)
|
||||
? primary
|
||||
: ([...files.filter((file) => isReadable(file.format))].sort((a, b) => readableRank(a.format) - readableRank(b.format))[0] ?? null);
|
||||
: ([...files.filter((file) => isReadable(file.format))].sort(
|
||||
(a, b) => rank(a) - rank(b) || Number(a.mediaOverlayAvailable) - Number(b.mediaOverlayAvailable),
|
||||
)[0] ?? null);
|
||||
|
||||
const overlayFiles = files.filter((file) => file.mediaOverlayAvailable && (file.format ?? '').toLowerCase() === 'epub');
|
||||
const readAlongFile = overlayFiles.find((file) => file.id === primaryFileId) ?? overlayFiles[0] ?? null;
|
||||
|
||||
@@ -142,15 +142,22 @@ describe('resolveResumeModes', () => {
|
||||
expect(resolveResumeModes(files, 1)).toMatchObject({ readFileId: 1, readFileFormat: 'pdf' });
|
||||
});
|
||||
|
||||
it('falls back to the best readable file when the primary is an audiobook', () => {
|
||||
it("falls back to the library's best readable file when the primary is an audiobook", () => {
|
||||
const files = [file(1, 'm4b'), file(2, 'pdf'), file(3, 'mobi')];
|
||||
|
||||
expect(resolveResumeModes(files, 1)).toEqual({
|
||||
readFileId: 3,
|
||||
readFileFormat: 'mobi',
|
||||
readFileId: 2,
|
||||
readFileFormat: 'pdf',
|
||||
readAlongFileId: null,
|
||||
hasAudio: true,
|
||||
});
|
||||
expect(resolveResumeModes(files, 1, ['m4b', 'mobi', 'pdf'])).toMatchObject({ readFileId: 3, readFileFormat: 'mobi' });
|
||||
});
|
||||
|
||||
it('reads the plain EPUB and keeps the read-along copy for read-along', () => {
|
||||
const files = [file(1, 'm4b'), file(2, 'epub', true), file(3, 'epub')];
|
||||
|
||||
expect(resolveResumeModes(files, 1)).toMatchObject({ readFileId: 3, readAlongFileId: 2, hasAudio: true });
|
||||
});
|
||||
|
||||
it('offers no read file for an audio-only book', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, notInArray, sql } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, notInArray, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import type {
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
bookMetadata,
|
||||
books,
|
||||
genres,
|
||||
libraries,
|
||||
readingProgress,
|
||||
readingAttempts,
|
||||
readingSessions,
|
||||
@@ -107,9 +108,11 @@ export class DashboardWidgetRepository {
|
||||
fileId: bookFiles.id,
|
||||
fileFormat: bookFiles.format,
|
||||
primaryFileId: books.primaryFileId,
|
||||
formatPriority: libraries.formatPriority,
|
||||
})
|
||||
.from(userBookStatus)
|
||||
.innerJoin(books, eq(books.id, userBookStatus.bookId))
|
||||
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.leftJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
|
||||
.leftJoin(readingProgress, and(isNotNull(bookFiles.id), eq(readingProgress.bookFileId, bookFiles.id), eq(readingProgress.userId, userId)))
|
||||
@@ -148,7 +151,8 @@ export class DashboardWidgetRepository {
|
||||
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.where(inArray(bookFiles.bookId, bookIds)),
|
||||
.where(and(inArray(bookFiles.bookId, bookIds), eq(bookFiles.role, 'content')))
|
||||
.orderBy(asc(bookFiles.bookId), asc(bookFiles.sortOrder), asc(bookFiles.id)),
|
||||
]);
|
||||
|
||||
const authorsByBookId = new Map<number, string[]>();
|
||||
@@ -173,7 +177,7 @@ export class DashboardWidgetRepository {
|
||||
hasCover: row.coverSource != null,
|
||||
fileId: row.fileId ?? null,
|
||||
fileFormat: row.fileFormat ?? null,
|
||||
...resolveResumeModes(filesByBookId.get(row.bookId) ?? [], row.primaryFileId),
|
||||
...resolveResumeModes(filesByBookId.get(row.bookId) ?? [], row.primaryFileId, row.formatPriority as string[] | null),
|
||||
}));
|
||||
|
||||
return { books: result };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...clauses: unknown[]) => ({ op: 'and', clauses })),
|
||||
asc: vi.fn((column: unknown) => ({ op: 'asc', column })),
|
||||
eq: vi.fn((left: unknown, right: unknown) => ({ op: 'eq', left, right })),
|
||||
sql: vi.fn((parts: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', parts, values })),
|
||||
}));
|
||||
@@ -65,15 +66,27 @@ describe('EmailBookReadRepository', () => {
|
||||
expect(and).toHaveBeenCalledWith({ op: 'eq', left: bookFiles.id, right: 5 }, { op: 'eq', left: bookFiles.bookId, right: 3 });
|
||||
});
|
||||
|
||||
it('findFilesByBookId returns all file rows for the book', async () => {
|
||||
it('findFilesByBookId returns all file rows for the book in stored order', async () => {
|
||||
const chain = selectChain();
|
||||
chain.where.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
||||
chain.orderBy.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
||||
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||
const repo = new EmailBookReadRepository(db as never);
|
||||
|
||||
await expect(repo.findFilesByBookId(8)).resolves.toEqual([{ id: 1 }, { id: 2 }]);
|
||||
expect(chain.from).toHaveBeenCalledWith(bookFiles);
|
||||
expect(eq).toHaveBeenCalledWith(bookFiles.bookId, 8);
|
||||
expect(chain.orderBy).toHaveBeenCalledWith({ op: 'asc', column: bookFiles.sortOrder }, { op: 'asc', column: bookFiles.id });
|
||||
});
|
||||
|
||||
it('findLibraryFormatPriority returns the book library priority or null', async () => {
|
||||
const chain = selectChain();
|
||||
chain.limit.mockResolvedValueOnce([{ formatPriority: ['m4b', 'epub'] }]).mockResolvedValueOnce([]);
|
||||
const db = { select: vi.fn().mockReturnValue(chain) };
|
||||
const repo = new EmailBookReadRepository(db as never);
|
||||
|
||||
await expect(repo.findLibraryFormatPriority(3)).resolves.toEqual(['m4b', 'epub']);
|
||||
await expect(repo.findLibraryFormatPriority(4)).resolves.toBeNull();
|
||||
expect(eq).toHaveBeenCalledWith(books.id, 3);
|
||||
});
|
||||
|
||||
it('findMetadataByBookId returns metadata row or null', async () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, bookTags, books, tags } from '../../db/schema';
|
||||
import { authors, bookAuthors, bookFiles, bookMetadata, bookTags, books, libraries, tags } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -32,7 +32,17 @@ export class EmailBookReadRepository {
|
||||
}
|
||||
|
||||
findFilesByBookId(bookId: number): Promise<(typeof bookFiles.$inferSelect)[]> {
|
||||
return this.db.select().from(bookFiles).where(eq(bookFiles.bookId, bookId));
|
||||
return this.db.select().from(bookFiles).where(eq(bookFiles.bookId, bookId)).orderBy(asc(bookFiles.sortOrder), asc(bookFiles.id));
|
||||
}
|
||||
|
||||
async findLibraryFormatPriority(bookId: number): Promise<string[] | null> {
|
||||
const [row] = await this.db
|
||||
.select({ formatPriority: libraries.formatPriority })
|
||||
.from(books)
|
||||
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.where(eq(books.id, bookId))
|
||||
.limit(1);
|
||||
return (row?.formatPriority as string[] | null | undefined) ?? null;
|
||||
}
|
||||
|
||||
async findMetadataByBookId(bookId: number): Promise<typeof bookMetadata.$inferSelect | null> {
|
||||
|
||||
@@ -9,56 +9,73 @@ describe('EmailFileSelector', () => {
|
||||
findFileForBook: ReturnType<typeof vi.fn>;
|
||||
findFilesByBookId: ReturnType<typeof vi.fn>;
|
||||
findBookPrimaryFileId: ReturnType<typeof vi.fn>;
|
||||
findLibraryFormatPriority: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const mockFile = { id: 100, bookId: 1, format: 'EPUB', role: 'primary' };
|
||||
const mockFile2 = { id: 101, bookId: 1, format: 'PDF', role: 'secondary' };
|
||||
const epub = { id: 100, bookId: 1, format: 'epub', role: 'content', sizeBytes: 10, mediaOverlayAvailable: false };
|
||||
const pdf = { id: 101, bookId: 1, format: 'pdf', role: 'content', sizeBytes: 10, mediaOverlayAvailable: false };
|
||||
const readAlong = { id: 102, bookId: 1, format: 'epub', role: 'content', sizeBytes: 10, mediaOverlayAvailable: true };
|
||||
const audiobook = { id: 103, bookId: 1, format: 'm4b', role: 'content', sizeBytes: 10, mediaOverlayAvailable: false };
|
||||
const cover = { id: 104, bookId: 1, format: 'jpg', role: 'cover', sizeBytes: 10, mediaOverlayAvailable: false };
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
findFileForBook: vi.fn(),
|
||||
findFilesByBookId: vi.fn(),
|
||||
findBookPrimaryFileId: vi.fn(),
|
||||
findBookPrimaryFileId: vi.fn().mockResolvedValue(null),
|
||||
findLibraryFormatPriority: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
selector = new EmailFileSelector(repo as unknown as EmailBookReadRepository);
|
||||
});
|
||||
|
||||
it('should select by fileId if provided', async () => {
|
||||
repo.findFileForBook.mockResolvedValue(mockFile);
|
||||
const result = await selector.select(1, 100, null);
|
||||
expect(result).toEqual(mockFile);
|
||||
it('selects by fileId if provided', async () => {
|
||||
repo.findFileForBook.mockResolvedValue(epub);
|
||||
await expect(selector.select(1, 100, null)).resolves.toEqual(epub);
|
||||
expect(repo.findFileForBook).toHaveBeenCalledWith(1, 100);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if specific fileId not found', async () => {
|
||||
it('rejects a fileId that is missing or not a sendable edition', async () => {
|
||||
repo.findFileForBook.mockResolvedValue(null);
|
||||
await expect(selector.select(1, 999, null)).rejects.toThrow(NotFoundException);
|
||||
repo.findFileForBook.mockResolvedValue(cover);
|
||||
await expect(selector.select(1, 104, null)).rejects.toThrow(NotFoundException);
|
||||
repo.findFileForBook.mockResolvedValue(audiobook);
|
||||
await expect(selector.select(1, 103, null)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should select preferred format if available', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
|
||||
const result = await selector.select(1, null, 'pdf');
|
||||
expect(result).toEqual(mockFile2);
|
||||
it('selects the preferred format when the book has it', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([epub, pdf]);
|
||||
await expect(selector.select(1, null, 'PDF')).resolves.toEqual(pdf);
|
||||
});
|
||||
|
||||
it('should fallback to primary if preferred format not found', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
|
||||
repo.findBookPrimaryFileId.mockResolvedValue(100);
|
||||
const result = await selector.select(1, null, 'mobi');
|
||||
expect(result).toEqual(mockFile);
|
||||
it('prefers the plain EPUB over a read-along copy for an EPUB recipient', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([readAlong, epub]);
|
||||
repo.findBookPrimaryFileId.mockResolvedValue(102);
|
||||
await expect(selector.select(1, null, 'epub')).resolves.toEqual(epub);
|
||||
});
|
||||
|
||||
it('should fallback to first file when primary file is missing', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([mockFile, mockFile2]);
|
||||
repo.findBookPrimaryFileId.mockResolvedValue(404);
|
||||
|
||||
const result = await selector.select(1, null, 'mobi');
|
||||
|
||||
expect(result).toEqual(mockFile);
|
||||
it('falls back to the primary when the preferred format is absent', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([epub, pdf]);
|
||||
repo.findBookPrimaryFileId.mockResolvedValue(101);
|
||||
await expect(selector.select(1, null, 'mobi')).resolves.toEqual(pdf);
|
||||
});
|
||||
|
||||
it('should throw if no files found for book', async () => {
|
||||
it('never sends an audiobook or a cover, even when the audiobook is primary', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([cover, audiobook, epub]);
|
||||
repo.findBookPrimaryFileId.mockResolvedValue(103);
|
||||
await expect(selector.select(1, null, null)).resolves.toEqual(epub);
|
||||
});
|
||||
|
||||
it('ranks the fallback by the library format priority', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([epub, pdf]);
|
||||
repo.findLibraryFormatPriority.mockResolvedValue(['pdf', 'epub']);
|
||||
await expect(selector.select(1, null, null)).resolves.toEqual(pdf);
|
||||
});
|
||||
|
||||
it('throws when the book has no files or nothing sendable', async () => {
|
||||
repo.findFilesByBookId.mockResolvedValue([]);
|
||||
await expect(selector.select(1, null, null)).rejects.toThrow(NotFoundException);
|
||||
repo.findFilesByBookId.mockResolvedValue([audiobook, cover]);
|
||||
await expect(selector.select(1, null, null)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { isAudioFormat, isContentBookFile } from '@bookorbit/types';
|
||||
|
||||
import type { BookFile } from '../../db/schema';
|
||||
import { rankFilesByFormatPriority } from '../../common/utils/primary-file-selection.utils';
|
||||
import { EmailBookReadRepository } from './email-book-read.repository';
|
||||
|
||||
/** A file an email can carry: a readable edition, never a cover, sidecar or audiobook track. */
|
||||
function isSendable(file: BookFile): boolean {
|
||||
return isContentBookFile(file) && !isAudioFormat(file.format!);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailFileSelector {
|
||||
constructor(private readonly bookReadRepository: EmailBookReadRepository) {}
|
||||
@@ -10,20 +17,28 @@ export class EmailFileSelector {
|
||||
async select(bookId: number, fileId: number | null | undefined, preferredFormat: string | null | undefined): Promise<BookFile> {
|
||||
if (fileId !== null && fileId !== undefined) {
|
||||
const file = await this.bookReadRepository.findFileForBook(bookId, fileId);
|
||||
if (!file) throw new NotFoundException('Book file not found');
|
||||
if (!file || !isSendable(file)) throw new NotFoundException('Book file not found');
|
||||
return file;
|
||||
}
|
||||
|
||||
const allFiles = await this.bookReadRepository.findFilesByBookId(bookId);
|
||||
if (allFiles.length === 0) throw new NotFoundException('No files found for this book');
|
||||
|
||||
const [primaryFileId, formatPriority] = await Promise.all([
|
||||
this.bookReadRepository.findBookPrimaryFileId(bookId),
|
||||
this.bookReadRepository.findLibraryFormatPriority(bookId),
|
||||
]);
|
||||
const sendable = rankFilesByFormatPriority(allFiles.filter(isSendable), formatPriority, primaryFileId);
|
||||
if (sendable.length === 0) throw new NotFoundException('This book has no file that can be sent by email');
|
||||
|
||||
if (preferredFormat) {
|
||||
const match = allFiles.find((f) => f.format?.toLowerCase() === preferredFormat.toLowerCase());
|
||||
const wanted = preferredFormat.toLowerCase();
|
||||
const matches = sendable.filter((file) => file.format?.toLowerCase() === wanted);
|
||||
// An e-reader gets the plain EPUB when a book has one beside its read-along copy.
|
||||
const match = matches.find((file) => !file.mediaOverlayAvailable) ?? matches[0];
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
const primaryFileId = await this.bookReadRepository.findBookPrimaryFileId(bookId);
|
||||
const primary = allFiles.find((f) => f.id === primaryFileId);
|
||||
return primary ?? allFiles[0];
|
||||
return sendable[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { createReadStream } from 'fs';
|
||||
import { basename } from 'path';
|
||||
import { mkdtemp, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { basename, join } from 'path';
|
||||
|
||||
import { NotificationType } from '@bookorbit/types';
|
||||
import type { BookFile } from '../../db/schema';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import { AudiolessEpubService } from '../book/audioless-epub.service';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { EmailBookAccessService } from './email-book-access.service';
|
||||
import { EmailFileSelector } from './email-file-selector';
|
||||
import { EmailPreferencesService } from './email-preferences.service';
|
||||
@@ -54,6 +58,7 @@ export class EmailSendOrchestrator {
|
||||
private readonly sendLogService: EmailSendLogService,
|
||||
private readonly transportService: EmailTransportService,
|
||||
private readonly notificationService: NotificationService,
|
||||
@Optional() private readonly audiolessEpubService?: AudiolessEpubService,
|
||||
) {}
|
||||
|
||||
async send(dto: SendBookDto, user: RequestUser): Promise<{ queued: number }> {
|
||||
@@ -248,6 +253,7 @@ export class EmailSendOrchestrator {
|
||||
bodyText: string,
|
||||
attemptCount: number,
|
||||
) {
|
||||
const attachment = await this.prepareAttachment(file, task);
|
||||
try {
|
||||
const transporter = this.transportService.buildTransporter(smtpConfig);
|
||||
const effectiveSubject = task.deviceType === 'kindle' ? KINDLE_CONVERT_SUBJECT : subject;
|
||||
@@ -261,7 +267,7 @@ export class EmailSendOrchestrator {
|
||||
attachments: [
|
||||
{
|
||||
filename: this.buildAttachmentFilename(file),
|
||||
content: createReadStream(file.absolutePath),
|
||||
content: createReadStream(attachment.path),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -308,6 +314,32 @@ export class EmailSendOrchestrator {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
await attachment.cleanup().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-along EPUB is mailed without its narration audio, as Kobo and KOReader receive it: the
|
||||
* audio is most of its size and an e-reader cannot play it. A failed rebuild sends the original.
|
||||
*/
|
||||
private async prepareAttachment(file: BookFile, task: SendTask): Promise<{ path: string; cleanup: () => Promise<void> }> {
|
||||
const original = { path: file.absolutePath, cleanup: () => Promise.resolve() };
|
||||
if (!this.audiolessEpubService || file.format?.toLowerCase() !== 'epub' || !file.mediaOverlayAvailable) return original;
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'bookorbit-email-epub-'));
|
||||
const cleanup = () => rm(tempDir, { recursive: true, force: true });
|
||||
try {
|
||||
const path = join(tempDir, 'attachment.epub');
|
||||
await this.audiolessEpubService.writeArchive(file.absolutePath, path);
|
||||
return { path, cleanup };
|
||||
} catch (error) {
|
||||
await cleanup().catch(() => undefined);
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.warn(
|
||||
`[${EMAIL_DISPATCH_EVENT}] [fail] bookId=${task.bookId} fileId=${file.id} errorClass=${err.constructor.name} error="${sanitizeLogValue(err.message)}" - audioless rebuild failed, sending original EPUB`,
|
||||
);
|
||||
return original;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1406,6 +1406,28 @@ describe('KoboSyncService', () => {
|
||||
};
|
||||
}
|
||||
|
||||
it('announces a read-along EPUB by its size without audio, as the download converts it', async () => {
|
||||
const rows = comicRows(false);
|
||||
for (const batch of rows.select.slice(0, 2)) {
|
||||
for (const row of batch as Array<Record<string, unknown>>) {
|
||||
row.fileSizeBytes = 900 * 1024 * 1024;
|
||||
row.fileMediaOverlayAvailable = true;
|
||||
}
|
||||
}
|
||||
const service = makeService(makeDb(rows));
|
||||
vi.spyOn(service as any, 'buildEligibleBooksWhereClause').mockResolvedValue({ where: true });
|
||||
vi.spyOn(service as any, 'getDeliverySettings').mockResolvedValue({
|
||||
convertToKepub: true,
|
||||
forceEnableHyphenation: false,
|
||||
kepubConversionLimitMb: 100,
|
||||
});
|
||||
|
||||
await (service as any).fetchEligibleSnapshotRows(8, false, new Map());
|
||||
const books = await (service as any).fetchEligibleBooksByIds(8, [5], false, new Map());
|
||||
|
||||
expect(books.get(5)?.deliveryFormat).toBe('KEPUB');
|
||||
});
|
||||
|
||||
it('announces a stored fixed-layout book as EPUB3FL in the payload the device receives', async () => {
|
||||
const db = makeDb(comicRows(true));
|
||||
const service = makeService(db);
|
||||
|
||||
@@ -116,6 +116,15 @@ export interface KoboBookEntry {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The size the kepub limit is judged by. A read-along is delivered with its narration stripped, which
|
||||
* the download measures after the rebuild; announcing by the size with audio would pick EPUB3 for a
|
||||
* book the download then streams as a kepub.
|
||||
*/
|
||||
function deliverySizeBytes(row: { fileSizeBytes: number | null; fileMediaOverlayAvailable: boolean | null }): number | null {
|
||||
return row.fileMediaOverlayAvailable ? null : row.fileSizeBytes;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KoboSyncService {
|
||||
private readonly logger = new Logger(KoboSyncService.name);
|
||||
@@ -1111,6 +1120,7 @@ export class KoboSyncService {
|
||||
coverUpdatedAt: schema.bookMetadata.coverUpdatedAt,
|
||||
fileFormat: schema.bookFiles.format,
|
||||
fileSizeBytes: schema.bookFiles.sizeBytes,
|
||||
fileMediaOverlayAvailable: schema.bookFiles.mediaOverlayAvailable,
|
||||
fileHash: schema.bookFiles.fileHash,
|
||||
fileId: schema.bookFiles.id,
|
||||
fileAbsolutePath: schema.bookFiles.absolutePath,
|
||||
@@ -1151,7 +1161,7 @@ export class KoboSyncService {
|
||||
|
||||
return rows.map((row) => {
|
||||
const isFixedLayout = row.isFixedLayout ?? fixedLayoutByFileId.get(row.fileId) ?? null;
|
||||
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, isFixedLayout);
|
||||
const delivery = this.getDeliveryInfo(row.fileFormat, deliverySizeBytes(row), deliverySettings, isFixedLayout);
|
||||
const identity = identitiesById.get(row.bookId);
|
||||
const coverImageId = identity
|
||||
? this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.coverUpdatedAt)
|
||||
@@ -1204,6 +1214,7 @@ export class KoboSyncService {
|
||||
seriesIndex: schema.bookMetadata.seriesIndex,
|
||||
fileFormat: schema.bookFiles.format,
|
||||
fileSizeBytes: schema.bookFiles.sizeBytes,
|
||||
fileMediaOverlayAvailable: schema.bookFiles.mediaOverlayAvailable,
|
||||
fileHash: schema.bookFiles.fileHash,
|
||||
isFixedLayout: schema.bookFiles.isFixedLayout,
|
||||
metadataUpdatedAt: schema.bookMetadata.updatedAt,
|
||||
@@ -1258,7 +1269,7 @@ export class KoboSyncService {
|
||||
const byId = new Map<number, KoboBookEntry>();
|
||||
for (const row of rows) {
|
||||
const authors = authorsByBook.get(row.bookId) ?? [];
|
||||
const delivery = this.getDeliveryInfo(row.fileFormat, row.fileSizeBytes, deliverySettings, row.isFixedLayout);
|
||||
const delivery = this.getDeliveryInfo(row.fileFormat, deliverySizeBytes(row), deliverySettings, row.isFixedLayout);
|
||||
const identity = identitiesById.get(row.bookId);
|
||||
if (!identity) continue;
|
||||
const coverImageId = this.bookIdentityService.buildVersionedCoverImageId(identity.coverImageId, row.coverUpdatedAt);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { bookMetadata, books, libraries } from '../../db/schema';
|
||||
import { formatKeyRank, normalizeFormatPriority } from '@bookorbit/types';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -18,7 +19,7 @@ const authorsSubquery = sql<string[]>`COALESCE((
|
||||
const formatsSubquery = sql<string[]>`COALESCE((
|
||||
SELECT array_agg(DISTINCT file.format)
|
||||
FROM book_files file
|
||||
WHERE file.book_id = ${books.id} AND file.format IS NOT NULL
|
||||
WHERE file.book_id = ${books.id} AND file.format IS NOT NULL AND file.role = 'content'
|
||||
), ARRAY[]::varchar[])`;
|
||||
|
||||
@Injectable()
|
||||
@@ -34,9 +35,10 @@ export class MissingResourcesRepository {
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
/** Each book's formats are its editions, in its library's format priority. */
|
||||
async findMissingBooks(libraryIds: number[], offset: number, limit: number) {
|
||||
if (libraryIds.length === 0) return [];
|
||||
return this.db
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: books.id,
|
||||
title: bookMetadata.title,
|
||||
@@ -45,6 +47,7 @@ export class MissingResourcesRepository {
|
||||
libraryName: libraries.name,
|
||||
folderPath: books.folderPath,
|
||||
formats: formatsSubquery,
|
||||
formatPriority: libraries.formatPriority,
|
||||
updatedAt: books.updatedAt,
|
||||
})
|
||||
.from(books)
|
||||
@@ -54,6 +57,13 @@ export class MissingResourcesRepository {
|
||||
.orderBy(asc(books.libraryId), asc(books.id))
|
||||
.offset(offset)
|
||||
.limit(limit);
|
||||
return rows.map(({ formatPriority, ...row }) => {
|
||||
const priority = normalizeFormatPriority(formatPriority as string[] | null);
|
||||
return {
|
||||
...row,
|
||||
formats: [...row.formats].sort((a, b) => formatKeyRank(a.toLowerCase(), priority) - formatKeyRank(b.toLowerCase(), priority)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async findMissingBookIds(libraryIds: number[], afterId: number, limit: number): Promise<number[]> {
|
||||
|
||||
@@ -259,11 +259,43 @@ describe('OpdsBookService', () => {
|
||||
await expect(service.getBookFiles(7)).resolves.toEqual({
|
||||
absolutePath: '/books/a.epub',
|
||||
format: 'unknown',
|
||||
readAlong: false,
|
||||
title: 'book-7',
|
||||
authorName: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a readable edition by default when the primary is an audiobook', async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
absolutePath: '/books/a.m4b',
|
||||
format: 'm4b',
|
||||
role: 'content',
|
||||
sizeBytes: 9,
|
||||
mediaOverlayAvailable: false,
|
||||
title: 'A',
|
||||
primaryFileId: 1,
|
||||
formatPriority: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
absolutePath: '/books/a.epub',
|
||||
format: 'epub',
|
||||
role: 'content',
|
||||
sizeBytes: 9,
|
||||
mediaOverlayAvailable: true,
|
||||
title: 'A',
|
||||
primaryFileId: 1,
|
||||
formatPriority: null,
|
||||
},
|
||||
];
|
||||
const { service } = makeService([rows, [{ name: 'Author' }], rows, [{ name: 'Author' }]]);
|
||||
|
||||
await expect(service.getBookFiles(7)).resolves.toMatchObject({ absolutePath: '/books/a.epub', format: 'epub', readAlong: true });
|
||||
await expect(service.getBookFiles(7, 1)).resolves.toMatchObject({ absolutePath: '/books/a.m4b', readAlong: false });
|
||||
});
|
||||
|
||||
it('applies text search inside smartScope when q is provided', async () => {
|
||||
const { service } = makeService([[{ id: 3, userId: 7, mediaType: 'books', isPublic: false, filter: null }]]);
|
||||
const privateService = testable(service);
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
userLibraryAccess,
|
||||
} from '../../db/schema';
|
||||
import { BookQueryBuilder } from '../book/book-query-builder.service';
|
||||
import type { ContentFilterRules, GroupRule } from '@bookorbit/types';
|
||||
import { isAudioFormat, type ContentFilterRules, type GroupRule } from '@bookorbit/types';
|
||||
import { rankFileRowsByBook, rankFilesByFormatPriority } from '../../common/utils/primary-file-selection.utils';
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
import { seriesIndexOrderBy } from '../../common/utils/series-index-sql.utils';
|
||||
|
||||
@@ -133,6 +134,20 @@ export interface OpdsManifestBookRow {
|
||||
files: OpdsManifestFileRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-along EPUB downloads over OPDS without its audio, so beside a plain EPUB of the same book
|
||||
* it would be a second link to the same text. The plain one stands for both.
|
||||
*/
|
||||
function isRedundantReadAlong(
|
||||
row: { bookId: number; id: number; format: string | null; mediaOverlayAvailable: boolean },
|
||||
rows: readonly { bookId: number; id: number; format: string | null; mediaOverlayAvailable: boolean }[],
|
||||
): boolean {
|
||||
if (row.format?.toLowerCase() !== 'epub' || !row.mediaOverlayAvailable) return false;
|
||||
return rows.some(
|
||||
(other) => other.bookId === row.bookId && other.id !== row.id && other.format?.toLowerCase() === 'epub' && !other.mediaOverlayAvailable,
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OpdsBookService {
|
||||
constructor(
|
||||
@@ -655,20 +670,41 @@ export class OpdsBookService {
|
||||
}
|
||||
}
|
||||
|
||||
async getBookFiles(bookId: number, fileId?: number): Promise<{ absolutePath: string; format: string; title: string; authorName: string } | null> {
|
||||
const fileQuery = this.db
|
||||
/**
|
||||
* The file a download serves: the one asked for when it is a content file, otherwise the edition an
|
||||
* OPDS reader can open, the primary unless that is an audiobook with a readable edition beside it.
|
||||
*/
|
||||
async getBookFiles(
|
||||
bookId: number,
|
||||
fileId?: number,
|
||||
): Promise<{ absolutePath: string; format: string; readAlong: boolean; title: string; authorName: string } | null> {
|
||||
const candidates = await this.db
|
||||
.select({
|
||||
id: bookFiles.id,
|
||||
absolutePath: bookFiles.absolutePath,
|
||||
format: bookFiles.format,
|
||||
role: bookFiles.role,
|
||||
sizeBytes: bookFiles.sizeBytes,
|
||||
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
|
||||
title: bookMetadata.title,
|
||||
primaryFileId: books.primaryFileId,
|
||||
formatPriority: libraries.formatPriority,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.leftJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.leftJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
|
||||
.where(fileId ? and(eq(bookFiles.id, fileId), eq(bookFiles.bookId, bookId)) : and(eq(books.id, bookId), eq(bookFiles.id, books.primaryFileId)))
|
||||
.limit(1);
|
||||
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.role, 'content')))
|
||||
.orderBy(bookFiles.sortOrder, bookFiles.id);
|
||||
|
||||
const [file] = await fileQuery;
|
||||
const first = candidates[0];
|
||||
if (!first) return null;
|
||||
const file = fileId
|
||||
? candidates.find((candidate) => candidate.id === fileId)
|
||||
: (() => {
|
||||
const ranked = rankFilesByFormatPriority(candidates, first.formatPriority as string[] | null, first.primaryFileId);
|
||||
return ranked.find((candidate) => candidate.format != null && !isAudioFormat(candidate.format)) ?? ranked[0];
|
||||
})();
|
||||
if (!file) return null;
|
||||
|
||||
const [authorRow] = await this.db
|
||||
@@ -682,6 +718,7 @@ export class OpdsBookService {
|
||||
return {
|
||||
absolutePath: file.absolutePath,
|
||||
format: file.format ?? 'unknown',
|
||||
readAlong: file.format?.toLowerCase() === 'epub' && file.mediaOverlayAvailable,
|
||||
title: file.title ?? `book-${bookId}`,
|
||||
authorName: authorRow?.name ?? '',
|
||||
};
|
||||
@@ -817,9 +854,12 @@ export class OpdsBookService {
|
||||
publisher: bookMetadata.publisher,
|
||||
isbn13: bookMetadata.isbn13,
|
||||
coverSource: bookMetadata.coverSource,
|
||||
primaryFileId: books.primaryFileId,
|
||||
formatPriority: libraries.formatPriority,
|
||||
})
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.leftJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.where(inArray(books.id, bookIds)),
|
||||
this.db
|
||||
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
||||
@@ -828,7 +868,14 @@ export class OpdsBookService {
|
||||
.where(inArray(bookAuthors.bookId, bookIds))
|
||||
.orderBy(bookAuthors.displayOrder),
|
||||
this.db
|
||||
.select({ bookId: books.id, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role })
|
||||
.select({
|
||||
bookId: books.id,
|
||||
id: bookFiles.id,
|
||||
format: bookFiles.format,
|
||||
role: bookFiles.role,
|
||||
sizeBytes: bookFiles.sizeBytes,
|
||||
mediaOverlayAvailable: bookFiles.mediaOverlayAvailable,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.where(and(inArray(bookFiles.bookId, bookIds), eq(bookFiles.role, 'content')))
|
||||
@@ -843,9 +890,13 @@ export class OpdsBookService {
|
||||
authorsByBook.set(row.bookId, list);
|
||||
}
|
||||
|
||||
const rankedFileRows = rankFileRowsByBook(
|
||||
fileRows,
|
||||
new Map(metaRows.map((row) => [row.id, { formatPriority: row.formatPriority as string[] | null, primaryFileId: row.primaryFileId }])),
|
||||
);
|
||||
const filesByBook = new Map<number, { id: number; format: string }[]>();
|
||||
for (const row of fileRows) {
|
||||
if (row.role !== 'content') continue;
|
||||
for (const row of rankedFileRows) {
|
||||
if (row.role !== 'content' || isRedundantReadAlong(row, rankedFileRows)) continue;
|
||||
const list = filesByBook.get(row.bookId) ?? [];
|
||||
list.push({ id: row.id, format: row.format ?? 'unknown' });
|
||||
filesByBook.set(row.bookId, list);
|
||||
|
||||
@@ -35,11 +35,14 @@ export function fileMimeType(format: string): string {
|
||||
switch (format.toLowerCase()) {
|
||||
case 'epub':
|
||||
return 'application/epub+zip';
|
||||
case 'kepub':
|
||||
return 'application/kepub+zip';
|
||||
case 'pdf':
|
||||
return 'application/pdf';
|
||||
case 'mobi':
|
||||
return 'application/x-mobipocket-ebook';
|
||||
case 'azw3':
|
||||
case 'azw':
|
||||
return 'application/vnd.amazon.ebook';
|
||||
case 'fb2':
|
||||
return 'application/x-fictionbook+xml';
|
||||
@@ -47,6 +50,18 @@ export function fileMimeType(format: string): string {
|
||||
return 'application/vnd.comicbook+zip';
|
||||
case 'cbr':
|
||||
return 'application/vnd.comicbook-rar';
|
||||
case 'cb7':
|
||||
return 'application/x-cb7';
|
||||
case 'm4b':
|
||||
case 'm4a':
|
||||
return 'audio/mp4';
|
||||
case 'mp3':
|
||||
return 'audio/mpeg';
|
||||
case 'ogg':
|
||||
case 'opus':
|
||||
return 'audio/ogg';
|
||||
case 'flac':
|
||||
return 'audio/flac';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
Headers,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
@@ -12,7 +14,9 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { createReadStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import { mkdtemp, rm, stat } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
import { MAX_OFFSET_ROWS, isOffsetWithinLimit } from '../../common/constants/pagination.constants';
|
||||
@@ -26,7 +30,9 @@ import { OpdsEnabledGuard } from './opds-enabled.guard';
|
||||
import { OpdsUser } from './opds-user.decorator';
|
||||
import { OpdsBookService } from './opds-book.service';
|
||||
import { OpdsService } from './opds.service';
|
||||
import { AudiolessEpubService } from '../book/audioless-epub.service';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
|
||||
|
||||
@Controller('opds')
|
||||
@@ -38,8 +44,11 @@ export class OpdsController {
|
||||
private readonly opdsBookService: OpdsBookService,
|
||||
private readonly bookService: BookService,
|
||||
private readonly coverStore: BookCoverStore,
|
||||
@Optional() private readonly audiolessEpubService?: AudiolessEpubService,
|
||||
) {}
|
||||
|
||||
private readonly logger = new Logger(OpdsController.name);
|
||||
|
||||
private assertPaginationWindow(page: number, size: number): void {
|
||||
if (!isOffsetWithinLimit((page - 1) * size)) {
|
||||
throw new BadRequestException(`pagination window is too deep; (page - 1) * size must be <= ${MAX_OFFSET_ROWS}`);
|
||||
@@ -282,19 +291,49 @@ export class OpdsController {
|
||||
if (!bookFiles) throw new NotFoundException('File not found');
|
||||
|
||||
const { absolutePath, format } = bookFiles;
|
||||
const { size: fileSize } = await stat(absolutePath);
|
||||
const mime = fileMimeType(format);
|
||||
const served = bookFiles.readAlong ? await this.audiolessCopy(absolutePath, bookId, fileId) : null;
|
||||
const servedPath = served?.path ?? absolutePath;
|
||||
try {
|
||||
const { size: fileSize } = await stat(servedPath);
|
||||
const filename = await this.bookService.resolveDownloadFilename({
|
||||
bookId,
|
||||
absolutePath,
|
||||
format: format === 'unknown' ? null : 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(fileMimeType(format));
|
||||
const stream = createReadStream(servedPath);
|
||||
// The rebuilt copy is only safe to delete once the response has finished reading it.
|
||||
if (served) stream.once('close', () => void served.cleanup().catch(() => undefined));
|
||||
reply.send(stream);
|
||||
} catch (error) {
|
||||
await served?.cleanup().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
reply.header('Content-Disposition', contentDispositionHeader('attachment', filename, 'download'));
|
||||
reply.header('Content-Length', fileSize);
|
||||
reply.type(mime);
|
||||
reply.send(createReadStream(absolutePath));
|
||||
/**
|
||||
* A read-along EPUB reaches an OPDS reader without its narration audio, as it reaches Kobo and
|
||||
* KOReader: the audio is most of its size and a reader cannot play it. A failed rebuild serves the original.
|
||||
*/
|
||||
private async audiolessCopy(absolutePath: string, bookId: number, fileId: number): Promise<{ path: string; cleanup: () => Promise<void> } | null> {
|
||||
if (!this.audiolessEpubService) return null;
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'bookorbit-opds-epub-'));
|
||||
const cleanup = () => rm(tempDir, { recursive: true, force: true });
|
||||
try {
|
||||
const path = join(tempDir, 'download.epub');
|
||||
await this.audiolessEpubService.writeArchive(absolutePath, path);
|
||||
return { path, cleanup };
|
||||
} catch (error) {
|
||||
await cleanup().catch(() => undefined);
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.warn(
|
||||
`[opds.download] [fail] bookId=${bookId} fileId=${fileId} errorClass=${err.constructor.name} error="${sanitizeLogValue(err.message)}" - audioless rebuild failed, serving original EPUB`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sendXml(reply: FastifyReply, xml: string, mimeType: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, desc, eq, gte, inArray, lt, ne, or, sql } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, gte, inArray, lt, ne, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
userLibraryAccess,
|
||||
users,
|
||||
} from '../../db/schema';
|
||||
import { rankFileRowsByBook } from '../../common/utils/primary-file-selection.utils';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
type DbTransaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
||||
@@ -583,12 +584,13 @@ export class ScannerRepository {
|
||||
async findBookCardData(bookIds: number[]) {
|
||||
if (bookIds.length === 0) return { rows: [], authorRows: [], fileRows: [], genreRows: [] };
|
||||
|
||||
const [rows, authorRows, fileRows, genreRows] = await Promise.all([
|
||||
const [rows, authorRows, unrankedFileRows, genreRows] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: books.id,
|
||||
status: books.status,
|
||||
coverAspectRatio: libraries.coverAspectRatio,
|
||||
formatPriority: libraries.formatPriority,
|
||||
primaryFileId: books.primaryFileId,
|
||||
folderPath: books.folderPath,
|
||||
addedAt: books.addedAt,
|
||||
@@ -630,7 +632,8 @@ export class ScannerRepository {
|
||||
mediaOverlayCheckedAt: bookFiles.mediaOverlayCheckedAt,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.where(inArray(bookFiles.bookId, bookIds)),
|
||||
.where(inArray(bookFiles.bookId, bookIds))
|
||||
.orderBy(asc(bookFiles.bookId), asc(bookFiles.sortOrder), asc(bookFiles.id)),
|
||||
this.db
|
||||
.select({ bookId: bookGenres.bookId, name: genres.name })
|
||||
.from(bookGenres)
|
||||
@@ -638,6 +641,10 @@ export class ScannerRepository {
|
||||
.where(inArray(bookGenres.bookId, bookIds)),
|
||||
]);
|
||||
|
||||
const fileRows = rankFileRowsByBook(
|
||||
unrankedFileRows,
|
||||
new Map(rows.map((row) => [row.id, { formatPriority: row.formatPriority as string[] | null, primaryFileId: row.primaryFileId }])),
|
||||
);
|
||||
return { rows, authorRows, fileRows, genreRows };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user