mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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 {
|
||||
|
||||
Reference in new issue
Block a user