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..f7d7e90bb 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,262 @@ 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', + 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', + }), + ]); + }); }); 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..c68e05ed9 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,631 @@ 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 getSearchTokens(value: unknown): string[] { + const normalized = normalizeSearchMatchText(value); + return normalized ? normalized.split(' ') : []; +} + +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, ...getSearchTokens(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 MIN_GLOBAL_SEARCH_CANDIDATE_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( + pagination: NormalizedGlobalSearchPagination +): number { + return Math.min( + Math.max( + (pagination.offset + pagination.limit) * 4, + MIN_GLOBAL_SEARCH_CANDIDATE_LIMIT + ), + MAX_GLOBAL_SEARCH_CANDIDATE_LIMIT + ); +} + +function buildContentTitleSearchConditions(searchTerm: string) { + const tokens = getSearchTokens(searchTerm); + if (tokens.length === 0) { + return []; + } + + return tokens + .map((token, index) => { + const mode = + index === 0 && token.length <= 2 ? 'prefix' : 'contains'; + const likeConditions = 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 [firstToken] = getSearchTokens(searchTerm); + + return !!firstToken && firstToken.length <= 2; +} + +function buildContentTitleFtsMatchQuery(searchTerm: string): string { + return getSearchTokens(searchTerm) + .filter((token) => token.length >= 3) + .join(' AND '); +} + +function shouldUseContentTitleFts(searchTerm: string): boolean { + return ( + !shouldUseContentTitlePrefixIndex(searchTerm) && + buildContentTitleFtsMatchQuery(searchTerm).length > 0 + ); +} + +function buildGlobPrefixPatterns(token: string): string[] { + const variants = new Set(); + + variants.add(token); + variants.add(token.toLocaleLowerCase()); + variants.add(token.toLocaleUpperCase()); + variants.add( + token.charAt(0).toLocaleUpperCase() + token.slice(1).toLocaleLowerCase() + ); + + return [...variants].map((value) => `${value}*`); +} + +function buildRawContentTitleSearchSql(searchTerm: string): SQL[] { + return getSearchTokens(searchTerm).map((token, index) => { + if (index === 0 && token.length <= 2) { + return sql`(${sql.join( + buildGlobPrefixPatterns(token).map( + (pattern) => sql`c.title GLOB ${pattern}` + ), + sql` OR ` + )})`; + } + + return sql`(${sql.join( + 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 buildM3uPayloadPrefixPatterns(token: string): string[] { + return buildLikePatterns(token, 'prefix').flatMap((pattern) => [ + `%"name":"${pattern}`, + `%"name": "${pattern}`, + `%"title":"${pattern}`, + `%"title": "${pattern}`, + ]); +} + +function buildM3uPayloadSearchConditions(searchTerm: string) { + const tokens = getSearchTokens(searchTerm); + if (tokens.length === 0) { + return []; + } + + return tokens + .map((token, index) => { + const patterns = + index === 0 && token.length <= 2 + ? buildM3uPayloadPrefixPatterns(token) + : buildLikePatterns(token); + 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, + channel, + id: `${row.id}::${channel.id || channel.url}`, + category_id: 'm3u', + title: channel.name || channel.tvg.name || channel.url, + rating: '', + added: '', + xtream_id: 0, + 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 +1050,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 +1078,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 +1089,124 @@ 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(pagination); + 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); - 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..aee393131 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -91,6 +91,17 @@ 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(typeof globalSearchRoute?.loadComponent).toBe('function'); + }); + 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..f69502f23 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -80,6 +80,16 @@ export const routes: Routes = [ (c) => c.GlobalCollectionRouteComponent ), }, + { + path: 'search', + 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..92d66bb6e 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -203,6 +203,41 @@ 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. Existing databases rebuild this + index once through the `migration:content-title-fts-trigram:v1` app-state + marker; fresh inserts, deletes, and title updates stay synchronized through + SQLite triggers. +2. `source_type = "m3u"` rows come from M3U playlist payloads stored in + `playlists.payload`. The worker uses SQL `payload LIKE` only as a coarse + candidate prefilter, then parses candidate JSON payloads and matches channel + name, TVG name, and group title case-insensitively in the worker. +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. +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. +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 +323,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..65c388982 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,8 @@ 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 search, + global favorites, and recently viewed. 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 +145,23 @@ 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 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..8253c17ae 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 @@ -3,7 +3,7 @@ @if (!showDetails()) { -
+