mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
feat(search): collapse language/quality variants in global search
Global search for a popular title returns dozens of near-duplicate
cards — one per language/quality/provider tag ("DE| The Pitt",
"4K-TR - The Pitt (2025) (US)", "|ALB| The Pitt"). A new "Group similar
titles" toggle (default on, persisted) collapses variants sharing a
normalized title and content type into a single card with a "×N" badge.
- groupResultsByVariant() keys on normalizeTitleKeys(title).base + type
(from PR #1211), so a movie and a series with the same name never
merge; the collapsed card shows the cleanest member title
- clicking a multi-variant card expands it inline to reveal every
version (the raw titles show the language); clicking again collapses;
a single-variant group opens directly
- composes with the existing "group by playlist" toggle: variants
collapse within each playlist section, or globally in the flat list
- ContentCardComponent gains an optional countBadge input
- SEARCH_VIEW.GROUP_SIMILAR_TITLES added to all 18 locales
The version picker (choose language up front) stays a follow-up; this
change removes the visual flood without a new picker component.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
402382421c
commit
ba69d4b75d
27 files changed
+490
-22
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -943,6 +943,7 @@
|
||||
"RESULTS_FOUND": "找到 {{count}} 筆結果",
|
||||
"NO_RESULTS_FOR": "找不到與「{{term}}」相符的結果",
|
||||
"GROUP_BY_PLAYLIST": "依播放清單分組顯示結果",
|
||||
"GROUP_SIMILAR_TITLES": "將相似標題分組",
|
||||
"VISIBLE_CATEGORIES_ONLY": "僅顯示可見類別",
|
||||
"GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.",
|
||||
"GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.",
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
{{ type() }}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (countBadge() > 1) {
|
||||
<span class="count-badge" aria-hidden="true">
|
||||
×{{ countBadge() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
|
||||
@@ -13,6 +13,19 @@
|
||||
.type-badge {
|
||||
@include grid.type-badge;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
|
||||
@@ -54,6 +54,12 @@ export class ContentCardComponent {
|
||||
/** Whether to render the type badge (live/movie/series) on the poster */
|
||||
readonly showTypeBadge = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Number of collapsed variants behind this card. Rendered as a small
|
||||
* "×N" pill when greater than 1 (global-search variant grouping).
|
||||
*/
|
||||
readonly countBadge = input<number>(0);
|
||||
|
||||
/** Emitted when the card is clicked */
|
||||
readonly cardClick = output<void>();
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access';
|
||||
import { groupResultsByVariant } from './global-search-grouping.util';
|
||||
|
||||
function item(
|
||||
title: string,
|
||||
overrides: Partial<XtreamSearchResultItem> = {}
|
||||
): XtreamSearchResultItem {
|
||||
return {
|
||||
id: title,
|
||||
title,
|
||||
type: 'movie',
|
||||
...overrides,
|
||||
} as unknown as XtreamSearchResultItem;
|
||||
}
|
||||
|
||||
const displayType = (i: XtreamSearchResultItem): string =>
|
||||
(i as { type?: string }).type ?? 'movie';
|
||||
|
||||
describe('groupResultsByVariant', () => {
|
||||
it('collapses tagged variants of one title into a single group', () => {
|
||||
const groups = groupResultsByVariant(
|
||||
[
|
||||
item('DE| The Pitt'),
|
||||
item('4K-TR - The Pitt (2025) (US)'),
|
||||
item('The Pitt (2025)'),
|
||||
item('|ALB| The Pitt'),
|
||||
],
|
||||
displayType
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].items).toHaveLength(4);
|
||||
expect(groups[0].representative.title).toBe('DE| The Pitt');
|
||||
});
|
||||
|
||||
it('labels the group with the cleanest member title', () => {
|
||||
const groups = groupResultsByVariant(
|
||||
[item('DE| The Pitt'), item('The Pitt (2025)'), item('|ALB| The Pitt')],
|
||||
displayType
|
||||
);
|
||||
|
||||
expect(groups[0].displayTitle).toBe('The Pitt');
|
||||
});
|
||||
|
||||
it('never merges different content types with the same title', () => {
|
||||
const groups = groupResultsByVariant(
|
||||
[
|
||||
item('The Pitt', { type: 'series' }),
|
||||
item('The Pitt', { type: 'movie' }),
|
||||
],
|
||||
displayType
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps unrelated titles in separate groups', () => {
|
||||
const groups = groupResultsByVariant(
|
||||
[item('The Pitt'), item('The Pradeeps of Pittsburgh')],
|
||||
displayType
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('preserves first-seen group order and ranked member order', () => {
|
||||
const groups = groupResultsByVariant(
|
||||
[
|
||||
item('EN| Fallout'),
|
||||
item('The Last of Us'),
|
||||
item('DE| Fallout'),
|
||||
],
|
||||
displayType
|
||||
);
|
||||
|
||||
expect(groups.map((g) => g.displayTitle)).toEqual([
|
||||
'Fallout',
|
||||
'The Last of Us',
|
||||
]);
|
||||
expect(groups[0].items.map((i) => i.title)).toEqual([
|
||||
'EN| Fallout',
|
||||
'DE| Fallout',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives titles that normalize to nothing their own group', () => {
|
||||
const groups = groupResultsByVariant(
|
||||
[item('|DE|', { id: 'a' }), item('|FR|', { id: 'b' })],
|
||||
displayType
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { XtreamSearchResultItem } from '@iptvnator/portal/xtream/data-access';
|
||||
import { stripCountryPrefix } from '@iptvnator/shared/m3u-utils';
|
||||
import { normalizeTitleKeys } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* A set of provider results that are the same title/type under different
|
||||
* language, quality or provider tags ("DE| The Pitt", "4K-TR - The Pitt",
|
||||
* "The Pitt (2025)"). Collapsing them turns a flood of near-duplicate
|
||||
* cards into one entry the user can expand to pick a specific variant.
|
||||
*/
|
||||
export interface VariantGroup {
|
||||
/** Stable key: content type + normalized title (or a unique fallback). */
|
||||
key: string;
|
||||
/** Cleanest member title, used as the collapsed card label. */
|
||||
displayTitle: string;
|
||||
/** Best-ranked member — opened when the group has a single variant. */
|
||||
representative: XtreamSearchResultItem;
|
||||
/** All members, in the original (ranked) order. */
|
||||
items: XtreamSearchResultItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group results that share a normalized title AND display type. Type is
|
||||
* part of the key so a movie and a series with the same name never merge.
|
||||
* Items whose title normalizes to an empty key (rare) each keep their own
|
||||
* group so they are never silently collapsed together. Order is preserved:
|
||||
* groups appear in first-seen order and members in their ranked order.
|
||||
*/
|
||||
export function groupResultsByVariant(
|
||||
items: readonly XtreamSearchResultItem[],
|
||||
getDisplayType: (item: XtreamSearchResultItem) => string
|
||||
): VariantGroup[] {
|
||||
const groups = new Map<string, VariantGroup>();
|
||||
|
||||
for (const item of items) {
|
||||
const type = getDisplayType(item);
|
||||
const base = normalizeTitleKeys(item.title).base;
|
||||
const key = base ? `${type}::${base}` : `${type}::id:${item.id}`;
|
||||
const cleaned = stripCountryPrefix(item.title) || item.title;
|
||||
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.items.push(item);
|
||||
// Prefer the shortest cleaned title — it is the least
|
||||
// tag-polluted label ("The Pitt" over "The Pitt (2025) DE").
|
||||
if (cleaned.length < existing.displayTitle.length) {
|
||||
existing.displayTitle = cleaned;
|
||||
}
|
||||
} else {
|
||||
groups.set(key, {
|
||||
key,
|
||||
displayTitle: cleaned,
|
||||
representative: item,
|
||||
items: [item],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -87,6 +87,15 @@
|
||||
| translate
|
||||
}}
|
||||
</mat-checkbox>
|
||||
<mat-checkbox
|
||||
[ngModel]="groupSimilar()"
|
||||
(ngModelChange)="toggleGroupSimilar($event)"
|
||||
>
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.GROUP_SIMILAR_TITLES'
|
||||
| translate
|
||||
}}
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,26 +113,36 @@
|
||||
Found {{ xtreamStore.searchResults().length }} items
|
||||
across {{ groupedResults().length }} playlists
|
||||
</h2>
|
||||
@for (group of groupedResults(); track group.playlistId) {
|
||||
@for (section of playlistSections(); track
|
||||
section.playlistId) {
|
||||
<div class="playlist-group">
|
||||
<h3 class="playlist-title">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
{{ group.playlistName }}
|
||||
{{ section.playlistName }}
|
||||
<span class="item-count"
|
||||
>({{ group.items.length }} items)</span
|
||||
>({{ section.items.length }} items)</span
|
||||
>
|
||||
</h3>
|
||||
<div class="results-grid">
|
||||
@for (item of group.items; track item.id) {
|
||||
<app-content-card
|
||||
[posterUrl]="
|
||||
item.poster_url ?? undefined
|
||||
"
|
||||
[title]="item.title"
|
||||
[type]="getDisplayType(item)"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
@if (groupSimilar()) {
|
||||
@for (vg of section.variantGroups; track
|
||||
vg.key) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="variantTpl"
|
||||
[ngTemplateOutletContext]="{
|
||||
$implicit: vg,
|
||||
}"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
@for (item of section.items; track item.id) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="itemTpl"
|
||||
[ngTemplateOutletContext]="{
|
||||
$implicit: item,
|
||||
}"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,14 +151,23 @@
|
||||
} @else {
|
||||
<!-- Flat list -->
|
||||
<div class="results-grid">
|
||||
@for (item of xtreamStore.searchResults(); track item.id) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url ?? undefined"
|
||||
[title]="item.title"
|
||||
[type]="getDisplayType(item)"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
@if (groupSimilar()) {
|
||||
@for (vg of flatVariantGroups(); track vg.key) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="variantTpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: vg }"
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
@for (
|
||||
item of xtreamStore.searchResults();
|
||||
track item.id
|
||||
) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="itemTpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: item }"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -170,3 +198,48 @@
|
||||
}
|
||||
</ng-container>
|
||||
</app-search-layout>
|
||||
|
||||
<!-- Single result card -->
|
||||
<ng-template #itemTpl let-item>
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url ?? undefined"
|
||||
[title]="item.title"
|
||||
[type]="getDisplayType(item)"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
</ng-template>
|
||||
|
||||
<!-- Variant group: collapsed card (+ expanded members) or a single card -->
|
||||
<ng-template #variantTpl let-vg>
|
||||
@if (vg.items.length > 1) {
|
||||
<app-content-card
|
||||
[posterUrl]="vg.representative.poster_url ?? undefined"
|
||||
[title]="vg.displayTitle"
|
||||
[type]="getDisplayType(vg.representative)"
|
||||
[showPlaceholder]="true"
|
||||
[countBadge]="vg.items.length"
|
||||
(cardClick)="selectVariantGroup(vg)"
|
||||
/>
|
||||
@if (isVariantExpanded(vg.key)) {
|
||||
@for (item of vg.items; track item.id) {
|
||||
<app-content-card
|
||||
class="variant-member"
|
||||
[posterUrl]="item.poster_url ?? undefined"
|
||||
[title]="item.title"
|
||||
[type]="getDisplayType(item)"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<app-content-card
|
||||
[posterUrl]="vg.representative.poster_url ?? undefined"
|
||||
[title]="vg.displayTitle"
|
||||
[type]="getDisplayType(vg.representative)"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(vg.representative)"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
@@ -128,3 +128,10 @@
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Expanded variants read as belonging to the collapsed group above them. */
|
||||
.variant-member {
|
||||
outline: 1px dashed var(--mat-sys-outline-variant);
|
||||
outline-offset: -1px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -434,3 +434,115 @@ describe('SearchResultsComponent initialQuery contract', () => {
|
||||
expect(store.searchResults()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchResultsComponent variant grouping', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: XtreamStore, useClass: MockXtreamStore },
|
||||
{ provide: Router, useValue: { navigate: jest.fn() } },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
data: { isGlobalSearch: true, layout: 'workspace' },
|
||||
queryParamMap: convertToParamMap({ q: '' }),
|
||||
},
|
||||
queryParamMap: of(convertToParamMap({ q: '' })),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: {
|
||||
globalSearchContent: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const build = () =>
|
||||
TestBed.runInInjectionContext(
|
||||
() =>
|
||||
new SearchResultsComponent(
|
||||
{ isGlobalSearch: true },
|
||||
undefined
|
||||
)
|
||||
);
|
||||
|
||||
it('defaults to grouping similar titles on', () => {
|
||||
expect(build().groupSimilar()).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses tagged variants into one flat group', () => {
|
||||
const component = build();
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
store.searchResults.set([
|
||||
createSearchItem({ id: 1, title: 'DE| The Pitt', xtream_id: 1 }),
|
||||
createSearchItem({ id: 2, title: 'The Pitt (2025)', xtream_id: 2 }),
|
||||
createSearchItem({ id: 3, title: '|ALB| The Pitt', xtream_id: 3 }),
|
||||
]);
|
||||
|
||||
const groups = component.flatVariantGroups();
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].items).toHaveLength(3);
|
||||
expect(groups[0].displayTitle).toBe('The Pitt');
|
||||
});
|
||||
|
||||
it('expands and collapses a multi-variant group', () => {
|
||||
const component = build();
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
store.searchResults.set([
|
||||
createSearchItem({ id: 1, title: 'DE| The Pitt', xtream_id: 1 }),
|
||||
createSearchItem({ id: 2, title: 'FR| The Pitt', xtream_id: 2 }),
|
||||
]);
|
||||
const group = component.flatVariantGroups()[0];
|
||||
|
||||
expect(component.isVariantExpanded(group.key)).toBe(false);
|
||||
component.selectVariantGroup(group);
|
||||
expect(component.isVariantExpanded(group.key)).toBe(true);
|
||||
component.selectVariantGroup(group);
|
||||
expect(component.isVariantExpanded(group.key)).toBe(false);
|
||||
});
|
||||
|
||||
it('opens a single-variant group directly without expanding', () => {
|
||||
const component = build();
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
const router = TestBed.inject(Router);
|
||||
store.searchResults.set([
|
||||
createSearchItem({
|
||||
id: 1,
|
||||
title: 'Solo Movie',
|
||||
xtream_id: 1,
|
||||
playlist_id: 'playlist-1',
|
||||
}),
|
||||
]);
|
||||
const group = component.flatVariantGroups()[0];
|
||||
|
||||
component.selectVariantGroup(group);
|
||||
|
||||
expect(component.isVariantExpanded(group.key)).toBe(false);
|
||||
expect(router.navigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists the toggle and clears expansion when turned off', () => {
|
||||
const component = build();
|
||||
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
|
||||
store.searchResults.set([
|
||||
createSearchItem({ id: 1, title: 'DE| The Pitt', xtream_id: 1 }),
|
||||
createSearchItem({ id: 2, title: 'FR| The Pitt', xtream_id: 2 }),
|
||||
]);
|
||||
const group = component.flatVariantGroups()[0];
|
||||
component.selectVariantGroup(group);
|
||||
expect(component.isVariantExpanded(group.key)).toBe(true);
|
||||
|
||||
component.toggleGroupSimilar(false);
|
||||
|
||||
expect(component.groupSimilar()).toBe(false);
|
||||
expect(component.isVariantExpanded(group.key)).toBe(false);
|
||||
expect(
|
||||
localStorage.getItem('global-search-group-similar')
|
||||
).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Location } from '@angular/common';
|
||||
import { Location, NgTemplateOutlet } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
GlobalSearchResult,
|
||||
isM3uGlobalSearchResult,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
groupResultsByVariant,
|
||||
VariantGroup,
|
||||
} from './global-search-grouping.util';
|
||||
|
||||
interface SearchResultsData {
|
||||
isGlobalSearch: boolean;
|
||||
@@ -54,6 +58,11 @@ interface GlobalSearchResultGroup {
|
||||
items: XtreamSearchResultItem[];
|
||||
}
|
||||
|
||||
interface PlaylistSection extends GlobalSearchResultGroup {
|
||||
/** Populated only when "group similar titles" is on. */
|
||||
variantGroups: VariantGroup[];
|
||||
}
|
||||
|
||||
const GLOBAL_SEARCH_PAGE_SIZE = 100;
|
||||
|
||||
function groupResultsByPlaylistId(
|
||||
@@ -87,6 +96,7 @@ function groupResultsByPlaylistId(
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatProgressSpinner,
|
||||
NgTemplateOutlet,
|
||||
SearchLayoutComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
@@ -123,6 +133,8 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
|
||||
private static readonly GROUP_BY_STORAGE_KEY =
|
||||
'global-search-group-by-playlist';
|
||||
private static readonly GROUP_SIMILAR_STORAGE_KEY =
|
||||
'global-search-group-similar';
|
||||
private static readonly EXCLUDE_HIDDEN_STORAGE_KEY =
|
||||
'xtream-search-exclude-hidden';
|
||||
private static readonly TYPE_FILTERS_STORAGE_KEY =
|
||||
@@ -136,6 +148,18 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
'false'
|
||||
);
|
||||
|
||||
/** Whether to collapse same-title/type variants into one card */
|
||||
readonly groupSimilar = signal(
|
||||
localStorage.getItem(
|
||||
SearchResultsComponent.GROUP_SIMILAR_STORAGE_KEY
|
||||
) !== 'false'
|
||||
);
|
||||
|
||||
/** Variant-group keys the user has expanded to reveal every version. */
|
||||
private readonly expandedVariantKeys = signal<ReadonlySet<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
/** Whether to exclude content from hidden categories */
|
||||
readonly excludeHidden = signal(
|
||||
localStorage.getItem(
|
||||
@@ -170,6 +194,30 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
return groupResultsByPlaylistId(results);
|
||||
});
|
||||
|
||||
private readonly displayType = (item: XtreamSearchResultItem): string =>
|
||||
this.getDisplayType(item);
|
||||
|
||||
/** Playlist sections with per-playlist variant grouping applied. */
|
||||
readonly playlistSections = computed<PlaylistSection[]>(() => {
|
||||
const collapse = this.groupSimilar();
|
||||
return this.groupedResults().map((group) => ({
|
||||
...group,
|
||||
variantGroups: collapse
|
||||
? groupResultsByVariant(group.items, this.displayType)
|
||||
: [],
|
||||
}));
|
||||
});
|
||||
|
||||
/** Variant groups for the flat (ungrouped-by-playlist) list. */
|
||||
readonly flatVariantGroups = computed<VariantGroup[]>(() =>
|
||||
this.groupSimilar()
|
||||
? groupResultsByVariant(
|
||||
this.xtreamStore.searchResults(),
|
||||
this.displayType
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
constructor(
|
||||
@Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData | null,
|
||||
@Optional() public dialogRef: MatDialogRef<SearchResultsComponent>
|
||||
@@ -290,6 +338,7 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
this.lastGlobalSearchTerm = '';
|
||||
this.lastGlobalSearchTypes = [];
|
||||
this.lastGlobalSearchExcludeHidden = undefined;
|
||||
this.expandedVariantKeys.set(new Set());
|
||||
this.xtreamStore.setGlobalSearchResults([]);
|
||||
}
|
||||
|
||||
@@ -338,6 +387,7 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
this.lastGlobalSearchExcludeHidden = effectiveExcludeHidden;
|
||||
this.hasMoreGlobalResults.set(false);
|
||||
this.isLoadingMoreGlobalResults.set(false);
|
||||
this.expandedVariantKeys.set(new Set());
|
||||
this.xtreamStore.setIsSearching(true);
|
||||
}
|
||||
|
||||
@@ -470,6 +520,35 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
);
|
||||
}
|
||||
|
||||
toggleGroupSimilar(value: boolean) {
|
||||
this.groupSimilar.set(value);
|
||||
this.expandedVariantKeys.set(new Set());
|
||||
localStorage.setItem(
|
||||
SearchResultsComponent.GROUP_SIMILAR_STORAGE_KEY,
|
||||
String(value)
|
||||
);
|
||||
}
|
||||
|
||||
isVariantExpanded(key: string): boolean {
|
||||
return this.expandedVariantKeys().has(key);
|
||||
}
|
||||
|
||||
/** Single variant → open it; multiple → toggle the expanded member list. */
|
||||
selectVariantGroup(group: VariantGroup) {
|
||||
if (group.items.length === 1) {
|
||||
this.selectItem(group.representative);
|
||||
return;
|
||||
}
|
||||
|
||||
const expanded = new Set(this.expandedVariantKeys());
|
||||
if (expanded.has(group.key)) {
|
||||
expanded.delete(group.key);
|
||||
} else {
|
||||
expanded.add(group.key);
|
||||
}
|
||||
this.expandedVariantKeys.set(expanded);
|
||||
}
|
||||
|
||||
toggleExcludeHidden(value: boolean) {
|
||||
this.excludeHidden.set(value);
|
||||
localStorage.setItem(
|
||||
|
||||
Reference in new issue
Block a user