diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 8ab3a5b38..a7696d3a9 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -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 { + 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 { + 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 }> { diff --git a/apps/electron-backend/build-worker.js b/apps/electron-backend/build-worker.js index c35b03aec..7d7d7c48f 100644 --- a/apps/electron-backend/build-worker.js +++ b/apps/electron-backend/build-worker.js @@ -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' diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index a552a2606..1d20e000b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.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; 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, diff --git a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts new file mode 100644 index 000000000..6992f28f2 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts @@ -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(); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index e20c8add6..bc9d1828f 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -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) diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index e513f938f..efc8e59dc 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -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, diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts new file mode 100644 index 000000000..6b3416117 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -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(), + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 96e66b627..bd9e4cdef 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -139,26 +139,12 @@ function buildPlaylistRow( }; } -function parseAppPlaylist(row: schema.Playlist): Record { +export function parseAppPlaylist(row: schema.Playlist): Record { const payload = parseJsonValue | 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(row.favorites, []); const recentlyViewed = parseJsonValue(row.recentlyViewed, []); const importDate = @@ -166,30 +152,42 @@ function parseAppPlaylist(row: schema.Playlist): Record { 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), }; } diff --git a/apps/electron-backend/src/app/events/database/category.events.ts b/apps/electron-backend/src/app/events/database/category.events.ts index 8c9dcab11..e25d97bce 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -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[] diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 7e0a2bc56..655c8b65d 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -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) => ({ diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index 171ef5a84..c0035dfaf 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -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(); + /** * 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((resolve, reject) => { + const cleanup = async (): Promise => { + 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) => { + 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; + 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({ diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts new file mode 100644 index 000000000..743338f48 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -0,0 +1,168 @@ +import { XTREAM_CANCEL_SESSION } from 'shared-interfaces'; + +const registeredHandlers = new Map unknown>(); +const axiosMock = Object.assign(jest.fn(), { + isAxiosError: jest.fn(), +}); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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; + + 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; + const secondPromise = requestHandler?.({}, { + url: 'http://localhost:3211', + params: { + action: 'get_vod_streams', + password: 'secret', + username: 'user1', + }, + sessionId: 'session-2', + suppressErrorLog: true, + }) as Promise; + + 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' }); + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 9bf4026d5..73f21485f 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -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; 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(); diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 797a4bd6c..5967e5495 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -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', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index e8e48a051..825b28b24 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -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; diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts new file mode 100644 index 000000000..959ce6bed --- /dev/null +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -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(); + +if (!parentPort) { + throw new Error('Playlist refresh worker must be started with a parent port'); +} + +function postMessage(message: PlaylistRefreshWorkerMessage): 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 +): 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 { + 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 { + 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 { + 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' }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts new file mode 100644 index 000000000..ed6607311 --- /dev/null +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.types.ts @@ -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 { + type: 'response'; + success: boolean; + result?: TResult; + error?: { + name?: string; + message: string; + stack?: string; + }; +} + +export type PlaylistRefreshWorkerIncomingMessage = + | PlaylistRefreshWorkerRequestMessage + | PlaylistRefreshWorkerCancelMessage; + +export type PlaylistRefreshWorkerMessage = + | PlaylistRefreshWorkerReadyMessage + | PlaylistRefreshWorkerEventMessage + | PlaylistRefreshWorkerResponseMessage; diff --git a/apps/web/src/app/app.component.spec.ts b/apps/web/src/app/app.component.spec.ts index b5a6ccc92..7792752d1 100644 --- a/apps/web/src/app/app.component.spec.ts +++ b/apps/web/src/app/app.component.spec.ts @@ -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: '', diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 8c7741be6..5ee8a3d91 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -1,8 +1,10 @@ describe('app routes', () => { let workspaceChildren: Array<{ + canActivate?: unknown[]; data?: Record; 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); + }); }); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 6e2d36a85..8ff52ba1b 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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 diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index cadc61797..b5b23de6e 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -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; 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); diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 3d090343e..b3d20c234 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -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; 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); diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 9f1fc5c8e..e9cfb7f13 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -119,6 +119,62 @@ +
+
+

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

+

+ {{ + 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ +
+
+

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

+

+ {{ + 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' + | translate + }} +

+
+
+ + {{ + 'SETTINGS.STARTUP_BEHAVIOR' | translate + }} + + @for ( + option of startupBehaviorOptions; + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + +
+
+

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index 9c5f793c2..a504c8a54 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -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); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index b324a59ae..d532ead66 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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: '', diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index a9625b81e..f1cda9069 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 197a73723..81f628aae 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index fad1fccec..505f422ff 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Палитра команд", diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index c0bd3b027..a17d42fab 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -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; platform: string; fetchPlaylistByUrl: ( @@ -119,8 +125,18 @@ declare global { url: string; params: Record; requestId?: string; + sessionId?: string; suppressErrorLog?: boolean; }) => Promise<{ payload: unknown; action: string }>; + xtreamCancelSession: ( + sessionId: string + ) => Promise<{ success: boolean; cancelled: number }>; + refreshPlaylist: ( + payload: PlaylistRefreshPayload + ) => Promise; + cancelPlaylistRefresh: ( + operationId: string + ) => Promise<{ success: boolean }>; // Database operations dbCreatePlaylist: ( playlist: Playlist @@ -176,7 +192,7 @@ declare global { ) => Promise; 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, diff --git a/global.d.ts b/global.d.ts index 4b89b4696..b47580a28 100644 --- a/global.d.ts +++ b/global.d.ts @@ -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; platform: string; fetchPlaylistByUrl: ( @@ -113,7 +120,18 @@ declare global { url: string; params: Record; requestId?: string; + sessionId?: string; + suppressErrorLog?: boolean; }) => Promise<{ payload: any; action: string }>; + xtreamCancelSession: ( + sessionId: string + ) => Promise<{ success: boolean; cancelled: number }>; + refreshPlaylist: ( + payload: PlaylistRefreshPayload + ) => Promise; + cancelPlaylistRefresh: ( + operationId: string + ) => Promise<{ success: boolean }>; // Database operations dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; dbGetPlaylist: (playlistId: string) => Promise; @@ -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, diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts new file mode 100644 index 000000000..bd2f67b1a --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts @@ -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 + ); + }); +}); diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index 4703ca092..2f413c808 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -122,6 +122,9 @@ export const playlistReducers = [ ...(p.recentlyViewed != null ? { recentlyViewed: p.recentlyViewed } : {}), + ...(p.updateDate !== undefined + ? { updateDate: p.updateDate } + : {}), }, }, state.playlists diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index f5c8dcb59..b62643b34 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -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(); @@ -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 | 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 }) => { + 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', diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 46dbb5279..bd13efd1c 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -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 { + 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 { @@ -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; + } } diff --git a/libs/playlist/shared/util/jest.config.ts b/libs/playlist/shared/util/jest.config.ts new file mode 100644 index 000000000..264b8c141 --- /dev/null +++ b/libs/playlist/shared/util/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'playlist-shared-util', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/playlist/shared/util', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/playlist/shared/util/project.json b/libs/playlist/shared/util/project.json index 9b55122b0..5c3b6a5c1 100644 --- a/libs/playlist/shared/util/project.json +++ b/libs/playlist/shared/util/project.json @@ -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" } diff --git a/libs/playlist/shared/util/src/index.ts b/libs/playlist/shared/util/src/index.ts index 68b5f7871..3cc1fb482 100644 --- a/libs/playlist/shared/util/src/index.ts +++ b/libs/playlist/shared/util/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/playlist-player-actions'; export * from './lib/playlist-context.facade'; +export * from './lib/playlist-refresh-action.service'; diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts new file mode 100644 index 000000000..2678e3547 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts @@ -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 { + 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 | 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 }) => { + 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(); + }); +}); diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts new file mode 100644 index 000000000..6e895c4a0 --- /dev/null +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts @@ -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 { + 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' + ); + } +} diff --git a/libs/playlist/shared/util/src/test-setup.ts b/libs/playlist/shared/util/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/playlist/shared/util/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/playlist/shared/util/tsconfig.spec.json b/libs/playlist/shared/util/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/playlist/shared/util/tsconfig.spec.json @@ -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" + ] +} diff --git a/libs/portal/catalog/feature/jest.config.ts b/libs/portal/catalog/feature/jest.config.ts new file mode 100644 index 000000000..7f2920c30 --- /dev/null +++ b/libs/portal/catalog/feature/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portal-catalog-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/catalog/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/portal/catalog/feature/project.json b/libs/portal/catalog/feature/project.json index b5eec6cb8..58afa7f34 100644 --- a/libs/portal/catalog/feature/project.json +++ b/libs/portal/catalog/feature/project.json @@ -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" } diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts new file mode 100644 index 000000000..3efd82a4a --- /dev/null +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -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(); + readonly items = input(); + readonly pageIndex = input(); + readonly totalPages = input(); + readonly limit = input(); + readonly pageSizeOptions = input(); + readonly showPaginator = input(true); + readonly itemClicked = output(); + readonly pageChange = output(); +} + +@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; + const isPaginatedContentLoading = signal(true); + const categoryItemCount = signal(0); + const contentSortMode = signal(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([]), + 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...' + ); + }); +}); diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 1fc58951e..7d17b2822 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -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', + ]); + } } diff --git a/libs/portal/catalog/feature/src/test-setup.ts b/libs/portal/catalog/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/catalog/feature/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/portal/catalog/feature/tsconfig.json b/libs/portal/catalog/feature/tsconfig.json new file mode 100644 index 000000000..7d33479d8 --- /dev/null +++ b/libs/portal/catalog/feature/tsconfig.json @@ -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" + } + ] +} diff --git a/libs/portal/catalog/feature/tsconfig.lib.json b/libs/portal/catalog/feature/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/portal/catalog/feature/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"] +} diff --git a/libs/portal/catalog/feature/tsconfig.spec.json b/libs/portal/catalog/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/catalog/feature/tsconfig.spec.json @@ -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" + ] +} diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index a6cecbeab..072f639f8 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -167,6 +167,18 @@
+ @switch (item.status) { @case ('queued') { + + } @else if (loadState() === 'loading') { + + } @else if (accountInfo(); as info) { + + } - - diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss index 3cfbfb0b4..f553b6fb3 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss @@ -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; - } -} \ No newline at end of file +} diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts index e9b7edc33..ee42f608a 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts @@ -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('loading'); + readonly accountInfo = signal(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(() => { + 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(() => [ + { + 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(() => [ + { + 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(() => [ + { + 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 { 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; } } diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html index 375ef6d6d..4d4f30e2e 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html @@ -32,26 +32,29 @@
- - search + +
@for (category of filteredCategories(); track category.id) { diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss index f617e264d..2f1944edf 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss @@ -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; } } diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index e00a7901b..44e1cac81 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -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', diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 17cf69250..57166dd1e 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -1,5 +1,23 @@
- @if (filteredChannels().length > 0) { + @if (isSelectedTypeContentLoading()) { +
+
+
+ {{ 'CHANNELS.LOADING' | translate }} +
+
+ + +
+ } @else if (filteredChannels().length > 0) { { + let fixture: ComponentFixture; + const selectedChannels = signal([]); + const selectedItem = signal(null); + const epgItems = signal([]); + const selectedTypeContentLoading = signal(true); + const selectedContentType = signal('live'); + const currentPlaylist = signal(null); + const selectedCategoryId = signal(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(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 6549e0849..e3386a1af 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -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); diff --git a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts new file mode 100644 index 000000000..ffe4cc547 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.spec.ts @@ -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: ` +
+ {{ title() }} + {{ description() }} +
+ `, +}) +class MockPlaylistErrorViewComponent { + readonly title = input(''); + readonly description = input(undefined); +} + +describe('XtreamContentGateComponent', () => { + let fixture: ComponentFixture; + const contentInitBlockReason = + signal(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); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts new file mode 100644 index 000000000..dd02b0509 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-content-gate.component.ts @@ -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) { +
+ + +
+ +
+
+ } @else { + + } + `, + 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(); + } +} diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts new file mode 100644 index 000000000..4af1c064f --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.spec.ts @@ -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' }); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts index ce1c58321..c8942e037 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts new file mode 100644 index 000000000..6ca8ca9a8 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -0,0 +1,179 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { NavigationEnd, Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + PortalStatusType, + XtreamContentInitBlockReason, + XtreamPlaylistData, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { PlaylistMeta } from 'shared-interfaces'; +import { XtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service'; + +const PLAYLIST_ID = 'playlist-1'; +const ACTIVE_PLAYLIST: PlaylistMeta = { + _id: PLAYLIST_ID, + filename: 'xtream.m3u', + password: 'secret', + serverUrl: 'http://localhost:3211', + title: 'Test Xtream', + username: 'user1', +} as PlaylistMeta; + +const XTREAM_PLAYLIST: XtreamPlaylistData = { + id: PLAYLIST_ID, + name: 'Test Xtream', + title: 'Test Xtream', + serverUrl: 'http://localhost:3211', + username: 'user1', + password: 'secret', + type: 'xtream', +}; + +async function flushEffects(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('XtreamWorkspaceRouteSession', () => { + const routerEvents = new Subject(); + const routeProvider = signal<'xtreams' | null>('xtreams'); + const routePlaylistId = signal(PLAYLIST_ID); + const activePlaylist = signal(ACTIVE_PLAYLIST); + const currentPlaylist = signal(XTREAM_PLAYLIST); + const playlistId = signal(PLAYLIST_ID); + const portalStatus = signal('active'); + const contentInitBlockReason = + signal(null); + + const playlistContext = { + routeProvider, + routePlaylistId, + activePlaylist, + syncFromUrl: jest.fn(), + }; + + const xtreamStore = { + currentPlaylist, + playlistId, + portalStatus, + resetStore: jest.fn((nextPlaylistId?: string) => { + playlistId.set(nextPlaylistId ?? null); + currentPlaylist.set(null); + }), + setCurrentPlaylist: jest.fn((playlist: XtreamPlaylistData | null) => { + currentPlaylist.set(playlist); + }), + fetchXtreamPlaylist: jest.fn().mockResolvedValue(undefined), + checkPortalStatus: jest.fn(), + contentInitBlockReason, + initializeContent: jest.fn().mockResolvedValue(undefined), + setSelectedContentType: jest.fn(), + setContentInitBlockReason: jest.fn( + (reason: XtreamContentInitBlockReason | null) => { + contentInitBlockReason.set(reason); + } + ), + }; + + const router = { + url: `/workspace/xtreams/${PLAYLIST_ID}/vod`, + events: routerEvents.asObservable(), + }; + + beforeEach(async () => { + router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod`; + routeProvider.set('xtreams'); + routePlaylistId.set(PLAYLIST_ID); + activePlaylist.set(ACTIVE_PLAYLIST); + currentPlaylist.set(XTREAM_PLAYLIST); + playlistId.set(PLAYLIST_ID); + portalStatus.set('active'); + contentInitBlockReason.set(null); + + playlistContext.syncFromUrl.mockImplementation((url: string) => ({ + inWorkspace: true, + provider: 'xtreams', + playlistId: PLAYLIST_ID, + section: url.endsWith('/favorites') ? 'favorites' : 'vod', + })); + + xtreamStore.resetStore.mockClear(); + xtreamStore.setCurrentPlaylist.mockClear(); + xtreamStore.fetchXtreamPlaylist.mockClear(); + xtreamStore.checkPortalStatus.mockReset(); + xtreamStore.initializeContent.mockClear(); + xtreamStore.setSelectedContentType.mockClear(); + xtreamStore.setContentInitBlockReason.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + XtreamWorkspaceRouteSession, + { + provide: PlaylistContextFacade, + useValue: playlistContext, + }, + { + provide: Router, + useValue: router, + }, + { + provide: XtreamStore, + useValue: xtreamStore, + }, + ], + }); + }); + + it('initializes content for active import-driven routes', async () => { + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('active'); + return 'active'; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + + expect(xtreamStore.checkPortalStatus).toHaveBeenCalled(); + expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(null); + expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod'); + expect(xtreamStore.initializeContent).toHaveBeenCalled(); + }); + + it.each(['expired', 'inactive', 'unavailable'] as const)( + 'blocks %s portals before import-driven initialization starts', + async (status) => { + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set(status); + return status; + }); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + + expect(xtreamStore.checkPortalStatus).toHaveBeenCalled(); + expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith( + status + ); + expect(xtreamStore.initializeContent).not.toHaveBeenCalled(); + } + ); + + it('does not clear a cancelled block while an active portal bootstrap finishes', async () => { + xtreamStore.checkPortalStatus.mockImplementation(async () => { + portalStatus.set('active'); + return 'active'; + }); + contentInitBlockReason.set('cancelled'); + + TestBed.inject(XtreamWorkspaceRouteSession); + await flushEffects(); + + expect(xtreamStore.checkPortalStatus).toHaveBeenCalled(); + expect(xtreamStore.setContentInitBlockReason).not.toHaveBeenCalledWith( + null + ); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 3266fb9a4..6efb3f4d0 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -12,6 +12,7 @@ import { filter } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PortalRailSection } from '@iptvnator/portal/shared/util'; import { + PortalStatusType, XtreamPlaylistData, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; @@ -44,6 +45,29 @@ function toXtreamPlaylistData( }; } +function isImportDrivenSection(section: PortalRailSection | null): boolean { + return ( + section === 'vod' || + section === 'live' || + section === 'series' || + section === 'search' || + section === 'recently-added' + ); +} + +function toContentInitBlockReason( + portalStatus: PortalStatusType +): 'expired' | 'inactive' | 'unavailable' | null { + switch (portalStatus) { + case 'expired': + case 'inactive': + case 'unavailable': + return portalStatus; + default: + return null; + } +} + @Injectable() export class XtreamWorkspaceRouteSession { private readonly destroyRef = inject(DestroyRef); @@ -53,6 +77,8 @@ export class XtreamWorkspaceRouteSession { private currentPlaylistId: string | null = null; private currentPlaylistUpdateDate: number | null = null; + private syncInFlight = false; + private syncPending = false; constructor() { effect(() => { @@ -68,7 +94,7 @@ export class XtreamWorkspaceRouteSession { return; } - void this.syncRouteContext(); + this.scheduleSyncRouteContext(); }); this.router.events @@ -80,10 +106,30 @@ export class XtreamWorkspaceRouteSession { takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { - void this.syncRouteContext(); + this.scheduleSyncRouteContext(); }); - void this.syncRouteContext(); + this.scheduleSyncRouteContext(); + } + + private scheduleSyncRouteContext(): void { + if (this.syncInFlight) { + this.syncPending = true; + return; + } + + this.syncInFlight = true; + + void (async () => { + try { + do { + this.syncPending = false; + await this.syncRouteContext(); + } while (this.syncPending); + } finally { + this.syncInFlight = false; + } + })(); } private async syncRouteContext(): Promise { @@ -92,6 +138,7 @@ export class XtreamWorkspaceRouteSession { routeContext.provider === 'xtreams' ? routeContext.playlistId : null; + const section = this.syncRouteState(routeContext.section); const routePlaylist = routeContext.provider === 'xtreams' ? toXtreamPlaylistData(this.playlistContext.activePlaylist()) @@ -104,6 +151,7 @@ export class XtreamWorkspaceRouteSession { (currentPlaylist?.id !== playlistId || this.currentPlaylistUpdateDate !== routePlaylistUpdateDate) ); + let portalStatus = this.xtreamStore.portalStatus(); if ( playlistId && @@ -118,10 +166,23 @@ export class XtreamWorkspaceRouteSession { this.xtreamStore.setCurrentPlaylist(routePlaylist); await this.xtreamStore.fetchXtreamPlaylist(); - await this.xtreamStore.checkPortalStatus(); + portalStatus = await this.xtreamStore.checkPortalStatus(); + const nextBlockReason = toContentInitBlockReason(portalStatus); + const currentBlockReason = + this.xtreamStore.contentInitBlockReason(); + + if ( + nextBlockReason !== null || + currentBlockReason !== 'cancelled' + ) { + this.xtreamStore.setContentInitBlockReason(nextBlockReason); + } + } + + if (isImportDrivenSection(section) && portalStatus !== 'active') { + return; } - const section = this.syncRouteState(routeContext.section); await this.initializeCurrentSectionContent(section); } @@ -150,11 +211,7 @@ export class XtreamWorkspaceRouteSession { } if ( - section === 'vod' || - section === 'live' || - section === 'series' || - section === 'search' || - section === 'recently-added' + isImportDrivenSection(section) ) { await this.xtreamStore.initializeContent(); } diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 65faf83a6..887f0a323 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/data.service'; export * from './lib/database-electron.service'; export * from './lib/downloads.service'; export * from './lib/playback-position.service'; +export * from './lib/playlist-refresh.service'; export * from './lib/playlists.service'; export * from './lib/portal-status.service'; export * from './lib/settings-store.service'; diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 09638abed..bf62ce1d2 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -99,6 +99,13 @@ export interface DbOperationOptions { onEvent?: (event: DbOperationEvent) => void; } +export type XtreamImportStatus = + | 'idle' + | 'importing' + | 'completed' + | 'cancelled' + | 'failed'; + export function isDbAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError'; } @@ -115,6 +122,13 @@ export interface GlobalRecentlyAddedItem extends XtreamContent { providedIn: 'root', }) export class DatabaseService { + private buildXtreamImportStateKey( + playlistId: string, + type: 'live' | 'movie' | 'series' + ): string { + return `xtream-import-status:${playlistId}:${type}`; + } + createOperationId(prefix = 'db-op'): string { return ( globalThis.crypto?.randomUUID?.() ?? @@ -459,6 +473,69 @@ export class DatabaseService { } } + async clearXtreamImportCache( + playlistId: string, + type: 'live' | 'movie' | 'series' + ): Promise { + try { + await window.electron.dbClearXtreamImportCache(playlistId, type); + return true; + } catch (error) { + console.error('Error clearing Xtream import cache:', error); + return false; + } + } + + async getAppState(key: string): Promise { + try { + return await window.electron.dbGetAppState(key); + } catch (error) { + console.error('Error getting app state:', error); + return null; + } + } + + async setAppState(key: string, value: string): Promise { + try { + await window.electron.dbSetAppState(key, value); + return true; + } catch (error) { + console.error('Error setting app state:', error); + return false; + } + } + + async getXtreamImportStatus( + playlistId: string, + type: 'live' | 'movie' | 'series' + ): Promise { + const value = await this.getAppState( + this.buildXtreamImportStateKey(playlistId, type) + ); + + switch (value) { + case 'importing': + case 'completed': + case 'cancelled': + case 'failed': + case 'idle': + return value; + default: + return 'idle'; + } + } + + async setXtreamImportStatus( + playlistId: string, + type: 'live' | 'movie' | 'series', + status: XtreamImportStatus + ): Promise { + return this.setAppState( + this.buildXtreamImportStateKey(playlistId, type), + status + ); + } + /** * Search content within a playlist */ diff --git a/libs/services/src/lib/playlist-refresh.service.ts b/libs/services/src/lib/playlist-refresh.service.ts new file mode 100644 index 000000000..a38f10218 --- /dev/null +++ b/libs/services/src/lib/playlist-refresh.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@angular/core'; +import { + Playlist, + PlaylistRefreshEvent, + PlaylistRefreshPayload, +} from 'shared-interfaces'; + +export interface PlaylistRefreshOptions { + onEvent?: (event: PlaylistRefreshEvent) => void; +} + +@Injectable({ + providedIn: 'root', +}) +export class PlaylistRefreshService { + async refreshPlaylist( + payload: PlaylistRefreshPayload, + options?: PlaylistRefreshOptions + ): Promise { + if (!window.electron?.refreshPlaylist) { + throw new Error('Playlist refresh is only available in Electron'); + } + + const unsubscribe = window.electron.onPlaylistRefreshEvent?.((event) => { + if (event.operationId !== payload.operationId) { + return; + } + + options?.onEvent?.(event); + }); + + try { + return await window.electron.refreshPlaylist(payload); + } finally { + unsubscribe?.(); + } + } + + async cancelRefresh(operationId: string): Promise { + if (!window.electron?.cancelPlaylistRefresh || !operationId) { + return false; + } + + try { + const result = await window.electron.cancelPlaylistRefresh(operationId); + return result.success; + } catch (error) { + console.error('Failed to cancel playlist refresh:', error); + return false; + } + } +} diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index b2320aa59..08c718915 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -11,6 +11,7 @@ import { firstValueFrom } from 'rxjs'; import { Language, Settings, + StartupBehavior, STORE_KEY, StreamFormat, Theme, @@ -22,6 +23,8 @@ const DEFAULT_SETTINGS: Settings = { streamFormat: StreamFormat.M3u8StreamFormat, language: Language.ENGLISH, showCaptions: false, + showDashboard: true, + startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, theme: Theme.SystemTheme, mpvPlayerPath: '', @@ -74,6 +77,8 @@ export const SettingsStore = signalStore( streamFormat: store.streamFormat(), language: store.language(), showCaptions: store.showCaptions(), + showDashboard: store.showDashboard(), + startupBehavior: store.startupBehavior(), showExternalPlaybackBar: store.showExternalPlaybackBar(), theme: store.theme(), mpvPlayerPath: store.mpvPlayerPath(), diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 61899cf1a..9081c7df9 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -12,6 +12,7 @@ export * from './lib/language.enum'; export * from './lib/parsed-playlist.interface'; export * from './lib/playback-position.interface'; export * from './lib/playlist-meta.type'; +export * from './lib/playlist-refresh.interface'; export * from './lib/playlist-recently-viewed.interface'; export * from './lib/playlist-recently-viewed.utils'; export * from './lib/playlist.interface'; diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 38779a707..c26b14708 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -12,6 +12,9 @@ export const EPG_FORCE_FETCH = 'EPG:EPG_FORCE_FETCH'; // Playlist related commands export const PLAYLIST_PARSE_BY_URL = 'PLAYLIST:PARSE_PLAYLIST_BY_URL'; export const PLAYLIST_UPDATE = 'PLAYLIST:UPDATE'; +export const PLAYLIST_REFRESH = 'PLAYLIST:REFRESH'; +export const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT'; +export const PLAYLIST_CANCEL_REFRESH = 'PLAYLIST:CANCEL_REFRESH'; // General export const ERROR = 'ERROR'; @@ -38,6 +41,7 @@ export const EXTERNAL_PLAYER_SESSION_UPDATE = // Xtream export const XTREAM_REQUEST = 'XTREAM_REQUEST'; export const XTREAM_RESPONSE = 'XTREAM_RESPONSE'; +export const XTREAM_CANCEL_SESSION = 'XTREAM_CANCEL_SESSION'; // Stalker export const STALKER_REQUEST = 'STALKER_REQUEST'; diff --git a/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts new file mode 100644 index 000000000..87ad10681 --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-refresh.interface.ts @@ -0,0 +1,28 @@ +export type PlaylistRefreshPhase = + | 'fetching' + | 'reading-file' + | 'parsing' + | 'saving'; + +export type PlaylistRefreshStatus = + | 'started' + | 'progress' + | 'completed' + | 'cancelled' + | 'error'; + +export interface PlaylistRefreshEvent { + operationId: string; + playlistId: string; + phase?: PlaylistRefreshPhase; + status: PlaylistRefreshStatus; + error?: string; +} + +export interface PlaylistRefreshPayload { + operationId: string; + playlistId: string; + title: string; + filePath?: string; + url?: string; +} diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 6078de6bd..78a74fcab 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -13,6 +13,11 @@ export enum VideoPlayer { ArtPlayer = 'artplayer', } +export enum StartupBehavior { + FirstView = 'first-view', + RestoreLastView = 'restore-last-view', +} + /** * Describes all available settings options of the application */ @@ -22,6 +27,8 @@ export interface Settings { streamFormat: StreamFormat; language: Language; showCaptions: boolean; + showDashboard: boolean; + startupBehavior: StartupBehavior; /** Show the desktop footer bar for external playback status */ showExternalPlaybackBar?: boolean; theme: Theme; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index b85e01e43..5845814dd 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -2,7 +2,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { selectAllPlaylistsMeta } from 'm3u-state'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from 'm3u-state'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; import { Playlist, PlaylistMeta } from 'shared-interfaces'; @@ -10,6 +13,7 @@ import { DashboardDataService } from './dashboard-data.service'; describe('DashboardDataService', () => { let service: DashboardDataService; + const playlistsLoadedSignal = signal(true); const playlistsSignal = signal([ { @@ -50,6 +54,9 @@ describe('DashboardDataService', () => { if (selector === selectAllPlaylistsMeta) { return playlistsSignal; } + if (selector === selectPlaylistsLoadingFlag) { + return playlistsLoadedSignal; + } return signal(null); }), dispatch: jest.fn(), @@ -58,7 +65,7 @@ describe('DashboardDataService', () => { const dbServiceMock = { getGlobalRecentlyAdded: jest.fn().mockResolvedValue([]), getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]), - getGlobalFavorites: jest.fn().mockResolvedValue([]), + getAllGlobalFavorites: jest.fn().mockResolvedValue([]), removeFromFavorites: jest.fn().mockResolvedValue(undefined), removeRecentItem: jest.fn().mockResolvedValue(undefined), }; @@ -113,6 +120,7 @@ describe('DashboardDataService', () => { value: {} as Window['electron'], configurable: true, }); + playlistsLoadedSignal.set(true); playlistsServiceMock.getPlaylistById.mockClear(); playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock)); playlistsServiceMock.setFavorites.mockClear(); @@ -124,8 +132,8 @@ describe('DashboardDataService', () => { recentlyViewed: [], }) ); - dbServiceMock.getGlobalFavorites.mockClear(); - dbServiceMock.getGlobalFavorites.mockResolvedValue([]); + dbServiceMock.getAllGlobalFavorites.mockClear(); + dbServiceMock.getAllGlobalFavorites.mockResolvedValue([]); dbServiceMock.getGlobalRecentlyAdded.mockClear(); dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([]); dbServiceMock.getGlobalRecentlyViewed.mockClear(); @@ -186,6 +194,60 @@ describe('DashboardDataService', () => { ); }); + it('includes Xtream movies and series in global favorite items', async () => { + dbServiceMock.getAllGlobalFavorites.mockResolvedValue([ + { + id: 51, + category_id: 12, + title: 'Action Movie', + rating: '8.0', + added_at: '2026-02-02T10:00:00.000Z', + poster_url: 'https://example.com/movie.png', + xtream_id: 5001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + { + id: 52, + category_id: 13, + title: 'Sci-Fi Series', + rating: '8.7', + added_at: '2026-02-03T10:00:00.000Z', + poster_url: 'https://example.com/series.png', + xtream_id: 5002, + type: 'series', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 51, + title: 'Action Movie', + type: 'movie', + source: 'xtream', + }), + expect.objectContaining({ + id: 52, + title: 'Sci-Fi Series', + type: 'series', + source: 'xtream', + }), + ]) + ); + expect( + service.globalFavoriteItems().filter((item) => item.type === 'movie') + ).toHaveLength(1); + expect( + service.globalFavoriteItems().filter((item) => item.type === 'series') + ).toHaveLength(1); + }); + it('builds the M3U favorites route', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -199,6 +261,16 @@ describe('DashboardDataService', () => { 'm3u-1', 'favorites', ]); + expect(service.getGlobalFavoriteNavigationState(m3uItem!)).toEqual({ + openLiveCollectionItem: { + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + itemId: 'channel-1', + title: 'Channel One', + imageUrl: 'https://example.com/logo-1.png', + }, + }); }); it('removes M3U favorites via PlaylistsService', async () => { @@ -292,7 +364,7 @@ describe('DashboardDataService', () => { expect(service.getRecentlyAddedNavigationState(item)).toBeUndefined(); }); - it('builds the M3U recent route with navigation state', () => { + it('builds the M3U recent route with collection auto-open state', () => { const m3uItem = service .globalRecentItems() .find((item) => item.source === 'm3u'); @@ -305,7 +377,14 @@ describe('DashboardDataService', () => { 'recent', ]); expect(service.getRecentItemNavigationState(m3uItem!)).toEqual({ - openRecentChannelUrl: 'https://example.com/stream-1.m3u8', + openLiveCollectionItem: { + contentType: 'live', + sourceType: 'm3u', + playlistId: 'm3u-1', + itemId: 'https://example.com/stream-1.m3u8', + title: 'Channel One', + imageUrl: 'https://example.com/logo-1.png', + }, }); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index d299c4a2f..9c6856f7a 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -1,10 +1,18 @@ -import { Injectable, NgZone, computed, inject, signal } from '@angular/core'; +import { + Injectable, + NgZone, + computed, + effect, + inject, + signal, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions, selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, } from 'm3u-state'; import { firstValueFrom, startWith } from 'rxjs'; import { @@ -66,7 +74,34 @@ export class DashboardDataService { [] ); private readonly m3uGlobalFavorites = signal([]); + private readonly globalRecentLoadingState = signal(true); + private readonly globalRecentLoadedState = signal(false); + private readonly globalRecentDbLoadedState = signal(!window.electron); + private readonly globalFavoritesLoadingState = signal(true); + private readonly globalFavoritesLoadedState = signal(false); readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); + readonly playlistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag); + readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly(); + readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly(); + readonly globalFavoritesLoading = + this.globalFavoritesLoadingState.asReadonly(); + readonly globalFavoritesLoaded = this.globalFavoritesLoadedState.asReadonly(); + + private readonly playlistFavoritesReloadKey = computed(() => { + 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('|'); + }); readonly playlistBackedGlobalRecentItems = computed( () => { @@ -112,6 +147,21 @@ export class DashboardDataService { constructor() { void this.reloadGlobalFavorites(); + + effect(() => { + this.playlistFavoritesReloadKey(); + if (!this.playlistsLoaded()) { + return; + } + + void this.refreshPlaylistBackedGlobalFavorites(); + }); + + effect(() => { + this.playlistsLoaded(); + this.finishInitialGlobalRecentLoadIfReady(); + }); + if (window.electron) { void this.reloadGlobalRecentItems(); } @@ -140,8 +190,14 @@ export class DashboardDataService { readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4)); async reloadGlobalRecentItems(): Promise { + if (!this.globalRecentLoaded()) { + this.globalRecentLoadingState.set(true); + } + if (!window.electron) { this.xtreamGlobalRecentItems.set([]); + this.globalRecentDbLoadedState.set(true); + this.finishInitialGlobalRecentLoadIfReady(); return; } @@ -157,12 +213,19 @@ export class DashboardDataService { err ); this.ngZone.run(() => this.xtreamGlobalRecentItems.set([])); + } finally { + this.globalRecentDbLoadedState.set(true); + this.finishInitialGlobalRecentLoadIfReady(); } } async reloadGlobalFavorites(): Promise { + if (!this.globalFavoritesLoaded()) { + this.globalFavoritesLoadingState.set(true); + } + const xtreamReload = this.reloadXtreamGlobalFavorites(); - const m3uReload = this.reloadM3uGlobalFavorites(); + const m3uReload = this.refreshPlaylistBackedGlobalFavorites(); await Promise.all([xtreamReload, m3uReload]); } @@ -184,11 +247,12 @@ export class DashboardDataService { private async reloadXtreamGlobalFavorites(): Promise { if (!window.electron) { this.xtreamGlobalFavorites.set([]); + this.finishInitialGlobalFavoritesLoadIfReady(); return; } try { - const favorites = await this.dbService.getGlobalFavorites(); + const favorites = await this.dbService.getAllGlobalFavorites(); const normalized = favorites.map((item) => mapDbFavoriteToItem(item) ); @@ -199,9 +263,16 @@ export class DashboardDataService { err ); this.ngZone.run(() => this.xtreamGlobalFavorites.set([])); + } finally { + this.finishInitialGlobalFavoritesLoadIfReady(); } } + private async refreshPlaylistBackedGlobalFavorites(): Promise { + await this.reloadM3uGlobalFavorites(); + this.finishInitialGlobalFavoritesLoadIfReady(); + } + private async reloadM3uGlobalFavorites(): Promise { const m3uPlaylists = this.playlists().filter( (playlist) => @@ -234,6 +305,28 @@ export class DashboardDataService { this.ngZone.run(() => this.m3uGlobalFavorites.set(flattenedFavorites)); } + private finishInitialGlobalFavoritesLoadIfReady(): void { + if (!this.playlistsLoaded()) { + return; + } + + this.ngZone.run(() => { + this.globalFavoritesLoadedState.set(true); + this.globalFavoritesLoadingState.set(false); + }); + } + + private finishInitialGlobalRecentLoadIfReady(): void { + if (!this.playlistsLoaded() || !this.globalRecentDbLoadedState()) { + return; + } + + this.ngZone.run(() => { + this.globalRecentLoadedState.set(true); + this.globalRecentLoadingState.set(false); + }); + } + isTypeInKind( type: PortalActivityType, kind: DashboardContentKind diff --git a/libs/workspace/dashboard/ui/jest.config.ts b/libs/workspace/dashboard/ui/jest.config.ts new file mode 100644 index 000000000..d2fa01536 --- /dev/null +++ b/libs/workspace/dashboard/ui/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'workspace-dashboard-ui', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/workspace/dashboard/ui', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/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', + ], +}; diff --git a/libs/workspace/dashboard/ui/project.json b/libs/workspace/dashboard/ui/project.json index b61565f1f..51b734732 100644 --- a/libs/workspace/dashboard/ui/project.json +++ b/libs/workspace/dashboard/ui/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:workspace", "domain:workspace", "type:ui"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/workspace/dashboard/ui/jest.config.ts", + "tsConfig": "libs/workspace/dashboard/ui/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html index a960a4381..aa2fdb1a8 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.html @@ -2,8 +2,16 @@ [eyebrow]="'WORKSPACE.DASHBOARD.FOCUS' | translate" [title]="'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate" > - @if (lastItem(); as item) { - + @if (data.globalRecentLoading()) { +
+ +
+ } @else if (lastItem(); as item) { +
@if (hasImage(item)) { diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss index f9132617e..588624475 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.scss @@ -13,6 +13,13 @@ --_series-accent: oklch(68% 0.16 55); } +.continue-watching-widget__loading { + min-height: 220px; + display: grid; + place-items: center; + padding: 16px 0; +} + // ── Hero card ───────────────────────────────────────────────────────────────── .hero { diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts new file mode 100644 index 000000000..0a151b59b --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.spec.ts @@ -0,0 +1,48 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { DashboardDataService } from 'workspace-dashboard-data-access'; +import { ContinueWatchingWidgetComponent } from './continue-watching-widget.component'; + +describe('ContinueWatchingWidgetComponent', () => { + let fixture: ComponentFixture; + + const dataServiceMock = { + globalRecentLoading: signal(true), + globalRecentItems: signal([]), + quickRecent: signal([]), + getRecentItemLink: jest.fn(() => ['/workspace', 'global-recent']), + getRecentItemNavigationState: jest.fn(() => undefined), + getPlaylistLink: jest.fn(() => ['/workspace', 'sources']), + getPlaylistProvider: jest.fn(() => 'M3U'), + }; + + beforeEach(async () => { + dataServiceMock.globalRecentLoading.set(true); + dataServiceMock.globalRecentItems.set([]); + dataServiceMock.quickRecent.set([]); + + await TestBed.configureTestingModule({ + imports: [ContinueWatchingWidgetComponent, TranslateModule.forRoot()], + providers: [ + provideRouter([]), + { + provide: DashboardDataService, + useValue: dataServiceMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ContinueWatchingWidgetComponent); + }); + + it('shows a spinner while recent history is still loading', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.focus-empty')).toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts index 9f77c7702..7c893a4ab 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/continue-watching-widget.component.ts @@ -2,6 +2,7 @@ import { Component, computed, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { RouterLink } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; @@ -16,6 +17,7 @@ import { DashboardWidgetShellComponent } from './dashboard-widget-shell.componen imports: [ MatButtonModule, MatIcon, + MatProgressSpinnerModule, RouterLink, DashboardWidgetShellComponent, TranslatePipe, @@ -44,6 +46,10 @@ export class ContinueWatchingWidgetComponent { return this.data.getRecentItemLink(item); } + getState(item: GlobalRecentItem): Record | undefined { + return this.data.getRecentItemNavigationState(item); + } + typeIcon(type: GlobalRecentItem['type']): string { if (type === 'live') return 'live_tv'; if (type === 'movie') return 'movie'; diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html index 45b57a451..a6c7dfab0 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.html @@ -80,15 +80,28 @@ - + @if (data.globalFavoritesLoading()) { +
+ +
+ } @else { + + } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss index d8a1d6c94..b1620d2c7 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.scss @@ -38,3 +38,10 @@ font-weight: 600; color: var(--mat-sys-primary); } + +.global-favorites-widget__loading { + min-height: 220px; + display: grid; + place-items: center; + padding: 16px 0; +} diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts new file mode 100644 index 000000000..b71186306 --- /dev/null +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.spec.ts @@ -0,0 +1,88 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { DashboardDataService } from 'workspace-dashboard-data-access'; +import { GlobalFavoritesWidgetComponent } from './global-favorites-widget.component'; + +describe('GlobalFavoritesWidgetComponent', () => { + let fixture: ComponentFixture; + const globalFavoritesLoading = signal(true); + const globalFavoritesLoaded = signal(false); + const globalFavoriteItems = signal([]); + + const dataServiceMock = { + globalFavoriteItems, + globalFavoritesLoading, + globalFavoritesLoaded, + reloadGlobalFavorites: jest.fn().mockResolvedValue(undefined), + isTypeInKind: jest.fn( + (type: 'live' | 'movie' | 'series', kind: string) => + kind === 'all' || + (kind === 'channels' && type === 'live') || + (kind === 'vod' && type === 'movie') || + (kind === 'series' && type === 'series') + ), + getFavoriteItemProviderLabel: jest.fn(() => 'Xtream'), + getFavoriteItemTypeLabel: jest.fn(() => 'Live TV'), + getGlobalFavoriteLink: jest.fn(() => ['/workspace', 'global-favorites']), + getGlobalFavoriteNavigationState: jest.fn(() => undefined), + removeGlobalFavorite: jest.fn(), + }; + + beforeEach(async () => { + globalFavoritesLoading.set(true); + globalFavoritesLoaded.set(false); + globalFavoriteItems.set([]); + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + imports: [ + GlobalFavoritesWidgetComponent, + TranslateModule.forRoot(), + ], + providers: [ + provideRouter([]), + { + provide: DashboardDataService, + useValue: dataServiceMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(GlobalFavoritesWidgetComponent); + fixture.componentRef.setInput('widget', { + id: 'global-favorites', + type: 'global-favorites', + enabled: true, + order: 0, + size: 'medium', + } as any); + }); + + it('shows a spinner while global favorites are still loading', () => { + fixture.detectChanges(); + + expect(dataServiceMock.reloadGlobalFavorites).toHaveBeenCalled(); + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('.empty-state') + ).toBeNull(); + }); + + it('shows the empty state only after the initial load completes', () => { + globalFavoritesLoading.set(false); + globalFavoritesLoaded.set(true); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('mat-progress-spinner') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('.empty-state') + ).not.toBeNull(); + }); +}); diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts index e543c35af..ce7bc7420 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts +++ b/libs/workspace/dashboard/ui/src/lib/widgets/global-favorites-widget.component.ts @@ -1,4 +1,5 @@ import { + ChangeDetectionStrategy, Component, OnInit, computed, @@ -11,6 +12,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; @@ -35,11 +37,13 @@ const PAGE_SIZE = 20; @Component({ selector: 'app-global-favorites-widget', + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatButtonToggleModule, MatMenuModule, MatIcon, + MatProgressSpinnerModule, MatTooltipModule, DashboardActivityItemsComponent, DashboardWidgetShellComponent, @@ -97,10 +101,8 @@ export class GlobalFavoritesWidgetComponent implements OnInit { })) ); - constructor() {} - ngOnInit(): void { - this.data.reloadGlobalFavorites().then(() => { + void this.data.reloadGlobalFavorites().then(() => { this.visibleItemLimit.set(INITIAL_PAGE_SIZE); }); } diff --git a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html index 535788fe1..88561673a 100644 --- a/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html +++ b/libs/workspace/dashboard/ui/src/lib/widgets/recently-added-widget.component.html @@ -61,11 +61,12 @@ @if (loading()) { -