From e079d268f08903d6b903e9f815ddd33e10a20e6a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Nov 2025 10:22:27 +0100 Subject: [PATCH 01/16] docs: add CLAUDE.md with developer guidance --- .claude/settings.local.json | 11 ++ CLAUDE.md | 290 ++++++++++++++++++++++++++++++++++++ README.md | 11 +- 3 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..ee75d9f10 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(tree:*)", + "WebSearch", + "WebFetch(domain:www.electronjs.org)" + ], + "deny": [], + "ask": [] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..c42097d00 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,290 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals. + +**Dual Environment Support**: The application is designed to work in both Electron and as a Progressive Web App (PWA). The architecture uses a factory pattern to inject environment-specific services at runtime, ensuring the same codebase works in both contexts. + +## Development Commands + +### Building and Serving + +```bash +# Serve the Angular web app only (development mode, baseHref="/") +npm run serve:frontend +# or +nx serve web + +# Serve with PWA configuration (optimized, baseHref="/") +npm run serve:frontend:pwa +# or +nx serve web --configuration=pwa + +# Serve the Electron app (starts both frontend and backend) +npm run serve:backend +# or +nx serve electron-backend + +# Build frontend for Electron (baseHref="./") +npm run build:frontend +# or +nx build web + +# Build frontend for PWA deployment (baseHref="/") +npm run build:frontend:pwa +# or +nx build web --configuration=pwa + +# Build backend (Electron) +npm run build:backend +# or +nx build electron-backend + +# Package the app (creates distributable without installers) +npm run package:app +# or +nx run electron-backend:package + +# Create installers/executables +npm run make:app +# or +nx run electron-backend:make +``` + +### Testing + +```bash +# Run frontend tests +npm run test:frontend +# or +nx test web + +# Run backend tests +npm run test:backend +# or +nx test electron-backend + +# Run e2e tests (Playwright) +nx e2e web-e2e + +# Run tests with coverage +nx test web --configuration=ci +``` + +### Linting + +```bash +# Lint frontend +nx lint web + +# Lint backend +nx lint electron-backend +``` + +## Architecture + +### Monorepo Structure (Nx Workspace) + +This is an Nx monorepo with the following structure: + +- **apps/web** - Angular application (frontend) +- **apps/electron-backend** - Electron main process +- **apps/web-e2e** - Playwright end-to-end tests +- **libs/** - Shared libraries: + - **m3u-state** - NgRx state management for playlists + - **services** - Abstract DataService and implementations + - **shared/interfaces** - TypeScript interfaces and types + - **shared/m3u-utils** - M3U playlist utilities + - **ui/components** - Reusable UI components + - **ui/pipes** - Angular pipes + - **ui/shared-portals** - Portal-related UI components + +### Frontend Architecture (Angular) + +**State Management**: Uses NgRx for playlist state management: +- Store configuration in `apps/web/src/app/app.config.ts` +- Playlist state, actions, effects, and reducers in `libs/m3u-state/` +- Entity adapter pattern for managing playlists collection +- Router store integration for route-based state + +**Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts` +- Home/playlists overview: `/` +- Video player: `/playlists/:id` or `/iptv` +- Xtream Codes: `/xtreams/:id` (different routes for Electron vs web) +- Stalker portal: `/portals/:id` +- Settings: `/settings` + +**Service Architecture** (Factory Pattern): +- Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract +- Two environment-specific implementations: + - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend + - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version +- Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject: + ```typescript + if (window.electron) { + return new ElectronService(); + } + return new PwaService(); + ``` + +**Data Storage (Environment-Specific)**: +- **Electron**: libSQL/SQLite database via Drizzle ORM + - Location: `~/.iptvnator/databases/iptvnator.db` + - Full-featured relational database with foreign keys and indexes + - Supports local file or remote Turso instance via env vars +- **PWA (Web)**: IndexedDB via `ngx-indexed-db` + - Browser-based NoSQL storage + - Same schema structure but implemented in IndexedDB + - Limited by browser storage quotas + +### Backend Architecture (Electron) + +**Main Entry**: `apps/electron-backend/src/main.ts` +- Bootstraps Electron app and initializes database +- Registers event handlers for IPC communication + +**Database**: +- **ORM**: Drizzle ORM with libSQL (local SQLite file or remote Turso) +- **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path) +- **Schema** (`apps/electron-backend/src/app/database/schema.ts`): + - `playlists` - Playlist metadata (M3U, Xtream, Stalker) + - `categories` - Content categories (live, movies, series) + - `content` - Streams/VOD/series items + - `favorites` - User favorites + - `recentlyViewed` - Watch history +- **Connection**: `apps/electron-backend/src/app/database/connection.ts` + - Auto-creates tables on init + - Supports local file or remote via env vars (`LIBSQL_URL`, `LIBSQL_AUTH_TOKEN`) + +**IPC Communication**: +- **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts` + - Exposes `window.electron` API via `contextBridge` + - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.) +- **Event handlers**: `apps/electron-backend/src/app/events/` + - `database.events.ts` - Database CRUD operations + - `playlist.events.ts` - Playlist import/update + - `epg.events.ts` - EPG fetch and parsing (uses worker) + - `xtream.events.ts` - Xtream Codes API + - `stalker.events.ts` - Stalker portal API + - `player.events.ts` - External player (MPV, VLC) integration + - `settings.events.ts` - App settings + - `electron.events.ts` - App version, etc. + +**Workers**: +- EPG parsing runs in worker thread: `apps/electron-backend/src/app/workers/epg-parser.worker.ts` + +### Key Features + +**Playlist Support**: +- M3U/M3U8 files (local or URL) +- Xtream Codes API (`username`, `password`, `serverUrl`) +- Stalker portal (`macAddress`, `url`) + +**Video Players**: +- Built-in HTML5 player with HLS.js or Video.js +- External players: MPV, VLC (via IPC to Electron backend) + +**EPG (Electronic Program Guide)**: +- XMLTV format support +- Background parsing in worker thread +- Stored in database for quick lookup + +**Favorites and Recently Viewed**: +- Per-playlist favorites and global favorites +- Recently viewed tracks watch history + +**Internationalization**: +- Uses `@ngx-translate` with 16 language files in `apps/web/src/assets/i18n/` + +## Development Notes + +### Environment Detection and Dual-Mode Architecture + +The app determines whether it's running in Electron or as a PWA by checking: +```typescript +window.electron // truthy in Electron, undefined in browser +``` + +**Why Dual Mode?** +IPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility: +- **Electron**: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access +- **PWA**: Lightweight web version that runs in any browser without installation + +**Environment-Specific Behavior**: +- `app.config.ts` - `DataFactory()` selects DataService implementation based on environment +- `app.routes.ts` - Different routes for Xtream portals (Electron uses Tauri-based routes, PWA uses standard routes) +- Storage layer switches automatically: + - Electron → libSQL/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db` + - PWA → IndexedDB → Browser storage +- External player support (MPV/VLC) only available in Electron +- File system operations only available in Electron (uploading playlists from disk) + +**Base Href Configuration**: +The app uses different base href values depending on the build target: +- **Development & PWA**: `baseHref="/"` (from `index.html`) + - Used by: `npm run serve:frontend`, `npm run build:frontend:pwa` + - For web servers with proper routing +- **Electron Production**: `baseHref="./"` (overridden in build config) + - Used by: `npm run build:backend`, `npm run make:app` + - Required for `file://` protocol in Electron + +Build configurations in `apps/web/project.json`: +- `production`: Electron build with `baseHref="./"` +- `pwa`: Web deployment with `baseHref="/"` +- `development`: Dev mode with `baseHref="/"` from index.html + +**Factory Pattern Implementation**: +The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations. + +### Testing Strategy + +- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks` +- **E2E tests**: Playwright testing the web app +- Backend tests use standard Jest + +### Nx Commands + +Use `nx` CLI for better performance: +```bash +nx run : +# Example: nx run web:build +# Example: nx run electron-backend:serve +``` + +To run multiple projects: +```bash +nx run-many --target=test --all +``` + +### Electron Build Process + +The Electron backend depends on the web app being built first: +- `electron-backend:build` depends on `web:build` +- Output goes to `dist/apps/electron-backend` (backend) and `dist/apps/web` (frontend) +- Packaging combines both into distributable + +### Database Migrations + +No formal migration system yet. Schema changes are applied via raw SQL in `connection.ts` `createTables()` function using `CREATE TABLE IF NOT EXISTS`. + +### Common Patterns + +**IPC Communication**: +1. Define handler in appropriate events file (e.g., `database.events.ts`) +2. Register with `ipcMain.handle()` in the event bootstrap function +3. Expose in preload script via `contextBridge.exposeInMainWorld()` +4. Call from Angular via `window.electron.()` + +**Adding New Playlist Source**: +1. Add type to `libs/shared/interfaces/src/lib/playlist.interface.ts` +2. Create event handler in `apps/electron-backend/src/app/events/` +3. Add UI in `apps/web/src/app/home/` +4. Update database schema if needed + +**State Management**: +- Use NgRx for global application state (playlists) +- Use component stores (`@ngrx/component-store`) for feature-specific state +- Use NgRx signals for reactive data streams diff --git a/README.md b/README.md index 3baad53ad..f8c6c395e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ **IPTVnator** is a video player application that provides support for IPTV playlist playback (m3u, m3u8). The application allows users to import playlists using remote URLs or by uploading files from the local file system. Additionally, it supports EPG information in XMLTV format which can be provided via URL. -The application is a cross-platform, open-source project built with ~~Electron~~ Tauri and Angular. +The application is a cross-platform, open-source project built with Electron and Angular. ⚠️ Note: IPTVnator does not provide any playlists or other digital content. The channels and pictures in the screenshots are for demonstration purposes only. @@ -113,7 +113,6 @@ sudo emerge iptvnator-bin Requirements: - Node.js with npm -- Rust (required for tauri) 1. Clone this repository and install project dependencies: @@ -123,15 +122,15 @@ Requirements: 2. Start the application: ``` - $ npm run tauri dev + $ npm run serve:backend ``` -This will open the Tauri version in a separate window, while the PWA version will be available at http://localhost:4200. +This will open the Electron app in a separate window, while the Angular dev server will run at http://localhost:4200. -To run only the Angular app without Tauri, use: +To run only the Angular app without Electron, use: ``` -$ npm run serve +$ npm run serve:frontend ``` ## Disclaimer From 3eed15d806bc42b4c7980b15055aee2284919e01 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Oct 2025 09:43:27 +0000 Subject: [PATCH 02/16] feat(player): use user-agent, referrer, origin for vlc and mpv Enhance MPV player IPC handler to accept and forward extra parameters (url, path, title, userAgent, referer, origin). Attempt to reuse an existing MPV instance when reuse is enabled and an IPC socket is available; if reuse fails, fall back to spawning a new process. When creating a new MPV process, generate a unique platform-aware IPC socket path, build arguments conditionally from provided metadata (user-agent, referrer, origin via http-header-fields, force-media-title) and append the URL last. Improve logging for debugging and handle process spawn errors by clearing stale process/socket references. These changes enable smoother playback handoff, preserve headers and title for remote resources, and make MPV invocations more robust. --- .../src/app/events/player.events.ts | 251 ++++++++++++------ apps/web/src/app/services/electron.service.ts | 1 + 2 files changed, 164 insertions(+), 88 deletions(-) diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 22be20597..7f1f221b0 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -53,87 +53,125 @@ function sendMpvCommand(command: string, args: string[]): Promise { }); } -ipcMain.handle('OPEN_MPV_PLAYER', async (event, url) => { - try { - const mpvPath = getMpvPath(); - const reuseInstance = store.get(MPV_REUSE_INSTANCE, false); +ipcMain.handle( + 'OPEN_MPV_PLAYER', + async ( + event, + url: string, + path: string, + title: string, + userAgent?: string, + referer?: string, + origin?: string + ) => { + try { + const mpvPath = getMpvPath(); + const reuseInstance = store.get(MPV_REUSE_INSTANCE, false); - console.log('Opening MPV player with path:', mpvPath); - console.log('Reuse instance:', reuseInstance); - console.log('URL:', url); + console.log('Opening MPV player with path:', mpvPath); + console.log('Reuse instance:', reuseInstance); + console.log('URL:', url); + console.log('User-Agent:', userAgent); + console.log('Referer:', referer); + console.log('Origin:', origin); - // If reuse is enabled and there's an existing process, try to use it - if ( - reuseInstance && - mpvProcess && - !mpvProcess.killed && - mpvSocketPath - ) { - console.log('Reusing existing MPV instance'); - try { - await sendMpvCommand('loadfile', [url, 'replace']); - console.log( - 'Successfully loaded new URL in existing MPV instance' - ); - return; - } catch (err) { - console.error('Failed to send command to existing MPV:', err); - // If it fails, clear the reference and create a new one + // If reuse is enabled and there's an existing process, try to use it + if ( + reuseInstance && + mpvProcess && + !mpvProcess.killed && + mpvSocketPath + ) { + console.log('Reusing existing MPV instance'); + try { + await sendMpvCommand('loadfile', [url, 'replace']); + console.log( + 'Successfully loaded new URL in existing MPV instance' + ); + return; + } catch (err) { + console.error('Failed to send command to existing MPV:', err); + // If it fails, clear the reference and create a new one + mpvProcess = null; + mpvSocketPath = null; + } + } + + // Create new MPV process + console.log('Creating new MPV instance'); + + // Generate unique socket path + const socketPath = + process.platform === 'win32' + ? `\\\\.\\pipe\\mpv-${Date.now()}` + : `/tmp/mpvsocket-${Date.now()}`; + + const args = [`--input-ipc-server=${socketPath}`, '--idle=yes']; + + // Add user agent if provided + if (userAgent) { + args.push(`--user-agent=${userAgent}`); + } + + // Add referer if provided + if (referer) { + args.push(`--referrer=${referer}`); + } + + // Add origin as custom HTTP header if provided + // MPV doesn't have a direct --origin flag, so we use --http-header-fields + if (origin) { + args.push(`--http-header-fields=Origin: ${origin}`); + } + + // Add title if provided + if (title) { + args.push(`--force-media-title=${title}`); + } + + // Add URL last + args.push(url); + + const proc = spawn(mpvPath, args, { + shell: false, + detached: !reuseInstance, + stdio: 'ignore', + }); + + proc.on('error', (err) => { + console.error('Failed to start MPV player:', err); mpvProcess = null; mpvSocketPath = null; - } - } - - // Create new MPV process - console.log('Creating new MPV instance'); - - // Generate unique socket path - const socketPath = - process.platform === 'win32' - ? `\\\\.\\pipe\\mpv-${Date.now()}` - : `/tmp/mpvsocket-${Date.now()}`; - - const args = [`--input-ipc-server=${socketPath}`, '--idle=yes', url]; - - const proc = spawn(mpvPath, args, { - shell: false, - detached: !reuseInstance, - stdio: 'ignore', - }); - - proc.on('error', (err) => { - console.error('Failed to start MPV player:', err); - mpvProcess = null; - mpvSocketPath = null; - /* event.sender.send(ERROR, { + /* event.sender.send(ERROR, { message: `Error: Failed to start MPV player. Make sure that mpv player is installed on your system and the path is correct.`, }); */ - }); + }); - proc.on('exit', (code) => { - console.log(`MPV exited with code ${code}`); + proc.on('exit', (code) => { + console.log(`MPV exited with code ${code}`); + mpvProcess = null; + mpvSocketPath = null; + }); + + // Store the process reference if reuse is enabled + if (reuseInstance) { + mpvProcess = proc; + mpvSocketPath = socketPath; + console.log( + 'Stored MPV process for reuse with socket:', + socketPath + ); + } else { + // Detach the process so it can continue running independently + proc.unref(); + } + } catch (error) { + console.log(error); mpvProcess = null; mpvSocketPath = null; - }); - - // Store the process reference if reuse is enabled - if (reuseInstance) { - mpvProcess = proc; - mpvSocketPath = socketPath; - console.log( - 'Stored MPV process for reuse with socket:', - socketPath - ); - } else { - // Detach the process so it can continue running independently - proc.unref(); } - } catch (error) { - console.log(error); - mpvProcess = null; - mpvSocketPath = null; } -}); +); ipcMain.handle('SET_MPV_PLAYER_PATH', (_event, mpvPlayerPath) => { console.log('... setting mpv player path', mpvPlayerPath); @@ -153,28 +191,65 @@ ipcMain.handle('SET_MPV_REUSE_INSTANCE', (_event, reuseInstance: boolean) => { } }); -ipcMain.handle('OPEN_VLC_PLAYER', (event, url) => { - const vlcPath = getVlcPath(); - console.log('Opening VLC player with path:', vlcPath); - console.log('URL:', url); +ipcMain.handle( + 'OPEN_VLC_PLAYER', + ( + event, + url: string, + path: string, + title: string, + userAgent?: string, + referer?: string, + origin?: string + ) => { + const vlcPath = getVlcPath(); + console.log('Opening VLC player with path:', vlcPath); + console.log('URL:', url); + console.log('User-Agent:', userAgent); + console.log('Referer:', referer); + console.log('Origin:', origin); - const proc = spawn(vlcPath, [url as string], { - shell: false, - detached: true, - stdio: 'ignore', - }); + const args: string[] = []; - proc.on('error', (err) => { - console.error('Failed to start VLC player:', err); - }); + // Add user agent if provided (VLC uses :http-user-agent= format) + if (userAgent) { + args.push(`:http-user-agent=${userAgent}`); + } - proc.on('exit', (code) => { - console.log(`VLC exited with code ${code}`); - }); + // Add referer if provided (VLC uses :http-referrer= format) + if (referer) { + args.push(`:http-referrer=${referer}`); + } - // Detach the process so it can continue running independently - proc.unref(); -}); + // Note: VLC doesn't have a direct origin option, but origin is typically + // included in referer for most IPTV use cases + + // Add title if provided + if (title) { + args.push(`--meta-title=${title}`); + } + + // Add URL last + args.push(url); + + const proc = spawn(vlcPath, args, { + shell: false, + detached: true, + stdio: 'ignore', + }); + + proc.on('error', (err) => { + console.error('Failed to start VLC player:', err); + }); + + proc.on('exit', (code) => { + console.log(`VLC exited with code ${code}`); + }); + + // Detach the process so it can continue running independently + proc.unref(); + } +); ipcMain.handle('SET_VLC_PLAYER_PATH', (_event, vlcPlayerPath) => { console.log('... setting vlc player path', vlcPlayerPath); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 64f475e56..8033e6619 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -80,6 +80,7 @@ export class ElectronService extends DataService { return await window.electron.openInVlc( data.url, data.vlcPlayerPath || '', + data.title ?? '', data['user-agent'] ?? undefined, data.referer ?? undefined, data.origin ?? undefined From 6f9e8aa64e4293e5c36c1b32a4c4d7d4db69e452 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Nov 2025 11:03:15 +0000 Subject: [PATCH 03/16] feat(player): pass playlist headers to external players --- apps/web/src/app/services/player.service.ts | 11 ++++++- apps/web/src/app/stalker/stalker.store.ts | 12 +++++++- apps/web/src/app/xtream-tauri/xtream.store.ts | 6 +++- .../xtream/xtream-main-container.component.ts | 30 ++++++++++++++++--- .../interfaces/src/lib/playlist.interface.ts | 2 ++ 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index 1affa269c..5426cb8b2 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -26,7 +26,10 @@ export class PlayerService { title: string, thumbnail?: string, hideExternalInfoDialog = true, - isLiveContent = false + isLiveContent = false, + userAgent?: string, + referer?: string, + origin?: string ) { const player = this.settingsStore.player() ?? VideoPlayer.VideoJs; @@ -39,6 +42,9 @@ export class PlayerService { mpvPlayerPath: this.settingsStore.mpvPlayerPath(), title, thumbnail, + 'user-agent': userAgent, + referer: referer, + origin: origin, }); } else if (player === VideoPlayer.VLC) { if (!hideExternalInfoDialog) { @@ -49,6 +55,9 @@ export class PlayerService { vlcPlayerPath: this.settingsStore.vlcPlayerPath(), title, thumbnail, + 'user-agent': userAgent, + referer: referer, + origin: origin, }); } else if (!isLiveContent) { this.dialog.open( diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index c800821c4..1da396a13 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -479,7 +479,17 @@ export const StalkerStore = signalStore( cover: thumbnail, title, }); - playerService.openPlayer(url, title, thumbnail); + const playlist = this.currentPlaylist(); + playerService.openPlayer( + url, + title, + thumbnail, + true, + false, + playlist?.userAgent, + playlist?.referrer, + playlist?.origin + ); }, addToRecentlyViewed(item: any) { console.log('Adding to recently viewed', item); diff --git a/apps/web/src/app/xtream-tauri/xtream.store.ts b/apps/web/src/app/xtream-tauri/xtream.store.ts index a4e37b920..2415af60c 100644 --- a/apps/web/src/app/xtream-tauri/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/xtream.store.ts @@ -844,13 +844,17 @@ export const XtreamStore = signalStore( title: string, thumbnail: string = null ) { + const playlist = store.currentPlaylist(); playerService.openPlayer( streamUrl, title, thumbnail, localStorage.getItem('hideExternalInfoDialog') === 'true', - store.selectedContentType() === 'live' + store.selectedContentType() === 'live', + playlist?.userAgent, + playlist?.referrer, + playlist?.origin ); }, addToFavorites(item: any) { diff --git a/apps/web/src/app/xtream/xtream-main-container.component.ts b/apps/web/src/app/xtream/xtream-main-container.component.ts index 8a3b676c7..fc7a0233d 100644 --- a/apps/web/src/app/xtream/xtream-main-container.component.ts +++ b/apps/web/src/app/xtream/xtream-main-container.component.ts @@ -345,12 +345,18 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy { openPlayer(streamUrl: string, title: string) { this.streamUrl = streamUrl; this.player = this.settingsStore.player() ?? VideoPlayer.VideoJs; + const playlist = this.currentPlaylist() as Playlist; + if (this.player === VideoPlayer.MPV) { if (!this.hideExternalInfoDialog()) this.dialog.open(ExternalPlayerInfoDialogComponent); this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: streamUrl, mpvPlayerPath: this.settingsStore.mpvPlayerPath(), + title, + 'user-agent': playlist?.userAgent, + referer: playlist?.referrer, + origin: playlist?.origin, }); } else if (this.player === VideoPlayer.VLC) { if (!this.hideExternalInfoDialog()) @@ -358,6 +364,10 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy { this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { url: streamUrl, vlcPlayerPath: this.settingsStore.vlcPlayerPath(), + title, + 'user-agent': playlist?.userAgent, + referer: playlist?.referrer, + origin: playlist?.origin, }); } else { if (this.selectedContentType !== ContentType.ITV) { @@ -384,14 +394,26 @@ export class XtreamMainContainerComponent implements OnInit, OnDestroy { } playEpisode(episode: XtreamSerieEpisode) { - const { serverUrl, username, password } = - this.currentPlaylist() as Playlist; + const playlist = this.currentPlaylist() as Playlist; + const { serverUrl, username, password, userAgent, referrer, origin } = playlist; const player = this.settingsStore.player(); const streamUrl = `${serverUrl}/series/${username}/${password}/${episode.id}.${episode.container_extension}`; if (player === VideoPlayer.MPV) { - this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { url: streamUrl }); + this.dataService.sendIpcEvent(OPEN_MPV_PLAYER, { + url: streamUrl, + title: episode.title, + 'user-agent': userAgent, + referer: referrer, + origin: origin, + }); } else if (player === VideoPlayer.VLC) { - this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { url: streamUrl }); + this.dataService.sendIpcEvent(OPEN_VLC_PLAYER, { + url: streamUrl, + title: episode.title, + 'user-agent': userAgent, + referer: referrer, + origin: origin, + }); } else { this.dialog.open(PlayerDialogComponent, { data: { streamUrl, player, title: episode.title }, diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 0281a7941..c1d87e03d 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -23,6 +23,8 @@ export interface Playlist { count: number; url?: string; userAgent?: string; + referrer?: string; + origin?: string; filePath?: string; autoRefresh: boolean; updateDate?: number; From e6aa33959ec733b7b1b37a9e7a43bc3a03302e95 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Nov 2025 10:22:57 +0100 Subject: [PATCH 04/16] feat(deps): bump Electron to v39.x --- package-lock.json | 22 ++++++++++++++++------ package.json | 6 ++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e6b35d6b..6ddf51303 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,8 +112,8 @@ "angular-eslint": "^20.0.0", "conventional-changelog-cli": "5.0.0", "drizzle-kit": "0.31.5", - "electron": "^34.5.6", - "electron-builder": "26.0.12", + "electron": "^39.0.0", + "electron-builder": "^26.0.12", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "2.32.0", @@ -16732,15 +16732,15 @@ } }, "node_modules/electron": { - "version": "34.5.8", - "resolved": "https://registry.npmjs.org/electron/-/electron-34.5.8.tgz", - "integrity": "sha512-vxLD65mabTzYmEVa9KceMHM0+zO+vqgrhcyNVlmTd0IGV5J7XZ8v/qElm0o4YQ4wPeq7olZkUjZkBQQEdr23/g==", + "version": "39.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.0.0.tgz", + "integrity": "sha512-UejnuOK4jpRZUq7MkEAnR/szsRWLKBJAdvn6j3xdQLT57fVv13VSNdaUHHjSheaqGzNhCGIdkPsPJnGJVh5kiA==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^20.9.0", + "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { @@ -16886,6 +16886,16 @@ "dev": true, "license": "ISC" }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.18.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz", + "integrity": "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", diff --git a/package.json b/package.json index ccd6fdc6b..078cf1e35 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,10 @@ "scripts": { "postinstall": "electron-builder install-app-deps", "build:frontend": "nx build web", + "build:frontend:pwa": "nx build web --configuration=pwa", "build:backend": "nx build electron-backend", "serve:frontend": "nx serve web --no-tui", + "serve:frontend:pwa": "nx serve web --configuration=pwa --no-tui", "serve:backend": "nx serve electron-backend", "test:frontend": "nx test web", "test:backend": "nx test electron-backend", @@ -174,8 +176,8 @@ "angular-eslint": "^20.0.0", "conventional-changelog-cli": "5.0.0", "drizzle-kit": "0.31.5", - "electron": "^34.5.6", - "electron-builder": "26.0.12", + "electron": "^39.0.0", + "electron-builder": "^26.0.12", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "2.32.0", From 1b9dc0b37eb7123616ad18e136ce3b309ddb2d64 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Nov 2025 10:21:06 +0100 Subject: [PATCH 05/16] fix(ui): disable two Angular template accessibility rules --- libs/ui/components/eslint.config.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/ui/components/eslint.config.mjs b/libs/ui/components/eslint.config.mjs index 14d65f5f3..cf003ab32 100644 --- a/libs/ui/components/eslint.config.mjs +++ b/libs/ui/components/eslint.config.mjs @@ -29,6 +29,9 @@ export default [ { files: ['**/*.html'], // Override or add rules here - rules: {}, + rules: { + '@angular-eslint/template/click-events-have-key-events': 'off', + '@angular-eslint/template/interactive-supports-focus': 'off', + }, }, ]; From 837849ee574d0f790ca2f09f0802c40db1a418bc Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Nov 2025 10:24:41 +0100 Subject: [PATCH 06/16] refactor: code style improvements --- apps/web/project.json | 22 +++++ libs/m3u-state/tsconfig.lib.json | 1 + .../epg-list-item.component.html | 2 +- .../src/lib/epg-list/epg-list.component.ts | 92 +++++++++---------- .../html-video-player.component.spec.ts | 7 +- .../info-overlay.component.spec.ts | 27 ------ .../multi-epg-container.component.ts | 26 +++--- .../recent-playlists.component.html | 4 +- .../recent-playlists.component.ts | 72 +++------------ 9 files changed, 95 insertions(+), 158 deletions(-) delete mode 100644 libs/ui/components/src/lib/info-overlay/info-overlay.component.spec.ts diff --git a/apps/web/project.json b/apps/web/project.json index 4bb746386..a971bd13f 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -45,6 +45,25 @@ ], "outputHashing": "all" }, + "pwa": { + "baseHref": "/", + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "5mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kb", + "maximumError": "8kb" + } + ], + "outputHashing": "all", + "optimization": true, + "extractLicenses": true, + "sourceMap": false + }, "development": { "optimization": false, "extractLicenses": false, @@ -60,6 +79,9 @@ "production": { "buildTarget": "web:build:production" }, + "pwa": { + "buildTarget": "web:build:pwa" + }, "development": { "buildTarget": "web:build:development" } diff --git a/libs/m3u-state/tsconfig.lib.json b/libs/m3u-state/tsconfig.lib.json index 13468aff4..643cfe5bc 100644 --- a/libs/m3u-state/tsconfig.lib.json +++ b/libs/m3u-state/tsconfig.lib.json @@ -7,6 +7,7 @@ "inlineSources": true, "types": [] }, + "files": ["../../global.d.ts"], "exclude": [ "src/**/*.spec.ts", "src/test-setup.ts", diff --git a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html index d06100f57..3fc5902dc 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html +++ b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html @@ -17,7 +17,7 @@ > } -@if (item.desc?.length > 0) { +@if (item?.desc?.length > 0) { ; + readonly timeshiftUntil$ = this.store.select(selectActive).pipe( + map((active) => { + // Create EpgChannel with proper structure + const displayNames = active?.name + ? [{ lang: '', value: active.name }] + : []; + const icons = active?.tvg?.logo ? [{ src: active.tvg.logo }] : []; + + this.channel = { + id: active?.tvg?.id || '', + displayName: displayNames, + url: active?.url ? [active.url] : [], + icon: icons, + }; + return ( + active?.tvg?.rec || active?.timeshift || active?.catchup?.days + ); + }), + map((value) => moment().subtract(value, 'days').toISOString()) + ); private readonly selectedDate$ = new BehaviorSubject( moment().format(DATE_FORMAT) ); + /** Filtered EPG programs based on selected date */ + filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe( + map(([items, selectedDate]) => + items + .filter( + (item) => + moment(item.start).format('YYYY-MM-DD') === selectedDate + ) + .sort((a, b) => moment(a.start).diff(moment(b.start))) + ) + ); + /** * Helper function to get channel display name */ @@ -108,47 +137,10 @@ export class EpgListComponent implements OnInit { return channel.icon[0]?.src || ''; } - /** Filtered EPG programs based on selected date */ - filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe( - map(([items, selectedDate]) => - items - .filter( - (item) => - moment(item.start).format('YYYY-MM-DD') === selectedDate - ) - .sort((a, b) => moment(a.start).diff(moment(b.start))) - ) - ); - /** * Subscribe for values from the store on component init */ ngOnInit(): void { - this.timeshiftUntil$ = this.store.select(selectActive).pipe( - map((active) => { - // Create EpgChannel with proper structure - const displayNames = active?.name - ? [{ lang: '', value: active.name }] - : []; - const icons = active?.tvg?.logo - ? [{ src: active.tvg.logo }] - : []; - - this.channel = { - id: active?.tvg?.id || '', - displayName: displayNames, - url: active?.url ? [active.url] : [], - icon: icons, - }; - return ( - active?.tvg?.rec || - active?.timeshift || - active?.catchup?.days - ); - }), - map((value) => moment().subtract(value, 'days').toISOString()) - ); - this.items$.subscribe((programs) => this.handleEpgData(programs)); this.dateToday = moment().format(DATE_FORMAT); this.selectedDate$.next(this.dateToday); diff --git a/libs/ui/components/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/components/src/lib/html-video-player/html-video-player.component.spec.ts index 6fc84e427..e7e0fc103 100644 --- a/libs/ui/components/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/components/src/lib/html-video-player/html-video-player.component.spec.ts @@ -1,12 +1,13 @@ import { SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { DataService } from '../../../../../services/src/lib/data.service'; +import { DataService } from 'services'; import { HtmlVideoPlayerComponent } from './html-video-player.component'; describe('HtmlVideoPlayerComponent', () => { let component: HtmlVideoPlayerComponent; let fixture: ComponentFixture; + // eslint-disable-next-line @typescript-eslint/no-unused-vars let dataService: DataService; const TEST_CHANNEL = { @@ -45,7 +46,9 @@ describe('HtmlVideoPlayerComponent', () => { it('should call play channel function after input changes', () => { jest.spyOn(component, 'playChannel'); - jest.spyOn(global.console, 'error').mockImplementation(() => {}); + jest.spyOn(global.console, 'error').mockImplementation(() => { + /* empty */ + }); component.ngOnChanges({ channel: new SimpleChange(null, TEST_CHANNEL, true), }); diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.spec.ts b/libs/ui/components/src/lib/info-overlay/info-overlay.component.spec.ts deleted file mode 100644 index fb58722a7..000000000 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { MomentDatePipe } from '@iptvnator/pipes'; -import { MockPipe } from 'ng-mocks'; -/* tslint:disable:no-unused-variable */ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; - -import { InfoOverlayComponent } from './info-overlay.component'; - -describe('InfoOverlayComponent', () => { - let component: InfoOverlayComponent; - let fixture: ComponentFixture; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [InfoOverlayComponent, MockPipe(MomentDatePipe)], - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(InfoOverlayComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts index 4c6f1158a..e4808ecf2 100644 --- a/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts @@ -6,11 +6,11 @@ import { ChangeDetectorRef, Component, ElementRef, - Inject, + inject, Input, OnDestroy, OnInit, - ViewChild, + viewChild, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; @@ -56,18 +56,16 @@ interface EnrichedChannel extends EpgChannel { export class MultiEpgContainerComponent implements OnInit, AfterViewInit, OnDestroy { - @ViewChild('epgContainer') epgContainer!: ElementRef; + readonly epgContainer = viewChild.required('epgContainer'); @Input() set playlistChannels(value: Observable) { if (value) { - value.subscribe((channels) => { - this._playlistChannels = channels; + value.subscribe(() => { this.initializeVisibleChannels(); this.requestPrograms(); }); } } - private _playlistChannels: Channel[] = []; readonly timeHeader = Array.from({ length: 24 }, (_, i) => i); readonly hourWidth$ = new BehaviorSubject(150); @@ -82,16 +80,14 @@ export class MultiEpgContainerComponent originalEpgData: any[] = []; private dateCache = new Map(); - private interval: any; + private interval!: number; isLastPage = false; totalChannels = 0; - constructor( - private dialog: MatDialog, - private cdr: ChangeDetectorRef, - @Inject(COMPONENT_OVERLAY_REF) private overlayRef: OverlayRef - ) {} + private readonly dialog = inject(MatDialog); + private cdr = inject(ChangeDetectorRef); + private overlayRef = inject(COMPONENT_OVERLAY_REF); ngOnInit() { this.calculateCurrentTimeBar(); @@ -123,9 +119,9 @@ export class MultiEpgContainerComponent } private initializeVisibleChannels(): void { - if (this.epgContainer) { - const containerHeight = - this.epgContainer.nativeElement.offsetHeight; + const epgContainer = this.epgContainer(); + if (epgContainer) { + const containerHeight = epgContainer.nativeElement.offsetHeight; const calculatedVisibleChannels = Math.floor( (containerHeight - this.barHeight) / this.barHeight ); diff --git a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.html b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.html index bdf2eff94..a8d5690f4 100644 --- a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.html +++ b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.html @@ -25,7 +25,7 @@ } @else { - + star @@ -42,7 +42,7 @@ @for (item of playlists; track item._id; let last = $last) { (); + + readonly allPlaylistsLoaded = this.store.selectSignal( + selectPlaylistsLoadingFlag + ); + readonly searchQueryInput = viewChild>('searchQuery'); @@ -112,58 +114,6 @@ export class RecentPlaylistsComponent implements OnInit { }) ); - allPlaylistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag); - - @Input() sidebarMode = false; - @Output() playlistClicked = new EventEmitter(); - - /** IPC Renderer commands list with callbacks */ - commandsList = [ - { - id: PLAYLIST_UPDATE_RESPONSE, - execute: (response: { - payload: { message: string; playlist: Playlist }; - }) => { - this.snackBar.open(response.payload.message, undefined, { - duration: 2000, - }); - this.store.dispatch( - PlaylistActions.updatePlaylist({ - playlistId: response.payload.playlist._id, - playlist: response.payload.playlist, - }) - ); - }, - }, - { - id: AUTO_UPDATE_PLAYLISTS_RESPONSE, - execute: (response: { - payload: { message: string; playlists: Playlist[] }; - }) => { - this.store.dispatch( - PlaylistActions.updateManyPlaylists({ - playlists: response.payload.playlists, - }) - ); - }, - }, - ]; - - ngOnInit(): void { - this.setRendererListeners(); - } - - setRendererListeners(): void { - this.commandsList.forEach((command) => { - const cb = (response: any) => { - if (response.data.type === command.id) { - command.execute(response.data); - } - }; - this.dataService.listenOn(command.id, cb); - }); - } - /** * Opens the details dialog with the information about the provided playlist * @param data selected playlist @@ -190,7 +140,7 @@ export class RecentPlaylistsComponent implements OnInit { ); } - getGlobalFavorites() { + navigateToGlobalFavorites() { this.router.navigate(['playlists', GLOBAL_FAVORITES_PLAYLIST_ID]); this.playlistClicked.emit(GLOBAL_FAVORITES_PLAYLIST_ID); } From d3fc2a5af8256e97a7574af33ba6f4800103bb4c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 8 Nov 2025 23:02:15 +0100 Subject: [PATCH 07/16] feat(ui): enhance navigation sidebar visuals and tooltips Update navigation components to improve sidebar usability and layout. - Reformat stalker-shell navigation binding to align attributes consistently. - Add matTooltip and matTooltipPosition to portal icon and nav items to surface portal status and item labels on hover. Rework navigation markup to use mat-nav-list, add home shortcut and dividers, and hide several textual labels/actions by commenting them out to create a more compact icon-only sidebar while preserving code for future restore. - Collapse unused portal info/actions and library labels into comments to simplify DOM while keeping structure for reactivation. These changes make the left navigation more compact, provide quick hover context via tooltips, and prepare the UI for a condensed icon-driven layout. --- .../stalker-shell.component.html | 2 +- .../stalker-shell.component.scss | 2 +- .../navigation/navigation.component.html | 112 ++++++++++-------- .../navigation/navigation.component.scss | 86 ++++++-------- .../navigation/navigation.component.ts | 2 - .../portal-channels-list.component.scss | 2 +- apps/web/src/app/xtream-tauri/sidebar.scss | 8 +- .../xtream-shell/xtream-shell.component.scss | 2 +- 8 files changed, 106 insertions(+), 110 deletions(-) diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.html b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.html index da82ad4c9..4c899c6bd 100644 --- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.html +++ b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.html @@ -3,8 +3,8 @@ class="navigation-sidebar" [navigationItems]="mainNavigationItems" (categoryClick)="setContentType($any($event))" - [selectedContentType]="stalkerStore.selectedContentType()" (pageClicked)="handlePageClick()" + [selectedContentType]="stalkerStore.selectedContentType()" /> diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.scss b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.scss index 39846aa78..9b2839123 100644 --- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.scss +++ b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.scss @@ -13,6 +13,6 @@ } .navigation-sidebar { - width: 200px; + width: 60px; border-right: 1px solid #333; } diff --git a/apps/web/src/app/xtream-tauri/navigation/navigation.component.html b/apps/web/src/app/xtream-tauri/navigation/navigation.component.html index fc16f5be9..f611d27b4 100644 --- a/apps/web/src/app/xtream-tauri/navigation/navigation.component.html +++ b/apps/web/src/app/xtream-tauri/navigation/navigation.component.html @@ -1,11 +1,16 @@
-
+
{{ getStatusIcon() }}
-
+
- + + +