feat(books): filter read-along titles

Expose read-along EPUBs as an additive format filter while preserving ordinary EPUB matching and existing client compatibility.

Closes #1583
This commit is contained in:
neon
2026-10-21 05:18:37 +00:00
parent d1cc18dc51
commit edc5e41105
14 changed files with 159 additions and 29 deletions
@@ -107,6 +107,7 @@ describe('AppInfoService', () => {
'book-journal',
'bookmark-edit',
'author-metadata-match',
'read-along-filter',
]),
);
});
@@ -41,6 +41,7 @@ import {
CUSTOM_FIELD_TYPE_OPERATORS,
EMPTY_CONTENT_FILTER_RULES,
FIELD_OPERATORS,
READ_ALONG_FORMAT_PRIORITY,
SORT_FIELDS,
customRuleField,
type CustomMetadataFieldType,
@@ -1266,6 +1267,72 @@ describe('formatRuleToSql', () => {
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'format', operator: 'excludesAll', value: [] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 1' });
});
it('matches the read-along edition key only on EPUBs with media overlays', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'format', operator: 'includesAny', value: [READ_ALONG_FORMAT_PRIORITY] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({
type: 'sql',
values: [
{
whereClause: {
clauses: [
expect.anything(),
{
type: 'or',
clauses: [
{
type: 'and',
clauses: [
{ type: 'eq', right: 'epub' },
{ type: 'eq', right: true },
],
},
],
},
],
},
},
],
});
});
it('ORs physical formats with the read-along edition key', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf', READ_ALONG_FORMAT_PRIORITY] }) as never,
USER_CTX,
) as any;
const predicate = (getRuleSql(where) as any).values[0].whereClause.clauses[1];
expect(predicate).toMatchObject({
type: 'or',
clauses: [
{ type: 'inArray', right: ['pdf'] },
{
type: 'and',
clauses: [
{ type: 'eq', right: 'epub' },
{ type: 'eq', right: true },
],
},
],
});
});
it('excludes books holding any read-along EPUB', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'format', operator: 'excludesAll', value: [READ_ALONG_FORMAT_PRIORITY] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'sql' } });
});
});
describe('dateRuleToSql (addedAt)', () => {
@@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AnyColumn, SQL, and, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, not, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { parseCustomRuleFieldId, parseCustomSortFieldId, parseSeriesIndex } from '@bookorbit/types';
import { READ_ALONG_FORMAT_PRIORITY, parseCustomRuleFieldId, parseCustomSortFieldId, parseSeriesIndex } from '@bookorbit/types';
import type {
CommunityRatingProvider,
ContentFilterRules,
@@ -661,13 +661,23 @@ export class BookQueryBuilder {
return sql`exists (${sq})`;
};
const matchesFormats = (): SQL | undefined => {
if (!values?.length) return undefined;
const physicalFormats = values.filter((value) => value !== READ_ALONG_FORMAT_PRIORITY);
const clauses: SQL[] = [];
if (physicalFormats.length > 0) clauses.push(inArray(bookFiles.format, physicalFormats));
if (values.includes(READ_ALONG_FORMAT_PRIORITY)) {
clauses.push(and(eq(bookFiles.format, 'epub'), eq(bookFiles.mediaOverlayAvailable, true))!);
}
return or(...clauses);
};
const match = matchesFormats();
switch (operator) {
case 'includesAny':
if (!values?.length) return sql`1 = 0`;
return existsFormat(inArray(bookFiles.format, values));
return match ? existsFormat(match) : sql`1 = 0`;
case 'excludesAll':
if (!values?.length) return sql`1 = 1`;
return not(existsFormat(inArray(bookFiles.format, values)));
return match ? not(existsFormat(match)) : sql`1 = 1`;
default:
throw new BadRequestException(`Invalid operator '${operator}' for format field`);
}
@@ -1268,11 +1268,13 @@ describe('BookRepository', () => {
expect(normalized).toContain('count(DISTINCT "books"."id")');
expect(normalized).toContain('LEFT JOIN "book_metadata" ON "book_metadata"."book_id" = "books"."id"');
expect(normalized).toContain('INNER JOIN "book_files" AS facet_file ON facet_file.book_id = "books"."id"');
expect(normalized).toContain('CROSS JOIN LATERAL');
expect(normalized).toContain("facet_file.format = 'epub' AND facet_file.media_overlay_available = true");
expect(normalized).toContain('"book_metadata"."title" is not null');
expect(normalized).toContain('"books"."status" <>');
expect(normalized).toContain('facet_file.format IN ($2, $3)');
expect(normalized).toContain('GROUP BY facet_file.format');
expect(query.params).toEqual(['processing', 'epub', 'pdf']);
expect(normalized).toContain('facet_key.format IN ($3, $4)');
expect(normalized).toContain('GROUP BY facet_key.format');
expect(query.params).toEqual(['epub:readalong', 'processing', 'epub', 'pdf']);
expect(result).toEqual([
{ format: 'epub', count: 7 },
{ format: 'pdf', count: 3 },
+10 -4
View File
@@ -15,7 +15,7 @@ import type {
TemporalJumpBucketUnit,
} from '@bookorbit/types';
import type { UnscopedBookRecommendation } from '@bookorbit/types';
import { isAudioFormat, isComicFormat, normalizeCoverAspectRatio } from '@bookorbit/types';
import { READ_ALONG_FORMAT_PRIORITY, 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';
@@ -1944,16 +1944,22 @@ export class BookRepository {
async countFormatFacets<F extends string>(where: SQL | undefined, formats: readonly F[]): Promise<Array<{ format: F; count: number }>> {
if (formats.length === 0) return [];
const result = await this.db.execute<{ format: F; count: string | number }>(sql`
SELECT facet_file.format AS format, count(DISTINCT ${books.id}) AS count
SELECT facet_key.format AS format, count(DISTINCT ${books.id}) AS count
FROM ${books}
LEFT JOIN ${bookMetadata} ON ${bookMetadata.bookId} = ${books.id}
INNER JOIN ${bookFiles} AS facet_file ON facet_file.book_id = ${books.id}
CROSS JOIN LATERAL (
SELECT facet_file.format AS format
UNION ALL
SELECT ${READ_ALONG_FORMAT_PRIORITY}
WHERE facet_file.format = 'epub' AND facet_file.media_overlay_available = true
) AS facet_key
WHERE ${this.visibleWhere(where)}
AND facet_file.format IN (${sql.join(
AND facet_key.format IN (${sql.join(
formats.map((format) => sql`${format}`),
sql`, `,
)})
GROUP BY facet_file.format
GROUP BY facet_key.format
`);
return result.rows.map((row) => ({ format: row.format, count: Number(row.count) }));
}
+9 -7
View File
@@ -10,7 +10,7 @@ import { SelfWriteRegistry } from '../../common/services/self-write-registry.ser
import { FileWatcherService, WATCHER_DEBOUNCE_MS } from '../scanner/file-watcher.service';
import {
AUDIO_BOOK_FILE_WRITE_FIELDS,
BOOK_FORMATS,
BOOK_FILTER_FORMATS,
MetadataProviderKey,
Permission,
type BookQuery,
@@ -5704,7 +5704,7 @@ describe('BookService', () => {
expect(queryBuilder.buildWhere).toHaveBeenCalledTimes(2);
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter: undefined, q: undefined });
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: 'dune' }, BOOK_FORMATS);
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: 'dune' }, BOOK_FILTER_FORMATS);
expect(result).toEqual({ total: 12, libraryTotal: 40, formats: [] });
});
@@ -5733,7 +5733,7 @@ describe('BookService', () => {
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(filter, expect.objectContaining({ q: 'dune' }));
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(stripped, expect.objectContaining({ q: 'dune' }));
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter: stripped, q: 'dune' }, BOOK_FORMATS);
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter: stripped, q: 'dune' }, BOOK_FILTER_FORMATS);
expect(filter.rules).toHaveLength(4);
});
@@ -5745,7 +5745,7 @@ describe('BookService', () => {
expect(queryBuilder.buildWhere).toHaveBeenCalledTimes(2);
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: undefined });
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: undefined }, BOOK_FORMATS);
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: undefined }, BOOK_FILTER_FORMATS);
});
it('keeps a root format exclusion, which the chips do not own', async () => {
@@ -5757,7 +5757,7 @@ describe('BookService', () => {
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith(
{ filter: { type: 'group', join: 'AND', rules: [excludePdf] }, q: undefined },
BOOK_FORMATS,
BOOK_FILTER_FORMATS,
);
});
@@ -5771,7 +5771,7 @@ describe('BookService', () => {
await service.facetsForLibrary(makeUser(), 7, facetQuery(filter));
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: undefined }, BOOK_FORMATS);
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter, q: undefined }, BOOK_FILTER_FORMATS);
});
it('drops the filter entirely when the only root rule is a format rule, still applying q', async () => {
@@ -5782,12 +5782,13 @@ describe('BookService', () => {
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(undefined, expect.objectContaining({ q: 'dune' }));
expect(bookRepo.countWhere).toHaveBeenCalledWith({ filter, q: 'dune' });
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter: undefined, q: 'dune' }, BOOK_FORMATS);
expect(bookRepo.countFormatFacets).toHaveBeenCalledWith({ filter: undefined, q: 'dune' }, BOOK_FILTER_FORMATS);
});
it('sorts formats by count then name and omits empty ones', async () => {
const { service, bookRepo } = makeFacetService();
bookRepo.countFormatFacets.mockResolvedValue([
{ format: 'epub:readalong', count: 4 },
{ format: 'pdf', count: 3 },
{ format: 'epub', count: 7 },
{ format: 'cbz', count: 0 },
@@ -5799,6 +5800,7 @@ describe('BookService', () => {
expect(result.formats).toEqual([
{ format: 'epub', count: 7 },
{ format: 'epub:readalong', count: 4 },
{ format: 'mobi', count: 3 },
{ format: 'pdf', count: 3 },
{ format: 'azw3', count: 1 },
+2 -2
View File
@@ -37,7 +37,7 @@ import { basename, dirname, extname, join, relative } from 'path';
import { tmpdir } from 'os';
import {
BOOK_FORMATS,
BOOK_FILTER_FORMATS,
BOOK_METADATA_LOCK_FIELDS,
DEFAULT_DOWNLOAD_PATTERN,
COVER_MEDIA,
@@ -1160,7 +1160,7 @@ export class BookService {
const [total, libraryTotal, formatCounts] = await Promise.all([
this.bookRepo.countWhere(where),
narrowed ? this.bookRepo.countWhere(this.queryBuilder.buildWhere(undefined, scope)) : null,
this.bookRepo.countFormatFacets(formatWhere, BOOK_FORMATS),
this.bookRepo.countFormatFacets(formatWhere, BOOK_FILTER_FORMATS),
]);
const formats = formatCounts.filter((facet) => facet.count > 0).sort((a, b) => b.count - a.count || a.format.localeCompare(b.format));