Merge pull request #761 from 4gray/feat/search-in-visible-categories

feat: add excludeHidden option to search functions for filtering results
This commit is contained in:
4gray authored and GitHub committed 2026-02-10 22:35:54 +01:00
commit 7415de5f0b
14 files changed
+97 -51

No files matched your search

@@ -184,10 +184,11 @@ contextBridge.exposeInMainWorld('electron', {
dbSearchContent: (
playlistId: string,
searchTerm: string,
types: string[]
) => ipcRenderer.invoke('DB_SEARCH_CONTENT', playlistId, searchTerm, types),
dbGlobalSearch: (searchTerm: string, types: string[]) =>
ipcRenderer.invoke('DB_GLOBAL_SEARCH', searchTerm, types),
types: string[],
excludeHidden?: boolean
) => ipcRenderer.invoke('DB_SEARCH_CONTENT', playlistId, searchTerm, types, excludeHidden),
dbGlobalSearch: (searchTerm: string, types: string[], excludeHidden?: boolean) =>
ipcRenderer.invoke('DB_GLOBAL_SEARCH', searchTerm, types, excludeHidden),
dbGetRecentlyViewed: () => ipcRenderer.invoke('DB_GET_RECENTLY_VIEWED'),
dbClearRecentlyViewed: () => ipcRenderer.invoke('DB_CLEAR_RECENTLY_VIEWED'),
// Favorites
@@ -259,8 +259,9 @@ ipcMain.handle(
*/
ipcMain.handle(
'DB_SEARCH_CONTENT',
async (event, playlistId: string, searchTerm: string, types: string[]) => {
async (event, playlistId: string, searchTerm: string, types: string[], excludeHidden = false) => {
try {
if (!types || types.length === 0) return [];
const db = await getDatabase();
const searchTermLower = searchTerm.toLocaleLowerCase();
const likePatterns = buildLikePatterns(searchTerm);
@@ -270,6 +271,19 @@ ipcMain.handle(
// Pre-filter with SQL LIKE (case-insensitive for ASCII, handled in native C code).
// This avoids loading the entire content table into JS memory.
const conditions = [
eq(schema.categories.playlistId, playlistId),
inArray(
schema.content.type,
types as Array<'live' | 'movie' | 'series'>
),
or(...likeConditions),
];
if (excludeHidden) {
conditions.push(eq(schema.categories.hidden, false));
}
const candidates = await db
.select({
id: schema.content.id,
@@ -286,16 +300,7 @@ ipcMain.handle(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
inArray(
schema.content.type,
types as Array<'live' | 'movie' | 'series'>
),
or(...likeConditions)
)
)
.where(and(...conditions))
.limit(200);
// Post-filter for proper Unicode case-insensitive matching
@@ -319,8 +324,9 @@ ipcMain.handle(
*/
ipcMain.handle(
'DB_GLOBAL_SEARCH',
async (event, searchTerm: string, types: string[]) => {
async (event, searchTerm: string, types: string[], excludeHidden = false) => {
try {
if (!types || types.length === 0) return [];
const db = await getDatabase();
const searchTermLower = searchTerm.toLocaleLowerCase();
const likePatterns = buildLikePatterns(searchTerm);
@@ -332,6 +338,18 @@ ipcMain.handle(
// This avoids loading the entire content table into JS memory.
// Previously this query loaded ALL content rows (potentially 100k+),
// created JS objects for each, then filtered in JS — blocking the main process.
const conditions = [
inArray(
schema.content.type,
types as Array<'live' | 'movie' | 'series'>
),
or(...likeConditions),
];
if (excludeHidden) {
conditions.push(eq(schema.categories.hidden, false));
}
const candidates = await db
.select({
id: schema.content.id,
@@ -354,15 +372,7 @@ ipcMain.handle(
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.where(
and(
inArray(
schema.content.type,
types as Array<'live' | 'movie' | 'series'>
),
or(...likeConditions)
)
)
.where(and(...conditions))
.orderBy(schema.content.title)
.limit(200);
@@ -318,10 +318,6 @@ export default class EpgEvents {
case 'EPG_PROGRESS':
if (message.stats) {
console.log(
this.loggerLabel,
`Progress: ${message.stats.totalChannels} channels, ${message.stats.totalPrograms} programs`
);
// Forward progress to renderer
this.sendProgressToRenderer(
url,
@@ -240,9 +240,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async searchContent(
playlistId: string,
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
return this.dbService.searchXtreamContent(playlistId, searchTerm, types);
return this.dbService.searchXtreamContent(playlistId, searchTerm, types, excludeHidden);
}
// =========================================================================
@@ -221,7 +221,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async searchContent(
playlistId: string,
searchTerm: string,
types: string[]
types: string[],
_excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
const results: any[] = [];
const searchLower = searchTerm.toLowerCase();
@@ -234,7 +234,8 @@ export interface IXtreamDataSource {
searchContent(
playlistId: string,
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
): Promise<XtreamContentItem[]>;
// =========================================================================
@@ -19,6 +19,15 @@
}
</ng-container>
<ng-container header-actions>
<mat-checkbox
[ngModel]="excludeHidden()"
(ngModelChange)="toggleExcludeHidden($event)"
>
{{ 'PORTALS.SEARCH_VIEW.VISIBLE_CATEGORIES_ONLY' | translate }}
</mat-checkbox>
</ng-container>
<ng-container subheader>
@if (isGlobalSearch) {
<div class="global-search-subheader">
@@ -63,6 +63,7 @@ export class SearchResultsComponent implements AfterViewInit {
readonly filters = this.xtreamStore.searchFilters;
private static readonly GROUP_BY_STORAGE_KEY = 'global-search-group-by-playlist';
private static readonly EXCLUDE_HIDDEN_STORAGE_KEY = 'xtream-search-exclude-hidden';
isGlobalSearch = false;
@@ -71,6 +72,11 @@ export class SearchResultsComponent implements AfterViewInit {
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,
@@ -125,13 +131,15 @@ export class SearchResultsComponent implements AfterViewInit {
const types = Object.entries(filters)
.filter(([_, enabled]) => enabled)
.map(([type]) => type);
const excludeHidden = this.excludeHidden();
if (this.isGlobalSearch) {
this.searchGlobal(this.searchTerm(), types);
this.searchGlobal(this.searchTerm(), types, excludeHidden);
} else {
this.xtreamStore.searchContent({
term: this.searchTerm(),
types,
excludeHidden,
});
}
}
@@ -160,12 +168,13 @@ export class SearchResultsComponent implements AfterViewInit {
this.xtreamStore.setGlobalSearchResults([]);
}
async searchGlobal(term: string, types: string[]) {
async searchGlobal(term: string, types: string[], excludeHidden?: boolean) {
this.xtreamStore.setIsSearching(true);
try {
const results = await this.databaseService.globalSearchContent(
term,
types
types,
excludeHidden
);
if (results && Array.isArray(results)) {
this.xtreamStore.setGlobalSearchResults(results);
@@ -211,6 +220,14 @@ export class SearchResultsComponent implements AfterViewInit {
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();
}
@@ -70,7 +70,8 @@ export function withSearch() {
*/
async searchContent(
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
// Access parent store's playlistId (from withPortal)
const storeAny = store as any;
@@ -87,7 +88,8 @@ export function withSearch() {
const results = await dataSource.searchContent(
playlistId,
searchTerm,
types
types,
excludeHidden
);
patchState(store, {
@@ -81,7 +81,7 @@ export const XtreamStore = signalStore(
const xtreamApiService = inject(XtreamApiService);
const ngrxStore = inject(Store);
const searchContent = (store as any)
.searchContent as (term: string, types: string[]) => Promise<unknown>;
.searchContent as (term: string, types: string[], excludeHidden?: boolean) => Promise<unknown>;
return {
/**
@@ -218,11 +218,11 @@ export const XtreamStore = signalStore(
/**
* Search content wrapper for rxMethod compatibility
* Can be called with object { term, types } or direct params
* Can be called with object { term, types, excludeHidden } or direct params
*/
searchContent(params: { term: string; types: string[] }): void {
searchContent(params: { term: string; types: string[]; excludeHidden?: boolean }): void {
// Call the underlying search method from withSearch
void searchContent(params.term, params.types);
void searchContent(params.term, params.types, params.excludeHidden);
},
};
})
+2 -1
View File
@@ -446,7 +446,8 @@
"RESULTS_FOUND": "Found {{count}} results",
"NO_RESULTS_FOR": "No results found for \"{{term}}\"",
"GROUP_BY_PLAYLIST": "Group results by playlist",
"XTREAM_ONLY_NOTE": "Global search is currently available for Xtream playlists only."
"XTREAM_ONLY_NOTE": "Global search is currently available for Xtream playlists only.",
"VISIBLE_CATEGORIES_ONLY": "Visible categories only"
},
"STREAM_URL_COPIED": "Stream URL copied to clipboard",
"COPY_STREAM_URL": "Copy Stream URL",
+4 -2
View File
@@ -150,11 +150,13 @@ declare global {
dbSearchContent: (
playlistId: string,
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
) => Promise<any[]>;
dbGlobalSearch: (
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
) => Promise<any[]>;
dbGetRecentlyViewed: () => Promise<any[]>;
dbClearRecentlyViewed: () => Promise<{ success: boolean }>;
Vendored
+4 -2
View File
@@ -153,11 +153,13 @@ declare global {
dbSearchContent: (
playlistId: string,
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
) => Promise<any[]>;
dbGlobalSearch: (
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
) => Promise<any[]>;
dbGetRecentlyViewed: () => Promise<any[]>;
dbClearRecentlyViewed: () => Promise<{ success: boolean }>;
@@ -261,12 +261,14 @@ export class DatabaseService {
async searchXtreamContent(
playlistId: string,
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
): Promise<XtreamContent[]> {
return await window.electron.dbSearchContent(
playlistId,
searchTerm,
types
types,
excludeHidden
);
}
@@ -275,9 +277,10 @@ export class DatabaseService {
*/
async globalSearchContent(
searchTerm: string,
types: string[]
types: string[],
excludeHidden?: boolean
): Promise<GlobalSearchResult[]> {
return await window.electron.dbGlobalSearch(searchTerm, types);
return await window.electron.dbGlobalSearch(searchTerm, types, excludeHidden);
}
/**