From e0c6eb67904303daa28ca96320e09e7c74348892 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 23 Feb 2023 13:35:45 +0100 Subject: [PATCH] refactor: auto-update mechanism --- electron/api.ts | 165 +++++++++++------- shared/ipc-commands.ts | 4 + src/app/app.component.spec.ts | 19 +- src/app/app.component.ts | 27 ++- .../recent-playlists.component.ts | 12 +- src/app/services/playlists.service.ts | 29 ++- src/app/state/actions.ts | 5 + src/app/state/effects.ts | 12 ++ src/app/state/reducers.ts | 23 ++- 9 files changed, 207 insertions(+), 89 deletions(-) diff --git a/electron/api.ts b/electron/api.ts index 7a488c2a0..554420611 100644 --- a/electron/api.ts +++ b/electron/api.ts @@ -1,7 +1,11 @@ import axios from 'axios'; import { app, BrowserWindow, ipcMain, session } from 'electron'; +import { promises as fsPromises } from 'fs'; import { parse } from 'iptv-playlist-parser'; +import { Channel } from '../shared/channel.interface'; import { + AUTO_UPDATE_PLAYLISTS, + AUTO_UPDATE_PLAYLISTS_RESPONSE, CHANNEL_SET_USER_AGENT, DELETE_ALL_PLAYLISTS, EPG_ERROR, @@ -25,6 +29,7 @@ import { PLAYLIST_UPDATE, PLAYLIST_UPDATE_RESPONSE, } from '../shared/ipc-commands'; +import { Playlist } from '../shared/playlist.interface'; import { createPlaylistObject } from '../shared/playlist.utils'; import { ParsedPlaylist } from '../src/typings.d'; @@ -162,6 +167,35 @@ export class Api { ); }); }) + .on( + AUTO_UPDATE_PLAYLISTS, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (event, playlists: Partial[]) => { + const results: any[] = []; + let playlist: any; + for (const element of playlists) { + if (element.url && element._id) { + playlist = await this.fetchPlaylistByUrl({ + id: element._id, + title: element.title || '', + url: element.url, + }); + results.push(playlist); + } else if (element.filePath && element._id) { + playlist = await this.fetchPlaylistByFilePath({ + id: element._id, + title: element.title || '', + filePath: element.filePath, + }); + results.push(playlist); + } + } + event.sender.send( + AUTO_UPDATE_PLAYLISTS_RESPONSE, + results.filter((item) => item !== undefined) + ); + } + ) .on(MIGRATE_PLAYLISTS, (event) => { this.getAllPlaylists().then((playlists) => { event.sender.send(MIGRATE_PLAYLISTS_RESPONSE, { @@ -211,33 +245,8 @@ export class Api { .on(EPG_FORCE_FETCH, (event, arg) => this.workerWindow.webContents.send(EPG_FORCE_FETCH, arg) ); - - // this.refreshPlaylists(); } - /** - * Starts the update process for all the playlists with the enabled auto-refresh flag - */ - // TODO: implement the same mechanism for self-hosted PWAs (ignore vercel instance) - /* refreshPlaylists(): void { - this.getAllPlaylistsMeta().then((playlists) => { - playlists.forEach((playlist) => { - if (playlist.autoRefresh && playlist.autoRefresh === true) { - if (playlist.url) { - this.fetchPlaylistByUrl(playlist._id, playlist.url); - } else if (playlist.filePath) { - this.fetchPlaylistByFilePath( - playlist._id, - playlist.filePath - ); - } else { - console.log('skip...'); - } - } - }); - }); - } */ - /** * Sets the user agent header for all http requests * @param userAgent user agent to use @@ -282,30 +291,39 @@ export class Api { * Converts the fetched playlist string to the playlist object, updates it in the database and sends the updated playlists array back to the renderer * @param id id of the playlist to update * @param playlistString updated playlist as string - * @param event ipc event to send the response back to the renderer */ - handlePlaylistRefresh( + getRefreshedPlaylist( args: { id: string; title: string; filePath?: string; url?: string }, - playlistString: string, - event?: Electron.IpcMainEvent + playlistString: string ) { - if (event) { - const parsedPlaylist: ParsedPlaylist = - this.parsePlaylist(playlistString); - const playlist = createPlaylistObject( - args.title, - parsedPlaylist, - args.url ? args.url : args.filePath, - args.url ? 'URL' : 'FILE' - ); - event.sender.send(PLAYLIST_UPDATE_RESPONSE, { - message: `Success! The playlist was successfully updated (${parsedPlaylist.items.length} channels)`, - playlist: { - ...playlist, - _id: args.id, - }, - }); - } + const parsedPlaylist: ParsedPlaylist = + this.parsePlaylist(playlistString); + const playlist = createPlaylistObject( + args.title, + parsedPlaylist, + args.url ? args.url : args.filePath, + args.url ? 'URL' : 'FILE' + ); + return { + ...playlist, + _id: args.id, + }; + } + + sendPlaylistRefreshResponse( + playlistId: string, + playlist: Playlist, + event: Electron.IpcMainEvent + ) { + event.sender.send(PLAYLIST_UPDATE_RESPONSE, { + message: `Success! The playlist was successfully updated (${ + (playlist.playlist.items as Channel[]).length + } channels)`, + playlist: { + ...playlist, + _id: playlistId, + }, + }); } /** @@ -317,14 +335,24 @@ export class Api { async fetchPlaylistByUrl( args: { id: string; title: string; url?: string }, event?: Electron.IpcMainEvent - ): Promise { + ) { if (!args.url) return; try { - await axios - .get(args.url, { httpsAgent: agent }) - .then((result) => - this.handlePlaylistRefresh(args, result.data, event) + const result = await axios.get(args.url, { httpsAgent: agent }); + + const refreshedPlaylist = this.getRefreshedPlaylist( + args, + result.data + ); + if (event) { + this.sendPlaylistRefreshResponse( + refreshedPlaylist._id, + refreshedPlaylist, + event ); + } else { + return refreshedPlaylist; + } } catch (err) { if (event) event.sender.send(ERROR, { @@ -340,22 +368,28 @@ export class Api { * @param playlistString updated playlist as string * @param event ipc event to send the response back to the renderer */ - fetchPlaylistByFilePath( + async fetchPlaylistByFilePath( args: { id: string; title: string; filePath?: string }, event?: Electron.IpcMainEvent - ): void { + ) { if (!args.filePath) return; - try { - fs.readFile(args.filePath, 'utf-8', (err, data) => { - if (err) { - this.handleFileNotFoundError(err, event); - return; - } + let refreshedPlaylist: Playlist; - this.handlePlaylistRefresh(args, data, event); - }); + try { + const playlist = await fsPromises.readFile(args.filePath, 'utf-8'); + refreshedPlaylist = this.getRefreshedPlaylist(args, playlist); + + if (event) { + this.sendPlaylistRefreshResponse( + refreshedPlaylist._id, + refreshedPlaylist, + event + ); + } else { + return refreshedPlaylist; + } } catch (err) { - this.handleFileNotFoundError(err, event); + return; } } @@ -394,15 +428,16 @@ export class Api { } /** @deprecated - used only for migration */ - removeAllPlaylists(event: Electron.IpcMainEvent) { + async removeAllPlaylists(event: Electron.IpcMainEvent) { + const removeCount = await db.remove({}, { multi: true }); + console.info(removeCount, ' playlists were removed'); fs.unlink(dbPath, (err) => { if (err && err.code == 'ENOENT') { - // file doesn't exist console.info("File doesn't exist, won't remove it."); } else if (err) { console.error('Error occurred while trying to remove file'); } else { - console.log(`${dbPath} was deleted`); + console.info(`${dbPath} was deleted`); event.sender.send(IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, { result: false, }); diff --git a/shared/ipc-commands.ts b/shared/ipc-commands.ts index 854d2ef2a..b227bc0f5 100644 --- a/shared/ipc-commands.ts +++ b/shared/ipc-commands.ts @@ -36,3 +36,7 @@ export const IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE = export const MIGRATE_PLAYLISTS = 'MIGRATE_PLAYLISTS'; export const MIGRATE_PLAYLISTS_RESPONSE = 'MIGRATE_PLAYLISTS_RESPONSE'; export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS'; + +// Auto-update +export const AUTO_UPDATE_PLAYLISTS = 'AUTO_UPDATE'; +export const AUTO_UPDATE_PLAYLISTS_RESPONSE = 'AUTO_UPDATE_RESPONSE'; diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 793999a55..7034746a9 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -11,11 +11,13 @@ import { RouterTestingModule } from '@angular/router/testing'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { MockModule, MockPipe, MockProviders } from 'ng-mocks'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; import { NgxWhatsNewModule } from 'ngx-whats-new'; import { of } from 'rxjs'; import { AppComponent } from './app.component'; import { DataService } from './services/data.service'; import { ElectronServiceStub } from './services/electron.service.stub'; +import { PlaylistsService } from './services/playlists.service'; import { SettingsService } from './services/settings.service'; import { WhatsNewService } from './services/whats-new.service'; import { WhatsNewServiceStub } from './services/whats-new.service.stub'; @@ -23,10 +25,6 @@ import { Language } from './settings/language.enum'; import { Theme } from './settings/theme.enum'; import { STORE_KEY } from './shared/enums/store-keys.enum'; -class MatSnackBarStub { - open(): void {} -} - jest.spyOn(global.console, 'error').mockImplementation(() => {}); describe('AppComponent', () => { @@ -43,10 +41,14 @@ describe('AppComponent', () => { TestBed.configureTestingModule({ declarations: [AppComponent, MockPipe(TranslatePipe)], providers: [ - { provide: MatSnackBar, useClass: MatSnackBarStub }, { provide: WhatsNewService, useClass: WhatsNewServiceStub }, - MockProviders(TranslateService), - SettingsService, // TODO: stub + MockProviders( + TranslateService, + PlaylistsService, + NgxIndexedDBService, + MatSnackBar + ), + SettingsService, { provide: DataService, useClass: ElectronServiceStub, @@ -70,6 +72,9 @@ describe('AppComponent', () => { translateService = TestBed.inject(TranslateService); whatsNewService = TestBed.inject(WhatsNewService); component = fixture.componentInstance; + + // TODO: investigate in detail + component.triggerAutoUpdateMechanism = jest.fn(); component.modals = []; fixture.detectChanges(); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 30f888777..22dd27077 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -4,9 +4,11 @@ import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { ModalWindow } from 'ngx-whats-new/lib/modal-window.interface'; +import { firstValueFrom } from 'rxjs'; import * as semver from 'semver'; import { IpcCommand } from '../../shared/ipc-command.class'; import { + AUTO_UPDATE_PLAYLISTS, EPG_ERROR, EPG_FETCH_DONE, ERROR, @@ -17,6 +19,7 @@ import { } from '../../shared/ipc-commands'; import { DataService } from './services/data.service'; import { EpgService } from './services/epg.service'; +import { PlaylistsService } from './services/playlists.service'; import { SettingsService } from './services/settings.service'; import { WhatsNewService } from './services/whats-new.service'; import { Language } from './settings/language.enum'; @@ -58,13 +61,11 @@ export class AppComponent { listeners = []; - /** - * Creates an instance of AppComponent - */ constructor( private electronService: DataService, private epgService: EpgService, private ngZone: NgZone, + private playlistService: PlaylistsService, private router: Router, private store: Store, private snackBar: MatSnackBar, @@ -93,16 +94,28 @@ export class AppComponent { } } - /** - * Starts all the functions to initialize the component - */ - ngOnInit(): void { + ngOnInit() { this.store.dispatch(PlaylistActions.loadPlaylists()); this.translate.setDefaultLang(this.DEFAULT_LANG); this.setRendererListeners(); this.initSettings(); this.handleWhatsNewDialog(); + + this.triggerAutoUpdateMechanism(); + } + + async triggerAutoUpdateMechanism() { + if (this.electronService.isElectron) { + const playlistForAutoUpdate = await firstValueFrom( + this.playlistService.getPlaylistsForAutoUpdate() + ); + if (playlistForAutoUpdate && playlistForAutoUpdate.length > 0) + this.electronService.sendIpcEvent( + AUTO_UPDATE_PLAYLISTS, + playlistForAutoUpdate + ); + } } /** diff --git a/src/app/home/recent-playlists/recent-playlists.component.ts b/src/app/home/recent-playlists/recent-playlists.component.ts index 7cf5ada99..02031db3b 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.ts @@ -18,6 +18,7 @@ import { DataService } from '../../services/data.service'; import * as PlaylistActions from '../../state/actions'; import { selectAllPlaylistsMeta } from '../../state/selectors'; import { + AUTO_UPDATE_PLAYLISTS_RESPONSE, DELETE_ALL_PLAYLISTS, IS_PLAYLISTS_MIGRATION_POSSIBLE, IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, @@ -49,7 +50,6 @@ export class RecentPlaylistsComponent implements OnDestroy { this.snackBar.open(response.message, null, { duration: 2000 }); this.store.dispatch( PlaylistActions.updatePlaylist({ - // TODO: check if this is correct playlistId: response.playlist._id, playlist: response.playlist, }) @@ -78,6 +78,16 @@ export class RecentPlaylistsComponent implements OnDestroy { ); } ), + new IpcCommand( + AUTO_UPDATE_PLAYLISTS_RESPONSE, + (playlists: Playlist[]) => { + this.store.dispatch( + PlaylistActions.updateManyPlaylists({ + playlists, + }) + ); + } + ), ]; isMigrationPossible = false; diff --git a/src/app/services/playlists.service.ts b/src/app/services/playlists.service.ts index 621e96367..ff14a543b 100644 --- a/src/app/services/playlists.service.ts +++ b/src/app/services/playlists.service.ts @@ -100,15 +100,15 @@ export class PlaylistsService { ); } - // TODO: for-loop - updatePlaylistPosition(id: string, position: number) { - return this.getPlaylistById(id).pipe( - switchMap((playlist) => - this.dbService.update(DbStores.Playlists, { + updateManyPlaylists(playlists: Playlist[]) { + return combineLatest( + playlists.map((playlist) => { + return this.dbService.update(DbStores.Playlists, { ...playlist, - position, - }) - ) + updateDate: Date.now(), + autoRefresh: true, + }); + }) ); } @@ -167,4 +167,17 @@ export class PlaylistsService { addManyPlaylists(playlists: Playlist[]) { return this.dbService.bulkAdd(DbStores.Playlists, playlists as any); // TODO: update ngx-indexed-db } + + getPlaylistsForAutoUpdate() { + return this.dbService.getAll(DbStores.Playlists).pipe( + map((playlists: Playlist[]) => { + return playlists + .filter((item) => item.autoRefresh) + .map( + ({ playlist, header, items, favorites, ...rest }) => + rest + ); + }) + ); + } } diff --git a/src/app/state/actions.ts b/src/app/state/actions.ts index d7bffa0a3..c6887a04e 100644 --- a/src/app/state/actions.ts +++ b/src/app/state/actions.ts @@ -41,6 +41,11 @@ export const updatePlaylist = createAction( props<{ playlist: Playlist; playlistId: string }>() ); +export const updateManyPlaylists = createAction( + `${PLAYLISTS_STORE_KEY} Update many playlists (auto-update mechanism)`, + props<{ playlists: Playlist[] }>() +); + export const parsePlaylist = createAction( `${PLAYLISTS_STORE_KEY} parse playlist`, props<{ diff --git a/src/app/state/effects.ts b/src/app/state/effects.ts index c046c65e5..6e02750af 100644 --- a/src/app/state/effects.ts +++ b/src/app/state/effects.ts @@ -173,6 +173,18 @@ export class PlaylistEffects { { dispatch: false } ); + updateManyPlaylists$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.updateManyPlaylists), + switchMap((action) => + this.playlistsService.updateManyPlaylists(action.playlists) + ) + ); + }, + { dispatch: false } + ); + constructor( private actions$: Actions, private playlistsService: PlaylistsService, diff --git a/src/app/state/reducers.ts b/src/app/state/reducers.ts index 935111fa4..2f7bd3898 100644 --- a/src/app/state/reducers.ts +++ b/src/app/state/reducers.ts @@ -97,7 +97,13 @@ export const playlistReducer = createReducer( playlists: playlistsAdapter.updateOne( { id: action.playlistId, - changes: { ...action.playlist, _id: action.playlistId }, + changes: { + ...action.playlist, + _id: action.playlistId, + updateDate: Date.now(), + favorites: [], + count: action.playlist.playlist.items.length, + }, }, state.playlists ), @@ -162,6 +168,21 @@ export const playlistReducer = createReducer( ...state, channels: action.channels, }; + }), + on(PlaylistActions.updateManyPlaylists, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.updateMany( + action.playlists.map((updatedPlaylist) => ({ + id: updatedPlaylist._id, + changes: { + ...updatedPlaylist, + updateDate: Date.now(), + }, + })), + state.playlists + ), + }; }) );