From ab3e2fc7937b229aca934a2ebb85d84862a06655 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 19 Jul 2026 20:54:35 +0200 Subject: [PATCH] fix(search): address variant-grouping review findings - auto-load further pages when grouping collapses a page into too few cards to scroll (the scroll-driven pager would otherwise never fire and later results stayed hidden); bounded by card threshold and page count - scope variant-group keys per playlist so expanding a title in one playlist section no longer expands it in every other section - split a bucket by year when it holds two or more distinct years so remakes ("Dune (1984)" vs "Dune (2021)") no longer collapse into one card; a single/absent year still keeps variants together - pick a representative that has a poster, skipping leading posterless clones so the collapsed card shows real artwork when any member has it Co-Authored-By: Claude Fable 5 --- .../global-search-grouping.util.spec.ts | 48 +++++++ .../global-search-grouping.util.ts | 117 ++++++++++++++---- .../search-results.component.spec.ts | 75 +++++++++++ .../search-results.component.ts | 58 ++++++++- 4 files changed, 272 insertions(+), 26 deletions(-) diff --git a/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.spec.ts b/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.spec.ts index 468315f16..cc53e5327 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.spec.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.spec.ts @@ -91,4 +91,52 @@ describe('groupResultsByVariant', () => { expect(groups).toHaveLength(2); }); + + it('splits same-title remakes with different years', () => { + const groups = groupResultsByVariant( + [ + item('Dune (1984)'), + item('DE| Dune (2021)'), + item('Dune (2021)'), + ], + displayType + ); + + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.items.length).sort()).toEqual([1, 2]); + }); + + it('keeps one group when only some variants carry the year', () => { + const groups = groupResultsByVariant( + [ + item('DE| The Pitt'), + item('The Pitt (2025)'), + item('|ALB| The Pitt'), + ], + displayType + ); + + expect(groups).toHaveLength(1); + expect(groups[0].items).toHaveLength(3); + }); + + it('prefers a representative that has a poster', () => { + const groups = groupResultsByVariant( + [ + item('DE| The Pitt', { id: 1, poster_url: '' }), + item('The Pitt (2025)', { id: 2, poster_url: 'poster.jpg' }), + ], + displayType + ); + + expect(groups[0].representative.poster_url).toBe('poster.jpg'); + }); + + it('applies a key prefix so identical titles stay independent', () => { + const a = groupResultsByVariant([item('DE| The Pitt')], displayType, 'p1::'); + const b = groupResultsByVariant([item('DE| The Pitt')], displayType, 'p2::'); + + expect(a[0].key).not.toBe(b[0].key); + expect(a[0].key.startsWith('p1::')).toBe(true); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.ts b/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.ts index d91087311..577a63863 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.ts @@ -1,6 +1,6 @@ import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access'; import { stripCountryPrefix } from '@iptvnator/shared/m3u-utils'; -import { normalizeTitleKeys } from '@iptvnator/shared/interfaces'; +import { extractYear, normalizeTitleKeys } from '@iptvnator/shared/interfaces'; /** * A set of provider results that are the same title/type under different @@ -9,52 +9,119 @@ import { normalizeTitleKeys } from '@iptvnator/shared/interfaces'; * cards into one entry the user can expand to pick a specific variant. */ export interface VariantGroup { - /** Stable key: content type + normalized title (or a unique fallback). */ + /** Stable key: content type + normalized title (+ playlist/year). */ key: string; /** Cleanest member title, used as the collapsed card label. */ displayTitle: string; - /** Best-ranked member — opened when the group has a single variant. */ + /** Best-ranked member with a poster — opened for a single variant. */ representative: XtreamSearchResultItem; /** All members, in the original (ranked) order. */ items: XtreamSearchResultItem[]; } +interface Bucket { + bucketKey: string; + members: { item: XtreamSearchResultItem; year: number | null }[]; +} + /** * Group results that share a normalized title AND display type. Type is * part of the key so a movie and a series with the same name never merge. - * Items whose title normalizes to an empty key (rare) each keep their own - * group so they are never silently collapsed together. Order is preserved: - * groups appear in first-seen order and members in their ranked order. + * + * Because the normalized base drops the release year, a bucket is split by + * year when it contains two or more distinct years — otherwise remakes + * like "Dune (1984)" and "Dune (2021)" would collapse into one card. A + * single (or absent) year keeps the whole bucket together, so the common + * case where only some variants carry the year still collapses cleanly. + * + * `keyPrefix` scopes the group key (e.g. per playlist) so the same title + * in two playlists expands independently. Order is preserved: groups + * appear in first-seen order and members in their ranked order. */ export function groupResultsByVariant( items: readonly XtreamSearchResultItem[], - getDisplayType: (item: XtreamSearchResultItem) => string + getDisplayType: (item: XtreamSearchResultItem) => string, + keyPrefix = '' ): VariantGroup[] { - const groups = new Map(); + const buckets = new Map(); + const order: string[] = []; for (const item of items) { const type = getDisplayType(item); const base = normalizeTitleKeys(item.title).base; - const key = base ? `${type}::${base}` : `${type}::id:${item.id}`; - const cleaned = stripCountryPrefix(item.title) || item.title; + const bucketKey = base ? `${type}::${base}` : `${type}::id:${item.id}`; + let bucket = buckets.get(bucketKey); + if (!bucket) { + bucket = { bucketKey, members: [] }; + buckets.set(bucketKey, bucket); + order.push(bucketKey); + } + bucket.members.push({ item, year: extractYear(null, item.title) }); + } - const existing = groups.get(key); - if (existing) { - existing.items.push(item); - // Prefer the shortest cleaned title — it is the least - // tag-polluted label ("The Pitt" over "The Pitt (2025) DE"). - if (cleaned.length < existing.displayTitle.length) { - existing.displayTitle = cleaned; + const groups: VariantGroup[] = []; + for (const bucketKey of order) { + const members = buckets.get(bucketKey)!.members; + const distinctYears = new Set( + members + .map((m) => m.year) + .filter((year): year is number => year !== null) + ); + + if (distinctYears.size < 2) { + groups.push( + buildGroup( + `${keyPrefix}${bucketKey}`, + members.map((m) => m.item) + ) + ); + continue; + } + + const byYear = new Map(); + const yearOrder: string[] = []; + for (const { item, year } of members) { + const yearKey = year !== null ? String(year) : ''; + if (!byYear.has(yearKey)) { + byYear.set(yearKey, []); + yearOrder.push(yearKey); } - } else { - groups.set(key, { - key, - displayTitle: cleaned, - representative: item, - items: [item], - }); + byYear.get(yearKey)!.push(item); + } + for (const yearKey of yearOrder) { + groups.push( + buildGroup( + `${keyPrefix}${bucketKey}::${yearKey}`, + byYear.get(yearKey)! + ) + ); } } - return [...groups.values()]; + return groups; +} + +function buildGroup( + key: string, + items: XtreamSearchResultItem[] +): VariantGroup { + let representative = items[0]; + let displayTitle = stripCountryPrefix(items[0].title) || items[0].title; + + for (let index = 1; index < items.length; index++) { + const item = items[index]; + // Prefer the shortest cleaned title — the least tag-polluted label + // ("The Pitt" over "The Pitt (2025) DE"). + const cleaned = stripCountryPrefix(item.title) || item.title; + if (cleaned.length < displayTitle.length) { + displayTitle = cleaned; + } + // Keep the best-ranked member, but skip past leading posterless + // clones so the collapsed card shows real artwork when any exists. + if (!representative.poster_url && item.poster_url) { + representative = item; + } + } + + return { key, displayTitle, representative, items }; } diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts index 6e00cbdb0..a684325db 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts @@ -526,6 +526,81 @@ describe('SearchResultsComponent variant grouping', () => { expect(router.navigate).toHaveBeenCalled(); }); + it('scopes expansion to each playlist so the same title stays independent', () => { + const component = build(); + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + store.searchResults.set([ + createSearchItem({ + id: 1, + title: 'DE| The Pitt', + xtream_id: 1, + playlist_id: 'p1', + playlist_name: 'One', + }), + createSearchItem({ + id: 2, + title: 'FR| The Pitt', + xtream_id: 2, + playlist_id: 'p1', + playlist_name: 'One', + }), + createSearchItem({ + id: 3, + title: 'DE| The Pitt', + xtream_id: 3, + playlist_id: 'p2', + playlist_name: 'Two', + }), + createSearchItem({ + id: 4, + title: 'FR| The Pitt', + xtream_id: 4, + playlist_id: 'p2', + playlist_name: 'Two', + }), + ]); + component.groupByPlaylist.set(true); + + const [sectionOne, sectionTwo] = component.playlistSections(); + expect(sectionOne.variantGroups[0].key).not.toBe( + sectionTwo.variantGroups[0].key + ); + + component.selectVariantGroup(sectionOne.variantGroups[0]); + expect( + component.isVariantExpanded(sectionOne.variantGroups[0].key) + ).toBe(true); + expect( + component.isVariantExpanded(sectionTwo.variantGroups[0].key) + ).toBe(false); + }); + + it('auto-loads more pages when grouping collapses below the scroll threshold', async () => { + const firstPage = Array.from({ length: 101 }, (_, index) => + createSearchItem({ + id: index + 1, + title: 'DE| The Pitt', + xtream_id: index + 1, + }) + ); + const databaseService = TestBed.inject(DatabaseService) as { + globalSearchContent: jest.Mock; + }; + databaseService.globalSearchContent + .mockResolvedValueOnce(firstPage) + .mockResolvedValue([]); + + const component = build(); + component.groupByPlaylist.set(false); + + await component.searchGlobal('the pitt', ['movie'], false); + + // One collapsed card from 100 items must have triggered backfill. + expect( + databaseService.globalSearchContent.mock.calls.length + ).toBeGreaterThan(1); + }); + it('persists the toggle and clears expansion when turned off', () => { const component = build(); const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index ef1c6a1f6..07fcb8969 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -135,6 +135,14 @@ export class SearchResultsComponent implements AfterViewInit { 'global-search-group-by-playlist'; private static readonly GROUP_SIMILAR_STORAGE_KEY = 'global-search-group-similar'; + /** + * Grouping can collapse a full page into a handful of cards that never + * overflow the viewport, so the scroll-driven `nearEnd` pager never + * fires. Keep pulling pages until there are plausibly enough cards to + * scroll, bounded so a heavily-collapsed result set can't page forever. + */ + private static readonly MIN_CARDS_BEFORE_SCROLL = 24; + private static readonly MAX_AUTO_FILL_PAGES = 5; private static readonly EXCLUDE_HIDDEN_STORAGE_KEY = 'xtream-search-exclude-hidden'; private static readonly TYPE_FILTERS_STORAGE_KEY = @@ -203,7 +211,13 @@ export class SearchResultsComponent implements AfterViewInit { return this.groupedResults().map((group) => ({ ...group, variantGroups: collapse - ? groupResultsByVariant(group.items, this.displayType) + ? groupResultsByVariant( + group.items, + this.displayType, + // Scope keys per playlist so the same title expands + // independently across sections. + `${group.playlistId}::` + ) : [], })); }); @@ -446,6 +460,44 @@ export class SearchResultsComponent implements AfterViewInit { } } } + + if (!append && requestVersion === this.globalSearchRequestVersion) { + await this.autoFillWhileSparse(); + } + } + + /** Top-level cards currently rendered (groups when collapsing). */ + private topLevelCardCount(): number { + if (!this.groupSimilar()) { + return this.xtreamStore.searchResults().length; + } + return this.groupByPlaylist() + ? this.playlistSections().reduce( + (total, section) => total + section.variantGroups.length, + 0 + ) + : this.flatVariantGroups().length; + } + + /** + * When grouping leaves too few cards to scroll, pull further pages so + * the hidden remainder becomes reachable. Bounded by page count and + * short-circuits as soon as enough cards exist or no more pages remain. + */ + private async autoFillWhileSparse(): Promise { + let pages = 0; + while ( + this.groupSimilar() && + this.hasMoreGlobalResults() && + !this.isLoadingMoreGlobalResults() && + !this.xtreamStore.isSearching() && + this.topLevelCardCount() < + SearchResultsComponent.MIN_CARDS_BEFORE_SCROLL && + pages < SearchResultsComponent.MAX_AUTO_FILL_PAGES + ) { + pages++; + await this.loadMoreGlobalResults(); + } } async loadMoreGlobalResults(): Promise { @@ -527,6 +579,10 @@ export class SearchResultsComponent implements AfterViewInit { SearchResultsComponent.GROUP_SIMILAR_STORAGE_KEY, String(value) ); + // Turning grouping on can leave too few cards to scroll; backfill. + if (value) { + void this.autoFillWhileSparse(); + } } isVariantExpanded(key: string): boolean {