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..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(