fix(search): address global search review feedback

This commit is contained in:
4gray committed 2026-06-20 20:33:16 +02:00
1 parent 12d931f4cc
commit 357d1de050
11 files changed
+216 -44

No files matched your search

@@ -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);
});
});
@@ -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(
+22 -1
View File
@@ -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 === ''
+16
View File
@@ -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<RuntimeCapabilitiesService, 'isElectron'>,
router: Pick<Router, 'parseUrl'>
) {
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,
},
+9 -5
View File
@@ -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.
+7 -5
View File
@@ -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.
@@ -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
);
}
@@ -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',
@@ -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'
@@ -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', () => {
@@ -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(),