fix(books): include subtitles in search

Match subtitle metadata in book searches and index it for large libraries. Add regression coverage for accent matching and library visibility.

+ add open collective

Closes #1552
This commit is contained in:
neon
2026-09-26 04:54:54 +00:00
parent 5a108bbd5e
commit 0b2df6e3d3
11 changed files with 22589 additions and 12 deletions
@@ -0,0 +1 @@
CREATE INDEX "bm_subtitle_unaccent_trgm_idx" ON "book_metadata" USING gin (public.bookorbit_unaccent("subtitle") gin_trgm_ops);
File diff suppressed because it is too large Load Diff
@@ -687,6 +687,13 @@
"when": 1790137565077,
"tag": "0097_add_book_covers",
"breakpoints": true
},
{
"idx": 98,
"version": "7",
"when": 1790398181669,
"tag": "0098_add_subtitle_search_index",
"breakpoints": true
}
]
}
+1
View File
@@ -92,6 +92,7 @@ export const bookMetadata = pgTable(
index('bm_title_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.title}) gin_trgm_ops`),
index('bm_title_lower_idx').on(sql`lower(${t.title})`),
index('bm_title_book_id_idx').on(t.title, t.bookId),
index('bm_subtitle_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.subtitle}) gin_trgm_ops`),
index('bm_series_trgm_idx').using('gin', t.seriesName.op('gin_trgm_ops')),
index('bm_series_unaccent_trgm_idx').using('gin', sql`public.bookorbit_unaccent(${t.seriesName}) gin_trgm_ops`),
index('bm_series_id_idx').on(t.seriesId),
@@ -48,7 +48,7 @@ import {
} from '@bookorbit/types';
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
import { bookCustomMetadataValues, books } from '../../db/schema';
import { bookCustomMetadataValues, bookMetadata, books } from '../../db/schema';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookSortBuilder } from './book-sort-builder.service';
@@ -778,22 +778,23 @@ describe('buildQuickSearch', () => {
builder.buildQuickSearch('gracian');
expect(accentInsensitiveIlike).toHaveBeenCalledTimes(5);
expect(accentInsensitiveIlike).toHaveBeenCalledTimes(6);
expect(accentInsensitiveIlike).toHaveBeenCalledWith(expect.anything(), '%gracian%');
});
it('produces an OR of accent-insensitive title/series matches and author/series/narrator exists subqueries', () => {
it('matches subtitle alongside title, series, author, and narrator', () => {
const { builder } = makeBuilder();
const result = builder.buildQuickSearch('tolkien') as any;
expect(result).toMatchObject({ type: 'or' });
expect(result.clauses).toHaveLength(5);
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%tolkien%' });
expect(result.clauses[1]).toMatchObject({ type: 'sql' });
expect(result.clauses[2]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%tolkien%' });
expect(result.clauses[3]).toMatchObject({ type: 'sql' });
expect(result.clauses).toHaveLength(6);
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.title, pattern: '%tolkien%' });
expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%tolkien%' });
expect(result.clauses[2]).toMatchObject({ type: 'sql' });
expect(result.clauses[3]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.seriesName, pattern: '%tolkien%' });
expect(result.clauses[4]).toMatchObject({ type: 'sql' });
expect(result.clauses[5]).toMatchObject({ type: 'sql' });
});
it('uses phrase containment without trigram expansion for multi-word searches', () => {
@@ -802,8 +803,9 @@ describe('buildQuickSearch', () => {
const result = builder.buildQuickSearch('The Wax Child');
expect(accentInsensitiveIlike).toHaveBeenCalledTimes(5);
expect(accentInsensitiveIlike).toHaveBeenCalledTimes(6);
expect(accentInsensitiveIlike).toHaveBeenCalledWith(expect.anything(), '%The Wax Child%');
expect(accentInsensitiveIlike).toHaveBeenCalledWith(bookMetadata.subtitle, '%The Wax Child%');
expect(collectSqlText(result).join(' ')).not.toContain(' % ');
});
@@ -813,7 +815,8 @@ describe('buildQuickSearch', () => {
const result = builder.buildQuickSearch('50% off') as any;
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%50\\% off%' });
expect(result.clauses[2]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%50\\% off%' });
expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%50\\% off%' });
expect(result.clauses[3]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%50\\% off%' });
});
it('escapes underscore in q', () => {
@@ -822,6 +825,7 @@ describe('buildQuickSearch', () => {
const result = builder.buildQuickSearch('book_one') as any;
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%book\\_one%' });
expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%book\\_one%' });
});
it('uses the same containment predicate for two-character queries', () => {
@@ -830,7 +834,8 @@ describe('buildQuickSearch', () => {
const result = builder.buildQuickSearch('du') as any;
expect(result.clauses[0]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%du%' });
expect(result.clauses[2]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%du%' });
expect(result.clauses[1]).toMatchObject({ type: 'accentInsensitiveIlike', left: bookMetadata.subtitle, pattern: '%du%' });
expect(result.clauses[3]).toMatchObject({ type: 'accentInsensitiveIlike', pattern: '%du%' });
});
it('calls db.select three times for author, series membership, and narrator exists subqueries', () => {
@@ -124,6 +124,7 @@ export class BookQueryBuilder {
return or(
accentInsensitiveIlike(bookMetadata.title, pattern),
accentInsensitiveIlike(bookMetadata.subtitle, pattern),
existsAuthor,
accentInsensitiveIlike(bookMetadata.seriesName, pattern),
existsSeries,
@@ -1088,6 +1088,34 @@ describe('BookRepository', () => {
expect(db.select).not.toHaveBeenCalled();
});
it('includes accent-insensitive subtitle matches in library-scoped global search', async () => {
const distinctChain = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
as: vi.fn().mockReturnValue({ bookId: sql`1` }),
};
distinctChain.from.mockReturnValue(distinctChain);
distinctChain.innerJoin.mockReturnValue(distinctChain);
distinctChain.where.mockReturnValue(distinctChain);
const mainChain = makeSelectChain('limit', []);
const db = {
selectDistinct: vi.fn().mockReturnValue(distinctChain),
select: vi.fn().mockReturnValue(mainChain),
};
const repo = new BookRepository(db as never);
await expect(repo.searchAcrossLibraries([7], 'Singapore', 10)).resolves.toEqual([]);
const query = new PgDialect().sqlToQuery(mainChain.where.mock.calls[0]![0]);
expect(query.sql).toContain('public.bookorbit_unaccent("book_metadata"."subtitle") ILIKE');
expect(query.sql).toContain('"books"."library_id" in');
expect(query.params).toContain('%Singapore%');
expect(query.params).toContain(7);
expect(mainChain.limit).toHaveBeenCalledWith(10);
});
it('combines title results with author names and unique formats', async () => {
const rows = [
{ id: 10, title: 'Dune', seriesName: 'Dune', libraryId: 7, libraryName: 'Main' },
@@ -1838,6 +1838,7 @@ export class BookRepository {
ne(books.status, 'processing'),
or(
accentInsensitiveIlike(bookMetadata.title, pattern),
accentInsensitiveIlike(bookMetadata.subtitle, pattern),
accentInsensitiveIlike(bookMetadata.seriesName, pattern),
isNotNull(matchedAuthors.bookId),
isNotNull(matchedSeries.bookId),
+44
View File
@@ -301,6 +301,50 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expectError(invalidLimit, 400, 'limit must not be greater than 20');
});
it('finds subtitle-only matches without exposing books in inaccessible libraries', async () => {
const [visibleMetadata] = await ctx.db
.select({ subtitle: bookMetadata.subtitle })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, visibleEpub.bookId));
const [hiddenMetadata] = await ctx.db
.select({ subtitle: bookMetadata.subtitle })
.from(bookMetadata)
.where(eq(bookMetadata.bookId, hiddenEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: 'The Singapôre Story' }).where(eq(bookMetadata.bookId, visibleEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: 'A Singapôre Chronicle' }).where(eq(bookMetadata.bookId, hiddenEpub.bookId));
try {
const query = await ctx.app.inject({
method: 'POST',
url: '/api/v1/books/query',
headers: authHeader(limitedUser.accessToken),
payload: { q: 'Singapore', pagination: { page: 0, size: 10 } },
});
expect(query.statusCode).toBe(201);
expect(query.json()).toMatchObject({ total: 1, items: [{ id: visibleEpub.bookId }] });
const libraryQuery = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${visibleLibrary.libraryId}/books`,
headers: authHeader(crossLibraryUser.accessToken),
payload: { q: 'Singapore', pagination: { page: 0, size: 10 } },
});
expect(libraryQuery.statusCode).toBe(201);
expect(libraryQuery.json()).toMatchObject({ total: 1, items: [{ id: visibleEpub.bookId }] });
const search = await ctx.app.inject({
method: 'GET',
url: '/api/v1/books/search?q=Singapore&limit=10',
headers: authHeader(limitedUser.accessToken),
});
expect(search.statusCode).toBe(200);
expect(search.json()).toEqual([expect.objectContaining({ id: visibleEpub.bookId })]);
} finally {
await ctx.db.update(bookMetadata).set({ subtitle: visibleMetadata!.subtitle }).where(eq(bookMetadata.bookId, visibleEpub.bookId));
await ctx.db.update(bookMetadata).set({ subtitle: hiddenMetadata!.subtitle }).where(eq(bookMetadata.bookId, hiddenEpub.bookId));
}
});
it('filters books by the selected primary file size', async () => {
const primaryFileSize = (await stat(visibleEpub.absolutePath)).size;
const response = await ctx.app.inject({