diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index bf4ec546f..ea6b7fe8b 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index 34d2bc61a..5fd212f1b 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index 307fe897c..0f1dddb80 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index e530ef81d..4171e0f56 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index 24ad44f67..47c7740af 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index d0e7651a2..a49350530 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index 752453717..fd2912129 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index ae7d75e39..f568a35de 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index 4b970e7e0..9bbba71ff 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index 2d108d99c..b2cf7a579 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 25b7226bd..e3528d086 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index fff5dfbee..f3bb7e6f4 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index d853d98cb..d2ca3f962 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index c0cd4ade0..244d9ba89 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index 414e25772..6622469f8 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index 24c48b1c3..338d522b0 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index db2db90ae..69583a5f5 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -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.",
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index ed43744f8..8e6fb3c80 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -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.",
diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html
index a89a09cce..afc77fd93 100644
--- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html
+++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html
@@ -39,6 +39,12 @@
{{ type() }}
}
+
+ @if (countBadge() > 1) {
+
+ ×{{ countBadge() }}
+
+ }
diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss
index 6cc36dea1..3de2d70e4 100644
--- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss
+++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss
@@ -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 {
diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts
index 6c8ae46de..7ec198be8 100644
--- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts
@@ -54,6 +54,12 @@ export class ContentCardComponent {
/** Whether to render the type badge (live/movie/series) on the poster */
readonly showTypeBadge = input(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(0);
+
/** Emitted when the card is clicked */
readonly cardClick = output();
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
new file mode 100644
index 000000000..cc53e5327
--- /dev/null
+++ b/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.spec.ts
@@ -0,0 +1,142 @@
+import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access';
+import { groupResultsByVariant } from './global-search-grouping.util';
+
+function item(
+ title: string,
+ overrides: Partial = {}
+): 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);
+ });
+
+ 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
new file mode 100644
index 000000000..577a63863
--- /dev/null
+++ b/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.ts
@@ -0,0 +1,127 @@
+import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access';
+import { stripCountryPrefix } from '@iptvnator/shared/m3u-utils';
+import { extractYear, 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 (+ playlist/year). */
+ key: string;
+ /** Cleanest member title, used as the collapsed card label. */
+ displayTitle: string;
+ /** 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.
+ *
+ * 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,
+ keyPrefix = ''
+): VariantGroup[] {
+ const buckets = new Map();
+ const order: string[] = [];
+
+ for (const item of items) {
+ const type = getDisplayType(item);
+ const base = normalizeTitleKeys(item.title).base;
+ 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 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);
+ }
+ byYear.get(yearKey)!.push(item);
+ }
+ for (const yearKey of yearOrder) {
+ groups.push(
+ buildGroup(
+ `${keyPrefix}${bucketKey}::${yearKey}`,
+ byYear.get(yearKey)!
+ )
+ );
+ }
+ }
+
+ 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.html b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html
index e4c93bbb9..c52a51c59 100644
--- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html
+++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html
@@ -87,6 +87,15 @@
| translate
}}
+
+ {{
+ 'PORTALS.SEARCH_VIEW.GROUP_SIMILAR_TITLES'
+ | translate
+ }}
+
@@ -104,26 +113,36 @@
Found {{ xtreamStore.searchResults().length }} items
across {{ groupedResults().length }} playlists
- @for (group of groupedResults(); track group.playlistId) {
+ @for (section of playlistSections(); track
+ section.playlistId) {
playlist_play
- {{ group.playlistName }}
+ {{ section.playlistName }}
({{ group.items.length }} items)({{ section.items.length }} items)
- @for (item of group.items; track item.id) {
-
+ @if (groupSimilar()) {
+ @for (vg of section.variantGroups; track
+ vg.key) {
+
+ }
+ } @else {
+ @for (item of section.items; track item.id) {
+
+ }
}
@@ -132,14 +151,23 @@
} @else {
- @for (item of xtreamStore.searchResults(); track item.id) {
-
+ @if (groupSimilar()) {
+ @for (vg of flatVariantGroups(); track vg.key) {
+
+ }
+ } @else {
+ @for (
+ item of xtreamStore.searchResults();
+ track item.id
+ ) {
+
+ }
}
}
@@ -170,3 +198,48 @@
}
+
+
+
+
+
+
+
+
+ @if (vg.items.length > 1) {
+
+ @if (isVariantExpanded(vg.key)) {
+ @for (item of vg.items; track item.id) {
+
+ }
+ }
+ } @else {
+
+ }
+
diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.scss b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.scss
index 9a017e691..3c0bb36a1 100644
--- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.scss
+++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.scss
@@ -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;
+}
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 372796444..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
@@ -434,3 +434,190 @@ 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('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;
+ 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');
+ });
+});
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 dff327772..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
@@ -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,16 @@ 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';
+ /**
+ * 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 =
@@ -136,6 +156,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>(
+ new Set()
+ );
+
/** Whether to exclude content from hidden categories */
readonly excludeHidden = signal(
localStorage.getItem(
@@ -170,6 +202,36 @@ 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(() => {
+ const collapse = this.groupSimilar();
+ return this.groupedResults().map((group) => ({
+ ...group,
+ variantGroups: collapse
+ ? groupResultsByVariant(
+ group.items,
+ this.displayType,
+ // Scope keys per playlist so the same title expands
+ // independently across sections.
+ `${group.playlistId}::`
+ )
+ : [],
+ }));
+ });
+
+ /** Variant groups for the flat (ungrouped-by-playlist) list. */
+ readonly flatVariantGroups = computed(() =>
+ this.groupSimilar()
+ ? groupResultsByVariant(
+ this.xtreamStore.searchResults(),
+ this.displayType
+ )
+ : []
+ );
+
constructor(
@Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData | null,
@Optional() public dialogRef: MatDialogRef
@@ -290,6 +352,7 @@ export class SearchResultsComponent implements AfterViewInit {
this.lastGlobalSearchTerm = '';
this.lastGlobalSearchTypes = [];
this.lastGlobalSearchExcludeHidden = undefined;
+ this.expandedVariantKeys.set(new Set());
this.xtreamStore.setGlobalSearchResults([]);
}
@@ -338,6 +401,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);
}
@@ -396,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 {
@@ -470,6 +572,39 @@ 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)
+ );
+ // Turning grouping on can leave too few cards to scroll; backfill.
+ if (value) {
+ void this.autoFillWhileSparse();
+ }
+ }
+
+ 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(