diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 38eda2a7b..02680a101 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -17,6 +17,8 @@ import type { ElectronBridgeWindowState, ElectronBridgeXtreamContentStream, ExternalPlayerSession, + GlobalSearchPaginationOptions, + GlobalSearchResultSource, PlaybackPositionData, PlayerContentInfo, Playlist, @@ -599,14 +601,28 @@ const electronApi: ElectronBridgeApi = { dbGlobalSearch: ( searchTerm: string, types: string[], - excludeHidden?: boolean - ) => - ipcRenderer.invoke( + excludeHidden?: boolean, + sources?: GlobalSearchResultSource[], + options?: GlobalSearchPaginationOptions + ) => { + if (sources?.length || options) { + return ipcRenderer.invoke( + 'DB_GLOBAL_SEARCH', + searchTerm, + types, + excludeHidden, + sources, + options + ); + } + + return ipcRenderer.invoke( 'DB_GLOBAL_SEARCH', searchTerm, types, excludeHidden - ), + ); + }, dbGetGlobalRecentlyAdded: ( kind: 'all' | 'vod' | 'series', limit?: number, 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 1688b1fb5..566993fbf 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 @@ -7,6 +7,21 @@ const eqMock = jest.fn((left: unknown, right: unknown) => ({ left, right, })); +const sqlJoinMock = jest.fn((chunks: unknown[], separator?: unknown) => ({ + kind: 'sql.join', + chunks, + separator, +})); +const sqlMock = Object.assign( + jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + kind: 'sql', + strings: Array.from(strings), + values, + })), + { + join: (...args: Parameters) => sqlJoinMock(...args), + } +); jest.mock('drizzle-orm', () => ({ and: (...conditions: unknown[]) => andMock(...conditions), @@ -15,12 +30,18 @@ jest.mock('drizzle-orm', () => ({ eq: (left: unknown, right: unknown) => eqMock(left, right), inArray: jest.fn(), or: jest.fn(), - sql: jest.fn(), + sql: sqlMock, })); import * as schema from '@iptvnator/shared/database/schema'; import type { AppDatabase } from '../database.types'; -import { getContentByXtreamId, saveContent } from './content.operations'; +import { + buildM3uGlobalSearchResults, + globalSearch, + getContentByXtreamId, + saveContent, + scoreSearchTextMatch, +} from './content.operations'; function createDbMock(result: unknown[] = []) { const limit = jest.fn().mockResolvedValue(result); @@ -44,6 +65,8 @@ describe('content.operations', () => { beforeEach(() => { andMock.mockClear(); eqMock.mockClear(); + sqlJoinMock.mockClear(); + sqlMock.mockClear(); }); it('adds the content type filter when resolving by xtream ID', async () => { @@ -255,4 +278,439 @@ describe('content.operations', () => { dateNowSpy.mockRestore(); } }); + + it('builds M3U global search results from playlist payloads and respects hidden groups', () => { + const visibleChannel = { + id: 'channel-news', + url: 'https://stream.test/news.m3u8', + name: 'Daily News', + group: { title: 'News' }, + tvg: { + id: 'daily-news', + name: 'Daily News HD', + url: '', + logo: 'https://image.test/news.png', + rec: '', + }, + http: { + referrer: '', + 'user-agent': '', + origin: '', + }, + radio: '', + }; + const radioChannel = { + ...visibleChannel, + id: 'radio-news', + url: 'https://stream.test/radio.mp3', + name: 'News Radio', + radio: 'true', + }; + const hiddenChannel = { + ...visibleChannel, + id: 'hidden-news', + url: 'https://stream.test/hidden.m3u8', + name: 'Hidden News', + group: { title: 'Hidden' }, + }; + + const results = buildM3uGlobalSearchResults( + [ + { + id: 'm3u-1', + name: 'M3U One', + payload: JSON.stringify({ + hiddenGroupTitles: ['Hidden'], + playlist: { + items: [ + visibleChannel, + radioChannel, + hiddenChannel, + ], + }, + }), + }, + { + id: 'm3u-broken', + name: 'Broken', + payload: '{', + }, + ], + 'news', + true + ); + + expect(results).toEqual([ + expect.objectContaining({ + source_type: 'm3u', + content_type: 'live', + playlist_id: 'm3u-1', + playlist_name: 'M3U One', + channel_id: 'channel-news', + stream_url: 'https://stream.test/news.m3u8', + group_title: 'News', + radio: '', + poster_url: 'https://image.test/news.png', + rating: null, + added: null, + xtream_id: -1, + title: 'Daily News', + type: 'live', + }), + expect.objectContaining({ + channel_id: 'radio-news', + radio: 'true', + title: 'News Radio', + }), + ]); + }); + + it('scores flexible search matches while keeping short first tokens anchored', () => { + const prefixScore = scoreSearchTextMatch('TV Sport News', 'tv'); + const trailingScore = scoreSearchTextMatch('Test TV', 'tv'); + const wordScore = scoreSearchTextMatch('beIN MAX 1', 'max'); + const substringScore = scoreSearchTextMatch('Cinemax East', 'max'); + + expect(prefixScore).not.toBeNull(); + expect(trailingScore).toBeNull(); + expect(wordScore).not.toBeNull(); + expect(substringScore).not.toBeNull(); + expect(wordScore as number).toBeLessThan(substringScore as number); + }); + + it('paginates ranked M3U global search results without losing substring matches', () => { + const makeChannel = (id: string, name: string) => ({ + id, + url: `https://stream.test/${id}.m3u8`, + name, + group: { title: 'Sports' }, + tvg: { + id, + name, + url: '', + logo: '', + rec: '', + }, + http: { + referrer: '', + 'user-agent': '', + origin: '', + }, + radio: '', + }); + + const results = buildM3uGlobalSearchResults( + [ + { + id: 'm3u-1', + name: 'M3U One', + payload: JSON.stringify({ + playlist: { + items: [ + makeChannel('exact', 'MAX'), + makeChannel('word', 'beIN MAX 1'), + makeChannel('substring', 'Cinemax East'), + ], + }, + }), + }, + ], + 'max', + false, + { + limit: 2, + offset: 1, + } + ); + + expect(results.map((item) => item.title)).toEqual([ + 'beIN MAX 1', + 'Cinemax East', + ]); + }); + + it('uses the title index raw query path for short global search prefixes', async () => { + const all = jest.fn().mockResolvedValue([ + { + id: 1, + category_id: 10, + title: 'TV Sport News', + 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 select = jest.fn(); + const db = { + all, + select, + } as unknown as AppDatabase; + + const results = await globalSearch( + db, + 'tv', + ['live'], + false, + ['xtream'], + { limit: 10 } + ); + + expect(all).toHaveBeenCalledTimes(1); + expect(select).not.toHaveBeenCalled(); + expect(sqlJoinMock).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + strings: expect.arrayContaining([ + expect.stringContaining('c.title GLOB'), + ]), + }), + ]), + expect.objectContaining({ + strings: expect.arrayContaining([ + expect.stringContaining(' OR '), + ]), + }) + ); + expect(results).toEqual([ + expect.objectContaining({ + title: 'TV Sport News', + source_type: 'xtream', + content_type: 'live', + }), + ]); + }); + + it('uses the content title FTS path for longer global search terms', async () => { + const all = jest.fn().mockResolvedValue([ + { + id: 1, + category_id: 10, + title: 'beIN MAX 1', + 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 select = jest.fn(); + const db = { + all, + select, + } as unknown as AppDatabase; + + const results = await globalSearch( + db, + 'max', + ['live'], + false, + ['xtream'], + { limit: 10 } + ); + + const sqlStrings = sqlMock.mock.calls + .map(([strings]) => + Array.from(strings as TemplateStringsArray).join(' ') + ) + .join('\n'); + + expect(all).toHaveBeenCalledTimes(1); + expect(select).not.toHaveBeenCalled(); + expect(sqlStrings).toContain('FROM content_title_fts'); + expect(results).toEqual([ + expect.objectContaining({ + title: 'beIN MAX 1', + source_type: 'xtream', + content_type: 'live', + }), + ]); + }); + + 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('keeps accented FTS token variants in global search SQL prefilters', async () => { + const all = jest.fn().mockResolvedValue([ + { + id: 1, + category_id: 10, + title: 'Café TV', + rating: null, + added: null, + poster_url: null, + 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, 'Café', ['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]).toContain('"café"'); + expect(matchSqlCall?.[1]).toContain('"cafe"'); + }); + + it('uses a stable max candidate limit for M3U playlist payload searches', 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, + offset: 100, + }); + + expect(orderBy).toHaveBeenCalledWith(schema.playlists.name); + expect(limit).toHaveBeenCalledWith(5000); + }); + + it('prefilters M3U payloads by searchable text fields instead of raw payload contains', 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, + }); + + const searchPatterns = sqlMock.mock.calls + .flatMap(([, ...values]) => values) + .filter( + (value): value is string => + typeof value === 'string' && + value.toLocaleLowerCase().includes('news') + ); + + expect(searchPatterns.length).toBeGreaterThan(0); + expect(searchPatterns).not.toContain('%news%'); + expect( + searchPatterns.every( + (pattern) => + pattern.includes('"name"') || pattern.includes('"title"') + ) + ).toBe(true); + }); + + it('keeps accented M3U payload text field variants in SQL prefilters', 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, 'Café', ['live'], false, ['m3u'], { + limit: 10, + }); + + const searchPatterns = sqlMock.mock.calls + .flatMap(([, ...values]) => values) + .filter( + (value): value is string => + typeof value === 'string' && + value.toLocaleLowerCase().includes('caf') + ); + + expect(searchPatterns).toEqual( + expect.arrayContaining([ + expect.stringContaining('café'), + expect.stringContaining('cafe'), + ]) + ); + expect( + searchPatterns.every( + (pattern) => + pattern.includes('"name"') || pattern.includes('"title"') + ) + ).toBe(true); + }); }); 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 200bb7cf3..bb8307bc6 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -1,8 +1,17 @@ -import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, or, sql, type SQL } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import { + Channel, + GLOBAL_SEARCH_CONTENT_TYPES, + GLOBAL_SEARCH_RESULT_SOURCES, + GlobalSearchContentType, + GlobalSearchPaginationOptions, + GlobalSearchResult, + GlobalSearchResultSource, + M3uGlobalSearchResult, getXtreamRecentlyAddedMaxEpochSeconds, toXtreamRecentlyAddedEpochSeconds, + XtreamGlobalSearchResult, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { @@ -16,20 +25,680 @@ function escapeLikePattern(term: string): string { return term.replace(/[%_\\]/g, '\\$&'); } -function buildLikePatterns(term: string): string[] { +function normalizeSearchMatchText(value: unknown): string { + return typeof value === 'string' + ? value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' ') + : ''; +} + +function normalizeSqlSearchText(value: unknown): string { + return typeof value === 'string' + ? value + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' ') + : ''; +} + +function getSearchTokens(value: unknown): string[] { + const normalized = normalizeSearchMatchText(value); + return normalized ? normalized.split(' ') : []; +} + +function getSqlSearchTokenGroups(value: unknown): string[][] { + const rawTokens = normalizeSqlSearchText(value).split(' ').filter(Boolean); + const normalizedTokens = getSearchTokens(value); + const tokenCount = Math.max(rawTokens.length, normalizedTokens.length); + + return Array.from({ length: tokenCount }, (_, index) => + [...new Set([rawTokens[index], normalizedTokens[index]])].filter( + Boolean + ) + ).filter((group) => group.length > 0); +} + +function isShortSearchTokenGroup(tokens: readonly string[]): boolean { + return tokens.some((token) => token.length <= 2); +} + +function getSqlSearchTokenVariants(value: unknown): string[] { + return [...new Set(getSqlSearchTokenGroups(value).flat())]; +} + +function buildLikePatterns( + term: string, + mode: 'contains' | 'prefix' = 'contains' +): string[] { const variants = new Set(); - const titleCase = - term.length > 0 - ? term.charAt(0).toLocaleUpperCase() + - term.slice(1).toLocaleLowerCase() - : term; - variants.add(term); - variants.add(term.toLocaleLowerCase()); - variants.add(term.toLocaleUpperCase()); - variants.add(titleCase); + for (const value of [term, ...getSqlSearchTokenVariants(term)]) { + const trimmedValue = value.trim(); + if (!trimmedValue) { + continue; + } - return [...variants].map((value) => `%${escapeLikePattern(value)}%`); + const titleCase = + trimmedValue.length > 0 + ? trimmedValue.charAt(0).toLocaleUpperCase() + + trimmedValue.slice(1).toLocaleLowerCase() + : trimmedValue; + + variants.add(trimmedValue); + variants.add(trimmedValue.toLocaleLowerCase()); + variants.add(trimmedValue.toLocaleUpperCase()); + variants.add(titleCase); + } + + return [...variants].map((value) => { + const escapedValue = escapeLikePattern(value); + return mode === 'prefix' ? `${escapedValue}%` : `%${escapedValue}%`; + }); +} + +const DEFAULT_GLOBAL_SEARCH_LIMIT = 50; +const MAX_GLOBAL_SEARCH_LIMIT = 500; +const MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT = 5000; +const M3U_PLAYLIST_TYPES = ['m3u-file', 'm3u-text', 'm3u-url'] as const; + +interface NormalizedGlobalSearchPagination { + limit: number; + offset: number; +} + +interface ScoredGlobalSearchResult< + T extends GlobalSearchResult = GlobalSearchResult, +> { + result: T; + score: number; +} + +interface M3uPlaylistSearchRow { + id: string; + name: string; + payload: string | null; +} + +interface XtreamGlobalSearchCandidate { + id: number; + category_id: number; + title: string; + rating: string | null; + added: string | null; + poster_url: string | null; + epg_channel_id: string | null; + tv_archive: number | null; + tv_archive_duration: number | null; + direct_source: string | null; + xtream_id: number; + type: string; + playlist_id: string; + playlist_name: string; +} + +interface ParsedM3uPlaylistItems { + items?: unknown; +} + +interface ParsedM3uPlaylistPayload { + hiddenGroupTitles?: unknown; + playlist?: ParsedM3uPlaylistItems; + items?: unknown; +} + +function normalizeSearchText(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeGlobalSearchPagination( + options?: GlobalSearchPaginationOptions | number +): NormalizedGlobalSearchPagination { + const limitValue = typeof options === 'number' ? options : options?.limit; + const offsetValue = typeof options === 'number' ? 0 : options?.offset; + const limit = Number.isFinite(limitValue) + ? Math.min( + Math.max(Math.trunc(Number(limitValue)), 1), + MAX_GLOBAL_SEARCH_LIMIT + ) + : DEFAULT_GLOBAL_SEARCH_LIMIT; + const offset = Number.isFinite(offsetValue) + ? Math.max(Math.trunc(Number(offsetValue)), 0) + : 0; + + return { limit, offset }; +} + +export function scoreSearchTextMatch( + value: string, + searchTerm: string +): number | null { + const candidateText = normalizeSearchMatchText(value); + const searchText = normalizeSearchMatchText(searchTerm); + if (!candidateText || !searchText) { + return null; + } + + const searchTokens = searchText.split(' '); + const candidateTokens = candidateText.split(' '); + const firstSearchToken = searchTokens[0]; + + if ( + firstSearchToken.length <= 2 && + !candidateText.startsWith(firstSearchToken) + ) { + return null; + } + + if (candidateText === searchText) { + return 0; + } + + if ( + candidateText.startsWith(searchText) && + (searchTokens.length > 1 || firstSearchToken.length <= 2) + ) { + return 10; + } + + if (candidateTokens.some((token) => token.startsWith(searchText))) { + return 20; + } + + if ( + searchTokens.every((searchToken) => + candidateTokens.some((candidateToken) => + candidateToken.startsWith(searchToken) + ) + ) + ) { + return 30; + } + + if (candidateText.includes(searchText)) { + return 40; + } + + if ( + searchTokens.every((searchToken) => candidateText.includes(searchToken)) + ) { + return 50; + } + + return null; +} + +function compareScoredGlobalSearchResults( + first: ScoredGlobalSearchResult, + second: ScoredGlobalSearchResult +): number { + const scoreCompare = first.score - second.score; + if (scoreCompare !== 0) { + return scoreCompare; + } + + const playlistCompare = first.result.playlist_name.localeCompare( + second.result.playlist_name + ); + if (playlistCompare !== 0) { + return playlistCompare; + } + + const playlistIdCompare = first.result.playlist_id.localeCompare( + second.result.playlist_id + ); + if (playlistIdCompare !== 0) { + return playlistIdCompare; + } + + const titleCompare = first.result.title.localeCompare(second.result.title); + if (titleCompare !== 0) { + return titleCompare; + } + + return String(first.result.id).localeCompare(String(second.result.id)); +} + +function paginateScoredResults( + results: ScoredGlobalSearchResult[], + pagination: NormalizedGlobalSearchPagination +): T[] { + return results + .sort(compareScoredGlobalSearchResults) + .slice(pagination.offset, pagination.offset + pagination.limit) + .map((item) => item.result); +} + +function getGlobalSearchCandidateLimit(): number { + return MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT; +} + +function buildContentTitleSearchConditions(searchTerm: string) { + const tokenGroups = getSqlSearchTokenGroups(searchTerm); + if (tokenGroups.length === 0) { + return []; + } + + return tokenGroups + .map((tokens, index) => { + const mode = + index === 0 && isShortSearchTokenGroup(tokens) + ? 'prefix' + : 'contains'; + const likeConditions = tokens.flatMap((token) => + buildLikePatterns(token, mode).map( + (pattern) => + sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` + ) + ); + return or(...likeConditions); + }) + .filter((condition) => condition !== undefined); +} + +function shouldUseContentTitlePrefixIndex(searchTerm: string): boolean { + const [firstTokenGroup] = getSqlSearchTokenGroups(searchTerm); + + return !!firstTokenGroup && isShortSearchTokenGroup(firstTokenGroup); +} + +function buildContentTitleFtsMatchQuery(searchTerm: string): string { + return getSqlSearchTokenGroups(searchTerm) + .map((tokens) => { + const quotedTokens = tokens + .filter((token) => token.length >= 3) + .map((token) => `"${token.replace(/"/g, '""')}"`); + + if (quotedTokens.length <= 1) { + return quotedTokens[0] ?? ''; + } + + return `(${quotedTokens.join(' OR ')})`; + }) + .filter(Boolean) + .join(' AND '); +} + +function shouldUseContentTitleFts(searchTerm: string): boolean { + return ( + !shouldUseContentTitlePrefixIndex(searchTerm) && + buildContentTitleFtsMatchQuery(searchTerm).length > 0 + ); +} + +function buildGlobPrefixPatterns(token: string): string[] { + const variants = new Set(); + + for (const value of [token, ...getSqlSearchTokenVariants(token)]) { + variants.add(value); + variants.add(value.toLocaleLowerCase()); + variants.add(value.toLocaleUpperCase()); + variants.add( + value.charAt(0).toLocaleUpperCase() + + value.slice(1).toLocaleLowerCase() + ); + } + + return [...variants].map((value) => `${value}*`); +} + +function buildRawContentTitleSearchSql(searchTerm: string): SQL[] { + return getSqlSearchTokenGroups(searchTerm).map((tokens, index) => { + if (index === 0 && isShortSearchTokenGroup(tokens)) { + return sql`(${sql.join( + tokens.flatMap((token) => + buildGlobPrefixPatterns(token).map( + (pattern) => sql`c.title GLOB ${pattern}` + ) + ), + sql` OR ` + )})`; + } + + return sql`(${sql.join( + tokens.flatMap((token) => + buildLikePatterns(token).map( + (pattern) => sql`c.title LIKE ${pattern} ESCAPE '\\'` + ) + ), + sql` OR ` + )})`; + }); +} + +async function selectXtreamGlobalSearchCandidatesWithTitleIndex( + db: AppDatabase, + searchTerm: string, + types: string[], + excludeHidden: boolean, + candidateLimit: number +): Promise { + const titleConditions = buildRawContentTitleSearchSql(searchTerm); + if (titleConditions.length === 0 || types.length === 0) { + return []; + } + + return (await db.all(sql` + SELECT + c.id AS id, + c.category_id AS category_id, + c.title AS title, + c.rating AS rating, + c.added AS added, + c.poster_url AS poster_url, + c.epg_channel_id AS epg_channel_id, + c.tv_archive AS tv_archive, + c.tv_archive_duration AS tv_archive_duration, + c.direct_source AS direct_source, + c.xtream_id AS xtream_id, + c.type AS type, + cat.playlist_id AS playlist_id, + p.name AS playlist_name + FROM content AS c INDEXED BY idx_content_title + INNER JOIN categories AS cat ON c.category_id = cat.id + INNER JOIN playlists AS p ON cat.playlist_id = p.id + WHERE c.type IN (${sql.join( + types.map((type) => sql`${type}`), + sql`, ` + )}) + AND ${sql.join(titleConditions, sql` AND `)} + ${excludeHidden ? sql`AND cat.hidden = 0` : sql``} + ORDER BY c.title + LIMIT ${candidateLimit} + `)) as XtreamGlobalSearchCandidate[]; +} + +async function selectXtreamGlobalSearchCandidatesWithFts( + db: AppDatabase, + searchTerm: string, + types: string[], + excludeHidden: boolean, + candidateLimit: number +): Promise { + const matchQuery = buildContentTitleFtsMatchQuery(searchTerm); + if (!matchQuery || types.length === 0) { + return []; + } + + return (await db.all(sql` + SELECT + c.id AS id, + c.category_id AS category_id, + c.title AS title, + c.rating AS rating, + c.added AS added, + c.poster_url AS poster_url, + c.epg_channel_id AS epg_channel_id, + c.tv_archive AS tv_archive, + c.tv_archive_duration AS tv_archive_duration, + c.direct_source AS direct_source, + c.xtream_id AS xtream_id, + c.type AS type, + cat.playlist_id AS playlist_id, + p.name AS playlist_name + FROM content_title_fts + INNER JOIN content AS c ON c.id = content_title_fts.rowid + INNER JOIN categories AS cat ON c.category_id = cat.id + INNER JOIN playlists AS p ON cat.playlist_id = p.id + WHERE content_title_fts MATCH ${matchQuery} + AND c.type IN (${sql.join( + types.map((type) => sql`${type}`), + sql`, ` + )}) + ${excludeHidden ? sql`AND cat.hidden = 0` : sql``} + ORDER BY rank, c.title + LIMIT ${candidateLimit} + `)) as XtreamGlobalSearchCandidate[]; +} + +async function selectXtreamGlobalSearchCandidatesWithContentScan( + db: AppDatabase, + searchTerm: string, + types: string[], + excludeHidden: boolean, + candidateLimit: number +): Promise { + const conditions = [ + inArray( + schema.content.type, + types as Array<'live' | 'movie' | 'series'> + ), + ...buildContentTitleSearchConditions(searchTerm), + ]; + + if (excludeHidden) { + conditions.push(eq(schema.categories.hidden, false)); + } + + return db + .select({ + ...selectContentFields(), + playlist_id: schema.categories.playlistId, + playlist_name: schema.playlists.name, + }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .innerJoin( + schema.playlists, + eq(schema.categories.playlistId, schema.playlists.id) + ) + .where(and(...conditions)) + .orderBy(schema.content.title) + .limit(candidateLimit); +} + +function buildM3uPayloadTextFieldPatterns( + token: string, + mode: 'contains' | 'prefix' +): string[] { + return buildLikePatterns(token, mode).flatMap((pattern) => [ + `%"name":"${pattern}"%`, + `%"name": "${pattern}"%`, + `%"title":"${pattern}"%`, + `%"title": "${pattern}"%`, + ]); +} + +function buildM3uPayloadSearchConditions(searchTerm: string) { + const tokenGroups = getSqlSearchTokenGroups(searchTerm); + if (tokenGroups.length === 0) { + return []; + } + + return tokenGroups + .map((tokens, index) => { + const mode = + index === 0 && isShortSearchTokenGroup(tokens) + ? 'prefix' + : 'contains'; + const patterns = tokens.flatMap((token) => + buildM3uPayloadTextFieldPatterns(token, mode) + ); + const likeConditions = patterns.map( + (pattern) => + sql`${schema.playlists.payload} LIKE ${pattern} ESCAPE '\\'` + ); + return or(...likeConditions); + }) + .filter((condition) => condition !== undefined); +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => normalizeSearchText(item)) + .filter((item) => item.length > 0); +} + +function hasGlobalSearchSource( + sources: readonly GlobalSearchResultSource[] | undefined, + source: GlobalSearchResultSource +): boolean { + return !sources || sources.length === 0 || sources.includes(source); +} + +function parseM3uPlaylistPayload( + payload: string | null +): ParsedM3uPlaylistPayload | null { + if (!payload) { + return null; + } + + try { + const parsed = JSON.parse(payload) as unknown; + return parsed && typeof parsed === 'object' + ? (parsed as ParsedM3uPlaylistPayload) + : null; + } catch { + return null; + } +} + +function normalizeM3uChannel(value: unknown): Channel | null { + if (!value || typeof value !== 'object') { + return null; + } + + const source = value as Partial; + const url = normalizeSearchText(source.url); + const name = normalizeSearchText(source.name); + if (!url || !name) { + return null; + } + + return { + id: normalizeSearchText(source.id) || url, + url, + name, + group: { + title: normalizeSearchText(source.group?.title), + }, + tvg: { + id: normalizeSearchText(source.tvg?.id), + name: normalizeSearchText(source.tvg?.name), + url: normalizeSearchText(source.tvg?.url), + logo: normalizeSearchText(source.tvg?.logo), + rec: normalizeSearchText(source.tvg?.rec), + }, + epgParams: source.epgParams, + timeshift: source.timeshift, + catchup: source.catchup, + http: { + referrer: normalizeSearchText(source.http?.referrer), + 'user-agent': normalizeSearchText(source.http?.['user-agent']), + origin: normalizeSearchText(source.http?.origin), + }, + radio: normalizeSearchText(source.radio), + }; +} + +function getM3uPayloadChannels(payload: ParsedM3uPlaylistPayload): Channel[] { + const items = Array.isArray(payload.playlist?.items) + ? payload.playlist?.items + : Array.isArray(payload.items) + ? payload.items + : []; + + return items + .map((item) => normalizeM3uChannel(item)) + .filter((item): item is Channel => item !== null); +} + +function scoreM3uChannel(channel: Channel, searchTerm: string): number | null { + const scores = [ + scoreSearchTextMatch(channel.name, searchTerm), + scoreSearchTextMatch(channel.tvg.name, searchTerm), + scoreSearchTextMatch(channel.group.title, searchTerm), + ].filter((score): score is number => score !== null); + + return scores.length > 0 ? Math.min(...scores) : null; +} + +function toM3uGlobalSearchResult( + row: M3uPlaylistSearchRow, + channel: Channel +): M3uGlobalSearchResult { + return { + source_type: GLOBAL_SEARCH_RESULT_SOURCES.M3u, + content_type: GLOBAL_SEARCH_CONTENT_TYPES.Live, + playlist_id: row.id, + playlist_name: row.name, + channel_id: channel.id, + stream_url: channel.url, + group_title: channel.group.title, + radio: channel.radio, + poster_url: channel.tvg.logo || null, + channel, + id: `${row.id}::${channel.id || channel.url}`, + category_id: 'm3u', + title: channel.name || channel.tvg.name || channel.url, + rating: null, + added: null, + xtream_id: -1, + type: GLOBAL_SEARCH_CONTENT_TYPES.Live, + }; +} + +function buildScoredM3uGlobalSearchResults( + rows: readonly M3uPlaylistSearchRow[], + searchTerm: string, + excludeHidden = false, + maxResults = MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT +): ScoredGlobalSearchResult[] { + const results: ScoredGlobalSearchResult[] = []; + + for (const row of rows) { + const payload = parseM3uPlaylistPayload(row.payload); + if (!payload) { + continue; + } + + const hiddenGroups = new Set(asStringArray(payload.hiddenGroupTitles)); + for (const channel of getM3uPayloadChannels(payload)) { + if (excludeHidden && hiddenGroups.has(channel.group.title)) { + continue; + } + + const score = scoreM3uChannel(channel, searchTerm); + if (score === null) { + continue; + } + + results.push({ + result: toM3uGlobalSearchResult(row, channel), + score, + }); + + if (results.length >= maxResults) { + return results; + } + } + } + + return results; +} + +export function buildM3uGlobalSearchResults( + rows: readonly M3uPlaylistSearchRow[], + searchTerm: string, + excludeHidden = false, + options?: GlobalSearchPaginationOptions | number +): M3uGlobalSearchResult[] { + return paginateScoredResults( + buildScoredM3uGlobalSearchResults(rows, searchTerm, excludeHidden), + normalizeGlobalSearchPagination(options) + ); } export type GlobalRecentlyAddedKind = 'all' | 'vod' | 'series'; @@ -430,23 +1099,17 @@ export async function searchContent( types: string[], excludeHidden = false ) { - if (!types || types.length === 0) { + if (!types || types.length === 0 || !normalizeSearchMatchText(searchTerm)) { return []; } - const searchTermLower = searchTerm.toLocaleLowerCase(); - const likePatterns = buildLikePatterns(searchTerm); - const likeConditions = likePatterns.map( - (pattern) => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` - ); - const conditions = [ eq(schema.categories.playlistId, playlistId), inArray( schema.content.type, types as Array<'live' | 'movie' | 'series'> ), - or(...likeConditions), + ...buildContentTitleSearchConditions(searchTerm), ]; if (excludeHidden) { @@ -464,8 +1127,9 @@ export async function searchContent( .limit(200); return candidates - .filter((item) => - item.title?.toLocaleLowerCase().includes(searchTermLower) + .filter( + (item) => + scoreSearchTextMatch(item.title ?? '', searchTerm) !== null ) .slice(0, 50); } @@ -474,52 +1138,125 @@ export async function globalSearch( db: AppDatabase, searchTerm: string, types: string[], - excludeHidden = false -) { - if (!types || types.length === 0) { + excludeHidden = false, + sources?: GlobalSearchResultSource[], + options?: GlobalSearchPaginationOptions +): Promise { + if (!types || types.length === 0 || !normalizeSearchMatchText(searchTerm)) { return []; } - const searchTermLower = searchTerm.toLocaleLowerCase(); - const likePatterns = buildLikePatterns(searchTerm); - const likeConditions = likePatterns.map( - (pattern) => sql`${schema.content.title} LIKE ${pattern} ESCAPE '\\'` - ); + const pagination = normalizeGlobalSearchPagination(options); + const candidateLimit = getGlobalSearchCandidateLimit(); + const results: ScoredGlobalSearchResult[] = []; - const conditions = [ - inArray( - schema.content.type, - types as Array<'live' | 'movie' | 'series'> - ), - or(...likeConditions), - ]; + if (hasGlobalSearchSource(sources, GLOBAL_SEARCH_RESULT_SOURCES.Xtream)) { + let candidates: XtreamGlobalSearchCandidate[]; - if (excludeHidden) { - conditions.push(eq(schema.categories.hidden, false)); + if (shouldUseContentTitlePrefixIndex(searchTerm)) { + candidates = await selectXtreamGlobalSearchCandidatesWithTitleIndex( + db, + searchTerm, + types, + excludeHidden, + candidateLimit + ); + } else if (shouldUseContentTitleFts(searchTerm)) { + try { + candidates = await selectXtreamGlobalSearchCandidatesWithFts( + db, + searchTerm, + types, + excludeHidden, + candidateLimit + ); + } catch { + candidates = + await selectXtreamGlobalSearchCandidatesWithContentScan( + db, + searchTerm, + types, + excludeHidden, + candidateLimit + ); + } + } else { + candidates = + await selectXtreamGlobalSearchCandidatesWithContentScan( + db, + searchTerm, + types, + excludeHidden, + candidateLimit + ); + } + + results.push( + ...candidates + .map( + ( + item + ): ScoredGlobalSearchResult | null => { + const score = scoreSearchTextMatch( + item.title ?? '', + searchTerm + ); + if (score === null) { + return null; + } + + return { + score, + result: { + ...item, + source_type: + GLOBAL_SEARCH_RESULT_SOURCES.Xtream, + content_type: + item.type as GlobalSearchContentType, + type: item.type as GlobalSearchContentType, + }, + }; + } + ) + .filter( + ( + item + ): item is ScoredGlobalSearchResult => + item !== null + ) + ); } - const candidates = await db - .select({ - ...selectContentFields(), - playlist_id: schema.categories.playlistId, - playlist_name: schema.playlists.name, - }) - .from(schema.content) - .innerJoin( - schema.categories, - eq(schema.content.categoryId, schema.categories.id) - ) - .innerJoin( - schema.playlists, - eq(schema.categories.playlistId, schema.playlists.id) - ) - .where(and(...conditions)) - .orderBy(schema.content.title) - .limit(200); + if ( + types.includes(GLOBAL_SEARCH_CONTENT_TYPES.Live) && + hasGlobalSearchSource(sources, GLOBAL_SEARCH_RESULT_SOURCES.M3u) + ) { + const rows = await db + .select({ + id: schema.playlists.id, + name: schema.playlists.name, + payload: schema.playlists.payload, + }) + .from(schema.playlists) + .where( + and( + inArray(schema.playlists.type, [...M3U_PLAYLIST_TYPES]), + sql`${schema.playlists.payload} IS NOT NULL`, + ...buildM3uPayloadSearchConditions(searchTerm) + ) + ) + .orderBy(schema.playlists.name) + .limit(candidateLimit); - return candidates - .filter((item) => - item.title?.toLocaleLowerCase().includes(searchTermLower) - ) - .slice(0, 50); + results.push( + ...buildScoredM3uGlobalSearchResults( + rows, + searchTerm, + excludeHidden, + candidateLimit + ) + ); + } + + return paginateScoredResults(results, pagination); } diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 5fff69473..2879a258b 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -8,6 +8,10 @@ import { handleWorkerRequest, requestWorkerWithEvents, } from './worker-events.utils'; +import type { + GlobalSearchPaginationOptions, + GlobalSearchResultSource, +} from '@iptvnator/shared/interfaces'; handleWorkerRequest( 'DB_HAS_CONTENT', @@ -114,11 +118,43 @@ handleWorkerRequest( }) ); -handleWorkerRequest( +handleWorkerRequest< + [ + string, + string[], + boolean | undefined, + GlobalSearchResultSource[]?, + GlobalSearchPaginationOptions?, + ] +>( 'DB_GLOBAL_SEARCH', - (searchTerm: string, types: string[], excludeHidden = false) => ({ - searchTerm, - types, - excludeHidden, - }) + ( + searchTerm: string, + types: string[], + excludeHidden = false, + sources?: GlobalSearchResultSource[], + options?: GlobalSearchPaginationOptions + ) => { + const payload: { + searchTerm: string; + types: string[]; + excludeHidden: boolean; + sources?: GlobalSearchResultSource[]; + options?: GlobalSearchPaginationOptions; + } = { + searchTerm, + types, + excludeHidden, + }; + + if (sources?.length) { + payload.sources = sources; + } + + if (options) { + payload.options = options; + } + + return payload; + } ); diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 899732a32..ecdc67bd6 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -459,12 +459,19 @@ async function executeRequest(message: DbWorkerRequestMessage) { searchTerm: string; types: string[]; excludeHidden?: boolean; + sources?: Array<'xtream' | 'm3u'>; + options?: { + limit?: number; + offset?: number; + }; }; return globalSearch( db, payload.searchTerm, payload.types, - payload.excludeHidden + payload.excludeHidden, + payload.sources, + payload.options ); } diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 22a41a3f6..ff34bc4e6 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -95,10 +95,7 @@ export class AppComponent implements OnInit { if (this.runtime.isElectron) { document.addEventListener('keydown', (event) => { if (event.ctrlKey || event.metaKey) { - if (event.key === 'f') { - event.preventDefault(); - this.workspaceShellActions.openGlobalSearch(); - } else if (event.key === 'r') { + if (event.key === 'r') { event.preventDefault(); this.workspaceShellActions.openGlobalRecent(); } diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index fa4bb7078..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 () => { @@ -91,6 +98,31 @@ describe('app routes', () => { expect(typeof globalRecentRoute?.loadComponent).toBe('function'); }); + it('adds a routed /workspace/search global search view', async () => { + const globalSearchRoute = workspaceChildren.find( + (route) => route.path === 'search' + ); + + 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 d15ab2df2..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: '', @@ -80,6 +95,17 @@ export const routes: Routes = [ (c) => c.GlobalCollectionRouteComponent ), }, + { + path: 'search', + canActivate: [electronOnlyGlobalSearchGuard], + data: { + isGlobalSearch: true, + }, + loadComponent: () => + import('@iptvnator/portal/xtream/feature').then( + (c) => c.GlobalSearchResultsComponent + ), + }, { path: 'downloads', loadComponent: () => diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index 47d3ed7a1..14d2bbfb9 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -32,20 +32,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions { } openGlobalSearch(initialQuery = ''): void { - void import('@iptvnator/portal/xtream/feature').then( - ({ GlobalSearchResultsComponent }) => { - this.dialog.open(GlobalSearchResultsComponent, { - width: '100%', - height: '100%', - maxWidth: '100%', - panelClass: 'global-search-overlay', - data: { - isGlobalSearch: true, - initialQuery, - }, - }); - } - ); + const query = initialQuery.trim(); + void this.router.navigate(['/workspace/search'], { + queryParams: query ? { q: query } : {}, + }); } openGlobalRecent(): void { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 1e6938040..67796f181 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "تم العثور على {{count}} نتيجة", "NO_RESULTS_FOR": "لم يتم العثور على نتائج لـ \"{{term}}\"", "GROUP_BY_PLAYLIST": "تجميع النتائج حسب قائمة التشغيل", - "XTREAM_ONLY_NOTE": "البحث الشامل متاح حاليًا لقوائم Xtream فقط.", - "VISIBLE_CATEGORIES_ONLY": "الفئات المرئية فقط" + "VISIBLE_CATEGORIES_ONLY": "الفئات المرئية فقط", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "تم نسخ رابط البث إلى الحافظة", "COPY_STREAM_URL": "نسخ رابط البث", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "فتح المصادر", "RAIL_GLOBAL_FAVORITES": "المفضلة", "RAIL_GLOBAL_RECENT": "تمت مشاهدتها مؤخرًا", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "الإعدادات", "OPEN_SETTINGS": "فتح الإعدادات", "RAIL_LIBRARY": "المكتبة", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "إجراءات القسم", "SEARCH_LABEL": "بحث", "SEARCH_SOURCES_PLACEHOLDER": "البحث في المصادر (جميع قوائم التشغيل)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "البحث في قائمة التشغيل هذه...", "SEARCH_SECTION_PLACEHOLDER": "البحث في هذا القسم...", "FILTER_SECTION_PLACEHOLDER": "تصفية هذا القسم...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "إيقاف المزامنة", "XTREAM_IMPORT_CANCELLING": "جارٍ إيقاف المزامنة...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "البحث في جميع قوائم Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "فتح طبقة البحث الشامل", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "بحث متقدم في قائمة التشغيل هذه", "PLAYLIST_SEARCH_DESCRIPTION": "فتح البحث المتقدم في قائمة التشغيل", "OPEN_GLOBAL_FAVORITES_LABEL": "فتح المفضلة العامة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 9f3119fea..029b5a668 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "تلقاو {{count}} ديال النتايج", "NO_RESULTS_FOR": "ما تلقاو حتى نتيجة لـ \"{{term}}\"", "GROUP_BY_PLAYLIST": "جمع النتائج حسب قائمة التشغيل", - "XTREAM_ONLY_NOTE": "البحث الشامل متوفر دابا غير لقوائم تشغيل Xtream.", - "VISIBLE_CATEGORIES_ONLY": "غير الفئات الظاهرة" + "VISIBLE_CATEGORIES_ONLY": "غير الفئات الظاهرة", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "رابط البث تنقل للحافظة", "COPY_STREAM_URL": "نسخ رابط البث", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "حل المصادر", "RAIL_GLOBAL_FAVORITES": "المفضلة", "RAIL_GLOBAL_RECENT": "متشافين مؤخراً", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "الإعدادات", "OPEN_SETTINGS": "حل الإعدادات", "RAIL_LIBRARY": "المكتبة", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "إجراءات القسم", "SEARCH_LABEL": "بحث", "SEARCH_SOURCES_PLACEHOLDER": "قلب على مصادر (جميع قوائم التشغيل)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "قلب في هاد قائمة التشغيل...", "SEARCH_SECTION_PLACEHOLDER": "قلب في هاد القسم...", "FILTER_SECTION_PLACEHOLDER": "فلتر هاد القسم...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "وقف المزامنة", "XTREAM_IMPORT_CANCELLING": "جاري وقف المزامنة...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "قلب في جميع قوائم تشغيل Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "حل واجهة البحث الشامل", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "بحث متقدم في هاد قائمة التشغيل", "PLAYLIST_SEARCH_DESCRIPTION": "حل البحث المتقدم لقائمة التشغيل", "OPEN_GLOBAL_FAVORITES_LABEL": "حل المفضلة العامة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 2fc65c058..08a763626 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "Знойдзена {{count}} вынікаў", "NO_RESULTS_FOR": "Не знойдзена вынікаў для \"{{term}}\"", "GROUP_BY_PLAYLIST": "Групаваць вынікі па плэйлісце", - "XTREAM_ONLY_NOTE": "Глабальны пошук на дадзены момант даступны толькі для плэйлістоў Xtream.", - "VISIBLE_CATEGORIES_ONLY": "Толькі бачныя катэгорыі" + "VISIBLE_CATEGORIES_ONLY": "Толькі бачныя катэгорыі", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "URL-адрас патоку быў скапіяваны ў буфер абмену.", "COPY_STREAM_URL": "Капіраваць URL-адрас патоку", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Адкрыць крыніцы", "RAIL_GLOBAL_FAVORITES": "Выбранае", "RAIL_GLOBAL_RECENT": "Нядаўна праглянутае", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Налады", "OPEN_SETTINGS": "Адкрыць налады", "RAIL_LIBRARY": "Бібліятэка", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Дзеянні раздзела", "SEARCH_LABEL": "Пошук", "SEARCH_SOURCES_PLACEHOLDER": "Пошук крыніц (усе плэйлісты)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Пошук у гэтым плэйлісце...", "SEARCH_SECTION_PLACEHOLDER": "Пошук у гэтым раздзеле...", "FILTER_SECTION_PLACEHOLDER": "Фільтраваць гэты раздзел...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Спыніць сінхранізацыю", "XTREAM_IMPORT_CANCELLING": "Спыненне сінхранізацыі...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Пошук па ўсіх плэйлістах Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "Адкрыць акно глабальнага пошуку", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Пашыраны пошук у гэтым плэйлісце", "PLAYLIST_SEARCH_DESCRIPTION": "Адкрыць пашыраны пошук па плэйлісце", "OPEN_GLOBAL_FAVORITES_LABEL": "Адкрыць усе фаварыты", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f770eea44..772764a9e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "{{count}} Ergebnisse gefunden", "NO_RESULTS_FOR": "Keine Ergebnisse für \"{{term}}\" gefunden", "GROUP_BY_PLAYLIST": "Ergebnisse nach Playlist gruppieren", - "XTREAM_ONLY_NOTE": "Die globale Suche steht derzeit nur für Xtream-Playlists zur Verfügung.", - "VISIBLE_CATEGORIES_ONLY": "Nur sichtbare Kategorien" + "VISIBLE_CATEGORIES_ONLY": "Nur sichtbare Kategorien", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "Stream-URL in die Zwischenablage kopiert", "COPY_STREAM_URL": "Stream-URL kopieren", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Quellen öffnen", "RAIL_GLOBAL_FAVORITES": "Favoriten", "RAIL_GLOBAL_RECENT": "Zuletzt angesehen", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Einstellungen", "OPEN_SETTINGS": "Einstellungen öffnen", "RAIL_LIBRARY": "Bibliothek", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Bereichsaktionen", "SEARCH_LABEL": "Suche", "SEARCH_SOURCES_PLACEHOLDER": "Quellen durchsuchen (alle Playlists)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "In dieser Playlist suchen...", "SEARCH_SECTION_PLACEHOLDER": "In diesem Abschnitt suchen...", "FILTER_SECTION_PLACEHOLDER": "Diesen Abschnitt filtern...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Synchronisierung stoppen", "XTREAM_IMPORT_CANCELLING": "Synchronisierung wird gestoppt...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Alle Xtream-Playlists durchsuchen", - "GLOBAL_SEARCH_DESCRIPTION": "Globale Suchüberlagerung öffnen", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Erweiterte Suche in dieser Playlist", "PLAYLIST_SEARCH_DESCRIPTION": "Erweiterte Suchansicht der Playlist öffnen", "OPEN_GLOBAL_FAVORITES_LABEL": "Globale Favoriten öffnen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 53ccdaf5e..9d05a48b1 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "Βρέθηκαν {{count}} αποτελέσματα", "NO_RESULTS_FOR": "Δεν βρέθηκαν αποτελέσματα για \"{{term}}\"", "GROUP_BY_PLAYLIST": "Ομαδοποίηση αποτελεσμάτων ανά λίστα αναπαραγωγής", - "XTREAM_ONLY_NOTE": "Η καθολική αναζήτηση είναι προς το παρόν διαθέσιμη μόνο για λίστες αναπαραγωγής Xtream.", - "VISIBLE_CATEGORIES_ONLY": "Μόνο ορατές κατηγορίες" + "VISIBLE_CATEGORIES_ONLY": "Μόνο ορατές κατηγορίες", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "Η διεύθυνση URL ροής αντιγράφηκε στο πρόχειρο", "COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Άνοιγμα πηγών", "RAIL_GLOBAL_FAVORITES": "Αγαπημένα", "RAIL_GLOBAL_RECENT": "Πρόσφατα προβληθέντα", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Ρυθμίσεις", "OPEN_SETTINGS": "Άνοιγμα ρυθμίσεων", "RAIL_LIBRARY": "Βιβλιοθήκη", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Ενέργειες ενότητας", "SEARCH_LABEL": "Αναζήτηση", "SEARCH_SOURCES_PLACEHOLDER": "Αναζήτηση πηγών (όλες οι λίστες αναπαραγωγής)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Αναζήτηση σε αυτή τη λίστα αναπαραγωγής...", "SEARCH_SECTION_PLACEHOLDER": "Αναζήτηση σε αυτή την ενότητα...", "FILTER_SECTION_PLACEHOLDER": "Φιλτράρισμα αυτής της ενότητας...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Διακοπή συγχρονισμού", "XTREAM_IMPORT_CANCELLING": "Διακοπή συγχρονισμού...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Αναζήτηση σε όλες τις λίστες αναπαραγωγής Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "Άνοιγμα επικάλυψης καθολικής αναζήτησης", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Σύνθετη αναζήτηση σε αυτή τη λίστα αναπαραγωγής", "PLAYLIST_SEARCH_DESCRIPTION": "Άνοιγμα σύνθετης αναζήτησης λίστας αναπαραγωγής", "OPEN_GLOBAL_FAVORITES_LABEL": "Άνοιγμα βασικών αγαπημένων", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index d9f25ee14..235bfe5c0 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -807,8 +807,10 @@ "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.", - "VISIBLE_CATEGORIES_ONLY": "Visible categories only" + "VISIBLE_CATEGORIES_ONLY": "Visible categories only", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "Stream URL copied to clipboard", "COPY_STREAM_URL": "Copy Stream URL", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Open sources", "RAIL_GLOBAL_FAVORITES": "Favorites", "RAIL_GLOBAL_RECENT": "Recently viewed", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Settings", "OPEN_SETTINGS": "Open settings", "RAIL_LIBRARY": "Library", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Section actions", "SEARCH_LABEL": "Search", "SEARCH_SOURCES_PLACEHOLDER": "Search sources (all playlists)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Search in this playlist...", "SEARCH_SECTION_PLACEHOLDER": "Search in this section...", "FILTER_SECTION_PLACEHOLDER": "Filter this section...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Stop sync", "XTREAM_IMPORT_CANCELLING": "Stopping sync...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Search all Xtream playlists", - "GLOBAL_SEARCH_DESCRIPTION": "Open global search overlay", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Advanced search this playlist", "PLAYLIST_SEARCH_DESCRIPTION": "Open advanced playlist search", "OPEN_GLOBAL_FAVORITES_LABEL": "Open global favorites", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 719d1f969..8db0ad043 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "Se encontraron {{count}} resultados", "NO_RESULTS_FOR": "No se encontraron resultados para \"{{term}}\"", "GROUP_BY_PLAYLIST": "Agrupar resultados por lista de reproducción", - "XTREAM_ONLY_NOTE": "La búsqueda global está disponible actualmente solo para listas Xtream.", - "VISIBLE_CATEGORIES_ONLY": "Solo categorías visibles" + "VISIBLE_CATEGORIES_ONLY": "Solo categorías visibles", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "URL de transmisión copiada al portapapeles", "COPY_STREAM_URL": "Copiar URL de transmisión", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Abrir fuentes", "RAIL_GLOBAL_FAVORITES": "Favoritos", "RAIL_GLOBAL_RECENT": "Vistos recientemente", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Ajustes", "OPEN_SETTINGS": "Abrir ajustes", "RAIL_LIBRARY": "Biblioteca", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Acciones de la sección", "SEARCH_LABEL": "Buscar", "SEARCH_SOURCES_PLACEHOLDER": "Buscar fuentes (todas las listas)…", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Buscar en esta lista de reproducción…", "SEARCH_SECTION_PLACEHOLDER": "Buscar en esta sección…", "FILTER_SECTION_PLACEHOLDER": "Filtrar esta sección…", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Detener sincronización", "XTREAM_IMPORT_CANCELLING": "Deteniendo sincronización…", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Buscar en todas las listas Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "Abrir el panel de búsqueda global", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Búsqueda avanzada en esta lista", "PLAYLIST_SEARCH_DESCRIPTION": "Abrir búsqueda avanzada de la lista", "OPEN_GLOBAL_FAVORITES_LABEL": "Abrir favoritos globales", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 508bf4da4..3d5cd3b73 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "{{count}} résultats trouvés", "NO_RESULTS_FOR": "Aucun résultat trouvé pour \"{{term}}\"", "GROUP_BY_PLAYLIST": "Grouper les résultats par liste de lecture", - "XTREAM_ONLY_NOTE": "La recherche globale n'est actuellement disponible que pour les listes de lecture Xtream.", - "VISIBLE_CATEGORIES_ONLY": "Catégories visibles uniquement" + "VISIBLE_CATEGORIES_ONLY": "Catégories visibles uniquement", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "URL du flux copiée dans le presse-papiers", "COPY_STREAM_URL": "Copier l'URL du flux", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Ouvrir les sources", "RAIL_GLOBAL_FAVORITES": "Favoris", "RAIL_GLOBAL_RECENT": "Récemment consultés", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Paramètres", "OPEN_SETTINGS": "Ouvrir les paramètres", "RAIL_LIBRARY": "Bibliothèque", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Actions de section", "SEARCH_LABEL": "Rechercher", "SEARCH_SOURCES_PLACEHOLDER": "Rechercher des sources (toutes les listes)…", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Rechercher dans cette liste de lecture…", "SEARCH_SECTION_PLACEHOLDER": "Rechercher dans cette section…", "FILTER_SECTION_PLACEHOLDER": "Filtrer cette section…", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Arrêter la synchronisation", "XTREAM_IMPORT_CANCELLING": "Arrêt de la synchronisation…", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Rechercher dans toutes les listes de lecture Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "Ouvrir la superposition de recherche globale", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Recherche avancée dans cette liste de lecture", "PLAYLIST_SEARCH_DESCRIPTION": "Ouvrir la recherche avancée de la liste de lecture", "OPEN_GLOBAL_FAVORITES_LABEL": "Ouvrir les favoris globaux", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 7ffc98f9f..a11d35f6e 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "Trovati {{count}} risultati", "NO_RESULTS_FOR": "Nessun risultato trovato per \"{{term}}\"", "GROUP_BY_PLAYLIST": "Raggruppa risultati per playlist", - "XTREAM_ONLY_NOTE": "La ricerca globale è attualmente disponibile solo per le playlist Xtream.", - "VISIBLE_CATEGORIES_ONLY": "Solo categorie visibili" + "VISIBLE_CATEGORIES_ONLY": "Solo categorie visibili", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "URL streaming copiato negli appunti", "COPY_STREAM_URL": "Copia URL streaming", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Apri sorgenti", "RAIL_GLOBAL_FAVORITES": "Preferiti", "RAIL_GLOBAL_RECENT": "Visti di recente", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Impostazioni", "OPEN_SETTINGS": "Apri impostazioni", "RAIL_LIBRARY": "Libreria", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Azioni di sezione", "SEARCH_LABEL": "Cerca", "SEARCH_SOURCES_PLACEHOLDER": "Cerca sorgenti (tutte le playlist)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Cerca in questa playlist...", "SEARCH_SECTION_PLACEHOLDER": "Cerca in questa sezione...", "FILTER_SECTION_PLACEHOLDER": "Filtra questa sezione...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Interrompi sincronizzazione", "XTREAM_IMPORT_CANCELLING": "Interruzione sincronizzazione...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Cerca in tutte le playlist Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "Apri overlay di ricerca globale", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Ricerca avanzata in questa playlist", "PLAYLIST_SEARCH_DESCRIPTION": "Apri ricerca avanzata della playlist", "OPEN_GLOBAL_FAVORITES_LABEL": "Apri preferiti globali", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 3eccbc95f..085508fcf 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "{{count}}件の結果が見つかりました", "NO_RESULTS_FOR": "「{{term}}」に一致する結果は見つかりませんでした", "GROUP_BY_PLAYLIST": "プレイリストごとに結果をグループ化", - "XTREAM_ONLY_NOTE": "グローバル検索は現在Xtreamプレイリストでのみ利用可能です。", - "VISIBLE_CATEGORIES_ONLY": "表示中のカテゴリーのみ" + "VISIBLE_CATEGORIES_ONLY": "表示中のカテゴリーのみ", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "ストリームURLがクリップボードにコピーされました", "COPY_STREAM_URL": "ストリームURLをコピー", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "ソースを開く", "RAIL_GLOBAL_FAVORITES": "お気に入り", "RAIL_GLOBAL_RECENT": "最近表示した項目", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "設定", "OPEN_SETTINGS": "設定を開く", "RAIL_LIBRARY": "ライブラリ", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "セクションのアクション", "SEARCH_LABEL": "検索", "SEARCH_SOURCES_PLACEHOLDER": "ソースを検索(すべてのプレイリスト)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "このプレイリスト内を検索...", "SEARCH_SECTION_PLACEHOLDER": "このセクション内を検索...", "FILTER_SECTION_PLACEHOLDER": "このセクションをフィルター...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "同期を停止", "XTREAM_IMPORT_CANCELLING": "同期を停止中...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "すべてのXtreamプレイリストを検索", - "GLOBAL_SEARCH_DESCRIPTION": "グローバル検索オーバーレイを開く", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "このプレイリストを詳細検索", "PLAYLIST_SEARCH_DESCRIPTION": "プレイリストの詳細検索を開く", "OPEN_GLOBAL_FAVORITES_LABEL": "グローバルお気に入りを開く", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index bfa21a99c..f13d09a5f 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "{{count}}개의 결과를 찾았습니다", "NO_RESULTS_FOR": "\"{{term}}\"에 대한 결과를 찾을 수 없습니다", "GROUP_BY_PLAYLIST": "재생목록별로 결과 그룹화", - "XTREAM_ONLY_NOTE": "전역 검색은 현재 Xtream 재생목록에서만 사용할 수 있습니다.", - "VISIBLE_CATEGORIES_ONLY": "표시되는 카테고리만" + "VISIBLE_CATEGORIES_ONLY": "표시되는 카테고리만", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "스트림 URL이 클립보드에 복사되었습니다.", "COPY_STREAM_URL": "스트림 URL 복사", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "소스 열기", "RAIL_GLOBAL_FAVORITES": "즐겨찾기", "RAIL_GLOBAL_RECENT": "최근 본 항목", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "설정", "OPEN_SETTINGS": "설정 열기", "RAIL_LIBRARY": "라이브러리", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "섹션 작업", "SEARCH_LABEL": "검색", "SEARCH_SOURCES_PLACEHOLDER": "소스 검색 (모든 재생목록)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "이 재생목록에서 검색...", "SEARCH_SECTION_PLACEHOLDER": "이 섹션에서 검색...", "FILTER_SECTION_PLACEHOLDER": "이 섹션 필터링...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "동기화 중지", "XTREAM_IMPORT_CANCELLING": "동기화를 중지하는 중...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "모든 Xtream 재생목록 검색", - "GLOBAL_SEARCH_DESCRIPTION": "전역 검색 오버레이 열기", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "이 재생목록 고급 검색", "PLAYLIST_SEARCH_DESCRIPTION": "고급 재생목록 검색 열기", "OPEN_GLOBAL_FAVORITES_LABEL": "전체 즐겨찾기 열기", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 95bda8344..c214f44c9 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "{{count}} resultaten gevonden", "NO_RESULTS_FOR": "Geen resultaten gevonden voor \"{{term}}\"", "GROUP_BY_PLAYLIST": "Resultaten groeperen op afspeellijst", - "XTREAM_ONLY_NOTE": "Algemeen zoeken is momenteel alleen beschikbaar voor Xtream-afspeellijsten.", - "VISIBLE_CATEGORIES_ONLY": "Alleen zichtbare categorieën" + "VISIBLE_CATEGORIES_ONLY": "Alleen zichtbare categorieën", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "Stream URL Gekopieërd", "COPY_STREAM_URL": "Kopieër Stream URL", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Open bronnen", "RAIL_GLOBAL_FAVORITES": "Favorieten", "RAIL_GLOBAL_RECENT": "Recent bekeken", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Instellingen", "OPEN_SETTINGS": "Open instellingen", "RAIL_LIBRARY": "Bibliotheek", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Sectieacties", "SEARCH_LABEL": "Zoeken", "SEARCH_SOURCES_PLACEHOLDER": "Zoek bronnen (alle afspeellijsten)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Zoek in deze afspeellijst...", "SEARCH_SECTION_PLACEHOLDER": "Zoek in deze sectie...", "FILTER_SECTION_PLACEHOLDER": "Filter deze sectie...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Synchronisatie stoppen", "XTREAM_IMPORT_CANCELLING": "Synchronisatie stoppen...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Zoek in alle Xtream-afspeellijsten", - "GLOBAL_SEARCH_DESCRIPTION": "Open algemene zoekoverlay", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Geavanceerd zoeken in deze afspeellijst", "PLAYLIST_SEARCH_DESCRIPTION": "Open geavanceerd zoeken voor afspeellijst", "OPEN_GLOBAL_FAVORITES_LABEL": "Open algemene favorieten", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 7e71c4be1..43042d40b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "Znaleziono {{count}} wyników", "NO_RESULTS_FOR": "Brak wyników dla „{{term}}”", "GROUP_BY_PLAYLIST": "Grupuj wyniki według listy odtwarzania", - "XTREAM_ONLY_NOTE": "Wyszukiwanie globalne jest obecnie dostępne tylko dla list odtwarzania Xtream.", - "VISIBLE_CATEGORIES_ONLY": "Tylko widoczne kategorie" + "VISIBLE_CATEGORIES_ONLY": "Tylko widoczne kategorie", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "URL strumienia skopiowany do schowka", "COPY_STREAM_URL": "Kopiuj URL strumienia", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Otwórz źródła", "RAIL_GLOBAL_FAVORITES": "Ulubione", "RAIL_GLOBAL_RECENT": "Ostatnio oglądane", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Ustawienia", "OPEN_SETTINGS": "Otwórz ustawienia", "RAIL_LIBRARY": "Biblioteka", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Akcje sekcji", "SEARCH_LABEL": "Szukaj", "SEARCH_SOURCES_PLACEHOLDER": "Szukaj źródeł (wszystkie listy odtwarzania)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Szukaj na tej liście odtwarzania...", "SEARCH_SECTION_PLACEHOLDER": "Szukaj w tej sekcji...", "FILTER_SECTION_PLACEHOLDER": "Filtruj tę sekcję...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Zatrzymaj synchronizację", "XTREAM_IMPORT_CANCELLING": "Zatrzymywanie synchronizacji...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Szukaj we wszystkich listach odtwarzania Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "Otwórz globalne wyszukiwanie", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Wyszukiwanie zaawansowane na tej liście odtwarzania", "PLAYLIST_SEARCH_DESCRIPTION": "Otwórz wyszukiwanie zaawansowane listy odtwarzania", "OPEN_GLOBAL_FAVORITES_LABEL": "Otwórz globalne ulubione", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 634bb6b16..f95426f9f 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "{{count}} resultados encontrados", "NO_RESULTS_FOR": "Nenhum resultado encontrado para \"{{term}}\"", "GROUP_BY_PLAYLIST": "Agrupar resultados por playlist", - "XTREAM_ONLY_NOTE": "A pesquisa global está disponível atualmente apenas para playlists Xtream.", - "VISIBLE_CATEGORIES_ONLY": "Apenas categorias visíveis" + "VISIBLE_CATEGORIES_ONLY": "Apenas categorias visíveis", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "URL do stream copiado para a área de transferência", "COPY_STREAM_URL": "Copiar URL do Stream", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Abrir fontes", "RAIL_GLOBAL_FAVORITES": "Favoritos", "RAIL_GLOBAL_RECENT": "Visualizados recentemente", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Configurações", "OPEN_SETTINGS": "Abrir configurações", "RAIL_LIBRARY": "Biblioteca", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Ações da seção", "SEARCH_LABEL": "Pesquisar", "SEARCH_SOURCES_PLACEHOLDER": "Pesquisar fontes (todas as playlists)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Pesquisar nesta playlist...", "SEARCH_SECTION_PLACEHOLDER": "Pesquisar nesta seção...", "FILTER_SECTION_PLACEHOLDER": "Filtrar esta seção...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Parar sincronização", "XTREAM_IMPORT_CANCELLING": "Parando sincronização...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Pesquisar todas as playlists Xtream", - "GLOBAL_SEARCH_DESCRIPTION": "Abrir sobreposição de pesquisa global", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Pesquisa avançada nesta playlist", "PLAYLIST_SEARCH_DESCRIPTION": "Abrir pesquisa avançada de playlist", "OPEN_GLOBAL_FAVORITES_LABEL": "Abrir favoritos globais", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 17facbdc7..de656f55a 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "Найдено {{count}} результатов", "NO_RESULTS_FOR": "Нет результатов по запросу \"{{term}}\"", "GROUP_BY_PLAYLIST": "Группировать результаты по плейлистам", - "XTREAM_ONLY_NOTE": "Глобальный поиск пока доступен только для плейлистов Xtream.", - "VISIBLE_CATEGORIES_ONLY": "Только видимые категории" + "VISIBLE_CATEGORIES_ONLY": "Только видимые категории", + "GLOBAL_SEARCH_SOURCE_NOTE": "Глобальный поиск включает результаты из плейлистов Xtream и M3U.", + "GLOBAL_INITIAL_DESCRIPTION": "Введите минимум 2 символа для поиска по плейлистам Xtream и M3U.", + "LOADING_MORE_RESULTS": "Загружаем еще результаты..." }, "STREAM_URL_COPIED": "URL-адрес потока был скопирован в буфер обмена.", "COPY_STREAM_URL": "Копировать URL-адрес потока", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Открыть источники", "RAIL_GLOBAL_FAVORITES": "Избранное", "RAIL_GLOBAL_RECENT": "Недавно просмотренные", + "RAIL_GLOBAL_SEARCH": "Глобальный поиск", "RAIL_SETTINGS": "Настройки", "OPEN_SETTINGS": "Открыть настройки", "RAIL_LIBRARY": "Библиотека", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Действия раздела", "SEARCH_LABEL": "Поиск", "SEARCH_SOURCES_PLACEHOLDER": "Искать источники (все плейлисты)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Искать во всех плейлистах...", "SEARCH_PLAYLIST_PLACEHOLDER": "Искать в этом плейлисте...", "SEARCH_SECTION_PLACEHOLDER": "Искать в этом разделе...", "FILTER_SECTION_PLACEHOLDER": "Фильтровать этот раздел...", @@ -1002,7 +1006,7 @@ "XTREAM_IMPORT_CANCEL": "Остановить синхронизацию", "XTREAM_IMPORT_CANCELLING": "Остановка синхронизации...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Искать во всех плейлистах Xtream", + "GLOBAL_SEARCH_LABEL": "Искать во всех плейлистах", "GLOBAL_SEARCH_DESCRIPTION": "Открыть глобальный поиск", "PLAYLIST_SEARCH_LABEL": "Расширенный поиск в этом плейлисте", "PLAYLIST_SEARCH_DESCRIPTION": "Открыть расширенный поиск плейлиста", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 409764bd9..10f05f25a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "{{count}} sonuç bulundu", "NO_RESULTS_FOR": "\"{{term}}\" için sonuç bulunamadı", "GROUP_BY_PLAYLIST": "Sonuçları oynatma listesine göre grupla", - "XTREAM_ONLY_NOTE": "Küresel arama şu anda sadece Xtream oynatma listeleri için mevcuttur.", - "VISIBLE_CATEGORIES_ONLY": "Sadece görünür kategoriler" + "VISIBLE_CATEGORIES_ONLY": "Sadece görünür kategoriler", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "Yayın URL'si panoya kopyalandı", "COPY_STREAM_URL": "Yayın URL'sini kopyala", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "Kaynakları aç", "RAIL_GLOBAL_FAVORITES": "Favoriler", "RAIL_GLOBAL_RECENT": "Son izlenenler", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "Ayarlar", "OPEN_SETTINGS": "Ayarları aç", "RAIL_LIBRARY": "Kütüphane", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "Bölüm işlemleri", "SEARCH_LABEL": "Ara", "SEARCH_SOURCES_PLACEHOLDER": "Kaynaklarda ara (tüm oynatma listeleri)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Bu oynatma listesinde ara...", "SEARCH_SECTION_PLACEHOLDER": "Bu bölümde ara...", "FILTER_SECTION_PLACEHOLDER": "Bu bölümü filtrele...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "Eşitlemeyi durdur", "XTREAM_IMPORT_CANCELLING": "Eşitleme durduruluyor...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "Tüm Xtream oynatma listelerinde ara", - "GLOBAL_SEARCH_DESCRIPTION": "Genel arama katmanını aç", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "Bu oynatma listesinde gelişmiş arama", "PLAYLIST_SEARCH_DESCRIPTION": "Gelişmiş oynatma listesi aramasını aç", "OPEN_GLOBAL_FAVORITES_LABEL": "Tüm favorileri aç", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 2c1062b83..ca2f3a839 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "找到 {{count}} 条结果", "NO_RESULTS_FOR": "未找到与“{{term}}”匹配的结果", "GROUP_BY_PLAYLIST": "按播放列表分组结果", - "XTREAM_ONLY_NOTE": "全局搜索目前仅支持 Xtream 播放列表。", - "VISIBLE_CATEGORIES_ONLY": "仅可见分类" + "VISIBLE_CATEGORIES_ONLY": "仅可见分类", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "流 URL 已复制到剪贴板", "COPY_STREAM_URL": "复制流 URL", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "打开源", "RAIL_GLOBAL_FAVORITES": "收藏", "RAIL_GLOBAL_RECENT": "最近观看", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "设置", "OPEN_SETTINGS": "打开设置", "RAIL_LIBRARY": "库", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "章节操作", "SEARCH_LABEL": "搜索", "SEARCH_SOURCES_PLACEHOLDER": "搜索源(所有播放列表)…", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "在此播放列表中搜索…", "SEARCH_SECTION_PLACEHOLDER": "在此章节中搜索…", "FILTER_SECTION_PLACEHOLDER": "筛选此章节…", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "停止同步", "XTREAM_IMPORT_CANCELLING": "正在停止同步…", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "搜索所有 Xtream 播放列表", - "GLOBAL_SEARCH_DESCRIPTION": "打开全局搜索浮层", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "高级搜索此播放列表", "PLAYLIST_SEARCH_DESCRIPTION": "打开高级播放列表搜索", "OPEN_GLOBAL_FAVORITES_LABEL": "打开全局收藏", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ff794a039..ccb24b0ef 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -807,8 +807,10 @@ "RESULTS_FOUND": "找到 {{count}} 筆結果", "NO_RESULTS_FOR": "找不到與「{{term}}」相符的結果", "GROUP_BY_PLAYLIST": "依播放清單分組顯示結果", - "XTREAM_ONLY_NOTE": "全域搜尋目前僅支援 Xtream 播放清單。", - "VISIBLE_CATEGORIES_ONLY": "僅顯示可見類別" + "VISIBLE_CATEGORIES_ONLY": "僅顯示可見類別", + "GLOBAL_SEARCH_SOURCE_NOTE": "Global search includes results from Xtream and M3U playlists.", + "GLOBAL_INITIAL_DESCRIPTION": "Enter at least 2 characters to search across Xtream and M3U playlists.", + "LOADING_MORE_RESULTS": "Loading more results..." }, "STREAM_URL_COPIED": "串流網址已複製到剪貼簿", "COPY_STREAM_URL": "複製串流網址", @@ -928,6 +930,7 @@ "OPEN_SOURCES": "開啟來源", "RAIL_GLOBAL_FAVORITES": "我的最愛", "RAIL_GLOBAL_RECENT": "最近觀看", + "RAIL_GLOBAL_SEARCH": "Global search", "RAIL_SETTINGS": "設定", "OPEN_SETTINGS": "開啟設定", "RAIL_LIBRARY": "媒體庫", @@ -945,6 +948,7 @@ "RAIL_CONTEXT_ACTIONS": "區段操作", "SEARCH_LABEL": "搜尋", "SEARCH_SOURCES_PLACEHOLDER": "搜尋來源(所有播放清單)...", + "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "在此播放清單中搜尋...", "SEARCH_SECTION_PLACEHOLDER": "在此區段中搜尋...", "FILTER_SECTION_PLACEHOLDER": "篩選此區段...", @@ -1002,8 +1006,8 @@ "XTREAM_IMPORT_CANCEL": "停止同步", "XTREAM_IMPORT_CANCELLING": "正在停止同步...", "COMMANDS": { - "GLOBAL_SEARCH_LABEL": "搜尋所有 Xtream 播放清單", - "GLOBAL_SEARCH_DESCRIPTION": "開啟全域搜尋疊加層", + "GLOBAL_SEARCH_LABEL": "Search all playlists", + "GLOBAL_SEARCH_DESCRIPTION": "Open global search", "PLAYLIST_SEARCH_LABEL": "在此播放清單中進行進階搜尋", "PLAYLIST_SEARCH_DESCRIPTION": "開啟進階播放清單搜尋", "OPEN_GLOBAL_FAVORITES_LABEL": "開啟全域我的最愛", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 799b67185..c4d00706e 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -193,13 +193,6 @@ textarea, backdrop-filter: blur(4px); } -// Full-screen overlay dialogs (global search/recent) must stay interactive in Electron. -.global-search-overlay, -.global-search-overlay .mat-mdc-dialog-surface, -.global-search-overlay .mat-mdc-dialog-container { - app-region: no-drag; -} - // Command palette dialog — the palette-shell renders its own background, border, // radius and shadow, so strip MDC's dialog surface chrome to avoid a darker // halo peeking out behind the rounded corners. diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 66cdaf999..973b6c746 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -203,6 +203,56 @@ state handlers that still used direct main-thread SQLite access. 6. `DB_GLOBAL_SEARCH` 7. `DB_GET_GLOBAL_RECENTLY_ADDED` +`DB_GLOBAL_SEARCH` returns a shared global-search result union rather than an +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. 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. SQL prefilters keep both + raw lower-case tokens and accent-normalized tokens, so an accented query such + as `Café` can still reach rows stored as either `Café` or `Cafe` before the + worker's accent-insensitive ranking step. Existing databases rebuild this + index once through the `migration:content-title-fts-trigram:v1` app-state + marker before legacy content cleanup/normalization migrations run; 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` against channel + `name`/`title` JSON fields 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. M3U payload prefilters also preserve raw + accented token variants next to normalized variants. The SQL candidate query + is capped by the same stable 5000-row 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. +4. The optional pagination argument accepts `{ limit, offset }`. The worker + keeps the legacy default of 50 results when the argument is omitted, but the + routed global-search view requests one extra row per page to implement + lazy loading without requiring a separate count query. Candidate selection + uses a stable max-size pool for every page so score-based in-memory ranking + cannot shift already-rendered items into later pages. +5. Candidate rows are ranked in the worker after the coarse SQL prefilter. + Exact and prefix matches sort ahead of word-prefix and substring matches; + short first tokens such as `tv` stay anchored to the start of the title, so + `TV Sport` matches but `Test TV` does not. These short first-token queries + bypass trigram FTS and use the `idx_content_title` prefix index path because + trigram tokenization cannot match 1-2 character terms. +6. `excludeHidden` still filters hidden Xtream categories and also filters M3U + channels whose `group.title` is listed in the playlist payload's + `hiddenGroupTitles`. +7. M3U radio entries are returned as live results with `radio = "true"` and the + serialized `Channel` attached for renderer playback routing. M3U global + search rows are not Xtream content rows: they use `xtream_id = -1`, and + Xtream-only metadata such as `rating`, `added`, and a missing `poster_url` + is represented as `null`. +8. EPG-program text is not part of this contract; EPG search remains a separate + feature because it uses different persistence and freshness rules. + ### Playlist metadata 1. `DB_CREATE_PLAYLIST` @@ -288,7 +338,8 @@ to `epg-worker.service.ts`. Xtream search now guards against stale async responses: 1. local playlist search uses a monotonically increasing request version -2. global search uses a separate request version in the dialog component +2. global search uses a separate request version in the routed workspace search + component 3. clearing search invalidates older pending results This prevents an older worker response from repainting over a newer query or a diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 991582789..6372f3bd3 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -43,10 +43,12 @@ Current workspace routes: 4. `/workspace/sources` 5. `/workspace/playlists/:id/:view` 6. `/workspace/global-favorites` -7. `/workspace/downloads` -8. `/workspace/settings` -9. `/workspace/xtreams/:id/...` -10. `/workspace/stalker/:id/...` +7. `/workspace/global-recent` +8. `/workspace/search` +9. `/workspace/downloads` +10. `/workspace/settings` +11. `/workspace/xtreams/:id/...` +12. `/workspace/stalker/:id/...` Compatibility redirect: @@ -74,7 +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 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: @@ -142,17 +146,24 @@ Search is shell-owned and route-aware: 1. Disabled on settings routes. 2. Enabled on sources routes. -3. Enabled for supported Xtream and Stalker content/search views. -4. Placeholder text and search handling vary by provider and section. -5. Input changes are debounced before route/store updates are applied. +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. +7. Global search uses the header input as its primary input and writes the + search phrase to the `q` query parameter, so history/back-forward behavior + matches the rest of the workspace. Rail navigation is also shell-owned: 1. Workspace-global entries are static. 2. Provider entries come from `buildPortalRailLinks(...)`. -3. On dashboard, sources, settings, and global favorites, the shell falls back - to the currently selected playlist so provider navigation remains available - even outside a provider route. +3. On dashboard, sources, settings, global search, global favorites, and global + recent, the shell falls back to the currently selected playlist so provider + navigation remains available even outside a provider route. Command palette behavior is shell-owned but view-extensible: diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 3affa7c56..e419e494b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -306,6 +306,8 @@ describe('VideoPlayerComponent', () => { overlayMock.create.mockClear(); overlayRef.attach.mockClear(); overlayRef.dispose.mockClear(); + routerMock.currentNavigation.mockReturnValue(null); + routerMock.navigate.mockClear(); storeMock.dispatch.mockClear(); dataServiceMock.sendIpcEvent.mockClear(); @@ -768,6 +770,32 @@ describe('VideoPlayerComponent', () => { ); }); + it('opens an M3U channel passed from global search route state', () => { + const globalSearchChannel = { + ...sampleChannel, + id: 'global-search-channel', + url: 'http://localhost/global-search-live.m3u8', + name: 'Global Search Live', + } as Channel; + routerMock.currentNavigation.mockReturnValue({ + extras: { + state: { + openM3uChannelUrl: globalSearchChannel.url, + }, + }, + }); + activeChannel.set(null); + activeChannel$.next(null); + channels.set([sampleChannel, globalSearchChannel]); + channels$.next([sampleChannel, globalSearchChannel]); + + fixture.detectChanges(); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + ChannelActions.setActiveChannel({ channel: globalSearchChannel }) + ); + }); + it('changes channels from remote navigation through a playback request', () => { const nextChannel = { ...sampleChannel, diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index bfd78db6e..f8f7ebfdd 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -321,13 +321,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { const state = this.router.currentNavigation()?.extras?.state ?? window.history.state; - const targetUrl = + const recentTargetUrl = typeof state?.openRecentChannelUrl === 'string' ? state.openRecentChannelUrl.trim() : ''; + const globalSearchTargetUrl = + typeof state?.openM3uChannelUrl === 'string' + ? state.openM3uChannelUrl.trim() + : ''; + const targetUrl = globalSearchTargetUrl || recentTargetUrl; + const canOpenGlobalSearchTarget = + !!globalSearchTargetUrl && currentView === 'all'; + const canOpenRecentTarget = + !!recentTargetUrl && currentView === 'recent'; if ( - currentView !== 'recent' || + (!canOpenGlobalSearchTarget && !canOpenRecentTarget) || !targetUrl || channels.length === 0 ) { @@ -335,7 +344,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } if (activeChannel?.url === targetUrl) { - this.clearConsumedRecentChannelState(); + this.clearConsumedChannelOpenState(); return; } @@ -349,7 +358,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.store.dispatch( ChannelActions.setActiveChannel({ channel: matchedChannel }) ); - this.clearConsumedRecentChannelState(); + this.clearConsumedChannelOpenState(); }); effect(() => { @@ -627,18 +636,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ); } - private clearConsumedRecentChannelState(): void { + private clearConsumedChannelOpenState(): void { const historyState = (window.history.state ?? {}) as Record< string, unknown >; - if (!historyState['openRecentChannelUrl']) { + if ( + !historyState['openRecentChannelUrl'] && + !historyState['openM3uChannelUrl'] + ) { return; } try { const nextState = { ...historyState }; delete nextState['openRecentChannelUrl']; + delete nextState['openM3uChannelUrl']; window.history.replaceState(nextState, document.title); } catch { // no-op diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts index f1cc1d469..6c8ae46de 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts @@ -71,6 +71,8 @@ export class ContentCardComponent { switch (this.type()) { case 'live': return 'live_tv'; + case 'radio': + return 'radio'; case 'series': return 'tv'; default: diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html index 25b0a9547..7901f338c 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html @@ -47,7 +47,10 @@ -
+
@if (isLoading()) {
@@ -59,8 +62,17 @@
search_off
-

{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}

-

{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}

+

+ {{ + 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate + }} +

+

+ {{ + 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' + | translate + }} +

{{ 'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR' @@ -73,8 +85,13 @@

search
-

{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}

-

{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}

+

+ {{ + 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' + | translate + }} +

+

{{ initialDescriptionKey() | translate }}

} diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss index 77a0de733..adaf58157 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.scss @@ -12,6 +12,7 @@ display: flex; flex-direction: column; height: 100%; + min-height: 0; width: 100%; } @@ -19,7 +20,8 @@ display: flex; flex-direction: column; flex: 1; - overflow-y: auto; + min-height: 0; + overflow: hidden; padding: 0 20px 0 20px; } @@ -90,6 +92,7 @@ .results-container { flex: 1; + min-height: 0; overflow-y: auto; } @@ -154,18 +157,3 @@ @include grid.content-grid; padding-bottom: 1rem; } - -// Global search overlay context -:host-context(.global-search-overlay) { - // Prevent Electron's app-region: drag from bleeding through the dialog overlay - app-region: no-drag; - - .search-page { - background: var(--background); - height: 100vh; - } - - .header { - background: var(--background); - } -} diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts new file mode 100644 index 000000000..a52cbd89c --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslatePipe } from '@ngx-translate/core'; +import { MockPipe } from 'ng-mocks'; +import { SearchLayoutComponent } from './search-layout.component'; + +describe('SearchLayoutComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchLayoutComponent], + }) + .overrideComponent(SearchLayoutComponent, { + remove: { imports: [TranslatePipe] }, + add: { + imports: [ + MockPipe( + TranslatePipe, + (value: string | null | undefined) => value ?? '' + ), + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(SearchLayoutComponent); + }); + + function renderResultsContainer(): HTMLElement { + fixture.componentRef.setInput('searchTerm', 'matrix'); + fixture.componentRef.setInput('resultsCount', 1); + fixture.detectChanges(); + + return fixture.debugElement.query(By.css('.results-container')) + .nativeElement as HTMLElement; + } + + function setScrollMetrics( + element: HTMLElement, + scrollTop: number, + scrollHeight = 1000, + clientHeight = 120 + ): void { + Object.defineProperties(element, { + scrollHeight: { + configurable: true, + value: scrollHeight, + }, + scrollTop: { + configurable: true, + value: scrollTop, + }, + clientHeight: { + configurable: true, + value: clientHeight, + }, + }); + } + + it('emits nearEnd when the results container is scrolled near the bottom', () => { + const nearEndSpy = jest.fn(); + fixture.componentInstance.nearEnd.subscribe(nearEndSpy); + const resultsContainer = renderResultsContainer(); + + setScrollMetrics(resultsContainer, 700); + + resultsContainer.dispatchEvent(new Event('scroll')); + + expect(nearEndSpy).toHaveBeenCalledTimes(1); + }); + + it('emits nearEnd only when crossing into the bottom threshold', () => { + const nearEndSpy = jest.fn(); + fixture.componentInstance.nearEnd.subscribe(nearEndSpy); + const resultsContainer = renderResultsContainer(); + + setScrollMetrics(resultsContainer, 500); + resultsContainer.dispatchEvent(new Event('scroll')); + expect(nearEndSpy).not.toHaveBeenCalled(); + + setScrollMetrics(resultsContainer, 700); + resultsContainer.dispatchEvent(new Event('scroll')); + expect(nearEndSpy).toHaveBeenCalledTimes(1); + + setScrollMetrics(resultsContainer, 710); + resultsContainer.dispatchEvent(new Event('scroll')); + expect(nearEndSpy).toHaveBeenCalledTimes(1); + + setScrollMetrics(resultsContainer, 500); + resultsContainer.dispatchEvent(new Event('scroll')); + setScrollMetrics(resultsContainer, 760); + resultsContainer.dispatchEvent(new Event('scroll')); + expect(nearEndSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts index 284810ec4..6bf728dc5 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.ts @@ -27,6 +27,8 @@ import { SearchFormComponent } from '../search-form/search-form.component'; }) export class SearchLayoutComponent { private readonly searchFormComponent = viewChild(SearchFormComponent); + private readonly nearEndThresholdPx = 240; + private isWithinNearEndThreshold = false; /** Page title translation key */ readonly title = input('PORTALS.SIDEBAR.SEARCH'); @@ -55,12 +57,20 @@ export class SearchLayoutComponent { /** Minimum characters required for search */ readonly minSearchLength = input(3); + /** Initial state description translation key */ + readonly initialDescriptionKey = input( + 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' + ); + /** Emitted when search term changes */ readonly searchTermChange = output(); /** Emitted when close button is clicked */ readonly closeClick = output(); + /** Emitted when the scroll container is close to the bottom */ + readonly nearEnd = output(); + /** Focus the search input */ focusSearchInput(): void { this.searchFormComponent()?.focusSearchInput(); @@ -74,6 +84,23 @@ export class SearchLayoutComponent { this.closeClick.emit(); } + onSearchContentScroll(event: Event): void { + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + const distanceToBottom = + target.scrollHeight - target.scrollTop - target.clientHeight; + const isNearEnd = distanceToBottom <= this.nearEndThresholdPx; + + if (isNearEnd && !this.isWithinNearEndThreshold) { + this.nearEnd.emit(); + } + + this.isWithinNearEndThreshold = isNearEnd; + } + /** Check if we should show the "no results" state */ get showNoResults(): boolean { return ( diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts index 39338be01..895c360a2 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts @@ -5,12 +5,13 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { GlobalSearchResult } from '@iptvnator/services'; +import { GlobalSearchResult } from '@iptvnator/shared/interfaces'; import { XTREAM_DATA_SOURCE, XtreamContentItem, } from '../../data-sources/xtream-data-source.interface'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { XtreamSearchResultItem } from '../../xtream-state'; /** * Search filters configuration @@ -27,7 +28,7 @@ export interface SearchFilters { export interface SearchState { searchTerm: string; searchFilters: SearchFilters; - searchResults: XtreamContentItem[]; + searchResults: XtreamSearchResultItem[]; globalSearchResults: GlobalSearchResult[]; isSearching: boolean; } diff --git a/libs/portal/xtream/data-access/src/lib/xtream-state.ts b/libs/portal/xtream/data-access/src/lib/xtream-state.ts index 6ea17eb79..700a8dddf 100644 --- a/libs/portal/xtream/data-access/src/lib/xtream-state.ts +++ b/libs/portal/xtream/data-access/src/lib/xtream-state.ts @@ -1,6 +1,6 @@ -import { GlobalSearchResult } from '@iptvnator/services'; import { EpgItem, + GlobalSearchResult, XtreamCategory, XtreamLiveStream, XtreamSerieItem, @@ -40,6 +40,8 @@ export interface XtreamPortalStatus { message?: string; } +export type XtreamSearchResultItem = XtreamContentItem | GlobalSearchResult; + export interface XtreamState { isLoadingCategories: boolean; isLoadingContent: boolean; @@ -54,7 +56,7 @@ export interface XtreamState { page: number; limit: number; selectedCategoryId: number | null; - searchResults: XtreamContentItem[]; + searchResults: XtreamSearchResultItem[]; selectedContentType: ContentType; selectedItem: unknown | null; importCount: number; diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html index 1e60c956c..fce516743 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html @@ -5,8 +5,11 @@ [showCloseButton]="false" [showResultsCount]="!isGlobalSearch || !groupByPlaylist()" [showSearchInput]="showInlineSearchInput" + [minSearchLength]="minSearchLength" + [initialDescriptionKey]="initialDescriptionKey" (searchTermChange)="updateSearchTerm($event)" (closeClick)="onCloseDialog()" + (nearEnd)="loadMoreGlobalResults()" > @@ -23,13 +26,15 @@ - - {{ 'PORTALS.SEARCH_VIEW.VISIBLE_CATEGORIES_ONLY' | translate }} - - @if (isGlobalSearch) { + @if (!isGlobalSearch) { + + {{ 'PORTALS.SEARCH_VIEW.VISIBLE_CATEGORIES_ONLY' | translate }} + + } + @if (showGlobalCloseButton) { @@ -39,28 +44,49 @@ @if (isGlobalSearch) {
-
-
- info_outline - {{ 'PORTALS.SEARCH_VIEW.XTREAM_ONLY_NOTE' | translate }} -
+
+ + + {{ + 'PORTALS.SEARCH_VIEW.GLOBAL_SEARCH_SOURCE_NOTE' + | translate + }} + +
+
@for (filter of filterConfig; track filter.key) { {{ filter.translationKey | translate }} }
+
+ + {{ + 'PORTALS.SEARCH_VIEW.VISIBLE_CATEGORIES_ONLY' + | translate + }} + + + {{ + 'PORTALS.SEARCH_VIEW.GROUP_BY_PLAYLIST' + | translate + }} + +
- - {{ 'PORTALS.SEARCH_VIEW.GROUP_BY_PLAYLIST' | translate }} -
} @@ -73,27 +99,26 @@

- Found {{ xtreamStore.searchResults().length }} items across - {{ (groupedResults() | keyvalue).length }} playlists + Found {{ xtreamStore.searchResults().length }} items + across {{ groupedResults().length }} playlists

- @for ( - group of groupedResults() | keyvalue; - track group.key - ) { + @for (group of groupedResults(); track group.playlistId) {

playlist_play - {{ group.key }} + {{ group.playlistName }} ({{ $any(group).value.length }} items)({{ group.items.length }} items)

- @for (item of $any(group).value; track item.id) { + @for (item of group.items; track item.id) { @@ -107,21 +132,32 @@
@for (item of xtreamStore.searchResults(); track item.id) { }
} + @if (isLoadingMoreGlobalResults()) { +
+ + + {{ + 'PORTALS.SEARCH_VIEW.LOADING_MORE_RESULTS' + | translate + }} + +
+ } } @else {
@for (item of xtreamStore.searchResults(); track item.id) { ({ ContentCardComponent: class {}, @@ -94,7 +95,10 @@ describe('SearchResultsComponent initialQuery contract', () => { provide: ActivatedRoute, useValue: { snapshot: { - data: {}, + data: { + isGlobalSearch: true, + layout: 'workspace', + }, queryParamMap: convertToParamMap({ q: '' }), }, queryParamMap: of(convertToParamMap({ q: '' })), @@ -142,6 +146,19 @@ describe('SearchResultsComponent initialQuery contract', () => { expect(store.setSearchTerm).not.toHaveBeenCalled(); }); + it('clears the workspace global search term when the route q param is empty', () => { + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + store.searchTerm.set('matrix'); + + const component = TestBed.runInInjectionContext( + () => new SearchResultsComponent(null, undefined) + ); + TestBed.flushEffects(); + + expect(store.setSearchTerm).toHaveBeenCalledWith(''); + expect(component.searchTerm()).toBe(''); + }); + it('navigates global search selection to workspace xtream route', () => { const component = TestBed.runInInjectionContext( () => @@ -167,6 +184,66 @@ describe('SearchResultsComponent initialQuery contract', () => { ); }); + it('navigates M3U global search selection to the M3U player with route state', () => { + const component = TestBed.runInInjectionContext( + () => + new SearchResultsComponent( + { + isGlobalSearch: true, + }, + undefined + ) + ); + const channel = { + id: 'channel-news', + url: 'https://stream.test/news.m3u8', + name: 'Daily News', + group: { title: 'News' }, + tvg: { + id: 'daily-news', + name: 'Daily News HD', + url: '', + logo: '', + rec: '', + }, + http: { + referrer: '', + 'user-agent': '', + origin: '', + }, + radio: '', + }; + + component.selectItem({ + source_type: 'm3u', + content_type: 'live', + playlist_id: 'm3u-1', + playlist_name: 'M3U One', + channel_id: 'channel-news', + stream_url: 'https://stream.test/news.m3u8', + group_title: 'News', + radio: '', + poster_url: '', + channel, + id: 'm3u-1::channel-news', + category_id: 'm3u', + title: 'Daily News', + rating: '', + added: '', + xtream_id: 0, + type: 'live', + } satisfies GlobalSearchResult); + + expect(routerNavigateMock).toHaveBeenCalledWith( + ['/workspace', 'playlists', 'm3u-1', 'all'], + { + state: { + openM3uChannelUrl: 'https://stream.test/news.m3u8', + }, + } + ); + }); + it('ignores stale global search responses once a newer search has completed', async () => { const staleSearch = createDeferred(); const freshResults = [ @@ -181,9 +258,12 @@ describe('SearchResultsComponent initialQuery contract', () => { const component = TestBed.runInInjectionContext( () => - new SearchResultsComponent({ - isGlobalSearch: true, - }, undefined) + new SearchResultsComponent( + { + isGlobalSearch: true, + }, + undefined + ) ); const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; @@ -200,4 +280,157 @@ describe('SearchResultsComponent initialQuery contract', () => { expect(store.searchResults()).toEqual(freshResults); }); + + it('loads additional global search pages and appends them to existing results', async () => { + const firstPage = Array.from({ length: 101 }, (_, index) => + createSearchItem({ + id: index + 1, + title: `Result ${index + 1}`, + xtream_id: index + 1, + }) + ); + const secondPage = [ + createSearchItem({ + id: 102, + title: 'Result 102', + xtream_id: 102, + }), + ]; + const databaseService = TestBed.inject(DatabaseService) as { + globalSearchContent: jest.Mock; + }; + databaseService.globalSearchContent + .mockResolvedValueOnce(firstPage) + .mockResolvedValueOnce(secondPage); + + const component = TestBed.runInInjectionContext( + () => + new SearchResultsComponent( + { + isGlobalSearch: true, + }, + undefined + ) + ); + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + + await component.searchGlobal('matrix', ['movie'], false); + + expect(databaseService.globalSearchContent).toHaveBeenCalledWith( + 'matrix', + ['movie'], + false, + undefined, + { + limit: 101, + offset: 0, + } + ); + expect(store.searchResults()).toHaveLength(100); + expect(component.hasMoreGlobalResults()).toBe(true); + + await component.loadMoreGlobalResults(); + + expect(databaseService.globalSearchContent).toHaveBeenLastCalledWith( + 'matrix', + ['movie'], + false, + undefined, + { + limit: 101, + offset: 100, + } + ); + expect(store.searchResults()).toHaveLength(101); + expect(store.searchResults()[100].title).toBe('Result 102'); + expect(component.hasMoreGlobalResults()).toBe(false); + }); + + it('clears the load-more indicator when a new global search supersedes pagination', async () => { + const appendSearch = createDeferred(); + const freshSearch = createDeferred(); + const databaseService = TestBed.inject(DatabaseService) as { + globalSearchContent: jest.Mock; + }; + databaseService.globalSearchContent + .mockReturnValueOnce(appendSearch.promise) + .mockReturnValueOnce(freshSearch.promise); + + const component = TestBed.runInInjectionContext( + () => + new SearchResultsComponent( + { + isGlobalSearch: true, + }, + undefined + ) + ); + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + store.searchResults.set([createSearchItem()]); + component.hasMoreGlobalResults.set(true); + + const appendPromise = component.searchGlobal( + 'matrix', + ['movie'], + false, + true + ); + expect(component.isLoadingMoreGlobalResults()).toBe(true); + + const freshPromise = component.searchGlobal('news', ['live'], false); + + expect(component.isLoadingMoreGlobalResults()).toBe(false); + + appendSearch.resolve([]); + freshSearch.resolve([]); + await Promise.all([appendPromise, freshPromise]); + }); + + it('uses the rendered global search results when calculating the next page offset', async () => { + const nextPage = [ + createSearchItem({ + id: 2, + title: 'Result 2', + xtream_id: 2, + }), + ]; + const databaseService = TestBed.inject(DatabaseService) as { + globalSearchContent: jest.Mock; + }; + databaseService.globalSearchContent.mockResolvedValueOnce(nextPage); + + const component = TestBed.runInInjectionContext( + () => + new SearchResultsComponent( + { + isGlobalSearch: true, + }, + undefined + ) + ); + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + store.searchResults.set([createSearchItem({ id: 1, xtream_id: 1 })]); + ( + store as unknown as { + globalSearchResults: ReturnType< + typeof signal + >; + } + ).globalSearchResults = signal([]); + component.hasMoreGlobalResults.set(true); + + await component.searchGlobal('matrix', ['movie'], false, true); + + expect(databaseService.globalSearchContent).toHaveBeenCalledWith( + 'matrix', + ['movie'], + false, + undefined, + { + limit: 101, + offset: 1, + } + ); + expect(store.searchResults()).toHaveLength(2); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index 374da5ef2..178a26b0c 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -1,4 +1,3 @@ -import { KeyValuePipe } from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, @@ -20,6 +19,7 @@ import { MatDialogRef, } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { DatabaseService } from '@iptvnator/services'; @@ -31,27 +31,48 @@ import { 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'; +import { + ContentType, + XtreamSearchResultItem, +} from '@iptvnator/portal/xtream/data-access'; +import { + GlobalSearchResult, + isM3uGlobalSearchResult, +} from '@iptvnator/shared/interfaces'; interface SearchResultsData { isGlobalSearch: boolean; initialQuery?: string; } -function groupResultsByPlaylistName( - items: XtreamContentItem[] -): Record { - return items.reduce>((groups, item) => { - const key = String(item.playlist_name); - if (!groups[key]) { - groups[key] = []; - } - groups[key].push(item); - return groups; - }, {}); +interface GlobalSearchResultGroup { + playlistId: string; + playlistName: string; + items: XtreamSearchResultItem[]; +} + +const GLOBAL_SEARCH_PAGE_SIZE = 100; + +function groupResultsByPlaylistId( + items: XtreamSearchResultItem[] +): GlobalSearchResultGroup[] { + const groups = new Map(); + + for (const item of items) { + const playlistId = String(item.playlist_id ?? 'unknown'); + const playlistName = String(item.playlist_name ?? playlistId); + const group = groups.get(playlistId) ?? { + playlistId, + playlistName, + items: [], + }; + group.items.push(item); + groups.set(playlistId, group); + } + + return [...groups.values()]; } @Component({ @@ -60,11 +81,11 @@ function groupResultsByPlaylistName( imports: [ ContentCardComponent, FormsModule, - KeyValuePipe, MatCheckboxModule, MatDialogModule, MatIcon, MatIconButton, + MatProgressSpinner, SearchLayoutComponent, TranslatePipe, ], @@ -92,6 +113,11 @@ export class SearchResultsComponent implements AfterViewInit { /** Search filters from store */ readonly filters = this.xtreamStore.searchFilters; private globalSearchRequestVersion = 0; + private lastGlobalSearchTerm = ''; + private lastGlobalSearchTypes: string[] = []; + private lastGlobalSearchExcludeHidden?: boolean; + readonly hasMoreGlobalResults = signal(false); + readonly isLoadingMoreGlobalResults = signal(false); private static readonly GROUP_BY_STORAGE_KEY = 'global-search-group-by-playlist'; @@ -136,15 +162,19 @@ export class SearchResultsComponent implements AfterViewInit { /** 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); + if (!this.isGlobalSearch) { + return []; + } + return groupResultsByPlaylistId(results); }); constructor( - @Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData, + @Optional() @Inject(MAT_DIALOG_DATA) data: SearchResultsData | null, @Optional() public dialogRef: MatDialogRef ) { - this.isGlobalSearch = data?.isGlobalSearch || false; + this.isGlobalSearch = + data?.isGlobalSearch || + this.activatedRoute.snapshot.data?.['isGlobalSearch'] === true; const initialQuery = (data?.initialQuery ?? '').trim(); if (this.isGlobalSearch) { @@ -173,21 +203,21 @@ export class SearchResultsComponent implements AfterViewInit { effect((onCleanup) => { const term = this.searchTerm(); - if (term.length >= 3) { + if (term.length >= this.minSearchLength) { const timeout = setTimeout(() => this.executeSearch(), 300); onCleanup(() => clearTimeout(timeout)); - } else if (term.length === 0) { + } else if (term.length < this.minSearchLength) { this.clearResultsOnly(); } }); effect(() => { - if (this.isGlobalSearch || !this.isWorkspaceLayout) { + if (!this.isWorkspaceLayout) { return; } const queryTerm = this.routeSearchTerm(); - if (!queryTerm || queryTerm === this.searchTerm()) { + if (queryTerm === this.searchTerm()) { return; } @@ -197,9 +227,11 @@ export class SearchResultsComponent implements AfterViewInit { ngAfterViewInit() { this.xtreamStore.setSelectedContentType('vod'); - setTimeout(() => { - this.searchLayoutComponent()?.focusSearchInput(); - }); + if (this.showInlineSearchInput) { + setTimeout(() => { + this.searchLayoutComponent()?.focusSearchInput(); + }); + } } executeSearch() { @@ -240,7 +272,7 @@ export class SearchResultsComponent implements AfterViewInit { ); } - if (this.searchTerm().length >= 3) { + if (this.searchTerm().length >= this.minSearchLength) { this.executeSearch(); } } @@ -251,17 +283,72 @@ export class SearchResultsComponent implements AfterViewInit { private clearResultsOnly() { this.globalSearchRequestVersion++; this.xtreamStore.setIsSearching(false); + this.hasMoreGlobalResults.set(false); + this.isLoadingMoreGlobalResults.set(false); + this.lastGlobalSearchTerm = ''; + this.lastGlobalSearchTypes = []; + this.lastGlobalSearchExcludeHidden = undefined; this.xtreamStore.setGlobalSearchResults([]); } - async searchGlobal(term: string, types: string[], excludeHidden?: boolean) { + async searchGlobal( + term: string, + types: string[], + excludeHidden?: boolean, + append = false + ) { + const trimmedTerm = + term.trim() || (append ? this.lastGlobalSearchTerm : ''); + const effectiveTypes = + append && this.lastGlobalSearchTypes.length > 0 + ? this.lastGlobalSearchTypes + : types; + const effectiveExcludeHidden = + append && this.lastGlobalSearchExcludeHidden !== undefined + ? this.lastGlobalSearchExcludeHidden + : excludeHidden; + + if ( + trimmedTerm.length < this.minSearchLength || + effectiveTypes.length === 0 + ) { + this.clearResultsOnly(); + return; + } + + if ( + append && + (!this.hasMoreGlobalResults() || + this.isLoadingMoreGlobalResults() || + this.xtreamStore.isSearching()) + ) { + return; + } + const requestVersion = ++this.globalSearchRequestVersion; - this.xtreamStore.setIsSearching(true); + const offset = append ? this.getCurrentGlobalSearchResults().length : 0; + + if (append) { + this.isLoadingMoreGlobalResults.set(true); + } else { + this.lastGlobalSearchTerm = trimmedTerm; + this.lastGlobalSearchTypes = [...effectiveTypes]; + this.lastGlobalSearchExcludeHidden = effectiveExcludeHidden; + this.hasMoreGlobalResults.set(false); + this.isLoadingMoreGlobalResults.set(false); + this.xtreamStore.setIsSearching(true); + } + try { const results = await this.databaseService.globalSearchContent( - term, - types, - excludeHidden + trimmedTerm, + effectiveTypes, + effectiveExcludeHidden, + undefined, + { + limit: GLOBAL_SEARCH_PAGE_SIZE + 1, + offset, + } ); if (requestVersion !== this.globalSearchRequestVersion) { @@ -269,9 +356,24 @@ export class SearchResultsComponent implements AfterViewInit { } if (results && Array.isArray(results)) { - this.xtreamStore.setGlobalSearchResults(results); + const visibleResults = results.slice( + 0, + GLOBAL_SEARCH_PAGE_SIZE + ); + this.hasMoreGlobalResults.set( + results.length > GLOBAL_SEARCH_PAGE_SIZE + ); + this.xtreamStore.setGlobalSearchResults( + append + ? [ + ...this.getCurrentGlobalSearchResults(), + ...visibleResults, + ] + : visibleResults + ); } else { this.xtreamStore.setIsSearching(false); + this.hasMoreGlobalResults.set(false); } } catch (error) { if (requestVersion !== this.globalSearchRequestVersion) { @@ -279,11 +381,45 @@ export class SearchResultsComponent implements AfterViewInit { } this.logger.error('Error in global search', error); - this.xtreamStore.resetSearchResults(); + if (append) { + this.hasMoreGlobalResults.set(false); + } else { + this.xtreamStore.resetSearchResults(); + } + } finally { + if (requestVersion === this.globalSearchRequestVersion) { + this.isLoadingMoreGlobalResults.set(false); + if (!append) { + this.xtreamStore.setIsSearching(false); + } + } } } - selectItem(item: XtreamContentItem) { + async loadMoreGlobalResults(): Promise { + if (!this.isGlobalSearch) { + return; + } + + const filters = this.filters(); + const types = Object.entries(filters) + .filter(([, enabled]) => enabled) + .map(([type]) => type); + + await this.searchGlobal( + this.searchTerm(), + types, + this.excludeHidden(), + true + ); + } + + selectItem(item: XtreamSearchResultItem) { + if (isM3uGlobalSearchResult(item as GlobalSearchResult)) { + this.selectM3uItem(item as GlobalSearchResult); + return; + } + const playlistId = item.playlist_id ?? this.xtreamStore.playlistId(); if (!playlistId) { return; @@ -334,7 +470,7 @@ export class SearchResultsComponent implements AfterViewInit { SearchResultsComponent.EXCLUDE_HIDDEN_STORAGE_KEY, String(value) ); - if (this.searchTerm().length >= 3) { + if (this.searchTerm().length >= this.minSearchLength) { this.executeSearch(); } } @@ -344,6 +480,52 @@ export class SearchResultsComponent implements AfterViewInit { } get showInlineSearchInput(): boolean { - return !this.isWorkspaceLayout || this.isGlobalSearch; + return !this.isWorkspaceLayout; + } + + get showGlobalCloseButton(): boolean { + return this.isGlobalSearch && !this.isWorkspaceLayout; + } + + get minSearchLength(): number { + return this.isGlobalSearch ? 2 : 3; + } + + get initialDescriptionKey(): string { + return this.isGlobalSearch + ? 'PORTALS.SEARCH_VIEW.GLOBAL_INITIAL_DESCRIPTION' + : 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION'; + } + + getDisplayType(item: XtreamSearchResultItem): string { + const result = item as GlobalSearchResult; + if (isM3uGlobalSearchResult(result) && result.radio === 'true') { + return 'radio'; + } + + return item.type; + } + + private getCurrentGlobalSearchResults(): GlobalSearchResult[] { + return this.xtreamStore.searchResults() as GlobalSearchResult[]; + } + + private selectM3uItem(item: GlobalSearchResult): void { + if (!isM3uGlobalSearchResult(item)) { + return; + } + + if (this.isGlobalSearch) { + this.dialogRef?.close(); + } + + void this.router.navigate( + ['/workspace', 'playlists', item.playlist_id, 'all'], + { + state: { + openM3uChannelUrl: item.stream_url, + }, + } + ); } } diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index e03a9489c..92247f64c 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -4,7 +4,10 @@ */ import { Injectable } from '@angular/core'; -import { +import type { + GlobalSearchPaginationOptions, + GlobalSearchResult, + GlobalSearchResultSource, PlaylistMeta, XtreamBackupFavoriteItem, XtreamBackupHiddenCategory, @@ -78,10 +81,7 @@ type XtreamContentStream = } | Record; -export interface GlobalSearchResult extends XtreamContent { - playlist_id: string; - playlist_name: string; -} +export type { GlobalSearchResult }; export interface GlobalRecentItem extends XtreamContent { playlist_id: string; @@ -590,12 +590,16 @@ export class DatabaseService { async globalSearchContent( searchTerm: string, types: string[], - excludeHidden?: boolean + excludeHidden?: boolean, + sources?: GlobalSearchResultSource[], + options?: GlobalSearchPaginationOptions ): Promise { return await window.electron.dbGlobalSearch( searchTerm, types, - excludeHidden + excludeHidden, + sources, + options ); } diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 8dcf6dfca..175617f82 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -23,6 +23,7 @@ describe('database schema statements', () => { columnMigrationStatements, indexMigrationStatements, normalizeXtreamContentAddedEpochs, + ensureContentTitleFts, } = __databaseConnectionTestHooks; it('defines the core fresh-install tables, indexes, and FTS triggers', () => { @@ -36,6 +37,10 @@ describe('database schema statements', () => { expect(schemaSql).toContain( 'CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5' ); + expect(schemaSql).toContain( + 'CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5' + ); + expect(schemaSql).toContain("tokenize='trigram'"); expect(schemaSql).toContain( 'CREATE TABLE IF NOT EXISTS playback_positions' ); @@ -55,6 +60,15 @@ describe('database schema statements', () => { expect(schemaSql).toContain( 'CREATE TRIGGER IF NOT EXISTS epg_programs_au' ); + expect(schemaSql).toContain( + 'CREATE TRIGGER IF NOT EXISTS content_title_fts_ai' + ); + expect(schemaSql).toContain( + 'CREATE TRIGGER IF NOT EXISTS content_title_fts_ad' + ); + expect(schemaSql).toContain( + 'CREATE TRIGGER IF NOT EXISTS content_title_fts_au' + ); }); it('keeps legacy column migrations idempotent ALTER TABLE statements', () => { @@ -170,4 +184,127 @@ describe('database schema statements', () => { expect(transaction).not.toHaveBeenCalled(); }); + + it('rebuilds the content title FTS index once for existing content', () => { + const selectGet = jest.fn().mockReturnValue(undefined); + const rebuildRun = jest.fn(); + const stateRun = jest.fn(); + const prepare = jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { get: selectGet }; + } + if (statement.includes('content_title_fts')) { + return { run: rebuildRun }; + } + if (statement.includes('INSERT INTO app_state')) { + return { run: stateRun }; + } + + throw new Error(`Unexpected statement: ${compactSql(statement)}`); + }); + const transaction = jest.fn((callback: () => void) => callback); + const sqlite = { + prepare, + transaction, + } as unknown as Parameters[0]; + + ensureContentTitleFts(sqlite); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(rebuildRun).toHaveBeenCalledTimes(1); + expect(stateRun).toHaveBeenCalledWith( + 'migration:content-title-fts-trigram:v1' + ); + }); + + it('skips content title FTS rebuild after it has run', () => { + const selectGet = jest.fn().mockReturnValue({ value: 'done' }); + const prepare = jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { get: selectGet }; + } + + throw new Error(`Unexpected statement: ${compactSql(statement)}`); + }); + const transaction = jest.fn((callback: () => void) => callback); + const sqlite = { + prepare, + transaction, + } as unknown as Parameters[0]; + + ensureContentTitleFts(sqlite); + + expect(transaction).not.toHaveBeenCalled(); + }); + + it('backfills the content title FTS index before content-mutating migrations', () => { + const callOrder: string[] = []; + const runMigrations = ( + __databaseConnectionTestHooks as unknown as { + runMigrations: (sqlite: { + exec: (statement: string) => void; + prepare: (statement: string) => { + all?: () => unknown[]; + get?: (...args: unknown[]) => unknown; + run?: (...args: unknown[]) => unknown; + }; + transaction: (callback: () => void) => () => void; + }) => void; + } + ).runMigrations; + const sqlite = { + exec: jest.fn(), + prepare: jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { + get: (): undefined => { + return undefined; + }, + }; + } + if ( + statement.includes( + 'INSERT INTO content_title_fts(content_title_fts)' + ) + ) { + return { + run: () => { + callOrder.push('fts-rebuild'); + }, + }; + } + if (statement.includes('INSERT INTO app_state')) { + return { run: jest.fn() }; + } + if (statement.includes('UPDATE content')) { + return { + run: () => { + callOrder.push('content-update'); + }, + }; + } + if (statement.includes('DELETE FROM content')) { + return { + run: () => { + callOrder.push('content-delete'); + }, + }; + } + + return { + all: () => [], + get: (): undefined => undefined, + run: jest.fn(), + }; + }), + transaction: jest.fn((callback: () => void) => callback), + }; + + runMigrations(sqlite); + + expect(callOrder).toEqual(['fts-rebuild', 'content-update']); + expect(sqlite.prepare).toHaveBeenCalledWith( + expect.stringContaining('UPDATE content') + ); + }); }); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index ae2649101..17278b2aa 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -26,6 +26,8 @@ let initPromise: Promise | null = null; const XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000; const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY = 'migration:xtream-content-added-epoch-seconds:v1'; +const CONTENT_TITLE_FTS_MIGRATION_KEY = + 'migration:content-title-fts-trigram:v1'; function readTraceFlag(name: string): boolean { const value = process.env[name]?.trim().toLowerCase(); @@ -158,6 +160,29 @@ const CREATE_TABLE_STATEMENTS = [ // categories row, and hidden categories are absent so they're skipped // before any row lookup. `CREATE INDEX IF NOT EXISTS idx_categories_visible ON categories(id, playlist_id, type) WHERE hidden = 0`, + // Trigram FTS index for global Xtream title search. It supports fast + // contains matches such as "max" -> "beIN MAX" and "Cinemax" without + // scanning the full content table. + `CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5( + title, + content='content', + content_rowid='id', + tokenize='trigram' + )`, + `CREATE TRIGGER IF NOT EXISTS content_title_fts_ai AFTER INSERT ON content BEGIN + INSERT INTO content_title_fts(rowid, title) + VALUES (new.id, new.title); + END`, + `CREATE TRIGGER IF NOT EXISTS content_title_fts_ad AFTER DELETE ON content BEGIN + INSERT INTO content_title_fts(content_title_fts, rowid, title) + VALUES ('delete', old.id, old.title); + END`, + `CREATE TRIGGER IF NOT EXISTS content_title_fts_au AFTER UPDATE ON content BEGIN + INSERT INTO content_title_fts(content_title_fts, rowid, title) + VALUES ('delete', old.id, old.title); + INSERT INTO content_title_fts(rowid, title) + VALUES (new.id, new.title); + END`, `CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id)`, `CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id)`, `CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id)`, @@ -303,6 +328,8 @@ export const __databaseConnectionTestHooks = { columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS, indexMigrationStatements: INDEX_MIGRATION_STATEMENTS, normalizeXtreamContentAddedEpochs, + ensureContentTitleFts, + runMigrations, } as const; /** @@ -523,6 +550,50 @@ function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void { } } +function ensureContentTitleFts(sqliteDb: Database.Database): void { + try { + const migrationState = sqliteDb + .prepare(`SELECT value FROM app_state WHERE key = ?`) + .get(CONTENT_TITLE_FTS_MIGRATION_KEY) as + | { value?: unknown } + | undefined; + + if (migrationState?.value === 'done') { + return; + } + + const executeMigration = sqliteDb.transaction(() => { + sqliteDb + .prepare( + `INSERT INTO content_title_fts(content_title_fts) + VALUES ('rebuild')` + ) + .run(); + + sqliteDb + .prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ) + .run(CONTENT_TITLE_FTS_MIGRATION_KEY); + }); + + executeMigration(); + } catch (error) { + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message ?? error) + : String(error); + + console.warn( + `Content title FTS rebuild failed (continuing): ${message}` + ); + } +} + function runMigrationStatements( sqliteDb: Database.Database, statements: string[] @@ -556,6 +627,7 @@ function runMigrationStatements( */ function runMigrations(sqliteDb: Database.Database): void { runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS); + ensureContentTitleFts(sqliteDb); deduplicateXtreamCache(sqliteDb); normalizeXtreamContentAddedEpochs(sqliteDb); runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS); diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 79d3c0086..c7e4c4b27 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/epg-item.interface'; export * from './lib/epg-program.model'; export * from './lib/external-player-arguments.utils'; export * from './lib/external-player-session.interface'; +export * from './lib/global-search-result.interface'; export * from './lib/indexed-db.config'; export * from './lib/ipc-command.class'; export * from './lib/ipc-commands'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 538f84f1f..bad08e043 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -7,6 +7,11 @@ import { import { EpgChannelMetadata } from './epg-channel-metadata.model'; import { EpgProgram } from './epg-program.model'; import { ExternalPlayerSession } from './external-player-session.interface'; +import { + GlobalSearchPaginationOptions, + GlobalSearchResult, + GlobalSearchResultSource, +} from './global-search-result.interface'; import { PlaybackPositionData } from './playback-position.interface'; import { XtreamBackupFavoriteItem, @@ -322,10 +327,7 @@ export type ElectronBridgeXtreamContentStream = } | Record; -export interface ElectronBridgeGlobalSearchResult extends ElectronBridgeXtreamContent { - playlist_id: string; - playlist_name: string; -} +export type ElectronBridgeGlobalSearchResult = GlobalSearchResult; export interface ElectronBridgeGlobalRecentItem extends ElectronBridgeXtreamContent { playlist_id: string; @@ -630,7 +632,9 @@ export interface ElectronBridgeApi { dbGlobalSearch: ( searchTerm: string, types: string[], - excludeHidden?: boolean + excludeHidden?: boolean, + sources?: GlobalSearchResultSource[], + options?: GlobalSearchPaginationOptions ) => Promise; dbGetGlobalRecentlyAdded: ( kind: ElectronBridgeGlobalRecentlyAddedKind, diff --git a/libs/shared/interfaces/src/lib/global-search-result.interface.ts b/libs/shared/interfaces/src/lib/global-search-result.interface.ts new file mode 100644 index 000000000..29cdfa9cc --- /dev/null +++ b/libs/shared/interfaces/src/lib/global-search-result.interface.ts @@ -0,0 +1,72 @@ +import { Channel } from './channel.interface'; + +export const GLOBAL_SEARCH_RESULT_SOURCES = { + Xtream: 'xtream', + M3u: 'm3u', +} as const; + +export type GlobalSearchResultSource = + (typeof GLOBAL_SEARCH_RESULT_SOURCES)[keyof typeof GLOBAL_SEARCH_RESULT_SOURCES]; + +export interface GlobalSearchPaginationOptions { + limit?: number; + offset?: number; +} + +export const GLOBAL_SEARCH_CONTENT_TYPES = { + Live: 'live', + Movie: 'movie', + Series: 'series', +} as const; + +export type GlobalSearchContentType = + (typeof GLOBAL_SEARCH_CONTENT_TYPES)[keyof typeof GLOBAL_SEARCH_CONTENT_TYPES]; + +export interface GlobalSearchBaseResult { + source_type: GlobalSearchResultSource; + content_type: GlobalSearchContentType; + id: number | string; + category_id: number | string; + title: string; + rating: string | null; + added: string | null; + poster_url: string | null; + xtream_id: number; + type: GlobalSearchContentType; + playlist_id: string; + playlist_name: string; +} + +export interface XtreamGlobalSearchResult extends GlobalSearchBaseResult { + source_type: typeof GLOBAL_SEARCH_RESULT_SOURCES.Xtream; + description?: string; + backdrop_url?: string | null; + epg_channel_id?: string | null; + tv_archive?: number | null; + tv_archive_duration?: number | null; + direct_source?: string | null; + added_at?: string; + viewed_at?: string; + position?: number | null; +} + +export interface M3uGlobalSearchResult extends GlobalSearchBaseResult { + source_type: typeof GLOBAL_SEARCH_RESULT_SOURCES.M3u; + content_type: typeof GLOBAL_SEARCH_CONTENT_TYPES.Live; + type: typeof GLOBAL_SEARCH_CONTENT_TYPES.Live; + channel_id: string; + stream_url: string; + group_title: string; + radio: string; + channel: Channel; +} + +export type GlobalSearchResult = + | XtreamGlobalSearchResult + | M3uGlobalSearchResult; + +export function isM3uGlobalSearchResult( + result: GlobalSearchResult +): result is M3uGlobalSearchResult { + return result.source_type === GLOBAL_SEARCH_RESULT_SOURCES.M3u; +} diff --git a/libs/ui/styles/_content-grid.scss b/libs/ui/styles/_content-grid.scss index b0f9b3a8f..096df6fb9 100644 --- a/libs/ui/styles/_content-grid.scss +++ b/libs/ui/styles/_content-grid.scss @@ -71,6 +71,9 @@ &.live { color: #ef5350; } + &.radio { + color: #ffa726; + } &.movie { color: #42a5f5; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 1554666b5..cde9d7359 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -29,6 +29,7 @@ } { expect(emitted).toEqual(['matrix']); }); + it('focuses and selects the search input on request', () => { + const input: HTMLInputElement = + fixture.nativeElement.querySelector('input[type="search"]'); + input.value = 'matrix'; + input.blur(); + + component.focusSearchInput({ select: true }); + + expect(document.activeElement).toBe(input); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe('matrix'.length); + }); + it('emits add playlist requests when the toolbar add button is clicked', () => { const requested = jest.fn(); component.addPlaylistRequested.subscribe(requested); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts index 732679602..e03d5b8ed 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.ts @@ -1,8 +1,10 @@ import { ChangeDetectionStrategy, Component, + ElementRef, input, output, + viewChild, } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; @@ -26,6 +28,9 @@ import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shel changeDetection: ChangeDetectionStrategy.OnPush, }) export class WorkspaceShellHeaderComponent { + private readonly searchInput = + viewChild>('searchInput'); + readonly isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform); @@ -70,6 +75,23 @@ export class WorkspaceShellHeaderComponent { readonly playlistInfoRequested = output(); readonly accountInfoRequested = output(); + focusSearchInput(options: { select?: boolean } = {}): void { + const inputElement = this.searchInput()?.nativeElement; + if (!inputElement || inputElement.disabled) { + return; + } + + inputElement.focus(); + if (options.select) { + inputElement.select(); + } + } + + containsSearchInput(target: EventTarget | null): boolean { + const inputElement = this.searchInput()?.nativeElement; + return !!inputElement && target === inputElement; + } + onSearchInput(event: Event): void { const target = event.target as HTMLInputElement | null; this.searchChanged.emit(target?.value ?? ''); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts index 52ea24360..34582064a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-command-builders.ts @@ -27,7 +27,7 @@ export interface CommandBuilderContext { context: WorkspacePortalContext | null; section: PortalRailSection | null; hasActivePlaylist: boolean; - hasXtreamPlaylists: boolean; + hasSearchablePlaylists: boolean; canRefreshPlaylist: boolean; supportsDownloads: boolean; showDashboard: boolean; @@ -141,7 +141,7 @@ export function getGlobalCommandDefinitions( ): WorkspaceCommandContribution[] { const { route, - hasXtreamPlaylists, + hasSearchablePlaylists, supportsDownloads, showDashboard, actions, @@ -156,8 +156,8 @@ export function getGlobalCommandDefinitions( descriptionKey: 'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION', priority: 10, - visible: hasXtreamPlaylists, - keywords: ['xtream'], + visible: hasSearchablePlaylists && route.kind !== 'global-search', + keywords: ['xtream', 'm3u', 'live'], run: ({ query }) => actions.openGlobalSearch(query), }, { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-search-labels.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-search-labels.ts index 16541327d..ec2dd3350 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-search-labels.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-search-labels.ts @@ -130,6 +130,10 @@ export function resolveSearchScopeLabel(ctx: SearchScopeContext): string { return translate('PORTALS.RECENTLY_VIEWED'); } + if (kind === 'global-search') { + return translate('WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH'); + } + if (kind === 'downloads') { return translate('WORKSPACE.SHELL.RAIL_DOWNLOADS'); } 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 318a3ce8a..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,6 +90,17 @@ export class WorkspaceShellRouteStateService { path: ['/workspace/sources'], }); + 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', tooltip: this.translateText('HOME.PLAYLISTS.GLOBAL_FAVORITES'), @@ -125,6 +142,7 @@ export class WorkspaceShellRouteStateService { currentRoute.kind !== 'settings' && currentRoute.kind !== 'global-favorites' && currentRoute.kind !== 'global-recent' && + currentRoute.kind !== 'global-search' && currentRoute.kind !== 'downloads' ) { return null; 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 e1d0b2308..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); @@ -82,6 +85,29 @@ export class WorkspaceShellSearchService { }; } + if (route.kind === 'global-search') { + const hasSearchablePlaylists = + this.runtime.isElectron && + this.routeState + .playlists() + .some(isWorkspaceGlobalSearchablePlaylist); + + return { + enabled: hasSearchablePlaylists, + behavior: hasSearchablePlaylists ? 'remote-search' : 'disabled', + context: null, + section: null, + searchMode: hasSearchablePlaylists ? 'remote-search' : 'none', + placeholderKey: 'WORKSPACE.SHELL.SEARCH_GLOBAL_PLACEHOLDER', + scopeLabel: this.translateText( + 'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH' + ), + statusLabel: '', + minLength: hasSearchablePlaylists ? 2 : 0, + advancedRouteTarget: null, + }; + } + if (route.searchMode === 'none') { return { enabled: false, 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 018499f13..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 @@ -653,6 +653,12 @@ describe('WorkspaceShellFacade', () => { tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES', path: ['/workspace/sources'], }, + { + icon: 'search', + tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH', + path: ['/workspace/search'], + exact: true, + }, { icon: 'favorite', tooltip: 'HOME.PLAYLISTS.GLOBAL_FAVORITES', @@ -669,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 @@ -706,6 +723,63 @@ describe('WorkspaceShellFacade', () => { expect(commands.every((command) => command.enabled)).toBe(true); }); + it('shows the global search command for M3U-only sources', () => { + activePlaylistSignal.set({ + _id: 'pl-m3u', + title: 'Playlist M3U', + count: 10, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + }); + playlistsSignal.set([ + { + _id: 'pl-m3u', + title: 'Playlist M3U', + count: 10, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + }, + ]); + facade.currentUrl.set('/workspace/dashboard'); + + 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', () => { runtime.supportsDownloads = false; activePlaylistSignal.set(null); 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 870f85212..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,9 +220,9 @@ export class WorkspaceShellFacade { context: this.currentContext(), section: this.currentSection(), hasActivePlaylist: !!this.activePlaylist(), - hasXtreamPlaylists: this.playlists().some( - (playlist) => !!playlist.serverUrl - ), + hasSearchablePlaylists: + this.isElectron && + this.playlists().some(isWorkspaceGlobalSearchablePlaylist), canRefreshPlaylist: this.canRefreshPlaylist(), supportsDownloads: this.supportsDownloads, showDashboard: this.showDashboard(), diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 17745132e..0408a64c6 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -22,6 +22,7 @@ /> (); readonly playlistInfoRequested = output(); readonly accountInfoRequested = output(); + + focusSearchInput = jest.fn(); + containsSearchInput = jest.fn(() => false); } @Component({ @@ -187,6 +190,7 @@ class MockWorkspaceShellFacade { onSearchInput = jest.fn(); onSearchEnter = jest.fn(); openCommandPalette = jest.fn(); + openGlobalSearch = jest.fn(); openAddPlaylistDialog = jest.fn(); runHeaderShortcut = jest.fn(); refreshCurrentPlaylist = jest.fn(); @@ -350,4 +354,59 @@ describe('WorkspaceShellComponent', () => { expect(shortcutsService.openShortcutsDialog).toHaveBeenCalledTimes(1); }); + + it('opens the routed global search and focuses header search on Ctrl/Cmd+F', async () => { + jest.useFakeTimers(); + const facade = new MockWorkspaceShellFacade(); + + await TestBed.configureTestingModule({ + imports: [WorkspaceShellComponent], + providers: [provideRouter([])], + }) + .overrideComponent(WorkspaceShellComponent, { + set: { + imports: [ + RouterOutlet, + MockExternalPlaybackDockComponent, + MockPlaylistDropOverlayComponent, + MockPlaylistDropZoneDirective, + MockWorkspaceShellContextSidebarComponent, + MockWorkspaceShellHeaderComponent, + MockWorkspaceShellImportOverlayComponent, + MockWorkspaceShellRailComponent, + ], + providers: [ + { + provide: WorkspaceShellFacade, + useValue: facade, + }, + { + provide: WorkspaceKeyboardShortcutsService, + useClass: MockWorkspaceKeyboardShortcutsService, + }, + ], + }, + }) + .compileComponents(); + + const fixture = TestBed.createComponent(WorkspaceShellComponent); + fixture.detectChanges(); + const header = fixture.debugElement.query( + By.directive(MockWorkspaceShellHeaderComponent) + ).componentInstance as MockWorkspaceShellHeaderComponent; + const event = new KeyboardEvent('keydown', { + key: 'f', + metaKey: true, + bubbles: true, + cancelable: true, + }); + + document.dispatchEvent(event); + jest.runOnlyPendingTimers(); + + expect(event.defaultPrevented).toBe(true); + expect(facade.openGlobalSearch).toHaveBeenCalledWith(''); + expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true }); + jest.useRealTimers(); + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts index 07abefca7..abcc06693 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, HostListener, inject, viewChild } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { ExternalPlaybackDockComponent } from '@iptvnator/ui/components'; import { @@ -46,4 +46,52 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu export class WorkspaceShellComponent { readonly facade = inject(WorkspaceShellFacade); readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService); + private readonly header = viewChild( + 'workspaceHeader' + ); + + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(event: KeyboardEvent): void { + if ( + event.defaultPrevented || + !this.facade.isElectron || + !isFindShortcut(event) + ) { + return; + } + + const header = this.header(); + const target = event.target; + if (isEditableTarget(target) && !header?.containsSearchInput(target)) { + return; + } + + event.preventDefault(); + this.facade.openGlobalSearch(this.facade.searchQuery()); + setTimeout(() => header?.focusSearchInput({ select: true })); + } +} + +function isFindShortcut(event: KeyboardEvent): boolean { + return (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f'; +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + if (target.isContentEditable) { + return true; + } + + const tagName = target.tagName.toLowerCase(); + return ( + tagName === 'input' || tagName === 'textarea' || tagName === 'select' + ); +} + +interface WorkspaceShellHeaderShortcutTarget { + containsSearchInput(target: EventTarget | null): boolean; + focusSearchInput(options?: { select?: boolean }): void; } diff --git a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts index 1fb327a09..e6421a411 100644 --- a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts +++ b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.spec.ts @@ -42,6 +42,15 @@ describe('workspace-shell-route.utils', () => { usesQuerySearch: true, }) ); + expect(parseWorkspaceShellRoute('/workspace/search?q=matrix')).toEqual( + expect.objectContaining({ + kind: 'global-search', + context: null, + contextPanel: 'none', + searchMode: 'remote-search', + usesQuerySearch: true, + }) + ); expect(parseWorkspaceShellRoute('/workspace/downloads')).toEqual( expect.objectContaining({ kind: 'downloads', diff --git a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts index 00cf96c3a..0bbd49cce 100644 --- a/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts +++ b/libs/workspace/shell/util/src/lib/navigation/workspace-shell-route.utils.ts @@ -8,6 +8,7 @@ export type WorkspaceShellPageKind = | 'downloads' | 'global-favorites' | 'global-recent' + | 'global-search' | 'portal' | 'settings' | 'sources' @@ -194,6 +195,10 @@ function resolveRouteSearchMode( return 'local-filter'; } + if (kind === 'global-search') { + return 'remote-search'; + } + if (kind === 'portal') { return resolvePortalSearchMode(context, section); } @@ -288,9 +293,11 @@ export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute { ? 'global-favorites' : page === 'global-recent' ? 'global-recent' - : page === 'downloads' - ? 'downloads' - : 'unknown'; + : page === 'search' + ? 'global-search' + : page === 'downloads' + ? 'downloads' + : 'unknown'; const searchMode = resolveRouteSearchMode(kind, null, null); return {