Files
KiraTV---App-Linux/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts
T
2026-05-03 23:43:09 +02:00

351 lines
11 KiB
TypeScript

import { KeyValuePipe } from '@angular/common';
import {
AfterViewInit,
Component,
computed,
effect,
inject,
Inject,
Optional,
signal,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { DatabaseService } from 'services';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import {
buildXtreamNavigationTarget,
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XtreamContentItem } from '@iptvnator/portal/xtream/data-access';
import { SearchFilters } from '@iptvnator/portal/xtream/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { ContentType } from '@iptvnator/portal/xtream/data-access';
interface SearchResultsData {
isGlobalSearch: boolean;
initialQuery?: string;
}
function groupResultsByPlaylistName(
items: XtreamContentItem[]
): Record<string, XtreamContentItem[]> {
return items.reduce<Record<string, XtreamContentItem[]>>(
(groups, item) => {
const key = String(item.playlist_name);
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(item);
return groups;
},
{}
);
}
@Component({
selector: 'app-search-results',
imports: [
ContentCardComponent,
FormsModule,
KeyValuePipe,
MatCheckboxModule,
MatDialogModule,
MatIcon,
MatIconButton,
SearchLayoutComponent,
TranslatePipe,
],
providers: [],
templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'],
})
export class SearchResultsComponent implements AfterViewInit {
readonly searchLayoutComponent = viewChild(SearchLayoutComponent);
readonly xtreamStore = inject(XtreamStore);
readonly router = inject(Router);
readonly activatedRoute = inject(ActivatedRoute);
readonly databaseService = inject(DatabaseService);
private readonly logger = createLogger('XtreamSearchResults');
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
readonly routeSearchTerm = queryParamSignal(
this.activatedRoute,
'q',
(value) => (value ?? '').trim()
);
/** Search term from store */
readonly searchTerm = this.xtreamStore.searchTerm;
/** Search filters from store */
readonly filters = this.xtreamStore.searchFilters;
private globalSearchRequestVersion = 0;
private static readonly GROUP_BY_STORAGE_KEY =
'global-search-group-by-playlist';
private static readonly EXCLUDE_HIDDEN_STORAGE_KEY =
'xtream-search-exclude-hidden';
private static readonly TYPE_FILTERS_STORAGE_KEY =
'global-search-type-filters';
isGlobalSearch = false;
/** Whether to group global search results by playlist */
readonly groupByPlaylist = signal(
localStorage.getItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY) !==
'false'
);
/** Whether to exclude content from hidden categories */
readonly excludeHidden = signal(
localStorage.getItem(
SearchResultsComponent.EXCLUDE_HIDDEN_STORAGE_KEY
) === 'true'
);
readonly filterConfig = [
{
key: 'live' as keyof SearchFilters,
label: 'Live TV',
translationKey: 'PORTALS.SIDEBAR.LIVE_TV',
},
{
key: 'movie' as keyof SearchFilters,
label: 'Movies',
translationKey: 'PORTALS.SIDEBAR.MOVIES',
},
{
key: 'series' as keyof SearchFilters,
label: 'Series',
translationKey: 'PORTALS.SIDEBAR.SERIES',
},
];
/** Grouped results computed once per result change (avoids recalculating on every CD cycle) */
readonly groupedResults = computed(() => {
const results = this.xtreamStore.searchResults();
if (!this.isGlobalSearch) return { default: results };
return groupResultsByPlaylistName(results);
});
constructor(
@Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData,
@Optional() public dialogRef: MatDialogRef<SearchResultsComponent>
) {
this.isGlobalSearch = data?.isGlobalSearch || false;
const initialQuery = (data?.initialQuery ?? '').trim();
if (this.isGlobalSearch) {
const savedFilters = localStorage.getItem(
SearchResultsComponent.TYPE_FILTERS_STORAGE_KEY
);
if (savedFilters) {
try {
const parsed = JSON.parse(
savedFilters
) as Partial<SearchFilters>;
this.xtreamStore.setSearchFilters({
live: parsed.live !== false,
movie: parsed.movie !== false,
series: parsed.series !== false,
});
} catch {
// Ignore malformed storage value and keep defaults.
}
}
if (initialQuery) {
this.xtreamStore.setSearchTerm(initialQuery);
}
}
effect((onCleanup) => {
const term = this.searchTerm();
if (term.length >= 3) {
const timeout = setTimeout(() => this.executeSearch(), 300);
onCleanup(() => clearTimeout(timeout));
} else if (term.length === 0) {
this.clearResultsOnly();
}
});
effect(() => {
if (this.isGlobalSearch || !this.isWorkspaceLayout) {
return;
}
const queryTerm = this.routeSearchTerm();
if (!queryTerm || queryTerm === this.searchTerm()) {
return;
}
this.xtreamStore.setSearchTerm(queryTerm);
});
}
ngAfterViewInit() {
this.xtreamStore.setSelectedContentType(undefined);
setTimeout(() => {
this.searchLayoutComponent()?.focusSearchInput();
});
}
executeSearch() {
const filters = this.filters();
const types = Object.entries(filters)
.filter(([, enabled]) => enabled)
.map(([type]) => type);
const excludeHidden = this.excludeHidden();
if (this.isGlobalSearch) {
this.searchGlobal(this.searchTerm(), types, excludeHidden);
} else {
this.xtreamStore.searchContent({
term: this.searchTerm(),
types,
excludeHidden,
});
}
}
/**
* Update search term in the store
*/
updateSearchTerm(term: string) {
this.xtreamStore.setSearchTerm(term);
}
/**
* Update a single filter in the store
*/
updateFilter(key: keyof SearchFilters, value: boolean) {
this.xtreamStore.updateSearchFilter(key, value);
if (this.isGlobalSearch) {
localStorage.setItem(
SearchResultsComponent.TYPE_FILTERS_STORAGE_KEY,
JSON.stringify(this.xtreamStore.searchFilters())
);
}
if (this.searchTerm().length >= 3) {
this.executeSearch();
}
}
/**
* Clear only the results, not the search term/filters
*/
private clearResultsOnly() {
this.globalSearchRequestVersion++;
this.xtreamStore.setIsSearching(false);
this.xtreamStore.setGlobalSearchResults([]);
}
async searchGlobal(term: string, types: string[], excludeHidden?: boolean) {
const requestVersion = ++this.globalSearchRequestVersion;
this.xtreamStore.setIsSearching(true);
try {
const results = await this.databaseService.globalSearchContent(
term,
types,
excludeHidden
);
if (requestVersion !== this.globalSearchRequestVersion) {
return;
}
if (results && Array.isArray(results)) {
this.xtreamStore.setGlobalSearchResults(results);
} else {
this.xtreamStore.setIsSearching(false);
}
} catch (error) {
if (requestVersion !== this.globalSearchRequestVersion) {
return;
}
this.logger.error('Error in global search', error);
this.xtreamStore.resetSearchResults();
}
}
selectItem(item: XtreamContentItem) {
const playlistId = item.playlist_id ?? this.xtreamStore.playlistId();
if (!playlistId) {
return;
}
const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType;
const navigationType =
item.type === 'movie'
? 'movie'
: item.type === 'series'
? 'series'
: 'live';
this.xtreamStore.setSelectedContentType(type);
const navigation = buildXtreamNavigationTarget({
playlistId,
type: navigationType,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
});
if (this.isGlobalSearch) {
this.dialogRef?.close();
}
void this.router.navigate(navigation.link, {
state: navigation.state,
});
}
onCloseDialog() {
this.dialogRef?.close();
}
toggleGroupByPlaylist(value: boolean) {
this.groupByPlaylist.set(value);
localStorage.setItem(
SearchResultsComponent.GROUP_BY_STORAGE_KEY,
String(value)
);
}
toggleExcludeHidden(value: boolean) {
this.excludeHidden.set(value);
localStorage.setItem(
SearchResultsComponent.EXCLUDE_HIDDEN_STORAGE_KEY,
String(value)
);
if (this.searchTerm().length >= 3) {
this.executeSearch();
}
}
getGroupedResults() {
return this.groupedResults();
}
get showInlineSearchInput(): boolean {
return !this.isWorkspaceLayout || this.isGlobalSearch;
}
}