feat(search): collapse language/quality variants in global search

Global search for a popular title returns dozens of near-duplicate
cards — one per language/quality/provider tag ("DE| The Pitt",
"4K-TR - The Pitt (2025) (US)", "|ALB| The Pitt"). A new "Group similar
titles" toggle (default on, persisted) collapses variants sharing a
normalized title and content type into a single card with a "×N" badge.

- groupResultsByVariant() keys on normalizeTitleKeys(title).base + type
  (from PR #1211), so a movie and a series with the same name never
  merge; the collapsed card shows the cleanest member title
- clicking a multi-variant card expands it inline to reveal every
  version (the raw titles show the language); clicking again collapses;
  a single-variant group opens directly
- composes with the existing "group by playlist" toggle: variants
  collapse within each playlist section, or globally in the flat list
- ContentCardComponent gains an optional countBadge input
- SEARCH_VIEW.GROUP_SIMILAR_TITLES added to all 18 locales

The version picker (choose language up front) stays a follow-up; this
change removes the visual flood without a new picker component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-19 20:03:39 +02:00
1 parent 402382421c
commit ba69d4b75d
27 files changed
+490 -22

No files matched your search

+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "تم العثور على {{count}} نتيجة",
"NO_RESULTS_FOR": "لم يتم العثور على نتائج لـ \"{{term}}\"",
"GROUP_BY_PLAYLIST": "تجميع النتائج حسب قائمة التشغيل",
"GROUP_SIMILAR_TITLES": "تجميع العناوين المتشابهة",
"VISIBLE_CATEGORIES_ONLY": "الفئات المرئية فقط",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "تلقاو {{count}} ديال النتايج",
"NO_RESULTS_FOR": "ما تلقاو حتى نتيجة لـ \"{{term}}\"",
"GROUP_BY_PLAYLIST": "جمع النتائج حسب قائمة التشغيل",
"GROUP_SIMILAR_TITLES": "جمع العناوين المتشابهة",
"VISIBLE_CATEGORIES_ONLY": "غير الفئات الظاهرة",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "Знойдзена {{count}} вынікаў",
"NO_RESULTS_FOR": "Не знойдзена вынікаў для \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Групаваць вынікі па плэйлісце",
"GROUP_SIMILAR_TITLES": "Групаваць падобныя назвы",
"VISIBLE_CATEGORIES_ONLY": "Толькі бачныя катэгорыі",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "{{count}} Ergebnisse gefunden",
"NO_RESULTS_FOR": "Keine Ergebnisse für \"{{term}}\" gefunden",
"GROUP_BY_PLAYLIST": "Ergebnisse nach Playlist gruppieren",
"GROUP_SIMILAR_TITLES": "Ähnliche Titel gruppieren",
"VISIBLE_CATEGORIES_ONLY": "Nur sichtbare Kategorien",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "Βρέθηκαν {{count}} αποτελέσματα",
"NO_RESULTS_FOR": "Δεν βρέθηκαν αποτελέσματα για \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Ομαδοποίηση αποτελεσμάτων ανά λίστα αναπαραγωγής",
"GROUP_SIMILAR_TITLES": "Ομαδοποίηση παρόμοιων τίτλων",
"VISIBLE_CATEGORIES_ONLY": "Μόνο ορατές κατηγορίες",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "Found {{count}} results",
"NO_RESULTS_FOR": "No results found for \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Group results by playlist",
"GROUP_SIMILAR_TITLES": "Group similar titles",
"VISIBLE_CATEGORIES_ONLY": "Visible categories only",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "Se encontraron {{count}} resultados",
"NO_RESULTS_FOR": "No se encontraron resultados para \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Agrupar resultados por lista de reproducción",
"GROUP_SIMILAR_TITLES": "Agrupar títulos similares",
"VISIBLE_CATEGORIES_ONLY": "Solo categorías visibles",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "{{count}} résultats trouvés",
"NO_RESULTS_FOR": "Aucun résultat trouvé pour \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Grouper les résultats par liste de lecture",
"GROUP_SIMILAR_TITLES": "Grouper les titres similaires",
"VISIBLE_CATEGORIES_ONLY": "Catégories visibles uniquement",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "Trovati {{count}} risultati",
"NO_RESULTS_FOR": "Nessun risultato trovato per \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Raggruppa risultati per playlist",
"GROUP_SIMILAR_TITLES": "Raggruppa titoli simili",
"VISIBLE_CATEGORIES_ONLY": "Solo categorie visibili",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "{{count}}件の結果が見つかりました",
"NO_RESULTS_FOR": "「{{term}}」に一致する結果は見つかりませんでした",
"GROUP_BY_PLAYLIST": "プレイリストごとに結果をグループ化",
"GROUP_SIMILAR_TITLES": "類似タイトルをグループ化",
"VISIBLE_CATEGORIES_ONLY": "表示中のカテゴリーのみ",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "{{count}}개의 결과를 찾았습니다",
"NO_RESULTS_FOR": "\"{{term}}\"에 대한 결과를 찾을 수 없습니다",
"GROUP_BY_PLAYLIST": "재생목록별로 결과 그룹화",
"GROUP_SIMILAR_TITLES": "유사한 제목 그룹화",
"VISIBLE_CATEGORIES_ONLY": "표시되는 카테고리만",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "{{count}} resultaten gevonden",
"NO_RESULTS_FOR": "Geen resultaten gevonden voor \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Resultaten groeperen op afspeellijst",
"GROUP_SIMILAR_TITLES": "Vergelijkbare titels groeperen",
"VISIBLE_CATEGORIES_ONLY": "Alleen zichtbare categorieën",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "Znaleziono {{count}} wyników",
"NO_RESULTS_FOR": "Brak wyników dla „{{term}}”",
"GROUP_BY_PLAYLIST": "Grupuj wyniki według listy odtwarzania",
"GROUP_SIMILAR_TITLES": "Grupuj podobne tytuły",
"VISIBLE_CATEGORIES_ONLY": "Tylko widoczne kategorie",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "{{count}} resultados encontrados",
"NO_RESULTS_FOR": "Nenhum resultado encontrado para \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Agrupar resultados por playlist",
"GROUP_SIMILAR_TITLES": "Agrupar títulos semelhantes",
"VISIBLE_CATEGORIES_ONLY": "Apenas categorias visíveis",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "Найдено {{count}} результатов",
"NO_RESULTS_FOR": "Нет результатов по запросу \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Группировать результаты по плейлистам",
"GROUP_SIMILAR_TITLES": "Группировать похожие названия",
"VISIBLE_CATEGORIES_ONLY": "Только видимые категории",
"GLOBAL_SEARCH_SOURCE_NOTE": "Глобальный поиск включает результаты из плейлистов Xtream и M3U.",
"GLOBAL_INITIAL_DESCRIPTION": "Введите минимум 2 символа для поиска по плейлистам Xtream и M3U.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "{{count}} sonuç bulundu",
"NO_RESULTS_FOR": "\"{{term}}\" için sonuç bulunamadı",
"GROUP_BY_PLAYLIST": "Sonuçları oynatma listesine göre grupla",
"GROUP_SIMILAR_TITLES": "Benzer başlıkları grupla",
"VISIBLE_CATEGORIES_ONLY": "Sadece görünür kategoriler",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "找到 {{count}} 条结果",
"NO_RESULTS_FOR": "未找到与“{{term}}”匹配的结果",
"GROUP_BY_PLAYLIST": "按播放列表分组结果",
"GROUP_SIMILAR_TITLES": "将相似标题分组",
"VISIBLE_CATEGORIES_ONLY": "仅可见分类",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
+1
View File
@@ -943,6 +943,7 @@
"RESULTS_FOUND": "找到 {{count}} 筆結果",
"NO_RESULTS_FOR": "找不到與「{{term}}」相符的結果",
"GROUP_BY_PLAYLIST": "依播放清單分組顯示結果",
"GROUP_SIMILAR_TITLES": "將相似標題分組",
"VISIBLE_CATEGORIES_ONLY": "僅顯示可見類別",
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
@@ -39,6 +39,12 @@
{{ type() }}
</span>
}
@if (countBadge() > 1) {
<span class="count-badge" aria-hidden="true">
×{{ countBadge() }}
</span>
}
</div>
<div class="card-info">
@@ -13,6 +13,19 @@
.type-badge {
@include grid.type-badge;
}
.count-badge {
position: absolute;
top: 6px;
right: 6px;
padding: 2px 7px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
color: #fff;
background: rgba(0, 0, 0, 0.72);
}
}
.card-info {
@@ -54,6 +54,12 @@ export class ContentCardComponent {
/** Whether to render the type badge (live/movie/series) on the poster */
readonly showTypeBadge = input<boolean>(true);
/**
* Number of collapsed variants behind this card. Rendered as a small
* "×N" pill when greater than 1 (global-search variant grouping).
*/
readonly countBadge = input<number>(0);
/** Emitted when the card is clicked */
readonly cardClick = output<void>();
@@ -0,0 +1,94 @@
import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access';
import { groupResultsByVariant } from './global-search-grouping.util';
function item(
title: string,
overrides: Partial<XtreamSearchResultItem> = {}
): XtreamSearchResultItem {
return {
id: title,
title,
type: 'movie',
...overrides,
} as unknown as XtreamSearchResultItem;
}
const displayType = (i: XtreamSearchResultItem): string =>
(i as { type?: string }).type ?? 'movie';
describe('groupResultsByVariant', () => {
it('collapses tagged variants of one title into a single group', () => {
const groups = groupResultsByVariant(
[
item('DE| The Pitt'),
item('4K-TR - The Pitt (2025) (US)'),
item('The Pitt (2025)'),
item('|ALB| The Pitt'),
],
displayType
);
expect(groups).toHaveLength(1);
expect(groups[0].items).toHaveLength(4);
expect(groups[0].representative.title).toBe('DE| The Pitt');
});
it('labels the group with the cleanest member title', () => {
const groups = groupResultsByVariant(
[item('DE| The Pitt'), item('The Pitt (2025)'), item('|ALB| The Pitt')],
displayType
);
expect(groups[0].displayTitle).toBe('The Pitt');
});
it('never merges different content types with the same title', () => {
const groups = groupResultsByVariant(
[
item('The Pitt', { type: 'series' }),
item('The Pitt', { type: 'movie' }),
],
displayType
);
expect(groups).toHaveLength(2);
});
it('keeps unrelated titles in separate groups', () => {
const groups = groupResultsByVariant(
[item('The Pitt'), item('The Pradeeps of Pittsburgh')],
displayType
);
expect(groups).toHaveLength(2);
});
it('preserves first-seen group order and ranked member order', () => {
const groups = groupResultsByVariant(
[
item('EN| Fallout'),
item('The Last of Us'),
item('DE| Fallout'),
],
displayType
);
expect(groups.map((g) => g.displayTitle)).toEqual([
'Fallout',
'The Last of Us',
]);
expect(groups[0].items.map((i) => i.title)).toEqual([
'EN| Fallout',
'DE| Fallout',
]);
});
it('gives titles that normalize to nothing their own group', () => {
const groups = groupResultsByVariant(
[item('|DE|', { id: 'a' }), item('|FR|', { id: 'b' })],
displayType
);
expect(groups).toHaveLength(2);
});
});
@@ -0,0 +1,60 @@
import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access';
import { stripCountryPrefix } from '@iptvnator/shared/m3u-utils';
import { normalizeTitleKeys } from '@iptvnator/shared/interfaces';
/**
* A set of provider results that are the same title/type under different
* language, quality or provider tags ("DE| The Pitt", "4K-TR - The Pitt",
* "The Pitt (2025)"). Collapsing them turns a flood of near-duplicate
* 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). */
key: string;
/** Cleanest member title, used as the collapsed card label. */
displayTitle: string;
/** Best-ranked member — opened when the group has a single variant. */
representative: XtreamSearchResultItem;
/** All members, in the original (ranked) order. */
items: XtreamSearchResultItem[];
}
/**
* 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.
*/
export function groupResultsByVariant(
items: readonly XtreamSearchResultItem[],
getDisplayType: (item: XtreamSearchResultItem) => string
): VariantGroup[] {
const groups = new Map<string, VariantGroup>();
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 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;
}
} else {
groups.set(key, {
key,
displayTitle: cleaned,
representative: item,
items: [item],
});
}
}
return [...groups.values()];
}
@@ -87,6 +87,15 @@
| translate
}}
</mat-checkbox>
<mat-checkbox
[ngModel]="groupSimilar()"
(ngModelChange)="toggleGroupSimilar($event)"
>
{{
'PORTALS.SEARCH_VIEW.GROUP_SIMILAR_TITLES'
| translate
}}
</mat-checkbox>
</div>
</div>
</div>
@@ -104,26 +113,36 @@
Found {{ xtreamStore.searchResults().length }} items
across {{ groupedResults().length }} playlists
</h2>
@for (group of groupedResults(); track group.playlistId) {
@for (section of playlistSections(); track
section.playlistId) {
<div class="playlist-group">
<h3 class="playlist-title">
<mat-icon>playlist_play</mat-icon>
{{ group.playlistName }}
{{ section.playlistName }}
<span class="item-count"
>({{ group.items.length }} items)</span
>({{ section.items.length }} items)</span
>
</h3>
<div class="results-grid">
@for (item of group.items; track item.id) {
<app-content-card
[posterUrl]="
item.poster_url ?? undefined
"
[title]="item.title"
[type]="getDisplayType(item)"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
@if (groupSimilar()) {
@for (vg of section.variantGroups; track
vg.key) {
<ng-container
[ngTemplateOutlet]="variantTpl"
[ngTemplateOutletContext]="{
$implicit: vg,
}"
/>
}
} @else {
@for (item of section.items; track item.id) {
<ng-container
[ngTemplateOutlet]="itemTpl"
[ngTemplateOutletContext]="{
$implicit: item,
}"
/>
}
}
</div>
</div>
@@ -132,14 +151,23 @@
} @else {
<!-- Flat list -->
<div class="results-grid">
@for (item of xtreamStore.searchResults(); track item.id) {
<app-content-card
[posterUrl]="item.poster_url ?? undefined"
[title]="item.title"
[type]="getDisplayType(item)"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
@if (groupSimilar()) {
@for (vg of flatVariantGroups(); track vg.key) {
<ng-container
[ngTemplateOutlet]="variantTpl"
[ngTemplateOutletContext]="{ $implicit: vg }"
/>
}
} @else {
@for (
item of xtreamStore.searchResults();
track item.id
) {
<ng-container
[ngTemplateOutlet]="itemTpl"
[ngTemplateOutletContext]="{ $implicit: item }"
/>
}
}
</div>
}
@@ -170,3 +198,48 @@
}
</ng-container>
</app-search-layout>
<!-- Single result card -->
<ng-template #itemTpl let-item>
<app-content-card
[posterUrl]="item.poster_url ?? undefined"
[title]="item.title"
[type]="getDisplayType(item)"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
</ng-template>
<!-- Variant group: collapsed card (+ expanded members) or a single card -->
<ng-template #variantTpl let-vg>
@if (vg.items.length > 1) {
<app-content-card
[posterUrl]="vg.representative.poster_url ?? undefined"
[title]="vg.displayTitle"
[type]="getDisplayType(vg.representative)"
[showPlaceholder]="true"
[countBadge]="vg.items.length"
(cardClick)="selectVariantGroup(vg)"
/>
@if (isVariantExpanded(vg.key)) {
@for (item of vg.items; track item.id) {
<app-content-card
class="variant-member"
[posterUrl]="item.poster_url ?? undefined"
[title]="item.title"
[type]="getDisplayType(item)"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
}
}
} @else {
<app-content-card
[posterUrl]="vg.representative.poster_url ?? undefined"
[title]="vg.displayTitle"
[type]="getDisplayType(vg.representative)"
[showPlaceholder]="true"
(cardClick)="selectItem(vg.representative)"
/>
}
</ng-template>
@@ -128,3 +128,10 @@
color: var(--mat-sys-on-surface-variant);
font-size: 0.85rem;
}
/* Expanded variants read as belonging to the collapsed group above them. */
.variant-member {
outline: 1px dashed var(--mat-sys-outline-variant);
outline-offset: -1px;
border-radius: 8px;
}
@@ -434,3 +434,115 @@ describe('SearchResultsComponent initialQuery contract', () => {
expect(store.searchResults()).toHaveLength(2);
});
});
describe('SearchResultsComponent variant grouping', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
{ provide: XtreamStore, useClass: MockXtreamStore },
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { isGlobalSearch: true, layout: 'workspace' },
queryParamMap: convertToParamMap({ q: '' }),
},
queryParamMap: of(convertToParamMap({ q: '' })),
},
},
{
provide: DatabaseService,
useValue: {
globalSearchContent: jest.fn().mockResolvedValue([]),
},
},
],
});
});
const build = () =>
TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{ isGlobalSearch: true },
undefined
)
);
it('defaults to grouping similar titles on', () => {
expect(build().groupSimilar()).toBe(true);
});
it('collapses tagged variants into one flat group', () => {
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 }),
createSearchItem({ id: 2, title: 'The Pitt (2025)', xtream_id: 2 }),
createSearchItem({ id: 3, title: '|ALB| The Pitt', xtream_id: 3 }),
]);
const groups = component.flatVariantGroups();
expect(groups).toHaveLength(1);
expect(groups[0].items).toHaveLength(3);
expect(groups[0].displayTitle).toBe('The Pitt');
});
it('expands and collapses a multi-variant group', () => {
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 }),
createSearchItem({ id: 2, title: 'FR| The Pitt', xtream_id: 2 }),
]);
const group = component.flatVariantGroups()[0];
expect(component.isVariantExpanded(group.key)).toBe(false);
component.selectVariantGroup(group);
expect(component.isVariantExpanded(group.key)).toBe(true);
component.selectVariantGroup(group);
expect(component.isVariantExpanded(group.key)).toBe(false);
});
it('opens a single-variant group directly without expanding', () => {
const component = build();
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const router = TestBed.inject(Router);
store.searchResults.set([
createSearchItem({
id: 1,
title: 'Solo Movie',
xtream_id: 1,
playlist_id: 'playlist-1',
}),
]);
const group = component.flatVariantGroups()[0];
component.selectVariantGroup(group);
expect(component.isVariantExpanded(group.key)).toBe(false);
expect(router.navigate).toHaveBeenCalled();
});
it('persists the toggle and clears expansion when turned off', () => {
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 }),
createSearchItem({ id: 2, title: 'FR| The Pitt', xtream_id: 2 }),
]);
const group = component.flatVariantGroups()[0];
component.selectVariantGroup(group);
expect(component.isVariantExpanded(group.key)).toBe(true);
component.toggleGroupSimilar(false);
expect(component.groupSimilar()).toBe(false);
expect(component.isVariantExpanded(group.key)).toBe(false);
expect(
localStorage.getItem('global-search-group-similar')
).toBe('false');
});
});
@@ -10,7 +10,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { Location } from '@angular/common';
import { Location, NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -42,6 +42,10 @@ import {
GlobalSearchResult,
isM3uGlobalSearchResult,
} from '@iptvnator/shared/interfaces';
import {
groupResultsByVariant,
VariantGroup,
} from './global-search-grouping.util';
interface SearchResultsData {
isGlobalSearch: boolean;
@@ -54,6 +58,11 @@ interface GlobalSearchResultGroup {
items: XtreamSearchResultItem[];
}
interface PlaylistSection extends GlobalSearchResultGroup {
/** Populated only when "group similar titles" is on. */
variantGroups: VariantGroup[];
}
const GLOBAL_SEARCH_PAGE_SIZE = 100;
function groupResultsByPlaylistId(
@@ -87,6 +96,7 @@ function groupResultsByPlaylistId(
MatIcon,
MatIconButton,
MatProgressSpinner,
NgTemplateOutlet,
SearchLayoutComponent,
TranslatePipe,
],
@@ -123,6 +133,8 @@ export class SearchResultsComponent implements AfterViewInit {
private static readonly GROUP_BY_STORAGE_KEY =
'global-search-group-by-playlist';
private static readonly GROUP_SIMILAR_STORAGE_KEY =
'global-search-group-similar';
private static readonly EXCLUDE_HIDDEN_STORAGE_KEY =
'xtream-search-exclude-hidden';
private static readonly TYPE_FILTERS_STORAGE_KEY =
@@ -136,6 +148,18 @@ export class SearchResultsComponent implements AfterViewInit {
'false'
);
/** Whether to collapse same-title/type variants into one card */
readonly groupSimilar = signal(
localStorage.getItem(
SearchResultsComponent.GROUP_SIMILAR_STORAGE_KEY
) !== 'false'
);
/** Variant-group keys the user has expanded to reveal every version. */
private readonly expandedVariantKeys = signal<ReadonlySet<string>>(
new Set()
);
/** Whether to exclude content from hidden categories */
readonly excludeHidden = signal(
localStorage.getItem(
@@ -170,6 +194,30 @@ export class SearchResultsComponent implements AfterViewInit {
return groupResultsByPlaylistId(results);
});
private readonly displayType = (item: XtreamSearchResultItem): string =>
this.getDisplayType(item);
/** Playlist sections with per-playlist variant grouping applied. */
readonly playlistSections = computed<PlaylistSection[]>(() => {
const collapse = this.groupSimilar();
return this.groupedResults().map((group) => ({
...group,
variantGroups: collapse
? groupResultsByVariant(group.items, this.displayType)
: [],
}));
});
/** Variant groups for the flat (ungrouped-by-playlist) list. */
readonly flatVariantGroups = computed<VariantGroup[]>(() =>
this.groupSimilar()
? groupResultsByVariant(
this.xtreamStore.searchResults(),
this.displayType
)
: []
);
constructor(
@Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData | null,
@Optional() public dialogRef: MatDialogRef<SearchResultsComponent>
@@ -290,6 +338,7 @@ export class SearchResultsComponent implements AfterViewInit {
this.lastGlobalSearchTerm = '';
this.lastGlobalSearchTypes = [];
this.lastGlobalSearchExcludeHidden = undefined;
this.expandedVariantKeys.set(new Set());
this.xtreamStore.setGlobalSearchResults([]);
}
@@ -338,6 +387,7 @@ export class SearchResultsComponent implements AfterViewInit {
this.lastGlobalSearchExcludeHidden = effectiveExcludeHidden;
this.hasMoreGlobalResults.set(false);
this.isLoadingMoreGlobalResults.set(false);
this.expandedVariantKeys.set(new Set());
this.xtreamStore.setIsSearching(true);
}
@@ -470,6 +520,35 @@ export class SearchResultsComponent implements AfterViewInit {
);
}
toggleGroupSimilar(value: boolean) {
this.groupSimilar.set(value);
this.expandedVariantKeys.set(new Set());
localStorage.setItem(
SearchResultsComponent.GROUP_SIMILAR_STORAGE_KEY,
String(value)
);
}
isVariantExpanded(key: string): boolean {
return this.expandedVariantKeys().has(key);
}
/** Single variant → open it; multiple → toggle the expanded member list. */
selectVariantGroup(group: VariantGroup) {
if (group.items.length === 1) {
this.selectItem(group.representative);
return;
}
const expanded = new Set(this.expandedVariantKeys());
if (expanded.has(group.key)) {
expanded.delete(group.key);
} else {
expanded.add(group.key);
}
this.expandedVariantKeys.set(expanded);
}
toggleExcludeHidden(value: boolean) {
this.excludeHidden.set(value);
localStorage.setItem(