Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/ary.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/by.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/i18n/zhtw.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
{{ type() }}
</span>
}

@if (countBadge() > 1) {
<span class="count-badge" aria-hidden="true">
×{{ countBadge() }}
</span>
}
</div>

<div class="card-info">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export class ContentCardComponent {
/** Whether to render the type badge (live/movie/series) on the poster */
readonly showTypeBadge = input<boolean>(true);

/**
* Number of collapsed variants behind this card. Rendered as a small
* "×N" pill when greater than 1 (global-search variant grouping).
*/
readonly countBadge = input<number>(0);

/** Emitted when the card is clicked */
readonly cardClick = output<void>();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access';
import { groupResultsByVariant } from './global-search-grouping.util';

function item(
title: string,
overrides: Partial<XtreamSearchResultItem> = {}
): XtreamSearchResultItem {
return {
id: title,
title,
type: 'movie',
...overrides,
} as unknown as XtreamSearchResultItem;
}

const displayType = (i: XtreamSearchResultItem): string =>
(i as { type?: string }).type ?? 'movie';

describe('groupResultsByVariant', () => {
it('collapses tagged variants of one title into a single group', () => {
const groups = groupResultsByVariant(
[
item('DE| The Pitt'),
item('4K-TR - The Pitt (2025) (US)'),
item('The Pitt (2025)'),
item('|ALB| The Pitt'),
],
displayType
);

expect(groups).toHaveLength(1);
expect(groups[0].items).toHaveLength(4);
expect(groups[0].representative.title).toBe('DE| The Pitt');
});

it('labels the group with the cleanest member title', () => {
const groups = groupResultsByVariant(
[item('DE| The Pitt'), item('The Pitt (2025)'), item('|ALB| The Pitt')],
displayType
);

expect(groups[0].displayTitle).toBe('The Pitt');
});

it('never merges different content types with the same title', () => {
const groups = groupResultsByVariant(
[
item('The Pitt', { type: 'series' }),
item('The Pitt', { type: 'movie' }),
],
displayType
);

expect(groups).toHaveLength(2);
});

it('keeps unrelated titles in separate groups', () => {
const groups = groupResultsByVariant(
[item('The Pitt'), item('The Pradeeps of Pittsburgh')],
displayType
);

expect(groups).toHaveLength(2);
});

it('preserves first-seen group order and ranked member order', () => {
const groups = groupResultsByVariant(
[
item('EN| Fallout'),
item('The Last of Us'),
item('DE| Fallout'),
],
displayType
);

expect(groups.map((g) => g.displayTitle)).toEqual([
'Fallout',
'The Last of Us',
]);
expect(groups[0].items.map((i) => i.title)).toEqual([
'EN| Fallout',
'DE| Fallout',
]);
});

it('gives titles that normalize to nothing their own group', () => {
const groups = groupResultsByVariant(
[item('|DE|', { id: 'a' }), item('|FR|', { id: 'b' })],
displayType
);

expect(groups).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access';
import { stripCountryPrefix } from '@iptvnator/shared/m3u-utils';
import { normalizeTitleKeys } from '@iptvnator/shared/interfaces';

/**
* A set of provider results that are the same title/type under different
* language, quality or provider tags ("DE| The Pitt", "4K-TR - The Pitt",
* "The Pitt (2025)"). Collapsing them turns a flood of near-duplicate
* cards into one entry the user can expand to pick a specific variant.
*/
export interface VariantGroup {
/** Stable key: content type + normalized title (or a unique fallback). */
key: string;
/** Cleanest member title, used as the collapsed card label. */
displayTitle: string;
/** Best-ranked member — opened when the group has a single variant. */
representative: XtreamSearchResultItem;
/** All members, in the original (ranked) order. */
items: XtreamSearchResultItem[];
}

/**
* Group results that share a normalized title AND display type. Type is
* part of the key so a movie and a series with the same name never merge.
* Items whose title normalizes to an empty key (rare) each keep their own
* group so they are never silently collapsed together. Order is preserved:
* groups appear in first-seen order and members in their ranked order.
*/
export function groupResultsByVariant(
items: readonly XtreamSearchResultItem[],
getDisplayType: (item: XtreamSearchResultItem) => string
): VariantGroup[] {
const groups = new Map<string, VariantGroup>();

for (const item of items) {
const type = getDisplayType(item);
const base = normalizeTitleKeys(item.title).base;
const key = base ? `${type}::${base}` : `${type}::id:${item.id}`;
const cleaned = stripCountryPrefix(item.title) || item.title;

const existing = groups.get(key);
if (existing) {
existing.items.push(item);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
// 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,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
items: [item],
});
}
}

return [...groups.values()];
}
Loading
Loading