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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-19 20:54:35 +02:00
1 parent ba69d4b75d
commit ab3e2fc793
4 files changed
+272 -26

No files matched your search

@@ -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);
});
});
@@ -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<string, VariantGroup>();
const buckets = new Map<string, Bucket>();
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<string, XtreamSearchResultItem[]>();
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 };
}
@@ -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;
@@ -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<void> {
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<void> {
@@ -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 {