mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(series): resolve missing series in koreader catalog by id
The KOReader plugin previously matched series by name, which caused issues for series with identical names or special characters. This updates the plugin and backend to use the exact series ID for navigation and filtering, ensuring all series load reliably. Also bumps the KOReader plugin version to 1.0.1. Closes #466
This commit is contained in:
@@ -872,7 +872,11 @@ function BookOrbitCatalog:paramsForEntry(section, entry)
|
||||
elseif section == "authors" then
|
||||
params.author = entry.id
|
||||
elseif section == "series" then
|
||||
params.series = entry.id
|
||||
if entry.seriesId then
|
||||
params.seriesId = tonumber(entry.seriesId)
|
||||
else
|
||||
params.series = entry.id
|
||||
end
|
||||
params.sort = "series"
|
||||
end
|
||||
return params
|
||||
|
||||
@@ -38,7 +38,7 @@ local BookOrbitMenuPin = require("bookorbit_menu_pin")
|
||||
local BookOrbitSweep = require("bookorbit_sweep")
|
||||
local BookOrbitUpdater = require("bookorbit_updater")
|
||||
|
||||
local PLUGIN_VERSION = "1.0.0"
|
||||
local PLUGIN_VERSION = "1.0.1"
|
||||
|
||||
local SYNC_STRATEGY = {
|
||||
PROMPT = 1,
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface KoreaderCatalogEntry {
|
||||
subtitle?: string | null;
|
||||
count?: number;
|
||||
icon?: string | null;
|
||||
seriesId?: number;
|
||||
href?: string;
|
||||
booksHref?: string;
|
||||
}
|
||||
@@ -140,6 +141,7 @@ export interface KoreaderCatalogBookListItem {
|
||||
id: number;
|
||||
title: string;
|
||||
authors: string[];
|
||||
seriesId: number | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
progressPercentage: number | null;
|
||||
|
||||
@@ -113,6 +113,12 @@ export class KoreaderCatalogBooksQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
series?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
seriesId?: number;
|
||||
}
|
||||
|
||||
export class KoreaderCatalogSectionQueryDto {
|
||||
|
||||
@@ -90,7 +90,7 @@ function makeService() {
|
||||
getUserCollections: vi.fn().mockResolvedValue([{ id: 2, name: 'Favorites', bookCount: 99 }]),
|
||||
getUserSmartScopes: vi.fn().mockResolvedValue([{ id: 3, name: 'Unread', icon: 'book-open' }]),
|
||||
getDistinctAuthorsPage: vi.fn().mockResolvedValue({ items: [{ name: 'Frank Herbert', bookCount: 2 }], hasNext: false }),
|
||||
getDistinctSeriesPage: vi.fn().mockResolvedValue({ items: [{ name: 'Dune', bookCount: 6 }], hasNext: false }),
|
||||
getDistinctSeriesPage: vi.fn().mockResolvedValue({ items: [{ id: 42, name: 'Dune', bookCount: 6 }], hasNext: false }),
|
||||
getBooksPage: vi.fn().mockResolvedValue({ entries: [], total: 0 }),
|
||||
getRandomBooks: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
@@ -204,6 +204,7 @@ describe('KoreaderCatalogService', () => {
|
||||
addedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-02-01T00:00:00.000Z'),
|
||||
description: null,
|
||||
seriesId: 42,
|
||||
seriesName: 'Dune',
|
||||
seriesIndex: 1,
|
||||
language: 'en',
|
||||
@@ -224,6 +225,7 @@ describe('KoreaderCatalogService', () => {
|
||||
addedAt: new Date('2026-01-05T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-02-05T00:00:00.000Z'),
|
||||
description: null,
|
||||
seriesId: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
language: 'en',
|
||||
@@ -350,7 +352,16 @@ describe('KoreaderCatalogService', () => {
|
||||
);
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'title_asc', 1, 1, { libraryId: 1 }, false, user.contentFilters);
|
||||
expect(authors.items[0]!.booksHref).toContain('author=Frank+Herbert');
|
||||
expect(series.items.map((item) => item.title)).toEqual(['Dune']);
|
||||
expect(series.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'series:42',
|
||||
title: 'Dune',
|
||||
section: 'series',
|
||||
seriesId: 42,
|
||||
count: 6,
|
||||
booksHref: '/api/v1/koreader/plugin/catalog/books?sort=series&seriesId=42',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown section names', async () => {
|
||||
@@ -371,6 +382,7 @@ describe('KoreaderCatalogService', () => {
|
||||
addedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-02-01T00:00:00.000Z'),
|
||||
description: null,
|
||||
seriesId: 42,
|
||||
seriesName: 'Dune',
|
||||
seriesIndex: 1,
|
||||
language: 'en',
|
||||
@@ -530,9 +542,21 @@ describe('KoreaderCatalogService', () => {
|
||||
.mockResolvedValueOnce({ entries: [], total: 2 });
|
||||
const user = makeUser({ id: 7 });
|
||||
|
||||
const result = await service.getBooksPage(user, Object.assign(new KoreaderCatalogBooksQueryDto(), { series: 'Dune', sort: 'series' }));
|
||||
const result = await service.getBooksPage(user, Object.assign(new KoreaderCatalogBooksQueryDto(), { seriesId: 42, sort: 'series' }));
|
||||
|
||||
expect(result.seriesSummary).toEqual({ total: 6, finished: 2 });
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenNthCalledWith(1, 7, 'series_asc', 1, 20, { seriesId: 42 }, false, user.contentFilters);
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenNthCalledWith(2, 7, 'title_asc', 1, 1, { seriesId: 42 }, false, user.contentFilters);
|
||||
expect(opdsBookService.getBooksPage).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
7,
|
||||
'title_asc',
|
||||
1,
|
||||
1,
|
||||
{ seriesId: 42, readStatus: 'finished' },
|
||||
false,
|
||||
user.contentFilters,
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the series summary for non-series listings', async () => {
|
||||
|
||||
@@ -198,7 +198,7 @@ export class KoreaderCatalogService {
|
||||
const [progressMap, statusMap, seriesSummary] = await Promise.all([
|
||||
this.findBestProgressMap(user.id, bookIds),
|
||||
this.userBookStatusService.findByBookIds(user.id, bookIds),
|
||||
query.series ? this.computeSeriesSummary(user, query.series) : Promise.resolve(null),
|
||||
filters.seriesId !== undefined || filters.series ? this.computeSeriesSummary(user, filters) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const items = entries.map((entry) => this.mapBookListItem(entry, progressMap.get(entry.id) ?? null, statusMap.get(entry.id)?.status ?? null));
|
||||
@@ -336,15 +336,20 @@ export class KoreaderCatalogService {
|
||||
user.contentFilters,
|
||||
);
|
||||
const entries: KoreaderCatalogEntry[] = items.map((row) => ({
|
||||
id: row.name,
|
||||
id: this.seriesEntryId(row.id),
|
||||
title: row.name,
|
||||
section: 'series',
|
||||
count: row.bookCount,
|
||||
booksHref: this.booksHref({ series: row.name, sort: 'series' }),
|
||||
seriesId: row.id,
|
||||
booksHref: this.booksHref({ seriesId: row.id, sort: 'series' }),
|
||||
}));
|
||||
return this.buildSectionPage('series', entries, page, hasNext, query.q);
|
||||
}
|
||||
|
||||
private seriesEntryId(seriesId: number): string {
|
||||
return `series:${seriesId}`;
|
||||
}
|
||||
|
||||
private buildSectionPage(
|
||||
section: KoreaderCatalogSection,
|
||||
items: KoreaderCatalogEntry[],
|
||||
@@ -375,14 +380,20 @@ export class KoreaderCatalogService {
|
||||
|
||||
private async countBooks(
|
||||
user: RequestUser,
|
||||
filters: { libraryId?: number; collectionId?: number; series?: string; readStatus?: 'unread' | 'reading' | 'finished' },
|
||||
filters: {
|
||||
libraryId?: number;
|
||||
collectionId?: number;
|
||||
series?: string;
|
||||
seriesId?: number;
|
||||
readStatus?: 'unread' | 'reading' | 'finished';
|
||||
},
|
||||
): Promise<number> {
|
||||
const result = await this.opdsBookService.getBooksPage(user.id, 'title_asc', 1, 1, filters, user.isSuperuser, user.contentFilters);
|
||||
return result.total;
|
||||
}
|
||||
|
||||
private async computeSeriesSummary(user: RequestUser, series: string): Promise<KoreaderCatalogSeriesSummary> {
|
||||
const [total, finished] = await Promise.all([this.countBooks(user, { series }), this.countBooks(user, { series, readStatus: 'finished' })]);
|
||||
private async computeSeriesSummary(user: RequestUser, filters: { series?: string; seriesId?: number }): Promise<KoreaderCatalogSeriesSummary> {
|
||||
const [total, finished] = await Promise.all([this.countBooks(user, filters), this.countBooks(user, { ...filters, readStatus: 'finished' })]);
|
||||
return { total, finished };
|
||||
}
|
||||
|
||||
@@ -392,6 +403,7 @@ export class KoreaderCatalogService {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
authors: entry.authors,
|
||||
seriesId: entry.seriesId ?? null,
|
||||
seriesName: entry.seriesName,
|
||||
seriesIndex: entry.seriesIndex,
|
||||
progressPercentage: progress?.percentage ?? null,
|
||||
@@ -422,6 +434,7 @@ export class KoreaderCatalogService {
|
||||
id: detail.id,
|
||||
title,
|
||||
authors: detail.authors.map((author) => author.name),
|
||||
seriesId: detail.seriesId,
|
||||
seriesName: detail.seriesName,
|
||||
seriesIndex: detail.seriesIndex,
|
||||
progressPercentage: progress?.percentage ?? null,
|
||||
@@ -506,6 +519,7 @@ export class KoreaderCatalogService {
|
||||
smartScopeId?: number;
|
||||
author?: string;
|
||||
series?: string;
|
||||
seriesId?: number;
|
||||
q?: string;
|
||||
readStatus?: 'unread' | 'reading' | 'finished';
|
||||
format?: string;
|
||||
@@ -516,6 +530,7 @@ export class KoreaderCatalogService {
|
||||
...(query.collectionId !== undefined ? { collectionId: query.collectionId } : {}),
|
||||
...(query.smartScopeId !== undefined ? { smartScopeId: query.smartScopeId } : {}),
|
||||
...(query.author ? { author: query.author } : {}),
|
||||
...(query.seriesId !== undefined ? { seriesId: query.seriesId } : {}),
|
||||
...(query.series ? { series: query.series } : {}),
|
||||
...(query.q?.trim() ? { q: query.q.trim() } : {}),
|
||||
...(query.readStatus ? { readStatus: query.readStatus } : {}),
|
||||
@@ -586,6 +601,7 @@ export class KoreaderCatalogService {
|
||||
'collectionId',
|
||||
'smartScopeId',
|
||||
'author',
|
||||
'seriesId',
|
||||
'series',
|
||||
] as const;
|
||||
for (const key of orderedKeys) {
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import { bookSeries, bookSeriesMemberships } from '../../../db/schema';
|
||||
import { OpdsBookService } from '../opds-book.service';
|
||||
|
||||
type BookPageResult = { entries: unknown[]; total: number };
|
||||
|
||||
type TestableOpdsBookService = {
|
||||
buildCatalogSearchClause(q: string): unknown;
|
||||
buildReadStatusClause(userId: number, status: 'unread' | 'reading' | 'finished'): unknown;
|
||||
fetchBookEntries(bookIds: number[], options?: unknown): Promise<unknown[]>;
|
||||
getBooksBySmartScope(
|
||||
userId: number,
|
||||
smartScopeId: number,
|
||||
accessibleIds: number[],
|
||||
sortOrder: string,
|
||||
page: number,
|
||||
size: number,
|
||||
contentFilters?: unknown,
|
||||
q?: string,
|
||||
): Promise<BookPageResult>;
|
||||
paginatedBookQuery(where: unknown, sortOrder: string, page: number, size: number, userId?: number, options?: unknown): Promise<BookPageResult>;
|
||||
};
|
||||
|
||||
function testable(service: OpdsBookService): TestableOpdsBookService {
|
||||
return service as unknown as TestableOpdsBookService;
|
||||
}
|
||||
|
||||
function makeChain(result: unknown, fields?: Record<string, unknown>) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(fields ?? {})) {
|
||||
@@ -64,8 +88,9 @@ describe('OpdsBookService', () => {
|
||||
it('handles getBooksPage access checks and smartScope delegation', async () => {
|
||||
const { service } = makeService([[{ userId: 999 }], [{ userId: 7 }]]);
|
||||
const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds');
|
||||
const smartScopeSpy = vi.spyOn(service as never, 'getBooksBySmartScope');
|
||||
const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery');
|
||||
const privateService = testable(service);
|
||||
const smartScopeSpy = vi.spyOn(privateService, 'getBooksBySmartScope');
|
||||
const paginatedSpy = vi.spyOn(privateService, 'paginatedBookQuery');
|
||||
|
||||
accessSpy.mockResolvedValueOnce([]);
|
||||
await expect(service.getBooksPage(7, 'recent', 1, 50)).resolves.toEqual({ entries: [], total: 0 });
|
||||
@@ -88,7 +113,7 @@ describe('OpdsBookService', () => {
|
||||
|
||||
accessSpy.mockResolvedValueOnce([1, 2]);
|
||||
paginatedSpy.mockResolvedValueOnce({ entries: [{ id: 9 }], total: 1 });
|
||||
const searchSpy = vi.spyOn(service as never, 'buildCatalogSearchClause');
|
||||
const searchSpy = vi.spyOn(privateService, 'buildCatalogSearchClause');
|
||||
await expect(
|
||||
service.getBooksPage(7, 'title_asc', 2, 20, {
|
||||
libraryId: 1,
|
||||
@@ -105,7 +130,7 @@ describe('OpdsBookService', () => {
|
||||
it('builds catalog search across title, author, series, and normalized ISBN', () => {
|
||||
const { service, db } = makeService();
|
||||
|
||||
const clause = (service as unknown as { buildCatalogSearchClause(q: string): unknown }).buildCatalogSearchClause('978-0 141187761');
|
||||
const clause = testable(service).buildCatalogSearchClause('978-0 141187761');
|
||||
const values = collectValues(clause);
|
||||
|
||||
expect(db.select).toHaveBeenCalledWith({ one: expect.anything() });
|
||||
@@ -116,7 +141,7 @@ describe('OpdsBookService', () => {
|
||||
it('escapes catalog search LIKE patterns', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
const clause = (service as unknown as { buildCatalogSearchClause(q: string): unknown }).buildCatalogSearchClause('100%_\\');
|
||||
const clause = testable(service).buildCatalogSearchClause('100%_\\');
|
||||
const values = collectValues(clause);
|
||||
|
||||
expect(values).toContain('%100\\%\\_\\\\%');
|
||||
@@ -125,7 +150,7 @@ describe('OpdsBookService', () => {
|
||||
it('handles getRecentBooksPage empty-access and delegated paths', async () => {
|
||||
const { service } = makeService();
|
||||
const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds');
|
||||
const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery');
|
||||
const paginatedSpy = vi.spyOn(testable(service), 'paginatedBookQuery');
|
||||
|
||||
accessSpy.mockResolvedValueOnce([]);
|
||||
await expect(service.getRecentBooksPage(5, 1, 30)).resolves.toEqual({ entries: [], total: 0 });
|
||||
@@ -138,7 +163,7 @@ describe('OpdsBookService', () => {
|
||||
it('handles getRandomBooks guard branches and wrapped id selection', async () => {
|
||||
const { service } = makeService([[{ minId: null, maxId: null }], [{ minId: 10, maxId: 12 }], [{ id: 11 }], [{ id: 10 }]]);
|
||||
const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds');
|
||||
const fetchSpy = vi.spyOn(service as never, 'fetchBookEntries');
|
||||
const fetchSpy = vi.spyOn(testable(service), 'fetchBookEntries');
|
||||
|
||||
await expect(service.getRandomBooks(7, 0)).resolves.toEqual([]);
|
||||
|
||||
@@ -155,8 +180,8 @@ describe('OpdsBookService', () => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith([11, 10]);
|
||||
});
|
||||
|
||||
it('returns distinct authors and series with and without access', async () => {
|
||||
const { service } = makeService([[{ name: 'Frank Herbert', bookCount: 2 }], [{ name: 'Dune', bookCount: 2 }]]);
|
||||
it('returns distinct authors and membership-backed series with and without access', async () => {
|
||||
const { service, db } = makeService([[{ name: 'Frank Herbert', bookCount: 2 }], [{ id: 42, name: 'Dune', bookCount: 2 }]]);
|
||||
const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds');
|
||||
|
||||
accessSpy.mockResolvedValueOnce([]);
|
||||
@@ -166,7 +191,12 @@ describe('OpdsBookService', () => {
|
||||
await expect(service.getDistinctAuthors(1)).resolves.toEqual([{ name: 'Frank Herbert', bookCount: 2 }]);
|
||||
|
||||
accessSpy.mockResolvedValueOnce([1]);
|
||||
await expect(service.getDistinctSeries(1)).resolves.toEqual([{ name: 'Dune', bookCount: 2 }]);
|
||||
await expect(service.getDistinctSeries(1)).resolves.toEqual([{ id: 42, name: 'Dune', bookCount: 2 }]);
|
||||
|
||||
const chains = (db.select as ReturnType<typeof vi.fn>).mock.results.map((r) => r.value as Record<string, unknown>);
|
||||
const seriesChain = chains.at(-1)!;
|
||||
expect(seriesChain.from).toHaveBeenCalledWith(bookSeries);
|
||||
expect(seriesChain.innerJoin).toHaveBeenCalledWith(bookSeriesMemberships, expect.anything());
|
||||
});
|
||||
|
||||
it('returns user collections and smartScopes', async () => {
|
||||
@@ -202,10 +232,11 @@ describe('OpdsBookService', () => {
|
||||
|
||||
it('applies text search inside smartScope when q is provided', async () => {
|
||||
const { service } = makeService([[{ id: 3, userId: 7, isPublic: false, filter: null }]]);
|
||||
const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery').mockResolvedValue({ entries: [], total: 0 });
|
||||
const searchSpy = vi.spyOn(service as never, 'buildCatalogSearchClause');
|
||||
const privateService = testable(service);
|
||||
const paginatedSpy = vi.spyOn(privateService, 'paginatedBookQuery').mockResolvedValue({ entries: [], total: 0 });
|
||||
const searchSpy = vi.spyOn(privateService, 'buildCatalogSearchClause');
|
||||
|
||||
await (service as never).getBooksBySmartScope(7, 3, [1], 'title_asc', 1, 20, undefined, 'dune');
|
||||
await privateService.getBooksBySmartScope(7, 3, [1], 'title_asc', 1, 20, undefined, 'dune');
|
||||
|
||||
expect(searchSpy).toHaveBeenCalledWith('dune');
|
||||
expect(paginatedSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -215,10 +246,11 @@ describe('OpdsBookService', () => {
|
||||
|
||||
it('omits text search clause inside smartScope when q is absent', async () => {
|
||||
const { service } = makeService([[{ id: 3, userId: 7, isPublic: false, filter: null }]]);
|
||||
const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery').mockResolvedValue({ entries: [], total: 0 });
|
||||
const searchSpy = vi.spyOn(service as never, 'buildCatalogSearchClause');
|
||||
const privateService = testable(service);
|
||||
const paginatedSpy = vi.spyOn(privateService, 'paginatedBookQuery').mockResolvedValue({ entries: [], total: 0 });
|
||||
const searchSpy = vi.spyOn(privateService, 'buildCatalogSearchClause');
|
||||
|
||||
await (service as never).getBooksBySmartScope(7, 3, [1], 'title_asc', 1, 20);
|
||||
await privateService.getBooksBySmartScope(7, 3, [1], 'title_asc', 1, 20);
|
||||
|
||||
expect(searchSpy).not.toHaveBeenCalled();
|
||||
expect(paginatedSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -226,40 +258,43 @@ describe('OpdsBookService', () => {
|
||||
|
||||
it('returns no smartScope books when smartScope is missing or private to another user', async () => {
|
||||
const { service } = makeService([[], [{ id: 5, userId: 99, isPublic: false, filter: null }]]);
|
||||
const privateService = testable(service);
|
||||
|
||||
await expect((service as never).getBooksBySmartScope(7, 5, [1], 'recent', 1, 25)).resolves.toEqual({ entries: [], total: 0 });
|
||||
await expect((service as never).getBooksBySmartScope(7, 5, [1], 'recent', 1, 25)).resolves.toEqual({ entries: [], total: 0 });
|
||||
await expect(privateService.getBooksBySmartScope(7, 5, [1], 'recent', 1, 25)).resolves.toEqual({ entries: [], total: 0 });
|
||||
await expect(privateService.getBooksBySmartScope(7, 5, [1], 'recent', 1, 25)).resolves.toEqual({ entries: [], total: 0 });
|
||||
});
|
||||
|
||||
it('builds smartScope filters and delegates smartScope pagination', async () => {
|
||||
const { service, queryBuilder } = makeService([[{ id: 9, userId: 7, isPublic: false, filter: { op: 'and' } }]], {
|
||||
buildWhere: vi.fn().mockReturnValue({ kind: 'where' }),
|
||||
});
|
||||
const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery').mockResolvedValue({ entries: [{ id: 1 }], total: 1 });
|
||||
const privateService = testable(service);
|
||||
const paginatedSpy = vi.spyOn(privateService, 'paginatedBookQuery').mockResolvedValue({ entries: [{ id: 1 }], total: 1 });
|
||||
|
||||
await expect((service as never).getBooksBySmartScope(7, 9, [1, 2], 'title_desc', 2, 10)).resolves.toEqual({ entries: [{ id: 1 }], total: 1 });
|
||||
await expect(privateService.getBooksBySmartScope(7, 9, [1, 2], 'title_desc', 2, 10)).resolves.toEqual({ entries: [{ id: 1 }], total: 1 });
|
||||
expect(queryBuilder.buildWhere).toHaveBeenCalledWith({ op: 'and' }, { accessibleLibraryIds: [1, 2], userId: 7 });
|
||||
expect(paginatedSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('paginates ids and only fetches entries when rows are present', async () => {
|
||||
const empty = makeService([[], [{ total: 5 }]]);
|
||||
await expect((empty.service as never).paginatedBookQuery({ kind: 'where' }, 'recent', 2, 10)).resolves.toEqual({ entries: [], total: 5 });
|
||||
await expect(testable(empty.service).paginatedBookQuery({ kind: 'where' }, 'recent', 2, 10)).resolves.toEqual({ entries: [], total: 5 });
|
||||
|
||||
const filled = makeService([[{ id: 3 }, { id: 1 }], [{ total: 2 }]]);
|
||||
const fetchSpy = vi.spyOn(filled.service as never, 'fetchBookEntries').mockResolvedValue([{ id: 3 }, { id: 1 }]);
|
||||
const filledPrivateService = testable(filled.service);
|
||||
const fetchSpy = vi.spyOn(filledPrivateService, 'fetchBookEntries').mockResolvedValue([{ id: 3 }, { id: 1 }]);
|
||||
|
||||
await expect((filled.service as never).paginatedBookQuery({ kind: 'where' }, 'author_asc', 1, 25)).resolves.toEqual({
|
||||
await expect(filledPrivateService.paginatedBookQuery({ kind: 'where' }, 'author_asc', 1, 25)).resolves.toEqual({
|
||||
entries: [{ id: 3 }, { id: 1 }],
|
||||
total: 2,
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledWith([3, 1]);
|
||||
expect(fetchSpy).toHaveBeenCalledWith([3, 1], {});
|
||||
});
|
||||
|
||||
it('builds read-status, format, and id filters and forwards the user id to pagination', async () => {
|
||||
const { service } = makeService();
|
||||
const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds');
|
||||
const paginatedSpy = vi.spyOn(service as never, 'paginatedBookQuery').mockResolvedValue({ entries: [{ id: 1 }], total: 1 });
|
||||
const paginatedSpy = vi.spyOn(testable(service), 'paginatedBookQuery').mockResolvedValue({ entries: [{ id: 1 }], total: 1 });
|
||||
|
||||
accessSpy.mockResolvedValueOnce([1, 2]);
|
||||
await expect(service.getBooksPage(7, 'recently_read', 1, 20, { readStatus: 'reading', format: 'EPUB', ids: [3, 1] })).resolves.toEqual({
|
||||
@@ -281,6 +316,64 @@ describe('OpdsBookService', () => {
|
||||
expect(values).toContain(1);
|
||||
});
|
||||
|
||||
it('builds membership-backed series filters from explicit and opaque legacy series ids', async () => {
|
||||
const explicit = makeService();
|
||||
const explicitAccessSpy = vi.spyOn(explicit.service, 'getAccessibleLibraryIds');
|
||||
const explicitPaginatedSpy = vi.spyOn(testable(explicit.service), 'paginatedBookQuery').mockResolvedValue({ entries: [{ id: 1 }], total: 1 });
|
||||
|
||||
explicitAccessSpy.mockResolvedValueOnce([1]);
|
||||
await expect(explicit.service.getBooksPage(7, 'series_asc', 1, 20, { seriesId: 42 })).resolves.toEqual({
|
||||
entries: [{ id: 1 }],
|
||||
total: 1,
|
||||
});
|
||||
|
||||
expect(explicitPaginatedSpy).toHaveBeenCalledTimes(1);
|
||||
expect((explicitPaginatedSpy.mock.calls[0] as unknown[])[5]).toEqual({ contextSeries: { seriesId: 42 } });
|
||||
expect(collectValues((explicitPaginatedSpy.mock.calls[0] as unknown[])[0])).toContain(42);
|
||||
|
||||
const legacy = makeService();
|
||||
const legacyAccessSpy = vi.spyOn(legacy.service, 'getAccessibleLibraryIds');
|
||||
const legacyPaginatedSpy = vi.spyOn(testable(legacy.service), 'paginatedBookQuery').mockResolvedValue({ entries: [], total: 0 });
|
||||
|
||||
legacyAccessSpy.mockResolvedValueOnce([1]);
|
||||
await legacy.service.getBooksPage(7, 'series_asc', 1, 20, { series: 'series:99' });
|
||||
|
||||
expect((legacyPaginatedSpy.mock.calls[0] as unknown[])[5]).toEqual({ contextSeries: { seriesId: 99 } });
|
||||
expect(collectValues((legacyPaginatedSpy.mock.calls[0] as unknown[])[0])).toContain(99);
|
||||
});
|
||||
|
||||
it('normalizes legacy series-name filters to membership identity', async () => {
|
||||
const { service } = makeService();
|
||||
const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds');
|
||||
const paginatedSpy = vi.spyOn(testable(service), 'paginatedBookQuery').mockResolvedValue({ entries: [], total: 0 });
|
||||
|
||||
accessSpy.mockResolvedValueOnce([1]);
|
||||
await service.getBooksPage(7, 'series_asc', 1, 20, { series: ' Dune Saga ' });
|
||||
|
||||
expect((paginatedSpy.mock.calls[0] as unknown[])[5]).toEqual({ contextSeries: { normalizedName: 'dune saga' } });
|
||||
expect(collectValues((paginatedSpy.mock.calls[0] as unknown[])[0])).toContain('dune saga');
|
||||
});
|
||||
|
||||
it('returns paged distinct series from membership rows with stable ids', async () => {
|
||||
const { service, db } = makeService([
|
||||
[
|
||||
{ id: 1, name: 'Dune', bookCount: 6 },
|
||||
{ id: 2, name: 'World of Warcraft', bookCount: 9 },
|
||||
],
|
||||
]);
|
||||
vi.spyOn(service, 'getAccessibleLibraryIds').mockResolvedValueOnce([1]);
|
||||
|
||||
await expect(service.getDistinctSeriesPage(7, { q: 'war', limit: 1, offset: 0 })).resolves.toEqual({
|
||||
items: [{ id: 1, name: 'Dune', bookCount: 6 }],
|
||||
hasNext: true,
|
||||
});
|
||||
|
||||
const chain = (db.select as ReturnType<typeof vi.fn>).mock.results.at(-1)!.value as Record<string, ReturnType<typeof vi.fn>>;
|
||||
expect(chain.from).toHaveBeenCalledWith(bookSeries);
|
||||
expect(chain.innerJoin).toHaveBeenCalledWith(bookSeriesMemberships, expect.anything());
|
||||
expect(collectValues(chain.where.mock.calls[0]?.[0])).toEqual(expect.arrayContaining([1, '%war%']));
|
||||
});
|
||||
|
||||
it('short-circuits when an empty id filter is supplied', async () => {
|
||||
const { service } = makeService();
|
||||
vi.spyOn(service, 'getAccessibleLibraryIds').mockResolvedValueOnce([1]);
|
||||
@@ -290,8 +383,8 @@ describe('OpdsBookService', () => {
|
||||
it('builds unread read-status as a negated active-status subquery', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
const readingClause = (service as never).buildReadStatusClause(7, 'reading');
|
||||
const unreadClause = (service as never).buildReadStatusClause(7, 'unread');
|
||||
const readingClause = testable(service).buildReadStatusClause(7, 'reading');
|
||||
const unreadClause = testable(service).buildReadStatusClause(7, 'unread');
|
||||
|
||||
expect(collectValues(readingClause)).toEqual(expect.arrayContaining([7, 'reading', 'rereading', 'on_hold']));
|
||||
expect(collectValues(unreadClause)).toEqual(expect.arrayContaining([7, 'read', 'skimmed', 'abandoned']));
|
||||
@@ -315,9 +408,9 @@ describe('OpdsBookService', () => {
|
||||
|
||||
for (const sortOrder of sortOrders) {
|
||||
const { service, db } = makeService([[], [{ total: 0 }]]);
|
||||
await (service as never).paginatedBookQuery({ kind: 'where' }, sortOrder, 1, 25, 7);
|
||||
await testable(service).paginatedBookQuery({ kind: 'where' }, sortOrder, 1, 25, 7);
|
||||
|
||||
const chains = (db.select as ReturnType<typeof vi.fn>).mock.results.map((r: { value: Record<string, unknown> }) => r.value);
|
||||
const chains = (db.select as ReturnType<typeof vi.fn>).mock.results.map((r) => r.value as Record<string, unknown>);
|
||||
const orderByArgs = chains.flatMap((chain: Record<string, unknown>) => {
|
||||
const fn = chain['orderBy'] as ReturnType<typeof vi.fn>;
|
||||
return fn.mock.calls.flat() as unknown[];
|
||||
@@ -341,6 +434,7 @@ describe('OpdsBookService', () => {
|
||||
bookUpdatedAt: now,
|
||||
title: null,
|
||||
description: null,
|
||||
seriesId: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
language: null,
|
||||
@@ -355,6 +449,7 @@ describe('OpdsBookService', () => {
|
||||
bookUpdatedAt: now,
|
||||
title: 'First',
|
||||
description: 'Desc',
|
||||
seriesId: 42,
|
||||
seriesName: 'Series',
|
||||
seriesIndex: 1,
|
||||
language: 'en',
|
||||
@@ -375,10 +470,11 @@ describe('OpdsBookService', () => {
|
||||
],
|
||||
]);
|
||||
|
||||
await expect((service as never).fetchBookEntries([1, 2])).resolves.toEqual([
|
||||
await expect(testable(service).fetchBookEntries([1, 2])).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
title: 'First',
|
||||
seriesId: 42,
|
||||
hasCover: true,
|
||||
authors: ['Author One'],
|
||||
files: [
|
||||
@@ -395,6 +491,41 @@ describe('OpdsBookService', () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect((service as never).fetchBookEntries([])).resolves.toEqual([]);
|
||||
await expect(testable(service).fetchBookEntries([])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('overrides primary metadata with the contextual series membership for series-scoped pages', async () => {
|
||||
const now = new Date('2026-04-15T00:00:00.000Z');
|
||||
const { service } = makeService([
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
folderPath: '/library/first',
|
||||
addedAt: now,
|
||||
bookUpdatedAt: now,
|
||||
title: 'First',
|
||||
description: null,
|
||||
seriesId: 10,
|
||||
seriesName: 'Primary Saga',
|
||||
seriesIndex: 1,
|
||||
language: 'en',
|
||||
publisher: null,
|
||||
isbn13: null,
|
||||
coverSource: null,
|
||||
},
|
||||
],
|
||||
[{ bookId: 1, name: 'Author One' }],
|
||||
[{ bookId: 1, id: 10, format: 'epub', role: 'content' }],
|
||||
[{ bookId: 1, seriesId: 42, seriesName: 'Secondary Arc', seriesIndex: 3 }],
|
||||
]);
|
||||
|
||||
await expect(testable(service).fetchBookEntries([1], { contextSeries: { seriesId: 42 } })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
seriesId: 42,
|
||||
seriesName: 'Secondary Arc',
|
||||
seriesIndex: 3,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ function sampleBook(overrides?: Partial<OpdsBookEntry>): OpdsBookEntry {
|
||||
addedAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-02'),
|
||||
description: 'A fantasy novel by Brandon Sanderson',
|
||||
seriesId: 1,
|
||||
seriesName: 'Mistborn',
|
||||
seriesIndex: 1,
|
||||
language: 'en',
|
||||
@@ -119,6 +120,15 @@ describe('OpdsService', () => {
|
||||
|
||||
expect(xml).toContain(encodeURIComponent('The Lord of the Rings'));
|
||||
});
|
||||
|
||||
it('uses stable series ids when available', () => {
|
||||
const service = makeService();
|
||||
const xml = service.generateSeriesNavigation([{ id: 42, name: 'The Lord of the Rings', bookCount: 3 }]);
|
||||
|
||||
expect(xml).toContain('urn:bookorbit:series:42');
|
||||
expect(xml).toContain(`${BASE}/catalog?seriesId=42`);
|
||||
expect(xml).not.toContain('catalog?series=The%20Lord%20of%20the%20Rings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAcquisitionFeed', () => {
|
||||
@@ -197,11 +207,12 @@ describe('OpdsService', () => {
|
||||
);
|
||||
|
||||
expect(xml).toContain('Mistborn #1');
|
||||
expect(xml).toContain(`${BASE}/catalog?seriesId=1`);
|
||||
});
|
||||
|
||||
it('omits series link when seriesName is null', () => {
|
||||
const service = makeService();
|
||||
const book = sampleBook({ seriesName: null, seriesIndex: null });
|
||||
const book = sampleBook({ seriesId: null, seriesName: null, seriesIndex: null });
|
||||
const xml = service.generateAcquisitionFeed(
|
||||
'Catalog',
|
||||
'urn:bookorbit:catalog',
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
bookAuthors,
|
||||
bookFiles,
|
||||
bookMetadata,
|
||||
bookSeries,
|
||||
bookSeriesMemberships,
|
||||
books,
|
||||
collections,
|
||||
collectionBooks,
|
||||
@@ -23,6 +25,32 @@ import { buildContentFilterClauses } from '../../common/utils/content-filter-sql
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
type OpdsBookFilters = {
|
||||
libraryId?: number;
|
||||
collectionId?: number;
|
||||
smartScopeId?: number;
|
||||
author?: string;
|
||||
series?: string;
|
||||
seriesId?: number;
|
||||
q?: string;
|
||||
readStatus?: 'unread' | 'reading' | 'finished';
|
||||
format?: string;
|
||||
ids?: number[];
|
||||
};
|
||||
|
||||
type SeriesFilter = { seriesId: number } | { normalizedName: string };
|
||||
|
||||
type FetchBookEntriesOptions = {
|
||||
contextSeries?: SeriesFilter;
|
||||
};
|
||||
|
||||
type ContextSeriesRow = {
|
||||
bookId: number;
|
||||
seriesId: number;
|
||||
seriesName: string;
|
||||
seriesIndex: number | null;
|
||||
};
|
||||
|
||||
type OpdsSortOrder =
|
||||
| 'recent'
|
||||
| 'recent_asc'
|
||||
@@ -68,6 +96,7 @@ export interface OpdsBookEntry {
|
||||
addedAt: Date;
|
||||
updatedAt: Date;
|
||||
description: string | null;
|
||||
seriesId: number | null;
|
||||
seriesName: string | null;
|
||||
seriesIndex: number | null;
|
||||
language: string | null;
|
||||
@@ -125,17 +154,7 @@ export class OpdsBookService {
|
||||
sortOrder: OpdsSortOrder,
|
||||
page: number,
|
||||
size: number,
|
||||
filters?: {
|
||||
libraryId?: number;
|
||||
collectionId?: number;
|
||||
smartScopeId?: number;
|
||||
author?: string;
|
||||
series?: string;
|
||||
q?: string;
|
||||
readStatus?: 'unread' | 'reading' | 'finished';
|
||||
format?: string;
|
||||
ids?: number[];
|
||||
},
|
||||
filters?: OpdsBookFilters,
|
||||
isSuperuser = false,
|
||||
contentFilters?: ContentFilterRules,
|
||||
): Promise<{ entries: OpdsBookEntry[]; total: number }> {
|
||||
@@ -181,9 +200,8 @@ export class OpdsBookService {
|
||||
);
|
||||
}
|
||||
|
||||
if (filters?.series) {
|
||||
clauses.push(eq(bookMetadata.seriesName, filters.series));
|
||||
}
|
||||
const seriesFilter = this.resolveSeriesFilter(filters);
|
||||
if (seriesFilter) clauses.push(this.buildSeriesMembershipClause(seriesFilter));
|
||||
|
||||
if (filters?.format) {
|
||||
const format = filters.format.trim().toLowerCase();
|
||||
@@ -207,7 +225,7 @@ export class OpdsBookService {
|
||||
clauses.push(...buildContentFilterClauses(contentFilters, this.db));
|
||||
}
|
||||
|
||||
return this.paginatedBookQuery(and(...clauses)!, sortOrder, page, size, userId);
|
||||
return this.paginatedBookQuery(and(...clauses)!, sortOrder, page, size, userId, { contextSeries: seriesFilter });
|
||||
}
|
||||
|
||||
private buildReadStatusClause(userId: number, readStatus: 'unread' | 'reading' | 'finished'): SQL {
|
||||
@@ -232,7 +250,15 @@ export class OpdsBookService {
|
||||
return sql`exists (${sq})`;
|
||||
})();
|
||||
|
||||
const clauses: SQL[] = [ilike(bookMetadata.title, pattern), existsAuthor, ilike(bookMetadata.seriesName, pattern)];
|
||||
const existsSeries = sql`exists (
|
||||
SELECT 1
|
||||
FROM ${bookSeriesMemberships}
|
||||
INNER JOIN ${bookSeries} ON ${bookSeries.id} = ${bookSeriesMemberships.seriesId}
|
||||
WHERE ${bookSeriesMemberships.bookId} = ${books.id}
|
||||
AND ${ilike(bookSeries.name, pattern)}
|
||||
)`;
|
||||
|
||||
const clauses: SQL[] = [ilike(bookMetadata.title, pattern), existsAuthor, existsSeries, ilike(bookMetadata.seriesName, pattern)];
|
||||
const normalizedIsbn = normalizeIsbnSearchTerm(term);
|
||||
if (normalizedIsbn) {
|
||||
clauses.push(or(eq(bookMetadata.isbn13, normalizedIsbn), eq(bookMetadata.isbn10, normalizedIsbn))!);
|
||||
@@ -327,7 +353,7 @@ export class OpdsBookService {
|
||||
userId: number,
|
||||
isSuperuser = false,
|
||||
contentFilters?: ContentFilterRules,
|
||||
): Promise<{ name: string | null; bookCount: number }[]> {
|
||||
): Promise<{ id: number; name: string; bookCount: number }[]> {
|
||||
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
|
||||
if (accessibleIds.length === 0) return [];
|
||||
|
||||
@@ -335,14 +361,16 @@ export class OpdsBookService {
|
||||
|
||||
return this.db
|
||||
.select({
|
||||
name: bookMetadata.seriesName,
|
||||
id: bookSeries.id,
|
||||
name: bookSeries.name,
|
||||
bookCount: sql<number>`count(DISTINCT ${books.id})::int`,
|
||||
})
|
||||
.from(bookMetadata)
|
||||
.innerJoin(books, and(eq(books.id, bookMetadata.bookId), eq(books.status, 'present'), ...filterClauses))
|
||||
.where(and(inArray(books.libraryId, accessibleIds), sql`${bookMetadata.seriesName} IS NOT NULL`))
|
||||
.groupBy(bookMetadata.seriesName)
|
||||
.orderBy(sql`${bookMetadata.seriesName} ASC`);
|
||||
.from(bookSeries)
|
||||
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.seriesId, bookSeries.id))
|
||||
.innerJoin(books, and(eq(books.id, bookSeriesMemberships.bookId), eq(books.status, 'present'), ...filterClauses))
|
||||
.where(inArray(books.libraryId, accessibleIds))
|
||||
.groupBy(bookSeries.id, bookSeries.name)
|
||||
.orderBy(sql`${bookSeries.name} ASC`);
|
||||
}
|
||||
|
||||
async getDistinctAuthorsPage(
|
||||
@@ -384,27 +412,29 @@ export class OpdsBookService {
|
||||
opts: { q?: string; limit: number; offset: number },
|
||||
isSuperuser = false,
|
||||
contentFilters?: ContentFilterRules,
|
||||
): Promise<{ items: { name: string; bookCount: number }[]; hasNext: boolean }> {
|
||||
): Promise<{ items: { id: number; name: string; bookCount: number }[]; hasNext: boolean }> {
|
||||
const accessibleIds = await this.getAccessibleLibraryIds(userId, isSuperuser);
|
||||
if (accessibleIds.length === 0) return { items: [], hasNext: false };
|
||||
|
||||
const filterClauses = !isSuperuser && contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
|
||||
const where: SQL[] = [inArray(books.libraryId, accessibleIds), sql`${bookMetadata.seriesName} IS NOT NULL`];
|
||||
const where: SQL[] = [inArray(books.libraryId, accessibleIds)];
|
||||
const term = opts.q?.trim();
|
||||
if (term) {
|
||||
where.push(ilike(bookMetadata.seriesName, `%${term.replace(LIKE_SPECIAL_CHARS, '\\$&')}%`));
|
||||
where.push(ilike(bookSeries.name, `%${term.replace(LIKE_SPECIAL_CHARS, '\\$&')}%`));
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
name: sql<string>`${bookMetadata.seriesName}`,
|
||||
id: bookSeries.id,
|
||||
name: bookSeries.name,
|
||||
bookCount: sql<number>`count(DISTINCT ${books.id})::int`,
|
||||
})
|
||||
.from(bookMetadata)
|
||||
.innerJoin(books, and(eq(books.id, bookMetadata.bookId), eq(books.status, 'present'), ...filterClauses))
|
||||
.from(bookSeries)
|
||||
.innerJoin(bookSeriesMemberships, eq(bookSeriesMemberships.seriesId, bookSeries.id))
|
||||
.innerJoin(books, and(eq(books.id, bookSeriesMemberships.bookId), eq(books.status, 'present'), ...filterClauses))
|
||||
.where(and(...where))
|
||||
.groupBy(bookMetadata.seriesName)
|
||||
.orderBy(sql`${bookMetadata.seriesName} ASC`)
|
||||
.groupBy(bookSeries.id, bookSeries.name)
|
||||
.orderBy(sql`${bookSeries.name} ASC`)
|
||||
.limit(opts.limit + 1)
|
||||
.offset(opts.offset);
|
||||
|
||||
@@ -520,13 +550,40 @@ export class OpdsBookService {
|
||||
page: number,
|
||||
size: number,
|
||||
userId?: number,
|
||||
options: FetchBookEntriesOptions = {},
|
||||
): Promise<{ entries: OpdsBookEntry[]; total: number }> {
|
||||
const offset = (page - 1) * size;
|
||||
const needsAuthorJoin = sortOrder === 'author_asc' || sortOrder === 'author_desc';
|
||||
const needsStatusJoin = (sortOrder === 'recently_read' || sortOrder === 'recently_read_asc') && userId !== undefined;
|
||||
const orderClauses = OPDS_SORT_MAP[sortOrder];
|
||||
const needsContextSeriesJoin = options.contextSeries !== undefined && (sortOrder === 'series_asc' || sortOrder === 'series_desc');
|
||||
const orderClauses = needsContextSeriesJoin ? this.buildContextSeriesOrder(sortOrder) : OPDS_SORT_MAP[sortOrder];
|
||||
|
||||
const buildIdQuery = () => {
|
||||
if (needsContextSeriesJoin) {
|
||||
const query = this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.innerJoin(bookSeriesMemberships, this.buildContextSeriesMembershipJoin(options.contextSeries!));
|
||||
|
||||
if ('normalizedName' in options.contextSeries!) {
|
||||
return query
|
||||
.innerJoin(
|
||||
bookSeries,
|
||||
and(eq(bookSeries.id, bookSeriesMemberships.seriesId), eq(bookSeries.normalizedName, options.contextSeries.normalizedName))!,
|
||||
)
|
||||
.where(where)
|
||||
.orderBy(...orderClauses)
|
||||
.limit(size)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
return query
|
||||
.where(where)
|
||||
.orderBy(...orderClauses)
|
||||
.limit(size)
|
||||
.offset(offset);
|
||||
}
|
||||
if (needsAuthorJoin) {
|
||||
return this.db
|
||||
.select({ id: books.id })
|
||||
@@ -568,14 +625,17 @@ export class OpdsBookService {
|
||||
|
||||
if (idRows.length === 0) return { entries: [], total: Number(total) };
|
||||
|
||||
const entries = await this.fetchBookEntries(idRows.map((r) => r.id));
|
||||
const entries = await this.fetchBookEntries(
|
||||
idRows.map((r) => r.id),
|
||||
options,
|
||||
);
|
||||
return { entries, total: Number(total) };
|
||||
}
|
||||
|
||||
private async fetchBookEntries(bookIds: number[]): Promise<OpdsBookEntry[]> {
|
||||
private async fetchBookEntries(bookIds: number[], options: FetchBookEntriesOptions = {}): Promise<OpdsBookEntry[]> {
|
||||
if (bookIds.length === 0) return [];
|
||||
|
||||
const [metaRows, authorRows, fileRows] = await Promise.all([
|
||||
const [metaRows, authorRows, fileRows, contextSeriesRows] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: books.id,
|
||||
@@ -584,6 +644,7 @@ export class OpdsBookService {
|
||||
bookUpdatedAt: books.updatedAt,
|
||||
title: bookMetadata.title,
|
||||
description: bookMetadata.description,
|
||||
seriesId: bookMetadata.seriesId,
|
||||
seriesName: bookMetadata.seriesName,
|
||||
seriesIndex: bookMetadata.seriesIndex,
|
||||
language: bookMetadata.language,
|
||||
@@ -606,6 +667,7 @@ export class OpdsBookService {
|
||||
.innerJoin(books, eq(books.id, bookFiles.bookId))
|
||||
.where(and(inArray(bookFiles.bookId, bookIds), eq(bookFiles.role, 'content')))
|
||||
.orderBy(sql`case when ${bookFiles.id} = ${books.primaryFileId} then 0 else 1 end`, bookFiles.sortOrder, bookFiles.id),
|
||||
options.contextSeries ? this.fetchContextSeriesRows(bookIds, options.contextSeries) : Promise.resolve<ContextSeriesRow[]>([]),
|
||||
]);
|
||||
|
||||
const authorsByBook = new Map<number, string[]>();
|
||||
@@ -624,28 +686,110 @@ export class OpdsBookService {
|
||||
}
|
||||
|
||||
const idOrder = new Map(bookIds.map((id, i) => [id, i]));
|
||||
const contextSeriesByBook = new Map(contextSeriesRows.map((row) => [row.bookId, row]));
|
||||
|
||||
return metaRows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? row.folderPath.split('/').pop() ?? 'Untitled',
|
||||
folderPath: row.folderPath,
|
||||
addedAt: row.addedAt,
|
||||
updatedAt: row.bookUpdatedAt,
|
||||
description: row.description,
|
||||
seriesName: row.seriesName,
|
||||
seriesIndex: row.seriesIndex,
|
||||
language: row.language,
|
||||
publisher: row.publisher,
|
||||
isbn13: row.isbn13,
|
||||
hasCover: row.coverSource !== null,
|
||||
authors: authorsByBook.get(row.id) ?? [],
|
||||
files: filesByBook.get(row.id) ?? [],
|
||||
}))
|
||||
.map((row) => {
|
||||
const contextSeries = contextSeriesByBook.get(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title ?? row.folderPath.split('/').pop() ?? 'Untitled',
|
||||
folderPath: row.folderPath,
|
||||
addedAt: row.addedAt,
|
||||
updatedAt: row.bookUpdatedAt,
|
||||
description: row.description,
|
||||
seriesId: contextSeries?.seriesId ?? row.seriesId,
|
||||
seriesName: contextSeries?.seriesName ?? row.seriesName,
|
||||
seriesIndex: contextSeries?.seriesIndex ?? row.seriesIndex,
|
||||
language: row.language,
|
||||
publisher: row.publisher,
|
||||
isbn13: row.isbn13,
|
||||
hasCover: row.coverSource !== null,
|
||||
authors: authorsByBook.get(row.id) ?? [],
|
||||
files: filesByBook.get(row.id) ?? [],
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0));
|
||||
}
|
||||
|
||||
private resolveSeriesFilter(filters?: OpdsBookFilters): SeriesFilter | undefined {
|
||||
if (filters?.seriesId !== undefined) return { seriesId: filters.seriesId };
|
||||
|
||||
const catalogSeriesId = parseCatalogSeriesId(filters?.series);
|
||||
if (catalogSeriesId !== undefined) return { seriesId: catalogSeriesId };
|
||||
|
||||
const normalizedName = normalizeSeriesNameFilter(filters?.series);
|
||||
return normalizedName ? { normalizedName } : undefined;
|
||||
}
|
||||
|
||||
private buildSeriesMembershipClause(filter: SeriesFilter): SQL {
|
||||
if ('seriesId' in filter) {
|
||||
return sql`${books.id} IN (
|
||||
SELECT ${bookSeriesMemberships.bookId}
|
||||
FROM ${bookSeriesMemberships}
|
||||
WHERE ${bookSeriesMemberships.seriesId} = ${filter.seriesId}
|
||||
)`;
|
||||
}
|
||||
|
||||
return sql`${books.id} IN (
|
||||
SELECT ${bookSeriesMemberships.bookId}
|
||||
FROM ${bookSeriesMemberships}
|
||||
INNER JOIN ${bookSeries} ON ${bookSeries.id} = ${bookSeriesMemberships.seriesId}
|
||||
WHERE ${bookSeries.normalizedName} = ${filter.normalizedName}
|
||||
)`;
|
||||
}
|
||||
|
||||
private buildContextSeriesMembershipJoin(filter: SeriesFilter): SQL {
|
||||
if ('seriesId' in filter) {
|
||||
return and(eq(bookSeriesMemberships.bookId, books.id), eq(bookSeriesMemberships.seriesId, filter.seriesId))!;
|
||||
}
|
||||
|
||||
return eq(bookSeriesMemberships.bookId, books.id);
|
||||
}
|
||||
|
||||
private buildContextSeriesOrder(sortOrder: OpdsSortOrder): SQL[] {
|
||||
const direction = sortOrder === 'series_desc' ? 'DESC' : 'ASC';
|
||||
return [
|
||||
sql`${bookSeriesMemberships.seriesIndex} ${sql.raw(direction)} NULLS LAST`,
|
||||
sql`${bookMetadata.title} ASC NULLS LAST`,
|
||||
sql`${books.id} ASC`,
|
||||
];
|
||||
}
|
||||
|
||||
private fetchContextSeriesRows(bookIds: number[], filter: SeriesFilter): Promise<ContextSeriesRow[]> {
|
||||
const conditions: SQL[] = [inArray(bookSeriesMemberships.bookId, bookIds)];
|
||||
if ('seriesId' in filter) {
|
||||
conditions.push(eq(bookSeriesMemberships.seriesId, filter.seriesId));
|
||||
} else {
|
||||
conditions.push(eq(bookSeries.normalizedName, filter.normalizedName));
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select({
|
||||
bookId: bookSeriesMemberships.bookId,
|
||||
seriesId: bookSeriesMemberships.seriesId,
|
||||
seriesName: bookSeries.name,
|
||||
seriesIndex: bookSeriesMemberships.seriesIndex,
|
||||
})
|
||||
.from(bookSeriesMemberships)
|
||||
.innerJoin(bookSeries, eq(bookSeries.id, bookSeriesMemberships.seriesId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(bookSeriesMemberships.displayOrder, bookSeriesMemberships.seriesId);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIsbnSearchTerm(value: string): string {
|
||||
return value.replace(/[^0-9Xx]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function parseCatalogSeriesId(value: string | null | undefined): number | undefined {
|
||||
const match = /^series:(\d+)$/.exec(value?.trim() ?? '');
|
||||
if (!match) return undefined;
|
||||
const id = Number.parseInt(match[1]!, 10);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
||||
}
|
||||
|
||||
function normalizeSeriesNameFilter(value: string | null | undefined): string | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export class OpdsController {
|
||||
@Get('series')
|
||||
async series(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) {
|
||||
const items = await this.opdsBookService.getDistinctSeries(user.userId, user.isSuperuser, user.contentFilters);
|
||||
const xml = this.opdsService.generateSeriesNavigation(items.filter((s): s is { name: string; bookCount: number } => s.name !== null));
|
||||
const xml = this.opdsService.generateSeriesNavigation(items.filter((s): s is { id: number; name: string; bookCount: number } => s.name !== null));
|
||||
this.sendXml(reply, xml, OPDS_MIME_NAV);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export class OpdsController {
|
||||
@Query('series') series?: string,
|
||||
@Query('q') q?: string,
|
||||
@Res() reply?: FastifyReply,
|
||||
@Query('seriesId') seriesIdStr?: string,
|
||||
) {
|
||||
const clampedSize = Math.min(Math.max(size, 1), 100);
|
||||
const clampedPage = Math.max(page, 1);
|
||||
@@ -111,10 +112,12 @@ export class OpdsController {
|
||||
const libraryId = this.parseOptionalPositiveInt('libraryId', libraryIdStr);
|
||||
const collectionId = this.parseOptionalPositiveInt('collectionId', collectionIdStr);
|
||||
const smartScopeId = this.parseOptionalPositiveInt('smartScopeId', smartScopeIdStr);
|
||||
const seriesId = this.parseOptionalPositiveInt('seriesId', seriesIdStr);
|
||||
|
||||
if (libraryId !== undefined) filters.libraryId = libraryId;
|
||||
if (collectionId !== undefined) filters.collectionId = collectionId;
|
||||
if (smartScopeId !== undefined) filters.smartScopeId = smartScopeId;
|
||||
if (seriesId !== undefined) filters.seriesId = seriesId;
|
||||
if (author) filters.author = author;
|
||||
if (series) filters.series = series;
|
||||
if (q) filters.q = q;
|
||||
|
||||
@@ -71,14 +71,14 @@ export class OpdsService {
|
||||
return this.wrapFeed('Authors', 'urn:bookorbit:authors', now, [xmlLink('self', `${BASE}/authors`, OPDS_MIME_NAV)], entries);
|
||||
}
|
||||
|
||||
generateSeriesNavigation(items: { name: string; bookCount: number }[]): string {
|
||||
generateSeriesNavigation(items: { id?: number; name: string; bookCount: number }[]): string {
|
||||
const now = new Date().toISOString();
|
||||
const entries = items.map((s) =>
|
||||
this.navEntry(
|
||||
`urn:bookorbit:series:${encodeURIComponent(s.name)}`,
|
||||
`urn:bookorbit:series:${s.id ?? encodeURIComponent(s.name)}`,
|
||||
s.name,
|
||||
`${s.bookCount} books`,
|
||||
`${BASE}/catalog?series=${encodeURIComponent(s.name)}`,
|
||||
s.id != null ? `${BASE}/catalog?seriesId=${s.id}` : `${BASE}/catalog?series=${encodeURIComponent(s.name)}`,
|
||||
now,
|
||||
),
|
||||
);
|
||||
@@ -154,8 +154,10 @@ export class OpdsService {
|
||||
}
|
||||
|
||||
if (book.seriesName) {
|
||||
const seriesHref =
|
||||
book.seriesId != null ? `${BASE}/catalog?seriesId=${book.seriesId}` : `${BASE}/catalog?series=${encodeURIComponent(book.seriesName)}`;
|
||||
lines.push(
|
||||
` <link rel="http://opds-spec.org/sort/series" href="${esc(BASE)}/catalog?series=${encodeURIComponent(book.seriesName)}" title="${esc(book.seriesName)}${book.seriesIndex != null ? ` #${book.seriesIndex}` : ''}"/>`,
|
||||
` <link rel="http://opds-spec.org/sort/series" href="${esc(seriesHref)}" title="${esc(book.seriesName)}${book.seriesIndex != null ? ` #${book.seriesIndex}` : ''}"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user