refactor: auto-update mechanism

This commit is contained in:
4gray committed 2023-02-23 13:35:45 +01:00
1 parent 02192a73be
commit e0c6eb6790
9 files changed
+207 -89

No files matched your search

+100 -65
View File
@@ -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<Playlist>[]) => {
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<void> {
) {
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,
});
+4
View File
@@ -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';
+12 -7
View File
@@ -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();
});
+20 -7
View File
@@ -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
);
}
}
/**
@@ -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;
+21 -8
View File
@@ -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
);
})
);
}
}
+5
View File
@@ -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<{
+12
View File
@@ -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,
+22 -1
View File
@@ -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
),
};
})
);