Merge pull request #648 from 4gray/fix/m3u-auto-refresh-mechanism

fix/m3u-auto-refresh-mechanism
This commit is contained in:
4gray authored and GitHub committed 2025-11-11 20:57:21 +01:00
commit b81798e87a
10 files changed
+228 -85

No files matched your search

@@ -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) =>
@@ -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<any> {
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<any> {
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) => {
+44 -8
View File
@@ -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
);
}
});
});
}
}
@@ -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<OverlayRef>(
'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,
},
],
+2 -1
View File
@@ -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",
-1
View File
@@ -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';
-5
View File
@@ -1,5 +0,0 @@
import { InjectionToken } from '@angular/core';
export const COMPONENT_OVERLAY_REF = new InjectionToken(
'COMPONENT_OVERLAY_REF'
);
@@ -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<OverlayRef>(COMPONENT_OVERLAY_REF);
private readonly COMPONENT_OVERLAY_REF = new InjectionToken<OverlayRef>(
'COMPONENT_OVERLAY_REF'
);
private readonly overlayRef = inject<OverlayRef>(
this.COMPONENT_OVERLAY_REF
);
ngOnInit() {
this.calculateCurrentTimeBar();
@@ -1,21 +1,37 @@
<mat-list-item (click)="playlistClicked.emit(item._id)">
@if (item.url) {
<mat-icon
matListItemIcon
class="upload-type-icon"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) + ' ' + item.url
"
>cloud</mat-icon
>
<div class="icon-container" matListItemIcon>
<mat-icon
class="upload-type-icon"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) + ' ' + item.url
"
>cloud</mat-icon
>
@if (item.autoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate"
>autorenew</mat-icon
>
}
</div>
}
@if (!item.url && !item.serverUrl && !item.macAddress) {
<mat-icon
matListItemIcon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
>folder</mat-icon
>
<div class="icon-container" matListItemIcon>
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
>folder</mat-icon
>
@if (item.autoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate"
>autorenew</mat-icon
>
}
</div>
}
@if (item.serverUrl) {
<div class="icon-container" matListItemIcon>
@@ -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 {