Merge pull request #853 from 4gray/feat/workspace-dashboard-improvements

Feat: xtream and workspace improvements
This commit is contained in:
4gray authored and GitHub committed 2026-04-05 18:58:50 +02:00
commit dd265f15e9
160 files changed
+9322 -1095

No files matched your search

@@ -116,6 +116,77 @@ test.describe('Electron Xtream Category Management', () => {
await closeElectronApp(app);
}
});
test('persists hidden selections after refreshing from the workspace header', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const portalName = 'Header Refresh Xtream';
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: portalName,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
const targetCategory = await pickSidebarCategory(app.mainWindow);
const dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.getByRole('button', {
name: 'Deselect All',
exact: true,
})
.click();
await expect(
dialog.locator('.category-item mat-checkbox input:checked')
).toHaveCount(0);
await dialog.locator('.search-field input').fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory, true);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toBeVisible();
await refreshFromWorkspaceHeader(app.mainWindow);
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expectVisibleSidebarCategoryIds(app.mainWindow, [
targetCategory.id,
]);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalName).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expectVisibleSidebarCategoryIds(app.mainWindow, [
targetCategory.id,
]);
} finally {
await closeElectronApp(app);
}
});
});
async function openManageCategoriesDialog(page: Page) {
@@ -134,12 +205,46 @@ async function openManageCategoriesDialog(page: Page) {
return dialog;
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
function sidebarCategoryById(page: Page, categoryId: string): Locator {
return page.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
);
}
async function expectVisibleSidebarCategoryIds(
page: Page,
expectedIds: string[]
): Promise<void> {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const actualIds: string[] = [];
const count = await categories.count();
for (let index = 0; index < count; index += 1) {
const categoryId = await categories
.nth(index)
.getAttribute('data-category-id');
if (categoryId) {
actualIds.push(categoryId.trim());
}
}
expect(actualIds).toEqual(expectedIds);
}
async function pickSidebarCategory(
page: Page
): Promise<{ id: string; itemCount: number; name: string }> {
+15
View File
@@ -44,6 +44,17 @@ async function buildWorker() {
'../../dist/apps/electron-backend/workers/database.worker.js'
),
},
{
label: 'playlist refresh worker',
entry: path.join(
__dirname,
'src/app/workers/playlist-refresh.worker.ts'
),
outfile: path.join(
__dirname,
'../../dist/apps/electron-backend/workers/playlist-refresh.worker.js'
),
},
];
for (const worker of workers) {
@@ -70,6 +81,10 @@ async function buildWorker() {
__dirname,
'../../libs/shared/interfaces/src/index.ts'
),
'm3u-utils': path.join(
__dirname,
'../../libs/shared/m3u-utils/src/index.ts'
),
'database': path.join(
__dirname,
'../../libs/shared/database/src/index.ts'
@@ -1,9 +1,15 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { ExternalPlayerSession } from 'shared-interfaces';
import type {
ExternalPlayerSession,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
XtreamCategory,
} from 'shared-interfaces';
const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
type PortalDebugEvent = {
requestId: string;
@@ -112,6 +118,14 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.on(DB_OPERATION_EVENT, handler);
return () => ipcRenderer.off(DB_OPERATION_EVENT, handler);
},
onPlaylistRefreshEvent: (
callback: (data: PlaylistRefreshEvent) => void
) => {
const handler = (_event: Electron.IpcRendererEvent, data: any) =>
callback(data as PlaylistRefreshEvent);
ipcRenderer.on(PLAYLIST_REFRESH_EVENT, handler);
return () => ipcRenderer.off(PLAYLIST_REFRESH_EVENT, handler);
},
// DB save content progress listener
onDbSaveContentProgress: (callback: (count: number) => void) => {
const handler = (
@@ -235,8 +249,15 @@ contextBridge.exposeInMainWorld('electron', {
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) => ipcRenderer.invoke('XTREAM_REQUEST', payload),
xtreamCancelSession: (sessionId: string) =>
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
cancelPlaylistRefresh: (operationId: string) =>
ipcRenderer.invoke('PLAYLIST:CANCEL_REFRESH', operationId),
// Database operations
dbCreatePlaylist: (playlist: any) =>
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
@@ -278,7 +299,7 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type),
dbSaveCategories: (
playlistId: string,
categories: any[],
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[]
) =>
@@ -314,6 +335,10 @@ contextBridge.exposeInMainWorld('electron', {
type,
operationId
),
dbClearXtreamImportCache: (
playlistId: string,
type: 'live' | 'movie' | 'series'
) => ipcRenderer.invoke('DB_CLEAR_XTREAM_IMPORT_CACHE', playlistId, type),
dbSearchContent: (
playlistId: string,
searchTerm: string,
@@ -0,0 +1,98 @@
import type { AppDatabase } from '../database.types';
import { saveCategories } from './category.operations';
function createDbMock(existingCount = 0) {
const where = jest.fn().mockResolvedValue([{ count: existingCount }]);
const from = jest.fn().mockReturnValue({ where });
const select = jest.fn().mockReturnValue({ from });
const onConflictDoNothing = jest.fn().mockResolvedValue(undefined);
const values = jest.fn().mockReturnValue({ onConflictDoNothing });
const insert = jest.fn().mockReturnValue({ values });
return {
db: {
select,
insert,
} as unknown as AppDatabase,
insert,
values,
onConflictDoNothing,
select,
from,
where,
};
}
describe('category.operations', () => {
it('restores hidden categories when Xtream API category IDs are strings', async () => {
const { db, values, insert } = createDbMock();
await saveCategories(
db,
'playlist-1',
[
{ category_name: 'News', category_id: '101' },
{ category_name: 'Sports', category_id: '102' },
],
'live',
[102]
);
expect(insert).toHaveBeenCalled();
expect(values).toHaveBeenCalledWith([
{
playlistId: 'playlist-1',
name: 'News',
type: 'live',
xtreamId: 101,
hidden: false,
},
{
playlistId: 'playlist-1',
name: 'Sports',
type: 'live',
xtreamId: 102,
hidden: true,
},
]);
});
it('skips categories whose Xtream IDs are not numeric', async () => {
const { db, values } = createDbMock();
await saveCategories(
db,
'playlist-1',
[
{ category_name: 'Valid', category_id: '201' },
{ category_name: 'Broken', category_id: 'not-a-number' },
],
'movies',
[201]
);
expect(values).toHaveBeenCalledWith([
{
playlistId: 'playlist-1',
name: 'Valid',
type: 'movies',
xtreamId: 201,
hidden: true,
},
]);
});
it('does not insert categories when all Xtream IDs are invalid', async () => {
const { db, insert } = createDbMock();
await saveCategories(
db,
'playlist-1',
[{ category_name: 'Broken', category_id: 'not-a-number' }],
'series',
[301]
);
expect(insert).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,17 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
type XtreamCategoryInput = {
category_name: string;
category_id: string | number;
};
function normalizeXtreamCategoryId(rawCategoryId: string | number): number | null {
const xtreamId = Number.parseInt(String(rawCategoryId), 10);
return Number.isNaN(xtreamId) ? null : xtreamId;
}
export async function hasCategories(
db: AppDatabase,
playlistId: string,
@@ -41,10 +52,7 @@ export async function getCategories(
export async function saveCategories(
db: AppDatabase,
playlistId: string,
categories: Array<{
category_name: string;
category_id: number;
}>,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): Promise<{ success: boolean }> {
@@ -67,13 +75,27 @@ export async function saveCategories(
}
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
const values = categories.map((category) => ({
playlistId,
name: category.category_name,
type,
xtreamId: category.category_id,
hidden: hiddenSet.has(category.category_id),
}));
const values = categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
if (xtreamId === null) {
return [];
}
return [
{
playlistId,
name: category.category_name,
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
},
];
});
if (values.length === 0) {
return { success: true };
}
await db
.insert(schema.categories)
@@ -3,6 +3,7 @@ import * as schema from 'database-schema';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
chunkValues,
type OperationControl,
reportOperationProgress,
} from './operation-control';
@@ -292,6 +293,50 @@ export async function saveContent(
return { success: true, count: totalInserted };
}
export async function clearXtreamImportCache(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<{ success: boolean }> {
const dbType =
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
const categoryRows = await db
.select({ id: schema.categories.id })
.from(schema.categories)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.categories.type, dbType)
)
);
const categoryIds = categoryRows.map((category) => category.id);
if (categoryIds.length === 0) {
return { success: true };
}
const contentRows = await db
.select({ id: schema.content.id })
.from(schema.content)
.where(inArray(schema.content.categoryId, categoryIds));
for (const chunk of chunkValues(
contentRows.map((row) => row.id),
100
)) {
await db.delete(schema.content).where(inArray(schema.content.id, chunk));
}
for (const chunk of chunkValues(categoryIds, 100)) {
await db
.delete(schema.categories)
.where(inArray(schema.categories.id, chunk));
}
return { success: true };
}
export async function getContentByXtreamId(
db: AppDatabase,
xtreamId: number,
@@ -0,0 +1,36 @@
import { parseAppPlaylist } from './playlist.operations';
describe('playlist.operations', () => {
it('hydrates updateDate from lastUpdated when payload is stale', async () => {
const parsed = parseAppPlaylist({
id: 'playlist-1',
name: 'Refresh Xtream Source',
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
dateCreated: '2026-04-03T08:00:00.000Z',
lastUpdated: '2026-04-03T11:15:00.000Z',
type: 'xtream',
autoRefresh: false,
count: 0,
importDate: '2026-04-03T08:00:00.000Z',
payload: JSON.stringify({
_id: 'playlist-1',
title: 'Refresh Xtream Source',
count: 0,
importDate: '2026-04-03T08:00:00.000Z',
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
}),
} as any);
expect(parsed).toEqual(
expect.objectContaining({
_id: 'playlist-1',
updateDate: new Date('2026-04-03T11:15:00.000Z').getTime(),
})
);
});
});
@@ -139,26 +139,12 @@ function buildPlaylistRow(
};
}
function parseAppPlaylist(row: schema.Playlist): Record<string, unknown> {
export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown> {
const payload = parseJsonValue<Record<string, unknown> | null>(
row.payload,
null
);
if (payload && typeof payload === 'object') {
return {
...payload,
_id:
getStringValue(payload._id) ??
getStringValue(payload.id) ??
row.id,
title:
getStringValue(payload.title) ??
getStringValue(payload.name) ??
row.name,
};
}
const base = payload && typeof payload === 'object' ? payload : {};
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
const importDate =
@@ -166,30 +152,42 @@ function parseAppPlaylist(row: schema.Playlist): Record<string, unknown> {
const portalUrl =
row.portalUrl ??
(row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
const updateDate =
row.updateDate ??
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
return {
...base,
_id: row.id,
title: row.name,
count: row.count ?? 0,
importDate,
lastUsage: row.lastUsage ?? importDate,
title:
getStringValue(base.title) ??
getStringValue(base.name) ??
row.name,
count: row.count ?? getNumericValue(base.count) ?? 0,
importDate: getStringValue(base.importDate) ?? importDate,
lastUsage:
row.lastUsage ??
getStringValue(base.lastUsage) ??
getStringValue(base.importDate) ??
importDate,
favorites,
recentlyViewed,
autoRefresh: row.autoRefresh ?? false,
url: row.type === PLAYLIST_TYPES.M3U_URL ? row.url : undefined,
filePath: row.filePath ?? undefined,
userAgent: row.userAgent ?? undefined,
referrer: row.referrer ?? undefined,
origin: row.origin ?? undefined,
updateDate:
row.updateDate ??
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined),
position: row.position ?? undefined,
serverUrl: row.serverUrl ?? undefined,
username: row.username ?? undefined,
password: row.password ?? undefined,
macAddress: row.macAddress ?? undefined,
portalUrl: portalUrl ?? undefined,
autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh),
url:
row.type === PLAYLIST_TYPES.M3U_URL
? row.url ?? getStringValue(base.url)
: getStringValue(base.url),
filePath: row.filePath ?? getStringValue(base.filePath),
userAgent: row.userAgent ?? getStringValue(base.userAgent),
referrer: row.referrer ?? getStringValue(base.referrer),
origin: row.origin ?? getStringValue(base.origin),
updateDate,
position: row.position ?? getNumericValue(base.position),
serverUrl: row.serverUrl ?? getStringValue(base.serverUrl),
username: row.username ?? getStringValue(base.username),
password: row.password ?? getStringValue(base.password),
macAddress: row.macAddress ?? getStringValue(base.macAddress),
portalUrl: portalUrl ?? getStringValue(base.portalUrl),
};
}
@@ -27,7 +27,7 @@ handleWorkerRequest(
playlistId: string,
categories: Array<{
category_name: string;
category_id: number;
category_id: string | number;
}>,
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
@@ -60,6 +60,14 @@ ipcMain.handle(
}
);
handleWorkerRequest(
'DB_CLEAR_XTREAM_IMPORT_CACHE',
(playlistId: string, type: 'live' | 'movie' | 'series') => ({
playlistId,
type,
})
);
handleWorkerRequest(
'DB_GET_CONTENT_BY_XTREAM_ID',
(xtreamId: number, playlistId: string) => ({
@@ -4,12 +4,27 @@
*/
import axios from 'axios';
import { dialog, ipcMain } from 'electron';
import { app, dialog, ipcMain, WebContents } from 'electron';
import { parse } from 'iptv-playlist-parser';
import { createPlaylistObject, getFilenameFromUrl } from 'm3u-utils';
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { AUTO_UPDATE_PLAYLISTS } from 'shared-interfaces';
import { pathToFileURL } from 'url';
import { Worker } from 'worker_threads';
import {
AUTO_UPDATE_PLAYLISTS,
PLAYLIST_CANCEL_REFRESH,
PLAYLIST_REFRESH,
PLAYLIST_REFRESH_EVENT,
Playlist,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
} from 'shared-interfaces';
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
import type {
PlaylistRefreshWorkerMessage,
PlaylistRefreshWorkerResponseMessage,
} from '../workers/playlist-refresh.worker.types';
export default class PlaylistEvents {
static bootstrapPlaylistEvents(): Electron.IpcMain {
@@ -19,6 +34,15 @@ export default class PlaylistEvents {
const https = require('https');
type ActivePlaylistRefresh = {
reject: (reason?: unknown) => void;
resolve: (value: Playlist) => void;
sender: WebContents;
worker: Worker;
};
const activePlaylistRefreshes = new Map<string, ActivePlaylistRefresh>();
/**
* Fetches and parses a playlist from a URL
* @param url - The URL to fetch the playlist from
@@ -73,6 +97,46 @@ async function fetchPlaylistFromFile(
return playlistObject;
}
function resolvePlaylistRefreshWorker(): Worker {
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: app.isPackaged,
workerFilename: 'playlist-refresh.worker.js',
developmentWorkerDir: __dirname + '/workers',
resourcesPath: (
process as NodeJS.Process & { resourcesPath?: string }
).resourcesPath,
appPath: app.getAppPath(),
});
return new Worker(pathToFileURL(bootstrap.workerPath), {
workerData: {
nativeModuleSearchPaths: bootstrap.nativeModuleSearchPaths,
},
});
}
function emitPlaylistRefreshEvent(
sender: WebContents,
event: PlaylistRefreshEvent
): void {
if (sender.isDestroyed()) {
return;
}
sender.send(PLAYLIST_REFRESH_EVENT, event);
}
function createPlaylistRefreshError(error: {
message: string;
name?: string;
stack?: string;
}): Error {
const workerError = new Error(error.message);
workerError.name = error.name || 'PlaylistRefreshWorkerError';
workerError.stack = error.stack || workerError.stack;
return workerError;
}
ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => {
try {
return await fetchPlaylistFromUrl(url, title);
@@ -179,6 +243,93 @@ ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => {
return updatedPlaylists;
});
ipcMain.handle(PLAYLIST_REFRESH, async (event, payload: PlaylistRefreshPayload) => {
const worker = resolvePlaylistRefreshWorker();
return await new Promise<Playlist>((resolve, reject) => {
const cleanup = async (): Promise<void> => {
activePlaylistRefreshes.delete(payload.operationId);
worker.removeAllListeners();
await worker.terminate().catch(() => undefined);
};
activePlaylistRefreshes.set(payload.operationId, {
worker,
sender: event.sender,
resolve,
reject,
});
worker.on('message', async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
if (message.type === 'ready') {
worker.postMessage({
type: 'request',
payload,
});
return;
}
if (message.type === 'event') {
emitPlaylistRefreshEvent(event.sender, message.event);
return;
}
await cleanup();
const response = message as PlaylistRefreshWorkerResponseMessage<Playlist>;
if (response.success && response.result) {
resolve(response.result);
return;
}
reject(
createPlaylistRefreshError(
response.error ?? {
message: 'Playlist refresh worker request failed',
}
)
);
});
worker.on('error', async (error) => {
await cleanup();
reject(error);
});
worker.on('exit', async (code) => {
if (!activePlaylistRefreshes.has(payload.operationId)) {
return;
}
await cleanup();
reject(
new Error(
code === 0
? 'Playlist refresh worker exited unexpectedly'
: `Playlist refresh worker stopped with exit code ${code}`
)
);
});
});
});
ipcMain.handle(
PLAYLIST_CANCEL_REFRESH,
async (_event, operationId: string): Promise<{ success: boolean }> => {
const activeRefresh = activePlaylistRefreshes.get(operationId);
if (!activeRefresh) {
return { success: false };
}
activeRefresh.worker.postMessage({
type: 'cancel',
operationId,
});
return { success: true };
}
);
ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => {
try {
const { canceled, filePath } = await dialog.showSaveDialog({
@@ -0,0 +1,168 @@
import { XTREAM_CANCEL_SESSION } from 'shared-interfaces';
const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(channel, handler);
}),
},
}));
jest.mock('axios', () => ({
__esModule: true,
default: axiosMock,
}));
jest.mock('./portal-debug.events', () => ({
emitPortalDebugEvent: jest.fn(),
}));
describe('XtreamEvents session cancellation', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(async () => {
jest.resetModules();
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
await import('./xtream.events');
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('aborts requests that were registered with only a session id', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
const pendingRequest = createDeferred<{ status: number; data: unknown }>();
const cancelError = Object.assign(new Error('cancelled'), {
code: 'ERR_CANCELED',
});
let abortSignal: AbortSignal | undefined;
expect(requestHandler).toBeDefined();
expect(cancelHandler).toBeDefined();
axiosMock.mockImplementation((config: { signal?: AbortSignal }) => {
abortSignal = config.signal;
return pendingRequest.promise;
});
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === cancelError
);
const requestPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_live_categories',
password: 'secret',
username: 'user1',
},
sessionId: 'session-1',
suppressErrorLog: true,
}) as Promise<unknown>;
expect(abortSignal?.aborted).toBe(false);
const cancelResult = (await cancelHandler?.(
{},
'session-1'
)) as { success: boolean; cancelled: number };
expect(cancelResult).toEqual({ success: true, cancelled: 1 });
expect(abortSignal?.aborted).toBe(true);
pendingRequest.reject(cancelError);
await expect(requestPromise).rejects.toMatchObject({
name: 'AbortError',
status: 499,
});
});
it('counts every matching in-flight request for the same session', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
const firstRequest = createDeferred<{ status: number; data: unknown }>();
const secondRequest = createDeferred<{ status: number; data: unknown }>();
const cancelError = Object.assign(new Error('cancelled'), {
code: 'ERR_CANCELED',
});
const abortSignals: AbortSignal[] = [];
expect(requestHandler).toBeDefined();
expect(cancelHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { signal?: AbortSignal }) => {
if (config.signal) {
abortSignals.push(config.signal);
}
return firstRequest.promise;
})
.mockImplementationOnce((config: { signal?: AbortSignal }) => {
if (config.signal) {
abortSignals.push(config.signal);
}
return secondRequest.promise;
});
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === cancelError
);
const firstPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_live_categories',
password: 'secret',
username: 'user1',
},
sessionId: 'session-2',
suppressErrorLog: true,
}) as Promise<unknown>;
const secondPromise = requestHandler?.({}, {
url: 'http://localhost:3211',
params: {
action: 'get_vod_streams',
password: 'secret',
username: 'user1',
},
sessionId: 'session-2',
suppressErrorLog: true,
}) as Promise<unknown>;
const cancelResult = (await cancelHandler?.(
{},
'session-2'
)) as { success: boolean; cancelled: number };
expect(cancelResult).toEqual({ success: true, cancelled: 2 });
expect(abortSignals).toHaveLength(2);
expect(abortSignals.every((signal) => signal.aborted)).toBe(true);
firstRequest.reject(cancelError);
secondRequest.reject(cancelError);
await expect(firstPromise).rejects.toMatchObject({ name: 'AbortError' });
await expect(secondPromise).rejects.toMatchObject({ name: 'AbortError' });
});
});
@@ -5,7 +5,7 @@
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { PortalDebugEvent } from 'shared-interfaces';
import { PortalDebugEvent, XTREAM_CANCEL_SESSION } from 'shared-interfaces';
import { emitPortalDebugEvent } from './portal-debug.events';
export default class XtreamEvents {
@@ -62,12 +62,14 @@ ipcMain.handle(
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}
) => {
const startedAt = Date.now();
let activeRequestKey: string | null = null;
try {
const { url, params, requestId } = payload;
const { url, params, requestId, sessionId } = payload;
// Build URL with query parameters
// Xtream API endpoint is always at /player_api.php
@@ -76,6 +78,15 @@ ipcMain.handle(
apiUrl.searchParams.append(key, value);
});
const controller = new AbortController();
if (requestId || sessionId) {
activeRequestKey = requestId ?? crypto.randomUUID();
activeXtreamRequests.set(activeRequestKey, {
controller,
sessionId,
});
}
// Configure axios request
const config: AxiosRequestConfig = {
method: 'GET',
@@ -87,6 +98,7 @@ ipcMain.handle(
},
timeout: 30000, // 30 seconds timeout for Xtream API
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
signal: controller.signal,
};
const response = await axios(config);
@@ -166,6 +178,14 @@ ipcMain.handle(
// Format error response
if (axios.isAxiosError(error)) {
if (error.code === 'ERR_CANCELED') {
throw {
type: 'ERROR',
name: 'AbortError',
message: 'Xtream request cancelled',
status: 499,
};
}
const errorResponse = {
type: 'ERROR',
message:
@@ -188,6 +208,40 @@ ipcMain.handle(
status: 500,
};
}
} finally {
if (activeRequestKey) {
activeXtreamRequests.delete(activeRequestKey);
}
}
}
);
ipcMain.handle(
XTREAM_CANCEL_SESSION,
async (_event, sessionId: string): Promise<{ success: boolean; cancelled: number }> => {
if (!sessionId) {
return { success: false, cancelled: 0 };
}
let cancelled = 0;
for (const activeRequest of activeXtreamRequests.values()) {
if (activeRequest.sessionId !== sessionId) {
continue;
}
activeRequest.controller.abort();
cancelled += 1;
}
return {
success: cancelled > 0,
cancelled,
};
}
);
type ActiveXtreamRequest = {
controller: AbortController;
sessionId?: string;
};
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
@@ -8,6 +8,7 @@ export const DB_WORKER_OPERATIONS = [
'DB_GET_CONTENT',
'DB_GET_GLOBAL_RECENTLY_ADDED',
'DB_SAVE_CONTENT',
'DB_CLEAR_XTREAM_IMPORT_CACHE',
'DB_GET_CONTENT_BY_XTREAM_ID',
'DB_SEARCH_CONTENT',
'DB_GLOBAL_SEARCH',
@@ -30,6 +30,7 @@ import {
reorderGlobalFavorites,
} from '../database/operations/favorites.operations';
import {
clearXtreamImportCache,
getContent,
getContentByXtreamId,
getGlobalRecentlyAdded,
@@ -288,7 +289,7 @@ async function executeRequest(message: DbWorkerRequestMessage) {
playlistId: string;
categories: Array<{
category_name: string;
category_id: number;
category_id: string | number;
}>;
type: 'live' | 'movies' | 'series';
hiddenCategoryXtreamIds?: number[];
@@ -387,6 +388,15 @@ async function executeRequest(message: DbWorkerRequestMessage) {
);
}
case 'DB_CLEAR_XTREAM_IMPORT_CACHE': {
const payload = message.payload as {
playlistId: string;
type: 'live' | 'movie' | 'series';
};
return clearXtreamImportCache(db, payload.playlistId, payload.type);
}
case 'DB_GET_CONTENT_BY_XTREAM_ID': {
const payload = message.payload as {
xtreamId: number;
@@ -0,0 +1,188 @@
import axios from 'axios';
import { parentPort } from 'worker_threads';
import { parse } from 'iptv-playlist-parser';
import { readFile } from 'node:fs/promises';
import { createPlaylistObject, getFilenameFromUrl } from 'm3u-utils';
import type {
Playlist,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
} from 'shared-interfaces';
import type {
PlaylistRefreshWorkerIncomingMessage,
PlaylistRefreshWorkerMessage,
} from './playlist-refresh.worker.types';
const https = require('https');
type ActiveRefreshState = {
cancelled: boolean;
controller: AbortController;
};
const activeRefreshes = new Map<string, ActiveRefreshState>();
if (!parentPort) {
throw new Error('Playlist refresh worker must be started with a parent port');
}
function postMessage(message: PlaylistRefreshWorkerMessage<Playlist>): void {
parentPort?.postMessage(message);
}
function serializeError(error: unknown) {
if (error instanceof Error) {
return {
name: error.name,
message: error.message,
stack: error.stack,
};
}
return {
message: String(error),
};
}
function createAbortError(operationId: string): Error {
const error = new Error(`Playlist refresh "${operationId}" was cancelled`);
error.name = 'AbortError';
return error;
}
function emitEvent(
payload: PlaylistRefreshPayload,
partial: Omit<PlaylistRefreshEvent, 'operationId' | 'playlistId'>
): void {
postMessage({
type: 'event',
event: {
operationId: payload.operationId,
playlistId: payload.playlistId,
...partial,
},
});
}
function checkpoint(payload: PlaylistRefreshPayload): void {
const active = activeRefreshes.get(payload.operationId);
if (active?.cancelled) {
throw createAbortError(payload.operationId);
}
}
async function fetchPlaylistFromUrl(
payload: PlaylistRefreshPayload,
controller: AbortController
): Promise<Playlist> {
emitEvent(payload, { status: 'started', phase: 'fetching' });
checkpoint(payload);
const agent = new https.Agent({
rejectUnauthorized: false,
});
const result = await axios.get(payload.url!, {
httpsAgent: agent,
signal: controller.signal,
timeout: 30000,
});
checkpoint(payload);
emitEvent(payload, { status: 'progress', phase: 'parsing' });
const parsedPlaylist = parse(result.data);
checkpoint(payload);
const extractedName =
payload.url && payload.url.length > 1
? getFilenameFromUrl(payload.url)
: '';
const playlistName =
!extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
return createPlaylistObject(
payload.title || playlistName,
parsedPlaylist,
payload.url,
'URL'
);
}
async function fetchPlaylistFromFile(
payload: PlaylistRefreshPayload
): Promise<Playlist> {
emitEvent(payload, { status: 'started', phase: 'reading-file' });
checkpoint(payload);
const fileContent = await readFile(payload.filePath!, 'utf-8');
checkpoint(payload);
emitEvent(payload, { status: 'progress', phase: 'parsing' });
const parsedPlaylist = parse(fileContent);
checkpoint(payload);
return createPlaylistObject(
payload.title,
parsedPlaylist,
payload.filePath,
'FILE'
);
}
async function executeRefresh(payload: PlaylistRefreshPayload): Promise<Playlist> {
const controller = new AbortController();
activeRefreshes.set(payload.operationId, {
cancelled: false,
controller,
});
try {
const playlist = payload.url
? await fetchPlaylistFromUrl(payload, controller)
: await fetchPlaylistFromFile(payload);
checkpoint(payload);
return playlist;
} finally {
activeRefreshes.delete(payload.operationId);
}
}
parentPort.on('message', async (message: PlaylistRefreshWorkerIncomingMessage) => {
if (message.type === 'cancel') {
const active = activeRefreshes.get(message.operationId);
if (active) {
active.cancelled = true;
active.controller.abort();
}
return;
}
try {
const result = await executeRefresh(message.payload);
postMessage({
type: 'response',
success: true,
result,
});
} catch (error) {
const payload = message.payload;
if (error instanceof Error && error.name === 'AbortError') {
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
} else {
emitEvent(payload, {
status: 'error',
phase: payload.url ? 'fetching' : 'reading-file',
error: error instanceof Error ? error.message : String(error),
});
}
postMessage({
type: 'response',
success: false,
error: serializeError(error),
});
}
});
postMessage({ type: 'ready' });
@@ -0,0 +1,40 @@
import type { PlaylistRefreshEvent, PlaylistRefreshPayload } from 'shared-interfaces';
export interface PlaylistRefreshWorkerRequestMessage {
type: 'request';
payload: PlaylistRefreshPayload;
}
export interface PlaylistRefreshWorkerCancelMessage {
type: 'cancel';
operationId: string;
}
export interface PlaylistRefreshWorkerReadyMessage {
type: 'ready';
}
export interface PlaylistRefreshWorkerEventMessage {
type: 'event';
event: PlaylistRefreshEvent;
}
export interface PlaylistRefreshWorkerResponseMessage<TResult = unknown> {
type: 'response';
success: boolean;
result?: TResult;
error?: {
name?: string;
message: string;
stack?: string;
};
}
export type PlaylistRefreshWorkerIncomingMessage =
| PlaylistRefreshWorkerRequestMessage
| PlaylistRefreshWorkerCancelMessage;
export type PlaylistRefreshWorkerMessage<TResult = unknown> =
| PlaylistRefreshWorkerReadyMessage
| PlaylistRefreshWorkerEventMessage
| PlaylistRefreshWorkerResponseMessage<TResult>;
+3
View File
@@ -12,6 +12,7 @@ import { DataService } from 'services';
import {
Language,
Settings,
StartupBehavior,
STORE_KEY,
StreamFormat,
Theme,
@@ -37,6 +38,8 @@ const DEFAULT_SETTINGS: Settings = {
streamFormat: StreamFormat.M3u8StreamFormat,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
+18
View File
@@ -1,8 +1,10 @@
describe('app routes', () => {
let workspaceChildren: Array<{
canActivate?: unknown[];
data?: Record<string, unknown>;
loadComponent?: unknown;
path?: string;
redirectTo?: unknown;
}> = [];
beforeAll(async () => {
@@ -50,4 +52,20 @@ describe('app routes', () => {
});
expect(typeof globalRecentRoute?.loadComponent).toBe('function');
});
it('uses a dynamic redirect for the default /workspace child route', async () => {
const workspaceDefaultRoute = workspaceChildren.find(
(route) => route.path === ''
);
expect(typeof workspaceDefaultRoute?.redirectTo).toBe('function');
});
it('protects the dashboard route behind a visibility guard', async () => {
const dashboardRoute = workspaceChildren.find(
(route) => route.path === 'dashboard'
);
expect(dashboardRoute?.canActivate).toHaveLength(1);
});
});
+18 -2
View File
@@ -1,5 +1,20 @@
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { provideM3uWorkspaceRouteSession } from '@iptvnator/playlist/m3u/feature-player';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
const workspaceEntryRedirect = async () =>
inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath();
const dashboardAccessGuard = async () => {
const startupPreferences = inject(WorkspaceStartupPreferencesService);
const router = inject(Router);
const redirectPath = await startupPreferences.resolveDashboardPath();
return redirectPath === '/workspace/dashboard'
? true
: router.parseUrl(redirectPath);
};
export const routes: Routes = [
{
@@ -20,10 +35,11 @@ export const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'dashboard',
redirectTo: workspaceEntryRedirect,
},
{
path: 'dashboard',
canActivate: [dashboardAccessGuard],
loadComponent: () =>
import('workspace-dashboard-feature').then(
(c) => c.WorkspaceDashboardComponent
@@ -50,6 +50,7 @@ export class ElectronService extends DataService {
XtreamCodeActions.GetLiveCategories,
XtreamCodeActions.GetVodCategories,
XtreamCodeActions.GetSeriesCategories,
XtreamCodeActions.GetShortEpg,
]);
constructor() {
@@ -432,6 +433,8 @@ export class ElectronService extends DataService {
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) {
const context = createPortalDebugRequestContext({
provider: 'xtream',
@@ -456,9 +459,9 @@ export class ElectronService extends DataService {
return result;
} catch (error: unknown) {
const action = payload.params?.action;
const isSilentAction = action
? this.silentXtreamActions.has(action)
: false;
const isSilentAction =
payload.suppressErrorLog === true ||
(action ? this.silentXtreamActions.has(action) : false);
const normalizedMessage = this.getReadableXtreamErrorMessage(error);
const errorInfo = this.getErrorDetails(error);
+10 -2
View File
@@ -62,6 +62,7 @@ export class PwaService extends DataService {
XtreamCodeActions.GetLiveCategories,
XtreamCodeActions.GetVodCategories,
XtreamCodeActions.GetSeriesCategories,
XtreamCodeActions.GetShortEpg,
]);
/** Proxy URL to avoid CORS issues */
@@ -244,6 +245,9 @@ export class PwaService extends DataService {
url: string;
params: Record<string, string>;
macAddress?: string;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) {
const headers = payload.macAddress
? {
@@ -292,7 +296,9 @@ export class PwaService extends DataService {
if (!response.payload) {
const action = payload.params.action;
const isSilentAction = this.silentXtreamActions.has(action);
const isSilentAction =
payload.suppressErrorLog === true ||
this.silentXtreamActions.has(action);
const normalizedMessage =
this.getReadableXtreamErrorMessage(response);
logPortalDebugEvent(
@@ -332,7 +338,9 @@ export class PwaService extends DataService {
} catch (error: unknown) {
logPortalDebugEvent(createPortalDebugErrorEvent(context, error));
const action = payload.params.action;
const isSilentAction = this.silentXtreamActions.has(action);
const isSilentAction =
payload.suppressErrorLog === true ||
this.silentXtreamActions.has(action);
const normalizedMessage = this.getReadableXtreamErrorMessage(error);
const errorInfo = this.getErrorDetails(error);
@@ -119,6 +119,62 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
<p>
{{
'SETTINGS.SHOW_DASHBOARD_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="showDashboard"
data-test-id="toggle-show-dashboard"
></mat-checkbox>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
<p>
{{
'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.STARTUP_BEHAVIOR' | translate
}}</mat-label>
<mat-select
formControlName="startupBehavior"
data-test-id="select-startup-behavior"
>
@for (
option of startupBehaviorOptions;
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="option.value"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
@@ -28,6 +28,7 @@ import { DialogService } from 'components';
import { DataService, PlaylistsService } from 'services';
import {
Language,
StartupBehavior,
StreamFormat,
Theme,
VideoPlayer,
@@ -58,6 +59,8 @@ const DEFAULT_SETTINGS = {
streamFormat: StreamFormat.M3u8StreamFormat,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
@@ -405,6 +408,18 @@ describe('SettingsComponent', () => {
expect(component.settingsForm.dirty).toBeTruthy();
});
it('renders workspace startup controls with the expected defaults', () => {
const nativeElement = fixture.nativeElement as HTMLElement;
expect(
nativeElement.querySelector('[data-test-id="toggle-show-dashboard"]')
).not.toBeNull();
expect(component.settingsForm.value.showDashboard).toBe(true);
expect(component.settingsForm.value.startupBehavior).toBe(
StartupBehavior.FirstView
);
});
it('should save settings on submit', async () => {
const mockStore = settingsStore as unknown as MockSettingsStore;
mockStore.updateSettings.mockResolvedValue(undefined);
@@ -44,6 +44,7 @@ import { DataService, PlaylistsService } from 'services';
import {
Language,
Playlist,
StartupBehavior,
StreamFormat,
Theme,
VideoPlayer,
@@ -70,6 +71,11 @@ interface ThemeOption {
labelKey: string;
}
interface StartupBehaviorOption {
value: StartupBehavior;
labelKey: string;
}
@Component({
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
@@ -179,6 +185,17 @@ export class SettingsComponent implements OnInit, OnDestroy {
},
];
readonly startupBehaviorOptions: StartupBehaviorOption[] = [
{
value: StartupBehavior.FirstView,
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_FIRST_VIEW',
},
{
value: StartupBehavior.RestoreLastView,
labelKey: 'SETTINGS.STARTUP_BEHAVIOR_RESTORE_LAST_VIEW',
},
];
/** Settings form object */
settingsForm = this.formBuilder.group({
player: [VideoPlayer.VideoJs],
@@ -186,6 +203,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
streamFormat: StreamFormat.M3u8StreamFormat,
language: Language.ENGLISH,
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
+62 -8
View File
@@ -243,6 +243,8 @@
"SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!",
"THEME": "Farbdesign",
"SHOW_CAPTIONS": "Untertitel anzeigen",
"SHOW_DASHBOARD": "Dashboard anzeigen",
"SHOW_DASHBOARD_DESCRIPTION": "Dashboard in der Workspace-Leiste verfügbar halten. Wenn deaktiviert, wird Quellen zur ersten Workspace-Seite.",
"STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)",
"REMOVE_ALL": "Playlist entfernen",
"REMOVE_ALL_BUTTON": "Entfernen",
@@ -274,6 +276,10 @@
"VLC_PLAYER_PATH_LABEL": "VLC-Pfad",
"VLC_PLAYER_PATH_DESCRIPTION": "Legen Sie den Pfad zum VLC-Player in Ihrem System fest. \nEs wird der bereitgestellte Pfad zu einer VLC-Binärdatei anstelle des Standardpfads für unser System verwendet.",
"THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus",
"STARTUP_BEHAVIOR": "Startansicht",
"STARTUP_BEHAVIOR_DESCRIPTION": "Legen Sie fest, ob IPTVnator die erste verfügbare Workspace-Ansicht oder Ihre zuletzt geöffnete Abschnittsansicht startet. Erste Ansicht bedeutet Dashboard, wenn aktiviert, sonst Quellen.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Erste verfügbare Ansicht",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Letzte Ansicht wiederherstellen",
"EPG_NOTE": "Derzeit ist EPG nur in der Electron-basierten Version von IPTVnator verfügbar.",
"VLC_PLAYER_PATH": "VLC-Playerpfad",
"IMPORT_EXPORT_DATA_DESCRIPTION": "Exportieren oder importieren Sie Wiedergabelisten im JSON-Format, das in der Anwendung verwendet wird.",
@@ -398,10 +404,18 @@
"COPY_STREAM_URL": "Stream-URL kopieren",
"ERROR_VIEW": {
"DELETE": "Löschen",
"IMPORT_CANCELLED": {
"TITLE": "Import abgebrochen",
"DESCRIPTION": "Der Playlist-Import wurde abgebrochen. Wiederholen Sie ihn, wenn Sie diese Quelle erneut laden möchten."
},
"ACCOUNT_EXPIRED": {
"TITLE": "Konto abgelaufen",
"DESCRIPTION": "Dieses Konto ist abgelaufen. \nBitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie sie."
},
"ACCOUNT_INACTIVE": {
"TITLE": "Konto inaktiv",
"DESCRIPTION": "Dieses Konto ist inaktiv. Bitte überprüfen Sie die Anmeldeinformationen in den Playlist-Einstellungen oder entfernen Sie es."
},
"HOME": "Startseite",
"EMPTY_CATEGORY": {
"TITLE": "Leere Kategorie",
@@ -419,6 +433,10 @@
"TITLE": "Portal nicht gefunden",
"DESCRIPTION": "Bitte überprüfen Sie die Anmeldeinformationen und die Portal-URL in den Playlist-Einstellungen oder entfernen Sie sie."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "Portal nicht erreichbar",
"DESCRIPTION": "Das Portal konnte nicht erreicht werden. Überprüfen Sie die Server-URL oder versuchen Sie es erneut, wenn die Quelle wieder verfügbar ist."
},
"UNKNOWN_ERROR": {
"TITLE": "Etwas ist schief gelaufen",
"DESCRIPTION": "Unbekannter Fehler, bitte versuchen Sie es später noch einmal"
@@ -473,6 +491,9 @@
"REMOVE": "Entfernen",
"PLAY": "Abspielen",
"REVEAL": "Im Ordner anzeigen",
"COPY_URL": "Download-URL kopieren",
"URL_COPIED": "Download-URL in die Zwischenablage kopiert",
"URL_COPY_FAILED": "URL konnte nicht kopiert werden",
"STATUS": {
"QUEUED": "In Warteschlange",
"DOWNLOADING": "Wird heruntergeladen",
@@ -590,6 +611,7 @@
"RAIL_DASHBOARD": "Dashboard",
"OPEN_DASHBOARD": "Dashboard öffnen",
"RAIL_SOURCES": "Quellen",
"OPEN_SOURCES": "Quellen öffnen",
"RAIL_SETTINGS": "Einstellungen",
"OPEN_SETTINGS": "Einstellungen öffnen",
"RAIL_LIBRARY": "Bibliothek",
@@ -620,6 +642,7 @@
"NO_SECTION_ACTIONS": "Keine Aktionen für diesen Abschnitt",
"PLAYLIST_INFO": "Playlist-Info",
"ACCOUNT_INFO": "Kontoinfo",
"REFRESH_PLAYLIST": "Playlist aktualisieren",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "Abgeschlossene Downloads löschen (alle Playlists)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Abgeschlossene Downloads für alle Playlists löschen",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Abgeschlossene Downloads löschen (diese Playlist)",
@@ -632,12 +655,18 @@
"NO_SOURCES_AVAILABLE": "Keine Quellen verfügbar",
"ONE_SOURCE_AVAILABLE": "1 Quelle verfügbar",
"SOURCES_AVAILABLE": "{{count}} Quellen verfügbar",
"XTREAM_IMPORT_PREPARING": "Playlist-Daten werden vorbereitet...",
"XTREAM_IMPORT_SAVING": "Playlist-Inhalte werden gespeichert...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Favoriten werden wiederhergestellt...",
"XTREAM_IMPORT_RESTORING_RECENT": "Zuletzt angesehene Inhalte werden wiederhergestellt...",
"XTREAM_IMPORT_CANCEL": "Import abbrechen",
"XTREAM_IMPORT_CANCELLING": "Import wird abgebrochen...",
"XTREAM_IMPORT_TITLE": "Playlist wird synchronisiert",
"XTREAM_IMPORT_REMOTE_BADGE": "Remote-Quelle",
"XTREAM_IMPORT_LOCAL_BADGE": "Lokale Bibliothek",
"XTREAM_IMPORT_DETAIL_REMOTE": "Warte auf die Antwort des Providers.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Bereitet schnellere Playlist-Wechsel für den nächsten Besuch vor.",
"XTREAM_IMPORT_PREPARING": "Lokale Bibliothek wird vorbereitet...",
"XTREAM_IMPORT_LOADING": "Playlist-Daten werden von der Quelle geladen...",
"XTREAM_IMPORT_SAVING": "Playlist-Daten werden lokal gespeichert...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Favoriten werden aus der lokalen Bibliothek wiederhergestellt...",
"XTREAM_IMPORT_RESTORING_RECENT": "Zuletzt angesehene Inhalte werden aus der lokalen Bibliothek wiederhergestellt...",
"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",
@@ -648,7 +677,27 @@
"OPEN_DOWNLOADS_LABEL": "Downloads öffnen",
"OPEN_DOWNLOADS_DESCRIPTION": "Zur Download-Ansicht wechseln",
"OPEN_GLOBAL_RECENT_LABEL": "Zuletzt angesehen öffnen",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Globale Zuletzt-angesehen-Seite öffnen"
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Globale Zuletzt-angesehen-Seite öffnen",
"OPEN_SETTINGS_LABEL": "Einstellungen öffnen",
"OPEN_SETTINGS_DESCRIPTION": "Zu den Anwendungseinstellungen",
"OPEN_SOURCES_LABEL": "Quellen verwalten",
"OPEN_SOURCES_DESCRIPTION": "Zur Quellenverwaltung",
"OPEN_DASHBOARD_LABEL": "Dashboard öffnen",
"OPEN_DASHBOARD_DESCRIPTION": "Zum Dashboard navigieren",
"ADD_PLAYLIST_LABEL": "Neue Quelle hinzufügen",
"ADD_PLAYLIST_DESCRIPTION": "Dialog zum Hinzufügen öffnen",
"REFRESH_PLAYLIST_LABEL": "Playlist aktualisieren",
"REFRESH_PLAYLIST_DESCRIPTION": "Daten von der Quelle neu laden",
"PLAYLIST_INFO_LABEL": "Playlist-Details",
"PLAYLIST_INFO_DESCRIPTION": "Aktuelle Playlist-Informationen anzeigen",
"ACCOUNT_INFO_LABEL": "Kontoinformationen",
"ACCOUNT_INFO_DESCRIPTION": "Xtream-Server-Kontodaten anzeigen",
"GO_TO_VOD_LABEL": "Filme",
"GO_TO_VOD_DESCRIPTION": "Zum Filmbereich wechseln",
"GO_TO_LIVE_LABEL": "Live-TV",
"GO_TO_LIVE_DESCRIPTION": "Zum Live-TV-Bereich wechseln",
"GO_TO_SERIES_LABEL": "Serien",
"GO_TO_SERIES_DESCRIPTION": "Zum Serienbereich wechseln"
}
},
"SOURCES": {
@@ -668,6 +717,9 @@
"MOVIE_CATEGORIES": "Filmkategorien",
"SERIES_CATEGORIES": "Serienkategorien",
"LIVE_CATEGORIES": "Live-Kategorien",
"XTREAM_SYNCING_MOVIES": "Filmkategorien werden synchronisiert...",
"XTREAM_SYNCING_SERIES": "Serienkategorien werden synchronisiert...",
"XTREAM_SYNCING_LIVE": "Live-Kategorien werden synchronisiert...",
"XTREAM_SOURCE": "Xtream-Quelle",
"STALKER_PORTAL": "Stalker-Portal",
"MANAGE_CATEGORIES": "Kategorien verwalten",
@@ -675,7 +727,9 @@
"LOAD_CATEGORIES_ERROR_HINT": "Bitte erneut versuchen oder die Portal-Einstellungen prüfen.",
"FILTER_BY_TYPE": "Nach Typ filtern",
"ITEM_COUNT_ONE": "1 Element",
"ITEM_COUNT_OTHER": "{{count}} Elemente"
"ITEM_COUNT_OTHER": "{{count}} Elemente",
"SEARCH": "Suchen",
"SEARCH_CATEGORIES": "Kategorien suchen"
},
"COMMAND_PALETTE": {
"TITLE": "Befehlspalette",
+62 -8
View File
@@ -200,6 +200,8 @@
"SETTINGS_SAVED": "Success! Configuration was saved.",
"THEME": "Visual theme",
"SHOW_CAPTIONS": "Show subtitles",
"SHOW_DASHBOARD": "Show dashboard",
"SHOW_DASHBOARD_DESCRIPTION": "Keep Dashboard available in the workspace rail. When disabled, Sources becomes the first workspace page.",
"ADD_EPG_SOURCE": "Add EPG source",
"CLEAR_EPG_DATA": "Clear EPG data",
"CLEAR_EPG_DIALOG": {
@@ -212,6 +214,10 @@
"STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)",
"LANGUAGE_DESCRIPTION": "Select language of the application",
"THEME_DESCRIPTION": "Select visual theme of the application",
"STARTUP_BEHAVIOR": "Startup view",
"STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "First available view",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restore last view",
"SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Show now playing footer",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Keep the external-player status bar visible at the bottom of the workspace on desktop.",
@@ -469,10 +475,18 @@
"ACCOUNT_INFO": "Account info"
},
"ERROR_VIEW": {
"IMPORT_CANCELLED": {
"TITLE": "Import cancelled",
"DESCRIPTION": "The playlist import was cancelled. Retry when you want to continue loading this source."
},
"ACCOUNT_EXPIRED": {
"TITLE": "Account expired",
"DESCRIPTION": "This account has been expired. Please check the credentials in playlist settings or remove it."
},
"ACCOUNT_INACTIVE": {
"TITLE": "Account inactive",
"DESCRIPTION": "This account is inactive. Please check the credentials in playlist settings or remove it."
},
"UNAUTHORIZED": {
"TITLE": "Unauthorized",
"DESCRIPTION": "Please check the credentials in playlist settings or remove it."
@@ -481,6 +495,10 @@
"TITLE": "Portal not found",
"DESCRIPTION": "Please check the credentials and portal url in playlist settings or remove it."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "Portal unavailable",
"DESCRIPTION": "The portal could not be reached. Check the server URL or retry when the source is available again."
},
"EMPTY_CATEGORY": {
"TITLE": "No content in this category",
"DESCRIPTION": "Select another category or switch the content type."
@@ -543,6 +561,9 @@
"REMOVE": "Remove",
"PLAY": "Play",
"REVEAL": "Show in Folder",
"COPY_URL": "Copy download URL",
"URL_COPIED": "Download URL copied to clipboard",
"URL_COPY_FAILED": "Failed to copy URL to clipboard",
"STATUS": {
"QUEUED": "Queued",
"DOWNLOADING": "Downloading",
@@ -661,6 +682,7 @@
"RAIL_DASHBOARD": "Dashboard",
"OPEN_DASHBOARD": "Open dashboard",
"RAIL_SOURCES": "Sources",
"OPEN_SOURCES": "Open sources",
"RAIL_SETTINGS": "Settings",
"OPEN_SETTINGS": "Open settings",
"RAIL_LIBRARY": "Library",
@@ -691,6 +713,7 @@
"NO_SECTION_ACTIONS": "No actions for this section",
"PLAYLIST_INFO": "Playlist info",
"ACCOUNT_INFO": "Account info",
"REFRESH_PLAYLIST": "Refresh playlist",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "Clear completed downloads (all playlists)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Clear completed downloads for all playlists",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Clear completed downloads (this playlist)",
@@ -703,12 +726,18 @@
"NO_SOURCES_AVAILABLE": "No sources available",
"ONE_SOURCE_AVAILABLE": "1 source available",
"SOURCES_AVAILABLE": "{{count}} sources available",
"XTREAM_IMPORT_PREPARING": "Preparing playlist data...",
"XTREAM_IMPORT_SAVING": "Saving playlist content...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restoring favorites...",
"XTREAM_IMPORT_RESTORING_RECENT": "Restoring recently viewed...",
"XTREAM_IMPORT_CANCEL": "Cancel import",
"XTREAM_IMPORT_CANCELLING": "Cancelling import...",
"XTREAM_IMPORT_TITLE": "Syncing playlist",
"XTREAM_IMPORT_REMOTE_BADGE": "Remote source",
"XTREAM_IMPORT_LOCAL_BADGE": "Local library",
"XTREAM_IMPORT_DETAIL_REMOTE": "Waiting on the provider response.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Preparing faster playlist switching for the next visit.",
"XTREAM_IMPORT_PREPARING": "Preparing local library...",
"XTREAM_IMPORT_LOADING": "Fetching playlist data from source...",
"XTREAM_IMPORT_SAVING": "Saving playlist data locally...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Restoring favorites from local library...",
"XTREAM_IMPORT_RESTORING_RECENT": "Restoring recently viewed from local library...",
"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",
@@ -719,7 +748,27 @@
"OPEN_DOWNLOADS_LABEL": "Open downloads",
"OPEN_DOWNLOADS_DESCRIPTION": "Navigate to downloads view",
"OPEN_GLOBAL_RECENT_LABEL": "Open recently viewed",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Open global recently viewed page"
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Open global recently viewed page",
"OPEN_SETTINGS_LABEL": "Open settings",
"OPEN_SETTINGS_DESCRIPTION": "Navigate to application settings",
"OPEN_SOURCES_LABEL": "Manage sources",
"OPEN_SOURCES_DESCRIPTION": "Navigate to source management",
"OPEN_DASHBOARD_LABEL": "Open dashboard",
"OPEN_DASHBOARD_DESCRIPTION": "Navigate to the dashboard",
"ADD_PLAYLIST_LABEL": "Add new source",
"ADD_PLAYLIST_DESCRIPTION": "Open add playlist dialog",
"REFRESH_PLAYLIST_LABEL": "Refresh playlist",
"REFRESH_PLAYLIST_DESCRIPTION": "Re-sync data from remote source",
"PLAYLIST_INFO_LABEL": "Playlist details",
"PLAYLIST_INFO_DESCRIPTION": "Show current playlist information",
"ACCOUNT_INFO_LABEL": "Account info",
"ACCOUNT_INFO_DESCRIPTION": "Show Xtream server account details",
"GO_TO_VOD_LABEL": "Movies",
"GO_TO_VOD_DESCRIPTION": "Jump to movies section",
"GO_TO_LIVE_LABEL": "Live TV",
"GO_TO_LIVE_DESCRIPTION": "Jump to live TV section",
"GO_TO_SERIES_LABEL": "Series",
"GO_TO_SERIES_DESCRIPTION": "Jump to series section"
}
},
"SOURCES": {
@@ -739,6 +788,9 @@
"MOVIE_CATEGORIES": "Movie Categories",
"SERIES_CATEGORIES": "Series Categories",
"LIVE_CATEGORIES": "Live Categories",
"XTREAM_SYNCING_MOVIES": "Syncing movie categories...",
"XTREAM_SYNCING_SERIES": "Syncing series categories...",
"XTREAM_SYNCING_LIVE": "Syncing live categories...",
"XTREAM_SOURCE": "Xtream source",
"STALKER_PORTAL": "Stalker portal",
"MANAGE_CATEGORIES": "Manage categories",
@@ -746,7 +798,9 @@
"LOAD_CATEGORIES_ERROR_HINT": "Please retry or verify portal settings.",
"FILTER_BY_TYPE": "Filter by type",
"ITEM_COUNT_ONE": "1 item",
"ITEM_COUNT_OTHER": "{{count}} items"
"ITEM_COUNT_OTHER": "{{count}} items",
"SEARCH": "Search",
"SEARCH_CATEGORIES": "Search categories"
},
"COMMAND_PALETTE": {
"TITLE": "Command palette",
+63 -9
View File
@@ -181,6 +181,8 @@
"SETTINGS_SAVED": "Новые настройки были успешно сохранены!",
"THEME": "Тема оформления",
"SHOW_CAPTIONS": "Отображать субтитры",
"SHOW_DASHBOARD": "Показывать панель",
"SHOW_DASHBOARD_DESCRIPTION": "Оставить Dashboard доступным в боковой панели рабочего пространства. Если отключить, первой страницей будет Источники.",
"ADD_EPG_SOURCE": "Добавить источник EPG",
"CLEAR_EPG_DATA": "Очистить данные EPG",
"CLEAR_EPG_DIALOG": {
@@ -193,6 +195,10 @@
"STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)",
"LANGUAGE_DESCRIPTION": "Выбрать язык приложения",
"THEME_DESCRIPTION": "Выбрать визуальную тему оформления",
"STARTUP_BEHAVIOR": "Начальный экран",
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберите, должен ли IPTVnator открывать первый доступный экран рабочего пространства или восстанавливать последний экран уровня раздела. Первый экран означает Dashboard, если он включён, иначе Источники.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Первый доступный экран",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Восстановить последний экран",
"SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.",
"VERSION_DESCRIPTION": "Текущая версия приложения",
"EPG_NOTE": "На данный момент EPG доступен только в нативной версии IPTVnator.",
@@ -393,10 +399,18 @@
"COPY_STREAM_URL": "Копировать URL-адрес потока",
"ERROR_VIEW": {
"DELETE": "Удалить",
"IMPORT_CANCELLED": {
"TITLE": "Импорт отменён",
"DESCRIPTION": "Импорт плейлиста был отменён. Повторите попытку, когда захотите снова загрузить этот источник."
},
"ACCOUNT_EXPIRED": {
"TITLE": "Срок действия учетной записи истек",
"DESCRIPTION": "Срок действия этой учетной записи истек. \nПожалуйста, проверьте учетные данные в настройках плейлиста или удалите его."
},
"ACCOUNT_INACTIVE": {
"TITLE": "Учётная запись неактивна",
"DESCRIPTION": "Эта учётная запись неактивна. Проверьте учётные данные в настройках плейлиста или удалите источник."
},
"HOME": "На главную",
"EMPTY_CATEGORY": {
"TITLE": "Пустая категория",
@@ -410,6 +424,10 @@
"TITLE": "Портал не найден",
"DESCRIPTION": "Пожалуйста, проверьте учетные данные и URL портала в настройках плейлиста или удалите его."
},
"PORTAL_UNAVAILABLE": {
"TITLE": "Портал недоступен",
"DESCRIPTION": "Не удалось подключиться к порталу. Проверьте URL сервера или повторите попытку позже."
},
"NO_CATEGORY_SELECTED": {
"TITLE": "Категория не выбрана",
"DESCRIPTION": "Пожалуйста, выберите категорию для просмотра содержимого"
@@ -456,7 +474,10 @@
},
"SOURCE_PLAYLIST_MISSING": "Эту загрузку больше нельзя открыть в библиотеке, потому что исходный плейлист был удален.",
"FILE_NOT_FOUND": "Этот загруженный файл больше недоступен на диске.",
"FILE_ACTION_ERROR": "Не удалось выполнить действие с файлом."
"FILE_ACTION_ERROR": "Не удалось выполнить действие с файлом.",
"COPY_URL": "Копировать URL загрузки",
"URL_COPIED": "URL загрузки скопирован в буфер обмена",
"URL_COPY_FAILED": "Не удалось скопировать URL"
},
"REMOTE_CONTROL": {
"TITLE": "Дистанционное управление",
@@ -567,6 +588,7 @@
"RAIL_DASHBOARD": "Панель",
"OPEN_DASHBOARD": "Открыть панель",
"RAIL_SOURCES": "Источники",
"OPEN_SOURCES": "Открыть источники",
"RAIL_SETTINGS": "Настройки",
"OPEN_SETTINGS": "Открыть настройки",
"RAIL_LIBRARY": "Библиотека",
@@ -597,6 +619,7 @@
"NO_SECTION_ACTIONS": "Для этого раздела нет действий",
"PLAYLIST_INFO": "Информация о плейлисте",
"ACCOUNT_INFO": "Информация об аккаунте",
"REFRESH_PLAYLIST": "Обновить плейлист",
"CLEAR_COMPLETED_DOWNLOADS_ALL": "Очистить завершенные загрузки (все плейлисты)",
"CLEAR_COMPLETED_DOWNLOADS_ALL_ARIA": "Очистить завершенные загрузки для всех плейлистов",
"CLEAR_COMPLETED_DOWNLOADS_THIS_PLAYLIST": "Очистить завершенные загрузки (этот плейлист)",
@@ -609,12 +632,18 @@
"NO_SOURCES_AVAILABLE": "Нет доступных источников",
"ONE_SOURCE_AVAILABLE": "Доступен 1 источник",
"SOURCES_AVAILABLE": "Доступно источников: {{count}}",
"XTREAM_IMPORT_PREPARING": "Подготовка данных плейлиста...",
"XTREAM_IMPORT_SAVING": "Сохранение контента плейлиста...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Восстановление избранного...",
"XTREAM_IMPORT_RESTORING_RECENT": "Восстановление недавно просмотренного...",
"XTREAM_IMPORT_CANCEL": "Отменить импорт",
"XTREAM_IMPORT_CANCELLING": "Отмена импорта...",
"XTREAM_IMPORT_TITLE": "Синхронизация плейлиста",
"XTREAM_IMPORT_REMOTE_BADGE": "Удалённый источник",
"XTREAM_IMPORT_LOCAL_BADGE": "Локальная библиотека",
"XTREAM_IMPORT_DETAIL_REMOTE": "Ожидание ответа от провайдера.",
"XTREAM_IMPORT_DETAIL_LOCAL": "Подготавливает более быстрые переключения плейлиста при следующем открытии.",
"XTREAM_IMPORT_PREPARING": "Подготовка локальной библиотеки...",
"XTREAM_IMPORT_LOADING": "Получение данных плейлиста из источника...",
"XTREAM_IMPORT_SAVING": "Сохранение данных плейлиста локально...",
"XTREAM_IMPORT_RESTORING_FAVORITES": "Восстановление избранного из локальной библиотеки...",
"XTREAM_IMPORT_RESTORING_RECENT": "Восстановление недавно просмотренного из локальной библиотеки...",
"XTREAM_IMPORT_CANCEL": "Остановить синхронизацию",
"XTREAM_IMPORT_CANCELLING": "Остановка синхронизации...",
"COMMANDS": {
"GLOBAL_SEARCH_LABEL": "Искать во всех плейлистах Xtream",
"GLOBAL_SEARCH_DESCRIPTION": "Открыть глобальный поиск",
@@ -625,7 +654,27 @@
"OPEN_DOWNLOADS_LABEL": "Открыть загрузки",
"OPEN_DOWNLOADS_DESCRIPTION": "Перейти к представлению загрузок",
"OPEN_GLOBAL_RECENT_LABEL": "Открыть недавно просмотренные",
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Открыть глобальную страницу недавно просмотренного"
"OPEN_GLOBAL_RECENT_DESCRIPTION": "Открыть глобальную страницу недавно просмотренного",
"OPEN_SETTINGS_LABEL": "Открыть настройки",
"OPEN_SETTINGS_DESCRIPTION": "Перейти к настройкам приложения",
"OPEN_SOURCES_LABEL": "Управление источниками",
"OPEN_SOURCES_DESCRIPTION": "Перейти к управлению источниками",
"OPEN_DASHBOARD_LABEL": "Открыть панель",
"OPEN_DASHBOARD_DESCRIPTION": "Перейти к панели управления",
"ADD_PLAYLIST_LABEL": "Добавить источник",
"ADD_PLAYLIST_DESCRIPTION": "Открыть диалог добавления плейлиста",
"REFRESH_PLAYLIST_LABEL": "Обновить плейлист",
"REFRESH_PLAYLIST_DESCRIPTION": "Синхронизировать данные с источника",
"PLAYLIST_INFO_LABEL": "Сведения о плейлисте",
"PLAYLIST_INFO_DESCRIPTION": "Показать информацию о текущем плейлисте",
"ACCOUNT_INFO_LABEL": "Информация об аккаунте",
"ACCOUNT_INFO_DESCRIPTION": "Показать данные аккаунта Xtream",
"GO_TO_VOD_LABEL": "Фильмы",
"GO_TO_VOD_DESCRIPTION": "Перейти к разделу фильмов",
"GO_TO_LIVE_LABEL": "Прямой эфир",
"GO_TO_LIVE_DESCRIPTION": "Перейти к разделу ТВ",
"GO_TO_SERIES_LABEL": "Сериалы",
"GO_TO_SERIES_DESCRIPTION": "Перейти к разделу сериалов"
}
},
"SOURCES": {
@@ -645,6 +694,9 @@
"MOVIE_CATEGORIES": "Категории фильмов",
"SERIES_CATEGORIES": "Категории сериалов",
"LIVE_CATEGORIES": "Категории ТВ",
"XTREAM_SYNCING_MOVIES": "Синхронизация категорий фильмов...",
"XTREAM_SYNCING_SERIES": "Синхронизация категорий сериалов...",
"XTREAM_SYNCING_LIVE": "Синхронизация категорий ТВ...",
"XTREAM_SOURCE": "Xtream источник",
"STALKER_PORTAL": "Портал Stalker",
"MANAGE_CATEGORIES": "Управлять категориями",
@@ -652,7 +704,9 @@
"LOAD_CATEGORIES_ERROR_HINT": "Повторите попытку или проверьте настройки портала.",
"FILTER_BY_TYPE": "Фильтр по типу",
"ITEM_COUNT_ONE": "1 элемент",
"ITEM_COUNT_OTHER": "{{count}} элементов"
"ITEM_COUNT_OTHER": "{{count}} элементов",
"SEARCH": "Поиск",
"SEARCH_CATEGORIES": "Поиск категорий"
},
"COMMAND_PALETTE": {
"TITLE": "Палитра команд",
+21 -1
View File
@@ -10,7 +10,10 @@ import {
ExternalPlayerSession,
PlaybackPositionData,
Playlist,
PlaylistRefreshEvent,
PlaylistRefreshPayload,
PortalDebugEvent,
XtreamCategory,
} from 'shared-interfaces';
import {
DbOperationEvent,
@@ -31,6 +34,9 @@ declare global {
onPortalDebugEvent?: (
callback: (data: PortalDebugEvent) => void
) => () => void;
onPlaylistRefreshEvent?: (
callback: (data: PlaylistRefreshEvent) => void
) => () => void;
getAppVersion: () => Promise<string>;
platform: string;
fetchPlaylistByUrl: (
@@ -119,8 +125,18 @@ declare global {
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) => Promise<{ payload: unknown; action: string }>;
xtreamCancelSession: (
sessionId: string
) => Promise<{ success: boolean; cancelled: number }>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
) => Promise<Playlist>;
cancelPlaylistRefresh: (
operationId: string
) => Promise<{ success: boolean }>;
// Database operations
dbCreatePlaylist: (
playlist: Playlist
@@ -176,7 +192,7 @@ declare global {
) => Promise<XCategoryFromDb[]>;
dbSaveCategories: (
playlistId: string,
categories: XCategoryFromDb[],
categories: XtreamCategory[],
type: string,
hiddenCategoryXtreamIds?: number[]
) => Promise<{ success: boolean }>;
@@ -202,6 +218,10 @@ declare global {
type: string,
operationId?: string
) => Promise<{ success: boolean; count: number }>;
dbClearXtreamImportCache: (
playlistId: string,
type: 'live' | 'movie' | 'series'
) => Promise<{ success: boolean }>;
dbSearchContent: (
playlistId: string,
searchTerm: string,
Vendored
+22
View File
@@ -1,5 +1,9 @@
import 'jest-extended';
import { ExternalPlayerSession } from './libs/shared/interfaces/src/lib/external-player-session.interface';
import {
PlaylistRefreshEvent,
PlaylistRefreshPayload,
} from './libs/shared/interfaces/src/lib/playlist-refresh.interface';
import { Playlist } from './libs/shared/interfaces/src/lib/playlist.interface';
declare module 'video.js' {
@@ -23,6 +27,9 @@ declare global {
interface Window {
electron: {
onPlaylistRefreshEvent?: (
callback: (data: PlaylistRefreshEvent) => void
) => () => void;
getAppVersion: () => Promise<string>;
platform: string;
fetchPlaylistByUrl: (
@@ -113,7 +120,18 @@ declare global {
url: string;
params: Record<string, string>;
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}) => Promise<{ payload: any; action: string }>;
xtreamCancelSession: (
sessionId: string
) => Promise<{ success: boolean; cancelled: number }>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
) => Promise<Playlist>;
cancelPlaylistRefresh: (
operationId: string
) => Promise<{ success: boolean }>;
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
@@ -190,6 +208,10 @@ declare global {
type: string,
operationId?: string
) => Promise<{ success: boolean; count: number }>;
dbClearXtreamImportCache: (
playlistId: string,
type: 'live' | 'movie' | 'series'
) => Promise<{ success: boolean }>;
dbSearchContent: (
playlistId: string,
searchTerm: string,
@@ -0,0 +1,44 @@
import { createReducer } from '@ngrx/store';
import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { initialState } from '../state';
import { playlistReducers } from './playlist.reducers';
import { PlaylistMeta } from 'shared-interfaces';
const reducer = createReducer(initialState, ...playlistReducers);
describe('playlistReducers', () => {
it('persists updateDate when playlist meta is updated', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
title: 'Xtream Playlist',
count: 0,
importDate: '2026-03-28T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
};
const state = {
...initialState,
playlists: playlistsAdapter.addOne(
existingPlaylist,
initialState.playlists
),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylistMeta({
playlist: {
...existingPlaylist,
updateDate: 1712145600000,
},
})
);
expect(nextState.playlists.entities['playlist-1']?.updateDate).toBe(
1712145600000
);
});
});
@@ -122,6 +122,9 @@ export const playlistReducers = [
...(p.recentlyViewed != null
? { recentlyViewed: p.recentlyViewed }
: {}),
...(p.updateDate !== undefined
? { updateDate: p.updateDate }
: {}),
},
},
state.playlists
@@ -176,6 +176,11 @@ describe('RecentPlaylistsComponent busy state', () => {
jest.spyOn(store, 'dispatch');
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it('tracks delete progress and clears the busy row after completion', async () => {
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
const deletion = createDeferred<boolean>();
@@ -288,6 +293,84 @@ describe('RecentPlaylistsComponent busy state', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
let confirmPromise: Promise<void> | undefined;
const executionOrder: string[] = [];
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712145600000);
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (key: string, value: string) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
(store.dispatch as jest.Mock).mockImplementation((action: unknown) => {
executionOrder.push('dispatch');
return action;
});
router.navigate.mockImplementation((commands: unknown[]) => {
executionOrder.push('navigate');
return Promise.resolve(Boolean(commands));
});
databaseService.deleteXtreamPlaylistContent.mockResolvedValue({
success: true,
favoritedXtreamIds: [101, 202],
recentlyViewedXtreamIds: [
{
xtreamId: 303,
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, type: 'live' }],
});
component.refreshXtreamPlaylist(item);
await confirmPromise;
expect(databaseService.updateXtreamPlaylistDetails).toHaveBeenCalledWith(
{
id: item._id,
updateDate: 1712145600000,
}
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712145600000 },
})
);
expect(setItemSpy).toHaveBeenCalledWith(
`xtream-restore-${item._id}`,
JSON.stringify({
favoritedXtreamIds: [101, 202],
recentlyViewedXtreamIds: [
{
xtreamId: 303,
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, type: 'live' }],
})
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
});
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
@@ -25,10 +25,15 @@ import {
DataService,
DbOperationEvent,
isDbAbortError,
PlaylistRefreshService,
SortBy,
SortService,
} from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import {
PLAYLIST_UPDATE,
PlaylistMeta,
PlaylistRefreshEvent,
} from 'shared-interfaces';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
@@ -59,6 +64,7 @@ export class RecentPlaylistsComponent {
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly dataService = inject(DataService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly sortService = inject(SortService);
@@ -280,6 +286,8 @@ export class RecentPlaylistsComponent {
if (item.serverUrl) {
// For Xtream playlists, delete and re-import
this.refreshXtreamPlaylist(item);
} else if (window.electron && (item.url || item.filePath)) {
void this.refreshM3uPlaylist(item);
} else {
// For M3U playlists, use existing refresh logic
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
@@ -360,6 +368,17 @@ export class RecentPlaylistsComponent {
}),
]);
const restoreKey = `xtream-restore-${item._id}`;
const restorePayload = {
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
};
localStorage.setItem(
restoreKey,
JSON.stringify(restorePayload)
);
// Update the timestamp in NgRx / IndexedDB
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
@@ -367,17 +386,6 @@ export class RecentPlaylistsComponent {
})
);
// Persist user data so it can be restored after re-import
const restoreKey = `xtream-restore-${item._id}`;
localStorage.setItem(
restoreKey,
JSON.stringify({
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
})
);
// Navigate to the playlist to trigger re-import
this.router.navigate(['/workspace', 'xtreams', item._id]);
} catch (error) {
@@ -399,6 +407,76 @@ export class RecentPlaylistsComponent {
});
}
private async refreshM3uPlaylist(item: PlaylistMeta): Promise<void> {
if (
this.isDeletePending(item._id) ||
this.isRefreshPending(item._id)
) {
return;
}
this.setPendingRefresh(item._id, true);
const operationId = this.databaseService.createOperationId(
'playlist-refresh'
);
try {
const refreshedPlaylist = await this.playlistRefreshService.refreshPlaylist(
{
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
filePath: item.filePath,
},
{
onEvent: (event) =>
this.updateBusyOperation(
item._id,
this.toPlaylistRefreshBusyEvent(event)
),
}
);
this.updateBusyOperation(item._id, {
operationId,
operation: 'playlist-refresh',
playlistId: item._id,
phase: 'saving',
status: 'progress',
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
})
);
this.clearBusyOperation(item._id);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'),
null,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getPlaylistRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingRefresh(item._id, false);
}
}
isDeletePending(playlistId: string): boolean {
return this.pendingDeletionIds().has(playlistId);
}
@@ -418,6 +496,8 @@ export class RecentPlaylistsComponent {
return this.translateDeletePhase(operation.phase);
case 'delete-xtream-content':
return this.translateRefreshPhase(operation.phase);
case 'playlist-refresh':
return this.translatePlaylistRefreshPhase(operation.phase);
default:
return '';
}
@@ -442,7 +522,15 @@ export class RecentPlaylistsComponent {
canCancelBusyOperation(item: PlaylistMeta): boolean {
const operation = this.busyOperations().get(item._id);
return Boolean(item.serverUrl && operation?.operationId);
if (!operation?.operationId) {
return false;
}
if (operation.operation === 'playlist-refresh') {
return true;
}
return Boolean(item.serverUrl);
}
async cancelBusyOperation(item: PlaylistMeta): Promise<void> {
@@ -451,6 +539,11 @@ export class RecentPlaylistsComponent {
return;
}
if (operation.operation === 'playlist-refresh') {
await this.playlistRefreshService.cancelRefresh(operation.operationId);
return;
}
await this.databaseService.cancelOperation(operation.operationId);
}
@@ -514,6 +607,19 @@ export class RecentPlaylistsComponent {
});
}
private toPlaylistRefreshBusyEvent(
event: PlaylistRefreshEvent
): DbOperationEvent {
return {
operationId: event.operationId,
operation: 'playlist-refresh',
playlistId: event.playlistId,
phase: event.phase,
status: event.status,
error: event.error,
};
}
private translateDeletePhase(phase?: string): string {
switch (phase) {
case 'deleting-favorites':
@@ -571,4 +677,61 @@ export class RecentPlaylistsComponent {
);
}
}
private translatePlaylistRefreshPhase(phase?: string): string {
switch (phase) {
case 'fetching':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_FETCHING',
'Fetching playlist...'
);
case 'reading-file':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_READING_FILE',
'Reading playlist file...'
);
case 'parsing':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_PARSING',
'Parsing playlist...'
);
case 'saving':
return this.translateWithFallback(
'HOME.PLAYLISTS.REFRESH_SAVING',
'Saving playlist...'
);
default:
return this.translate.instant('HOME.PLAYLISTS.REFRESH');
}
}
private getPlaylistRefreshErrorMessage(
error: unknown,
item: PlaylistMeta
): string {
if (item.filePath) {
const message = String((error as { message?: string })?.message ?? error);
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
return this.translateWithFallback(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND',
'Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
return this.translateWithFallback(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR',
'Playlist refresh failed. The app can no longer access the local file.'
);
}
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
}
private translateWithFallback(key: string, fallback: string): string {
const translated = this.translate.instant(key);
return translated === key ? fallback : translated;
}
}
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'playlist-shared-util',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/playlist/shared/util',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+8
View File
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:util"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/playlist/shared/util/jest.config.ts",
"tsConfig": "libs/playlist/shared/util/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
+1
View File
@@ -1,2 +1,3 @@
export * from './lib/playlist-player-actions';
export * from './lib/playlist-context.facade';
export * from './lib/playlist-refresh-action.service';
@@ -0,0 +1,182 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { DialogService } from 'components';
import { DatabaseService, PlaylistRefreshService } from 'services';
import { PlaylistActions } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Xtream Playlist',
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
...overrides,
} as PlaylistMeta;
}
describe('PlaylistRefreshActionService', () => {
let service: PlaylistRefreshActionService;
let databaseService: {
createOperationId: jest.Mock;
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let router: {
navigate: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: {
dispatch: jest.Mock;
};
beforeEach(() => {
localStorage.clear();
databaseService = {
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
success: true,
favoritedXtreamIds: [101, 202],
recentlyViewedXtreamIds: [
{
xtreamId: 303,
viewedAt: '2026-04-04T08:00:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, type: 'live' }],
}),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
router = {
navigate: jest.fn().mockResolvedValue(true),
};
snackBar = {
open: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
PlaylistRefreshActionService,
{
provide: Router,
useValue: router,
},
{
provide: Store,
useValue: store,
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: PlaylistRefreshService,
useValue: {
refreshPlaylist: jest.fn(),
},
},
],
});
service = TestBed.inject(PlaylistRefreshActionService);
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
const item = createPlaylistMeta();
const executionOrder: string[] = [];
let confirmPromise: Promise<void> | undefined;
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712217600000);
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (key: string, value: string) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
store.dispatch.mockImplementation((action: unknown) => {
executionOrder.push('dispatch');
return action;
});
router.navigate.mockImplementation((commands: unknown[]) => {
executionOrder.push('navigate');
return Promise.resolve(Boolean(commands));
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
service.refresh(item);
await confirmPromise;
expect(setItemSpy).toHaveBeenCalledWith(
`xtream-restore-${item._id}`,
JSON.stringify({
favoritedXtreamIds: [101, 202],
recentlyViewedXtreamIds: [
{
xtreamId: 303,
viewedAt: '2026-04-04T08:00:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, type: 'live' }],
})
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712217600000 },
})
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
});
});
@@ -0,0 +1,201 @@
import { inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DialogService } from 'components';
import {
DatabaseService,
isDbAbortError,
PlaylistRefreshService,
} from 'services';
import { PlaylistActions } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
@Injectable({ providedIn: 'root' })
export class PlaylistRefreshActionService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
readonly isRefreshing = signal(false);
canRefresh(playlist: PlaylistMeta | null): boolean {
if (!playlist || !window.electron) {
return false;
}
return Boolean(
playlist.serverUrl || playlist.url || playlist.filePath
);
}
refresh(playlist: PlaylistMeta): void {
if (this.isRefreshing()) {
return;
}
if (playlist.serverUrl) {
this.refreshXtream(playlist);
} else if (playlist.url || playlist.filePath) {
void this.refreshM3u(playlist);
}
}
private refreshXtream(item: PlaylistMeta): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.TITLE'
),
message: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
),
onConfirm: async () => {
if (this.isRefreshing()) {
return;
}
this.isRefreshing.set(true);
const operationId =
this.databaseService.createOperationId('xtream-refresh');
try {
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
),
undefined,
{ duration: 2000 }
);
const updateDate = Date.now();
const [
{
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
},
] = await Promise.all([
this.databaseService.deleteXtreamPlaylistContent(
item._id,
{ operationId }
),
this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
}),
]);
const restoreKey = `xtream-restore-${item._id}`;
const restorePayload = {
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
};
localStorage.setItem(
restoreKey,
JSON.stringify(restorePayload)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
);
await this.router.navigate([
'/workspace',
'xtreams',
item._id,
]);
} catch (error) {
if (!isDbAbortError(error)) {
console.error(
'Error refreshing Xtream playlist:',
error
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
),
undefined,
{ duration: 3000 }
);
}
} finally {
this.isRefreshing.set(false);
}
},
});
}
private async refreshM3u(item: PlaylistMeta): Promise<void> {
this.isRefreshing.set(true);
try {
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist({
operationId:
this.databaseService.createOperationId(
'playlist-refresh'
),
playlistId: item._id,
title: item.title,
url: item.url,
filePath: item.filePath,
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
})
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.isRefreshing.set(false);
}
}
private getRefreshErrorMessage(
error: unknown,
item: PlaylistMeta
): string {
if (
error instanceof Error &&
error.message?.includes('ENOENT') &&
item.filePath
) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR'
);
}
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-catalog-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/catalog/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+8
View File
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/catalog/feature/jest.config.ts",
"tsConfig": "libs/portal/catalog/feature/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -0,0 +1,175 @@
import { Component, input, output, signal } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
PortalCatalogSortMode,
} from '@iptvnator/portal/shared/util';
import { CategoryContentViewComponent } from './category-content-view.component';
@Component({
selector: 'app-grid-list',
standalone: true,
template: '',
})
class MockGridListComponent {
readonly isLoading = input<boolean>();
readonly items = input<unknown[]>();
readonly pageIndex = input<number>();
readonly totalPages = input<number>();
readonly limit = input<number>();
readonly pageSizeOptions = input<number[]>();
readonly showPaginator = input(true);
readonly itemClicked = output<unknown>();
readonly pageChange = output<unknown>();
}
@Component({
selector: 'app-playlist-error-view',
standalone: true,
template: '',
})
class MockPlaylistErrorViewComponent {
readonly title = input('');
readonly description = input('');
readonly showActionButtons = input(true);
readonly viewType = input('');
}
@Component({
standalone: true,
template: '',
})
class MockDetailComponent {}
describe('CategoryContentViewComponent', () => {
let fixture: ComponentFixture<CategoryContentViewComponent>;
const isPaginatedContentLoading = signal(true);
const categoryItemCount = signal(0);
const contentSortMode = signal<PortalCatalogSortMode | null>(null);
const catalog = {
provider: 'xtream' as const,
pageSizeOptions: [10, 25, 50],
contentType: signal('vod'),
limit: signal(25),
pageIndex: signal(0),
selectedCategory: signal({ id: 1 }),
paginatedContent: signal<unknown[]>([]),
selectedCategoryTitle: signal('Movies'),
categoryItemCount,
selectedItem: signal(null),
totalPages: signal(0),
contentSortMode,
playlist: signal(null),
isPaginatedContentLoading,
initialize: jest.fn(),
clearSelectedItem: jest.fn(),
setPage: jest.fn(),
setLimit: jest.fn(),
setContentSortMode: jest.fn(),
selectItem: jest.fn().mockReturnValue(null),
getItemProgress: jest.fn().mockReturnValue({}),
};
beforeEach(async () => {
isPaginatedContentLoading.set(true);
categoryItemCount.set(0);
contentSortMode.set(null);
catalog.initialize.mockClear();
await TestBed.configureTestingModule({
imports: [CategoryContentViewComponent, NoopAnimationsModule],
providers: [
{
provide: TranslateService,
useValue: {
instant: (key: string) =>
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
? 'Fetching playlist data from source...'
: key,
get: (key: string) =>
of(
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
? 'Fetching playlist data from source...'
: key
),
stream: (key: string) =>
of(
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
? 'Fetching playlist data from source...'
: key
),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: PORTAL_CATALOG_FACADE,
useValue: catalog,
},
{
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
useValue: MockDetailComponent,
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
params: {},
},
},
},
{
provide: Router,
useValue: {
navigate: jest.fn(),
},
},
],
})
.overrideComponent(CategoryContentViewComponent, {
set: {
imports: [
NgComponentOutlet,
MockGridListComponent,
MockPlaylistErrorViewComponent,
MatIcon,
MatIconButton,
MatMenuModule,
MatPaginatorModule,
MatTooltip,
TranslatePipe,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(CategoryContentViewComponent);
});
it('shows loading copy in the subtitle instead of 0 items while xtream content is still warming up', () => {
fixture.detectChanges();
const subtitle = fixture.nativeElement.querySelector(
'.category-subtitle'
) as HTMLElement | null;
expect(catalog.initialize).toHaveBeenCalledWith(null);
expect(subtitle?.textContent?.trim()).toBe(
'Fetching playlist data from source...'
);
});
});
@@ -6,13 +6,16 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
GridListComponent,
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
clearNavigationStateKeys,
getOpenStalkerItemState,
PortalCatalogFacade,
OPEN_STALKER_ITEM_STATE_KEY,
PORTAL_CATALOG_DETAIL_COMPONENT,
PORTAL_CATALOG_FACADE,
PortalCatalogSortMode,
@@ -47,6 +50,7 @@ interface CategoryContentItem {
export class CategoryContentViewComponent implements OnInit {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
@@ -67,7 +71,18 @@ export class CategoryContentViewComponent implements OnInit {
readonly contentSortMode = this.catalog.contentSortMode;
readonly isPaginatedContentLoading =
this.catalog.isPaginatedContentLoading;
readonly isXtreamLoadingSubtitle = computed(
() =>
this.catalog.provider === 'xtream' &&
this.isPaginatedContentLoading()
);
readonly categoryItemSubtitle = computed(() => {
if (this.isXtreamLoadingSubtitle()) {
return this.translate.instant(
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
);
}
const itemCount = this.categoryItemCount();
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
});
@@ -89,6 +104,7 @@ export class CategoryContentViewComponent implements OnInit {
ngOnInit(): void {
const { categoryId } = this.activatedRoute.snapshot.params;
this.catalog.initialize(categoryId ?? null);
this.openStalkerItemFromNavigationState();
}
onPageChange(event: PageEvent): void {
@@ -104,4 +120,22 @@ export class CategoryContentViewComponent implements OnInit {
});
}
}
private openStalkerItemFromNavigationState(): void {
if (this.catalog.provider !== 'stalker') {
return;
}
const item = getOpenStalkerItemState(window.history.state);
if (!item) {
return;
}
this.catalog.selectItem(item as CategoryContentItem);
clearNavigationStateKeys([
OPEN_STALKER_ITEM_STATE_KEY,
'openFavoriteItem',
'openRecentItem',
]);
}
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+28
View File
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
@@ -167,6 +167,18 @@
</div>
<div class="downloads__item-actions">
<button
mat-icon-button
(click)="
$event.stopPropagation();
copyUrl(item)
"
[matTooltip]="
'DOWNLOADS.COPY_URL' | translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
@switch (item.status) {
@case ('queued') {
<button
@@ -201,6 +201,23 @@ export class DownloadsComponent {
}
}
async copyUrl(item: DownloadItem): Promise<void> {
try {
await navigator.clipboard.writeText(item.url);
this.snackBar.open(
this.translate.instant('DOWNLOADS.URL_COPIED'),
undefined,
{ duration: 2000, horizontalPosition: 'start' }
);
} catch {
this.snackBar.open(
this.translate.instant('DOWNLOADS.URL_COPY_FAILED'),
undefined,
{ duration: 3000, horizontalPosition: 'start' }
);
}
}
async cancel(item: DownloadItem) {
await this.downloadsService.cancelDownload(item.id);
}
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-shared-ui',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/shared/ui',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+8
View File
@@ -6,6 +6,14 @@
"projectType": "library",
"tags": ["scope:portal", "domain:portal-shared", "type:ui"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/shared/ui/jest.config.ts",
"tsConfig": "libs/portal/shared/ui/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -118,9 +118,11 @@
[items]="liveItems()"
[mode]="mode()"
[searchTerm]="workspaceSearchTerm()"
[autoOpenItem]="pendingAutoOpenLiveItem()"
(removeItem)="onRemoveItem($event)"
(reorderItems)="onReorder($event)"
(itemPlayed)="onItemPlayed($event)"
(autoOpenHandled)="onLiveAutoOpenHandled()"
/>
}
}
@@ -0,0 +1,187 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
signal,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { convertToParamMap, ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import {
CollectionScope,
ScopeToggleService,
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { of } from 'rxjs';
import { PlaylistMeta } from 'shared-interfaces';
import { UnifiedCollectionPageComponent } from './unified-collection-page.component';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
@Component({
selector: 'app-unified-live-tab',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubUnifiedLiveTabComponent {
readonly items = input.required<UnifiedCollectionItem[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly autoOpenItem = input<unknown>(null);
readonly removeItem = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
}
@Component({
selector: 'app-unified-grid-tab',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubUnifiedGridTabComponent {
readonly items = input.required<UnifiedCollectionItem[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly contentType = input<'movie' | 'series'>('movie');
readonly searchTerm = input('');
readonly removeItem = output<UnifiedCollectionItem>();
}
describe('UnifiedCollectionPageComponent', () => {
let fixture: ComponentFixture<UnifiedCollectionPageComponent>;
const playlistsLoaded = signal(false);
const playlists = signal<PlaylistMeta[]>([]);
const favoritesData = {
getFavorites: jest.fn().mockResolvedValue([]),
removeFavorite: jest.fn(),
reorder: jest.fn(),
};
const recentData = {
getRecentItems: jest.fn().mockResolvedValue([]),
removeRecentItem: jest.fn(),
clearRecentItems: jest.fn(),
};
beforeEach(async () => {
playlistsLoaded.set(false);
playlists.set([]);
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [
UnifiedCollectionPageComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({}),
queryParams: {},
params: {},
data: {},
parent: null,
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{
snapshot: {
data: { layout: 'workspace' },
},
},
],
},
},
{
provide: Store,
useValue: {
selectSignal: jest.fn((selector: unknown) => {
if (selector === selectAllPlaylistsMeta) {
return playlists;
}
if (selector === selectPlaylistsLoadingFlag) {
return playlistsLoaded;
}
return signal(null);
}),
},
},
{
provide: ScopeToggleService,
useValue: {
getScope: jest.fn(() => signal<CollectionScope>('all')),
setScope: jest.fn(),
},
},
{
provide: UnifiedFavoritesDataService,
useValue: favoritesData,
},
{
provide: UnifiedRecentDataService,
useValue: recentData,
},
],
})
.overrideComponent(UnifiedCollectionPageComponent, {
remove: {
imports: [
UnifiedGridTabComponent,
UnifiedLiveTabComponent,
],
},
add: {
imports: [
StubUnifiedGridTabComponent,
StubUnifiedLiveTabComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
fixture.componentRef.setInput('mode', 'favorites');
fixture.componentRef.setInput('defaultScope', 'all');
});
it('reloads favorites after playlist hydration completes', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
playlists.set([
{
_id: 'xtream-1',
title: 'Xtream One',
count: 1,
importDate: '2026-04-03T10:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://example.com',
favorites: ['fav-1'],
} as PlaylistMeta,
]);
playlistsLoaded.set(true);
fixture.detectChanges();
await fixture.whenStable();
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(2);
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
'all',
undefined,
undefined
);
});
});
@@ -13,17 +13,25 @@ import { MatIconButton } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
clearNavigationStateKeys,
CollectionContentType,
CollectionScope,
getOpenLiveCollectionItemState,
isWorkspaceLayoutRoute,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
queryParamSignal,
ScopeToggleService,
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
@@ -48,9 +56,14 @@ export class UnifiedCollectionPageComponent implements OnInit {
readonly defaultScope = input<CollectionScope>();
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly scopeService = inject(ScopeToggleService);
private readonly favoritesData = inject(UnifiedFavoritesDataService);
private readonly recentData = inject(UnifiedRecentDataService);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
private readonly playlistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
@@ -64,6 +77,9 @@ export class UnifiedCollectionPageComponent implements OnInit {
readonly isLoading = signal(true);
readonly allItems = signal<UnifiedCollectionItem[]>([]);
readonly selectedContentType = signal<CollectionContentType>('live');
readonly pendingAutoOpenLiveItem = signal(
getOpenLiveCollectionItemState(window.history.state)
);
readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i);
readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i);
@@ -118,12 +134,35 @@ export class UnifiedCollectionPageComponent implements OnInit {
: 'PORTALS.RECENTLY_VIEWED';
});
private readonly favoritesReloadKey = computed(() => {
if (this.mode() !== 'favorites') {
return 'recent';
}
if (!this.playlistsLoaded()) {
return null;
}
return this.playlists()
.map((playlist) =>
[
playlist._id,
playlist.serverUrl ? 'xtream' : playlist.macAddress ? 'stalker' : 'm3u',
JSON.stringify(playlist.favorites ?? []),
].join('::')
)
.join('|');
});
private loadRequestId = 0;
constructor() {
effect(() => {
this.mode();
this.portalType();
this.playlistId();
this.effectiveScope();
this.favoritesReloadKey();
void this.loadData();
});
}
@@ -205,7 +244,11 @@ export class UnifiedCollectionPageComponent implements OnInit {
}
private async loadData(): Promise<void> {
this.isLoading.set(true);
const requestId = ++this.loadRequestId;
if (this.allItems().length === 0) {
this.isLoading.set(true);
}
try {
const s = this.effectiveScope();
const pid = this.playlistId();
@@ -214,12 +257,26 @@ export class UnifiedCollectionPageComponent implements OnInit {
this.mode() === 'favorites'
? await this.favoritesData.getFavorites(s, pid, pt)
: await this.recentData.getRecentItems(s, pid, pt);
if (requestId !== this.loadRequestId) {
return;
}
this.allItems.set(items);
this.autoSelectContentType();
if (
this.pendingAutoOpenLiveItem() &&
items.some((item) => item.contentType === 'live')
) {
this.selectedContentType.set('live');
}
} catch {
if (requestId !== this.loadRequestId) {
return;
}
this.allItems.set([]);
} finally {
this.isLoading.set(false);
if (requestId === this.loadRequestId) {
this.isLoading.set(false);
}
}
}
@@ -229,4 +286,9 @@ export class UnifiedCollectionPageComponent implements OnInit {
this.selectedContentType.set(types[0]);
}
}
onLiveAutoOpenHandled(): void {
this.pendingAutoOpenLiveItem.set(null);
clearNavigationStateKeys([OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]);
}
}
@@ -12,6 +12,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import {
CollectionContentType,
getUnifiedCollectionNavigation,
STALKER_RETURN_TO_STATE_KEY,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { ContentCardComponent } from '../content-card/content-card.component';
@@ -51,8 +52,16 @@ export class UnifiedGridTabComponent {
return;
}
const state =
item.sourceType === 'stalker' && item.contentType !== 'live'
? {
...(navigation.state ?? {}),
[STALKER_RETURN_TO_STATE_KEY]: this.router.url,
}
: navigation.state;
void this.router.navigate(navigation.link, {
state: navigation.state,
state,
});
}
@@ -13,6 +13,8 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
matchesOpenLiveCollectionItem,
OpenLiveCollectionItemState,
PORTAL_PLAYER,
ResolvedLiveCollectionDetail,
StreamResolverService,
@@ -54,10 +56,12 @@ export class UnifiedLiveTabComponent {
readonly items = input.required<UnifiedCollectionItem[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly autoOpenItem = input<OpenLiveCollectionItemState | null>(null);
readonly removeItem = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
private readonly streamResolver = inject(StreamResolverService);
private readonly recentData = inject(UnifiedRecentDataService);
@@ -166,6 +170,28 @@ export class UnifiedLiveTabComponent {
}
});
effect(() => {
const target = this.autoOpenItem();
const items = this.items();
if (!target || items.length === 0) {
return;
}
const matchedItem = items.find((item) =>
matchesOpenLiveCollectionItem(item, target)
);
if (!matchedItem) {
return;
}
if (this.activeUid() === matchedItem.uid && this.activeDetail()) {
this.autoOpenHandled.emit();
return;
}
void this.activateItem(matchedItem, true);
});
const tickInterval = setInterval(
() => this.progressTick.update((tick) => tick + 1),
30_000
@@ -178,9 +204,47 @@ export class UnifiedLiveTabComponent {
if (!item) {
return;
}
await this.activateItem(item);
}
if (this.activeUid() === item.uid) {
this.onClose();
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
const item = this.items().find((candidate) => candidate.uid === channel.uid);
if (item) {
this.removeItem.emit(item);
}
}
onReorder(channels: UnifiedFavoriteChannel[]): void {
const reordered = channels
.map((channel) =>
this.items().find((candidate) => candidate.uid === channel.uid)
)
.filter(Boolean) as UnifiedCollectionItem[];
this.reorderItems.emit(reordered);
}
onClose(): void {
this.selectionRequestId += 1;
this.isSelecting.set(false);
this.activeDetail.set(null);
this.activeUid.set(null);
}
private async loadEpgMap(
items: UnifiedCollectionItem[]
): Promise<void> {
const epgMap = await this.streamResolver.loadEpgForItems(items);
this.epgMap.set(epgMap);
}
private async activateItem(
item: UnifiedCollectionItem,
isAutoOpen = false
): Promise<void> {
if (this.activeUid() === item.uid && this.activeDetail()) {
if (isAutoOpen) {
this.autoOpenHandled.emit();
}
return;
}
@@ -217,6 +281,10 @@ export class UnifiedLiveTabComponent {
} catch {
// Keep playback/EPG visible even if history persistence fails.
}
if (requestId === this.selectionRequestId && isAutoOpen) {
this.autoOpenHandled.emit();
}
} catch {
if (requestId === this.selectionRequestId) {
this.activeDetail.set(null);
@@ -229,36 +297,6 @@ export class UnifiedLiveTabComponent {
}
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
const item = this.items().find((candidate) => candidate.uid === channel.uid);
if (item) {
this.removeItem.emit(item);
}
}
onReorder(channels: UnifiedFavoriteChannel[]): void {
const reordered = channels
.map((channel) =>
this.items().find((candidate) => candidate.uid === channel.uid)
)
.filter(Boolean) as UnifiedCollectionItem[];
this.reorderItems.emit(reordered);
}
onClose(): void {
this.selectionRequestId += 1;
this.isSelecting.set(false);
this.activeDetail.set(null);
this.activeUid.set(null);
}
private async loadEpgMap(
items: UnifiedCollectionItem[]
): Promise<void> {
const epgMap = await this.streamResolver.loadEpgForItems(items);
this.epgMap.set(epgMap);
}
private async hydrateSelectedM3uPrograms(
item: UnifiedCollectionItem,
detail: ResolvedLiveCollectionDetail,
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
@@ -217,6 +217,91 @@ describe('StreamResolverService', () => {
'https://xtream.example.com/live/1'
);
expect(detail.epgItems).toHaveLength(1);
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
{
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
},
1,
10,
{
suppressErrorLog: true,
}
);
});
it('reuses cached empty Xtream preview EPG results instead of refetching immediately', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamApi.getShortEpg.mockResolvedValue([]);
const items = [
{
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
} satisfies UnifiedCollectionItem,
];
await service.loadEpgForItems(items);
await service.loadEpgForItems(items);
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
{
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
},
1,
2,
{
suppressErrorLog: true,
}
);
});
it('backs off repeated Xtream detail EPG failures during the cooldown window', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamUrl.constructLiveUrl.mockReturnValue(
'https://xtream.example.com/live/1'
);
xtreamApi.getShortEpg.mockRejectedValue(new Error('EPG failed'));
const item = {
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
} satisfies UnifiedCollectionItem;
const firstDetail = await service.resolveLiveDetail(item);
const secondDetail = await service.resolveLiveDetail(item);
expect(firstDetail.epgItems).toEqual([]);
expect(secondDetail.epgItems).toEqual([]);
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
});
it('loads current Stalker EPG previews for live collection rows', async () => {
@@ -40,6 +40,11 @@ interface StalkerEpgResponse {
readonly js?: StalkerEpgEntry[] | { readonly data?: StalkerEpgEntry[] };
}
interface XtreamEpgCacheEntry {
readonly data: EpgItem[];
readonly timestamp: number;
}
export interface ResolvedLiveCollectionDetail {
readonly playback: ResolvedPortalPlayback;
readonly epgMode: 'm3u' | 'portal';
@@ -56,6 +61,10 @@ export class StreamResolverService {
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly m3uEpgTimeoutMs = 3000;
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
private readonly xtreamEpgCacheTtlMs = 60 * 1000;
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
async resolvePlayback(
item: UnifiedCollectionItem
@@ -296,7 +305,12 @@ export class StreamResolverService {
return [];
}
return await this.xtreamApi.getShortEpg(creds, item.xtreamId, 10);
return await this.fetchXtreamEpgItems(
item.playlistId,
creds,
item.xtreamId,
10
);
} catch {
return [];
}
@@ -440,7 +454,8 @@ export class StreamResolverService {
}
try {
const items = await this.xtreamApi.getShortEpg(
const items = await this.fetchXtreamEpgItems(
playlistId,
creds,
channel.xtreamId,
2
@@ -485,6 +500,88 @@ export class StreamResolverService {
);
}
private getXtreamEpgCacheKey(
playlistId: string,
streamId: number,
limit: number
): string {
return `${playlistId}:${streamId}:${limit}`;
}
private getCachedXtreamEpgItems(cacheKey: string): EpgItem[] | null {
const entry = this.xtreamEpgCache.get(cacheKey);
if (!entry) {
return null;
}
if (Date.now() - entry.timestamp > this.xtreamEpgCacheTtlMs) {
this.xtreamEpgCache.delete(cacheKey);
return null;
}
return entry.data;
}
private isXtreamEpgFailureCoolingDown(cacheKey: string): boolean {
const timestamp = this.xtreamEpgFailureTimestamps.get(cacheKey);
if (timestamp == null) {
return false;
}
if (Date.now() - timestamp > this.xtreamEpgFailureCooldownMs) {
this.xtreamEpgFailureTimestamps.delete(cacheKey);
return false;
}
return true;
}
private async fetchXtreamEpgItems(
playlistId: string,
credentials: {
serverUrl: string;
username: string;
password: string;
},
streamId: number,
limit: number
): Promise<EpgItem[]> {
const cacheKey = this.getXtreamEpgCacheKey(
playlistId,
streamId,
limit
);
const cached = this.getCachedXtreamEpgItems(cacheKey);
if (cached !== null) {
return cached;
}
if (this.isXtreamEpgFailureCoolingDown(cacheKey)) {
return [];
}
try {
const items = await this.xtreamApi.getShortEpg(
credentials,
streamId,
limit,
{
suppressErrorLog: true,
}
);
this.xtreamEpgCache.set(cacheKey, {
data: items,
timestamp: Date.now(),
});
this.xtreamEpgFailureTimestamps.delete(cacheKey);
return items;
} catch {
this.xtreamEpgFailureTimestamps.set(cacheKey, Date.now());
return [];
}
}
private async loadStalkerEpgBatch(
playlistId: string,
channels: UnifiedCollectionItem[],
@@ -14,6 +14,10 @@ import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
describe('UnifiedFavoritesDataService', () => {
let service: UnifiedFavoritesDataService;
let databaseService: {
getAllGlobalFavorites: jest.Mock;
getFavorites: jest.Mock;
};
let playlistsService: {
getPlaylistById: jest.Mock;
setFavorites: jest.Mock;
@@ -75,11 +79,22 @@ describe('UnifiedFavoritesDataService', () => {
];
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {
dbGetAllGlobalFavorites: jest.fn(),
} as Window['electron'],
configurable: true,
});
playlistsService = {
getPlaylistById: jest.fn(),
setFavorites: jest.fn().mockReturnValue(of({})),
setPortalFavorites: jest.fn().mockReturnValue(of({})),
};
databaseService = {
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
};
TestBed.configureTestingModule({
providers: [
@@ -109,10 +124,7 @@ describe('UnifiedFavoritesDataService', () => {
},
{
provide: DatabaseService,
useValue: {
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
},
useValue: databaseService,
},
{
provide: PlaylistsService,
@@ -130,6 +142,72 @@ describe('UnifiedFavoritesDataService', () => {
service = TestBed.inject(UnifiedFavoritesDataService);
});
it('maps global Xtream favorites across live, movie, and series content types', async () => {
databaseService.getAllGlobalFavorites.mockResolvedValue([
{
id: 10,
category_id: 1,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 101,
title: 'Live One',
type: 'live',
poster_url: 'live.png',
added_at: '2026-03-26T09:00:00.000Z',
position: 0,
},
{
id: 11,
category_id: 2,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 102,
title: 'Movie One',
type: 'movie',
poster_url: 'movie.png',
added_at: '2026-03-26T08:00:00.000Z',
position: 1,
},
{
id: 12,
category_id: 3,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 103,
title: 'Series One',
type: 'series',
poster_url: 'series.png',
added_at: '2026-03-26T07:00:00.000Z',
position: 2,
},
]);
const items = await service.getFavorites('all');
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Live One',
contentType: 'live',
logo: 'live.png',
posterUrl: null,
}),
expect.objectContaining({
name: 'Movie One',
contentType: 'movie',
logo: null,
posterUrl: 'movie.png',
}),
expect.objectContaining({
name: 'Series One',
contentType: 'series',
logo: null,
posterUrl: 'series.png',
}),
])
);
});
it('preserves persisted M3U favorites order when extracting playlist favorites', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
@@ -1,8 +1,16 @@
import {
buildStalkerStateItem,
buildXtreamItemLink,
getGlobalFavoriteNavigation,
getOpenLiveCollectionItemState,
getOpenStalkerItemState,
getRecentItemNavigation,
getStalkerReturnToState,
getUnifiedCollectionNavigation,
matchesOpenLiveCollectionItem,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
OPEN_STALKER_ITEM_STATE_KEY,
STALKER_RETURN_TO_STATE_KEY,
} from './workspace-portal-navigation';
describe('workspace-portal-navigation', () => {
@@ -35,49 +43,171 @@ describe('workspace-portal-navigation', () => {
]);
});
it('keeps M3U favorites on the playlist favorites route', () => {
expect(
getGlobalFavoriteNavigation({
id: 'channel-1',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
category_id: 'live',
xtream_id: 'channel-1',
source: 'm3u',
poster_url: 'https://example.com/logo.png',
added_at: '2026-03-01T00:00:00.000Z',
})
).toEqual({
link: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
it('routes live M3U favorites to the playlist favorites collection with auto-open state', () => {
const navigation = getGlobalFavoriteNavigation({
id: 'channel-1',
title: 'Channel One',
type: 'live',
playlist_id: 'm3u-1',
category_id: 'live',
xtream_id: 'channel-1',
source: 'm3u',
poster_url: 'https://example.com/logo.png',
added_at: '2026-03-01T00:00:00.000Z',
});
expect(navigation).toEqual({
link: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
state: {
openLiveCollectionItem: {
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
itemId: 'channel-1',
title: 'Channel One',
imageUrl: 'https://example.com/logo.png',
},
},
});
expect(
getOpenLiveCollectionItemState(navigation.state)
).toEqual(navigation.state?.[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]);
});
it('builds recent-item state for Stalker recent entries', () => {
expect(
getRecentItemNavigation({
id: 'stalker-1',
title: 'Movie One',
it('routes Stalker movie recents into the source category route with detail state', () => {
const navigation =
getGlobalFavoriteNavigation({
id: 'movie-7',
title: 'Movie Seven',
type: 'movie',
playlist_id: 'stalker-1',
category_id: 'vod',
xtream_id: 'stalker-1',
category_id: '17',
xtream_id: 'movie-7',
source: 'stalker',
poster_url: 'https://example.com/poster.png',
added_at: '2026-03-01T00:00:00.000Z',
});
expect(navigation).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'vod', '17'],
state: {
openStalkerItem: expect.objectContaining({
category_id: '17',
id: 'movie-7',
title: 'Movie Seven',
}),
stalkerReturnTo: '/workspace/dashboard',
},
});
expect(getOpenStalkerItemState(navigation.state)).toEqual(
navigation.state?.[OPEN_STALKER_ITEM_STATE_KEY]
);
expect(getStalkerReturnToState(navigation.state)).toBe(
navigation.state?.[STALKER_RETURN_TO_STATE_KEY]
);
});
it('routes Stalker live recents to the collection route with auto-open state', () => {
const navigation =
getRecentItemNavigation({
id: 'stalker-live-1',
title: 'Live One',
type: 'live',
playlist_id: 'stalker-1',
category_id: 'itv',
xtream_id: 'stalker-live-1',
source: 'stalker',
poster_url: 'https://example.com/poster.png',
viewed_at: '2026-03-01T00:00:00.000Z',
})
).toEqual({
});
expect(navigation).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'recent'],
state: {
openRecentItem: expect.objectContaining({
category_id: 'vod',
id: 'stalker-1',
title: 'Movie One',
openLiveCollectionItem: {
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
itemId: 'stalker-live-1',
title: 'Live One',
imageUrl: 'https://example.com/poster.png',
},
},
});
});
it('matches collection live state against multiple live item identifiers', () => {
expect(
matchesOpenLiveCollectionItem(
{
uid: 'm3u::m3u-1::https://example.com/live.m3u8',
name: 'Channel One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U Playlist',
streamUrl: 'https://example.com/live.m3u8',
channelId: 'channel-1',
},
{
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
itemId: 'channel-1',
title: 'Channel One',
}
)
).toBe(true);
});
it('builds unified Stalker grid navigation with detail state', () => {
expect(
getUnifiedCollectionNavigation({
uid: 'stalker::stalker-1::series-9',
name: 'Series Nine',
contentType: 'series',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker Playlist',
stalkerId: 'series-9',
categoryId: '44',
posterUrl: 'https://example.com/poster.png',
})
).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'series', '44'],
state: {
openStalkerItem: expect.objectContaining({
category_id: '44',
id: 'series-9',
title: 'Series Nine',
}),
},
});
});
it('preserves concrete Stalker category ids when building detail state', () => {
expect(
buildStalkerStateItem(
{
id: '77',
category_id: '101',
title: 'Concrete Category Movie',
},
{
id: '77',
title: 'Concrete Category Movie',
type: 'movie',
}
)
).toEqual(
expect.objectContaining({
id: '77',
category_id: '101',
title: 'Concrete Category Movie',
})
);
});
it('builds unified Xtream grid navigation from category-aware items', () => {
expect(
getUnifiedCollectionNavigation({
@@ -4,32 +4,51 @@ import {
PortalRecentItem,
StalkerPortalItem,
} from 'shared-interfaces';
import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface';
import {
CollectionSourceType,
UnifiedCollectionItem,
} from '../collection/unified-collection-item.interface';
export interface WorkspaceNavigationTarget {
link: string[];
state?: Record<string, unknown>;
}
export const OPEN_LIVE_COLLECTION_ITEM_STATE_KEY = 'openLiveCollectionItem';
export const OPEN_STALKER_ITEM_STATE_KEY = 'openStalkerItem';
export const STALKER_RETURN_TO_STATE_KEY = 'stalkerReturnTo';
export interface OpenLiveCollectionItemState {
contentType: 'live';
sourceType: CollectionSourceType;
playlistId: string;
itemId: string;
title?: string;
imageUrl?: string | null;
}
export function getRecentItemNavigation(
item: PortalRecentItem
): WorkspaceNavigationTarget {
if (item.source === 'stalker') {
return {
link: ['/workspace', 'stalker', item.playlist_id, 'recent'],
state: {
openRecentItem: buildStalkerStateItem(item.stalker_item, item),
},
};
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'recent',
sourceType: item.source,
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
imageUrl: item.poster_url,
});
}
if (item.source === 'm3u') {
return {
link: ['/workspace', 'playlists', item.playlist_id, 'recent'],
state: {
openRecentChannelUrl: String(item.xtream_id ?? item.id ?? ''),
},
};
if (item.source === 'stalker') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, item),
returnTo: '/workspace/dashboard',
});
}
return buildXtreamNavigationTarget({
@@ -45,22 +64,25 @@ export function getRecentItemNavigation(
export function getGlobalFavoriteNavigation(
item: PortalFavoriteItem
): WorkspaceNavigationTarget {
if (item.source === 'stalker') {
return {
link: ['/workspace', 'stalker', item.playlist_id, 'favorites'],
state: {
openFavoriteItem: buildStalkerStateItem(
item.stalker_item,
item
),
},
};
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'favorites',
sourceType: item.source,
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
imageUrl: item.poster_url,
});
}
if (item.source === 'm3u') {
return {
link: ['/workspace', 'playlists', item.playlist_id, 'favorites'],
};
if (item.source === 'stalker') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, item),
returnTo: '/workspace/dashboard',
});
}
return buildXtreamNavigationTarget({
@@ -103,6 +125,82 @@ export function buildXtreamNavigationTarget(params: {
};
}
export function buildLiveCollectionNavigationTarget(params: {
mode: 'favorites' | 'recent';
sourceType: CollectionSourceType;
playlistId: string;
itemId?: string | number | null;
title?: string;
imageUrl?: string | null;
}): WorkspaceNavigationTarget {
return {
link: buildCollectionRoute(params.sourceType, params.playlistId, params.mode),
state: {
[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]: buildOpenLiveCollectionItemState(
params
),
},
};
}
export function buildOpenLiveCollectionItemState(params: {
sourceType: CollectionSourceType;
playlistId: string;
itemId?: string | number | null;
title?: string;
imageUrl?: string | null;
}): OpenLiveCollectionItemState {
return {
contentType: 'live',
sourceType: params.sourceType,
playlistId: params.playlistId,
itemId: toPathSegment(params.itemId),
title: params.title,
imageUrl: params.imageUrl,
};
}
export function getOpenLiveCollectionItemState(
state: unknown
): OpenLiveCollectionItemState | null {
const record = toStateRecord(state);
const candidate = record?.[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY];
if (!candidate || typeof candidate !== 'object') {
return null;
}
const sourceType = toPathSegment(
(candidate as Record<string, unknown>)['sourceType']
) as CollectionSourceType;
const playlistId = toPathSegment(
(candidate as Record<string, unknown>)['playlistId']
);
const itemId = toPathSegment((candidate as Record<string, unknown>)['itemId']);
if (
!playlistId ||
!itemId ||
(sourceType !== 'm3u' &&
sourceType !== 'xtream' &&
sourceType !== 'stalker')
) {
return null;
}
return {
contentType: 'live',
sourceType,
playlistId,
itemId,
title: toOptionalPathSegment(
(candidate as Record<string, unknown>)['title']
),
imageUrl: toOptionalPathSegment(
(candidate as Record<string, unknown>)['imageUrl']
),
};
}
export function buildXtreamItemLink(params: {
playlistId: string;
type: PortalActivityType;
@@ -156,13 +254,165 @@ export function getUnifiedCollectionNavigation(
}
if (item.sourceType === 'stalker') {
const section = item.contentType === 'movie' ? 'vod' : item.contentType;
const categoryId = toPathSegment(item.categoryId);
const link = categoryId
? ['/workspace', 'stalker', item.playlistId, section, categoryId]
: ['/workspace', 'stalker', item.playlistId, section];
if (item.contentType === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'favorites',
sourceType: item.sourceType,
playlistId: item.playlistId,
itemId: item.stalkerId,
title: item.name,
imageUrl: item.logo ?? item.posterUrl ?? null,
});
}
return { link };
return buildStalkerDetailNavigationTarget({
playlistId: item.playlistId,
type: item.contentType,
categoryId: item.categoryId,
item: buildStalkerStateItem(
item.stalkerItem as StalkerPortalItem | undefined,
{
id:
item.stalkerId ??
getLastSegment(item.uid, '::') ??
'',
title: item.name,
type: item.contentType,
category_id: item.categoryId,
poster_url: item.posterUrl ?? item.logo ?? undefined,
}
),
});
}
return null;
}
export function buildStalkerDetailNavigationTarget(params: {
playlistId: string;
type: Exclude<PortalActivityType, 'live'>;
categoryId?: string | number | null;
item: Record<string, unknown>;
returnTo?: string | string[] | null;
}): WorkspaceNavigationTarget {
const section = params.type === 'movie' ? 'vod' : 'series';
const normalizedCategoryId = toPathSegment(
params.categoryId ?? toStalkerCategoryId(params.type)
);
const link = normalizedCategoryId
? ['/workspace', 'stalker', params.playlistId, section, normalizedCategoryId]
: ['/workspace', 'stalker', params.playlistId, section];
const state: Record<string, unknown> = {
[OPEN_STALKER_ITEM_STATE_KEY]: params.item,
};
const returnTo = normalizeReturnToState(params.returnTo);
if (returnTo) {
state[STALKER_RETURN_TO_STATE_KEY] = returnTo;
}
return {
link,
state,
};
}
export function getOpenStalkerItemState(
state: unknown
): Record<string, unknown> | null {
const record = toStateRecord(state);
const candidate =
record?.[OPEN_STALKER_ITEM_STATE_KEY] ??
record?.['openFavoriteItem'] ??
record?.['openRecentItem'];
return candidate && typeof candidate === 'object'
? (candidate as Record<string, unknown>)
: null;
}
export function getStalkerReturnToState(state: unknown): string | null {
const record = toStateRecord(state);
const candidate = record?.[STALKER_RETURN_TO_STATE_KEY];
return typeof candidate === 'string' && candidate.trim().length > 0
? candidate.trim()
: null;
}
export function clearNavigationStateKeys(keys: string[]): void {
try {
const state = toStateRecord(window.history.state);
if (!state) {
return;
}
const nextState = { ...state };
let changed = false;
keys.forEach((key) => {
if (key in nextState) {
delete nextState[key];
changed = true;
}
});
if (changed) {
window.history.replaceState(nextState, document.title);
}
} catch {
// no-op
}
}
export function matchesOpenLiveCollectionItem(
item: UnifiedCollectionItem,
target: OpenLiveCollectionItemState
): boolean {
if (
item.contentType !== 'live' ||
item.sourceType !== target.sourceType ||
item.playlistId !== target.playlistId
) {
return false;
}
const targetId = toPathSegment(target.itemId);
const sourceItemId = getLastSegment(item.uid, '::') ?? '';
const candidates = [
sourceItemId,
item.streamUrl,
item.channelId,
item.xtreamId,
item.stalkerId,
item.contentId,
]
.map((value) => toPathSegment(value))
.filter(Boolean);
if (candidates.includes(targetId)) {
return true;
}
const normalizedTargetTitle = target.title?.trim().toLowerCase();
return Boolean(
normalizedTargetTitle &&
item.name.trim().toLowerCase() === normalizedTargetTitle
);
}
function getLastSegment(value: string, separator: string): string | undefined {
const segments = value.split(separator);
return segments.length > 0 ? segments[segments.length - 1] : undefined;
}
function normalizeReturnToState(
value: string | string[] | null | undefined
): string | null {
if (Array.isArray(value)) {
const normalized = value.join('/');
return normalized.trim() ? normalized : null;
}
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
return null;
@@ -174,12 +424,14 @@ export function buildStalkerStateItem(
id: string | number;
title: string;
type: PortalActivityType;
category_id?: string | number | null;
poster_url?: string;
}
): Record<string, unknown> {
const normalizedCategory = toStalkerCategoryId(
rawItem?.category_id ?? fallback.type
);
const normalizedCategory =
toOptionalPathSegment(rawItem?.category_id) ??
toOptionalPathSegment(fallback.category_id) ??
toStalkerCategoryId(fallback.type);
if (rawItem) {
return {
...(rawItem as Record<string, unknown>),
@@ -215,3 +467,32 @@ export function toStalkerCategoryId(value: unknown): 'vod' | 'series' | 'itv' {
function toPathSegment(value: unknown): string {
return String(value ?? '').trim();
}
function toOptionalPathSegment(value: unknown): string | undefined {
const normalized = toPathSegment(value);
return normalized ? normalized : undefined;
}
function buildCollectionRoute(
sourceType: CollectionSourceType,
playlistId: string,
mode: 'favorites' | 'recent'
): string[] {
if (sourceType === 'xtream') {
return ['/workspace', 'xtreams', playlistId, mode];
}
if (sourceType === 'stalker') {
return ['/workspace', 'stalker', playlistId, mode];
}
return ['/workspace', 'playlists', playlistId, mode];
}
function toStateRecord(
state: unknown
): Record<string, unknown> | null {
return state && typeof state === 'object'
? (state as Record<string, unknown>)
: null;
}
@@ -1,6 +1,7 @@
import { VodDetailsItem } from 'shared-interfaces';
import { StalkerFavoriteItem } from './models';
import {
buildStalkerFavoritePayload,
createStalkerInlineDetailState,
createStalkerDetailViewState,
normalizeStalkerFavoriteItem,
@@ -110,6 +111,32 @@ describe('stalker-vod.utils regressions', () => {
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('preserves the concrete category id in favorite payloads', () => {
expect(
buildStalkerFavoritePayload({
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
category_id: '17',
} as unknown as Parameters<typeof buildStalkerFavoritePayload>[0])
).toEqual(
expect.objectContaining({
id: '42',
category_id: '17',
})
);
});
it('normalizes nested favorite details for the shared inline detail shell', () => {
const detailState = createStalkerInlineDetailState(
normalizeStalkerFavoriteItem({
@@ -73,9 +73,14 @@ export function isStalkerSeriesItem(item: {
export function buildStalkerFavoritePayload(
data: StalkerVodDetails
): Record<string, unknown> {
const categoryId = String(
(data as StalkerVodDetails & { category_id?: string | number })
.category_id ?? 'vod'
).trim();
return {
...data,
category_id: 'vod',
category_id: categoryId || 'vod',
title: data.info?.name,
cover: data.info?.movie_image,
added_at: new Date().toISOString(),
@@ -17,6 +17,14 @@ type FavoritePayload = StalkerPortalItem & {
id?: string | number;
};
function resolveCategoryId(
value: unknown,
fallback: string
): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
/**
* Favorites concern methods.
*/
@@ -37,7 +45,10 @@ export function withStalkerFavorites() {
playlistService
.addPortalFavorite(portalId, {
...item,
category_id: storeContext.selectedContentType(),
category_id: resolveCategoryId(
item.category_id,
storeContext.selectedContentType()
),
added_at: Date.now(),
id: item.stream_id ?? item.id,
})
@@ -58,6 +58,10 @@ interface StalkerResponse {
*/
export function withStalkerPlayer() {
const logger = createLogger('withStalkerPlayer');
const resolveCategoryId = (value: unknown, fallback: string): string => {
const normalized = String(value ?? '').trim();
return normalized || fallback;
};
return signalStoreFeature(
withMethods(
(
@@ -404,7 +408,10 @@ export function withStalkerPlayer() {
cmd,
cover,
title,
category_id: storeState.selectedContentType(),
category_id: resolveCategoryId(
item.category_id,
storeState.selectedContentType()
),
added_at: Date.now(),
};
playlistService
@@ -16,6 +16,14 @@ type RecentlyViewedPayload = StalkerPortalItem & {
title?: string;
};
function resolveCategoryId(
value: unknown,
fallback: string
): string {
const normalized = String(value ?? '').trim();
return normalized || fallback;
}
/**
* Recently-viewed concern methods.
*/
@@ -35,7 +43,10 @@ export function withStalkerRecent() {
const portalId = storeContext.currentPlaylist()?._id;
const recentItem = {
...item,
category_id: storeContext.selectedContentType(),
category_id: resolveCategoryId(
item.category_id,
storeContext.selectedContentType()
),
added_at: Date.now(),
id: item.id ?? item.stream_id ?? '',
title:
@@ -7,8 +7,10 @@ import {
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import {
getStalkerReturnToState,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -63,6 +65,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly catalog = inject(StalkerCatalogFacadeService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly router = inject(Router);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
@@ -206,8 +209,13 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
onVodBack(): void {
const returnTo = getStalkerReturnToState(window.history.state);
this.closeInlinePlayer();
this.catalog.clearSelectedItem();
if (returnTo) {
void this.router.navigateByUrl(returnTo);
}
}
handleInlineTimeUpdate(event: {
@@ -9,6 +9,7 @@ import {
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import {
@@ -26,6 +27,7 @@ import {
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
getStalkerReturnToState,
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
@@ -68,6 +70,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly router = inject(Router);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly downloadsService = inject(DownloadsService);
private readonly snackBar = inject(MatSnackBar);
@@ -415,9 +418,14 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
goBack() {
const returnTo = getStalkerReturnToState(window.history.state);
this.closeInlinePlayer();
this.backClicked.emit();
this.stalkerStore.clearSelectedItem();
if (returnTo) {
void this.router.navigateByUrl(returnTo);
}
}
toSeriesId(id: string | number): number {
@@ -1,5 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { DatabaseService, PlaybackPositionService } from 'services';
import {
DatabaseService,
PlaybackPositionService,
XtreamImportStatus,
} from 'services';
import {
PlaybackPositionData,
PlaylistMeta,
@@ -43,6 +47,26 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
Promise<XtreamContentItem[]>
>();
private mapCategoryTypeToImportType(
type: CategoryType
): 'live' | 'movie' | 'series' {
switch (type) {
case 'live':
return 'live';
case 'vod':
return 'movie';
case 'series':
return 'series';
}
}
private async getImportStatus(
playlistId: string,
type: 'live' | 'movie' | 'series'
): Promise<XtreamImportStatus> {
return this.dbService.getXtreamImportStatus(playlistId, type);
}
// =========================================================================
// Playlist Operations
// =========================================================================
@@ -93,7 +117,8 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
async getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
const dbType = mapCategoryTypeToDbType(type);
const requestKey = `${playlistId}:${dbType}`;
@@ -103,8 +128,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return inFlightRequest;
}
const request = this.loadCategories(playlistId, credentials, type, dbType)
.finally(() => {
const request = this.loadCategories(
playlistId,
credentials,
type,
dbType,
options
).finally(() => {
this.categoryRequests.delete(requestKey);
});
@@ -116,23 +146,29 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType,
dbType: DbCategoryType
dbType: DbCategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
const importType = this.mapCategoryTypeToImportType(type);
const importStatus = await this.getImportStatus(playlistId, importType);
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
const cached = await this.dbService.getXtreamCategories(
playlistId,
dbType
);
if (cached.length > 0) {
if (importStatus === 'completed' && cached.length > 0) {
return cached;
}
// Fetch from API and cache
options?.onPhaseChange?.('loading-categories');
const remoteData = await this.apiService.getCategories(
credentials,
type
type,
{
sessionId: options?.sessionId,
}
);
if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) {
@@ -142,6 +178,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
dbType
);
options?.onPhaseChange?.('saving-categories');
await this.dbService.saveXtreamCategories(
playlistId,
remoteData,
@@ -259,15 +296,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
const importStatus = await this.getImportStatus(playlistId, type);
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
const cached = await this.dbService.getXtreamContent(playlistId, type);
if (cached.length > 0) {
if (importStatus === 'completed' && cached.length > 0) {
return cached;
}
// Fetch from API
const remoteData = await this.apiService.getStreams(credentials, type);
options?.onPhaseChange?.(
type === 'live'
? 'loading-live'
: type === 'movie'
? 'loading-movies'
: 'loading-series'
);
const remoteData = await this.apiService.getStreams(credentials, type, {
sessionId: options?.sessionId,
});
if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) {
// Report total items to import
@@ -136,8 +136,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategory[]> {
void options;
const cacheKey = `${playlistId}-${type}-categories`;
// Check in-memory cache first
@@ -120,7 +120,9 @@ export type ProgressCallback = (count: number) => void;
export interface XtreamOperationOptions {
operationId?: string;
sessionId?: string;
onEvent?: (event: DbOperationEvent) => void;
onPhaseChange?: (phase: string) => void;
}
/**
@@ -171,7 +173,8 @@ export interface IXtreamDataSource {
getCategories(
playlistId: string,
credentials: XtreamCredentials,
type: CategoryType
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategory[] | XtreamCategoryFromDb[]>;
/**
@@ -0,0 +1,85 @@
import { TestBed } from '@angular/core/testing';
import { EpgQueueService } from './epg-queue.service';
import { XtreamApiService } from './xtream-api.service';
describe('EpgQueueService', () => {
let service: EpgQueueService;
let xtreamApi: { getShortEpg: jest.Mock };
const credentials = {
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
};
beforeEach(() => {
jest.useFakeTimers();
xtreamApi = {
getShortEpg: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
EpgQueueService,
{
provide: XtreamApiService,
useValue: xtreamApi,
},
],
});
service = TestBed.inject(EpgQueueService);
});
afterEach(() => {
jest.useRealTimers();
});
it('caches empty EPG responses and does not immediately refetch them', async () => {
xtreamApi.getShortEpg.mockResolvedValue([]);
await (service as unknown as { fetchEpg: (credentials: typeof credentials, streamId: number) => Promise<void> }).fetchEpg(
credentials,
101
);
expect(service.getCached(101)).toEqual([]);
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
credentials,
101,
3,
{
suppressErrorLog: true,
}
);
expect(
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
101
)
).toBe(false);
});
it('applies a cooldown after EPG request failures', async () => {
xtreamApi.getShortEpg.mockRejectedValue(new Error('EPG failed'));
await (service as unknown as { fetchEpg: (credentials: typeof credentials, streamId: number) => Promise<void> }).fetchEpg(
credentials,
202
);
expect(
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
202
)
).toBe(false);
jest.advanceTimersByTime(60_001);
expect(
(service as unknown as { shouldFetch: (streamId: number) => boolean }).shouldFetch(
202
)
).toBe(true);
});
});
@@ -26,10 +26,12 @@ export class EpgQueueService implements OnDestroy {
private readonly inFlight = new Set<number>();
private visibleSet = new Set<number>();
private processing = false;
private readonly failureTimestamps = new Map<number, number>();
private readonly maxConcurrency = 2;
private readonly delayMs = 200;
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes
private readonly failureCooldownMs = 60 * 1000;
/** Emits EPG results as they arrive. */
readonly epgResult$ = new Subject<{ streamId: number; items: EpgItem[] }>();
@@ -45,6 +47,28 @@ export class EpgQueueService implements OnDestroy {
return entry.data;
}
private isFailureCoolingDown(streamId: number): boolean {
const timestamp = this.failureTimestamps.get(streamId);
if (timestamp == null) {
return false;
}
if (Date.now() - timestamp > this.failureCooldownMs) {
this.failureTimestamps.delete(streamId);
return false;
}
return true;
}
private shouldFetch(streamId: number): boolean {
return (
this.getCached(streamId) === null &&
!this.isFailureCoolingDown(streamId) &&
!this.inFlight.has(streamId)
);
}
/**
* Enqueue stream IDs for EPG fetching.
*
@@ -60,9 +84,7 @@ export class EpgQueueService implements OnDestroy {
this.visibleSet = visibleIds;
const toFetch = streamIds.filter(
(id) =>
!this.getCached(id) &&
!this.inFlight.has(id)
(id) => this.shouldFetch(id)
);
// Replace the queue – any previously queued but now-invisible IDs
@@ -92,8 +114,8 @@ export class EpgQueueService implements OnDestroy {
// Drop stale entries that are no longer visible
if (!this.visibleSet.has(streamId)) continue;
// Skip if it got cached while queued (e.g. duplicate)
if (this.getCached(streamId)) continue;
// Skip if it got cached or cooled down while queued (e.g. duplicate/failure)
if (!this.shouldFetch(streamId)) continue;
this.inFlight.add(streamId);
this.fetchEpg(credentials, streamId);
@@ -113,18 +135,19 @@ export class EpgQueueService implements OnDestroy {
const items = await this.apiService.getShortEpg(
credentials,
streamId,
this.previewLimit
this.previewLimit,
{
suppressErrorLog: true,
}
);
if (items.length > 0) {
this.cache.set(streamId, {
data: items,
timestamp: Date.now(),
});
} else {
this.cache.delete(streamId);
}
this.cache.set(streamId, {
data: items,
timestamp: Date.now(),
});
this.failureTimestamps.delete(streamId);
this.epgResult$.next({ streamId, items });
} catch (error) {
this.failureTimestamps.set(streamId, Date.now());
this.logger.error(
`Failed to load EPG for stream ${streamId}`,
error
@@ -32,6 +32,12 @@ export type CategoryType = 'live' | 'vod' | 'series';
*/
export type StreamType = 'live' | 'movie' | 'series';
export interface XtreamRequestOptions {
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
}
/**
* Raw EPG listing from API (before decoding)
*/
@@ -60,11 +66,26 @@ interface EpgResponse {
export class XtreamApiService {
private readonly dataService = inject(DataService);
async cancelSession(sessionId: string): Promise<boolean> {
if (!sessionId || typeof window.electron?.xtreamCancelSession !== 'function') {
return false;
}
try {
const result = await window.electron.xtreamCancelSession(sessionId);
return result.success;
} catch (error) {
console.error('Failed to cancel Xtream session:', error);
return false;
}
}
/**
* Get account/portal status
*/
async getAccountInfo(
credentials: XtreamCredentials
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamAccountInfo> {
return this.sendRequest(
credentials.serverUrl,
@@ -72,7 +93,8 @@ export class XtreamApiService {
username: credentials.username,
password: credentials.password,
action: XtreamCodeActions.GetAccountInfo,
}
},
options
);
}
@@ -81,7 +103,8 @@ export class XtreamApiService {
*/
async getCategories(
credentials: XtreamCredentials,
type: CategoryType
type: CategoryType,
options?: XtreamRequestOptions
): Promise<XtreamCategory[]> {
const actionMap: Record<CategoryType, XtreamCodeActions> = {
live: XtreamCodeActions.GetLiveCategories,
@@ -95,7 +118,8 @@ export class XtreamApiService {
action: actionMap[type],
username: credentials.username,
password: credentials.password,
}
},
options
);
return Array.isArray(response) ? response : [];
@@ -105,7 +129,8 @@ export class XtreamApiService {
* Get live streams
*/
async getLiveStreams(
credentials: XtreamCredentials
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamLiveStream[]> {
const response = await this.sendRequest(
credentials.serverUrl,
@@ -113,7 +138,8 @@ export class XtreamApiService {
action: XtreamCodeActions.GetLiveStreams,
username: credentials.username,
password: credentials.password,
}
},
options
);
return Array.isArray(response) ? response : [];
@@ -123,7 +149,8 @@ export class XtreamApiService {
* Get VOD streams
*/
async getVodStreams(
credentials: XtreamCredentials
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamVodStream[]> {
const response = await this.sendRequest(
credentials.serverUrl,
@@ -131,7 +158,8 @@ export class XtreamApiService {
action: XtreamCodeActions.GetVodStreams,
username: credentials.username,
password: credentials.password,
}
},
options
);
return Array.isArray(response) ? response : [];
@@ -141,7 +169,8 @@ export class XtreamApiService {
* Get series items
*/
async getSeriesStreams(
credentials: XtreamCredentials
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamSerieItem[]> {
const response = await this.sendRequest(
credentials.serverUrl,
@@ -149,7 +178,8 @@ export class XtreamApiService {
action: XtreamCodeActions.GetSeries,
username: credentials.username,
password: credentials.password,
}
},
options
);
return Array.isArray(response) ? response : [];
@@ -160,15 +190,16 @@ export class XtreamApiService {
*/
async getStreams(
credentials: XtreamCredentials,
type: StreamType
type: StreamType,
options?: XtreamRequestOptions
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[]> {
switch (type) {
case 'live':
return this.getLiveStreams(credentials);
return this.getLiveStreams(credentials, options);
case 'movie':
return this.getVodStreams(credentials);
return this.getVodStreams(credentials, options);
case 'series':
return this.getSeriesStreams(credentials);
return this.getSeriesStreams(credentials, options);
}
}
@@ -177,7 +208,8 @@ export class XtreamApiService {
*/
async getVodInfo(
credentials: XtreamCredentials,
vodId: string | number
vodId: string | number,
options?: XtreamRequestOptions
): Promise<XtreamVodDetails> {
return this.sendRequest(
credentials.serverUrl,
@@ -186,7 +218,8 @@ export class XtreamApiService {
username: credentials.username,
password: credentials.password,
vod_id: vodId,
}
},
options
);
}
@@ -195,7 +228,8 @@ export class XtreamApiService {
*/
async getSeriesInfo(
credentials: XtreamCredentials,
seriesId: string | number
seriesId: string | number,
options?: XtreamRequestOptions
): Promise<XtreamSerieDetails> {
return this.sendRequest(
credentials.serverUrl,
@@ -204,7 +238,8 @@ export class XtreamApiService {
username: credentials.username,
password: credentials.password,
series_id: seriesId,
}
},
options
);
}
@@ -215,7 +250,8 @@ export class XtreamApiService {
async getShortEpg(
credentials: XtreamCredentials,
streamId: number,
limit = 10
limit = 10,
options?: XtreamRequestOptions
): Promise<EpgItem[]> {
const response: EpgResponse = await this.sendRequest(
credentials.serverUrl,
@@ -225,7 +261,8 @@ export class XtreamApiService {
password: credentials.password,
stream_id: streamId,
limit,
}
},
options
);
if (!response?.epg_listings || !Array.isArray(response.epg_listings)) {
@@ -263,7 +300,8 @@ export class XtreamApiService {
*/
private async sendRequest<TResponse>(
url: string,
params: Record<string, string | number>
params: Record<string, string | number>,
options?: XtreamRequestOptions
): Promise<TResponse> {
const serializedParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
@@ -273,6 +311,9 @@ export class XtreamApiService {
const response = (await this.dataService.sendIpcEvent(XTREAM_REQUEST, {
url,
params: serializedParams,
requestId: options?.requestId,
sessionId: options?.sessionId,
suppressErrorLog: options?.suppressErrorLog,
})) as {
message?: string;
payload?: unknown;
@@ -1,10 +1,12 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { DatabaseService } from 'services';
import {
XTREAM_DATA_SOURCE,
XtreamPlaylistData,
} from '../../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import { withContent } from './with-content.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -27,11 +29,21 @@ const PLAYLIST: XtreamPlaylistData = {
type: 'xtream',
};
let checkPortalStatusMock: jest.Mock<Promise<PortalStatusType>, []>;
const TestContentStore = signalStore(
withState({
playlistId: PLAYLIST.id,
currentPlaylist: PLAYLIST,
portalStatus: 'active' as PortalStatusType,
}),
withMethods((store) => ({
async checkPortalStatus(): Promise<PortalStatusType> {
const status = await checkPortalStatusMock();
patchState(store, { portalStatus: status });
return status;
},
})),
withContent()
);
@@ -76,22 +88,43 @@ describe('withContent import state', () => {
restoreUserData: jest.Mock;
};
let databaseService: {
clearXtreamImportCache: jest.Mock;
cancelOperation: jest.Mock;
createOperationId: jest.Mock;
setXtreamImportStatus: jest.Mock;
supportsDbOperationCancellation: jest.Mock;
};
let xtreamApiService: {
cancelSession: jest.Mock;
};
beforeEach(() => {
localStorage.clear();
let operationCounter = 0;
dataSource = {
getCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn(),
restoreUserData: jest.fn().mockResolvedValue(undefined),
};
databaseService = {
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn().mockImplementation((prefix?: string) => {
if (prefix === 'xtream-import-session') {
return 'xtream-import-session';
}
operationCounter += 1;
return `${prefix ?? 'db-op'}-${operationCounter}`;
}),
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
supportsDbOperationCancellation: jest.fn().mockReturnValue(true),
};
xtreamApiService = {
cancelSession: jest.fn().mockResolvedValue(true),
};
checkPortalStatusMock = jest.fn().mockResolvedValue('active');
TestBed.configureTestingModule({
providers: [
@@ -104,6 +137,10 @@ describe('withContent import state', () => {
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XtreamApiService,
useValue: xtreamApiService,
},
],
});
@@ -127,7 +164,7 @@ describe('withContent import state', () => {
};
const optionsByType = new Map<
ContentType,
{ onEvent?: (event: any) => void } | undefined
{ onEvent?: (event: any) => void; operationId?: string } | undefined
>();
const totals: Record<ContentType, number> = {
live: 2,
@@ -153,16 +190,17 @@ describe('withContent import state', () => {
) => {
optionsByType.set(type, options);
onTotal?.(totals[type]);
const operationId = options?.operationId ?? `${type}-op`;
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
operationId,
status: 'started',
phase: 'saving-content',
});
onProgress?.(1);
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
operationId,
status: 'progress',
phase: 'saving-content',
current: 1,
@@ -179,40 +217,174 @@ describe('withContent import state', () => {
pendingCategories.live.resolve([]);
pendingCategories.vod.resolve([]);
pendingCategories.series.resolve([]);
await waitForCondition(() => store.importCount() === 3);
await waitForCondition(() => store.importCount() === 1);
const liveOperationId =
optionsByType.get('live')?.operationId ?? 'live-op';
expect(store.isImporting()).toBe(true);
expect(store.importCount()).toBe(3);
expect(store.itemsToImport()).toBe(9);
expect(store.activeImportOperationIds().sort()).toEqual([
'live-op',
'movie-op',
'series-op',
]);
expect(store.importCount()).toBe(1);
expect(store.itemsToImport()).toBe(2);
expect(store.activeImportOperationIds()).toEqual([liveOperationId]);
expect(store.importPhase()).toBe('saving-content');
(['live', 'movie', 'series'] as ContentType[]).forEach((type) => {
optionsByType.get(type)?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
status: 'completed',
phase: 'saving-content',
current: totals[type],
total: totals[type],
});
pending[type].resolve([]);
optionsByType.get('live')?.onEvent?.({
operation: 'save-content',
operationId: liveOperationId,
status: 'completed',
phase: 'saving-content',
current: totals.live,
total: totals.live,
});
pending.live.resolve([]);
await waitForCondition(() => store.importCount() === 2);
const movieOperationId =
optionsByType.get('movie')?.operationId ?? 'movie-op';
expect(store.itemsToImport()).toBe(5);
expect(store.activeImportOperationIds()).toEqual([movieOperationId]);
optionsByType.get('movie')?.onEvent?.({
operation: 'save-content',
operationId: movieOperationId,
status: 'completed',
phase: 'saving-content',
current: totals.movie,
total: totals.movie,
});
pending.movie.resolve([]);
await waitForCondition(() => store.importCount() === 3);
const seriesOperationId =
optionsByType.get('series')?.operationId ?? 'series-op';
expect(store.itemsToImport()).toBe(9);
expect(store.activeImportOperationIds()).toEqual([seriesOperationId]);
optionsByType.get('series')?.onEvent?.({
operation: 'save-content',
operationId: seriesOperationId,
status: 'completed',
phase: 'saving-content',
current: totals.series,
total: totals.series,
});
pending.series.resolve([]);
await initialization;
expect(store.isImporting()).toBe(false);
expect(store.isContentInitialized()).toBe(true);
expect(store.contentInitBlockReason()).toBeNull();
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
expect(store.activeImportOperationIds()).toEqual([]);
expect(store.importPhase()).toBeNull();
expect(store.importCount()).toBe(0);
expect(store.itemsToImport()).toBe(0);
});
it('marks content types ready and patches their streams as each import completes', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
vod: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
const pending = {
live: createDeferred<any[]>(),
movie: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
const liveItems = [
{
xtream_id: 101,
category_id: 11,
title: 'Live One',
},
];
const vodItems = [
{
xtream_id: 202,
category_id: 22,
title: 'Movie One',
},
];
const seriesItems = [
{
xtream_id: 303,
category_id: 33,
title: 'Series One',
},
];
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: 'live' | 'vod' | 'series'
) => pendingCategories[type].promise
);
dataSource.getContent.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: ContentType
) => pending[type].promise
);
const initialization = store.initializeContent();
await Promise.resolve();
expect(store.contentLoadStateByType()).toEqual({
live: 'loading',
vod: 'loading',
series: 'loading',
});
pendingCategories.live.resolve([]);
pendingCategories.vod.resolve([]);
pendingCategories.series.resolve([]);
pending.live.resolve(liveItems);
await waitForCondition(
() => store.contentLoadStateByType().live === 'ready'
);
expect(store.liveStreams()).toEqual(liveItems);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'loading',
series: 'loading',
});
expect(store.isContentInitialized()).toBe(false);
pending.movie.resolve(vodItems);
await waitForCondition(
() => store.contentLoadStateByType().vod === 'ready'
);
expect(store.vodStreams()).toEqual(vodItems);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'loading',
});
expect(store.isContentInitialized()).toBe(false);
pending.series.resolve(seriesItems);
await initialization;
expect(store.serialStreams()).toEqual(seriesItems);
expect(store.contentLoadStateByType()).toEqual({
live: 'ready',
vod: 'ready',
series: 'ready',
});
expect(store.isContentInitialized()).toBe(true);
});
it('loads categories before starting content import', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
@@ -244,6 +416,28 @@ describe('withContent import state', () => {
expect(dataSource.getContent).toHaveBeenCalledTimes(3);
});
it('reuses cached content without clearing the playlist import cache', async () => {
dataSource.getCategories.mockResolvedValue([]);
dataSource.getContent.mockResolvedValue([]);
const initialization = store.initializeContent();
await Promise.resolve();
expect(store.isImporting()).toBe(false);
await initialization;
expect(store.isImporting()).toBe(false);
expect(store.isContentInitialized()).toBe(true);
expect(store.contentInitBlockReason()).toBeNull();
expect(databaseService.clearXtreamImportCache).not.toHaveBeenCalled();
expect(databaseService.setXtreamImportStatus).not.toHaveBeenCalledWith(
PLAYLIST.id,
expect.anything(),
'importing'
);
});
it('ignores concurrent initializeContent calls while an import is already running', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
@@ -264,7 +458,7 @@ describe('withContent import state', () => {
const secondInitialization = store.initializeContent();
await Promise.resolve();
expect(store.isImporting()).toBe(true);
expect(store.activeImportSessionId()).toBe('xtream-import-session');
expect(dataSource.getCategories).toHaveBeenCalledTimes(3);
expect(dataSource.getContent).not.toHaveBeenCalled();
@@ -278,6 +472,127 @@ describe('withContent import state', () => {
expect(dataSource.getContent).toHaveBeenCalledTimes(3);
});
it('blocks auto restart after cancelling during category loading until retry is explicit', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
vod: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: 'live' | 'vod' | 'series'
) => pendingCategories[type].promise
);
dataSource.getContent.mockResolvedValue([]);
const initialization = store.initializeContent();
await waitForCondition(() => Boolean(store.activeImportSessionId()));
await store.cancelImport();
expect(store.isCancellingImport()).toBe(true);
expect(store.contentInitBlockReason()).toBe('cancelled');
expect(databaseService.cancelOperation).not.toHaveBeenCalled();
expect(xtreamApiService.cancelSession).toHaveBeenCalledWith(
'xtream-import-session'
);
pendingCategories.live.reject(createAbortError());
pendingCategories.vod.reject(createAbortError());
pendingCategories.series.reject(createAbortError());
await expect(initialization).resolves.toBeUndefined();
expect(store.isImporting()).toBe(false);
expect(store.isCancellingImport()).toBe(false);
expect(store.isContentInitialized()).toBe(false);
expect(store.contentInitBlockReason()).toBe('cancelled');
expect(store.contentLoadStateByType()).toEqual({
live: 'idle',
vod: 'idle',
series: 'idle',
});
await store.initializeContent();
expect(dataSource.getCategories).toHaveBeenCalledTimes(3);
expect(dataSource.getContent).not.toHaveBeenCalled();
dataSource.getCategories.mockResolvedValue([]);
dataSource.getContent.mockResolvedValue([]);
await store.retryContentInitialization();
expect(checkPortalStatusMock).toHaveBeenCalledTimes(1);
expect(store.contentInitBlockReason()).toBeNull();
expect(store.isContentInitialized()).toBe(true);
expect(dataSource.getCategories).toHaveBeenCalledTimes(6);
expect(dataSource.getContent).toHaveBeenCalledTimes(3);
});
it('stops before content fetch if cancel lands between categories and content phases', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
vod: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: 'live' | 'vod' | 'series'
) => pendingCategories[type].promise
);
dataSource.getContent.mockResolvedValue([]);
const initialization = store.initializeContent();
await waitForCondition(() => Boolean(store.activeImportSessionId()));
pendingCategories.live.resolve([]);
pendingCategories.vod.resolve([]);
pendingCategories.series.resolve([]);
await store.cancelImport();
await expect(initialization).resolves.toBeUndefined();
expect(store.contentInitBlockReason()).toBe('cancelled');
expect(store.isContentInitialized()).toBe(false);
expect(dataSource.getContent).not.toHaveBeenCalled();
});
it('keeps the blocked state when retry finds a non-active portal status', async () => {
checkPortalStatusMock.mockResolvedValue('expired');
store.setContentInitBlockReason('cancelled');
await store.retryContentInitialization();
expect(checkPortalStatusMock).toHaveBeenCalledTimes(1);
expect(store.contentInitBlockReason()).toBe('expired');
expect(store.isContentInitialized()).toBe(false);
expect(dataSource.getCategories).not.toHaveBeenCalled();
expect(dataSource.getContent).not.toHaveBeenCalled();
});
it('keeps a cancelled block sticky until retry or reset clears it', async () => {
store.setContentInitBlockReason('cancelled');
store.setContentInitBlockReason(null);
expect(store.contentInitBlockReason()).toBe('cancelled');
dataSource.getCategories.mockResolvedValue([]);
dataSource.getContent.mockResolvedValue([]);
await store.retryContentInitialization();
expect(store.contentInitBlockReason()).toBeNull();
expect(store.isContentInitialized()).toBe(true);
});
it('cancels active imports and clears stale progress after worker aborts', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
@@ -291,7 +606,7 @@ describe('withContent import state', () => {
};
const optionsByType = new Map<
ContentType,
{ onEvent?: (event: any) => void } | undefined
{ onEvent?: (event: any) => void; operationId?: string } | undefined
>();
dataSource.getCategories.mockImplementation(
@@ -312,16 +627,17 @@ describe('withContent import state', () => {
) => {
optionsByType.set(type, options);
onTotal?.(5);
const operationId = options?.operationId ?? `${type}-op`;
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
operationId,
status: 'started',
phase: 'saving-content',
});
onProgress?.(2);
options?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
operationId,
status: 'progress',
phase: 'saving-content',
current: 2,
@@ -339,36 +655,41 @@ describe('withContent import state', () => {
pendingCategories.vod.resolve([]);
pendingCategories.series.resolve([]);
await waitForCondition(
() => store.activeImportOperationIds().length === 3
() => store.activeImportOperationIds().length === 1
);
const liveOperationId =
optionsByType.get('live')?.operationId ?? 'live-op';
await store.cancelImport();
expect(store.isCancellingImport()).toBe(true);
expect(databaseService.cancelOperation).toHaveBeenCalledTimes(3);
expect(databaseService.cancelOperation).toHaveBeenCalledWith('live-op');
expect(databaseService.cancelOperation).toHaveBeenCalledTimes(1);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'movie-op'
liveOperationId
);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'series-op'
expect(xtreamApiService.cancelSession).toHaveBeenCalledWith(
'xtream-import-session'
);
(['live', 'movie', 'series'] as ContentType[]).forEach((type) => {
optionsByType.get(type)?.onEvent?.({
operation: 'save-content',
operationId: `${type}-op`,
status: 'cancelled',
phase: 'saving-content',
});
pending[type].reject(createAbortError());
optionsByType.get('live')?.onEvent?.({
operation: 'save-content',
operationId: liveOperationId,
status: 'cancelled',
phase: 'saving-content',
});
pending.live.reject(createAbortError());
await expect(initialization).resolves.toBeUndefined();
expect(store.isImporting()).toBe(false);
expect(store.isCancellingImport()).toBe(false);
expect(store.isContentInitialized()).toBe(false);
expect(store.contentInitBlockReason()).toBe('cancelled');
expect(store.contentLoadStateByType()).toEqual({
live: 'idle',
vod: 'idle',
series: 'idle',
});
expect(store.activeImportOperationIds()).toEqual([]);
expect(store.importPhase()).toBeNull();
expect(store.importCount()).toBe(0);
@@ -13,13 +13,70 @@ import {
XtreamVodStream,
} from 'shared-interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DatabaseService, DbOperationEvent, isDbAbortError } from 'services';
import {
DatabaseService,
DbOperationEvent,
isDbAbortError,
XtreamImportStatus,
} from 'services';
import {
XTREAM_DATA_SOURCE,
XtreamCategoryFromDb,
} from '../../data-sources/xtream-data-source.interface';
import { XtreamCredentials } from '../../services/xtream-api.service';
import { ContentType } from '../../xtream-state';
import {
XtreamApiService,
XtreamCredentials,
} from '../../services/xtream-api.service';
import {
ContentType,
PortalStatusType,
XtreamContentInitBlockReason,
XtreamContentLoadState,
XtreamContentLoadStateByType,
} from '../../xtream-state';
const cancelledPlaylistInitializationLockKey = (
playlistId: string
): string => `xtream-init-cancelled:${playlistId}`;
const hasCancelledPlaylistInitializationLock = (
playlistId: string
): boolean => {
try {
return (
localStorage.getItem(
cancelledPlaylistInitializationLockKey(playlistId)
) === 'true'
);
} catch {
return false;
}
};
const setCancelledPlaylistInitializationLock = (
playlistId: string
): void => {
try {
localStorage.setItem(
cancelledPlaylistInitializationLockKey(playlistId),
'true'
);
} catch {
// Ignore storage write failures; runtime state still carries the block.
}
};
const clearCancelledPlaylistInitializationLock = (
playlistId: string
): void => {
try {
localStorage.removeItem(
cancelledPlaylistInitializationLockKey(playlistId)
);
} catch {
// Ignore storage write failures; retry still clears the in-memory block.
}
};
/**
* Content state for managing categories and streams
@@ -34,17 +91,26 @@ export interface ContentState {
isLoadingCategories: boolean;
isLoadingContent: boolean;
isImporting: boolean;
contentLoadStateByType: XtreamContentLoadStateByType;
isCancellingImport: boolean;
importCount: number;
importPhase: string | null;
itemsToImport: number;
activeImportSessionId: string | null;
activeImportOperationIds: string[];
isContentInitialized: boolean;
contentInitBlockReason: XtreamContentInitBlockReason | null;
}
/**
* Initial content state
*/
const initialContentLoadStateByType: XtreamContentLoadStateByType = {
live: 'idle',
vod: 'idle',
series: 'idle',
};
const initialContentState: ContentState = {
liveCategories: [],
vodCategories: [],
@@ -55,12 +121,15 @@ const initialContentState: ContentState = {
isLoadingCategories: false,
isLoadingContent: false,
isImporting: false,
contentLoadStateByType: { ...initialContentLoadStateByType },
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportSessionId: null,
activeImportOperationIds: [],
isContentInitialized: false,
contentInitBlockReason: null,
};
/**
@@ -79,6 +148,8 @@ export function withContent() {
username: string;
} | null;
playlistId?: () => string | null;
portalStatus?: () => PortalStatusType;
checkPortalStatus?: () => Promise<PortalStatusType>;
};
return signalStoreFeature(
@@ -136,6 +207,55 @@ export function withContent() {
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
const databaseService = inject(DatabaseService);
const xtreamApiService = inject(XtreamApiService);
const importTypes: ContentType[] = ['live', 'vod', 'series'];
let activeInitializationPromise: Promise<void> | null = null;
const updateContentTypeLoadState = (
type: ContentType,
loadState: XtreamContentLoadState
): void => {
patchState(store, (state) => ({
contentLoadStateByType: {
...state.contentLoadStateByType,
[type]: loadState,
},
}));
};
const resolveInitBlockReason = (
portalStatus: PortalStatusType | null | undefined
): XtreamContentInitBlockReason | null => {
switch (portalStatus) {
case 'expired':
case 'inactive':
case 'unavailable':
return portalStatus;
default:
return null;
}
};
const getPortalStore = (): ParentPortalStoreLike =>
store as ParentPortalStoreLike;
const createImportAbortError = (): Error => {
const error = new Error('Xtream import cancelled');
error.name = 'AbortError';
return error;
};
const throwIfImportCancelled = (
expectedImportSessionId?: string | null
): void => {
if (
store.contentInitBlockReason() === 'cancelled' ||
(expectedImportSessionId != null &&
store.activeImportSessionId() !== expectedImportSessionId)
) {
throw createImportAbortError();
}
};
/**
* Helper to get credentials from parent store
@@ -146,7 +266,7 @@ export function withContent() {
credentials: XtreamCredentials;
} | null => {
// Access parent store state (currentPlaylist is from withPortal)
const storeAny = store as ParentPortalStoreLike;
const storeAny = getPortalStore();
const playlist = storeAny.currentPlaylist?.();
const playlistId = storeAny.playlistId?.();
@@ -167,6 +287,25 @@ export function withContent() {
const trackImportEvent = (event: DbOperationEvent): void => {
const operationId = event.operationId;
if (
store.contentInitBlockReason() === 'cancelled' &&
event.status !== 'cancelled' &&
event.status !== 'error' &&
event.status !== 'completed'
) {
return;
}
if (
event.status === 'started' ||
event.status === 'progress'
) {
patchState(store, (state) => ({
isImporting: true,
importPhase: event.phase ?? state.importPhase,
}));
}
patchState(store, (state) => ({
importPhase: event.phase ?? state.importPhase,
activeImportOperationIds:
@@ -186,18 +325,273 @@ export function withContent() {
...state.activeImportOperationIds,
operationId,
],
isCancellingImport:
event.status === 'cancelled'
? false
: state.isCancellingImport,
isCancellingImport: state.isCancellingImport,
}));
};
return {
const registerImportOperation = (operationId: string): void => {
patchState(store, (state) => ({
activeImportOperationIds: state.activeImportOperationIds.includes(
operationId
)
? state.activeImportOperationIds
: [...state.activeImportOperationIds, operationId],
}));
};
const setImportStatus = async (
playlistId: string,
type: ContentType,
status: XtreamImportStatus
): Promise<void> => {
const importType = type === 'vod' ? 'movie' : type;
await databaseService.setXtreamImportStatus(
playlistId,
importType,
status
);
};
const clearImportCache = async (
playlistId: string,
type: ContentType
): Promise<void> => {
const importType = type === 'vod' ? 'movie' : type;
await databaseService.clearXtreamImportCache(
playlistId,
importType
);
};
const finalizePendingImportTypes = async (
playlistId: string,
completedTypes: Set<ContentType>,
status: XtreamImportStatus
): Promise<void> => {
for (const type of importTypes) {
if (completedTypes.has(type)) {
continue;
}
await setImportStatus(playlistId, type, status);
await clearImportCache(playlistId, type);
}
};
const finalizePendingContentLoadStates = (
completedTypes: Set<ContentType>,
loadState: XtreamContentLoadState
): void => {
patchState(store, (state) => {
const nextLoadStates = {
...state.contentLoadStateByType,
};
for (const type of importTypes) {
if (completedTypes.has(type)) {
continue;
}
nextLoadStates[type] = loadState;
}
return {
contentLoadStateByType: nextLoadStates,
};
});
};
const executeContentInitialization = async (
ignoreBlockedState = false
): Promise<void> => {
const ctx = getCredentialsFromStore();
if (!ctx) return;
if (
!ignoreBlockedState &&
hasCancelledPlaylistInitializationLock(ctx.playlistId)
) {
patchState(store, {
contentInitBlockReason: 'cancelled',
});
return;
}
// Skip duplicate route-session triggers while initialization is
// already running. The workspace session currently syncs from
// multiple entry points during bootstrap, and without this guard
// Electron can duplicate the same Xtream load work.
if (
(!ignoreBlockedState && store.contentInitBlockReason()) ||
store.isCancellingImport() ||
store.isContentInitialized() ||
store.activeImportSessionId()
) {
return;
}
const importSessionId = databaseService.createOperationId(
'xtream-import-session'
);
patchState(store, {
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportSessionId: importSessionId,
activeImportOperationIds: [],
contentLoadStateByType: {
live: 'loading',
vod: 'loading',
series: 'loading',
},
});
const completedTypes = new Set<ContentType>();
try {
// Electron content persistence maps remote category IDs
// to internal DB category rows, so categories must exist
// before content import starts.
await methods.fetchAllCategories({
sessionId: importSessionId,
});
throwIfImportCancelled(importSessionId);
await methods.fetchAllContent({
importSessionId,
sessionId: importSessionId,
completedTypes,
});
throwIfImportCancelled(importSessionId);
// Restore user data if needed
const restoreKey = `xtream-restore-${ctx.playlistId}`;
const restoreData = localStorage.getItem(restoreKey);
if (restoreData) {
try {
throwIfImportCancelled(importSessionId);
const {
favoritedXtreamIds,
recentlyViewedXtreamIds,
} = JSON.parse(restoreData);
const restoreOperationId =
databaseService.createOperationId(
'xtream-restore'
);
registerImportOperation(restoreOperationId);
patchState(store, {
importPhase: 'restoring-favorites',
});
await dataSource.restoreUserData(
ctx.playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds,
{
onEvent: trackImportEvent,
operationId: restoreOperationId,
}
);
throwIfImportCancelled(importSessionId);
localStorage.removeItem(restoreKey);
} catch (err) {
if (!isDbAbortError(err)) {
logger.error(
'Error restoring user data',
err
);
}
}
}
throwIfImportCancelled(importSessionId);
// Mark as initialized so next routings won't re-trigger it
clearCancelledPlaylistInitializationLock(ctx.playlistId);
patchState(store, {
isContentInitialized: true,
contentInitBlockReason: null,
});
} catch (error) {
if (store.isImporting()) {
await finalizePendingImportTypes(
ctx.playlistId,
completedTypes,
isDbAbortError(error) ? 'cancelled' : 'failed'
);
}
finalizePendingContentLoadStates(
completedTypes,
isDbAbortError(error) ? 'idle' : 'error'
);
if (isDbAbortError(error)) {
patchState(store, (state) => ({
contentInitBlockReason:
state.contentInitBlockReason ?? 'cancelled',
}));
} else {
patchState(store, {
contentInitBlockReason:
resolveInitBlockReason(
getPortalStore().portalStatus?.()
) ?? 'error',
});
logger.error('Error initializing content', error);
}
} finally {
patchState(store, {
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportSessionId: null,
activeImportOperationIds: [],
});
}
};
const runContentInitialization = async (
ignoreBlockedState = false
): Promise<void> => {
if (activeInitializationPromise) {
return activeInitializationPromise;
}
const initializationPromise = executeContentInitialization(
ignoreBlockedState
).finally(() => {
if (activeInitializationPromise === initializationPromise) {
activeInitializationPromise = null;
}
});
activeInitializationPromise = initializationPromise;
return initializationPromise;
};
const methods = {
setContentInitBlockReason(
reason: XtreamContentInitBlockReason | null
): void {
patchState(store, (state) => ({
contentInitBlockReason:
reason === null &&
state.contentInitBlockReason === 'cancelled'
? state.contentInitBlockReason
: reason,
}));
},
/**
* Fetch all categories in parallel
*/
async fetchAllCategories(): Promise<void> {
async fetchAllCategories(
options?: { sessionId?: string }
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
@@ -208,17 +602,41 @@ export function withContent() {
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'live'
'live',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
),
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'vod'
'vod',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
),
dataSource.getCategories(
ctx.playlistId,
ctx.credentials,
'series'
'series',
{
sessionId: options?.sessionId,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
),
]);
@@ -238,9 +656,15 @@ export function withContent() {
},
/**
* Fetch all content/streams in parallel with progress tracking
* Fetch all content/streams with shared progress tracking
*/
async fetchAllContent(): Promise<void> {
async fetchAllContent(
options?: {
importSessionId?: string;
sessionId?: string;
completedTypes?: Set<ContentType>;
}
): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
@@ -261,39 +685,98 @@ export function withContent() {
};
try {
const [live, vod, series] = await Promise.all([
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'live',
onProgress,
onTotal,
{ onEvent: trackImportEvent }
),
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'movie',
onProgress,
onTotal,
{ onEvent: trackImportEvent }
),
dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series',
onProgress,
onTotal,
{ onEvent: trackImportEvent }
),
]);
throwIfImportCancelled(options?.importSessionId);
const liveOperationId = databaseService.createOperationId(
'db-save-content'
);
registerImportOperation(liveOperationId);
const live = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'live',
onProgress,
onTotal,
{
operationId: liveOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
)) as XtreamLiveStream[];
throwIfImportCancelled(options?.importSessionId);
await setImportStatus(ctx.playlistId, 'live', 'completed');
options?.completedTypes?.add('live');
patchState(store, {
liveStreams: live as XtreamLiveStream[],
vodStreams: vod as XtreamVodStream[],
serialStreams: series as XtreamSerieItem[],
liveStreams: live,
});
updateContentTypeLoadState('live', 'ready');
throwIfImportCancelled(options?.importSessionId);
const vodOperationId = databaseService.createOperationId(
'db-save-content'
);
registerImportOperation(vodOperationId);
const vod = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'movie',
onProgress,
onTotal,
{
operationId: vodOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
)) as XtreamVodStream[];
throwIfImportCancelled(options?.importSessionId);
await setImportStatus(ctx.playlistId, 'vod', 'completed');
options?.completedTypes?.add('vod');
patchState(store, {
vodStreams: vod,
});
updateContentTypeLoadState('vod', 'ready');
throwIfImportCancelled(options?.importSessionId);
const seriesOperationId =
databaseService.createOperationId(
'db-save-content'
);
registerImportOperation(seriesOperationId);
const series = (await dataSource.getContent(
ctx.playlistId,
ctx.credentials,
'series',
onProgress,
onTotal,
{
operationId: seriesOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: (phase) =>
patchState(store, {
isImporting: true,
importPhase: phase,
}),
}
)) as XtreamSerieItem[];
throwIfImportCancelled(options?.importSessionId);
await setImportStatus(ctx.playlistId, 'series', 'completed');
options?.completedTypes?.add('series');
patchState(store, {
serialStreams: series,
isLoadingContent: false,
});
updateContentTypeLoadState('series', 'ready');
} catch (error) {
if (!isDbAbortError(error)) {
logger.error('Error fetching content', error);
@@ -307,94 +790,64 @@ export function withContent() {
* Initialize content (fetch categories and content)
*/
async initializeContent(): Promise<void> {
const ctx = getCredentialsFromStore();
if (!ctx) return;
await runContentInitialization();
},
// Skip duplicate route-session triggers while an import is already
// running. The workspace session currently syncs from multiple
// entry points during bootstrap, and without this guard Electron
// can save the same Xtream categories/content multiple times.
if (store.isContentInitialized() || store.isImporting()) {
async retryContentInitialization(): Promise<void> {
const portalStatus =
(await getPortalStore().checkPortalStatus?.()) ??
getPortalStore().portalStatus?.() ??
'unavailable';
const blockReason = resolveInitBlockReason(portalStatus);
if (blockReason) {
patchState(store, {
contentInitBlockReason: blockReason,
});
return;
}
patchState(store, {
isImporting: true,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportOperationIds: [],
contentInitBlockReason: null,
isContentInitialized: false,
});
try {
// Electron content persistence maps remote category IDs
// to internal DB category rows, so categories must exist
// before content import starts.
await this.fetchAllCategories();
await this.fetchAllContent();
// Restore user data if needed
const restoreKey = `xtream-restore-${ctx.playlistId}`;
const restoreData = localStorage.getItem(restoreKey);
if (restoreData) {
try {
const {
favoritedXtreamIds,
recentlyViewedXtreamIds,
} = JSON.parse(restoreData);
await dataSource.restoreUserData(
ctx.playlistId,
favoritedXtreamIds,
recentlyViewedXtreamIds,
{ onEvent: trackImportEvent }
);
localStorage.removeItem(restoreKey);
} catch (err) {
if (!isDbAbortError(err)) {
logger.error(
'Error restoring user data',
err
);
}
}
}
// Mark as initialized so next routings won't re-trigger it
patchState(store, { isContentInitialized: true });
} catch (error) {
if (!isDbAbortError(error)) {
logger.error('Error initializing content', error);
}
} finally {
patchState(store, {
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportOperationIds: [],
});
const ctx = getCredentialsFromStore();
if (ctx) {
clearCancelledPlaylistInitializationLock(
ctx.playlistId
);
}
await runContentInitialization(true);
},
async cancelImport(): Promise<void> {
if (
!databaseService.supportsDbOperationCancellation() ||
store.activeImportOperationIds().length === 0 ||
store.isCancellingImport()
) {
const activeImportSessionId = store.activeImportSessionId();
const activeImportOperationIds =
store.activeImportOperationIds();
if (!activeImportSessionId || store.isCancellingImport()) {
return;
}
patchState(store, { isCancellingImport: true });
patchState(store, {
isCancellingImport: true,
contentInitBlockReason: 'cancelled',
activeImportSessionId: null,
});
const ctx = getCredentialsFromStore();
if (ctx) {
setCancelledPlaylistInitializationLock(
ctx.playlistId
);
}
await xtreamApiService.cancelSession(activeImportSessionId);
await Promise.all(
store
.activeImportOperationIds()
.map((operationId) =>
databaseService.cancelOperation(operationId)
)
activeImportOperationIds.map((operationId) =>
databaseService.cancelOperation(operationId)
)
);
},
@@ -454,6 +907,8 @@ export function withContent() {
patchState(store, initialContentState);
},
};
return methods;
})
);
}
@@ -97,7 +97,10 @@ export function withEpg() {
const epgItems = await apiService.getShortEpg(
credentials,
selectedItem.xtream_id,
10
10,
{
suppressErrorLog: true,
}
);
patchState(store, {
@@ -129,7 +132,10 @@ export function withEpg() {
return await apiService.getShortEpg(
credentials,
streamId,
1
1,
{
suppressErrorLog: true,
}
);
} catch (error) {
logger.error('Error loading channel EPG', error);
@@ -43,6 +43,31 @@ const initialPortalState: PortalState = {
*/
export function withPortal() {
const logger = createLogger('withPortal');
const resolvePortalStatus = (response: {
user_info?: {
exp_date?: string;
status?: string;
};
} | null): PortalStatusType => {
if (!response?.user_info?.status) {
return 'unavailable';
}
if (response.user_info.status === 'Active') {
if (!response.user_info.exp_date) {
return 'active';
}
const expDate = new Date(
parseInt(response.user_info.exp_date, 10) * 1000
);
return expDate < new Date() ? 'expired' : 'active';
}
return 'inactive';
};
return signalStoreFeature(
withState<PortalState>(initialPortalState),
@@ -98,11 +123,11 @@ export function withPortal() {
/**
* Check portal status via API
*/
async checkPortalStatus(): Promise<void> {
async checkPortalStatus(): Promise<PortalStatusType> {
const playlist = store.currentPlaylist();
if (!playlist) {
patchState(store, { portalStatus: 'unavailable' });
return;
return 'unavailable';
}
const credentials: XtreamCredentials = {
@@ -114,32 +139,13 @@ export function withPortal() {
try {
const response =
await apiService.getAccountInfo(credentials);
if (!response?.user_info?.status) {
patchState(store, { portalStatus: 'unavailable' });
return;
}
if (response.user_info.status === 'Active') {
if (!response.user_info.exp_date) {
patchState(store, { portalStatus: 'active' });
return;
}
const expDate = new Date(
parseInt(response.user_info.exp_date, 10) * 1000
);
if (expDate < new Date()) {
patchState(store, { portalStatus: 'expired' });
} else {
patchState(store, { portalStatus: 'active' });
}
} else {
patchState(store, { portalStatus: 'inactive' });
}
const portalStatus = resolvePortalStatus(response);
patchState(store, { portalStatus });
return portalStatus;
} catch (error) {
logger.error('Error checking portal status', error);
patchState(store, { portalStatus: 'unavailable' });
return 'unavailable';
}
},
@@ -6,7 +6,7 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { ContentType } from '../../xtream-state';
import { ContentType, XtreamContentLoadState } from '../../xtream-state';
/**
* Module-level collator — allocating Intl.Collator is expensive;
@@ -101,6 +101,7 @@ interface XtreamSelectionItem {
}
type ParentSelectionStoreLike = {
contentLoadStateByType?: () => Record<ContentType, XtreamContentLoadState>;
isLoadingContent?: () => boolean;
liveCategories?: () => XtreamSelectionCategory[];
liveStreams?: () => XtreamSelectionItem[];
@@ -222,6 +223,23 @@ export function withSelection() {
(store as ParentSelectionStoreLike).serialStreams?.() || []
)
);
const selectedTypeContentState = computed(() => {
const storeAny = store as ParentSelectionStoreLike;
const selectedType = store.selectedContentType();
return (
storeAny.contentLoadStateByType?.()?.[selectedType] ??
'idle'
);
});
const selectedTypeContentLoading = computed(
() => selectedTypeContentState() === 'loading'
);
const selectedTypeContentReady = computed(
() => selectedTypeContentState() === 'ready'
);
const selectedTypeCountsReady = computed(
() => selectedTypeContentReady()
);
// ---------------------------------------------------------------------------
// Stable filter + sort intermediate.
@@ -344,11 +362,17 @@ export function withSelection() {
/**
* Check if paginated content is loading
*/
isPaginatedContentLoading: computed(() => {
// Access parent store loading state (from withContent)
const storeAny = store as ParentSelectionStoreLike;
return storeAny.isLoadingContent?.() || false;
}),
isPaginatedContentLoading: computed(() =>
selectedTypeContentLoading()
),
selectedTypeContentState,
selectedTypeContentLoading,
selectedTypeContentReady,
selectedTypeCountsReady,
/**
* Memoized category item counts map.
@@ -96,6 +96,10 @@ export const XtreamStore = signalStore(
// Clear the session cache for the playlist we're leaving so
// stale data cannot bleed into the new playlist (PWA path).
const leavingPlaylistId = store.playlistId();
const preserveCancelledBlock =
Boolean(newPlaylistId) &&
leavingPlaylistId === newPlaylistId &&
store.contentInitBlockReason() === 'cancelled';
if (leavingPlaylistId) {
dataSource.clearSessionCache(leavingPlaylistId);
}
@@ -110,6 +114,10 @@ export const XtreamStore = signalStore(
if (newPlaylistId) {
store.setPlaylistId(newPlaylistId);
}
if (preserveCancelledBlock) {
store.setContentInitBlockReason('cancelled');
}
},
/**
@@ -12,6 +12,11 @@ import {
} from './data-sources/xtream-data-source.interface';
export type ContentType = 'live' | 'vod' | 'series';
export type XtreamContentLoadState = 'idle' | 'loading' | 'ready' | 'error';
export type XtreamContentLoadStateByType = Record<
ContentType,
XtreamContentLoadState
>;
export type PortalStatusType =
| 'active'
@@ -19,6 +24,13 @@ export type PortalStatusType =
| 'expired'
| 'unavailable';
export type XtreamContentInitBlockReason =
| 'cancelled'
| 'expired'
| 'inactive'
| 'unavailable'
| 'error';
export interface XtreamPortalStatus {
status: 'active' | 'inactive' | 'expired' | 'unavailable';
message?: string;
@@ -28,6 +40,7 @@ export interface XtreamState {
isLoadingCategories: boolean;
isLoadingContent: boolean;
isImporting: boolean;
contentLoadStateByType: XtreamContentLoadStateByType;
liveCategories: XtreamCategory[];
vodCategories: XtreamCategory[];
serialCategories: XtreamCategory[];
@@ -46,6 +59,7 @@ export interface XtreamState {
epgItems: EpgItem[];
hideExternalInfoDialog: boolean;
portalStatus: PortalStatusType;
contentInitBlockReason: XtreamContentInitBlockReason | null;
globalSearchResults: GlobalSearchResult[];
streamUrl: string;
playlistId: string | null;
+1
View File
@@ -9,6 +9,7 @@ export * from './lib/recently-added/recently-added.component';
export * from './lib/search-results/search-results.component';
export * from './lib/serial-details/serial-details.component';
export * from './lib/vod-details/vod-details-route.component';
export * from './lib/xtream-content-gate.component';
export * from './lib/xtream-catalog-facade.service';
export * from './lib/xtream-feature.routes';
export * from './lib/xtream-workspace-route-session.service';
@@ -1,114 +1,218 @@
<h2 mat-dialog-title>{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}</h2>
<mat-dialog-content class="mat-typography">
@if (accountInfo?.user_info?.message) {
<div class="welcome-message">
{{ accountInfo?.user_info?.message }}
</div>
}
<div class="info-grid">
<div class="info-section">
<h3>{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}</h3>
<mat-list>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.STATUS' | translate }}:
<span [class.active]="isActive">{{
accountInfo?.user_info?.status
}}</span>
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.USERNAME' | translate }}:
{{ accountInfo?.user_info?.username }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS' | translate }}:
{{ accountInfo?.user_info?.active_cons }}/{{
accountInfo?.user_info?.max_connections
}}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.CREATED' | translate }}:
{{ formattedCreatedDate }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.EXPIRES' | translate }}:
{{ formattedExpDate }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate }}:
<span [class.trial]="isTrial">{{
(isTrial
? 'XTREAM.ACCOUNT_INFO.YES'
: 'XTREAM.ACCOUNT_INFO.NO'
) | translate
}}</span>
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}:
<span class="formats">
{{
accountInfo?.user_info?.allowed_output_formats?.join(
', '
)
}}
</span>
</mat-list-item>
</mat-list>
<section mat-dialog-title class="account-dialog__hero">
<div class="account-dialog__hero-copy">
<div class="account-dialog__eyebrow">
<span class="account-dialog__eyebrow-mark"></span>
@if (accountInfo(); as info) {
{{ info.server_info?.server_protocol || 'Xtream' }}
@if (info.server_info?.timezone) {
<span class="account-dialog__eyebrow-divider"></span>
{{ info.server_info?.timezone }}
}
} @else {
Xtream
}
</div>
<div class="info-section">
<h3>{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}</h3>
<mat-list>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.LIVE_TV' | translate }}:
{{ liveStreamsCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.MOVIES' | translate }}:
{{ vodStreamsCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TV_SERIES' | translate }}:
{{ seriesCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.URL' | translate }}:
{{ accountInfo?.server_info?.url }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.PROTOCOL' | translate }}:
{{ accountInfo?.server_info?.server_protocol }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TIMEZONE' | translate }}:
{{ accountInfo?.server_info?.timezone }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.SERVER_TIME' | translate }}:
{{ accountInfo?.server_info?.time_now }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}:
<div class="ports">
<div class="account-dialog__headline-row">
<div>
<h2 class="account-dialog__title">
{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}
</h2>
<p class="account-dialog__subtitle">
{{ playlistLabel() }}
</p>
</div>
@if (accountInfo(); as info) {
<div class="account-dialog__status-cluster">
<span
class="account-dialog__status-pill"
[class.account-dialog__status-pill--active]="isActive()"
>
{{ info.user_info?.status || '-' }}
</span>
@if (isTrial()) {
<span
>{{ 'XTREAM.ACCOUNT_INFO.HTTP_PORT' | translate }}:
{{ accountInfo?.server_info?.port }}</span
class="account-dialog__status-pill account-dialog__status-pill--trial"
>
<span
>{{ 'XTREAM.ACCOUNT_INFO.HTTPS_PORT' | translate }}:
{{ accountInfo?.server_info?.https_port }}</span
>
<span
>{{ 'XTREAM.ACCOUNT_INFO.RTMP_PORT' | translate }}:
{{ accountInfo?.server_info?.rtmp_port }}</span
>
</div>
</mat-list-item>
</mat-list>
{{
'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate
}}
</span>
}
</div>
}
</div>
@if (accountInfo()?.user_info?.message) {
<div class="account-dialog__message">
{{ accountInfo()?.user_info?.message }}
</div>
} @else if (loadState() === 'loading') {
<p class="account-dialog__loading-copy">
{{ 'LOADING' | translate }}...
</p>
}
</div>
<div class="account-dialog__hero-stats">
@if (loadState() === 'ready') {
@for (stat of heroStats(); track stat.labelKey) {
<article class="account-stat">
<div class="account-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="account-stat__label">
{{ stat.labelKey | translate }}
</div>
<div class="account-stat__value">
{{ stat.value }}
</div>
@if (stat.meter !== null) {
<div class="account-stat__meter">
<span [style.width.%]="stat.meter"></span>
</div>
}
</article>
}
} @else {
@for (item of skeletonStats; track item) {
<div class="account-skeleton account-skeleton--stat"></div>
}
}
</div>
</section>
<mat-dialog-content class="account-dialog__content">
@if (loadState() === 'error') {
<section class="account-dialog__error-state">
<div class="account-dialog__error-icon">
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
</div>
<h3>
{{ 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' | translate }}
</h3>
<p>
{{
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
| translate
}}
</p>
<button mat-flat-button color="primary" (click)="reload()">
{{ 'RETRY' | translate }}
</button>
</section>
} @else if (loadState() === 'loading') {
<div class="account-dialog__grid">
@for (panel of skeletonPanels; track panel) {
<section class="account-panel account-panel--loading">
<div class="account-skeleton account-skeleton--heading"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row short"></div>
</section>
}
</div>
} @else if (accountInfo(); as info) {
<div class="account-dialog__grid">
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}
</p>
<h3>{{ info.user_info?.username || '-' }}</h3>
</div>
<dl class="account-detail-list">
@for (row of userDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
[class.account-detail-row__value--accent]="
row.tone === 'accent'
"
[class.account-detail-row__value--positive]="
row.tone === 'positive'
"
[class.account-detail-row__value--warning]="
row.tone === 'warning'
"
>
@if (row.translateValue) {
{{ row.value | translate }}
} @else {
{{ row.value }}
}
</dd>
</div>
}
</dl>
<div class="account-chip-section">
<p class="account-chip-section__label">
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}
</p>
<div class="account-chip-group">
@for (format of allowedFormats(); track format) {
<span class="account-chip">
{{ format }}
</span>
} @empty {
<span class="account-chip account-chip--muted">
-
</span>
}
</div>
</div>
</section>
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}
</p>
<h3>{{ serverHost() }}</h3>
</div>
<dl class="account-detail-list">
@for (row of serverDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
>
{{ row.value }}
</dd>
</div>
}
</dl>
<div class="account-chip-section">
<p class="account-chip-section__label">
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}
</p>
<div class="account-chip-group">
@for (port of ports(); track port.labelKey) {
<span class="account-chip account-chip--mono">
{{ port.labelKey | translate }} {{ port.value }}
</span>
}
</div>
</div>
</section>
</div>
}
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close color="accent">
<mat-dialog-actions class="account-dialog__actions">
<button mat-stroked-button mat-dialog-close class="account-dialog__close">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -1,89 +1,477 @@
.account-info-card {
margin: 16px;
max-width: 1200px;
:host {
display: block;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.info-grid {
// ─── Hero header ────────────────────────────────────────────────────────────
.account-dialog__hero {
margin: 0;
padding: 20px 20px 18px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
gap: 18px;
background:
var(--app-accent-gradient),
var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
.info-section {
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
.account-dialog__hero-copy {
display: grid;
gap: 10px;
}
.account-dialog__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.account-dialog__eyebrow-mark {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--app-provider-xtream, var(--mat-sys-primary));
box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 14%, transparent);
}
.account-dialog__eyebrow-divider {
width: 3px;
height: 3px;
border-radius: 999px;
background: currentColor;
opacity: 0.5;
}
.account-dialog__headline-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.account-dialog__title {
margin: 0;
font-size: 1.3rem;
line-height: 1.2;
font-weight: 650;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.account-dialog__subtitle {
margin: 4px 0 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.88rem;
}
// ─── Status pills ───────────────────────────────────────────────────────────
.account-dialog__status-cluster {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.account-dialog__status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container);
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.account-dialog__status-pill--active {
border-color: color-mix(in srgb, #34d399 30%, transparent);
background: color-mix(in srgb, #34d399 12%, transparent);
color: #2db36f;
}
.account-dialog__status-pill--trial {
border-color: color-mix(in srgb, #fbbf24 28%, transparent);
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #d18b09;
}
// ─── Messages ───────────────────────────────────────────────────────────────
.account-dialog__message,
.account-dialog__loading-copy {
margin: 0;
max-width: 60ch;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.account-dialog__message {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container);
line-height: 1.5;
font-size: 0.88rem;
}
// ─── Hero stat cards ────────────────────────────────────────────────────────
.account-dialog__hero-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.account-stat {
display: grid;
gap: 6px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--app-widget-border, transparent);
background: var(--app-widget-bg, var(--mat-sys-surface-container));
box-shadow: var(--app-widget-shadow, none);
// Subtle inset highlight matching widget-shell pattern
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
}
.account-stat__icon {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 12%, transparent);
color: var(--app-provider-xtream, var(--mat-sys-primary));
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.account-stat__label {
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.account-stat__value {
font-size: 1.3rem;
font-weight: 650;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.account-stat__meter {
position: relative;
overflow: hidden;
height: 4px;
border-radius: 999px;
background: var(--app-separator);
}
.account-stat__meter span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--app-provider-xtream, var(--mat-sys-primary));
}
// ─── Content area ───────────────────────────────────────────────────────────
.account-dialog__content {
margin: 0;
padding: 20px;
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.account-dialog__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
// ─── Detail panels ──────────────────────────────────────────────────────────
.account-panel {
display: grid;
gap: 14px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border-radius: 16px;
border: 1px solid var(--app-widget-border, transparent);
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
box-shadow: var(--app-widget-shadow, none);
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
}
.info-section h3 {
margin-bottom: 16px;
padding-left: 16px;
.account-panel__header {
display: grid;
gap: 4px;
}
.account-panel__header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
border-bottom: 1px solid var(--mat-sys-outline-variant);
padding-bottom: 8px;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
mat-list-item {
height: auto !important;
margin-bottom: 8px;
padding: 8px 16px;
.account-panel__eyebrow {
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
strong {
margin-right: 8px;
min-width: 140px;
display: inline-block;
// ─── Detail rows ────────────────────────────────────────────────────────────
.account-detail-list {
display: grid;
gap: 0;
margin: 0;
}
.active {
color: var(--mat-sys-primary);
.account-detail-row {
display: grid;
grid-template-columns: minmax(120px, 140px) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px solid var(--app-separator);
}
.account-detail-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.account-detail-row dt {
margin: 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.account-detail-row dd {
margin: 0;
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.9rem;
font-weight: 500;
word-break: break-word;
}
.trial {
color: var(--mat-sys-error);
font-weight: 500;
.account-detail-row__value--mono {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
font-size: 0.84rem;
}
.formats {
color: var(--mat-sys-tertiary);
.account-detail-row__value--accent {
color: var(--app-provider-xtream, var(--mat-sys-primary));
}
.ports {
.account-detail-row__value--positive {
color: #2db36f;
}
.account-detail-row__value--warning {
color: #d18b09;
}
// ─── Chip sections ──────────────────────────────────────────────────────────
.account-chip-section {
display: grid;
gap: 10px;
}
.account-chip-section__label {
margin: 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.account-chip-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
gap: 8px;
}
.ports span {
padding: 4px 8px;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
border-radius: 4px;
font-size: 0.9em;
.account-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 16%, transparent);
background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 8%, transparent);
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.welcome-message {
border: 1px solid var(--mat-sys-outline-variant);
padding: 16px;
border-radius: 4px;
margin-bottom: 16px;
.account-chip--mono {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
text-transform: none;
}
.account-chip--muted {
border-color: var(--app-separator);
background: var(--mat-sys-surface-container);
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
}
// ─── Error state ────────────────────────────────────────────────────────────
.account-dialog__error-state {
display: grid;
justify-items: center;
gap: 12px;
padding: 36px 20px;
border-radius: 16px;
border: 1px dashed var(--app-separator);
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
text-align: center;
font-style: italic;
}
@media (max-width: 768px) {
.info-grid {
.account-dialog__error-state h3,
.account-dialog__error-state p {
margin: 0;
}
.account-dialog__error-state p {
max-width: 52ch;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
line-height: 1.5;
font-size: 0.88rem;
}
.account-dialog__error-icon {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
color: var(--mat-sys-error);
}
// ─── Skeleton loading ───────────────────────────────────────────────────────
.account-panel--loading {
gap: 12px;
}
.account-skeleton {
position: relative;
overflow: hidden;
border-radius: 999px;
background: var(--app-separator);
}
.account-skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--app-heading-color, var(--mat-sys-on-surface)) 10%, transparent),
transparent
);
animation: account-dialog-shimmer 1.4s infinite;
}
.account-skeleton--stat {
min-height: 110px;
border-radius: 12px;
}
.account-skeleton--heading {
width: 54%;
height: 20px;
}
.account-skeleton--row {
width: 100%;
height: 16px;
}
.account-skeleton--row.short {
width: 62%;
}
// ─── Footer actions ─────────────────────────────────────────────────────────
.account-dialog__actions {
padding: 0 20px 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.account-dialog__close {
min-width: 100px;
border-radius: 999px;
}
@keyframes account-dialog-shimmer {
100% {
transform: translateX(100%);
}
}
// ─── Responsive breakpoints ─────────────────────────────────────────────────
@media (max-width: 980px) {
.account-dialog__hero-stats,
.account-dialog__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.account-dialog__hero,
.account-dialog__content,
.account-dialog__actions {
padding-left: 16px;
padding-right: 16px;
}
.account-dialog__headline-row {
flex-direction: column;
align-items: flex-start;
}
.account-dialog__status-cluster {
justify-content: flex-start;
}
.account-dialog__hero-stats,
.account-dialog__grid {
grid-template-columns: 1fr;
}
.account-info-card {
margin: 8px;
.account-detail-row {
grid-template-columns: 1fr;
gap: 4px;
}
.ports {
flex-direction: column;
gap: 8px;
}
}
}
@@ -1,7 +1,13 @@
import { Component, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
XtreamAccountInfo,
@@ -10,11 +16,34 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
type AccountLoadState = 'loading' | 'ready' | 'error';
interface AccountStat {
icon: string;
labelKey: string;
value: string;
meter: number | null;
}
interface AccountDetailRow {
labelKey: string;
value: string;
mono?: boolean;
tone?: 'accent' | 'positive' | 'warning';
translateValue?: boolean;
}
interface AccountPort {
labelKey: string;
value: string;
}
@Component({
selector: 'app-account-info',
imports: [MatButton, MatDialogModule, MatListModule, TranslatePipe],
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
templateUrl: './account-info.component.html',
styleUrl: './account-info.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccountInfoComponent {
readonly data = inject<{
@@ -26,51 +55,206 @@ export class AccountInfoComponent {
private readonly xtreamStore = inject(XtreamStore);
private readonly logger = createLogger('XtreamAccountInfo');
accountInfo: XtreamAccountInfo;
formattedExpDate: string;
formattedCreatedDate: string;
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly loadState = signal<AccountLoadState>('loading');
readonly accountInfo = signal<XtreamAccountInfo | null>(null);
readonly skeletonStats = [1, 2, 3, 4];
readonly skeletonPanels = [1, 2];
readonly vodStreamsCount = this.data.vodStreamsCount;
readonly liveStreamsCount = this.data.liveStreamsCount;
readonly seriesCount = this.data.seriesCount;
readonly isActive = computed(
() => this.accountInfo()?.user_info?.status === 'Active'
);
readonly isTrial = computed(
() => this.accountInfo()?.user_info?.is_trial === '1'
);
readonly playlistLabel = computed(() => {
const playlist = this.currentPlaylist();
const info = this.accountInfo();
return (
playlist?.title ||
info?.server_info?.url ||
info?.user_info?.username ||
'-'
);
});
readonly serverHost = computed(
() => this.accountInfo()?.server_info?.url || '-'
);
readonly activeConnections = computed(() =>
this.parseNumber(this.accountInfo()?.user_info?.active_cons)
);
readonly maxConnections = computed(() =>
this.parseNumber(this.accountInfo()?.user_info?.max_connections)
);
readonly connectionUsagePercent = computed(() => {
const maxConnections = this.maxConnections();
if (maxConnections <= 0) {
return 0;
}
return Math.min(
100,
Math.round((this.activeConnections() / maxConnections) * 100)
);
});
readonly activeConnectionsLabel = computed(
() => `${this.activeConnections()}/${Math.max(this.maxConnections(), 0)}`
);
readonly formattedExpDate = computed(() =>
this.formatUnixDate(this.accountInfo()?.user_info?.exp_date)
);
readonly formattedCreatedDate = computed(() =>
this.formatUnixDate(this.accountInfo()?.user_info?.created_at)
);
readonly allowedFormats = computed(
() => this.accountInfo()?.user_info?.allowed_output_formats ?? []
);
readonly ports = computed<AccountPort[]>(() => {
const serverInfo = this.accountInfo()?.server_info;
return [
{
labelKey: 'XTREAM.ACCOUNT_INFO.HTTP_PORT',
value: serverInfo?.port || '-',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.HTTPS_PORT',
value: serverInfo?.https_port || '-',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.RTMP_PORT',
value: serverInfo?.rtmp_port || '-',
},
];
});
readonly heroStats = computed<AccountStat[]>(() => [
{
icon: 'bolt',
labelKey: 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS',
value: this.activeConnectionsLabel(),
meter: this.connectionUsagePercent(),
},
{
icon: 'live_tv',
labelKey: 'XTREAM.ACCOUNT_INFO.LIVE_TV',
value: String(this.liveStreamsCount),
meter: null,
},
{
icon: 'movie',
labelKey: 'XTREAM.ACCOUNT_INFO.MOVIES',
value: String(this.vodStreamsCount),
meter: null,
},
{
icon: 'tv',
labelKey: 'XTREAM.ACCOUNT_INFO.TV_SERIES',
value: String(this.seriesCount),
meter: null,
},
]);
readonly userDetails = computed<AccountDetailRow[]>(() => [
{
labelKey: 'XTREAM.ACCOUNT_INFO.STATUS',
value: this.accountInfo()?.user_info?.status || '-',
tone: this.isActive() ? 'positive' : undefined,
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.USERNAME',
value: this.accountInfo()?.user_info?.username || '-',
mono: true,
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS',
value: this.activeConnectionsLabel(),
tone: 'accent',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.CREATED',
value: this.formattedCreatedDate(),
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.EXPIRES',
value: this.formattedExpDate(),
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT',
value: this.isTrial()
? 'XTREAM.ACCOUNT_INFO.YES'
: 'XTREAM.ACCOUNT_INFO.NO',
translateValue: true,
tone: this.isTrial() ? 'warning' : undefined,
},
]);
readonly serverDetails = computed<AccountDetailRow[]>(() => [
{
labelKey: 'XTREAM.ACCOUNT_INFO.URL',
value: this.accountInfo()?.server_info?.url || '-',
mono: true,
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.PROTOCOL',
value: this.accountInfo()?.server_info?.server_protocol || '-',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.TIMEZONE',
value: this.accountInfo()?.server_info?.timezone || '-',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.SERVER_TIME',
value: this.accountInfo()?.server_info?.time_now || '-',
mono: true,
},
]);
constructor() {
this.setAccountInfo();
this.vodStreamsCount = this.data.vodStreamsCount;
this.liveStreamsCount = this.data.liveStreamsCount;
this.seriesCount = this.data.seriesCount;
void this.reload();
}
async setAccountInfo() {
async reload(): Promise<void> {
const playlist = this.currentPlaylist();
if (!playlist) return;
if (!playlist) {
this.loadState.set('error');
this.accountInfo.set(null);
return;
}
this.loadState.set('loading');
try {
this.accountInfo = await this.xtreamApiService.getAccountInfo({
const accountInfo = await this.xtreamApiService.getAccountInfo({
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
});
if (this.accountInfo) {
this.formattedExpDate = new Date(
parseInt(this.accountInfo.user_info.exp_date) * 1000
).toLocaleDateString();
this.formattedCreatedDate = new Date(
parseInt(this.accountInfo.user_info.created_at) * 1000
).toLocaleDateString();
}
this.accountInfo.set(accountInfo);
this.loadState.set('ready');
} catch (error) {
this.logger.error('Failed to fetch account info', error);
this.accountInfo.set(null);
this.loadState.set('error');
}
}
get isActive(): boolean {
return this.accountInfo?.user_info?.status === 'Active';
private formatUnixDate(timestamp?: string): string {
const value = Number.parseInt(timestamp ?? '', 10);
if (!Number.isFinite(value) || value <= 0) {
return '-';
}
return new Date(value * 1000).toLocaleDateString();
}
get isTrial(): boolean {
return this.accountInfo?.user_info?.is_trial === '1';
private parseNumber(value?: string): number {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : 0;
}
}
@@ -32,26 +32,29 @@
</div>
</div>
<mat-form-field
appearance="outline"
class="search-field"
subscriptSizing="dynamic"
>
<mat-icon matPrefix>search</mat-icon>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
matInput
[ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)"
placeholder="{{
'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
}}"
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'XTREAM.CATEGORY_MANAGEMENT.SEARCH_PLACEHOLDER'
| translate
"
/>
@if (searchTerm()) {
<button matSuffix mat-icon-button (click)="clearSearch()">
<button
type="button"
mat-icon-button
class="search-inline__clear"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</mat-form-field>
</label>
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
@@ -43,17 +43,74 @@
}
}
.search-field {
width: 100%;
.search-inline {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
cursor: text;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
mat-icon[matPrefix] {
margin-right: 8px;
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
}
.search-inline__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.search-inline input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.84rem;
width: 100%;
min-width: 0;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.6;
}
&::-webkit-search-cancel-button {
display: none;
}
}
.search-inline__clear {
width: 24px;
height: 24px;
min-width: 24px;
line-height: 24px;
flex-shrink: 0;
padding: 0;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.categories-list {
height: 450px;
flex: 1;
min-height: 200px;
overflow-y: auto;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
@@ -62,9 +119,10 @@
.category-item {
display: flex;
align-items: center;
padding: 2px 10px;
padding: 0 10px;
min-height: 30px;
cursor: pointer;
gap: 8px;
gap: 6px;
transition: background-color 0.12s ease;
&:hover {
@@ -76,7 +134,7 @@
}
mat-checkbox {
--mdc-checkbox-state-layer-size: 32px;
--mdc-checkbox-state-layer-size: 28px;
}
}
@@ -99,7 +157,7 @@
.mat-mdc-dialog-content {
min-width: 480px;
max-width: 600px;
max-height: 75vh !important;
max-height: 85vh !important;
}
}
@@ -1,5 +1,4 @@
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import {
@@ -7,9 +6,7 @@ import {
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe } from '@ngx-translate/core';
@@ -32,11 +29,8 @@ interface CategoryWithSelection extends XCategoryFromDb {
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
FormsModule,
TranslatePipe,
],
templateUrl: './category-management-dialog.component.html',
@@ -1,5 +1,23 @@
<div class="channels-list">
@if (filteredChannels().length > 0) {
@if (isSelectedTypeContentLoading()) {
<div class="channels-loading-state" aria-live="polite">
<div class="channels-loading-state__copy">
<div class="channels-loading-state__title">
{{ 'CHANNELS.LOADING' | translate }}
</div>
</div>
<div class="channels-loading-state__list" aria-hidden="true">
@for (row of loadingRows; track row) {
<div class="channel-skeleton">
<span class="channel-skeleton__logo"></span>
<span class="channel-skeleton__title"></span>
<span class="channel-skeleton__meta"></span>
</div>
}
</div>
</div>
} @else if (filteredChannels().length > 0) {
<cdk-virtual-scroll-viewport
[itemSize]="68"
class="scroll-viewport-portals"
@@ -142,6 +142,102 @@
}
}
.channels-loading-state {
display: grid;
gap: 14px;
height: 100%;
padding: 16px 12px 18px;
}
.channels-loading-state__copy {
padding: 0 4px;
}
.channels-loading-state__title {
font-size: 0.86rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
.channels-loading-state__list {
display: grid;
gap: 8px;
overflow: hidden;
}
.channel-skeleton {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
grid-template-rows: 14px 10px;
align-items: center;
gap: 8px 12px;
min-height: 68px;
padding: 10px 12px;
border-radius: 14px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 12%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%,
transparent
);
}
.channel-skeleton__logo,
.channel-skeleton__title,
.channel-skeleton__meta {
position: relative;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 8%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
transparent
)
44%,
transparent 74%
);
transform: translateX(-120%);
animation: channel-skeleton-shimmer 1.15s ease-in-out infinite;
}
}
.channel-skeleton__logo {
grid-row: 1 / span 2;
width: 44px;
height: 44px;
border-radius: 12px;
}
.channel-skeleton__title {
height: 11px;
width: min(68%, 180px);
border-radius: 999px;
}
.channel-skeleton__meta {
height: 9px;
width: min(42%, 120px);
border-radius: 999px;
}
@container (max-width: 350px) {
.scroll-viewport-portals {
padding-inline: 6px;
@@ -159,3 +255,9 @@
transform: translateY(0);
}
}
@keyframes channel-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -0,0 +1,129 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import {
EpgQueueService,
FavoritesService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { PortalChannelsListComponent } from './portal-channels-list.component';
describe('PortalChannelsListComponent', () => {
let fixture: ComponentFixture<PortalChannelsListComponent>;
const selectedChannels = signal<unknown[]>([]);
const selectedItem = signal<unknown>(null);
const epgItems = signal<unknown[]>([]);
const selectedTypeContentLoading = signal(true);
const selectedContentType = signal('live');
const currentPlaylist = signal<unknown>(null);
const selectedCategoryId = signal<number | null>(1);
const storeSignals = {
selectItemsFromSelectedCategory: selectedChannels,
selectedItem,
epgItems,
selectedTypeContentLoading,
selectedContentType,
currentPlaylist,
selectedCategoryId,
setSelectedCategory: jest.fn(),
};
const epgResults$ = new Subject<{ streamId: number; items: unknown[] }>();
beforeEach(async () => {
storeSignals.setSelectedCategory.mockClear();
selectedChannels.set([]);
selectedItem.set(null);
epgItems.set([]);
selectedTypeContentLoading.set(true);
selectedContentType.set('live');
currentPlaylist.set(null);
selectedCategoryId.set(1);
await TestBed.configureTestingModule({
imports: [PortalChannelsListComponent, NoopAnimationsModule],
providers: [
{
provide: TranslateService,
useValue: {
instant: (key: string) =>
key === 'CHANNELS.LOADING'
? 'Loading channels...'
: key,
get: (key: string) =>
of(
key === 'CHANNELS.LOADING'
? 'Loading channels...'
: key
),
stream: (key: string) =>
of(
key === 'CHANNELS.LOADING'
? 'Loading channels...'
: key
),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: XtreamStore,
useValue: storeSignals,
},
{
provide: FavoritesService,
useValue: {
getFavorites: jest.fn().mockReturnValue(of([])),
},
},
{
provide: EpgQueueService,
useValue: {
epgResult$: epgResults$,
getCached: jest.fn().mockReturnValue(null),
enqueue: jest.fn(),
},
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
params: {},
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PortalChannelsListComponent);
});
it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.channels-loading-state')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('.empty-search-state')
).toBeNull();
});
it('renders the empty state once loading has finished and the selected category has no channels', () => {
selectedTypeContentLoading.set(false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.channels-loading-state')
).toBeNull();
expect(
fixture.nativeElement.querySelector('.empty-search-state')
).not.toBeNull();
});
});
@@ -69,6 +69,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
private readonly favoritesService = inject(FavoritesService);
private readonly epgQueueService = inject(EpgQueueService);
private readonly route = inject(ActivatedRoute);
readonly isSelectedTypeContentLoading =
this.xtreamStore.selectedTypeContentLoading;
readonly loadingRows = Array.from({ length: 9 }, (_, index) => index);
readonly channels = computed(() => {
const override = this.channelsOverride();
if (Array.isArray(override)) {
@@ -206,12 +209,18 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
// Apply cached results immediately
for (const channel of channels) {
const cached = this.epgQueueService.getCached(channel.xtream_id);
const previewProgram = this.pickPreviewProgram(cached ?? []);
if (previewProgram) {
if (!this.epgPrograms.has(channel.xtream_id)) {
this.applyProgram(channel.xtream_id, previewProgram);
if (cached !== null) {
const previewProgram = this.pickPreviewProgram(cached);
if (previewProgram) {
if (!this.epgPrograms.has(channel.xtream_id)) {
this.applyProgram(channel.xtream_id, previewProgram);
}
}
} else if (!this.epgPrograms.has(channel.xtream_id)) {
continue;
}
if (!this.epgPrograms.has(channel.xtream_id)) {
uncachedIds.push(channel.xtream_id);
}
}
@@ -255,11 +264,13 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
toggleFavorite(event: Event, item: XtreamChannelListItem) {
event.stopPropagation();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
this.xtreamStore
.toggleFavorite(
item.xtream_id,
this.xtreamStore.currentPlaylist().id
)
.toggleFavorite(item.xtream_id, playlistId)
.then((result: boolean) => {
if (result) {
this.favorites.set(item.xtream_id, true);
@@ -0,0 +1,120 @@
import { Component, input, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { provideRouter, RouterOutlet } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
XtreamContentInitBlockReason,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { XtreamContentGateComponent } from './xtream-content-gate.component';
@Component({
selector: 'app-playlist-error-view',
standalone: true,
template: `
<div class="mock-error">
<span class="mock-error__title">{{ title() }}</span>
<span class="mock-error__description">{{ description() }}</span>
</div>
`,
})
class MockPlaylistErrorViewComponent {
readonly title = input<string>('');
readonly description = input<string | undefined>(undefined);
}
describe('XtreamContentGateComponent', () => {
let fixture: ComponentFixture<XtreamContentGateComponent>;
const contentInitBlockReason =
signal<XtreamContentInitBlockReason | null>(null);
const retryContentInitialization = jest.fn().mockResolvedValue(undefined);
beforeEach(async () => {
contentInitBlockReason.set(null);
retryContentInitialization.mockClear();
await TestBed.configureTestingModule({
imports: [XtreamContentGateComponent],
providers: [
provideRouter([]),
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: XtreamStore,
useValue: {
contentInitBlockReason,
retryContentInitialization,
},
},
],
})
.overrideComponent(XtreamContentGateComponent, {
set: {
imports: [
MatButtonModule,
MatIconModule,
MockPlaylistErrorViewComponent,
RouterOutlet,
TranslatePipe,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(XtreamContentGateComponent);
});
it.each([
['cancelled', 'PORTALS.ERROR_VIEW.IMPORT_CANCELLED.TITLE'],
['expired', 'PORTALS.ERROR_VIEW.ACCOUNT_EXPIRED.TITLE'],
['inactive', 'PORTALS.ERROR_VIEW.ACCOUNT_INACTIVE.TITLE'],
['unavailable', 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'],
['error', 'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE'],
] as const)(
'renders the blocked error state for %s imports',
(reason, expectedTitleKey) => {
contentInitBlockReason.set(reason);
fixture.detectChanges();
const title = fixture.nativeElement.querySelector(
'.mock-error__title'
) as HTMLElement | null;
expect(title?.textContent?.trim()).toBe(expectedTitleKey);
}
);
it('keeps the child outlet available when there is no block reason', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.mock-error')
).toBeNull();
expect(fixture.nativeElement.querySelector('router-outlet')).not.toBeNull();
});
it('retries content initialization from the blocked state', () => {
contentInitBlockReason.set('cancelled');
fixture.detectChanges();
const retryButton = fixture.nativeElement.querySelector(
'button'
) as HTMLButtonElement | null;
retryButton?.click();
expect(retryContentInitialization).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,94 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import {
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
XtreamContentInitBlockReason,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
@Component({
selector: 'app-xtream-content-gate',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
PlaylistErrorViewComponent,
RouterOutlet,
TranslatePipe,
],
template: `
@if (contentInitBlockReason(); as blockReason) {
<div class="xtream-content-gate">
<app-playlist-error-view
[title]="titleKey() | translate"
[description]="descriptionKey() | translate"
/>
<div class="xtream-content-gate__actions">
<button
mat-flat-button
color="primary"
type="button"
(click)="retryContentInitialization()"
>
<mat-icon>refresh</mat-icon>
{{ 'DOWNLOADS.RETRY' | translate }}
</button>
</div>
</div>
} @else {
<router-outlet />
}
`,
styles: [
`
.xtream-content-gate {
display: grid;
gap: 16px;
justify-items: center;
padding: 24px;
}
.xtream-content-gate__actions {
display: flex;
justify-content: center;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class XtreamContentGateComponent {
private readonly xtreamStore = inject(XtreamStore);
readonly contentInitBlockReason = this.xtreamStore.contentInitBlockReason;
private readonly errorViewKey = computed(() => {
switch (this.contentInitBlockReason()) {
case 'cancelled':
return 'IMPORT_CANCELLED';
case 'expired':
return 'ACCOUNT_EXPIRED';
case 'inactive':
return 'ACCOUNT_INACTIVE';
case 'unavailable':
return 'PORTAL_UNAVAILABLE';
case 'error':
default:
return 'UNKNOWN_ERROR';
}
});
readonly titleKey = computed(
() => `PORTALS.ERROR_VIEW.${this.errorViewKey()}.TITLE`
);
readonly descriptionKey = computed(
() => `PORTALS.ERROR_VIEW.${this.errorViewKey()}.DESCRIPTION`
);
retryContentInitialization(): void {
void this.xtreamStore.retryContentInitialization();
}
}
@@ -0,0 +1,28 @@
import { createXtreamRoutes } from './xtream-feature.routes';
describe('createXtreamRoutes', () => {
it('keeps import-driven routes behind the content gate and leaves collections outside it', () => {
const [xtreamRoute] = createXtreamRoutes();
const gateRoute = xtreamRoute.children?.find(
(route) => route.path === '' && typeof route.loadComponent === 'function'
);
expect(gateRoute?.children?.map((route) => route.path)).toEqual(
expect.arrayContaining([
'live',
'live/:categoryId',
'vod',
'series',
'search',
'recently-added',
])
);
expect(xtreamRoute.children?.find((route) => route.path === 'favorites'))
.toMatchObject({ path: 'favorites' });
expect(xtreamRoute.children?.find((route) => route.path === 'recent'))
.toMatchObject({ path: 'recent' });
expect(xtreamRoute.children?.find((route) => route.path === 'downloads'))
.toMatchObject({ path: 'downloads' });
});
});
@@ -9,6 +9,11 @@ const loadDownloadsComponent: ComponentLoader = () =>
(c) => c.DownloadsComponent
);
const loadXtreamContentGateComponent: ComponentLoader = () =>
import('./xtream-content-gate.component').then(
(c) => c.XtreamContentGateComponent
);
const loadLiveStreamLayoutComponent: ComponentLoader = () =>
import('./live-stream-layout/live-stream-layout.component').then(
(c) => c.LiveStreamLayoutComponent
@@ -56,46 +61,60 @@ export function createXtreamRoutes(): Route[] {
pathMatch: 'full',
},
{
path: 'live',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: 'live/:categoryId',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: 'vod',
providers: provideXtreamCatalogFacade(),
path: '',
loadComponent: loadXtreamContentGateComponent,
children: [
{
path: '',
loadComponent: loadCategoryContentViewComponent,
path: 'live',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: ':categoryId',
loadComponent: loadCategoryContentViewComponent,
path: 'live/:categoryId',
loadComponent: loadLiveStreamLayoutComponent,
},
{
path: ':categoryId/:vodId',
loadComponent: loadVodDetailsRouteComponent,
},
],
},
{
path: 'series',
providers: provideXtreamCatalogFacade(),
children: [
{
path: '',
loadComponent: loadCategoryContentViewComponent,
path: 'vod',
providers: provideXtreamCatalogFacade(),
children: [
{
path: '',
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId',
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId/:vodId',
loadComponent: loadVodDetailsRouteComponent,
},
],
},
{
path: ':categoryId',
loadComponent: loadCategoryContentViewComponent,
path: 'series',
providers: provideXtreamCatalogFacade(),
children: [
{
path: '',
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId',
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId/:serialId',
loadComponent: loadSerialDetailsComponent,
},
],
},
{
path: ':categoryId/:serialId',
loadComponent: loadSerialDetailsComponent,
path: 'search',
loadComponent: loadSearchResultsComponent,
},
{
path: 'recently-added',
loadComponent: loadRecentlyAddedComponent,
},
],
},
@@ -109,14 +128,6 @@ export function createXtreamRoutes(): Route[] {
loadComponent: loadXtreamCollectionRouteComponent,
data: { mode: 'recent', portalType: 'xtream' },
},
{
path: 'search',
loadComponent: loadSearchResultsComponent,
},
{
path: 'recently-added',
loadComponent: loadRecentlyAddedComponent,
},
{
path: 'downloads',
loadComponent: loadDownloadsComponent,
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more