feat(search): add workspace global search with M3U support

Moves global search into a routed workspace view, adds M3U live/radio results, lazy pagination, and DB-backed matching improvements.
This commit is contained in:
4gray authored and GitHub committed 2026-06-20 23:05:48 +02:00
1 parent ffc07146fe
commit 89916af9d8
65 files changed
+3010 -391

No files matched your search

@@ -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,
@@ -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<typeof sqlJoinMock>) => 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);
});
});
@@ -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<string>();
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<T extends GlobalSearchResult>(
results: ScoredGlobalSearchResult<T>[],
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<string>();
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<XtreamGlobalSearchCandidate[]> {
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<XtreamGlobalSearchCandidate[]> {
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<XtreamGlobalSearchCandidate[]> {
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<Channel>;
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<M3uGlobalSearchResult>[] {
const results: ScoredGlobalSearchResult<M3uGlobalSearchResult>[] = [];
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<GlobalSearchResult[]> {
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<XtreamGlobalSearchResult> | 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<XtreamGlobalSearchResult> =>
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);
}
@@ -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;
}
);
@@ -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
);
}
+1 -4
View File
@@ -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();
}
+33 -1
View File
@@ -7,6 +7,10 @@ describe('app routes', () => {
path?: string;
redirectTo?: unknown;
}> = [];
let resolveElectronOnlyGlobalSearchRoute: (
runtime: { isElectron: boolean },
router: { parseUrl: (url: string) => unknown }
) => unknown;
beforeAll(async () => {
jest.resetModules();
@@ -42,11 +46,14 @@ describe('app routes', () => {
})
);
const { routes } = await import('./app.routes');
const appRoutes = await import('./app.routes');
const { routes } = appRoutes;
const workspaceRoute = routes.find(
(route) => route.path === 'workspace'
);
workspaceChildren = workspaceRoute?.children ?? [];
resolveElectronOnlyGlobalSearchRoute =
appRoutes.resolveElectronOnlyGlobalSearchRoute;
});
it('lazy-loads M3U workspace routes through the feature boundary', async () => {
@@ -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 === ''
+26
View File
@@ -1,5 +1,6 @@
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
const workspaceEntryRedirect = async () =>
@@ -15,6 +16,20 @@ const dashboardAccessGuard = async () => {
: router.parseUrl(redirectPath);
};
export function resolveElectronOnlyGlobalSearchRoute(
runtime: Pick<RuntimeCapabilitiesService, 'isElectron'>,
router: Pick<Router, 'parseUrl'>
) {
return runtime.isElectron ? true : router.parseUrl('/workspace/sources');
}
const electronOnlyGlobalSearchGuard = () => {
return resolveElectronOnlyGlobalSearchRoute(
inject(RuntimeCapabilitiesService),
inject(Router)
);
};
export const routes: Routes = [
{
path: '',
@@ -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: () =>
@@ -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 {
+8 -4
View File
@@ -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": "فتح المفضلة العامة",
+8 -4
View File
@@ -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": "حل المفضلة العامة",
+8 -4
View File
@@ -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": "Адкрыць усе фаварыты",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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": "Άνοιγμα βασικών αγαπημένων",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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": "グローバルお気に入りを開く",
+8 -4
View File
@@ -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": "전체 즐겨찾기 열기",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+8 -4
View File
@@ -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",
+7 -3
View File
@@ -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": "Открыть расширенный поиск плейлиста",
+8 -4
View File
@@ -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ç",
+8 -4
View File
@@ -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": "打开全局收藏",
+8 -4
View File
@@ -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": "開啟全域我的最愛",
-7
View File
@@ -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.
+52 -1
View File
@@ -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
+22 -11
View File
@@ -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:
@@ -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,
@@ -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
@@ -71,6 +71,8 @@ export class ContentCardComponent {
switch (this.type()) {
case 'live':
return 'live_tv';
case 'radio':
return 'radio';
case 'series':
return 'tv';
default:
@@ -47,7 +47,10 @@
<ng-content select="[subheader]" />
</div>
<div class="results-container">
<div
class="results-container"
(scroll)="onSearchContentScroll($event)"
>
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="48" />
@@ -59,8 +62,17 @@
<div class="no-items">
<mat-icon>search_off</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
<h3>
{{
'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate
}}
</h3>
<p>
{{
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS'
| translate
}}
</p>
<p class="search-term-note">
{{
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
@@ -73,8 +85,13 @@
<div class="no-items initial-state">
<mat-icon>search</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
<h3>
{{
'PORTALS.SEARCH_VIEW.INITIAL_TITLE'
| translate
}}
</h3>
<p>{{ initialDescriptionKey() | translate }}</p>
</div>
</div>
}
@@ -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);
}
}
@@ -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<SearchLayoutComponent>;
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);
});
});
@@ -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<string>('PORTALS.SIDEBAR.SEARCH');
@@ -55,12 +57,20 @@ export class SearchLayoutComponent {
/** Minimum characters required for search */
readonly minSearchLength = input<number>(3);
/** Initial state description translation key */
readonly initialDescriptionKey = input<string>(
'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION'
);
/** Emitted when search term changes */
readonly searchTermChange = output<string>();
/** Emitted when close button is clicked */
readonly closeClick = output<void>();
/** Emitted when the scroll container is close to the bottom */
readonly nearEnd = output<void>();
/** 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 (
@@ -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;
}
@@ -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;
@@ -5,8 +5,11 @@
[showCloseButton]="false"
[showResultsCount]="!isGlobalSearch || !groupByPlaylist()"
[showSearchInput]="showInlineSearchInput"
[minSearchLength]="minSearchLength"
[initialDescriptionKey]="initialDescriptionKey"
(searchTermChange)="updateSearchTerm($event)"
(closeClick)="onCloseDialog()"
(nearEnd)="loadMoreGlobalResults()"
>
<!-- Type filters -->
<ng-container filters>
@@ -23,13 +26,15 @@
</ng-container>
<ng-container header-actions>
<mat-checkbox
[ngModel]="excludeHidden()"
(ngModelChange)="toggleExcludeHidden($event)"
>
{{ 'PORTALS.SEARCH_VIEW.VISIBLE_CATEGORIES_ONLY' | translate }}
</mat-checkbox>
@if (isGlobalSearch) {
@if (!isGlobalSearch) {
<mat-checkbox
[ngModel]="excludeHidden()"
(ngModelChange)="toggleExcludeHidden($event)"
>
{{ 'PORTALS.SEARCH_VIEW.VISIBLE_CATEGORIES_ONLY' | translate }}
</mat-checkbox>
}
@if (showGlobalCloseButton) {
<button mat-icon-button (click)="onCloseDialog()">
<mat-icon>close</mat-icon>
</button>
@@ -39,28 +44,49 @@
<ng-container subheader>
@if (isGlobalSearch) {
<div class="global-search-subheader">
<div class="global-search-subheader-left">
<div class="global-search-note">
<mat-icon>info_outline</mat-icon>
<span>{{ 'PORTALS.SEARCH_VIEW.XTREAM_ONLY_NOTE' | translate }}</span>
</div>
<div class="global-search-note">
<mat-icon aria-hidden="true">info</mat-icon>
<span>
{{
'PORTALS.SEARCH_VIEW.GLOBAL_SEARCH_SOURCE_NOTE'
| translate
}}
</span>
</div>
<div class="global-search-controls">
<div class="global-search-filters">
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[ngModel]="filters()[filter.key]"
(ngModelChange)="updateFilter(filter.key, $event)"
(ngModelChange)="
updateFilter(filter.key, $event)
"
>
{{ filter.translationKey | translate }}
</mat-checkbox>
}
</div>
<div class="global-search-options">
<mat-checkbox
[ngModel]="excludeHidden()"
(ngModelChange)="toggleExcludeHidden($event)"
>
{{
'PORTALS.SEARCH_VIEW.VISIBLE_CATEGORIES_ONLY'
| translate
}}
</mat-checkbox>
<mat-checkbox
[ngModel]="groupByPlaylist()"
(ngModelChange)="toggleGroupByPlaylist($event)"
>
{{
'PORTALS.SEARCH_VIEW.GROUP_BY_PLAYLIST'
| translate
}}
</mat-checkbox>
</div>
</div>
<mat-checkbox
[ngModel]="groupByPlaylist()"
(ngModelChange)="toggleGroupByPlaylist($event)"
>
{{ 'PORTALS.SEARCH_VIEW.GROUP_BY_PLAYLIST' | translate }}
</mat-checkbox>
</div>
}
</ng-container>
@@ -73,27 +99,26 @@
<!-- Grouped by playlist -->
<div class="playlist-section">
<h2 class="section-title">
Found {{ xtreamStore.searchResults().length }} items across
{{ (groupedResults() | keyvalue).length }} playlists
Found {{ xtreamStore.searchResults().length }} items
across {{ groupedResults().length }} playlists
</h2>
@for (
group of groupedResults() | keyvalue;
track group.key
) {
@for (group of groupedResults(); track group.playlistId) {
<div class="playlist-group">
<h3 class="playlist-title">
<mat-icon>playlist_play</mat-icon>
{{ group.key }}
{{ group.playlistName }}
<span class="item-count"
>({{ $any(group).value.length }} items)</span
>({{ group.items.length }} items)</span
>
</h3>
<div class="results-grid">
@for (item of $any(group).value; track item.id) {
@for (item of group.items; track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[posterUrl]="
item.poster_url ?? undefined
"
[title]="item.title"
[type]="item.type"
[type]="getDisplayType(item)"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
@@ -107,21 +132,32 @@
<div class="results-grid">
@for (item of xtreamStore.searchResults(); track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[posterUrl]="item.poster_url ?? undefined"
[title]="item.title"
[type]="item.type"
[type]="getDisplayType(item)"
[showPlaceholder]="true"
(cardClick)="selectItem(item)"
/>
}
</div>
}
@if (isLoadingMoreGlobalResults()) {
<div class="global-search-loading-more">
<mat-spinner diameter="18" />
<span>
{{
'PORTALS.SEARCH_VIEW.LOADING_MORE_RESULTS'
| translate
}}
</span>
</div>
}
} @else {
<!-- Playlist-level Search Results -->
<div class="results-grid">
@for (item of xtreamStore.searchResults(); track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[posterUrl]="item.poster_url ?? undefined"
[title]="item.title"
[type]="item.type"
[showPlaceholder]="true"
@@ -14,87 +14,68 @@
padding-bottom: 1rem;
}
// Global search overlay styles
:host-context(.global-search-overlay) {
.results-grid {
mat-card {
position: relative;
.poster,
.poster-placeholder {
margin-top: 24px;
}
.playlist-badge {
position: absolute;
top: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.85);
padding: 6px 8px;
font-size: 12px;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
display: flex;
align-items: center;
gap: 4px;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
flex-shrink: 0;
}
}
}
}
}
// Subheader row for global search (note + grouping toggle)
// Subheader row for global search (scope note + filters/options)
.global-search-subheader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
.global-search-subheader-left {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
flex-direction: column;
align-items: stretch;
gap: 10px;
.global-search-note {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
font-size: 0.8rem;
opacity: 0.55;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: var(--mat-sys-primary);
font-size: 16px;
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.global-search-filters {
.global-search-controls {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.global-search-filters,
.global-search-options {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.global-search-options {
padding-inline-start: 20px;
border-inline-start: 1px solid var(--mat-sys-outline-variant);
}
::ng-deep mat-checkbox {
flex-shrink: 0;
}
}
@media (max-width: 900px) {
.global-search-subheader {
.global-search-controls {
gap: 10px;
}
.global-search-options {
width: 100%;
padding-inline-start: 0;
border-inline-start: 0;
}
}
}
// Playlist grouping styles for global search
.playlist-group {
margin-bottom: 2rem;
@@ -127,27 +108,6 @@
}
}
:host-context(.global-search-overlay) {
.playlist-group {
.playlist-title {
background: var(--surface);
}
}
.results-grid {
mat-card {
.playlist-badge {
display: none;
}
.poster,
.poster-placeholder {
margin-top: 0;
}
}
}
}
.playlist-section {
.section-title {
margin: 0 0 1.5rem;
@@ -158,3 +118,13 @@
padding: 0 8px;
}
}
.global-search-loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 18px 0 22px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.85rem;
}
@@ -9,6 +9,7 @@ import {
XtreamContentItem,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { GlobalSearchResult } from '@iptvnator/shared/interfaces';
jest.mock('@iptvnator/portal/shared/ui', () => ({
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<XtreamContentItem[]>();
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<XtreamContentItem[]>();
const freshSearch = createDeferred<XtreamContentItem[]>();
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<XtreamContentItem[]>
>;
}
).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);
});
});
@@ -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<string, XtreamContentItem[]> {
return items.reduce<Record<string, XtreamContentItem[]>>((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<string, GlobalSearchResultGroup>();
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<SearchResultsComponent>
) {
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<void> {
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,
},
}
);
}
}
@@ -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<string, unknown>;
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<GlobalSearchResult[]> {
return await window.electron.dbGlobalSearch(
searchTerm,
types,
excludeHidden
excludeHidden,
sources,
options
);
}
@@ -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<typeof ensureContentTitleFts>[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<typeof ensureContentTitleFts>[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')
);
});
});
@@ -26,6 +26,8 @@ let initPromise: Promise<DatabaseInstance> | 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);
+1
View File
@@ -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';
@@ -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<string, unknown>;
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<ElectronBridgeGlobalSearchResult[]>;
dbGetGlobalRecentlyAdded: (
kind: ElectronBridgeGlobalRecentlyAddedKind,
@@ -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;
}
+3
View File
@@ -71,6 +71,9 @@
&.live {
color: #ef5350;
}
&.radio {
color: #ffa726;
}
&.movie {
color: #42a5f5;
}
@@ -29,6 +29,7 @@
</span>
}
<input
#searchInput
type="search"
spellcheck="false"
[value]="searchQuery()"
@@ -93,6 +93,19 @@ describe('WorkspaceShellHeaderComponent', () => {
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);
@@ -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<ElementRef<HTMLInputElement>>('searchInput');
readonly isMac =
typeof navigator !== 'undefined' &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
@@ -70,6 +75,23 @@ export class WorkspaceShellHeaderComponent {
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
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 ?? '');
@@ -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),
},
{
@@ -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');
}
@@ -0,0 +1,22 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
type SearchablePlaylistMeta = Pick<
PlaylistMeta,
'count' | 'filePath' | 'macAddress' | 'serverUrl' | 'url'
>;
export function isWorkspaceGlobalSearchablePlaylist(
playlist: SearchablePlaylistMeta
): boolean {
if (playlist.serverUrl) {
return true;
}
if (playlist.macAddress) {
return false;
}
return Boolean(
playlist.filePath || playlist.url || Number(playlist.count ?? 0) > 0
);
}
@@ -1,4 +1,10 @@
import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core';
import {
computed,
DestroyRef,
inject,
Injectable,
signal,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -84,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;
@@ -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,
@@ -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);
@@ -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(),
@@ -22,6 +22,7 @@
/>
<app-workspace-shell-header
#workspaceHeader
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
@@ -72,6 +72,9 @@ class MockWorkspaceShellHeaderComponent {
readonly headerBulkActionRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
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();
});
});
@@ -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<WorkspaceShellHeaderShortcutTarget>(
'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;
}
@@ -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',
@@ -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 {