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/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/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", 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' -); 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(); 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 {