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..dbb7913bb 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`); @@ -74,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, @@ -90,3 +96,52 @@ ipcMain.handle( } } ); + +/** + * Update category visibility (hidden status) + */ +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) + .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/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/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, { diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index 0c882fee7..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 = @@ -477,16 +472,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 +632,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 +646,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 +869,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 +883,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 +909,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 +952,6 @@ export const StalkerStore = signalStore( } }, addToRecentlyViewed(item: any) { - console.log('Adding to recently viewed', item); playlistService .addPortalRecentlyViewed(this.currentPlaylist()?._id, { ...item, 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..38ff9f639 --- /dev/null +++ b/apps/web/src/app/xtream-tauri/category-management-dialog/category-management-dialog.component.ts @@ -0,0 +1,185 @@ +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 { MatSnackBar } from '@angular/material/snack-bar'; +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); + inject(MatSnackBar).open( + 'Failed to save category visibility', + 'Close', + { duration: 3000 } + ); + } finally { + this.isSaving.set(false); + } + } + + cancel(): void { + this.dialogRef.close(false); + } +} 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..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,6 +9,9 @@ {{ item.category_name || $any(item).name || 'No category name' }} + @if (showCounts()) { + ({{ getItemCount(item) }}) + } } @empty { (); + readonly itemCounts = input>(new Map()); + readonly showCounts = input(false); readonly categoryClicked = output(); 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 { + 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 81ec04de6..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 @@ -13,6 +13,14 @@ list

{{ 'PORTALS.ALL_CATEGORIES' | translate }}

+ } @if (selectedCategoryId()) { @@ -20,7 +28,9 @@ } @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 fbb0f02af..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 @@ -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,13 @@ 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 categoryItemCounts = this.xtreamStore.getCategoryItemCounts; readonly epgItems = this.xtreamStore.epgItems; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; - private readonly route = inject(ActivatedRoute); readonly player = this.settingsStore.player; streamUrl: string; @@ -67,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)); } @@ -75,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); } @@ -86,4 +102,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..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 @@ -4,11 +4,21 @@ list

{{ 'PORTALS.ALL_CATEGORIES' | translate }}

+ 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 4f3c30429..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 @@ -1,9 +1,15 @@ import { Component, inject, OnInit } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { XtreamCategory } from 'shared-interfaces'; +import { + CategoryManagementDialogComponent, + CategoryManagementDialogData, +} from './category-management-dialog/category-management-dialog.component'; import { CategoryViewComponent } from './category-view/category-view.component'; import { XtreamStore } from './xtream.store'; @@ -18,6 +24,7 @@ import { XtreamStore } from './xtream.store'; /* MpvPlayerBarComponent, */ MatIcon, MatIconButton, + MatTooltipModule, ], }) export class XtreamMainContainerComponent implements OnInit { @@ -25,8 +32,10 @@ export class XtreamMainContainerComponent implements OnInit { readonly route = inject(ActivatedRoute); readonly translateService = inject(TranslateService); readonly xtreamStore = inject(XtreamStore); + private readonly dialog = inject(MatDialog); readonly categories = this.xtreamStore.getCategoriesBySelectedType; + readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; @@ -81,4 +90,30 @@ export class XtreamMainContainerComponent implements OnInit { relativeTo: this.route, }); } + + openCategoryManagement(): void { + // The playlist id is in the parent route (xtreams/:id) + 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) { + // 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..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 [ @@ -639,7 +662,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 +912,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/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( diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index b25863ca6..a719fc962 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,44 @@ 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) { + // 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); + } + } } 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 +205,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 +239,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 +250,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),