diff --git a/server/src/modules/book/book-query-builder.service.test.ts b/server/src/modules/book/book-query-builder.service.test.ts index b33c05651..205cac465 100644 --- a/server/src/modules/book/book-query-builder.service.test.ts +++ b/server/src/modules/book/book-query-builder.service.test.ts @@ -41,6 +41,7 @@ import { CUSTOM_FIELD_TYPE_OPERATORS, EMPTY_CONTENT_FILTER_RULES, FIELD_OPERATORS, + SORT_FIELDS, customRuleField, type CustomMetadataFieldType, type RuleField, @@ -1987,6 +1988,15 @@ describe('BookQueryBuilder.buildCollapseOrderBy', () => { expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'updatedAt', dir: 'asc' }], 1)).toBe('updated_at ASC NULLS LAST, r.id ASC'); }); + it('generates metadataScore sort against the representative row', () => { + expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'metadataScore', dir: 'asc' }], 1)).toBe('metadata_score ASC NULLS LAST, r.id ASC'); + expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'metadataScore', dir: 'desc' }], 1)).toBe('metadata_score DESC NULLS LAST, r.id ASC'); + }); + + it.each(SORT_FIELDS.filter((field) => field !== 'relevance'))('honours the %s browse sort instead of falling back to title', (field) => { + expect(BookQueryBuilder.buildCollapseOrderBy([{ field, dir: 'desc' }], 1)).not.toBe('sort_title ASC NULLS LAST, r.id ASC'); + }); + it('generates fileSize sort using the primary file', () => { expect(BookQueryBuilder.buildCollapseOrderBy([{ field: 'fileSize', dir: 'desc' }], 1)).toBe( '(SELECT bf.size_bytes FROM book_files bf WHERE bf.id = r.primary_file_id) DESC NULLS LAST, r.id ASC', diff --git a/server/src/modules/book/book-query-builder.service.ts b/server/src/modules/book/book-query-builder.service.ts index 15165bb83..7bdbc13a0 100644 --- a/server/src/modules/book/book-query-builder.service.ts +++ b/server/src/modules/book/book-query-builder.service.ts @@ -1166,6 +1166,9 @@ export class BookQueryBuilder { case 'pageCount': parts.push(`page_count ${D} NULLS LAST`); break; + case 'metadataScore': + parts.push(`metadata_score ${D} NULLS LAST`); + break; case 'updatedAt': parts.push(`updated_at ${D} NULLS LAST`); break; diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index 92684ae98..e6b32c508 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -1275,6 +1275,7 @@ export class BookRepository { book_metadata.published_year, book_metadata.publisher, book_metadata.page_count, + book_metadata.metadata_score, NULLIF(lower(btrim(book_metadata.series_name)), '') AS norm_series ${source.baseExtraSelect} FROM books @@ -1301,6 +1302,7 @@ export class BookRepository { base.published_year, base.publisher, base.page_count, + base.metadata_score, ubr.rating, base.primary_author_sort_name AS author_sort_name, COALESCE(base.norm_series, lower(base.title)) AS sort_title, diff --git a/server/test/jump-buckets-invariant.e2e-spec.ts b/server/test/jump-buckets-invariant.e2e-spec.ts index 94752c7c5..8a34a2690 100644 --- a/server/test/jump-buckets-invariant.e2e-spec.ts +++ b/server/test/jump-buckets-invariant.e2e-spec.ts @@ -396,6 +396,22 @@ describe('Jump buckets invariant (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => } } + it('applies a secondary metadataScore tier to collapsed buckets without breaking the anchors', async () => { + const sort: SortSpec[] = [ + { field: 'title', dir: 'asc' }, + { field: 'metadataScore', dir: 'desc' }, + ]; + const [bucketsResponse, listing] = await Promise.all([fetchBuckets(sort, true), fetchListing(sort, true)]); + + expect(bucketsResponse.total).toBe(listing.total); + expect(bucketsResponse.buckets.length).toBeGreaterThan(1); + for (const bucket of bucketsResponse.buckets) { + const seeded = seededById.get((listing.items[bucket.index] as BookCard).id); + expect(seeded).toBeDefined(); + expect(expectedBucketKey(seeded!, 'title', true, bucketsResponse.granularity)).toBe(bucket.key); + } + }); + it('preserves every populated year when sparse years fit the bounded capacity', async () => { const response = await fetchBuckets([{ field: 'publishedYear', dir: 'asc' }], false); const expectedYears = [...new Set([...seededById.values()].flatMap((book) => (book.publishedYear === null ? [] : [String(book.publishedYear)])))];