From 5904025e829a410131002b6dba52ff2c76e0fda7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 6 Jan 2026 19:50:44 +0100 Subject: [PATCH 02/12] refactor: remove unnecessary console logs from Stalker events and portal import components --- .../src/app/events/stalker.events.ts | 29 ---- .../stalker-portal-import.component.ts | 69 --------- apps/web/src/app/stalker/stalker.store.ts | 41 ------ .../src/lib/stalker-session.service.ts | 134 ------------------ 4 files changed, 273 deletions(-) diff --git a/apps/electron-backend/src/app/events/stalker.events.ts b/apps/electron-backend/src/app/events/stalker.events.ts index 3c2613478..20f600afd 100644 --- a/apps/electron-backend/src/app/events/stalker.events.ts +++ b/apps/electron-backend/src/app/events/stalker.events.ts @@ -31,18 +31,6 @@ ipcMain.handle( try { const { url, macAddress, params, token, serialNumber } = payload; - console.log('[StalkerEvents] Incoming request'); - console.log('[StalkerEvents] Base URL:', url); - console.log('[StalkerEvents] MAC Address:', macAddress); - console.log( - '[StalkerEvents] Params:', - JSON.stringify(params, null, 2) - ); - console.log( - '[StalkerEvents] Token:', - token ? token.substring(0, 10) + '...' : 'none' - ); - // Build URL with query parameters // Note: For 'cmd' parameter, we need to use encodeURI (not encodeURIComponent) // to preserve forward slashes, matching stalker-to-m3u implementation @@ -69,7 +57,6 @@ ipcMain.handle( // Build final URL with manually constructed query string const fullUrl = `${urlObject.origin}${urlObject.pathname}?${queryParts.join('&')}`; - console.log('[StalkerEvents] Full request URL:', fullUrl); // Build cookie string matching the working curl example format // Format: mac=XX:XX:XX:XX:XX:XX; stb_lang=de_DE; timezone=Europe/Berlin; __cfduid=... @@ -103,14 +90,8 @@ ipcMain.handle( // Add Authorization header if token is provided if (token) { headers['Authorization'] = `Bearer ${token}`; - console.log('[StalkerEvents] Authorization header added'); } - console.log( - '[StalkerEvents] Request headers:', - JSON.stringify(headers, null, 2) - ); - // Determine timeout based on action type // create_link requests can take longer as server generates stream URL const isCreateLink = params.action === 'create_link'; @@ -125,13 +106,7 @@ ipcMain.handle( validateStatus: (status) => status < 500, // Don't throw on 4xx errors }; - console.log('[StalkerEvents] Sending request...'); const response = await axios(config); - console.log('[StalkerEvents] Response status:', response.status); - console.log( - '[StalkerEvents] Response data:', - JSON.stringify(response.data, null, 2).substring(0, 500) + '...' - ); // Check if response is successful if (response.status >= 400) { @@ -161,10 +136,6 @@ ipcMain.handle( 'Failed to fetch data from Stalker portal', status: error.response?.status || 500, }; - console.error( - '[StalkerEvents] Axios error response:', - errorResponse - ); throw errorResponse; } else if ( error && diff --git a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts index 57fdf9b3f..4b622d7f4 100644 --- a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts +++ b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts @@ -93,18 +93,6 @@ export class StalkerPortalImportComponent { const isFullStalkerPortal = this.isFullStalkerPortalUrl(originalUrl); - console.log('[StalkerImport] Starting import process'); - console.log('[StalkerImport] Original URL:', originalUrl); - console.log('[StalkerImport] Transformed URL:', transformedUrl); - console.log( - '[StalkerImport] Is Full Stalker Portal:', - isFullStalkerPortal - ); - console.log( - '[StalkerImport] MAC Address:', - this.form.value.macAddress - ); - let stalkerToken: string | undefined; let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined; let stalkerSerialNumber: string | undefined; @@ -115,21 +103,10 @@ export class StalkerPortalImportComponent { // For full stalker portal URLs, perform handshake and get profile if (isFullStalkerPortal) { - console.log( - '[StalkerImport] Full stalker portal detected, starting authentication...' - ); - // Use provided serial number or generate a new one stalkerSerialNumber = this.form.value.serialNumber?.trim() || STALKER_SERIAL_NUMBER; - console.log( - '[StalkerImport] Using serial number:', - stalkerSerialNumber, - this.form.value.serialNumber?.trim() - ? '(user provided)' - : '(auto-generated)' - ); // Use provided device IDs if available (64 hex chars each) stalkerDeviceId1 = @@ -140,34 +117,6 @@ export class StalkerPortalImportComponent { this.form.value.signature1?.trim() || undefined; stalkerSignature2 = this.form.value.signature2?.trim() || undefined; - console.log( - '[StalkerImport] Device ID 1:', - stalkerDeviceId1 - ? stalkerDeviceId1.substring(0, 16) + - '... (user provided)' - : '(auto-generated)' - ); - console.log( - '[StalkerImport] Device ID 2:', - stalkerDeviceId2 - ? stalkerDeviceId2.substring(0, 16) + - '... (user provided)' - : '(auto-generated)' - ); - console.log( - '[StalkerImport] Signature 1:', - stalkerSignature1 - ? stalkerSignature1.substring(0, 16) + - '... (user provided)' - : '(not provided)' - ); - console.log( - '[StalkerImport] Signature 2:', - stalkerSignature2 - ? stalkerSignature2.substring(0, 16) + - '... (user provided)' - : '(not provided)' - ); try { const authResult = @@ -180,10 +129,6 @@ export class StalkerPortalImportComponent { ); stalkerToken = authResult.token; - console.log( - '[StalkerImport] Authentication successful, token received:', - stalkerToken?.substring(0, 10) + '...' - ); if (authResult.accountInfo) { stalkerAccountInfo = { @@ -193,10 +138,6 @@ export class StalkerPortalImportComponent { authResult.accountInfo.tariff_plan_name, status: authResult.accountInfo.status, }; - console.log( - '[StalkerImport] Account info received:', - stalkerAccountInfo - ); } // Show success notification with account info if available @@ -223,10 +164,6 @@ export class StalkerPortalImportComponent { this.isLoading.set(false); return; } - } else { - console.log( - '[StalkerImport] Simple portal URL, skipping authentication' - ); } const playlist: Playlist = { @@ -242,12 +179,6 @@ export class StalkerPortalImportComponent { stalkerSignature2, } as Playlist; - console.log('[StalkerImport] Creating playlist with config:', { - portalUrl: playlist.portalUrl, - isFullStalkerPortal: playlist.isFullStalkerPortal, - hasToken: !!playlist.stalkerToken, - }); - this.store.dispatch(PlaylistActions.addPlaylist({ playlist })); this.addClicked.emit(); } finally { diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index 0c882fee7..b5c9f6cde 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -477,16 +477,7 @@ export const StalkerStore = signalStore( ); } - console.log( - '[StalkerStore] VOD series seasons response:', - response - ); - if (!response?.js?.data) { - console.warn( - '[StalkerStore] Invalid VOD series seasons response:', - response - ); return []; } @@ -646,13 +637,6 @@ export const StalkerStore = signalStore( p: '1', }; - console.log( - '[StalkerStore] Fetching VOD series episodes for video_id:', - videoId, - 'season_id:', - seasonId - ); - let response: any; if (playlist.isFullStalkerPortal) { response = await stalkerSession.makeAuthenticatedRequest( @@ -667,16 +651,7 @@ export const StalkerStore = signalStore( }); } - console.log( - '[StalkerStore] VOD series episodes response:', - response - ); - if (!response?.js?.data) { - console.warn( - '[StalkerStore] Invalid VOD series episodes response:', - response - ); return []; } @@ -899,11 +874,6 @@ export const StalkerStore = signalStore( p: '1', }; - console.log( - '[StalkerStore] Fetching movie files for movie_id:', - movieId - ); - let response: any; if (playlist.isFullStalkerPortal) { response = await stalkerSession.makeAuthenticatedRequest( @@ -918,12 +888,9 @@ export const StalkerStore = signalStore( }); } - console.log('[StalkerStore] Movie files response:', response); - // Extract id from the first data item if (response?.js?.data?.[0]?.id) { const fileId = response.js.data[0].id; - console.log('[StalkerStore] Found file id:', fileId); return String(fileId); } @@ -947,16 +914,9 @@ export const StalkerStore = signalStore( cmdToUse.includes('/media/') && !cmdToUse.includes('/media/file_') ) { - console.log( - '[StalkerStore] Item has has_files, fetching file id...' - ); const fileId = await this.fetchMovieFileId(item.id); if (fileId) { cmdToUse = `/media/file_${fileId}.mpg`; - console.log( - '[StalkerStore] Transformed cmd with file id:', - cmdToUse - ); } } @@ -997,7 +957,6 @@ export const StalkerStore = signalStore( } }, addToRecentlyViewed(item: any) { - console.log('Adding to recently viewed', item); playlistService .addPortalRecentlyViewed(this.currentPlaylist()?._id, { ...item, diff --git a/libs/services/src/lib/stalker-session.service.ts b/libs/services/src/lib/stalker-session.service.ts index 5dee22625..d458101c7 100644 --- a/libs/services/src/lib/stalker-session.service.ts +++ b/libs/services/src/lib/stalker-session.service.ts @@ -157,10 +157,6 @@ export class StalkerSessionService { }; const fullUrl = `${portalUrl}?type=stb&action=handshake&token=&prehash=${prehash}&JsHttpRequest=1-xml`; - console.log('[StalkerSession] Performing handshake...'); - console.log('[StalkerSession] Handshake URL:', fullUrl); - console.log('[StalkerSession] MAC Address:', macAddress); - console.log('[StalkerSession] Prehash:', prehash); try { const response: StalkerHandshakeResponse = @@ -170,20 +166,7 @@ export class StalkerSessionService { params, }); - console.log( - '[StalkerSession] Handshake response:', - JSON.stringify(response, null, 2) - ); - if (response?.js?.token) { - console.log( - '[StalkerSession] Token received:', - response.js.token.substring(0, 10) + '...' - ); - console.log( - '[StalkerSession] Random received:', - response.js.random - ); return { token: response.js.token, random: response.js.random || generateRandom(), @@ -255,19 +238,6 @@ export class StalkerSessionService { JsHttpRequest: '1-xml', }; - console.log('[StalkerSession] Getting profile...'); - console.log('[StalkerSession] Profile URL:', portalUrl); - console.log('[StalkerSession] Token:', token.substring(0, 10) + '...'); - console.log('[StalkerSession] Serial Number:', serialNumber); - console.log( - '[StalkerSession] Device ID:', - deviceId1.substring(0, 16) + '...' - ); - console.log( - '[StalkerSession] Signature:', - signature1 ? signature1.substring(0, 16) + '...' : '(empty)' - ); - try { const response: StalkerProfileResponse = await this.dataService.sendIpcEvent(STALKER_REQUEST, { @@ -278,11 +248,6 @@ export class StalkerSessionService { serialNumber, }); - console.log( - '[StalkerSession] Profile response:', - JSON.stringify(response, null, 2) - ); - return response; } catch (error) { console.error('[StalkerSession] Get profile error:', error); @@ -308,10 +273,6 @@ export class StalkerSessionService { JsHttpRequest: '1-xml', }; - console.log('[StalkerSession] Performing do_auth...'); - console.log('[StalkerSession] do_auth URL:', portalUrl); - console.log('[StalkerSession] Token:', token.substring(0, 10) + '...'); - try { const response = await this.dataService.sendIpcEvent( STALKER_REQUEST, @@ -323,18 +284,11 @@ export class StalkerSessionService { } ); - console.log( - '[StalkerSession] do_auth response:', - JSON.stringify(response, null, 2) - ); - // do_auth returns { js: true } on success if (response?.js === true) { - console.log('[StalkerSession] do_auth successful'); return true; } - console.warn('[StalkerSession] do_auth returned:', response?.js); return false; } catch (error) { console.error('[StalkerSession] do_auth error:', error); @@ -358,50 +312,14 @@ export class StalkerSessionService { token: string; accountInfo?: StalkerProfileResponse['js']['account_info']; }> { - console.log('[StalkerSession] Starting full authentication flow...'); - console.log('[StalkerSession] Portal URL:', portalUrl); - console.log('[StalkerSession] MAC Address:', macAddress); - console.log('[StalkerSession] Serial Number:', serialNumber); - console.log( - '[StalkerSession] Device ID 1:', - deviceId1 - ? deviceId1.substring(0, 16) + '... (provided)' - : '(will be auto-generated)' - ); - console.log( - '[StalkerSession] Device ID 2:', - deviceId2 - ? deviceId2.substring(0, 16) + '... (provided)' - : '(will be auto-generated)' - ); - console.log( - '[StalkerSession] Signature 1:', - signature1 - ? signature1.substring(0, 16) + '... (provided)' - : '(empty)' - ); - console.log( - '[StalkerSession] Signature 2:', - signature2 - ? signature2.substring(0, 16) + '... (provided)' - : '(empty)' - ); - // Step 1: Handshake to get token and random - console.log('[StalkerSession] Step 1: Performing handshake...'); const { token, random } = await this.performHandshake( portalUrl, macAddress ); - console.log( - '[StalkerSession] Step 1 complete: Token and random obtained' - ); // Step 2: Get profile to activate token and get account info // The random from handshake must be used in auth_second_step - console.log( - '[StalkerSession] Step 2: Getting profile with handshake random (activates token)...' - ); try { const profileResponse = await this.getProfile( portalUrl, @@ -425,10 +343,6 @@ export class StalkerSessionService { throw new Error(`Profile error: ${errorMsg}`); } - console.log( - '[StalkerSession] Step 2 complete: Profile obtained, token activated' - ); - console.log('[StalkerSession] Authentication successful!'); return { token, accountInfo: profileResponse?.js?.account_info, @@ -448,30 +362,14 @@ export class StalkerSessionService { async ensureToken( playlist: Playlist ): Promise<{ token: string | null; serialNumber?: string }> { - console.log( - '[StalkerSession] Ensuring token for playlist:', - playlist._id - ); - console.log( - '[StalkerSession] Is full stalker portal:', - playlist.isFullStalkerPortal - ); - // If not a full stalker portal, no token needed if (!playlist.isFullStalkerPortal) { - console.log( - '[StalkerSession] Not a full stalker portal, no token needed' - ); return { token: null }; } // Check in-memory cache first (valid for current session only) const cachedToken = this.getCachedToken(playlist._id); if (cachedToken) { - console.log( - '[StalkerSession] Using cached token:', - cachedToken.substring(0, 10) + '...' - ); return { token: cachedToken }; } @@ -496,21 +394,8 @@ export class StalkerSessionService { let serialNumber = playlist.stalkerSerialNumber; if (!serialNumber) { serialNumber = STALKER_SERIAL_NUMBER; - console.log( - '[StalkerSession] Generated new serial number:', - serialNumber - ); - } else { - console.log( - '[StalkerSession] Using stored serial number:', - serialNumber - ); } - console.log( - '[StalkerSession] No cached token, performing full authentication...' - ); - // Create the authentication promise and store it to prevent concurrent auth attempts // Use async/await wrapper to properly clean up on both success and failure const authPromise = (async () => { @@ -520,9 +405,6 @@ export class StalkerSessionService { playlist.macAddress, serialNumber ); - console.log( - '[StalkerSession] Authentication complete, token cached' - ); this.setCachedToken(playlist._id, token); return { token, serialNumber }; } finally { @@ -586,11 +468,6 @@ export class StalkerSessionService { const { token } = await this.ensureToken(playlist); const serialNumber = playlist.stalkerSerialNumber; - console.log( - '[StalkerSession] Making authenticated request:', - params['action'] - ); - try { const response = await this.dataService.sendIpcEvent( STALKER_REQUEST, @@ -605,15 +482,7 @@ export class StalkerSessionService { // Check for authorization failure in response if (this.isAuthorizationError(response)) { - console.warn( - '[StalkerSession] Authorization error detected in response:', - response - ); - if (retryOnAuthFailure && playlist.isFullStalkerPortal) { - console.log( - '[StalkerSession] Clearing token and retrying with fresh auth...' - ); // Clear cached token to force re-authentication this.clearCachedToken(playlist._id); // Retry once with fresh authentication @@ -635,9 +504,6 @@ export class StalkerSessionService { retryOnAuthFailure && playlist.isFullStalkerPortal ) { - console.log( - '[StalkerSession] Clearing token and retrying with fresh auth (caught error)...' - ); // Clear cached token and retry with new handshake this.clearCachedToken(playlist._id); return this.makeAuthenticatedRequest( From 934c02d9ecbc3978cf28e79822a113ee1ecfc2b9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 6 Jan 2026 19:51:10 +0100 Subject: [PATCH 03/12] fix: adjust player dialog opening condition for non-live content in favorites view --- apps/web/src/app/services/player.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index fa5a38bad..53588c9e9 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -57,7 +57,7 @@ export class PlayerService { referer: referer, origin: origin, }); - } else if (!isLiveContent) { + } else { this.dialog.open( PlayerDialogComponent, { From 2043b7d8a1f1c5ec3c5192900acfac20749db909 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 6 Jan 2026 21:34:44 +0100 Subject: [PATCH 04/12] feat(xtream): add category management to hide/show categories Add the ability to hide irrelevant or empty categories in Xtream playlists. Users can now manage category visibility through a dialog accessible from the sidebar header in both VOD/Series and Live TV views. Features: - New category management dialog with search filter and select/deselect all - Visibility settings persisted to database per playlist - Hidden categories are filtered from sidebar navigation - New categories added during playlist refresh default to visible - Database migration for existing installations Files changed: - Database: schema.ts, connection.ts (hidden column + migration) - Backend: category.events.ts, main.preload.ts (IPC handlers) - Frontend: new dialog component, store method, UI integration - Translations: en.json - Documentation: docs/architecture/category-management.md --- .../src/app/api/main.preload.ts | 8 + .../app/events/database/category.events.ts | 53 +++++- .../category-management-dialog.component.html | 97 ++++++++++ .../category-management-dialog.component.scss | 103 ++++++++++ .../category-management-dialog.component.ts | 179 ++++++++++++++++++ .../live-stream-layout.component.html | 8 + .../live-stream-layout.component.ts | 34 +++- apps/web/src/app/xtream-tauri/sidebar.scss | 8 + .../xtream-main-container.component.html | 8 + .../xtream-main-container.component.ts | 34 ++++ apps/web/src/app/xtream-tauri/xtream.store.ts | 32 +++- apps/web/src/assets/i18n/en.json | 11 ++ docs/architecture/category-management.md | 142 ++++++++++++++ global.d.ts | 8 + .../src/lib/database-electron.service.ts | 30 +++ libs/shared/database/src/lib/connection.ts | 171 ++++++++++------- libs/shared/database/src/lib/schema.ts | 1 + 17 files changed, 849 insertions(+), 78 deletions(-) create mode 100644 apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.html create mode 100644 apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss create mode 100644 apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts create mode 100644 docs/architecture/category-management.md diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index a9d4208d9..771fe67b2 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -131,6 +131,14 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.invoke('DB_GET_CATEGORIES', playlistId, type), dbSaveCategories: (playlistId: string, categories: any[], type: string) => ipcRenderer.invoke('DB_SAVE_CATEGORIES', playlistId, categories, type), + dbGetAllCategories: (playlistId: string, type: string) => + ipcRenderer.invoke('DB_GET_ALL_CATEGORIES', playlistId, type), + dbUpdateCategoryVisibility: (categoryIds: number[], hidden: boolean) => + ipcRenderer.invoke( + 'DB_UPDATE_CATEGORY_VISIBILITY', + categoryIds, + hidden + ), dbHasContent: (playlistId: string, type: string) => ipcRenderer.invoke('DB_HAS_CONTENT', playlistId, type), dbGetContent: (playlistId: string, type: string) => 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 f5dad10a2..603a8cf87 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -3,7 +3,7 @@ * Operations for managing categories within playlists */ -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { ipcMain } from 'electron'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; @@ -34,7 +34,7 @@ ipcMain.handle( ); /** - * Get categories for a playlist + * Get categories for a playlist (excluding hidden) */ ipcMain.handle( 'DB_GET_CATEGORIES', @@ -47,7 +47,8 @@ ipcMain.handle( .where( and( eq(schema.categories.playlistId, playlistId), - eq(schema.categories.type, type) + eq(schema.categories.type, type), + eq(schema.categories.hidden, false) ) ) .orderBy(sql`name COLLATE NOCASE`); @@ -90,3 +91,49 @@ ipcMain.handle( } } ); + +/** + * Update category visibility (hidden status) + */ +ipcMain.handle( + 'DB_UPDATE_CATEGORY_VISIBILITY', + async (event, categoryIds: number[], hidden: boolean) => { + try { + const db = await getDatabase(); + await db + .update(schema.categories) + .set({ hidden }) + .where(inArray(schema.categories.id, categoryIds)); + return { success: true }; + } catch (error) { + console.error('Error updating category visibility:', error); + throw error; + } + } +); + +/** + * Get all categories for a playlist (including hidden, for management dialog) + */ +ipcMain.handle( + 'DB_GET_ALL_CATEGORIES', + async (event, playlistId: string, type: 'live' | 'movies' | 'series') => { + try { + const db = await getDatabase(); + const result = await db + .select() + .from(schema.categories) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.categories.type, type) + ) + ) + .orderBy(sql`name COLLATE NOCASE`); + return result; + } catch (error) { + console.error('Error getting all categories:', error); + throw error; + } + } +); diff --git a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.html b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.html new file mode 100644 index 000000000..781b0e08b --- /dev/null +++ b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.html @@ -0,0 +1,97 @@ +

{{ 'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate }}

+ + + @if (isLoading()) { +
+ +

{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}

+
+ } @else { +
+
+ {{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}: + {{ selectedCount() }} / {{ totalCount() }} +
+
+ + +
+
+ + + search + + @if (searchTerm()) { + + } + + +
+ @for (category of filteredCategories(); track category.id) { +
+ + + {{ category.name }} +
+ } @empty { +
+ @if (searchTerm()) { + {{ + 'XTREAM.CATEGORY_MANAGEMENT.NO_RESULTS' | translate + }} + } @else { + {{ + 'XTREAM.CATEGORY_MANAGEMENT.NO_CATEGORIES' + | translate + }} + } +
+ } +
+ } +
+ + + + + diff --git a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss new file mode 100644 index 000000000..079ba4d9f --- /dev/null +++ b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss @@ -0,0 +1,103 @@ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + gap: 16px; +} + +.header-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} + +.selection-info { + font-size: 13px; + color: var(--mat-sys-on-surface-variant); + + strong { + color: var(--mat-sys-primary); + } +} + +.bulk-actions { + display: flex; + gap: 8px; + + button { + font-size: 12px; + } +} + +.search-field { + width: 100%; + + mat-icon[matPrefix] { + margin-right: 8px; + color: var(--mat-sys-on-surface-variant); + } +} + +.categories-list { + height: 450px; + overflow-y: auto; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 8px; +} + +.category-item { + display: flex; + align-items: center; + padding: 6px 12px; + cursor: pointer; + gap: 8px; + transition: background-color 0.12s ease; + + &:hover { + background-color: var(--mat-sys-surface-container); + } + + &:not(:last-child) { + border-bottom: 1px solid var(--mat-sys-surface-container); + } + + mat-checkbox { + --mdc-checkbox-state-layer-size: 32px; + } +} + +.category-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.empty-message { + padding: 24px; + text-align: center; + color: var(--mat-sys-on-surface-variant); + font-size: 13px; +} + +:host ::ng-deep { + .mat-mdc-dialog-content { + min-width: 480px; + max-width: 600px; + max-height: 75vh !important; + } +} + +mat-dialog-actions { + button { + mat-spinner { + display: inline-block; + } + } +} diff --git a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts new file mode 100644 index 000000000..ff6ab5490 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts @@ -0,0 +1,179 @@ +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 { + MAT_DIALOG_DATA, + 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 { TranslatePipe } from '@ngx-translate/core'; +import { DatabaseService, XCategoryFromDb } from 'services'; + +export interface CategoryManagementDialogData { + playlistId: string; + contentType: 'live' | 'vod' | 'series'; +} + +interface CategoryWithSelection extends XCategoryFromDb { + selected: boolean; +} + +@Component({ + selector: 'app-category-management-dialog', + imports: [ + MatDialogModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + FormsModule, + TranslatePipe, + ], + templateUrl: './category-management-dialog.component.html', + styleUrl: './category-management-dialog.component.scss', +}) +export class CategoryManagementDialogComponent implements OnInit { + private readonly dbService = inject(DatabaseService); + private readonly dialogRef = inject( + MatDialogRef + ); + readonly data = inject(MAT_DIALOG_DATA); + + readonly isLoading = signal(true); + readonly isSaving = signal(false); + readonly categories = signal([]); + readonly searchTerm = signal(''); + + readonly filteredCategories = computed(() => { + const term = this.searchTerm().toLowerCase(); + if (!term) return this.categories(); + return this.categories().filter((c) => + c.name.toLowerCase().includes(term) + ); + }); + + readonly selectedCount = computed( + () => this.categories().filter((c) => c.selected).length + ); + + readonly totalCount = computed(() => this.categories().length); + + readonly allSelected = computed( + () => + this.categories().length > 0 && + this.categories().every((c) => c.selected) + ); + + readonly someSelected = computed( + () => this.categories().some((c) => c.selected) && !this.allSelected() + ); + + async ngOnInit(): Promise { + await this.loadCategories(); + } + + private async loadCategories(): Promise { + try { + const type = this.getDbType(); + const allCategories = await this.dbService.getAllXtreamCategories( + this.data.playlistId, + type + ); + this.categories.set( + allCategories.map((c) => ({ + ...c, + selected: !c.hidden, + })) + ); + } catch (error) { + console.error('Error loading categories:', error); + } finally { + this.isLoading.set(false); + } + } + + clearSearch(): void { + this.searchTerm.set(''); + } + + private getDbType(): 'live' | 'movies' | 'series' { + switch (this.data.contentType) { + case 'live': + return 'live'; + case 'vod': + return 'movies'; + case 'series': + return 'series'; + } + } + + toggleCategory(category: CategoryWithSelection): void { + this.categories.update((cats) => + cats.map((c) => + c.id === category.id ? { ...c, selected: !c.selected } : c + ) + ); + } + + selectAll(): void { + this.categories.update((cats) => + cats.map((c) => ({ ...c, selected: true })) + ); + } + + deselectAll(): void { + this.categories.update((cats) => + cats.map((c) => ({ ...c, selected: false })) + ); + } + + toggleAll(): void { + if (this.allSelected()) { + this.deselectAll(); + } else { + this.selectAll(); + } + } + + async save(): Promise { + this.isSaving.set(true); + try { + const categories = this.categories(); + + // Get IDs of categories to hide (unselected) + const toHide = categories + .filter((c) => !c.selected) + .map((c) => c.id); + + // Get IDs of categories to show (selected) + const toShow = categories + .filter((c) => c.selected) + .map((c) => c.id); + + // Update visibility in database + if (toHide.length > 0) { + await this.dbService.updateCategoryVisibility(toHide, true); + } + if (toShow.length > 0) { + await this.dbService.updateCategoryVisibility(toShow, false); + } + + this.dialogRef.close(true); + } catch (error) { + console.error('Error saving category visibility:', error); + } finally { + this.isSaving.set(false); + } + } + + cancel(): void { + this.dialogRef.close(false); + } +} diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html index 81ec04de6..049187809 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html @@ -13,6 +13,14 @@ list

{{ 'PORTALS.ALL_CATEGORIES' | translate }}

+ } @if (selectedCategoryId()) { diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts index fbb0f02af..63cecfd15 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts @@ -6,15 +6,21 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIcon } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { XtreamCategory } from 'shared-interfaces'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; import { SettingsStore } from '../../services/settings-store.service'; +import { + CategoryManagementDialogComponent, + CategoryManagementDialogData, +} from '../category-management-dialog/category-management-dialog.component'; import { CategoryViewComponent } from '../category-view/category-view.component'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { FavoritesService } from '../services/favorites.service'; @@ -33,6 +39,7 @@ import { XtreamStore } from '../xtream.store'; MatIconButton, MatInputModule, MatListModule, + MatTooltipModule, /* MpvPlayerBarComponent, */ PortalChannelsListComponent, TranslatePipe, @@ -44,11 +51,12 @@ export class LiveStreamLayoutComponent implements OnInit { private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); private readonly settingsStore = inject(SettingsStore); + private readonly dialog = inject(MatDialog); + private readonly route = inject(ActivatedRoute); readonly categories = this.xtreamStore.getCategoriesBySelectedType; readonly epgItems = this.xtreamStore.epgItems; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; - private readonly route = inject(ActivatedRoute); readonly player = this.settingsStore.player; streamUrl: string; @@ -86,4 +94,28 @@ export class LiveStreamLayoutComponent implements OnInit { backToCategories() { this.xtreamStore.setSelectedCategory(null); } + + openCategoryManagement(): void { + const playlistId = this.route.parent?.snapshot.params['id']; + const contentType = this.xtreamStore.selectedContentType(); + + const dialogRef = this.dialog.open< + CategoryManagementDialogComponent, + CategoryManagementDialogData, + boolean + >(CategoryManagementDialogComponent, { + data: { + playlistId, + contentType, + }, + width: '500px', + maxHeight: '80vh', + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.xtreamStore.reloadCategories(); + } + }); + } } diff --git a/apps/web/src/app/xtream-tauri/sidebar.scss b/apps/web/src/app/xtream-tauri/sidebar.scss index 8f9f46b0e..f9f32cb33 100644 --- a/apps/web/src/app/xtream-tauri/sidebar.scss +++ b/apps/web/src/app/xtream-tauri/sidebar.scss @@ -8,15 +8,23 @@ padding: 16px; border-bottom: 1px solid rgba(0, 0, 0, 0.12); app-region: drag; + display: flex; + align-items: center; + gap: 8px; h2 { margin: 0; font-size: 1.2rem; font-weight: 500; + flex: 1; } button { app-region: no-drag; } + + .manage-categories-btn { + margin-left: auto; + } } } diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html index c2298a3c1..94e1e5054 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html @@ -4,6 +4,14 @@ list

{{ 'PORTALS.ALL_CATEGORIES' | translate }}

+ (CategoryManagementDialogComponent, { + data: { + playlistId, + contentType, + }, + width: '500px', + maxHeight: '80vh', + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + // Reload categories from database to reflect visibility changes + this.xtreamStore.reloadCategories(); + } + }); + } } diff --git a/apps/web/src/app/xtream-tauri/xtream.store.ts b/apps/web/src/app/xtream-tauri/xtream.store.ts index 5d0668a81..2ea2b37db 100644 --- a/apps/web/src/app/xtream-tauri/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/xtream.store.ts @@ -639,7 +639,10 @@ export const XtreamStore = signalStore( ); localStorage.removeItem(restoreKey); } catch (err) { - console.error('Error restoring user data:', err); + console.error( + 'Error restoring user data:', + err + ); } } } @@ -886,6 +889,33 @@ export const XtreamStore = signalStore( removeFromFavorites(favoriteId: string) { console.log('not needed now', favoriteId); }, + /** + * Reload categories from database after visibility changes + */ + async reloadCategories() { + const playlistId = route.snapshot.params.id; + if (!playlistId) return; + + try { + const [ + liveCategories, + vodCategories, + serialCategories, + ] = await Promise.all([ + dbService.getXtreamCategories(playlistId, 'live'), + dbService.getXtreamCategories(playlistId, 'movies'), + dbService.getXtreamCategories(playlistId, 'series'), + ]); + + patchState(store, { + liveCategories: liveCategories as any, + vodCategories: vodCategories as any, + serialCategories: serialCategories as any, + }); + } catch (error) { + console.error('Error reloading categories:', error); + } + }, }; } ) diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 3312edc23..0a5b99de1 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -356,6 +356,17 @@ "HTTP_PORT": "HTTP", "HTTPS_PORT": "HTTPS", "RTMP_PORT": "RTMP" + }, + "CATEGORY_MANAGEMENT": { + "TITLE": "Manage Categories", + "LOADING": "Loading categories...", + "SELECTED": "Selected", + "SELECT_ALL": "Select All", + "DESELECT_ALL": "Deselect All", + "SEARCH_PLACEHOLDER": "Search categories...", + "NO_RESULTS": "No matching categories found", + "NO_CATEGORIES": "No categories available", + "SAVE": "Save" } }, "PORTALS": { diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md new file mode 100644 index 000000000..af20001e5 --- /dev/null +++ b/docs/architecture/category-management.md @@ -0,0 +1,142 @@ +# Category Management Feature + +## Overview + +Xtream API playlists often contain many categories, some of which may be empty, in another language, or simply not relevant to the user. The category management feature allows users to hide unwanted categories from the sidebar while keeping them in the database for potential future use. + +## User Flow + +1. User navigates to an Xtream playlist (Live TV, Movies, or Series section) +2. In the sidebar header, next to "All categories", there's a **tune icon button** +3. Clicking it opens the **Category Management Dialog** +4. User sees all categories with checkboxes (checked = visible, unchecked = hidden) +5. User can: + - Individually toggle categories + - Use "Select All" / "Deselect All" buttons + - Search/filter categories by name +6. On save, visibility preferences are persisted to the database +7. Hidden categories no longer appear in the sidebar + +## Technical Implementation + +### Database Schema + +Added `hidden` column to the `categories` table: + +```sql +ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0 +``` + +- `hidden = 0` (false): Category is visible (default) +- `hidden = 1` (true): Category is hidden + +**Migration**: Uses a safe migration pattern in `connection.ts` that catches errors for already-applied migrations, ensuring existing users get the new column automatically. + +### Backend (Electron) + +**File**: `apps/electron-backend/src/app/events/database/category.events.ts` + +| IPC Handler | Purpose | +| ------------------------------- | -------------------------------------------------- | +| `DB_GET_CATEGORIES` | Returns visible categories only (`hidden = false`) | +| `DB_GET_ALL_CATEGORIES` | Returns all categories (for management dialog) | +| `DB_UPDATE_CATEGORY_VISIBILITY` | Batch updates `hidden` status for category IDs | + +### Frontend Services + +**File**: `libs/services/src/lib/database-electron.service.ts` + +| Method | Purpose | +| ---------------------------- | ------------------------------ | +| `getXtreamCategories()` | Sidebar display (filtered) | +| `getAllXtreamCategories()` | Management dialog (unfiltered) | +| `updateCategoryVisibility()` | Save visibility changes | + +### Components + +**Category Management Dialog** + +- Path: `apps/web/src/app/xtream-tauri/category-management-dialog/` +- Features: + - Checkbox list of all categories + - Select All / Deselect All buttons + - Search/filter with clearable input + - Shows selected count vs total + - Saves changes to database on confirm + +**Integration Points** + +- `xtream-main-container.component.ts` - Movies & Series sections +- `live-stream-layout.component.ts` - Live TV section + +Both components: + +1. Add a "tune" icon button in the sidebar header +2. Open the dialog with playlist ID and content type +3. Call `xtreamStore.reloadCategories()` after dialog closes with changes + +### Store + +**File**: `apps/web/src/app/xtream-tauri/xtream.store.ts` + +Added `reloadCategories()` method to refresh categories from database after visibility changes, ensuring the sidebar updates immediately. + +## Behavior Notes + +- **New categories**: When a playlist is refreshed, new categories from the remote API are added with `hidden = false` (visible by default) +- **Persistence**: Visibility settings survive playlist refresh +- **Per-playlist, per-type**: Categories are managed per playlist and per content type (live/movies/series) +- **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database + +## Files Changed + +``` +libs/shared/database/src/lib/ +├── schema.ts # Added hidden column to categories table +└── connection.ts # Added migration for existing databases + +apps/electron-backend/src/app/ +├── events/database/category.events.ts # New IPC handlers +└── api/main.preload.ts # Exposed new IPC methods + +libs/services/src/lib/ +└── database-electron.service.ts # New service methods + +apps/web/src/app/xtream-tauri/ +├── category-management-dialog/ # New dialog component +│ ├── category-management-dialog.component.ts +│ ├── category-management-dialog.component.html +│ └── category-management-dialog.component.scss +├── xtream-main-container.component.ts # Added button & dialog +├── xtream-main-container.component.html +├── live-stream-layout/ +│ ├── live-stream-layout.component.ts # Added button & dialog +│ └── live-stream-layout.component.html +├── sidebar.scss # Updated header styles +└── xtream.store.ts # Added reloadCategories method + +apps/web/src/assets/i18n/ +└── en.json # Added translation keys + +global.d.ts # TypeScript types for new IPC methods +``` + +## Translation Keys + +```json +{ + "XTREAM": { + "CATEGORY_MANAGEMENT": { + "TITLE": "Manage Categories", + "LOADING": "Loading categories...", + "SELECTED": "Selected", + "SELECT_ALL": "Select All", + "DESELECT_ALL": "Deselect All", + "SEARCH_PLACEHOLDER": "Search categories...", + "NO_RESULTS": "No matching categories found", + "NO_CATEGORIES": "No categories available", + "SAVE": "Save" + } + } +} +``` diff --git a/global.d.ts b/global.d.ts index e4f957790..7ad9673e2 100644 --- a/global.d.ts +++ b/global.d.ts @@ -123,6 +123,14 @@ declare global { categories: any[], type: string ) => Promise<{ success: boolean }>; + dbGetAllCategories: ( + playlistId: string, + type: string + ) => Promise; + dbUpdateCategoryVisibility: ( + categoryIds: number[], + hidden: boolean + ) => Promise<{ success: boolean }>; dbHasContent: ( playlistId: string, type: string diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 8e6907bb8..41aec7929 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -12,6 +12,7 @@ export interface XCategoryFromDb { playlist_id: string; type: 'movies' | 'live' | 'series'; xtream_id: number; + hidden: boolean; } export interface XtreamContent { @@ -168,6 +169,35 @@ export class DatabaseService { await window.electron.dbSaveCategories(playlistId, categories, type); } + /** + * Get all categories for a playlist (including hidden, for management dialog) + */ + async getAllXtreamCategories( + playlistId: string, + type: 'live' | 'movies' | 'series' + ): Promise { + return await window.electron.dbGetAllCategories(playlistId, type); + } + + /** + * Update category visibility (show/hide categories) + */ + async updateCategoryVisibility( + categoryIds: number[], + hidden: boolean + ): Promise { + try { + await window.electron.dbUpdateCategoryVisibility( + categoryIds, + hidden + ); + return true; + } catch (error) { + console.error('Error updating category visibility:', error); + return false; + } + } + /** * Check if content exists */ diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index b25863ca6..d9354c4c4 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -26,21 +26,21 @@ let initPromise: Promise | null = null; * Get the database file path */ export function getDatabasePath(): string { - const dbDir = join(homedir(), '.iptvnator', 'databases'); + const dbDir = join(homedir(), '.iptvnator', 'databases'); - // Ensure the directory exists - if (!existsSync(dbDir)) { - mkdirSync(dbDir, { recursive: true }); - } + // Ensure the directory exists + if (!existsSync(dbDir)) { + mkdirSync(dbDir, { recursive: true }); + } - return join(dbDir, 'iptvnator.db'); + return join(dbDir, 'iptvnator.db'); } /** * SQL statements for creating all tables */ const CREATE_TABLE_STATEMENTS = [ - `CREATE TABLE IF NOT EXISTS playlists ( + `CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, name TEXT NOT NULL, serverUrl TEXT, @@ -58,15 +58,16 @@ const CREATE_TABLE_STATEMENTS = [ url TEXT, last_usage TEXT )`, - `CREATE TABLE IF NOT EXISTS categories ( + `CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')), xtream_id INTEGER NOT NULL, + hidden INTEGER DEFAULT 0, FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE )`, - `CREATE TABLE IF NOT EXISTS content ( + `CREATE TABLE IF NOT EXISTS content ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, title TEXT NOT NULL, @@ -77,7 +78,7 @@ const CREATE_TABLE_STATEMENTS = [ type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')), FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE )`, - `CREATE TABLE IF NOT EXISTS recently_viewed ( + `CREATE TABLE IF NOT EXISTS recently_viewed ( id INTEGER PRIMARY KEY AUTOINCREMENT, content_id INTEGER NOT NULL, playlist_id TEXT NOT NULL, @@ -85,7 +86,7 @@ const CREATE_TABLE_STATEMENTS = [ FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE )`, - `CREATE TABLE IF NOT EXISTS favorites ( + `CREATE TABLE IF NOT EXISTS favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, content_id INTEGER NOT NULL, playlist_id TEXT NOT NULL, @@ -93,20 +94,20 @@ const CREATE_TABLE_STATEMENTS = [ FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE )`, - `CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`, - `CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`, - `CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`, - `CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`, - `CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`, - `CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`, - `CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id)`, - `CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id)`, - `CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id)`, - `CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id)`, - `CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id)`, - `CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at)`, - // EPG tables - `CREATE TABLE IF NOT EXISTS epg_channels ( + `CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`, + `CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`, + `CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`, + `CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`, + `CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`, + `CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`, + `CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id)`, + `CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id)`, + `CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id)`, + `CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id)`, + `CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at)`, + // EPG tables + `CREATE TABLE IF NOT EXISTS epg_channels ( id TEXT PRIMARY KEY, display_name TEXT NOT NULL, icon_url TEXT, @@ -114,7 +115,7 @@ const CREATE_TABLE_STATEMENTS = [ source_url TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) )`, - `CREATE TABLE IF NOT EXISTS epg_programs ( + `CREATE TABLE IF NOT EXISTS epg_programs ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL, start TEXT NOT NULL, @@ -127,30 +128,30 @@ const CREATE_TABLE_STATEMENTS = [ episode_num TEXT, FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE )`, - // EPG indexes - `CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url)`, - `CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name)`, - `CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id)`, - `CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start)`, - `CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop)`, - // FTS5 virtual table for full-text search on EPG programs - `CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5( + // EPG indexes + `CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url)`, + `CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name)`, + `CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id)`, + `CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start)`, + `CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop)`, + // FTS5 virtual table for full-text search on EPG programs + `CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5( title, description, category, content='epg_programs', content_rowid='id' )`, - // Triggers to keep FTS index in sync with epg_programs table - `CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN + // Triggers to keep FTS index in sync with epg_programs table + `CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END`, - `CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN + `CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) VALUES ('delete', old.id, old.title, old.description, old.category); END`, - `CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN + `CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) VALUES ('delete', old.id, old.title, old.description, old.category); INSERT INTO epg_programs_fts(rowid, title, description, category) @@ -158,20 +159,43 @@ const CREATE_TABLE_STATEMENTS = [ END`, ]; +/** + * Migration statements that may fail if already applied + * These are run with try-catch to handle existing columns + */ +const MIGRATION_STATEMENTS = [ + // v1.0.0 -> v1.1.0: Add hidden column to categories for category management + `ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0`, +]; + /** * Create tables if they don't exist */ function createTables(sqliteDb: Database.Database): void { - for (const stmt of CREATE_TABLE_STATEMENTS) { - sqliteDb.exec(stmt); - } + for (const stmt of CREATE_TABLE_STATEMENTS) { + sqliteDb.exec(stmt); + } +} + +/** + * Run migrations that may fail if already applied + */ +function runMigrations(sqliteDb: Database.Database): void { + for (const stmt of MIGRATION_STATEMENTS) { + try { + sqliteDb.exec(stmt); + } catch (error) { + // Ignore errors for migrations that may have already been applied + // (e.g., "duplicate column name" for ALTER TABLE ADD COLUMN) + } + } } export interface DatabaseOptions { - /** Open database in read-only mode (for agent-backend) */ - readonly?: boolean; - /** Skip table creation (useful for read-only connections) */ - skipTableCreation?: boolean; + /** Open database in read-only mode (for agent-backend) */ + readonly?: boolean; + /** Skip table creation (useful for read-only connections) */ + skipTableCreation?: boolean; } /** @@ -180,31 +204,32 @@ export interface DatabaseOptions { * Used by electron-backend */ export async function initDatabase( - options: DatabaseOptions = {} + options: DatabaseOptions = {} ): Promise { - const { readonly = false, skipTableCreation = false } = options; + const { readonly = false, skipTableCreation = false } = options; - if (db) return db; - if (initPromise) return initPromise; + if (db) return db; + if (initPromise) return initPromise; - initPromise = (async () => { - const filePath = getDatabasePath(); - sqlite = new Database(filePath, { readonly }); + initPromise = (async () => { + const filePath = getDatabasePath(); + sqlite = new Database(filePath, { readonly }); - // Enable foreign keys - sqlite.pragma('foreign_keys = ON'); + // Enable foreign keys + sqlite.pragma('foreign_keys = ON'); - // Create tables only for read-write connections - if (!readonly && !skipTableCreation) { - createTables(sqlite); - } + // Create tables only for read-write connections + if (!readonly && !skipTableCreation) { + createTables(sqlite); + runMigrations(sqlite); + } - const database = drizzle(sqlite, { schema }); - db = database; - return database; - })(); + const database = drizzle(sqlite, { schema }); + db = database; + return database; + })(); - return initPromise; + return initPromise; } /** @@ -213,10 +238,10 @@ export async function initDatabase( * Used by electron-backend */ export async function getDatabase( - options?: DatabaseOptions + options?: DatabaseOptions ): Promise { - if (db) return db; - return initDatabase(options); + if (db) return db; + return initDatabase(options); } /** @@ -224,17 +249,17 @@ export async function getDatabase( * Used by agent-backend for safe read-only queries */ export async function getReadOnlyDatabase(): Promise { - return getDatabase({ readonly: true, skipTableCreation: true }); + return getDatabase({ readonly: true, skipTableCreation: true }); } /** * Close the database connection */ export function closeDatabase(): void { - if (sqlite) { - sqlite.close(); - sqlite = null; - db = null; - initPromise = null; - } + if (sqlite) { + sqlite.close(); + sqlite = null; + db = null; + initPromise = null; + } } diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 3e21f0167..4b43ba180 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -49,6 +49,7 @@ export const categories = sqliteTable( name: text('name').notNull(), type: text('type', { enum: ['live', 'movies', 'series'] }).notNull(), xtreamId: integer('xtream_id').notNull(), + hidden: integer('hidden', { mode: 'boolean' }).default(false), }, (table) => ({ playlistIdx: index('idx_categories_playlist').on(table.playlistId), From bc30acf4a77abc8e15c7e270329ac5b44c2fbeb0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 6 Jan 2026 21:41:18 +0100 Subject: [PATCH 05/12] fix: handle empty xtream categories gracefully Skip database insert when categories array is empty to prevent 'values() must be called with at least one value' error. Closes #684 --- .../src/app/events/database/category.events.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 603a8cf87..619573ecd 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -75,6 +75,11 @@ ipcMain.handle( type: 'live' | 'movies' | 'series' ) => { try { + // Skip if no categories to save + if (!categories || categories.length === 0) { + return { success: true }; + } + const db = await getDatabase(); const values = categories.map((cat) => ({ playlistId, From 8e15e898588459617ab910f0377030d251c1a3d5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Jan 2026 19:10:17 +0100 Subject: [PATCH 06/12] feat(xtream): add category item counts to category view --- .../category-management-dialog.component.scss | 4 ++++ .../category-view.component.html | 1 + .../category-view.component.scss | 6 +++++ .../category-view/category-view.component.ts | 6 +++++ .../live-stream-layout.component.html | 1 + .../live-stream-layout.component.ts | 3 ++- .../xtream-main-container.component.html | 1 + .../xtream-main-container.component.ts | 1 + apps/web/src/app/xtream-tauri/xtream.store.ts | 23 +++++++++++++++++++ 9 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss index 079ba4d9f..caba6ebe6 100644 --- a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss +++ b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss @@ -1,3 +1,7 @@ +::ng-deep .mat-mdc-dialog-content { + overflow: hidden !important; +} + .loading-container { display: flex; flex-direction: column; diff --git a/apps/web/src/app/xtream-tauri/category-view/category-view.component.html b/apps/web/src/app/xtream-tauri/category-view/category-view.component.html index 8a2bca02d..c94586314 100644 --- a/apps/web/src/app/xtream-tauri/category-view/category-view.component.html +++ b/apps/web/src/app/xtream-tauri/category-view/category-view.component.html @@ -9,6 +9,7 @@ {{ item.category_name || $any(item).name || 'No category name' }} + ({{ getItemCount(item) }}) } @empty { (); + readonly itemCounts = input>(new Map()); readonly categoryClicked = output(); @@ -31,4 +32,9 @@ export class CategoryViewComponent { String(selectedCategory) === String(itemId) ); } + + getItemCount(item: XtreamCategory): number { + const itemId = Number((item as any).category_id ?? item.id); + return this.itemCounts().get(itemId) ?? 0; + } } diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html index 049187809..cee63ce9c 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html @@ -28,6 +28,7 @@ } @else { } diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts index 63cecfd15..c899b7931 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts @@ -55,6 +55,7 @@ export class LiveStreamLayoutComponent implements OnInit { private readonly route = inject(ActivatedRoute); readonly categories = this.xtreamStore.getCategoriesBySelectedType; + readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts; readonly epgItems = this.xtreamStore.epgItems; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; @@ -75,7 +76,7 @@ export class LiveStreamLayoutComponent implements OnInit { }); } - const { categoryId } = this.route.firstChild.snapshot.params; + const categoryId = this.route.firstChild?.snapshot.params['categoryId']; if (categoryId) this.xtreamStore.setSelectedCategory(Number(categoryId)); } diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html index 94e1e5054..4b388fd1a 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html @@ -16,6 +16,7 @@ diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts b/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts index 847779bff..67e78bd73 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts @@ -35,6 +35,7 @@ export class XtreamMainContainerComponent implements OnInit { private readonly dialog = inject(MatDialog); readonly categories = this.xtreamStore.getCategoriesBySelectedType; + readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; diff --git a/apps/web/src/app/xtream-tauri/xtream.store.ts b/apps/web/src/app/xtream-tauri/xtream.store.ts index 2ea2b37db..eb7970754 100644 --- a/apps/web/src/app/xtream-tauri/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/xtream.store.ts @@ -87,6 +87,29 @@ export const XtreamStore = signalStore( ? store.vodCategories() : store.serialCategories(); }), + /** + * Memoized category item counts map. + * Returns a Map for the currently selected content type. + * The Map is recomputed only when streams or content type changes. + */ + getCategoryItemCounts: computed(() => { + const type = store.selectedContentType(); + const streams = + type === 'live' + ? store.liveStreams() + : type === 'vod' + ? store.vodStreams() + : store.serialStreams(); + + const countMap = new Map(); + for (const item of streams) { + const catId = Number(item.category_id); + if (!isNaN(catId)) { + countMap.set(catId, (countMap.get(catId) || 0) + 1); + } + } + return countMap; + }), getSelectedCategory: computed(() => { const categoryId = store.selectedCategoryId(); return [ From 529b53c66cd6feed6f96d62b0f2fa62144c93f09 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Jan 2026 20:40:24 +0100 Subject: [PATCH 07/12] fix(database): log errors for already applied migrations --- libs/shared/database/src/lib/connection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index d9354c4c4..a719fc962 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -185,8 +185,9 @@ function runMigrations(sqliteDb: Database.Database): void { try { sqliteDb.exec(stmt); } catch (error) { - // Ignore errors for migrations that may have already been applied + // Log and ignore errors for migrations that may have already been applied // (e.g., "duplicate column name" for ALTER TABLE ADD COLUMN) + console.debug('Migration already applied or failed:', error); } } } From 2c3c979a960206294118091d4efe2137c181a0e8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Jan 2026 20:40:45 +0100 Subject: [PATCH 08/12] fix(category): handle empty categoryIds in visibility update --- .../src/app/events/database/category.events.ts | 3 +++ 1 file changed, 3 insertions(+) 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 619573ecd..dbb7913bb 100644 --- a/apps/electron-backend/src/app/events/database/category.events.ts +++ b/apps/electron-backend/src/app/events/database/category.events.ts @@ -104,6 +104,9 @@ ipcMain.handle( 'DB_UPDATE_CATEGORY_VISIBILITY', async (event, categoryIds: number[], hidden: boolean) => { try { + if (categoryIds.length === 0) { + return { success: true }; + } const db = await getDatabase(); await db .update(schema.categories) From 74a65a53a977ab101c783300b65d14102fd82df1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Jan 2026 20:41:03 +0100 Subject: [PATCH 09/12] fix(category-view): ensure itemId is a number for selection comparison --- .../xtream-tauri/category-view/category-view.component.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/xtream-tauri/category-view/category-view.component.ts b/apps/web/src/app/xtream-tauri/category-view/category-view.component.ts index 9939329ed..0136e1b44 100644 --- a/apps/web/src/app/xtream-tauri/category-view/category-view.component.ts +++ b/apps/web/src/app/xtream-tauri/category-view/category-view.component.ts @@ -25,12 +25,9 @@ export class CategoryViewComponent { isSelected(item: XtreamCategory): boolean { const selectedCategory = this.selectedCategoryId(); - const itemId = (item as any).category_id ?? item.id; + const itemId = Number((item as any).category_id ?? item.id); - return ( - selectedCategory !== null && - String(selectedCategory) === String(itemId) - ); + return selectedCategory !== null && selectedCategory === itemId; } getItemCount(item: XtreamCategory): number { From 16a5e1e4c9605e5ddadd3354244ad817fc9c016c Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Jan 2026 20:41:22 +0100 Subject: [PATCH 10/12] feat(category-management): add snackbar notification for save errors --- .../category-management-dialog.component.scss | 4 ---- .../category-management-dialog.component.ts | 6 ++++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss index caba6ebe6..079ba4d9f 100644 --- a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss +++ b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.scss @@ -1,7 +1,3 @@ -::ng-deep .mat-mdc-dialog-content { - overflow: hidden !important; -} - .loading-container { display: flex; flex-direction: column; diff --git a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts index ff6ab5490..38ff9f639 100644 --- a/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts +++ b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts @@ -11,6 +11,7 @@ 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'; import { DatabaseService, XCategoryFromDb } from 'services'; @@ -168,6 +169,11 @@ export class CategoryManagementDialogComponent implements OnInit { this.dialogRef.close(true); } catch (error) { console.error('Error saving category visibility:', error); + inject(MatSnackBar).open( + 'Failed to save category visibility', + 'Close', + { duration: 3000 } + ); } finally { this.isSaving.set(false); } From f2b59d3994187d937b3b680850e3bae9bdbf679d Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Jan 2026 20:41:31 +0100 Subject: [PATCH 11/12] refactor(stalker): remove debug logging for VOD series seasons fetching --- apps/web/src/app/stalker/stalker.store.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index b5c9f6cde..10974cc91 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -454,11 +454,6 @@ export const StalkerStore = signalStore( p: '1', }; - console.log( - '[StalkerStore] Fetching VOD series seasons for movie_id:', - item.id - ); - let response: any; if (playlist.isFullStalkerPortal) { response = From 5070c2a707b004f61bd712142f8f84441a187e44 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Jan 2026 23:40:28 +0100 Subject: [PATCH 12/12] feat(category-view): add showCounts input to conditionally display item counts --- .../category-view/category-view.component.html | 4 +++- .../xtream-tauri/category-view/category-view.component.ts | 1 + .../live-stream-layout/live-stream-layout.component.html | 1 + .../live-stream-layout/live-stream-layout.component.ts | 7 +++++++ .../app/xtream-tauri/xtream-main-container.component.html | 1 + 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/xtream-tauri/category-view/category-view.component.html b/apps/web/src/app/xtream-tauri/category-view/category-view.component.html index c94586314..4f846df56 100644 --- a/apps/web/src/app/xtream-tauri/category-view/category-view.component.html +++ b/apps/web/src/app/xtream-tauri/category-view/category-view.component.html @@ -9,7 +9,9 @@ {{ item.category_name || $any(item).name || 'No category name' }} - ({{ getItemCount(item) }}) + @if (showCounts()) { + ({{ getItemCount(item) }}) + } } @empty { (); readonly itemCounts = input>(new Map()); + readonly showCounts = input(false); readonly categoryClicked = output(); diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html index cee63ce9c..cb34a6c50 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html @@ -30,6 +30,7 @@ [items]="categories()" [itemCounts]="categoryItemCounts()" (categoryClicked)="selectCategory($event)" + [showCounts]="true" /> } diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts index c899b7931..11e5178b5 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts @@ -84,6 +84,13 @@ export class LiveStreamLayoutComponent implements OnInit { playLive(item: any) { const streamUrl = this.xtreamStore.constructStreamUrl(item); this.streamUrl = streamUrl; + const isEmbeddedPlayer = + this.player() === 'videojs' || + this.player() === 'html5' || + this.player() === 'artplayer'; + if (isEmbeddedPlayer) { + return; + } this.xtreamStore.openPlayer(streamUrl, item.title, item.poster_url); } diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html index 4b388fd1a..6c6895e60 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html @@ -18,6 +18,7 @@ [selectedCategoryId]="selectedCategoryId()" [itemCounts]="categoryItemCounts()" (categoryClicked)="categoryClicked($event)" + [showCounts]="true" />