mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
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:
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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 === ''
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "فتح المفضلة العامة",
|
||||
|
||||
@@ -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": "حل المفضلة العامة",
|
||||
|
||||
@@ -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": "Адкрыць усе фаварыты",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Άνοιγμα βασικών αγαπημένων",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "グローバルお気に入りを開く",
|
||||
|
||||
@@ -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": "전체 즐겨찾기 열기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Открыть расширенный поиск плейлиста",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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": "打开全局收藏",
|
||||
|
||||
@@ -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": "開啟全域我的最愛",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+22
-5
@@ -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>
|
||||
}
|
||||
|
||||
+4
-16
@@ -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);
|
||||
}
|
||||
}
|
||||
+96
@@ -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;
|
||||
}
|
||||
+237
-4
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -71,6 +71,9 @@
|
||||
&.live {
|
||||
color: #ef5350;
|
||||
}
|
||||
&.radio {
|
||||
color: #ffa726;
|
||||
}
|
||||
&.movie {
|
||||
color: #42a5f5;
|
||||
}
|
||||
|
||||
+1
@@ -29,6 +29,7 @@
|
||||
</span>
|
||||
}
|
||||
<input
|
||||
#searchInput
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
[value]="searchQuery()"
|
||||
|
||||
+13
@@ -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);
|
||||
|
||||
+22
@@ -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 ?? '');
|
||||
|
||||
+4
-4
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
+4
@@ -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');
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
|
||||
type SearchablePlaylistMeta = Pick<
|
||||
PlaylistMeta,
|
||||
'count' | 'filePath' | 'macAddress' | 'serverUrl' | 'url'
|
||||
>;
|
||||
|
||||
export function isWorkspaceGlobalSearchablePlaylist(
|
||||
playlist: SearchablePlaylistMeta
|
||||
): boolean {
|
||||
if (playlist.serverUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (playlist.macAddress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
playlist.filePath || playlist.url || Number(playlist.count ?? 0) > 0
|
||||
);
|
||||
}
|
||||
+19
-1
@@ -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;
|
||||
|
||||
+26
@@ -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,
|
||||
|
||||
+74
@@ -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);
|
||||
|
||||
+4
-3
@@ -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()"
|
||||
|
||||
+59
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user