diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index f7d7e90bb..3447438b4 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -536,4 +536,63 @@ describe('content.operations', () => { }), ]); }); + + it('quotes FTS tokens so reserved words do not fall back to content scans', async () => { + const all = jest.fn().mockResolvedValue([ + { + id: 1, + category_id: 10, + title: 'And One TV', + rating: '', + added: '', + poster_url: '', + epg_channel_id: '', + tv_archive: 0, + tv_archive_duration: 0, + direct_source: '', + xtream_id: 99, + type: 'live', + playlist_id: 'playlist-1', + playlist_name: 'Playlist One', + }, + ]); + const db = { + all, + select: jest.fn(), + } as unknown as AppDatabase; + + await globalSearch(db, 'and', ['live'], false, ['xtream'], { + limit: 10, + }); + + const matchSqlCall = sqlMock.mock.calls.find(([strings]) => + Array.from(strings as TemplateStringsArray) + .join(' ') + .includes('content_title_fts MATCH') + ); + + expect(matchSqlCall?.[1]).toBe('"and"'); + }); + + it('limits M3U playlist payload candidates before parsing large JSON payloads', async () => { + const limit = jest.fn().mockResolvedValue([]); + const orderedQuery = { + limit, + then: (resolve: (value: unknown[]) => void) => resolve([]), + }; + const orderBy = jest.fn().mockReturnValue(orderedQuery); + const where = jest.fn().mockReturnValue({ orderBy }); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + const db = { + select, + } as unknown as AppDatabase; + + await globalSearch(db, 'news', ['live'], false, ['m3u'], { + limit: 10, + }); + + expect(orderBy).toHaveBeenCalledWith(schema.playlists.name); + expect(limit).toHaveBeenCalledWith(500); + }); }); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index c68e05ed9..5bf6b5092 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -285,6 +285,7 @@ function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean { function buildContentTitleFtsMatchQuery(searchTerm: string): string { return getSearchTokens(searchTerm) .filter((token) => token.length >= 3) + .map((token) => `"${token.replace(/"/g, '""')}"`) .join(' AND '); } @@ -1196,7 +1197,8 @@ export async function globalSearch( ...buildM3uPayloadSearchConditions(searchTerm) ) ) - .orderBy(schema.playlists.name); + .orderBy(schema.playlists.name) + .limit(candidateLimit); results.push( ...buildScoredM3uGlobalSearchResults( diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index aee393131..a53309567 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -7,6 +7,10 @@ describe('app routes', () => { path?: string; redirectTo?: unknown; }> = []; + let resolveElectronOnlyGlobalSearchRoute: ( + runtime: { isElectron: boolean }, + router: { parseUrl: (url: string) => unknown } + ) => unknown; beforeAll(async () => { jest.resetModules(); @@ -42,11 +46,14 @@ describe('app routes', () => { }) ); - const { routes } = await import('./app.routes'); + const appRoutes = await import('./app.routes'); + const { routes } = appRoutes; const workspaceRoute = routes.find( (route) => route.path === 'workspace' ); workspaceChildren = workspaceRoute?.children ?? []; + resolveElectronOnlyGlobalSearchRoute = + appRoutes.resolveElectronOnlyGlobalSearchRoute; }); it('lazy-loads M3U workspace routes through the feature boundary', async () => { @@ -99,9 +106,23 @@ describe('app routes', () => { expect(globalSearchRoute?.data).toEqual({ isGlobalSearch: true, }); + expect(globalSearchRoute?.canActivate).toHaveLength(1); expect(typeof globalSearchRoute?.loadComponent).toBe('function'); }); + it('redirects the Electron-only global search route in the web runtime', async () => { + const redirectTree = { url: '/workspace/sources' }; + const parseUrl = jest.fn().mockReturnValue(redirectTree); + + const result = resolveElectronOnlyGlobalSearchRoute( + { isElectron: false }, + { parseUrl } + ); + + expect(parseUrl).toHaveBeenCalledWith('/workspace/sources'); + expect(result).toBe(redirectTree); + }); + it('uses a dynamic redirect for the default /workspace child route', async () => { const workspaceDefaultRoute = workspaceChildren.find( (route) => route.path === '' diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index f69502f23..5993f4622 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -1,5 +1,6 @@ import { inject } from '@angular/core'; import { Router, Routes } from '@angular/router'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; const workspaceEntryRedirect = async () => @@ -15,6 +16,20 @@ const dashboardAccessGuard = async () => { : router.parseUrl(redirectPath); }; +export function resolveElectronOnlyGlobalSearchRoute( + runtime: Pick, + router: Pick +) { + return runtime.isElectron ? true : router.parseUrl('/workspace/sources'); +} + +const electronOnlyGlobalSearchGuard = () => { + return resolveElectronOnlyGlobalSearchRoute( + inject(RuntimeCapabilitiesService), + inject(Router) + ); +}; + export const routes: Routes = [ { path: '', @@ -82,6 +97,7 @@ export const routes: Routes = [ }, { path: 'search', + canActivate: [electronOnlyGlobalSearchGuard], data: { isGlobalSearch: true, }, diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 92d66bb6e..7b8111bb0 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -209,14 +209,18 @@ Xtream-only row shape: 1. `source_type = "xtream"` rows come from the normalized `content` and `categories` tables joined with `playlists`. Xtream title matching uses `content_title_fts`, an FTS5 trigram index over `content.title`, for search - terms with at least one 3+ character token. Existing databases rebuild this - index once through the `migration:content-title-fts-trigram:v1` app-state - marker; fresh inserts, deletes, and title updates stay synchronized through - SQLite triggers. + terms with at least one 3+ character token. Tokens are quoted before being + passed to `MATCH`, so FTS reserved words such as `and` are treated as search + text instead of degrading to the slow fallback path. Existing databases + rebuild this index once through the `migration:content-title-fts-trigram:v1` + app-state marker; fresh inserts, deletes, and title updates stay + synchronized through SQLite triggers. 2. `source_type = "m3u"` rows come from M3U playlist payloads stored in `playlists.payload`. The worker uses SQL `payload LIKE` only as a coarse candidate prefilter, then parses candidate JSON payloads and matches channel - name, TVG name, and group title case-insensitively in the worker. + name, TVG name, and group title case-insensitively in the worker. The SQL + candidate query is capped by the same candidate limit used for Xtream search, + so large matching M3U payloads are not loaded without an upper bound. 3. The optional `sources` argument can restrict search to `xtream` or `m3u`, but omitted callers keep the backward-compatible behavior of searching all supported global-search sources. diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 65c388982..6372f3bd3 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -76,8 +76,9 @@ Provider route integration: The shell is intentionally split into four persistent regions: 1. Left rail: - 1. Static workspace links for dashboard, sources, global search, - global favorites, and recently viewed. + 1. Static workspace links for dashboard, sources, global favorites, and + recently viewed. The routed global-search rail link is Electron-only + because its data source is the SQLite worker bridge. 2. Provider-aware context links derived from the active or current playlist. 3. Settings remains a persistent footer shortcut in the rail. 2. Top header: @@ -145,9 +146,10 @@ Search is shell-owned and route-aware: 1. Disabled on settings routes. 2. Enabled on sources routes. -3. Enabled for `/workspace/search`, which is the routed global-search view. - `Ctrl/Cmd+F` in Electron opens this route and focuses/selects the header - search input instead of opening a fullscreen dialog. +3. Enabled for `/workspace/search`, which is the Electron-only routed + global-search view. `Ctrl/Cmd+F` in Electron opens this route and + focuses/selects the header search input instead of opening a fullscreen + dialog. 4. Enabled for supported Xtream and Stalker content/search views. 5. Placeholder text and search handling vary by provider and section. 6. Input changes are debounced before route/store updates are applied. diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-searchable-playlists.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-searchable-playlists.ts new file mode 100644 index 000000000..2367bac7c --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-searchable-playlists.ts @@ -0,0 +1,22 @@ +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; + +type SearchablePlaylistMeta = Pick< + PlaylistMeta, + 'count' | 'filePath' | 'macAddress' | 'serverUrl' | 'url' +>; + +export function isWorkspaceGlobalSearchablePlaylist( + playlist: SearchablePlaylistMeta +): boolean { + if (playlist.serverUrl) { + return true; + } + + if (playlist.macAddress) { + return false; + } + + return Boolean( + playlist.filePath || playlist.url || Number(playlist.count ?? 0) > 0 + ); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts index a3b4d09b4..4ad1b570b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts @@ -1,4 +1,10 @@ -import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core'; +import { + computed, + DestroyRef, + inject, + Injectable, + signal, +} from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -84,12 +90,16 @@ export class WorkspaceShellRouteStateService { path: ['/workspace/sources'], }); - links.push({ - icon: 'search', - tooltip: this.translateText('WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH'), - path: ['/workspace/search'], - exact: true, - }); + if (this.runtime.isElectron) { + links.push({ + icon: 'search', + tooltip: this.translateText( + 'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH' + ), + path: ['/workspace/search'], + exact: true, + }); + } links.push({ icon: 'favorite', diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts index 5e1ec5825..a45a1ed3c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts @@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util'; import { SEARCH_LOADED_ONLY_STATUS, @@ -14,6 +15,7 @@ import { resolveSearchPlaceholderKey, resolveSearchScopeLabel, } from './helpers/workspace-shell-search-labels'; +import { isWorkspaceGlobalSearchablePlaylist } from './helpers/workspace-shell-searchable-playlists'; import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service'; import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service'; @@ -23,6 +25,7 @@ export class WorkspaceShellSearchService { private readonly xtreamStore = inject(XtreamStore); private readonly stalkerStore = inject(StalkerStore); private readonly translate = inject(TranslateService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly routeState = inject(WorkspaceShellRouteStateService); private readonly searchSync = inject(WorkspaceShellSearchSyncService); @@ -83,25 +86,18 @@ export class WorkspaceShellSearchService { } if (route.kind === 'global-search') { - const hasSearchablePlaylists = this.routeState.playlists().some( - (playlist) => - !!playlist.serverUrl || - (!playlist.macAddress && - (!!playlist.filePath || - !!playlist.url || - !playlist.serverUrl)) - ); + const hasSearchablePlaylists = + this.runtime.isElectron && + this.routeState + .playlists() + .some(isWorkspaceGlobalSearchablePlaylist); return { enabled: hasSearchablePlaylists, - behavior: hasSearchablePlaylists - ? 'remote-search' - : 'disabled', + behavior: hasSearchablePlaylists ? 'remote-search' : 'disabled', context: null, section: null, - searchMode: hasSearchablePlaylists - ? 'remote-search' - : 'none', + searchMode: hasSearchablePlaylists ? 'remote-search' : 'none', placeholderKey: 'WORKSPACE.SHELL.SEARCH_GLOBAL_PLACEHOLDER', scopeLabel: this.translateText( 'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH' diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index fc4fe06ab..4148ad06d 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -675,6 +675,17 @@ describe('WorkspaceShellFacade', () => { expect(facade.brandLink()).toBe('/workspace/sources'); }); + it('hides the Electron-only global search rail link in the web runtime', () => { + runtime.isElectron = false; + showDashboardSignal.set(false); + + expect(facade.workspaceLinks().map((link) => link.path)).toEqual([ + ['/workspace/sources'], + ['/workspace/global-favorites'], + ['/workspace/global-recent'], + ]); + }); + it('persists the last restorable route from navigation events', () => { expect( startupPreferences.persistLastRestorablePath @@ -731,9 +742,42 @@ describe('WorkspaceShellFacade', () => { ]); facade.currentUrl.set('/workspace/dashboard'); - expect(facade.commandPaletteCommands().map((command) => command.id)).toContain( - 'global-search' - ); + expect( + facade.commandPaletteCommands().map((command) => command.id) + ).toContain('global-search'); + }); + + it('does not enable global search for empty invalid playlist metadata', () => { + activePlaylistSignal.set(null); + playlistsSignal.set([ + { + _id: 'empty-playlist', + title: 'Empty playlist', + } as PlaylistSignalMeta, + ]); + facade.currentUrl.set('/workspace/search'); + + expect(facade.canUseSearch()).toBe(false); + expect( + facade.commandPaletteCommands().map((command) => command.id) + ).not.toContain('global-search'); + }); + + it('hides the global search command and header capability in the web runtime', () => { + runtime.isElectron = false; + playlistsSignal.set([ + { + _id: 'pl-m3u', + title: 'Playlist M3U', + count: 10, + } as PlaylistSignalMeta, + ]); + facade.currentUrl.set('/workspace/search'); + + expect(facade.canUseSearch()).toBe(false); + expect( + facade.commandPaletteCommands().map((command) => command.id) + ).not.toContain('global-search'); }); it('hides the downloads command when downloads are unsupported', () => { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index 4812b9e76..2e32b2e85 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -17,6 +17,7 @@ import { CommandBuilderActions, CommandBuilderContext, } from './helpers/workspace-shell-command-builders'; +import { isWorkspaceGlobalSearchablePlaylist } from './helpers/workspace-shell-searchable-playlists'; import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service'; import { WorkspaceShellHeaderService } from './workspace-shell-header.service'; import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service'; @@ -219,14 +220,9 @@ export class WorkspaceShellFacade { context: this.currentContext(), section: this.currentSection(), hasActivePlaylist: !!this.activePlaylist(), - hasSearchablePlaylists: this.playlists().some( - (playlist) => - !!playlist.serverUrl || - (!playlist.macAddress && - (!!playlist.filePath || - !!playlist.url || - !playlist.serverUrl)) - ), + hasSearchablePlaylists: + this.isElectron && + this.playlists().some(isWorkspaceGlobalSearchablePlaylist), canRefreshPlaylist: this.canRefreshPlaylist(), supportsDownloads: this.supportsDownloads, showDashboard: this.showDashboard(),