refactor(nx): created shared and m3u-utils library

This commit is contained in:
4gray committed 2025-10-10 11:49:35 +02:00
1 parent 99ed5df404
commit 920afa6850
59 files changed
+566 -225

No files matched your search

@@ -1,6 +1,8 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electron', {
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
platform: process.platform,
fetchPlaylistByUrl: (url: string) =>
ipcRenderer.invoke('fetch-playlist-by-url', url),
});
@@ -0,0 +1,47 @@
/**
* This module is responsible on handling all the inter process communications
* between the frontend to the electron backend.
*/
import axios from 'axios';
import { ipcMain } from 'electron';
import { parse } from 'iptv-playlist-parser';
import { createPlaylistObject, getFilenameFromUrl } from 'm3u-utils';
export default class PlaylistEvents {
static bootstrapPlaylistEvents(): Electron.IpcMain {
return ipcMain;
}
}
const https = require('https');
// Retrieve app version
ipcMain.handle('fetch-playlist-by-url', async (event, url) => {
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(
playlistName,
parsedPlaylist,
url,
'URL'
);
return playlistObject;
} catch (error) {
console.error('Error fetching playlist:', error);
throw error;
}
});
+20 -19
View File
@@ -1,29 +1,30 @@
import SquirrelEvents from './app/events/squirrel.events';
import ElectronEvents from './app/events/electron.events';
import UpdateEvents from './app/events/update.events';
import { app, BrowserWindow } from 'electron';
import App from './app/app';
import ElectronEvents from './app/events/electron.events';
import PlaylistEvents from './app/events/playlist.events';
import SquirrelEvents from './app/events/squirrel.events';
export default class Main {
static initialize() {
if (SquirrelEvents.handleEvents()) {
// squirrel event handled (except first run event) and app will exit in 1000ms, so don't do anything else
app.quit();
static initialize() {
if (SquirrelEvents.handleEvents()) {
// squirrel event handled (except first run event) and app will exit in 1000ms, so don't do anything else
app.quit();
}
}
}
static bootstrapApp() {
App.main(app, BrowserWindow);
}
static bootstrapAppEvents() {
ElectronEvents.bootstrapElectronEvents();
// initialize auto updater service
if (!App.isDevelopmentMode()) {
// UpdateEvents.initAutoUpdateService();
static bootstrapApp() {
App.main(app, BrowserWindow);
}
static bootstrapAppEvents() {
ElectronEvents.bootstrapElectronEvents();
PlaylistEvents.bootstrapPlaylistEvents();
// initialize auto updater service
if (!App.isDevelopmentMode()) {
// UpdateEvents.initAutoUpdateService();
}
}
}
}
// handle setup events as quickly as possible
+1 -1
View File
@@ -38,7 +38,7 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
* Conditionally imports the necessary service based on the current environment
*/
export function DataFactory() {
if (isTauri()) {
if (window.electron) {
return new TauriService();
}
return new PwaService();
+13 -15
View File
@@ -1,11 +1,10 @@
import { Component, NgZone } from '@angular/core';
import { Component, inject, OnDestroy } from '@angular/core';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Playlist } from 'shared-interfaces';
import { ERROR, PLAYLIST_PARSE_RESPONSE } from '../../shared/ipc-commands';
import { Playlist } from '../../shared/playlist.interface';
import { DataService } from '../services/data.service';
import { HeaderComponent } from '../shared/components/header/header.component';
import { addPlaylist } from '../state/actions';
@@ -16,13 +15,17 @@ import { RecentPlaylistsComponent } from './recent-playlists/recent-playlists.co
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
imports: [
HeaderComponent,
MatProgressBarModule,
RecentPlaylistsComponent,
TranslatePipe
],
HeaderComponent,
MatProgressBarModule,
RecentPlaylistsComponent,
TranslatePipe,
],
})
export class HomeComponent {
export class HomeComponent implements OnDestroy {
private dataService = inject(DataService);
private snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
/** Loading spinner state */
isLoading = false;
@@ -48,12 +51,7 @@ export class HomeComponent {
listeners = [];
constructor(
private dataService: DataService,
private ngZone: NgZone,
private snackBar: MatSnackBar,
private readonly store: Store
) {
constructor() {
this.setRendererListeners();
}
@@ -20,7 +20,7 @@ import { isTauri } from '@tauri-apps/api/core';
import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { firstValueFrom } from 'rxjs';
import { Playlist } from '../../../../shared/playlist.interface';
import { Playlist } from 'shared-interfaces';
import { DatabaseService } from '../../../services/database.service';
import { PlaylistsService } from '../../../services/playlists.service';
import { PlaylistMeta } from '../../../shared/playlist-meta.type';
@@ -4,7 +4,6 @@ import {
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnDestroy,
OnInit,
@@ -23,8 +22,7 @@ import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants';
import { Playlist } from '../../../shared/playlist.interface';
import { GLOBAL_FAVORITES_PLAYLIST_ID, Playlist } from 'shared-interfaces';
import { DataService } from '../../services/data.service';
import { DatabaseService } from '../../services/database.service';
import { SortService } from '../../services/sort.service';
@@ -11,8 +11,8 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
import { Playlist } from '../../../shared/playlist.interface';
import { DataService } from '../../services/data.service';
import { addPlaylist } from '../../state/actions';
@@ -7,10 +7,10 @@
formControlName="playlistUrl"
/>
</mat-form-field>
@if (!isTauri) {
<p>
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
</p>
@if (!isDesktop) {
<p>
{{ 'HOME.URL_UPLOAD.CORS_NOTE' | translate }}
</p>
}
<button
class="add-button"
@@ -8,7 +8,6 @@ import {
import { MatButton } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { isTauri } from '@tauri-apps/api/core';
@Component({
selector: 'app-url-upload',
@@ -22,7 +21,7 @@ export class UrlUploadComponent implements OnInit {
@Output() urlAdded: EventEmitter<string> = new EventEmitter();
form: FormGroup;
isTauri = isTauri();
readonly isDesktop = window.electron;
ngOnInit() {
const urlRegex = '(https?://.*?)';
@@ -12,8 +12,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Playlist } from 'shared-interfaces';
import { v4 as uuid } from 'uuid';
import { Playlist } from '../../../shared/playlist.interface';
import {
PortalStatus,
PortalStatusService,
@@ -102,7 +102,9 @@ export class XtreamCodeImportComponent {
this.isTestingConnection = true;
const serverUrlAsString = this.form.value.serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`;
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
try {
this.connectionStatus =
@@ -134,7 +136,9 @@ export class XtreamCodeImportComponent {
addPlaylist() {
const serverUrlAsString = this.form.value.serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`;
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
this.store.dispatch(
addPlaylist({
playlist: {
@@ -1,6 +1,7 @@
import {
Component,
ElementRef,
inject,
Input,
OnChanges,
OnDestroy,
@@ -9,7 +10,7 @@ import {
} from '@angular/core';
import Artplayer from 'artplayer';
import Hls from 'hls.js';
import { Channel } from '../../../../shared/channel.interface';
import { Channel } from 'shared-interfaces';
Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000;
@@ -38,7 +39,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
private player: Artplayer;
constructor(private elementRef: ElementRef) {}
private readonly elementRef = inject(ElementRef);
ngOnInit(): void {
this.initPlayer();
@@ -9,6 +9,7 @@ import {
Component,
ElementRef,
HostListener,
inject,
Input,
ViewChild,
} from '@angular/core';
@@ -25,7 +26,7 @@ import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import * as _ from 'lodash';
import { map, skipWhile } from 'rxjs';
import { Channel } from '../../../../shared/channel.interface';
import { Channel } from 'shared-interfaces';
import { EpgService } from '../../../services/epg.service';
import { FilterPipe } from '../../../shared/pipes/filter.pipe';
import * as PlaylistActions from '../../../state/actions';
@@ -58,6 +59,11 @@ import { ChannelListItemComponent } from './channel-list-item/channel-list-item.
],
})
export class ChannelListContainerComponent {
private readonly epgService = inject(EpgService);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translateService = inject(TranslateService);
/**
* Channels array
* Create local copy of the store for local manipulations without updates in the store
@@ -67,7 +73,7 @@ export class ChannelListContainerComponent {
return this._channelList;
}
@Input('channelList')
@Input()
set channelList(value: Channel[]) {
this._channelList = value;
this.groupedChannels = _.default.groupBy(value, 'group.title');
@@ -118,13 +124,6 @@ export class ChannelListContainerComponent {
)
);
constructor(
private readonly epgService: EpgService,
private readonly snackBar: MatSnackBar,
private readonly store: Store,
private readonly translateService: TranslateService
) {}
/**
* Sets clicked channel as selected and emits them to the parent component
* @param channel selected channel
@@ -1,6 +1,7 @@
import {
Component,
ElementRef,
inject,
Input,
OnChanges,
OnDestroy,
@@ -9,9 +10,9 @@ import {
ViewChild,
} from '@angular/core';
import Hls from 'hls.js';
import { Channel } from '../../../../shared/channel.interface';
import { getExtensionFromUrl } from 'm3u-utils';
import { Channel } from 'shared-interfaces';
import { CHANNEL_SET_USER_AGENT } from '../../../../shared/ipc-commands';
import { getExtensionFromUrl } from '../../../../shared/playlist.utils';
import { DataService } from '../../../services/data.service';
/**
@@ -26,12 +27,9 @@ import { DataService } from '../../../services/data.service';
export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
/** Channel to play */
@Input() channel: Channel;
dataService: DataService; // Declare the dataService property
@Input() volume = 1;
constructor(dataService: DataService) {
this.dataService = dataService; // Inject the DataService
}
private readonly dataService = inject(DataService);
/** Video player DOM element */
@ViewChild('videoPlayer', { static: true })
@@ -1,7 +1,7 @@
import { NgStyle } from '@angular/common';
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import moment from 'moment';
import { Channel } from '../../../../shared/channel.interface';
import { Channel } from 'shared-interfaces';
import { MomentDatePipe } from '../../../shared/pipes/moment-date.pipe';
import { EpgProgram } from '../../models/epg-program.model';
@@ -9,7 +9,7 @@ import { EpgProgram } from '../../models/epg-program.model';
imports: [MomentDatePipe, NgStyle],
selector: 'app-info-overlay',
templateUrl: './info-overlay.component.html',
styleUrls: ['./info-overlay.component.scss']
styleUrls: ['./info-overlay.component.scss'],
})
export class InfoOverlayComponent implements OnChanges {
/** Active channel */
@@ -20,7 +20,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { invoke } from '@tauri-apps/api/core';
import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
import { BehaviorSubject, Observable } from 'rxjs';
import { Channel } from '../../../../shared/channel.interface';
import { Channel } from 'shared-interfaces';
import { MomentDatePipe } from '../../../shared/pipes/moment-date.pipe';
import { EpgChannel } from '../../models/epg-channel.model';
import { EpgProgram } from '../../models/epg-program.model';
@@ -6,7 +6,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel } from '../../../../../shared/channel.interface';
import { Channel } from 'shared-interfaces';
import { RecentPlaylistsComponent } from '../../../../home/recent-playlists/recent-playlists.component';
import * as PlaylistActions from '../../../../state/actions';
import { selectPlaylistTitle } from '../../../../state/selectors';
@@ -1,5 +1,5 @@
import { AsyncPipe } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, EventEmitter, inject, Input, Output } from '@angular/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
@@ -7,7 +7,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel } from '../../../../../shared/channel.interface';
import { Channel } from 'shared-interfaces';
import { SettingsComponent } from '../../../../settings/settings.component';
import { updateFavorites } from '../../../../state/actions';
import {
@@ -37,15 +37,13 @@ export class ToolbarComponent {
@Output() toggleLeftDrawerClicked = new EventEmitter<void>();
@Output() toggleRightDrawerClicked = new EventEmitter<void>();
private readonly dialog = inject(MatDialog);
private readonly store = inject(Store);
readonly favorites$ = this.store.select(selectFavorites);
readonly isEpgAvailable$ = this.store.select(selectIsEpgAvailable);
readonly playlistId$ = this.store.select(selectActivePlaylistId);
constructor(
private readonly dialog: MatDialog,
private readonly store: Store
) {}
updateFavoriteStatus(channel: Channel) {
this.store.dispatch(updateFavorites({ channel }));
}
@@ -17,14 +17,13 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { Observable, combineLatestWith, filter, map, switchMap } from 'rxjs';
import { Channel } from '../../../../shared/channel.interface';
import { Channel, Playlist } from 'shared-interfaces';
import {
CHANNEL_SET_USER_AGENT,
ERROR,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_PARSE_RESPONSE,
} from '../../../../shared/ipc-commands';
import { Playlist } from '../../../../shared/playlist.interface';
import { DataService } from '../../../services/data.service';
import { PlaylistsService } from '../../../services/playlists.service';
import { SettingsStore } from '../../../services/settings-store.service';
@@ -8,7 +8,7 @@ import {
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { StorageMap } from '@ngx-pwa/local-storage';
import { getExtensionFromUrl } from '../../../shared/playlist.utils';
import { getExtensionFromUrl } from 'm3u-utils';
import { ArtPlayerComponent } from '../../player/components/art-player/art-player.component';
import { HtmlVideoPlayerComponent } from '../../player/components/html-video-player/html-video-player.component';
import { VjsPlayerComponent } from '../../player/components/vjs-player/vjs-player.component';
@@ -4,19 +4,19 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { parse } from 'iptv-playlist-parser';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { combineLatest, EMPTY, map, switchMap } from 'rxjs';
import { Channel } from '../../shared/channel.interface';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../shared/constants';
import {
Playlist,
PlaylistUpdateState,
} from '../../shared/playlist.interface';
import {
aggregateFavoriteChannels,
createFavoritesPlaylist,
createPlaylistObject,
} from '../../shared/playlist.utils';
} from 'm3u-utils';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { combineLatest, EMPTY, map, switchMap } from 'rxjs';
import {
Channel,
GLOBAL_FAVORITES_PLAYLIST_ID,
Playlist,
PlaylistUpdateState,
} from 'shared-interfaces';
import { XtreamItem } from '../../shared/xtream-item.interface';
import { XtreamSerieItem } from '../../shared/xtream-serie-item.interface';
import { DbStores } from '../indexed-db.config';
+2 -2
View File
@@ -6,6 +6,8 @@ import { SwUpdate } from '@angular/service-worker';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { catchError, firstValueFrom, throwError } from 'rxjs';
import { Playlist } from 'shared-interfaces';
import { AppConfig } from '../../environments/environment';
import {
ERROR,
PLAYLIST_PARSE_BY_URL,
@@ -15,8 +17,6 @@ import {
XTREAM_REQUEST,
XTREAM_RESPONSE,
} from '../../shared/ipc-commands';
import { Playlist } from '../../shared/playlist.interface';
import { AppConfig } from '../../environments/environment';
import * as PlaylistActions from '../state/actions';
import { DataService } from './data.service';
+21 -13
View File
@@ -6,6 +6,9 @@ import { listen } from '@tauri-apps/api/event';
import { readTextFile } from '@tauri-apps/plugin-fs';
import { fetch } from '@tauri-apps/plugin-http';
import { parse } from 'iptv-playlist-parser';
import { createPlaylistObject } from 'm3u-utils';
import { Playlist } from 'shared-interfaces';
import { AppConfig } from '../../environments/environment';
import {
AUTO_UPDATE_PLAYLISTS,
AUTO_UPDATE_PLAYLISTS_RESPONSE,
@@ -17,9 +20,6 @@ import {
PLAYLIST_UPDATE_RESPONSE,
XTREAM_RESPONSE,
} from '../../shared/ipc-commands';
import { Playlist } from '../../shared/playlist.interface';
import { createPlaylistObject, getFilenameFromUrl } from '../../shared/playlist.utils';
import { AppConfig } from '../../environments/environment';
import { DataService } from './data.service';
@Injectable({
@@ -37,7 +37,7 @@ export class TauriService extends DataService {
private async setupEventListeners() {
// Listen for player errors
this.eventListeners['player-error'] = await listen(
/* this.eventListeners['player-error'] = await listen(
'player-error',
(event) => {
console.error('Player error:', event);
@@ -47,7 +47,7 @@ export class TauriService extends DataService {
{ duration: 5000 }
);
}
);
); */
}
getAppVersion(): string {
@@ -206,18 +206,26 @@ export class TauriService extends DataService {
}
async fetchFromUrl(payload: Partial<Playlist>) {
try {
window.electron.fetchPlaylistByUrl(payload.url).then((result) => {
window.postMessage({
type: PLAYLIST_PARSE_RESPONSE,
payload: { ...result, isTemporary: payload.isTemporary },
});
});
/* try {
const response = await fetch(payload.url);
const responseBody = await response.text();
const parsedPlaylist = parse(responseBody);
// Extract playlist name from URL, use "Imported from URL" as fallback
const extractedName = payload.url && payload.url.length > 1
? getFilenameFromUrl(payload.url)
: '';
const playlistName = !extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
const extractedName =
payload.url && payload.url.length > 1
? getFilenameFromUrl(payload.url)
: '';
const playlistName =
!extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
const playlist = createPlaylistObject(
playlistName,
@@ -236,7 +244,7 @@ export class TauriService extends DataService {
message: this.getErrorMessageByStatusCode(error.status),
status: error.status,
});
}
} */
}
getErrorMessageByStatusCode(status: number) {
@@ -34,12 +34,12 @@ import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { take } from 'rxjs';
import * as semver from 'semver';
import { Playlist } from 'shared-interfaces';
import {
SET_MPV_PLAYER_PATH,
SET_VLC_PLAYER_PATH,
SETTINGS_UPDATE,
} from '../../shared/ipc-commands';
import { Playlist } from '../../shared/playlist.interface';
import { DataService } from '../services/data.service';
import { DialogService } from '../services/dialog.service';
import { EpgService } from '../services/epg.service';
+1 -1
View File
@@ -1,4 +1,4 @@
import { Channel } from '../../shared/channel.interface';
import { Channel } from 'shared-interfaces';
/**
* Creates new channel object based on the given fields
@@ -8,8 +8,8 @@ import {
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { getFilenameFromUrl } from 'm3u-utils';
import { PLAYLIST_PARSE_BY_URL } from '../../../../shared/ipc-commands';
import { getFilenameFromUrl } from '../../../../shared/playlist.utils';
import { FileUploadComponent } from '../../../home/file-upload/file-upload.component';
import { StalkerPortalImportComponent } from '../../../home/stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from '../../../home/text-import/text-import.component';
@@ -32,7 +32,7 @@ export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
UrlUploadComponent,
],
selector: 'app-add-playlist',
templateUrl: './add-playlist-dialog.component.html'
templateUrl: './add-playlist-dialog.component.html',
})
export class AddPlaylistDialogComponent {
playlistType!: PlaylistType;
@@ -1,4 +1,4 @@
import { Playlist } from '../../shared/playlist.interface';
import { Playlist } from 'shared-interfaces';
export type PlaylistMeta = Pick<
Playlist,
+1 -2
View File
@@ -1,6 +1,5 @@
import { createAction, props } from '@ngrx/store';
import { Channel } from '../../shared/channel.interface';
import { Playlist } from '../../shared/playlist.interface';
import { Channel, Playlist } from 'shared-interfaces';
import { EpgProgram } from '../player/models/epg-program.model';
import { PlaylistMeta } from '../shared/playlist-meta.type';
+1 -1
View File
@@ -15,13 +15,13 @@ import {
tap,
withLatestFrom,
} from 'rxjs';
import { Playlist } from 'shared-interfaces';
import {
CHANNEL_SET_USER_AGENT,
EPG_GET_PROGRAM,
OPEN_MPV_PLAYER,
OPEN_VLC_PLAYER,
} from '../../shared/ipc-commands';
import { Playlist } from '../../shared/playlist.interface';
import { DataService } from '../services/data.service';
import { PlaylistsService } from '../services/playlists.service';
import { Settings, VideoPlayer } from '../settings/settings.interface';
+1 -1
View File
@@ -1,5 +1,5 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../shared/constants';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces';
import * as fromPlaylistMetaState from './playlists.state';
import * as fromPlaylistState from './reducers';
import { selectRouteParam } from './router.selectors';
+1 -1
View File
@@ -1,4 +1,4 @@
import { Channel } from '../../shared/channel.interface';
import { Channel } from 'shared-interfaces';
import { initialPlaylistMetaState, PlaylistMetaState } from './playlists.state';
import { EpgProgram } from '../player/models/epg-program.model';
+2
View File
@@ -1,4 +1,5 @@
import 'jest-extended';
import { Playlist } from 'shared-interfaces';
declare module 'video.js' {
export interface VideoJsPlayer {
@@ -11,6 +12,7 @@ declare global {
electron: {
getAppVersion: () => Promise<string>;
platform: string;
fetchPlaylistByUrl: (url: string) => Promise<Playlist>;
};
}
}
-95
View File
@@ -1,95 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { ParsedPlaylist, ParsedPlaylistItem } from '../typings';
import { Channel } from './channel.interface';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from './constants';
import { Playlist } from './playlist.interface';
/**
* Aggregates favorite channels as objects from all available playlists
* @param playlists all available playlists
* @returns an array with favorite channels from all playlists
*/
export function aggregateFavoriteChannels(playlists: Playlist[]): Channel[] {
const favorites: Channel[] = [];
playlists.forEach((playlist) => {
if (playlist.favorites?.length && playlist.favorites.length > 0) {
playlist?.playlist?.items.forEach((channel: Channel) => {
if (
playlist.favorites &&
(playlist.favorites.includes(channel.id) ||
playlist.favorites.includes(channel.url))
) {
favorites.push(channel);
}
});
}
});
return favorites;
}
/**
* Creates a simplified playlist object which is used for global favorites
* @param channels channels list
* @returns simplified playlist object
*/
export function createFavoritesPlaylist(
channels: Channel[]
): Partial<Playlist> {
return {
_id: GLOBAL_FAVORITES_PLAYLIST_ID,
count: channels.length,
playlist: {
items: channels,
},
filename: 'Global favorites',
};
}
/**
* Returns last segment (part after last slash "/") of the given URL
* @param value URL as string
*/
export const getFilenameFromUrl = (value: string): string => {
if (value && value.length > 1) {
return value.substring(value.lastIndexOf('/') + 1);
}
return 'Untitled playlist';
};
/**
* Creates a playlist object
* @param name name of the playlist
* @param playlist playlist to save
* @param urlOrPath absolute fs path or url of the playlist
* @param uploadType upload type - by file or via an url
*/
export const createPlaylistObject = (
name: string,
playlist: ParsedPlaylist,
urlOrPath?: string,
uploadType?: 'URL' | 'FILE' | 'TEXT'
): Playlist => {
return {
_id: uuidv4(),
filename: name,
title: name,
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item: ParsedPlaylistItem) => ({
...item,
id: uuidv4(),
})),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
};
export const getExtensionFromUrl = (url: string) => {
return url.split(/[#?]/)[0].split('.').pop()?.trim();
};
+2 -1
View File
@@ -8,7 +8,7 @@ interface Window {
require: any;
}
export interface ParsedPlaylist {
/* export interface ParsedPlaylist {
header: {
attrs: {
'x-tvg-url': string;
@@ -37,3 +37,4 @@ export interface ParsedPlaylistItem {
url: string;
raw: string;
}
*/
+21
View File
@@ -0,0 +1,21 @@
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: [
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
],
},
],
},
languageOptions: {
parser: await import('jsonc-eslint-parser'),
},
},
];
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'shared-interfaces',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/shared/interfaces',
};
+11
View File
@@ -0,0 +1,11 @@
{
"name": "shared-interfaces",
"version": "0.0.1",
"private": true,
"type": "commonjs",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"tslib": "^2.3.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "shared-interfaces",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/interfaces/src",
"projectType": "library",
"tags": ["shared"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/interfaces",
"main": "libs/shared/interfaces/src/index.ts",
"tsConfig": "libs/shared/interfaces/tsconfig.lib.json",
"assets": []
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/interfaces/jest.config.ts"
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './lib/channel.interface';
export * from './lib/constants';
export * from './lib/parsed-playlist.interface';
export * from './lib/playlist.interface';
File renamed without changes.
@@ -0,0 +1,29 @@
export interface ParsedPlaylist {
header: {
attrs: {
'x-tvg-url': string;
};
raw: string;
};
items: ParsedPlaylistItem[];
}
export interface ParsedPlaylistItem {
name: string;
tvg: {
id: string;
name: string;
url: string;
logo: string;
rec: string;
};
group: {
title: string;
};
http: {
referrer: string;
'user-agent': string;
};
url: string;
raw: string;
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+21
View File
@@ -0,0 +1,21 @@
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: [
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
],
},
],
},
languageOptions: {
parser: await import('jsonc-eslint-parser'),
},
},
];
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'm3u-utils',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/shared/m3u-utils',
};
+11
View File
@@ -0,0 +1,11 @@
{
"name": "m3u-utils",
"version": "0.0.1",
"private": true,
"type": "commonjs",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"dependencies": {
"tslib": "^2.3.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "m3u-utils",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/m3u-utils/src",
"projectType": "library",
"tags": ["shared", "m3u"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/m3u-utils",
"main": "libs/shared/m3u-utils/src/index.ts",
"tsConfig": "libs/shared/m3u-utils/tsconfig.lib.json",
"assets": []
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/m3u-utils/jest.config.ts"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/playlist.utils';
@@ -0,0 +1,98 @@
import {
Channel,
GLOBAL_FAVORITES_PLAYLIST_ID,
ParsedPlaylist,
ParsedPlaylistItem,
Playlist,
} from 'shared-interfaces';
import { v4 as uuidv4 } from 'uuid';
/**
* Aggregates favorite channels as objects from all available playlists
* @param playlists all available playlists
* @returns an array with favorite channels from all playlists
*/
export function aggregateFavoriteChannels(playlists: Playlist[]): Channel[] {
const favorites: Channel[] = [];
playlists.forEach((playlist) => {
if (playlist.favorites?.length && playlist.favorites.length > 0) {
playlist?.playlist?.items.forEach((channel: Channel) => {
if (
playlist.favorites &&
(playlist.favorites.includes(channel.id) ||
playlist.favorites.includes(channel.url))
) {
favorites.push(channel);
}
});
}
});
return favorites;
}
/**
* Creates a simplified playlist object which is used for global favorites
* @param channels channels list
* @returns simplified playlist object
*/
export function createFavoritesPlaylist(
channels: Channel[]
): Partial<Playlist> {
return {
_id: GLOBAL_FAVORITES_PLAYLIST_ID,
count: channels.length,
playlist: {
items: channels,
},
filename: 'Global favorites',
};
}
/**
* Returns last segment (part after last slash "/") of the given URL
* @param value URL as string
*/
export const getFilenameFromUrl = (value: string): string => {
if (value && value.length > 1) {
return value.substring(value.lastIndexOf('/') + 1);
}
return 'Untitled playlist';
};
/**
* Creates a playlist object
* @param name name of the playlist
* @param playlist playlist to save
* @param urlOrPath absolute fs path or url of the playlist
* @param uploadType upload type - by file or via an url
*/
export const createPlaylistObject = (
name: string,
playlist: ParsedPlaylist,
urlOrPath?: string,
uploadType?: 'URL' | 'FILE' | 'TEXT'
): Playlist => {
return {
_id: uuidv4(),
filename: name,
title: name,
count: playlist.items.length,
playlist: {
...playlist,
items: playlist.items.map((item: ParsedPlaylistItem) => ({
...item,
id: uuidv4(),
})),
},
importDate: new Date().toISOString(),
lastUsage: new Date().toISOString(),
favorites: [],
autoRefresh: false,
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
};
};
export const getExtensionFromUrl = (url: string) => {
return url.split(/[#?]/)[0].split('.').pop()?.trim();
};
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+9 -1
View File
@@ -12,7 +12,10 @@
"!{projectRoot}/src/test-setup.[jt]s",
"!{projectRoot}/test-setup.[jt]s"
],
"sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"]
"sharedGlobals": [
"{workspaceRoot}/.github/workflows/ci.yml",
"{workspaceRoot}/.github/workflows/ci.yml"
]
},
"nxCloudId": "68be99a8c4fb535f60d0d8e8",
"targetDefaults": {
@@ -46,6 +49,11 @@
"codeCoverage": true
}
}
},
"@nx/js:tsc": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
}
},
"plugins": [
+1
View File
@@ -130,6 +130,7 @@
"jest-environment-node": "^30.0.2",
"jest-preset-angular": "~15.0.0",
"jest-util": "^30.0.2",
"jsonc-eslint-parser": "^2.1.0",
"ng-mocks": "14.13.2",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
+12 -11
View File
@@ -56,6 +56,16 @@
"@ngx-pwa/local-storage": "20.0.0",
"@rx-angular/cdk": "20.0.0",
"@rx-angular/template": "20.0.0",
"@tauri-apps/api": "2.3.0",
"@tauri-apps/plugin-dialog": "2.2.0",
"@tauri-apps/plugin-fs": "2.2.0",
"@tauri-apps/plugin-http": "2.3.0",
"@tauri-apps/plugin-os": "2.2.0",
"@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "2.2.0",
"@tauri-apps/plugin-sql": "2.2.0",
"@tauri-apps/plugin-updater": "2.5.1",
"@tauri-apps/plugin-window-state": "2.2.1",
"@videojs/http-streaming": "2.15.0",
"@yangkghjh/videojs-aspect-ratio-panel": "0.0.1",
"artplayer": "5.3.0",
@@ -76,17 +86,7 @@
"video.js": "7.20.3",
"videojs-contrib-quality-levels": "2.2.0",
"videojs-hls-quality-selector": "1.1.4",
"zone.js": "~0.15.1",
"@tauri-apps/api": "2.3.0",
"@tauri-apps/plugin-dialog": "2.2.0",
"@tauri-apps/plugin-fs": "2.2.0",
"@tauri-apps/plugin-http": "2.3.0",
"@tauri-apps/plugin-os": "2.2.0",
"@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "2.2.0",
"@tauri-apps/plugin-sql": "2.2.0",
"@tauri-apps/plugin-updater": "2.5.1",
"@tauri-apps/plugin-window-state": "2.2.1"
"zone.js": "~0.15.1"
},
"devDependencies": {
"@angular-devkit/core": "~20.1.0",
@@ -156,6 +156,7 @@
"jest-environment-node": "^30.0.2",
"jest-preset-angular": "~15.0.0",
"jest-util": "^30.0.2",
"jsonc-eslint-parser": "^2.1.0",
"ng-mocks": "14.13.2",
"node-polyfill-webpack-plugin": "1.1.4",
"npm-run-all": "4.1.5",
+3 -1
View File
@@ -15,7 +15,9 @@
"skipDefaultLibCheck": true,
"baseUrl": ".",
"paths": {
"@package": ["./package.json"]
"@package": ["./package.json"],
"m3u-utils": ["libs/shared/m3u-utils/src/index.ts"],
"shared-interfaces": ["libs/shared/interfaces/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"]