From a06c642eef6b3c9292481a8fe5785f130fd41e98 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 11 Nov 2025 08:23:27 +0100 Subject: [PATCH 1/4] feat(recent-playlists): show auto-refresh indicator on playlist items Add a compact auto-refresh icon to playlist items to indicate when a playlist has auto-refresh enabled. Move existing upload-type icons into an .icon-container so the auto-refresh indicator can be positioned absolutely beside them. - Add .auto-refresh-indicator styles to position and size the indicator, match colors, and center the icon. - Wrap mat-icon elements in .icon-container and render an additional mat-icon when item.autoRefresh is true for URL and file playlists. - Keep tooltips for the auto-refresh icon to explain its meaning. This improves UX by making auto-refresh state visible at a glance. --- .../playlist-item.component.html | 44 +++++++++++++------ .../playlist-item.component.scss | 15 +++++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html index d0ffcf34e..17a9365c5 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -1,21 +1,37 @@ @if (item.url) { - cloud +
+ cloud + @if (item.autoRefresh) { + autorenew + } +
} @if (!item.url && !item.serverUrl && !item.macAddress) { - folder +
+ folder + @if (item.autoRefresh) { + autorenew + } +
} @if (item.serverUrl) {
diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.scss b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.scss index 42a1cd43e..d3695f333 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.scss +++ b/libs/ui/components/src/lib/recent-playlists/playlist-item/playlist-item.component.scss @@ -84,6 +84,21 @@ mat-list-item { background-color: #444444; } +.auto-refresh-indicator { + position: absolute; + right: -2px; + bottom: -2px; + font-size: 14px !important; + width: 14px !important; + height: 14px !important; + color: #4caf50; + background-color: var(--background-color, #fff); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + @media only screen and (max-width: 480px) { .upload-type-icon, .icon-container { From aee5d931183aad170b0e87306825a8e5c86ff07d Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 11 Nov 2025 08:24:12 +0100 Subject: [PATCH 2/4] refactor(interfaces): remove angular dep (InjectionToken) from shared lib Remove the COMPONENT_OVERLAY_REF InjectionToken and its export from the shared interfaces library. The token was unused in the codebase, so deleting it cleans up dead code and reduces API surface area exposed by libs/shared/interfaces. This simplifies imports and avoids maintaining an unneeded token definition. --- libs/shared/interfaces/src/index.ts | 1 - libs/shared/interfaces/src/lib/tokens.ts | 5 ----- 2 files changed, 6 deletions(-) delete mode 100644 libs/shared/interfaces/src/lib/tokens.ts diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 5ac17b1fa..90f8a9c2e 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -17,7 +17,6 @@ export * from './lib/stalker-portal-actions.enum'; export * from './lib/store-keys.enum'; export * from './lib/stream-format.enum'; export * from './lib/theme.enum'; -export * from './lib/tokens'; export * from './lib/xtream-category.interface'; export * from './lib/xtream-code-actions'; export * from './lib/xtream-item.interface'; diff --git a/libs/shared/interfaces/src/lib/tokens.ts b/libs/shared/interfaces/src/lib/tokens.ts deleted file mode 100644 index 2e78f6988..000000000 --- a/libs/shared/interfaces/src/lib/tokens.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { InjectionToken } from '@angular/core'; - -export const COMPONENT_OVERLAY_REF = new InjectionToken( - 'COMPONENT_OVERLAY_REF' -); From 487b721d7c0c386464e8aeadb7cc9ddea76b77b2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 11 Nov 2025 08:24:30 +0100 Subject: [PATCH 3/4] refactor: replace shared COMPONENT_OVERLAY_REF with local tokens --- .../home/video-player/video-player.component.ts | 16 +++++++++++++--- .../multi-epg/multi-epg-container.component.ts | 16 +++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index c5f2a956b..19d2aaf7b 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -1,7 +1,14 @@ import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { AsyncPipe, CommonModule } from '@angular/common'; -import { Component, Injector, OnInit, effect, inject } from '@angular/core'; +import { + Component, + InjectionToken, + Injector, + OnInit, + effect, + inject, +} from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSidenavModule } from '@angular/material/sidenav'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; @@ -27,7 +34,6 @@ import { import { Observable, combineLatestWith, filter, map, switchMap } from 'rxjs'; import { DataService, PlaylistsService } from 'services'; import { - COMPONENT_OVERLAY_REF, Channel, PLAYLIST_PARSE_BY_URL, STORE_KEY, @@ -67,6 +73,10 @@ export class VideoPlayerComponent implements OnInit { private readonly storage = inject(StorageMap); private readonly store = inject(Store); + private readonly COMPONENT_OVERLAY_REF = new InjectionToken( + 'COMPONENT_OVERLAY_REF' + ); + /** Active selected channel */ readonly activeChannel$ = this.store .select(selectActive) @@ -204,7 +214,7 @@ export class VideoPlayerComponent implements OnInit { const injector = Injector.create({ providers: [ { - provide: COMPONENT_OVERLAY_REF, + provide: this.COMPONENT_OVERLAY_REF, useValue: this.overlayRef, }, ], 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 6ab402725..2bfe0b177 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 @@ -7,6 +7,7 @@ import { Component, ElementRef, inject, + InjectionToken, Input, OnDestroy, OnInit, @@ -20,12 +21,7 @@ import { MomentDatePipe } from '@iptvnator/pipes'; import { TranslatePipe } from '@ngx-translate/core'; import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; -import { - Channel, - COMPONENT_OVERLAY_REF, - EpgChannel, - EpgProgram, -} from 'shared-interfaces'; +import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces'; import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component'; interface EnrichedProgram extends EpgProgram { @@ -93,7 +89,13 @@ export class MultiEpgContainerComponent private readonly dialog = inject(MatDialog); private readonly cdr = inject(ChangeDetectorRef); - private readonly overlayRef = inject(COMPONENT_OVERLAY_REF); + private readonly COMPONENT_OVERLAY_REF = new InjectionToken( + 'COMPONENT_OVERLAY_REF' + ); + + private readonly overlayRef = inject( + this.COMPONENT_OVERLAY_REF + ); ngOnInit() { this.calculateCurrentTimeBar(); From d2b076f1feb25cd3150f5415b415c7b7ab203fee Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 11 Nov 2025 08:25:02 +0100 Subject: [PATCH 4/4] feat: auto-update playlists on startup Add automatic playlist refresh on application startup by listening for successful playlist load and dispatching an IPC event for any playlists with autoRefresh enabled. - Inject Actions into AppComponent and add triggerAutoUpdatePlaylists that waits for PlaylistActions.loadPlaylistsSuccess, selects selectAllPlaylistsMeta from the store, filters playlists with autoRefresh, and sends AUTO_UPDATE_PLAYLISTS via DataService. - Call triggerAutoUpdatePlaylists from ngOnInit and remove a now- unused commented commands list. - Add helper functions in backend playlist events to fetch and parse playlists from URL or file, consolidating parsing logic and creating playlist objects. This implements the previously noted TODO to trigger playlist auto- refresh and centralizes playlist fetching/parsing for reuse and clarity. --- .../src/app/api/main.preload.ts | 2 +- .../src/app/events/playlist.events.ts | 159 +++++++++++++----- apps/web/src/app/app.component.ts | 52 +++++- apps/web/src/assets/i18n/en.json | 3 +- 4 files changed, 161 insertions(+), 55 deletions(-) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 0b01876ab..d3404676e 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -47,7 +47,7 @@ contextBridge.exposeInMainWorld('electron', { origin ), autoUpdatePlaylists: (playlists) => - ipcRenderer.invoke('AUTO_UPDATE_PLAYLISTS', playlists), + ipcRenderer.invoke('AUTO_UPDATE', playlists), fetchEpg: (urls: string[]) => ipcRenderer.invoke('FETCH_EPG', { url: urls }), getChannelPrograms: (channelId: string) => diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index 7a271a714..99169e6b8 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -18,29 +18,63 @@ export default class PlaylistEvents { const https = require('https'); +/** + * Fetches and parses a playlist from a URL + * @param url - The URL to fetch the playlist from + * @param title - Optional title for the playlist + * @returns Parsed playlist object + */ +async function fetchPlaylistFromUrl( + url: string, + title?: string +): Promise { + const agent = new https.Agent({ + rejectUnauthorized: false, + }); + const result = await axios.get(url, { httpsAgent: agent }); + const parsedPlaylist = parse(result.data); + + const extractedName = + url && url.length > 1 ? getFilenameFromUrl(url) : ''; + const playlistName = + !extractedName || extractedName === 'Untitled playlist' + ? 'Imported from URL' + : extractedName; + + const playlistObject = createPlaylistObject( + title ?? playlistName, + parsedPlaylist, + url, + 'URL' + ); + + return playlistObject; +} + +/** + * Reads and parses a playlist from a file path + * @param filePath - The path to the playlist file + * @param title - Title for the playlist + * @returns Parsed playlist object + */ +async function fetchPlaylistFromFile( + filePath: string, + title: string +): Promise { + const fileContent = await readFile(filePath, 'utf-8'); + const parsedPlaylist = parse(fileContent); + const playlistObject = createPlaylistObject( + title, + parsedPlaylist, + filePath, + 'FILE' + ); + return playlistObject; +} + ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => { try { - const agent = new https.Agent({ - rejectUnauthorized: false, - }); - const result = await axios.get(url, { httpsAgent: agent }); - const parsedPlaylist = parse(result.data); - - const extractedName = - url && url.length > 1 ? getFilenameFromUrl(url) : ''; - const playlistName = - !extractedName || extractedName === 'Untitled playlist' - ? 'Imported from URL' - : extractedName; - - const playlistObject = createPlaylistObject( - title ?? playlistName, - parsedPlaylist, - url, - 'URL' - ); - - return playlistObject; + return await fetchPlaylistFromUrl(url, title); } catch (error) { console.error('Error fetching playlist:', error); throw error; @@ -50,15 +84,12 @@ ipcMain.handle('fetch-playlist-by-url', async (event, url, title?: string) => { ipcMain.handle( 'update-playlist-from-file-path', async (event, filePath, title) => { - const playlist = await readFile(filePath, 'utf-8'); - const parsedPlaylist = parse(playlist); - const playlistObject = createPlaylistObject( - title, - parsedPlaylist, - filePath, - 'FILE' - ); - return playlistObject; + try { + return await fetchPlaylistFromFile(filePath, title); + } catch (error) { + console.error('Error reading playlist from file:', error); + throw error; + } } ); @@ -79,28 +110,66 @@ ipcMain.handle('open-playlist-from-file', async () => { const filePath = filePaths[0]; try { - const fileContent = await readFile(filePath, 'utf-8'); - - const parsedPlaylist = parse(fileContent); - const playlistObject = createPlaylistObject( - 'from file', - parsedPlaylist, - filePath, - 'FILE' - ); - - return playlistObject; + return await fetchPlaylistFromFile(filePath, 'from file'); } catch (error) { console.error('Error reading or parsing the file:', error); throw new Error('Failed to process the selected file.'); } }); -ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlistUrls) => { - // TODO: Implement auto-update logic - for (const url of playlistUrls) { - console.log(`Auto-updating playlist from ${url}`); +ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { + console.log(`Auto-updating ${playlists.length} playlist(s)...`); + + const updatedPlaylists = []; + + for (const playlist of playlists) { + try { + let playlistObject; + + if (playlist.importDate && playlist.url) { + // Update from URL + console.log( + `Updating playlist "${playlist.title}" from URL: ${playlist.url}` + ); + playlistObject = await fetchPlaylistFromUrl( + playlist.url, + playlist.title + ); + } else if (playlist.filePath) { + // Update from file path + console.log( + `Updating playlist "${playlist.title}" from file: ${playlist.filePath}` + ); + playlistObject = await fetchPlaylistFromFile( + playlist.filePath, + playlist.title + ); + } else { + console.warn( + `Skipping playlist "${playlist.title}": no URL or file path found` + ); + continue; + } + + // Preserve the original _id and autoRefresh setting + updatedPlaylists.push({ + ...playlistObject, + _id: playlist._id, + autoRefresh: playlist.autoRefresh, + }); + + console.log(`Successfully updated playlist "${playlist.title}"`); + } catch (error) { + console.error( + `Failed to update playlist "${playlist.title}":`, + error + ); + // Continue with other playlists even if one fails + } } + + console.log(`Auto-update completed: ${updatedPlaylists.length} updated`); + return updatedPlaylists; }); ipcMain.handle('save-file-dialog', async (event, defaultPath, filters) => { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 07472a9c3..1efaef72b 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -1,12 +1,16 @@ import { Component, inject, OnInit } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router, RouterOutlet } from '@angular/router'; +import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { filter, take } from 'rxjs'; /* import * as semver from 'semver'; */ import * as PlaylistActions from 'm3u-state'; +import { selectAllPlaylistsMeta } from 'm3u-state'; import { DataService, EpgService } from 'services'; import { + AUTO_UPDATE_PLAYLISTS, Language, OPEN_FILE, Settings, @@ -23,6 +27,7 @@ import { SearchResultsComponent } from './xtream-tauri/search-results/search-res imports: [RouterOutlet], }) export class AppComponent implements OnInit { + private actions$ = inject(Actions); private dataService = inject(DataService); private dialog = inject(MatDialog); private epgService = inject(EpgService); @@ -31,12 +36,6 @@ export class AppComponent implements OnInit { private translate = inject(TranslateService); private settingsService = inject(SettingsService); - /** List of ipc commands with function mapping */ - /* private readonly commandsList = [ - new IpcCommand(VIEW_ADD_PLAYLIST, () => this.navigateToRoute('/')), - new IpcCommand(VIEW_SETTINGS, () => this.navigateToRoute('/settings')), - ]; */ - /** Default language as fallback */ private readonly DEFAULT_LANG = Language.ENGLISH; @@ -80,6 +79,7 @@ export class AppComponent implements OnInit { this.translate.setDefaultLang(this.DEFAULT_LANG); this.initSettings(); + this.triggerAutoUpdatePlaylists(); } /** @@ -106,8 +106,6 @@ export class AppComponent implements OnInit { this.epgService.fetchEpg(settings.epgUrl); } - // TODO: trigger auto-refresh mechanism for playlists - if (settings.theme) { this.settingsService.changeTheme(settings.theme); } else { @@ -163,4 +161,42 @@ export class AppComponent implements OnInit { disableClose: false, }); } + + /** + * Triggers auto-update for playlists that have autoRefresh enabled + */ + private triggerAutoUpdatePlaylists(): void { + // Wait for playlists to be loaded successfully + this.actions$ + .pipe( + ofType(PlaylistActions.loadPlaylistsSuccess), + take(1) // Only trigger once on app startup + ) + .subscribe(() => { + // Get all playlists from store + this.store + .select(selectAllPlaylistsMeta) + .pipe( + take(1), + filter((playlists) => playlists.length > 0) + ) + .subscribe((playlists) => { + // Filter playlists with autoRefresh enabled + const playlistsToUpdate = playlists.filter( + (playlist) => playlist.autoRefresh === true + ); + + // Trigger auto-update if there are playlists to update + if (playlistsToUpdate.length > 0) { + console.log( + `Auto-updating ${playlistsToUpdate.length} playlist(s) on startup` + ); + this.dataService.sendIpcEvent( + AUTO_UPDATE_PLAYLISTS, + playlistsToUpdate + ); + } + }); + }); + } } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a362eef84..2451434ef 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -67,7 +67,8 @@ "MY_PLAYLISTS_SUBTITLE": "all available playlists", "PLAYLIST_UPDATE_SUCCESS": "Success! The playlist was successfully updated.", "PLAYLIST_UPDATE_ERROR": "Error updating playlist details.", - "AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated" + "AUTO_REFRESH_UPDATE_SUCCESS": "Success! The playlists were successfully updated", + "AUTO_REFRESH_ENABLED": "Auto-refresh enabled" }, "FILE_UPLOAD": { "DRAG_DROP": "Drag & drop files here",