From f8c6874ad3734ef74e5de25e83626c7e8e77c55a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 9 Jul 2022 20:01:20 +0200 Subject: [PATCH] feat: support multiple epg sources --- electron/main.ts | 6 +- src/app/app.component.spec.ts | 23 -- src/app/app.component.ts | 51 +-- .../components/epg-list/epg-list.component.ts | 10 +- src/app/services/epg.service.spec.ts | 50 +++ src/app/services/epg.service.ts | 69 ++++ src/app/settings/settings.component.html | 344 ++++++++++-------- src/app/settings/settings.component.scss | 6 - src/app/settings/settings.component.spec.ts | 6 +- src/app/settings/settings.component.ts | 98 +++-- src/app/settings/settings.interface.ts | 2 +- 11 files changed, 387 insertions(+), 278 deletions(-) create mode 100644 src/app/services/epg.service.spec.ts create mode 100644 src/app/services/epg.service.ts diff --git a/electron/main.ts b/electron/main.ts index 28287cbcd..c028f3df5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -6,7 +6,7 @@ import { Api } from '../api'; import { AppMenu } from '../menu'; const contextMenu = require('electron-context-menu'); -let win: BrowserWindow = null; +let win: BrowserWindow | null = null; const args = process.argv.slice(1), serve = args.some((val) => val === '--serve'); @@ -79,11 +79,9 @@ function createEpgWorkerWindow() { }, }); + window.loadFile('./electron/epg-worker.html'); if (serve) { - window.loadFile(path.join(__dirname, '../electron/index.html')); window.webContents.openDevTools(); - } else { - window.loadFile('./epg-worker.html'); } window.once('ready-to-show', () => { diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 9034a3836..3ba345a52 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -19,7 +19,6 @@ import { WhatsNewService } from './services/whats-new.service'; import { WhatsNewServiceStub } from './services/whats-new.service.stub'; import { Theme } from './settings/theme.enum'; import { STORE_KEY } from './shared/enums/store-keys.enum'; -import { ChannelStore } from './state'; class MatSnackBarStub { open(): void {} @@ -117,28 +116,6 @@ describe('AppComponent', () => { } )); - it('should show a notification on epg error', inject( - [MatSnackBar], - (snackbar: MatSnackBar) => { - jest.spyOn(snackbar, 'open'); - component.onEpgError(); - expect(snackbar.open).toHaveBeenCalledTimes(1); - } - )); - - it('should handle epg download success', inject( - [MatSnackBar, ChannelStore], - (snackbar: MatSnackBar, channelStore: ChannelStore) => { - jest.spyOn(snackbar, 'open'); - jest.spyOn(channelStore, 'setEpgAvailableFlag'); - component.onEpgFetchDone(); - expect(snackbar.open).toHaveBeenCalledTimes(1); - expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith( - true - ); - } - )); - it('show show whats new dialog', () => { jest.spyOn(whatsNewService, 'getModalsByVersion'); jest.spyOn(component, 'setDialogVisibility'); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d20e334ec..6361eab00 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,5 +1,4 @@ import { Component, NgZone } from '@angular/core'; -import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface'; @@ -7,7 +6,6 @@ import * as semver from 'semver'; import { IpcCommand } from '../../shared/ipc-command.class'; import { EPG_ERROR, - EPG_FETCH, EPG_FETCH_DONE, OPEN_FILE, SHOW_WHATS_NEW, @@ -15,13 +13,13 @@ import { VIEW_SETTINGS, } from '../../shared/ipc-commands'; import { DataService } from './services/data.service'; +import { EpgService } from './services/epg.service'; import { SettingsService } from './services/settings.service'; import { WhatsNewService } from './services/whats-new.service'; import { Language } from './settings/language.enum'; import { Settings } from './settings/settings.interface'; import { Theme } from './settings/theme.enum'; import { STORE_KEY } from './shared/enums/store-keys.enum'; -import { ChannelStore } from './state'; /** * AppComponent @@ -40,18 +38,12 @@ export class AppComponent { /** Modals to show for the updated version of the application */ modals: ModalWindow[] = []; - /** Default options for epg snackbar notifications */ - epgSnackBarOptions: MatSnackBarConfig = { - verticalPosition: 'bottom', - horizontalPosition: 'right', - }; - /** List of ipc commands with function mapping */ commandsList = [ new IpcCommand(VIEW_ADD_PLAYLIST, () => this.navigateToRoute('/')), new IpcCommand(VIEW_SETTINGS, () => this.navigateToRoute('/settings')), - new IpcCommand(EPG_FETCH_DONE, () => this.onEpgFetchDone()), - new IpcCommand(EPG_ERROR, () => this.onEpgError()), + new IpcCommand(EPG_FETCH_DONE, () => this.epgService.onEpgFetchDone()), + new IpcCommand(EPG_ERROR, () => this.epgService.onEpgError()), new IpcCommand(SHOW_WHATS_NEW, () => this.showWhatsNewDialog()), ]; @@ -62,13 +54,12 @@ export class AppComponent { * Creates an instance of AppComponent */ constructor( - private channelStore: ChannelStore, private electronService: DataService, + private epgService: EpgService, private ngZone: NgZone, private router: Router, private translate: TranslateService, private settingsService: SettingsService, - private snackBar: MatSnackBar, private whatsNewService: WhatsNewService ) { if ( @@ -127,14 +118,7 @@ export class AppComponent { if (settings && Object.keys(settings).length > 0) { this.translate.use(settings.language ?? this.DEFAULT_LANG); if (settings.epgUrl) { - this.electronService.sendIpcEvent(EPG_FETCH, { - url: settings.epgUrl, - }); - this.snackBar.open( - this.translate.instant('EPG.FETCH_EPG'), - this.translate.instant('CLOSE'), - this.epgSnackBarOptions - ); + this.epgService.fetchEpg(settings.epgUrl); } if (settings.theme) { @@ -205,31 +189,6 @@ export class AppComponent { this.router.navigateByUrl(route); } - /** - * Handles the event when the EPG fetching is done - */ - onEpgFetchDone(): void { - this.channelStore.setEpgAvailableFlag(true); - this.snackBar.open( - this.translate.instant('EPG.DOWNLOAD_SUCCESS'), - null, - { - ...this.epgSnackBarOptions, - duration: 2000, - } - ); - } - - /** - * Handles epg error - */ - onEpgError(): void { - this.snackBar.open(this.translate.instant('EPG.ERROR'), null, { - ...this.epgSnackBarOptions, - duration: 2000, - }); - } - /** * Shows the "what is new" dialog */ diff --git a/src/app/player/components/epg-list/epg-list.component.ts b/src/app/player/components/epg-list/epg-list.component.ts index 23c9aca01..bfa5ceb6c 100644 --- a/src/app/player/components/epg-list/epg-list.component.ts +++ b/src/app/player/components/epg-list/epg-list.component.ts @@ -1,12 +1,12 @@ import { Component, NgZone } from '@angular/core'; -import { EpgChannel } from '../../models/epg-channel.model'; -import { EpgProgram } from '../../models/epg-program.model'; import * as moment from 'moment'; -import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands'; -import { ChannelQuery, ChannelStore } from '../../../state'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { EPG_GET_PROGRAM_DONE } from '../../../../../shared/ipc-commands'; import { DataService } from '../../../services/data.service'; +import { ChannelQuery, ChannelStore } from '../../../state'; +import { EpgChannel } from '../../models/epg-channel.model'; +import { EpgProgram } from '../../models/epg-program.model'; export interface EpgData { channel: EpgChannel; @@ -76,7 +76,7 @@ export class EpgListComponent { this.timeshiftUntil$ = this.channelQuery .select( (store) => - store.active.tvg.rec || + store.active.tvg?.rec || store.active.timeshift || store.active.catchup?.days ) diff --git a/src/app/services/epg.service.spec.ts b/src/app/services/epg.service.spec.ts new file mode 100644 index 000000000..00c18beb3 --- /dev/null +++ b/src/app/services/epg.service.spec.ts @@ -0,0 +1,50 @@ +import { inject, TestBed } from '@angular/core/testing'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { MockModule, MockProviders } from 'ng-mocks'; +import { ChannelStore } from '../state'; +import { DataService } from './data.service'; +import { EpgService } from './epg.service'; + +describe('EpgService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + EpgService, + MockProviders(DataService, TranslateService, MatSnackBar), + ], + imports: [MockModule(MatSnackBarModule)], + }); + }); + + it('should create a service instance', inject( + [EpgService], + (service: EpgService) => { + expect(service).toBeTruthy(); + } + )); + + it('should show a notification on epg error', inject( + [MatSnackBar, EpgService], + (snackbar: MatSnackBar, service: EpgService) => { + jest.spyOn(snackbar, 'open'); + service.onEpgError(); + expect(snackbar.open).toHaveBeenCalledTimes(1); + } + )); + + it('should handle epg download success', inject( + [MatSnackBar, ChannelStore, EpgService], + ( + snackbar: MatSnackBar, + channelStore: ChannelStore, + service: EpgService + ) => { + jest.spyOn(snackbar, 'open'); + jest.spyOn(channelStore, 'setEpgAvailableFlag'); + service.onEpgFetchDone(); + expect(snackbar.open).toHaveBeenCalledTimes(1); + expect(channelStore.setEpgAvailableFlag).toHaveBeenCalledWith(true); + } + )); +}); diff --git a/src/app/services/epg.service.ts b/src/app/services/epg.service.ts new file mode 100644 index 000000000..9c89a5996 --- /dev/null +++ b/src/app/services/epg.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; +import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { EPG_FETCH } from '../../../shared/ipc-commands'; +import { ChannelStore } from '../state'; +import { DataService } from './data.service'; + +@Injectable({ + providedIn: 'root', +}) +export class EpgService { + /** Default options for epg snackbar notifications */ + epgSnackBarOptions: MatSnackBarConfig = { + verticalPosition: 'bottom', + horizontalPosition: 'right', + }; + + constructor( + private channelStore: ChannelStore, + private electronService: DataService, + private snackBar: MatSnackBar, + private translate: TranslateService + ) {} + + /** + * Fetches and updates EPG from the given URL + * @param urls epg source urls + */ + fetchEpg(urls: string | string[]): void { + if (!Array.isArray(urls)) { + urls = [urls]; + } + urls.forEach((url) => + this.electronService.sendIpcEvent(EPG_FETCH, { + url, + }) + ); + this.snackBar.open( + this.translate.instant('EPG.FETCH_EPG'), + this.translate.instant('CLOSE'), + this.epgSnackBarOptions + ); + } + + /** + * Handles the event when the EPG fetching is done + */ + onEpgFetchDone(): void { + this.channelStore.setEpgAvailableFlag(true); + this.snackBar.open( + this.translate.instant('EPG.DOWNLOAD_SUCCESS'), + null, + { + ...this.epgSnackBarOptions, + duration: 2000, + } + ); + } + + /** + * Handles epg error + */ + onEpgError(): void { + this.snackBar.open(this.translate.instant('EPG.ERROR'), null, { + ...this.epgSnackBarOptions, + duration: 2000, + }); + } +} diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index 267541b89..da98f7eea 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -1,187 +1,217 @@ -
- + -
-
- - - - +
+ + + + + +

{{ 'SETTINGS.EPG_URL_LABEL' | translate }}

-

+

+ The value is not a valid + URL +

-

- -
-

- {{ - 'SETTINGS.VIDEO_PLAYER_LABEL' + + +

+

+

+ +

+
+ + +
+

+ {{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }} +

+

+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} -

-

- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' - | translate - }} - - {{ player.label }} - - - -

-
-
- -
-

- {{ 'SETTINGS.LANGUAGE' | translate }} -

-

- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' - | translate - }} - - {{ - 'LANGUAGES.' + language.key - | translate - }} - - -

-
-
- -
-

{{ 'SETTINGS.THEME' | translate }}

-

- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' - | translate - }} - - {{ - 'THEMES.' + theme.value - | translate - }} - - -

-
-
- -
-

- {{ 'SETTINGS.SHOW_CAPTIONS' | translate }} -

-

- -

-
-
- -
-

- {{ 'SETTINGS.VERSION' | translate }} -

-

- {{ version }}
- {{ updateMessage }} -

-
-
- EPG is available in electron-based version of - IPTVnator. Check it here - - - - -
+
+ +
+

+ {{ 'SETTINGS.LANGUAGE' | translate }} +

+

+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' + | translate + }} + + {{ + 'LANGUAGES.' + language.key + | translate + }} + + +

+
+
+ +
+

{{ 'SETTINGS.THEME' | translate }}

+

+ + {{ + 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' + | translate + }} + + {{ + 'THEMES.' + theme.value + | translate + }} + + +

+
+
+ +
+

+ {{ 'SETTINGS.SHOW_CAPTIONS' | translate }} +

+

+ +

+
+
+ +
+

+ {{ 'SETTINGS.VERSION' | translate }} +

+

+ {{ version }}
+ {{ updateMessage }} +

+
+
+ EPG is available in electron-based version of + IPTVnator. Check it here - chevron_left - {{ 'SETTINGS.BACK_TO_HOME' | translate }} - - - -
- -
+
+
+ + + + + +
+
diff --git a/src/app/settings/settings.component.scss b/src/app/settings/settings.component.scss index 2f8190d91..98d268810 100644 --- a/src/app/settings/settings.component.scss +++ b/src/app/settings/settings.component.scss @@ -16,12 +16,6 @@ button { height: 70px !important; } -.main-container { - display: grid; - grid-template-rows: 105px auto; - height: calc(100vh - 105px); -} - .settings-container { overflow: auto; height: calc(100vh - 140px); diff --git a/src/app/settings/settings.component.spec.ts b/src/app/settings/settings.component.spec.ts index 798578996..0d31c6d10 100644 --- a/src/app/settings/settings.component.spec.ts +++ b/src/app/settings/settings.component.spec.ts @@ -39,7 +39,7 @@ export class MockRouter { const DEFAULT_SETTINGS = { player: VideoPlayer.VideoJs, - epgUrl: '', + epgUrl: [], language: Language.ENGLISH, showCaptions: false, theme: Theme.LightTheme, @@ -108,7 +108,7 @@ describe('SettingsComponent', () => { }); describe('Get and set settings on component init', () => { - const settings = { player: 'test', showCaptions: true }; + const settings = { player: 'test', showCaptions: true, epgUrl: [] }; let spyOnStorageGet; beforeEach(() => { @@ -168,7 +168,7 @@ describe('SettingsComponent', () => { it('should send epg fetch command', () => { jest.spyOn(electronService, 'sendIpcEvent'); - component.fetchEpg(); + component.fetchEpg(['']); expect(electronService.sendIpcEvent).toHaveBeenCalledWith(EPG_FETCH, { url: '', }); diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 12efb8a8f..a3521a885 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -1,6 +1,11 @@ import { HttpClient } from '@angular/common/http'; import { Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { + FormArray, + FormBuilder, + FormControl, + Validators, +} from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { StorageMap } from '@ngx-pwa/local-storage'; @@ -8,8 +13,8 @@ import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { catchError } from 'rxjs/operators'; import * as semver from 'semver'; -import { EPG_FETCH } from '../../../shared/ipc-commands'; import { DataService } from '../services/data.service'; +import { EpgService } from '../services/epg.service'; import { STORE_KEY } from '../shared/enums/store-keys.enum'; import { ChannelQuery } from '../state'; import { SettingsService } from './../services/settings.service'; @@ -43,7 +48,13 @@ export class SettingsComponent implements OnInit { ]; /** Settings form object */ - settingsForm: FormGroup; + settingsForm = this.formBuilder.group({ + player: [VideoPlayer.VideoJs], + epgUrl: new FormArray([]), + language: Language.ENGLISH, + showCaptions: false, + theme: Theme.LightTheme, + }); /** Current version of the app */ version: string; @@ -62,22 +73,17 @@ export class SettingsComponent implements OnInit { /** All available visual themes */ themeEnum = Theme; + /** Form array with epg sources */ + epgUrl = this.settingsForm.get('epgUrl') as FormArray; + /** * Creates an instance of SettingsComponent and injects * required dependencies into the component - * @param channelQuery - * @param electronService - * @param formBuilder - * @param http - * @param router - * @param settingsService - * @param snackBar - * @param storage - * @param translate */ constructor( private channelQuery: ChannelQuery, private electronService: DataService, + private epgService: EpgService, private formBuilder: FormBuilder, private http: HttpClient, private router: Router, @@ -85,17 +91,7 @@ export class SettingsComponent implements OnInit { private snackBar: MatSnackBar, private storage: StorageMap, private translate: TranslateService - ) { - this.settingsForm = this.formBuilder.group({ - player: [VideoPlayer.VideoJs], - epgUrl: '', - language: Language.ENGLISH, - showCaptions: false, - theme: Theme.LightTheme, - }); - - this.checkAppVersion(); - } + ) {} /** * Reads the config object from the browsers @@ -110,7 +106,7 @@ export class SettingsComponent implements OnInit { player: settings.player ? settings.player : VideoPlayer.VideoJs, - epgUrl: settings.epgUrl ? settings.epgUrl : '', + epgUrl: [], language: settings.language ? settings.language : Language.ENGLISH, @@ -121,8 +117,33 @@ export class SettingsComponent implements OnInit { ? settings.theme : Theme.LightTheme, }); + + this.setEpgUrls(settings.epgUrl); } }); + + this.checkAppVersion(); + } + + /** + * Sets the epg urls to the form array + * @param epgUrls urls of the EPG sources + */ + setEpgUrls(epgUrls: string[] | string): void { + const URL_REGEX = /^(http|https):\/\/[^ "]+$/; + + if (!Array.isArray(epgUrls)) { + epgUrls = [epgUrls]; + } + + for (const url of epgUrls) { + this.epgUrl.push( + new FormControl(url, [ + Validators.required, + Validators.pattern(URL_REGEX), + ]) + ); + } } /** @@ -187,7 +208,7 @@ export class SettingsComponent implements OnInit { this.settingsForm.markAsPristine(); // check whether the epg url was changed or not if (this.settingsForm.value.epgUrl) { - this.fetchEpg(); + this.fetchEpg(this.settingsForm.value.epgUrl); } this.translate.use(this.settingsForm.value.language); this.settingsService.changeTheme(this.settingsForm.value.theme); @@ -210,14 +231,25 @@ export class SettingsComponent implements OnInit { /** * Fetches and updates EPG from the given URL + * @param urls epg source urls */ - fetchEpg(): void { - this.electronService.sendIpcEvent(EPG_FETCH, { - url: this.settingsForm.value.epgUrl, - }); - this.snackBar.open(this.translate.instant('EPG.FETCH_EPG'), 'Close', { - verticalPosition: 'bottom', - horizontalPosition: 'right', - }); + fetchEpg(urls: string | string[]): void { + this.epgService.fetchEpg(urls); + } + + /** + * Initializes new entry in form array for EPG URL + */ + addEpgSource(): void { + this.epgUrl.insert(this.epgUrl.length, new FormControl('')); + } + + /** + * Removes entry from form array for EPG URL + * @param index index of the item to remove + */ + removeEpgSource(index: number): void { + this.epgUrl.removeAt(index); + this.settingsForm.markAsDirty(); } } diff --git a/src/app/settings/settings.interface.ts b/src/app/settings/settings.interface.ts index c5fb94edb..c55e83e7b 100644 --- a/src/app/settings/settings.interface.ts +++ b/src/app/settings/settings.interface.ts @@ -15,7 +15,7 @@ export enum VideoPlayer { */ export interface Settings { player: VideoPlayer; - epgUrl: string; + epgUrl: string[]; language: Language; showCaptions: boolean; theme: Theme;