mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
Merge pull request #853 from 4gray/feat/workspace-dashboard-improvements
Feat: xtream and workspace improvements
This commit is contained in:
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 }> {
|
||||
|
||||
@@ -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>;
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Палитра команд",
|
||||
|
||||
Vendored
+21
-1
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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,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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+175
@@ -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...'
|
||||
);
|
||||
});
|
||||
});
|
||||
+35
-1
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+2
@@ -118,9 +118,11 @@
|
||||
[items]="liveItems()"
|
||||
[mode]="mode()"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
[autoOpenItem]="pendingAutoOpenLiveItem()"
|
||||
(removeItem)="onRemoveItem($event)"
|
||||
(reorderItems)="onReorder($event)"
|
||||
(itemPlayed)="onItemPlayed($event)"
|
||||
(autoOpenHandled)="onLiveAutoOpenHandled()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
+187
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
+64
-2
@@ -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]);
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+70
-32
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
+12
-1
@@ -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,
|
||||
})
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+12
-1
@@ -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:
|
||||
|
||||
+8
@@ -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: {
|
||||
|
||||
+8
@@ -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 {
|
||||
|
||||
+57
-10
@@ -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;
|
||||
|
||||
+361
-40
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
-14
@@ -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) {
|
||||
|
||||
+67
-9
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-6
@@ -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',
|
||||
|
||||
+19
-1
@@ -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"
|
||||
|
||||
+102
@@ -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%);
|
||||
}
|
||||
}
|
||||
+129
@@ -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();
|
||||
});
|
||||
});
|
||||
+20
-9
@@ -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
Reference in new issue
Block a user