From f8c8ecf99af63b32d0e0241f8f6fce7673d84458 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:10:28 +0000 Subject: [PATCH] fix(books): honour the metadata score sort when series are collapsed The collapsed order by had no metadataScore case, so the sort was dropped and the list fell back to title order. Order by metadata_score there and select it in the collapsed jump-bucket query that shares the order by. Fixes #1638 --- .../book/book-query-builder.service.test.ts | 10 ++++++++++ .../modules/book/book-query-builder.service.ts | 3 +++ server/src/modules/book/book.repository.ts | 2 ++ server/test/jump-buckets-invariant.e2e-spec.ts | 16 ++++++++++++++++ 4 files changed, 31 insertions(+) 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)])))];