mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
fix(search): address global search review feedback
This commit is contained in:
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(
|
||||
|
||||
@@ -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 === ''
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+22
@@ -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
|
||||
);
|
||||
}
|
||||
+17
-7
@@ -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',
|
||||
|
||||
+10
-14
@@ -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'
|
||||
|
||||
+47
-3
@@ -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', () => {
|
||||
|
||||
+4
-8
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user