From ba69d4b75d2f33106d12d486be277e9e62451695 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 19 Jul 2026 20:03:39 +0200 Subject: [PATCH] feat(search): collapse language/quality variants in global search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + .../content-card/content-card.component.html | 6 + .../content-card/content-card.component.scss | 13 ++ .../content-card/content-card.component.ts | 6 + .../global-search-grouping.util.spec.ts | 94 ++++++++++++++ .../global-search-grouping.util.ts | 60 +++++++++ .../search-results.component.html | 115 ++++++++++++++---- .../search-results.component.scss | 7 ++ .../search-results.component.spec.ts | 112 +++++++++++++++++ .../search-results.component.ts | 81 +++++++++++- 27 files changed, 490 insertions(+), 22 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.ts 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) { + + }
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..468315f16 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.spec.ts @@ -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 { + 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); + }); +}); 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..d91087311 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/search-results/global-search-grouping.util.ts @@ -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(); + + 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()]; +} 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..6e00cbdb0 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,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'); + }); +}); 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..ef1c6a1f6 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,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>( + 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(() => { + 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(() => + this.groupSimilar() + ? groupResultsByVariant( + this.xtreamStore.searchResults(), + this.displayType + ) + : [] + ); + constructor( @Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData | null, @Optional() public dialogRef: MatDialogRef @@ -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(