diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5a55f74b8..875431a0f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ jobs: strategy: matrix: - os: [macos-latest, windows-latest] + os: [macos-latest] steps: - uses: actions/checkout@v2 diff --git a/angular.json b/angular.json index 582efe231..4ad1b8370 100644 --- a/angular.json +++ b/angular.json @@ -14,7 +14,8 @@ "projectType": "application", "schematics": { "@schematics/angular:application": { - "strict": true + "strict": true, + "style": "scss" } }, "architect": { diff --git a/electron/api.ts b/electron/api.ts index 098f2d04e..502d0511f 100644 --- a/electron/api.ts +++ b/electron/api.ts @@ -1,9 +1,13 @@ import axios from 'axios'; import { app, BrowserWindow, ipcMain, session } from 'electron'; +import { promises as fsPromises } from 'fs'; import { parse } from 'iptv-playlist-parser'; -import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../shared/constants'; +import { Channel } from '../shared/channel.interface'; import { + AUTO_UPDATE_PLAYLISTS, + AUTO_UPDATE_PLAYLISTS_RESPONSE, CHANNEL_SET_USER_AGENT, + DELETE_ALL_PLAYLISTS, EPG_ERROR, EPG_FETCH, EPG_FETCH_DONE, @@ -15,39 +19,36 @@ import { EPG_GET_PROGRAM, EPG_GET_PROGRAM_DONE, ERROR, + IS_PLAYLISTS_MIGRATION_POSSIBLE, + IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, + MIGRATE_PLAYLISTS, + MIGRATE_PLAYLISTS_RESPONSE, OPEN_FILE, - PLAYLIST_GET_ALL, - PLAYLIST_GET_ALL_RESPONSE, - PLAYLIST_GET_BY_ID, - PLAYLIST_PARSE, PLAYLIST_PARSE_BY_URL, PLAYLIST_PARSE_RESPONSE, - PLAYLIST_PARSE_TEXT, - PLAYLIST_REMOVE_BY_ID, - PLAYLIST_REMOVE_BY_ID_RESPONSE, - PLAYLIST_SAVE_DETAILS, PLAYLIST_UPDATE, - PLAYLIST_UPDATE_FAVORITES, - PLAYLIST_UPDATE_POSITIONS, PLAYLIST_UPDATE_RESPONSE, } from '../shared/ipc-commands'; -import { Playlist, PlaylistUpdateState } from '../shared/playlist.interface'; -import { - aggregateFavoriteChannels, - createFavoritesPlaylist, -} from '../shared/playlist.utils'; +import { Playlist } from '../shared/playlist.interface'; +import { createPlaylistObject } from '../shared/playlist.utils'; import { ParsedPlaylist } from '../src/typings.d'; -const Nedb = require('nedb-promises'); - const fs = require('fs'); const https = require('https'); + +/** @deprecated - used only for migration */ +const Nedb = require('nedb-promises'); + +/** @deprecated - used only for migration */ const userData = process.env['e2e'] ? process.cwd() + '/e2e' : app.getPath('userData'); +/** @deprecated - used only for migration */ +const dbPath = `${userData}/db/data.db`; +/** @deprecated - used only for migration */ const db = new Nedb({ - filename: `${userData}/db/data.db`, + filename: dbPath, autoload: true, }); @@ -55,10 +56,6 @@ const agent = new https.Agent({ rejectUnauthorized: false, }); -export function guid() { - return Math.random().toString(36).slice(2); -} - export class Api { /** Instance of the main application window */ mainWindow: BrowserWindow; @@ -73,130 +70,142 @@ export class Api { workerWindow: BrowserWindow; constructor() { - ipcMain.on(PLAYLIST_PARSE_BY_URL, (event, args) => { - try { - axios.get(args.url, { httpsAgent: agent }).then((result) => { - const parsedPlaylist = this.convertFileStringToPlaylist( - result.data - ); - const playlistObject = this.createPlaylistObject( - args.title, - parsedPlaylist, - args.url, - 'URL' - ); - this.insertToDb(playlistObject); - event.sender.send(PLAYLIST_PARSE_RESPONSE, { - payload: playlistObject, - }); - }); - } catch (err) { - event.sender.send(ERROR, { - message: err.response.statusText, - status: err.response.status, - }); - } - }); - - ipcMain.on(PLAYLIST_PARSE_TEXT, (event, args) => { - try { - const parsedPlaylist = this.parsePlaylist( - args.text.split('\n') - ); - const playlistObject = this.createPlaylistObject( - 'Imported as text', - parsedPlaylist - ); - this.insertToDb(playlistObject); - event.sender.send(PLAYLIST_PARSE_RESPONSE, { - payload: playlistObject, - }); - } catch (err) { - event.sender.send(ERROR, { - message: err.response.statusText, - status: err.response.status, - }); - } - }); - - ipcMain.on(PLAYLIST_PARSE, (event, args) => { - const parsedPlaylist = this.parsePlaylist(args.playlist); - const playlistObject = this.createPlaylistObject( - args.title, - parsedPlaylist, - args.path, - 'FILE' - ); - this.insertToDb(playlistObject); - event.sender.send(PLAYLIST_PARSE_RESPONSE, { - payload: playlistObject, - }); - }); - - ipcMain.on(PLAYLIST_GET_ALL, (event) => this.sendAllPlaylists(event)); - - ipcMain.on(PLAYLIST_GET_BY_ID, (event, args) => { - if (args.id === GLOBAL_FAVORITES_PLAYLIST_ID) { - this.sendPlaylistWithGlobalFavorites(event); - } else { - db.findOne({ _id: args.id }).then((playlist) => { - this.setUserAgent(playlist?.userAgent); - event.sender.send(PLAYLIST_PARSE_RESPONSE, { - payload: playlist, - }); - }); - } - }); - - ipcMain.on(PLAYLIST_REMOVE_BY_ID, (event, args) => { - db.remove({ _id: args.id }).then((removed) => { - if (removed) { - event.sender.send(PLAYLIST_REMOVE_BY_ID_RESPONSE, { - message: 'playlist was removed', + ipcMain + .on(PLAYLIST_PARSE_BY_URL, (event, args) => { + try { + axios + .get(args.url, { httpsAgent: agent }) + .then((result) => { + const parsedPlaylist = this.parsePlaylist( + result.data + ); + const playlistObject = createPlaylistObject( + args.title, + parsedPlaylist, + args.url, + 'URL' + ); + event.sender.send(PLAYLIST_PARSE_RESPONSE, { + payload: playlistObject, + }); + }); + } catch (err) { + event.sender.send(ERROR, { + message: err.response.statusText, + status: err.response.status, }); } - }); - }); + }) + .on(OPEN_FILE, (event, args) => { + fs.readFile( + args.filePath, + 'utf-8', + (err: NodeJS.ErrnoException, data: string) => { + if (err) { + console.log( + 'An error ocurred reading the file :' + + err.message + ); + return; + } - // open playlist from file system - ipcMain.on(OPEN_FILE, (event, args) => { - fs.readFile( - args.filePath, - 'utf-8', - (err: NodeJS.ErrnoException, data: string) => { - if (err) { - console.log( - 'An error ocurred reading the file :' + err.message + const parsedPlaylist = this.parsePlaylist(data); + const playlistObject = createPlaylistObject( + args.fileName, + parsedPlaylist, + args.filePath, + 'FILE' ); - return; + + event.sender.send(PLAYLIST_PARSE_RESPONSE, { + payload: playlistObject, + }); } - - const parsedPlaylist = - this.convertFileStringToPlaylist(data); - const playlistObject = this.createPlaylistObject( - args.fileName, - parsedPlaylist, - args.filePath, - 'FILE' + ); + }) + .on( + PLAYLIST_UPDATE, + ( + event, + args: { + id: string; + title: string; + filePath?: string; + url?: string; + } + ) => { + if (args.filePath && args.id) { + this.fetchPlaylistByFilePath(args, event); + } else if (args.url && args.id) { + this.fetchPlaylistByUrl(args, event); + } + } + ) + .on( + CHANNEL_SET_USER_AGENT, + (event, args: { userAgent: string; referer?: string }) => { + if (args.userAgent && args.referer) { + this.setUserAgent(args.userAgent, args.referer); + } else { + this.setUserAgent(this.defaultUserAgent, 'localhost'); + } + } + ) + .on(IS_PLAYLISTS_MIGRATION_POSSIBLE, (event) => { + db.count({ + type: { $exists: false }, + }).then((count: number) => { + event.sender.send( + IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, + { + result: count > 0, + message: + count > 0 + ? `${count} playlists were found, which can be migrated from the database used in the last version of the application.` + : 'No playlists for migration', + } ); - this.insertToDb(playlistObject); - event.sender.send(PLAYLIST_PARSE_RESPONSE, { - payload: playlistObject, + }); + }) + .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, { + payload: playlists, }); - } - ); - }); - - ipcMain.on(PLAYLIST_UPDATE_FAVORITES, (event, args) => { - db.update( - { _id: args.id }, - { $set: { favorites: args.favorites } } - ).then((updated) => { - if (!updated.numAffected || updated.numAffected === 0) { - console.error('Error: Favorites were not updated'); - } + }); + }) + .on(DELETE_ALL_PLAYLISTS, (event) => { + this.removeAllPlaylists(event); }); - }); // listeners for EPG events ipcMain @@ -237,80 +246,85 @@ export class Api { this.workerWindow.webContents.send(EPG_FORCE_FETCH, arg) ); - ipcMain.on( - PLAYLIST_SAVE_DETAILS, - ( - event, - args: Pick< - Playlist, - '_id' | 'title' | 'userAgent' | 'autoRefresh' - > - ) => { - this.updatePlaylistById(args._id, { - title: args.title, - userAgent: args.userAgent, - autoRefresh: args.autoRefresh, - }).then((updated) => { - if (!updated.numAffected || updated.numAffected === 0) { - console.error( - 'Error: Playlist details were not updated' - ); - } - this.sendAllPlaylists(event); - }); - } - ); - - ipcMain.on( - PLAYLIST_UPDATE, - (event, args: { id: string; filePath?: string; url?: string }) => { - if (args.filePath && args.id) { - this.fetchPlaylistByFilePath(args.id, args.filePath, event); - } else if (args.url && args.id) { - this.fetchPlaylistByUrl(args.id, args.url, event); - } - } - ); - - ipcMain.on( - CHANNEL_SET_USER_AGENT, - (event, args: { userAgent: string; referer?: string }) => { - if (args.userAgent && args.referer) { - this.setUserAgent(args.userAgent, args.referer); - } else { - this.setUserAgent(this.defaultUserAgent, 'localhost'); - } - } - ); - - ipcMain.on( - PLAYLIST_UPDATE_POSITIONS, - (event, playlists: Partial) => - playlists.forEach((list, index) => { - this.updatePlaylistById((list as Playlist)._id, { - ...list, - position: index, - }); - }) - ); - this.setTitleBarListeners(); - this.refreshPlaylists(); } /** - * Sends a message with playlist that contains favorite channels from all available playlists - * @param event ipc main event + * Sets the user agent header for all http requests + * @param userAgent user agent to use + * @param referer referer to use */ - sendPlaylistWithGlobalFavorites(event: Electron.IpcMainEvent) { - db.find({ type: { $exists: false } }).then((playlists: Playlist[]) => { - const favoriteChannels = aggregateFavoriteChannels(playlists); - const favPlaylist = createFavoritesPlaylist(favoriteChannels); + setUserAgent(userAgent: string, referer?: string): void { + if (userAgent === undefined || userAgent === null || userAgent === '') { + userAgent = this.defaultUserAgent; + } - event.sender.send(PLAYLIST_PARSE_RESPONSE, { - type: PLAYLIST_PARSE_RESPONSE, - payload: favPlaylist, - }); + session.defaultSession.webRequest.onBeforeSendHeaders( + (details, callback) => { + details.requestHeaders['User-Agent'] = userAgent; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + details.requestHeaders['Referer'] = referer as string; + callback({ requestHeaders: details.requestHeaders }); + } + ); + console.log(`Success: Set "${userAgent}" as user agent header`); + } + + /** + * Sets epg browser window + * @param workerWindow + */ + setEpgWorkerWindow(workerWindow: BrowserWindow): void { + this.workerWindow = workerWindow; + + // store default user agent as fallback + this.defaultUserAgent = this.workerWindow.webContents.getUserAgent(); + } + + /** + * Sets browser window of the main app window + * @param mainWindow + */ + setMainWindow(mainWindow: BrowserWindow): void { + this.mainWindow = mainWindow; + } + + /** + * 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 + */ + getRefreshedPlaylist( + args: { id: string; title: string; filePath?: string; url?: string }, + playlistString: string + ) { + 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, + }, }); } @@ -338,195 +352,6 @@ export class Api { }); } - /** - * Starts the update process for all the playlists with the enabled auto-refresh flag - */ - 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...'); - } - } - }); - }); - } - - /** - * Sends list with all playlists which are stored in the database - * @param event main event - */ - sendAllPlaylists(event: Electron.IpcMainEvent): void { - this.getAllPlaylistsMeta().then((playlists) => { - event.sender.send(PLAYLIST_GET_ALL_RESPONSE, { - payload: playlists, - }); - }); - } - - /** - * Returns all existing playlists with meta information from the database - * @returns - */ - getAllPlaylistsMeta() { - return db - .find( - { type: { $exists: false } }, - { - count: 1, - title: 1, - _id: 1, - id: 1, - url: 1, - importDate: 1, - userAgent: 1, - filename: 1, - filePath: 1, - autoRefresh: 1, - updateDate: 1, - updateState: 1, - position: 1, - } - ) - .sort({ position: 1, importDate: -1 }); - } - - /** - * Sets the user agent header for all http requests - * @param userAgent user agent to use - * @param referer referer to use - */ - setUserAgent(userAgent: string, referer?: string): void { - if (userAgent === undefined || userAgent === null || userAgent === '') { - userAgent = this.defaultUserAgent; - } - - session.defaultSession.webRequest.onBeforeSendHeaders( - (details, callback) => { - details.requestHeaders['User-Agent'] = userAgent; - details.requestHeaders['Referer'] = referer as string; - callback({ requestHeaders: details.requestHeaders }); - } - ); - console.log(`Success: Set "${userAgent}" as user agent header`); - } - - /** - * Sets epg browser window - * @param workerWindow - */ - setEpgWorkerWindow(workerWindow: BrowserWindow): void { - this.workerWindow = workerWindow; - - // store default user agent as fallback - this.defaultUserAgent = this.workerWindow.webContents.getUserAgent(); - } - - /** - * Sets browser window of the main app window - * @param mainWindow - */ - setMainWindow(mainWindow: BrowserWindow): void { - this.mainWindow = mainWindow; - } - - /** - * 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 - */ - createPlaylistObject( - name: string, - playlist: ParsedPlaylist, - urlOrPath?: string, - uploadType?: 'URL' | 'FILE' - ): Playlist { - return { - id: guid(), - _id: guid(), - filename: name, - title: name, - count: playlist.items.length, - playlist: { - ...playlist, - items: playlist.items.map((item) => ({ - ...item, - id: guid(), - })), - }, - importDate: new Date().toISOString(), - lastUsage: new Date().toISOString(), - favorites: [], - autoRefresh: false, - ...(uploadType === 'URL' ? { url: urlOrPath } : {}), - ...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}), - }; - } - - /** - * Updates the provided playlist in the database - * @param id id of the playlist - * @param data playlist data to update - * @returns - */ - updatePlaylistById( - id: string, - data: Partial - ): Promise<{ - numAffected: number; - upsert: boolean; - }> { - return db.update( - { _id: id }, - { - $set: data, - } - ); - } - - /** - * 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 - */ - async handlePlaylistRefresh( - id: string, - playlistString: string, - event?: Electron.IpcMainEvent - ): Promise { - const playlist: ParsedPlaylist = - this.convertFileStringToPlaylist(playlistString); - const updated = await this.updatePlaylistById(id, { - playlist, - count: playlist.items.length, - updateDate: Date.now(), - updateState: PlaylistUpdateState.UPDATED, - }); - if (!updated.numAffected || updated.numAffected === 0) { - console.error('Error: Playlist details were not updated'); - } - - if (event) { - event.sender.send(PLAYLIST_UPDATE_RESPONSE, { - message: `Success! The playlist was successfully updated (${playlist.items.length} channels)`, - }); - - // send all playlists back to the renderer process - this.sendAllPlaylists(event); - } - } - /** * Fetches the playlist from the given url and triggers the update operation * @param id id of the playlist to update @@ -534,24 +359,31 @@ export class Api { * @param event ipc event to send the response back to the renderer */ async fetchPlaylistByUrl( - id: string, - url: string, + args: { id: string; title: string; url?: string }, event?: Electron.IpcMainEvent - ): Promise { + ) { + if (!args.url) return; try { - await axios - .get(url, { httpsAgent: agent }) - .then((result) => - this.handlePlaylistRefresh(id, 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) { - this.updatePlaylistById(id, { - updateState: PlaylistUpdateState.NOT_UPDATED, - }); if (event) event.sender.send(ERROR, { message: `File not found. Please check the entered playlist URL again.`, - status: err.response.status, + status: err.response?.status, }); } } @@ -562,31 +394,32 @@ export class Api { * @param playlistString updated playlist as string * @param event ipc event to send the response back to the renderer */ - fetchPlaylistByFilePath( - id: string, - path: string, + async fetchPlaylistByFilePath( + args: { id: string; title: string; filePath?: string }, event?: Electron.IpcMainEvent - ): void { - try { - fs.readFile(path, 'utf-8', (err, data) => { - if (err) { - this.handleFileNotFoundError(err, id, event); - return; - } + ) { + if (!args.filePath) return; + let refreshedPlaylist: Playlist; - this.handlePlaylistRefresh(id, 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, id, event); + return; } } - /** - * Sends an error message to the renderer process - * @param error - * @param id - * @param event - */ + /** Sends an error message to the renderer process */ handleFileNotFoundError( error: { errno: string; @@ -594,16 +427,10 @@ export class Api { syscall: string; path: string; }, - id: string, event?: Electron.IpcMainEvent ): void { console.error(error); - this.updatePlaylistById(id, { - updateState: PlaylistUpdateState.NOT_UPDATED, - }); if (event) { - // send all playlists back to the renderer process - this.sendAllPlaylists(event); event.sender.send(ERROR, { message: `Sorry, playlist was not found (${error.path})`, status: 'ENOENT', @@ -611,26 +438,36 @@ export class Api { } } - convertFileStringToPlaylist(m3uString: string): ParsedPlaylist { - return this.parsePlaylist(m3uString.split('\n')); - } - /** * Parses string based array to playlist object - * @param m3uArray m3u playlist as array with strings + * @param m3uString m3u playlist as string */ - parsePlaylist(m3uArray: string[]): ParsedPlaylist { - const playlistAsString = m3uArray.join('\n'); - return parse(playlistAsString); + parsePlaylist(m3uString: string): ParsedPlaylist { + return parse(m3uString); } - /** - * Inserts new playlist to the database - * @param playlist playlist to add - */ - insertToDb(playlist: Playlist): void { - db.insert(playlist).then((response) => { - console.log('playlist was saved...', response._id); + /** @deprecated - used only for migration */ + getAllPlaylists() { + return db + .find({ type: { $exists: false } }) + .sort({ position: 1, importDate: -1 }); + } + + /** @deprecated - used only for migration */ + 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') { + console.info("File doesn't exist, won't remove it."); + } else if (err) { + console.error('Error occurred while trying to remove file'); + } else { + console.info(`${dbPath} was deleted`); + event.sender.send(IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, { + result: false, + }); + } }); } } diff --git a/electron/epg-worker.ts b/electron/epg-worker.ts index 83922f7e3..da6b17545 100644 --- a/electron/epg-worker.ts +++ b/electron/epg-worker.ts @@ -112,7 +112,10 @@ const convertEpgData = () => { // fetches epg data from the provided URL ipcRenderer.on(EPG_FETCH, (event, epgUrl: string) => { console.log(loggerLabel, 'epg fetch command was triggered'); - if (fetchedUrls.indexOf(epgUrl) > -1) return; + if (fetchedUrls.indexOf(epgUrl) > -1) { + ipcRenderer.send(EPG_FETCH_DONE); + return; + } fetchedUrls.push(epgUrl); fetchEpgDataFromUrl(epgUrl); }); diff --git a/package-lock.json b/package-lock.json index df0bfde97..16045dc81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "@ngrx/eslint-plugin": "^14.3.2", "@ngx-translate/core": "14.0.0", "@ngx-translate/http-loader": "7.0.0", - "@playwright/test": "1.29.1", + "@playwright/test": "1.31.1", "@semantic-release/changelog": "6.0.1", "@semantic-release/git": "10.0.1", "@semantic-release/npm": "9.0.0", @@ -91,8 +91,8 @@ "ng-mocks": "14.6.0", "node-polyfill-webpack-plugin": "1.1.4", "npm-run-all": "4.1.5", - "playwright": "1.29.1", - "playwright-core": "1.29.1", + "playwright": "1.31.1", + "playwright-core": "1.31.1", "prettier": "2.5.1", "semantic-release": "19.0.3", "stylelint": "14.2.0", @@ -5314,19 +5314,22 @@ } }, "node_modules/@playwright/test": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.29.1.tgz", - "integrity": "sha512-iQxk2DX5U9wOGV3+/Jh9OHPsw5H3mleUL2S4BgQuwtlAfK3PnKvn38m4Rg9zIViGHVW24opSm99HQm/UFLEy6w==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.31.1.tgz", + "integrity": "sha512-IsytVZ+0QLDh1Hj83XatGp/GsI1CDJWbyDaBGbainsh0p2zC7F4toUocqowmjS6sQff2NGT3D9WbDj/3K2CJiA==", "dev": true, "dependencies": { "@types/node": "*", - "playwright-core": "1.29.1" + "playwright-core": "1.31.1" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=14" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, "node_modules/@schematics/angular": { @@ -22634,13 +22637,13 @@ } }, "node_modules/playwright": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.29.1.tgz", - "integrity": "sha512-lasC+pMqsQ2uWhNurt3YK3xo0gWlMjslYUylKbHcqF/NTjwp9KStRGO7S6wwz2f52GcSnop8XUK/GymJjdzrxw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.31.1.tgz", + "integrity": "sha512-zKJabsIA2rvOwJ12lGTqWv4HVJzlfw2JtUvO4hAr7J8UXQZ1qEPpX20E1vcz/9fotnTkwgqp3CVdIBwptBN3Fg==", "dev": true, "hasInstallScript": true, "dependencies": { - "playwright-core": "1.29.1" + "playwright-core": "1.31.1" }, "bin": { "playwright": "cli.js" @@ -22650,9 +22653,9 @@ } }, "node_modules/playwright-core": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.29.1.tgz", - "integrity": "sha512-20Ai3d+lMkWpI9YZYlxk8gxatfgax5STW8GaMozAHwigLiyiKQrdkt7gaoT9UQR8FIVDg6qVXs9IoZUQrDjIIg==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.31.1.tgz", + "integrity": "sha512-JTyX4kV3/LXsvpHkLzL2I36aCdml4zeE35x+G5aPc4bkLsiRiQshU5lWeVpHFAuC8xAcbI6FDcw/8z3q2xtJSQ==", "dev": true, "bin": { "playwright": "cli.js" @@ -31347,13 +31350,14 @@ } }, "@playwright/test": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.29.1.tgz", - "integrity": "sha512-iQxk2DX5U9wOGV3+/Jh9OHPsw5H3mleUL2S4BgQuwtlAfK3PnKvn38m4Rg9zIViGHVW24opSm99HQm/UFLEy6w==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.31.1.tgz", + "integrity": "sha512-IsytVZ+0QLDh1Hj83XatGp/GsI1CDJWbyDaBGbainsh0p2zC7F4toUocqowmjS6sQff2NGT3D9WbDj/3K2CJiA==", "dev": true, "requires": { "@types/node": "*", - "playwright-core": "1.29.1" + "fsevents": "2.3.2", + "playwright-core": "1.31.1" } }, "@schematics/angular": { @@ -44490,18 +44494,18 @@ } }, "playwright": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.29.1.tgz", - "integrity": "sha512-lasC+pMqsQ2uWhNurt3YK3xo0gWlMjslYUylKbHcqF/NTjwp9KStRGO7S6wwz2f52GcSnop8XUK/GymJjdzrxw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.31.1.tgz", + "integrity": "sha512-zKJabsIA2rvOwJ12lGTqWv4HVJzlfw2JtUvO4hAr7J8UXQZ1qEPpX20E1vcz/9fotnTkwgqp3CVdIBwptBN3Fg==", "dev": true, "requires": { - "playwright-core": "1.29.1" + "playwright-core": "1.31.1" } }, "playwright-core": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.29.1.tgz", - "integrity": "sha512-20Ai3d+lMkWpI9YZYlxk8gxatfgax5STW8GaMozAHwigLiyiKQrdkt7gaoT9UQR8FIVDg6qVXs9IoZUQrDjIIg==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.31.1.tgz", + "integrity": "sha512-JTyX4kV3/LXsvpHkLzL2I36aCdml4zeE35x+G5aPc4bkLsiRiQshU5lWeVpHFAuC8xAcbI6FDcw/8z3q2xtJSQ==", "dev": true }, "plist": { diff --git a/package.json b/package.json index 147172e37..a70d0f6c2 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "test": "jest", "test:watch": "jest --watch", "e2e": "npm run build:electron && npx playwright test --retries 3", - "e2e:ci": "npx playwright test --retries 3", + "e2e:ci": "npx playwright test --retries 3 --workers=1", "e2e:debug": "npm run build:electron && PWDEBUG=1 npx playwright test -c e2e/playwright.config.ts e2e/ /", "e2e:html-report": "npm run build:electron && npm run e2e -- --reporter=html", "e2e:show-report": "npx playwright show-report", @@ -110,7 +110,7 @@ "@ngrx/eslint-plugin": "^14.3.2", "@ngx-translate/core": "14.0.0", "@ngx-translate/http-loader": "7.0.0", - "@playwright/test": "1.29.1", + "@playwright/test": "1.31.1", "@semantic-release/changelog": "6.0.1", "@semantic-release/git": "10.0.1", "@semantic-release/npm": "9.0.0", @@ -133,8 +133,8 @@ "ng-mocks": "14.6.0", "node-polyfill-webpack-plugin": "1.1.4", "npm-run-all": "4.1.5", - "playwright": "1.29.1", - "playwright-core": "1.29.1", + "playwright": "1.31.1", + "playwright-core": "1.31.1", "prettier": "2.5.1", "semantic-release": "19.0.3", "stylelint": "14.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index 9e1f34377..ee835f20d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { devices, PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { testDir: './e2e', + timeout: 45000, maxFailures: 2, testMatch: /.*\.e2e\.ts/, projects: [ @@ -28,6 +29,7 @@ const config: PlaywrightTestConfig = { }, */ ], use: { + headless: false, screenshot: 'only-on-failure', testIdAttribute: 'data-test-id', }, diff --git a/shared/ipc-commands.ts b/shared/ipc-commands.ts index 612ecf4f5..b227bc0f5 100644 --- a/shared/ipc-commands.ts +++ b/shared/ipc-commands.ts @@ -12,20 +12,10 @@ export const EPG_GET_CHANNELS_BY_RANGE_RESPONSE = export const EPG_FORCE_FETCH = 'EPG:EPG_FORCE_FETCH'; // Playlist related commands -export const PLAYLIST_GET_ALL = 'PLAYLIST:GET_ALL'; -export const PLAYLIST_GET_ALL_RESPONSE = 'PLAYLIST:GET_ALL_RESPONSE'; -export const PLAYLIST_GET_BY_ID = 'PLAYLIST:GET_BY_ID'; -export const PLAYLIST_SAVE_DETAILS = 'PLAYLIST:SAVE_DETAILS'; -export const PLAYLIST_PARSE = 'PLAYLIST:PARSE_PLAYLIST'; export const PLAYLIST_PARSE_BY_URL = 'PLAYLIST:PARSE_PLAYLIST_BY_URL'; -export const PLAYLIST_PARSE_TEXT = 'PLAYLIST:PLAYLIST_PARSE_TEXT'; export const PLAYLIST_PARSE_RESPONSE = 'PLAYLIST:PARSE_PLAYLIST_RESPONSE'; export const PLAYLIST_UPDATE = 'PLAYLIST:UPDATE'; export const PLAYLIST_UPDATE_RESPONSE = 'PLAYLIST:UPDATE_RESPONSE'; -export const PLAYLIST_UPDATE_POSITIONS = 'PLAYLIST:UPDATE_POSITIONS'; -export const PLAYLIST_REMOVE_BY_ID = 'PLAYLIST:REMOVE_BY_ID'; -export const PLAYLIST_REMOVE_BY_ID_RESPONSE = 'PLAYLIST:REMOVE_BY_ID_RESPONSE'; -export const PLAYLIST_UPDATE_FAVORITES = 'PLAYLIST:UPDATE_FAVORITES'; // General export const SHOW_WHATS_NEW = 'SHOW_WHATS_NEW'; @@ -38,3 +28,15 @@ export const CHANNEL_SET_USER_AGENT = 'CHANNEL:SET_USER_AGENT'; // Views export const VIEW_SETTINGS = 'VIEW:SETTINGS'; export const VIEW_ADD_PLAYLIST = 'VIEW:PLAYLISTS'; + +// Migration +export const IS_PLAYLISTS_MIGRATION_POSSIBLE = 'MIGRATION_POSSIBLE'; +export const IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE = + '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/shared/playlist.interface.ts b/shared/playlist.interface.ts index aef31d0b5..02e2c5b9e 100644 --- a/shared/playlist.interface.ts +++ b/shared/playlist.interface.ts @@ -11,7 +11,6 @@ export enum PlaylistUpdateState { * Describes playlist interface */ export interface Playlist { - id: string; _id: string; title: string; filename: string; diff --git a/shared/playlist.utils.ts b/shared/playlist.utils.ts index 078450444..207d4d46f 100644 --- a/shared/playlist.utils.ts +++ b/shared/playlist.utils.ts @@ -1,3 +1,5 @@ +import { v4 as uuidv4 } from 'uuid'; +import { ParsedPlaylist } from '../src/typings'; import { Channel } from './channel.interface'; import { GLOBAL_FAVORITES_PLAYLIST_ID } from './constants'; import { Playlist } from './playlist.interface'; @@ -33,7 +35,6 @@ export function createFavoritesPlaylist( channels: Channel[] ): Partial { return { - id: GLOBAL_FAVORITES_PLAYLIST_ID, _id: GLOBAL_FAVORITES_PLAYLIST_ID, count: channels.length, playlist: { @@ -43,16 +44,6 @@ export function createFavoritesPlaylist( }; } -export const getUUID = () => - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - (String(1e7) + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => - ( - Number(c) ^ - (crypto.getRandomValues(new Uint8Array(1))[0] & - (15 >> (Number(c) / 4))) - ).toString(16) - ); - /** * Returns last segment (part after last slash "/") of the given URL * @param value URL as string @@ -63,3 +54,37 @@ export const getFilenameFromUrl = (value: string): string => { } 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) => ({ + id: uuidv4(), + ...item, + })), + }, + importDate: new Date().toISOString(), + lastUsage: new Date().toISOString(), + favorites: [], + autoRefresh: false, + ...(uploadType === 'URL' ? { url: urlOrPath } : {}), + ...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}), + }; +}; diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 7eeb9bb5c..ce0accfc8 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -7,11 +7,21 @@ const routes: Routes = [ loadChildren: () => import('./home/home.module').then((m) => m.HomeModule), }, + { + path: 'playlists', + loadChildren: () => + import('./player/player.module').then((m) => m.PlayerModule), + }, { path: 'iptv', loadChildren: () => import('./player/player.module').then((m) => m.PlayerModule), }, + { + path: 'playlists/:id', + loadChildren: () => + import('./player/player.module').then((m) => m.PlayerModule), + }, { path: 'settings', loadComponent: () => 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 26b06c1c7..22dd27077 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,11 +1,14 @@ import { Component, NgZone } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; 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, @@ -16,13 +19,14 @@ 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'; import { Settings } from './settings/settings.interface'; import { Theme } from './settings/theme.enum'; import { STORE_KEY } from './shared/enums/store-keys.enum'; - +import * as PlaylistActions from './state/actions'; /** * AppComponent */ @@ -57,14 +61,13 @@ 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, private translate: TranslateService, private settingsService: SettingsService, @@ -91,15 +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/app.module.ts b/src/app/app.module.ts index 9a37714a5..6533f1df1 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -44,7 +44,7 @@ export function DataFactory(dbService: NgxIndexedDBService, http: HttpClient) { if (isElectron()) { return new ElectronService(); } - return new PwaService(dbService, http); + return new PwaService(http); } @NgModule({ diff --git a/src/app/home/home.component.spec.ts b/src/app/home/home.component.spec.ts index b3c46fbbe..66a5f83b0 100644 --- a/src/app/home/home.component.spec.ts +++ b/src/app/home/home.component.spec.ts @@ -18,10 +18,7 @@ import { UrlUploadComponent } from '../home/url-upload/url-upload.component'; import { DataService } from '../services/data.service'; import { ElectronServiceStub } from '../services/electron.service.stub'; import { HeaderComponent } from '../shared/components/header/header.component'; -import { - PLAYLIST_PARSE, - PLAYLIST_PARSE_BY_URL, -} from './../../../shared/ipc-commands'; +import { PLAYLIST_PARSE_BY_URL } from './../../../shared/ipc-commands'; import { HomeComponent } from './home.component'; class MatSnackBarStub { @@ -106,7 +103,7 @@ describe('HomeComponent', () => { }); it('should send an event to the main process to parse a playlist', () => { - jest.spyOn(electronService, 'sendIpcEvent'); + jest.spyOn(mockStore, 'dispatch'); const title = 'my-list.m3u'; const path = '/home/user/iptv/' + title; const playlistContent = 'test'; @@ -118,10 +115,7 @@ describe('HomeComponent', () => { target: { result: playlistContent }, } as unknown as Event; component.handlePlaylist({ file, uploadEvent }); - expect(electronService.sendIpcEvent).toHaveBeenCalledWith( - PLAYLIST_PARSE, - { title, playlist: [playlistContent], path } - ); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); }); it('should set IPC event listeners', () => { @@ -138,16 +132,6 @@ describe('HomeComponent', () => { expect(component.showNotification).toHaveBeenCalledTimes(1); }); - it('should navigate to the player view', () => { - jest.spyOn(router, 'navigateByUrl'); - component.navigateToPlayer(); - expect(router.navigateByUrl).toHaveBeenCalledTimes(1); - expect(router.navigateByUrl).toHaveBeenCalledWith( - '/iptv', - expect.anything() - ); - }); - it('should remove all ipc listeners on destroy', () => { jest.spyOn(electronService, 'removeAllListeners'); component.ngOnDestroy(); diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index ee2fec3b1..68b10018e 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -1,19 +1,16 @@ import { Component, NgZone } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { ERROR, - PLAYLIST_PARSE, PLAYLIST_PARSE_BY_URL, PLAYLIST_PARSE_RESPONSE, - PLAYLIST_PARSE_TEXT, } from '../../../shared/ipc-commands'; import { Playlist } from '../../../shared/playlist.interface'; import { getFilenameFromUrl } from '../../../shared/playlist.utils'; import { DataService } from '../services/data.service'; -import { setPlaylist } from '../state/actions'; +import { addPlaylist, parsePlaylist } from '../state/actions'; @Component({ selector: 'app-home', @@ -30,9 +27,10 @@ export class HomeComponent { id: PLAYLIST_PARSE_RESPONSE, execute: (response: { payload: Playlist }): void => { this.store.dispatch( - setPlaylist({ playlist: response.payload }) + addPlaylist({ + playlist: response.payload, + }) ); - this.navigateToPlayer(); }, }, { @@ -43,19 +41,9 @@ export class HomeComponent { listeners = []; - /** - * Creates an instance of HomeComponent - * @param dataService data service - * @param ngZone angular ngZone module - * @param router angular router - * @param snackBar snackbar for notification messages - * @param store channels store - * @param translateService ngx-translate service - */ constructor( private dataService: DataService, private ngZone: NgZone, - private router: Router, private snackBar: MatSnackBar, private readonly store: Store, private translateService: TranslateService @@ -103,22 +91,17 @@ export class HomeComponent { */ handlePlaylist(payload: { uploadEvent: Event; file: File }): void { this.isLoading = true; - const result = (payload.uploadEvent.target as FileReader) + const playlist = (payload.uploadEvent.target as FileReader) .result as string; - const array = result.split('\n'); - this.dataService.sendIpcEvent(PLAYLIST_PARSE, { - title: payload.file.name, - playlist: array, - path: payload.file.path, - }); - } - /** - * Navigates to the video player route - */ - navigateToPlayer(): void { - this.isLoading = false; - this.router.navigateByUrl('/iptv', { skipLocationChange: true }); + this.store.dispatch( + parsePlaylist({ + uploadType: 'FILE', + playlist, + title: payload.file.name, + path: payload.file.path, + }) + ); } /** @@ -137,11 +120,15 @@ export class HomeComponent { * Sends IPC event to the renderer process to parse playlist * @param text playlist as string */ - uploadAsText(text: string): void { + uploadAsText(playlist: string): void { this.isLoading = true; - this.dataService.sendIpcEvent(PLAYLIST_PARSE_TEXT, { - text, - }); + this.store.dispatch( + parsePlaylist({ + uploadType: 'TEXT', + playlist, + title: 'Imported as text', + }) + ); } /** diff --git a/src/app/home/recent-playlists/playlist-info/playlist-info.component.html b/src/app/home/recent-playlists/playlist-info/playlist-info.component.html index cfe746ab2..778ac7892 100644 --- a/src/app/home/recent-playlists/playlist-info/playlist-info.component.html +++ b/src/app/home/recent-playlists/playlist-info/playlist-info.component.html @@ -54,7 +54,7 @@ {{ 'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' | translate }} - + {{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }} diff --git a/src/app/home/recent-playlists/playlist-info/playlist-info.component.spec.ts b/src/app/home/recent-playlists/playlist-info/playlist-info.component.spec.ts index cb6f9c372..c5985e0e6 100644 --- a/src/app/home/recent-playlists/playlist-info/playlist-info.component.spec.ts +++ b/src/app/home/recent-playlists/playlist-info/playlist-info.component.spec.ts @@ -1,5 +1,4 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; -import { PLAYLIST_SAVE_DETAILS } from './../../../../../shared/ipc-commands'; /* eslint-disable @typescript-eslint/unbound-method */ import { DatePipe } from '@angular/common'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; @@ -10,8 +9,12 @@ import { } from '@angular/forms'; import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; import { MockModule, MockPipe } from 'ng-mocks'; +import { Observable } from 'rxjs'; import { DataService } from '../../../services/data.service'; import { ElectronServiceStub } from '../../../services/electron.service.stub'; import { Playlist } from './../../../../../shared/playlist.interface'; @@ -21,6 +24,8 @@ describe('PlaylistInfoComponent', () => { let component: PlaylistInfoComponent; let fixture: ComponentFixture; let electronService: DataService; + let mockStore: MockStore; + const actions$ = new Observable(); beforeEach( waitForAsync(() => { @@ -38,6 +43,8 @@ describe('PlaylistInfoComponent', () => { { provide: MAT_DIALOG_DATA, useValue: {} }, { provide: DataService, useClass: ElectronServiceStub }, UntypedFormBuilder, + provideMockStore(), + provideMockActions(actions$), ], }).compileComponents(); }) @@ -47,6 +54,7 @@ describe('PlaylistInfoComponent', () => { fixture = TestBed.createComponent(PlaylistInfoComponent); component = fixture.componentInstance; electronService = TestBed.inject(DataService); + mockStore = TestBed.inject(MockStore); fixture.detectChanges(); }); @@ -54,14 +62,10 @@ describe('PlaylistInfoComponent', () => { expect(component).toBeTruthy(); }); - it('should send an event to the main process after save', () => { + it('should dispatch an event to save changes in the store', () => { const playlistToSave = { _id: 'a12345', title: 'Playlist' } as Playlist; - jest.spyOn(electronService, 'sendIpcEvent'); + jest.spyOn(mockStore, 'dispatch'); component.saveChanges(playlistToSave); - expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1); - expect(electronService.sendIpcEvent).toHaveBeenCalledWith( - PLAYLIST_SAVE_DETAILS, - playlistToSave - ); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts b/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts index 64eca239d..98d7e12d1 100644 --- a/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts +++ b/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts @@ -2,9 +2,9 @@ import { CommonModule, DatePipe } from '@angular/common'; import { Component, Inject } from '@angular/core'; import { + FormControl, ReactiveFormsModule, UntypedFormBuilder, - UntypedFormControl, UntypedFormGroup, Validators, } from '@angular/forms'; @@ -12,10 +12,12 @@ import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatInputModule } from '@angular/material/input'; +import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { PLAYLIST_SAVE_DETAILS } from '../../../../../shared/ipc-commands'; import { Playlist } from '../../../../../shared/playlist.interface'; import { DataService } from '../../../services/data.service'; +import { PlaylistMeta } from '../../../shared/playlist-meta.type'; +import * as PlaylistActions from '../../../state/actions'; @Component({ selector: 'app-playlist-info', @@ -34,7 +36,7 @@ import { DataService } from '../../../services/data.service'; }) export class PlaylistInfoComponent { /** Flag that returns true if application runs in electron-based environment */ - isElectron = this.electronService.isElectron; + isElectron = this.dataService.isElectron; /** Playlist object */ playlist: Playlist; @@ -42,18 +44,12 @@ export class PlaylistInfoComponent { /** Form group with playlist details */ playlistDetails: UntypedFormGroup; - /** - * Creates an instance of the component and injects the selected playlist from the parent component - * @param datePipe - * @param formBuilder - * @param electronService - * @param playlist playlist object to show - */ constructor( private datePipe: DatePipe, private formBuilder: UntypedFormBuilder, - private electronService: DataService, - @Inject(MAT_DIALOG_DATA) playlist: Playlist + private dataService: DataService, + @Inject(MAT_DIALOG_DATA) playlist: Playlist, + private store: Store ) { this.playlist = playlist; } @@ -64,42 +60,33 @@ export class PlaylistInfoComponent { ngOnInit(): void { this.playlistDetails = this.formBuilder.group({ _id: this.playlist._id, - title: new UntypedFormControl( - this.playlist.title, - Validators.required - ), + title: new FormControl(this.playlist.title, Validators.required), userAgent: this.playlist.userAgent || '', - filename: new UntypedFormControl({ + filename: new FormControl({ value: this.playlist.filename || '', disabled: true, }), - count: new UntypedFormControl({ + count: new FormControl({ value: this.playlist.count, disabled: true, }), - importDate: new UntypedFormControl({ + importDate: new FormControl({ value: this.datePipe.transform(this.playlist.importDate), disabled: true, }), - url: new UntypedFormControl({ + url: new FormControl({ value: this.playlist.url, disabled: true, }), - filePath: new UntypedFormControl({ + filePath: new FormControl({ value: this.playlist.filePath, disabled: true, }), - autoRefresh: new UntypedFormControl(this.playlist.autoRefresh), + autoRefresh: new FormControl(this.playlist.autoRefresh), }); } - /** - * Saves updated playlist information - * @param data updated form data - */ - saveChanges( - data: Pick - ): void { - this.electronService.sendIpcEvent(PLAYLIST_SAVE_DETAILS, data); + saveChanges(playlist: PlaylistMeta): void { + this.store.dispatch(PlaylistActions.updatePlaylistMeta({ playlist })); } } diff --git a/src/app/home/recent-playlists/recent-playlists.component.html b/src/app/home/recent-playlists/recent-playlists.component.html index d7128bd2c..cee4aa9d3 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.html +++ b/src/app/home/recent-playlists/recent-playlists.component.html @@ -1,30 +1,9 @@ -
-
- -
- - -
-
- -
-
-
- - - + + search
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
@@ -54,5 +33,54 @@ (refreshClicked)="refreshPlaylist($event)" (removeClicked)="removeClicked($event)" > + +
+

{{ migrationMessage }}

+   + +
+
+ + +
+
+ +
+ + +
+
+ +
+
+
diff --git a/src/app/home/recent-playlists/recent-playlists.component.scss b/src/app/home/recent-playlists/recent-playlists.component.scss index 92a7ac2c8..8e549f96a 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.scss +++ b/src/app/home/recent-playlists/recent-playlists.component.scss @@ -50,3 +50,18 @@ } } } + +#migration-container { + padding: 10px; + border: 1px solid #999; + border-radius: 5px; + margin: 5px; + + p { + margin-left: 15px; + } + + .migration-btn { + text-transform: uppercase; + } +} diff --git a/src/app/home/recent-playlists/recent-playlists.component.spec.ts b/src/app/home/recent-playlists/recent-playlists.component.spec.ts index 1fe5f197d..97b0b5ec4 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.spec.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.spec.ts @@ -4,17 +4,19 @@ import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { MockModule, MockPipe, MockProvider } from 'ng-mocks'; -import { - PLAYLIST_GET_BY_ID, - PLAYLIST_REMOVE_BY_ID, - PLAYLIST_UPDATE, -} from '../../../../shared/ipc-commands'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { Observable } from 'rxjs'; +import { PLAYLIST_UPDATE } from '../../../../shared/ipc-commands'; import { DataService } from '../../services/data.service'; import { DialogService } from '../../services/dialog.service'; import { ElectronServiceStub } from '../../services/electron.service.stub'; import { PlaylistMeta } from '../../shared/playlist-meta.type'; +import { initialPlaylistMetaState } from '../../state/playlists.state'; import { RecentPlaylistsComponent } from './recent-playlists.component'; describe('RecentPlaylistsComponent', () => { @@ -23,6 +25,8 @@ describe('RecentPlaylistsComponent', () => { let electronService: DataService; let dialog: MatDialog; let dialogService: DialogService; + let mockStore: MockStore; + const actions$ = new Observable(); beforeEach( waitForAsync(() => { @@ -36,12 +40,15 @@ describe('RecentPlaylistsComponent', () => { MockModule(MatListModule), MockModule(MatIconModule), MockModule(MatTooltipModule), + MockModule(NgxSkeletonLoaderModule), ], providers: [ { provide: DataService, useClass: ElectronServiceStub }, MockProvider(TranslateService), MockProvider(DialogService), MatSnackBar, + provideMockStore(), + provideMockActions(actions$), ], }).compileComponents(); }) @@ -50,10 +57,13 @@ describe('RecentPlaylistsComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(RecentPlaylistsComponent); component = fixture.componentInstance; - component.playlists = []; dialog = TestBed.inject(MatDialog); electronService = TestBed.inject(DataService); dialogService = TestBed.inject(DialogService); + mockStore = TestBed.inject(MockStore); + mockStore.setState({ + playlistState: { playlists: initialPlaylistMetaState }, + }); fixture.detectChanges(); }); @@ -78,9 +88,9 @@ describe('RecentPlaylistsComponent', () => { distance: { x: 0, y: 0 }, dropPoint: { x: 0, y: 0 }, } as any; - jest.spyOn(electronService, 'sendIpcEvent'); - component.drop(event); - expect(electronService.sendIpcEvent).toHaveBeenCalledTimes(1); + jest.spyOn(mockStore, 'dispatch'); + component.drop(event, []); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); }); it('should open the confirmation dialog on remove icon click', () => { @@ -92,36 +102,33 @@ describe('RecentPlaylistsComponent', () => { it('should send an event to the main process to remove a playlist', () => { const playlistId = '12345'; - jest.spyOn(electronService, 'sendIpcEvent'); + jest.spyOn(mockStore, 'dispatch'); component.removePlaylist(playlistId); - expect(electronService.sendIpcEvent).toHaveBeenCalledWith( - PLAYLIST_REMOVE_BY_ID, - { id: playlistId } - ); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); }); it('should send an event to the main process to refresh a playlist', () => { const playlistMeta: PlaylistMeta = { - _id: 'iptv1', + id: 'iptv1', + title: 'iptv', filePath: '/home/user/lists/iptv.m3u', - } as PlaylistMeta; + } as unknown as PlaylistMeta; jest.spyOn(electronService, 'sendIpcEvent'); component.refreshPlaylist(playlistMeta); expect(electronService.sendIpcEvent).toHaveBeenCalledWith( PLAYLIST_UPDATE, - { id: playlistMeta._id, filePath: playlistMeta.filePath } + { + id: playlistMeta._id, + filePath: playlistMeta.filePath, + title: playlistMeta.title, + } ); }); it('should send an event to the main process to get a playlist', () => { const playlistId = '6789'; - jest.spyOn(electronService, 'sendIpcEvent'); + jest.spyOn(component.playlistClicked, 'emit'); component.getPlaylist(playlistId); - expect(electronService.sendIpcEvent).toHaveBeenCalledWith( - PLAYLIST_GET_BY_ID, - { - id: playlistId, - } - ); + expect(component.playlistClicked.emit).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/home/recent-playlists/recent-playlists.component.ts b/src/app/home/recent-playlists/recent-playlists.component.ts index 19495e9ab..e53d41946 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.ts @@ -1,18 +1,33 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; -import { Component, NgZone, OnDestroy } from '@angular/core'; +import { + Component, + EventEmitter, + NgZone, + OnDestroy, + Output, +} from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { map } from 'rxjs'; import { IpcCommand } from '../../../../shared/ipc-command.class'; +import { Playlist } from '../../../../shared/playlist.interface'; import { DataService } from '../../services/data.service'; +import * as PlaylistActions from '../../state/actions'; import { - PLAYLIST_GET_ALL, - PLAYLIST_GET_ALL_RESPONSE, - PLAYLIST_GET_BY_ID, - PLAYLIST_REMOVE_BY_ID, - PLAYLIST_REMOVE_BY_ID_RESPONSE, + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '../../state/selectors'; +import { + AUTO_UPDATE_PLAYLISTS_RESPONSE, + DELETE_ALL_PLAYLISTS, + IS_PLAYLISTS_MIGRATION_POSSIBLE, + IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, + MIGRATE_PLAYLISTS, + MIGRATE_PLAYLISTS_RESPONSE, PLAYLIST_UPDATE, - PLAYLIST_UPDATE_POSITIONS, PLAYLIST_UPDATE_RESPONSE, } from './../../../../shared/ipc-commands'; import { DialogService } from './../../services/dialog.service'; @@ -25,57 +40,82 @@ import { PlaylistInfoComponent } from './playlist-info/playlist-info.component'; styleUrls: ['./recent-playlists.component.scss'], }) export class RecentPlaylistsComponent implements OnDestroy { - /** All available playlists */ - playlists: PlaylistMeta[] = []; + playlists$ = this.store.select(selectAllPlaylistsMeta).pipe( + // eslint-disable-next-line @ngrx/avoid-mapping-selectors + map((playlists) => playlists.sort((a, b) => a.position - b.position)) + ); - loading = true; + allPlaylistsLoaded$ = this.store.select(selectPlaylistsLoadingFlag); /** IPC Renderer commands list with callbacks */ commandsList = [ new IpcCommand( - PLAYLIST_GET_ALL_RESPONSE, - (response: { payload: Partial }) => { - this.playlists = response.payload; - this.loading = false; + PLAYLIST_UPDATE_RESPONSE, + (response: { message: string; playlist: Playlist }) => { + this.snackBar.open(response.message, null, { duration: 2000 }); + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlistId: response.playlist._id, + playlist: response.playlist, + }) + ); } ), - new IpcCommand(PLAYLIST_REMOVE_BY_ID_RESPONSE, (): void => { - this.snackBar.open('Done! Playlist was removed.', null, { - duration: 2000, - }); - this.electronService.sendIpcEvent(PLAYLIST_GET_ALL); - }), new IpcCommand( - PLAYLIST_UPDATE_RESPONSE, - (response: { message: string }) => { - this.snackBar.open(response.message, null, { duration: 2000 }); + IS_PLAYLISTS_MIGRATION_POSSIBLE_RESPONSE, + (response: { result: boolean; message: string }) => { + this.isMigrationPossible = response.result; + this.migrationMessage = response.message || ''; + } + ), + new IpcCommand( + MIGRATE_PLAYLISTS_RESPONSE, + (response: { payload: Playlist[] }) => { + this.store.dispatch( + PlaylistActions.addManyPlaylists({ + playlists: response.payload, + }) + ); + this.snackBar.open( + `${response.payload.length} playlists were successfully migrated`, + null, + { duration: 2000 } + ); + } + ), + new IpcCommand( + AUTO_UPDATE_PLAYLISTS_RESPONSE, + (playlists: Playlist[]) => { + this.store.dispatch( + PlaylistActions.updateManyPlaylists({ + playlists, + }) + ); } ), ]; - listeners = []; + isMigrationPossible = false; + migrationMessage = ''; + + @Output() playlistClicked = new EventEmitter(); - /** - * Creates an instance of the component - * @param dialog angular material dialog reference - * @param dialogService dialog service - * @param electronService electron service - * @param snackBar angular material snackbar reference - * @param translate translate service - */ constructor( private dialog: MatDialog, private dialogService: DialogService, private electronService: DataService, private ngZone: NgZone, + private router: Router, private snackBar: MatSnackBar, + private readonly store: Store, private translate: TranslateService ) {} ngOnInit(): void { - // get all playlists - this.electronService.sendIpcEvent(PLAYLIST_GET_ALL); this.setRendererListeners(); + if (this.electronService.isElectron) { + this.electronService.sendIpcEvent(IS_PLAYLISTS_MIGRATION_POSSIBLE); + } } /** @@ -87,14 +127,6 @@ export class RecentPlaylistsComponent implements OnDestroy { this.electronService.listenOn(command.id, (event, response) => this.ngZone.run(() => command.callback(response)) ); - } else { - const cb = (response) => { - if (response.data.type === command.id) { - command.callback(response.data); - } - }; - this.electronService.listenOn(command.id, cb); - this.listeners.push(cb); } }); } @@ -113,26 +145,21 @@ export class RecentPlaylistsComponent implements OnDestroy { * Drop event handler - applies the new sort order to the playlists array * @param event drop event */ - drop(event: CdkDragDrop): void { - moveItemInArray( - this.playlists, - event.previousIndex, - event.currentIndex - ); - this.electronService.sendIpcEvent( - PLAYLIST_UPDATE_POSITIONS, - this.playlists + drop(event: CdkDragDrop, playlists: PlaylistMeta[]): void { + moveItemInArray(playlists, event.previousIndex, event.currentIndex); + this.store.dispatch( + PlaylistActions.updatePlaylistPositions({ + positionUpdates: playlists.map((item, index) => ({ + id: item._id, + changes: { position: index }, + })), + }) ); } - /** - * Requests playlist by id - * @param playlistId playlist id - */ getPlaylist(playlistId: string): void { - this.electronService.sendIpcEvent(PLAYLIST_GET_BY_ID, { - id: playlistId, - }); + this.router.navigate(['playlists', playlistId]); + this.playlistClicked.emit(playlistId); } /** @@ -154,9 +181,7 @@ export class RecentPlaylistsComponent implements OnDestroy { * @param playlistId playlist id to remove */ removePlaylist(playlistId: string): void { - this.electronService.sendIpcEvent(PLAYLIST_REMOVE_BY_ID, { - id: playlistId, - }); + this.store.dispatch(PlaylistActions.removePlaylist({ playlistId })); } /** @@ -166,10 +191,19 @@ export class RecentPlaylistsComponent implements OnDestroy { refreshPlaylist(item: PlaylistMeta): void { this.electronService.sendIpcEvent(PLAYLIST_UPDATE, { id: item._id, + title: item.title, ...(item.url ? { url: item.url } : { filePath: item.filePath }), }); } + migratePlaylists() { + this.electronService.sendIpcEvent(MIGRATE_PLAYLISTS); + } + + deleteMigratedPlaylists() { + this.electronService.sendIpcEvent(DELETE_ALL_PLAYLISTS); + } + /** * Removes command listeners on component destroy */ @@ -178,10 +212,6 @@ export class RecentPlaylistsComponent implements OnDestroy { this.commandsList.forEach((command) => this.electronService.removeAllListeners(command.id) ); - } else { - this.listeners.forEach((listener) => { - window.removeEventListener('message', listener); - }); } } } diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.spec.ts b/src/app/player/components/channel-list-container/channel-list-container.component.spec.ts index 6905daf4d..e246e6a24 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.spec.ts +++ b/src/app/player/components/channel-list-container/channel-list-container.component.spec.ts @@ -13,8 +13,9 @@ import { RouterTestingModule } from '@angular/router/testing'; import { Actions } from '@ngrx/effects'; import { provideMockActions } from '@ngrx/effects/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { TranslatePipe } from '@ngx-translate/core'; -import { MockModule, MockPipes } from 'ng-mocks'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockModule, MockPipes, MockProviders } from 'ng-mocks'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; import { Observable } from 'rxjs'; import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json'; import { DataService } from '../../../services/data.service'; @@ -44,6 +45,7 @@ describe('ChannelListContainerComponent', () => { { provide: DataService, useClass: ElectronServiceStub }, provideMockStore(), provideMockActions(actions$), + MockProviders(NgxIndexedDBService, TranslateService), ], imports: [ MockModule(MatSnackBarModule), @@ -73,8 +75,6 @@ describe('ChannelListContainerComponent', () => { mockStore.setState({ playlistState: { channels, - favorites: [MOCKED_PLAYLIST.playlist.items[0].url], - playlistId: '', active: undefined, }, }); @@ -158,9 +158,10 @@ describe('ChannelListContainerComponent', () => { new MouseEvent('click') ); fixture.detectChanges(); - expect(mockStore.dispatch).toHaveBeenCalledWith( - {channel: component._channelList[0], type: expect.stringContaining('favorites')}, - ); + expect(mockStore.dispatch).toHaveBeenCalledWith({ + channel: component._channelList[0], + type: expect.stringContaining('favorites'), + }); expect(mockStore.dispatch).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/player/components/channel-list-container/channel-list-container.component.ts b/src/app/player/components/channel-list-container/channel-list-container.component.ts index 82801f40f..f63255943 100644 --- a/src/app/player/components/channel-list-container/channel-list-container.component.ts +++ b/src/app/player/components/channel-list-container/channel-list-container.component.ts @@ -7,14 +7,15 @@ import { } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; import * as _ from 'lodash'; -import { combineLatestWith, map, skipWhile } from 'rxjs'; +import { map, skipWhile } from 'rxjs'; import { Channel } from '../../../../../shared/channel.interface'; +import { PlaylistsService } from '../../../services/playlists.service'; import * as PlaylistActions from '../../../state/actions'; import { - selectChannels, + selectActivePlaylistId, selectFavorites, - selectPlaylistId, } from '../../../state/selectors'; @Component({ selector: 'app-channel-list-container', @@ -43,14 +44,6 @@ export class ChannelListContainerComponent { /** Selected channel */ selected!: Channel; - /** List with favorites */ - favorites$ = this.store.select(selectChannels).pipe( - combineLatestWith(this.store.select(selectFavorites)), - map(([channels, favorites]) => - channels.filter((channel) => favorites.includes(channel.id)) - ) - ); - /** Search term for channel filter */ searchTerm: any = { name: '', @@ -69,17 +62,30 @@ export class ChannelListContainerComponent { /** ID of the current playlist */ playlistId$ = this.store - .select(selectPlaylistId) + .select(selectActivePlaylistId) .pipe( skipWhile( (playlistId) => playlistId === '' || playlistId === undefined ) ); - /** - * Creates an instance of ChannelListContainerComponent - */ - constructor(private readonly store: Store, private snackBar: MatSnackBar) {} + /** List with favorites */ + favorites$ = this.store + .select(selectFavorites) + .pipe( + map((favorites) => + this.channelList.filter((channel) => + favorites.includes(channel.id) + ) + ) + ); + + constructor( + private playlistService: PlaylistsService, + private readonly store: Store, + private snackBar: MatSnackBar, + private translateService: TranslateService + ) {} /** * Sets clicked channel as selected and emits them to the parent component @@ -97,7 +103,11 @@ export class ChannelListContainerComponent { */ toggleFavoriteChannel(channel: Channel, clickEvent: MouseEvent): void { clickEvent.stopPropagation(); - this.snackBar.open('Favorites were updated!', null, { duration: 2000 }); + this.snackBar.open( + this.translateService.instant('CHANNELS.FAVORITES_UPDATED'), + null, + { duration: 2000 } + ); this.store.dispatch(PlaylistActions.updateFavorites({ channel })); } diff --git a/src/app/player/components/html-video-player/html-video-player.component.ts b/src/app/player/components/html-video-player/html-video-player.component.ts index 199ca965b..cef1bdc45 100644 --- a/src/app/player/components/html-video-player/html-video-player.component.ts +++ b/src/app/player/components/html-video-player/html-video-player.component.ts @@ -48,15 +48,17 @@ export class HtmlVideoPlayerComponent implements OnChanges, OnDestroy { */ playChannel(channel: Channel): void { if (this.hls) this.hls.destroy(); - const url = channel.url + channel.epgParams; - if (Hls && Hls.isSupported()) { - console.log('... switching channel to ', channel.name, url); - this.hls = new Hls(); - this.hls.attachMedia(this.videoPlayer.nativeElement); - this.hls.loadSource(url); - this.handlePlayOperation(); - } else { - console.error('something wrong with hls.js init...'); + if (channel.url) { + const url = channel.url + channel.epgParams; + if (Hls && Hls.isSupported()) { + console.log('... switching channel to ', channel.name, url); + this.hls = new Hls(); + this.hls.attachMedia(this.videoPlayer.nativeElement); + this.hls.loadSource(url); + this.handlePlayOperation(); + } else { + console.error('something wrong with hls.js init...'); + } } } diff --git a/src/app/player/components/video-player/sidebar/sidebar.component.html b/src/app/player/components/video-player/sidebar/sidebar.component.html new file mode 100644 index 000000000..327d82b42 --- /dev/null +++ b/src/app/player/components/video-player/sidebar/sidebar.component.html @@ -0,0 +1,52 @@ +
+
+ +
+
+ + {{ playlistTitle$ | async }} + + + {{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }} + +
+
+ {{ + sidebarView === 'CHANNELS' + ? channels.length + + ' ' + + ('HOME.PLAYLISTS.CHANNELS' | translate) + : ('HOME.PLAYLISTS.MY_PLAYLISTS_SUBTITLE' | translate) + }} +
+
+ + +
+
+ + + + + + diff --git a/src/app/player/components/video-player/sidebar/sidebar.component.scss b/src/app/player/components/video-player/sidebar/sidebar.component.scss new file mode 100644 index 000000000..eb919ec39 --- /dev/null +++ b/src/app/player/components/video-player/sidebar/sidebar.component.scss @@ -0,0 +1,47 @@ +#playlists-navigation { + background-color: #000; + color: #fff; + + .current-playlist { + display: flex; + align-items: center; + padding: 8px; + } + + .playlist-info { + flex: 1; + max-width: 300px; + + .name { + font-size: 1.1em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .channels-count { + font-size: 0.8em; + color: #ccc; + text-transform: lowercase; + } + } +} + +.recent-playlists { + height: calc(100vh - 60px); + overflow: auto; + width: 100%; + display: block; +} + +.electron { + height: calc(100vh - 85px) !important; +} + +@media only screen and (max-width: 480px) { + .playlist-info { + .name { + max-width: 105px; + } + } +} diff --git a/src/app/player/components/video-player/sidebar/sidebar.component.ts b/src/app/player/components/video-player/sidebar/sidebar.component.ts new file mode 100644 index 000000000..edac9557f --- /dev/null +++ b/src/app/player/components/video-player/sidebar/sidebar.component.ts @@ -0,0 +1,42 @@ +import { Component, Input } from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { Channel } from '../../../../../../shared/channel.interface'; +import { DataService } from '../../../../services/data.service'; +import * as PlaylistActions from '../../../../state/actions'; +import { selectPlaylistTitle } from '../../../../state/selectors'; +import { SidebarView } from '../video-player.component'; + +@Component({ + selector: 'app-sidebar', + templateUrl: './sidebar.component.html', + styleUrls: ['./sidebar.component.scss'], +}) +export class SidebarComponent { + @Input() channels: Channel[] = []; + + isElectron = this.dataService.isElectron; + + playlistTitle$ = this.store.select(selectPlaylistTitle); + + sidebarView: SidebarView = 'CHANNELS'; + + constructor( + public dataService: DataService, + private router: Router, + private store: Store + ) {} + + goBack(): void { + if (this.sidebarView === 'PLAYLISTS') { + this.store.dispatch(PlaylistActions.resetActiveChannel()); + this.router.navigate(['/']); + } else { + this.sidebarView = 'PLAYLISTS'; + } + } + + selectPlaylist() { + this.sidebarView = 'CHANNELS'; + } +} diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.html b/src/app/player/components/video-player/toolbar/toolbar.component.html new file mode 100644 index 000000000..ab2e065d9 --- /dev/null +++ b/src/app/player/components/video-player/toolbar/toolbar.component.html @@ -0,0 +1,41 @@ + + + + {{ activeChannel?.name }} +
+ + + + +
diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.scss b/src/app/player/components/video-player/toolbar/toolbar.component.scss new file mode 100644 index 000000000..dcbd5a563 --- /dev/null +++ b/src/app/player/components/video-player/toolbar/toolbar.component.scss @@ -0,0 +1,3 @@ +.spacer { + flex: 1 1 auto; +} diff --git a/src/app/player/components/video-player/toolbar/toolbar.component.ts b/src/app/player/components/video-player/toolbar/toolbar.component.ts new file mode 100644 index 000000000..d17f63857 --- /dev/null +++ b/src/app/player/components/video-player/toolbar/toolbar.component.ts @@ -0,0 +1,46 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { Channel } from '../../../../../../shared/channel.interface'; +import { updateFavorites } from '../../../../state/actions'; +import { + selectActivePlaylistId, + selectFavorites, + selectIsEpgAvailable, +} from '../../../../state/selectors'; + +@Component({ + selector: 'app-toolbar', + templateUrl: './toolbar.component.html', + styleUrls: ['./toolbar.component.scss'], +}) +export class ToolbarComponent { + @Input() activeChannel!: Channel; + @Output() multiEpgClicked = new EventEmitter(); + @Output() toggleLeftDrawerClicked = new EventEmitter(); + @Output() toggleRightDrawerClicked = new EventEmitter(); + + favorites$ = this.store.select(selectFavorites); + isEpgAvailable$ = this.store.select(selectIsEpgAvailable); + playlistId$ = this.store.select(selectActivePlaylistId); + + constructor( + private snackBar: MatSnackBar, + private store: Store, + private translateService: TranslateService + ) {} + + /** + * Adds/removes a given channel to the favorites list + * @param channel channel to add + */ + addToFavorites(channel: Channel): void { + this.snackBar.open( + this.translateService.instant('CHANNELS.FAVORITES_UPDATED'), + null, + { duration: 2000 } + ); + this.store.dispatch(updateFavorites({ channel })); + } +} diff --git a/src/app/player/components/video-player/video-player.component.html b/src/app/player/components/video-player/video-player.component.html index 6cd802ba6..92a789537 100644 --- a/src/app/player/components/video-player/video-player.component.html +++ b/src/app/player/components/video-player/video-player.component.html @@ -1,117 +1,15 @@ - + - - -
-
- -
-
- - {{ playlistTitle$ | async }} - - - {{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }} - -
-
- {{ - sidebarView === 'CHANNELS' - ? channels.length + ' channels' - : 'all available playlists' - }} -
-
- - -
-
- - - - - - -
+ + - - - - - {{ activeChannel?.name }} -
- - -
+
- +
diff --git a/src/app/player/components/video-player/video-player.component.scss b/src/app/player/components/video-player/video-player.component.scss index 15070cf6b..1a5469a15 100644 --- a/src/app/player/components/video-player/video-player.component.scss +++ b/src/app/player/components/video-player/video-player.component.scss @@ -16,58 +16,9 @@ min-width: 400px; } -.spacer { - flex: 1 1 auto; -} - -#playlists-navigation { - background-color: #000; - color: #fff; - - .current-playlist { - display: flex; - align-items: center; - padding: 8px; - } - - .playlist-info { - flex: 1; - max-width: 300px; - - .name { - font-size: 1.1em; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .channels-count { - font-size: 0.8em; - color: #ccc; - } - } -} - -.recent-playlists { - height: calc(100vh - 60px); - overflow: auto; - width: 100%; - display: block; -} - -.electron { - height: calc(100vh - 85px) !important; -} - @media only screen and (max-width: 480px) { .mat-drawer { width: 200px; min-width: 200px; } - - .playlist-info { - .name { - max-width: 105px; - } - } } diff --git a/src/app/player/components/video-player/video-player.component.spec.ts b/src/app/player/components/video-player/video-player.component.spec.ts index c03594a08..7ad5a079b 100644 --- a/src/app/player/components/video-player/video-player.component.spec.ts +++ b/src/app/player/components/video-player/video-player.component.spec.ts @@ -9,12 +9,10 @@ import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslatePipe } from '@ngx-translate/core'; -import { MockComponent, MockModule, MockPipe } from 'ng-mocks'; -import * as MOCKED_PLAYLIST from '../../../../mocks/playlist.json'; +import { MockComponent, MockModule, MockPipe, MockProviders } from 'ng-mocks'; import { DataService } from '../../../services/data.service'; import { ElectronServiceStub } from '../../../services/electron.service.stub'; import { VideoPlayer } from '../../../settings/settings.interface'; -import { createChannel } from '../../../shared/channel.model'; import { ChannelListContainerComponent } from '../channel-list-container/channel-list-container.component'; import { EpgListComponent } from '../epg-list/epg-list.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; @@ -24,7 +22,10 @@ import { VideoPlayerComponent } from './video-player.component'; import { Actions } from '@ngrx/effects'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Observable } from 'rxjs'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { Observable, of } from 'rxjs'; +import { PlaylistsService } from '../../../services/playlists.service'; +import { initialState } from '../../../state/state'; class MatSnackBarStub { open(): void {} @@ -56,6 +57,7 @@ describe('VideoPlayerComponent', () => { { provide: ActivatedRoute, useValue: { + params: of({ id: '1' }), snapshot: { queryParams: { url: 'https://iptvnator/list.m3u', @@ -65,6 +67,7 @@ describe('VideoPlayerComponent', () => { }, provideMockStore(), provideMockActions(actions$), + MockProviders(NgxIndexedDBService, PlaylistsService), ], imports: [ MockModule(MatSidenavModule), @@ -83,34 +86,16 @@ describe('VideoPlayerComponent', () => { component = fixture.componentInstance; mockStore = TestBed.inject(MockStore); - // set channels - channels = MOCKED_PLAYLIST.playlist.items.map((element) => - createChannel(element) - ); - - mockStore.setState({ playlistState: {channels} }); - }); - - it('should create and init component', () => { - expect(component).toBeTruthy(); - jest.spyOn(component, 'applySettings'); + mockStore.setState({ + playlistState: initialState, + }); fixture.detectChanges(); - expect(component.applySettings).toHaveBeenCalledTimes(1); }); it('should check default component settings', () => { - fixture.detectChanges(); expect(component.playerSettings).toEqual({ player: VideoPlayer.VideoJs, showCaptions: false, }); }); - - it('should update store after channel was faved', () => { - jest.spyOn(mockStore, 'dispatch'); - const [firstChannel] = channels; - component.addToFavorites(firstChannel); - expect(mockStore.dispatch).toHaveBeenCalledTimes(1); - expect(mockStore.dispatch).toHaveBeenCalledWith({channel: firstChannel, type: expect.stringContaining('favorite')}); - }); }); diff --git a/src/app/player/components/video-player/video-player.component.ts b/src/app/player/components/video-player/video-player.component.ts index 627befba0..140b6b781 100644 --- a/src/app/player/components/video-player/video-player.component.ts +++ b/src/app/player/components/video-player/video-player.component.ts @@ -7,45 +7,44 @@ import { NgZone, OnDestroy, OnInit, - ViewChild, } from '@angular/core'; -import { MatSidenav } from '@angular/material/sidenav'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; -import { filter, skipWhile } from 'rxjs'; +import { + combineLatestWith, + filter, + Observable, + skipWhile, + switchMap, +} from 'rxjs'; import { Channel } from '../../../../../shared/channel.interface'; import { - PLAYLIST_GET_ALL, 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 { Settings, VideoPlayer } from '../../../settings/settings.interface'; import { STORE_KEY } from '../../../shared/enums/store-keys.enum'; -import { setPlaylist, updateFavorites } from '../../../state/actions'; +import * as PlaylistActions from '../../../state/actions'; import { selectActive, selectChannels, selectCurrentEpgProgram, - selectFavorites, - selectIsEpgAvailable, - selectPlaylistFilename, - selectPlaylistId, + selectPlaylistTitle, } from '../../../state/selectors'; import { MultiEpgContainerComponent } from '../multi-epg/multi-epg-container.component'; /** Possible sidebar view options */ -type SidebarView = 'CHANNELS' | 'PLAYLISTS'; +export type SidebarView = 'CHANNELS' | 'PLAYLISTS'; export const COMPONENT_OVERLAY_REF = new InjectionToken( 'COMPONENT_OVERLAY_REF' ); @Component({ - selector: 'app-video-player', templateUrl: './video-player.component.html', styleUrls: ['./video-player.component.scss'], }) @@ -53,84 +52,66 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** Active selected channel */ activeChannel$ = this.store .select(selectActive) - .pipe(filter((channel) => Boolean(channel))); + .pipe(filter((channel) => Boolean(channel?.url))); /** Channels list */ - channels$ = this.store.select(selectChannels); - - /** EPG availability flag */ - epgAvailable$ = this.store.select(selectIsEpgAvailable); + channels$!: Observable; /** Current epg program */ epgProgram$ = this.store.select(selectCurrentEpgProgram); - /** Favorites list */ - favorites$ = this.store.select(selectFavorites); - /** Selected video player options */ playerSettings: Partial = { player: VideoPlayer.VideoJs, showCaptions: false, }; - /** Playlists array */ - playlists = []; - - /** Sidebar object */ - @ViewChild('sidenav') sideNav: MatSidenav; - - /** ID of the current playlist */ - playlistId$ = this.store - .select(selectPlaylistId) - .pipe( - skipWhile( - (playlistId) => playlistId === '' || playlistId === undefined - ) - ); - /** Title of the current playlist */ playlistTitle$ = this.store - .select(selectPlaylistFilename) + .select(selectPlaylistTitle) .pipe(skipWhile((playlistFilename) => playlistFilename === '')); - isElectron = this.dataService.isElectron; - /** IPC Renderer commands list with callbacks */ commandsList = [ { id: PLAYLIST_PARSE_RESPONSE, execute: (response: { payload: Playlist }): void => { - this.store.dispatch( - setPlaylist({ playlist: response.payload }) - ); - this.setSidebarView('CHANNELS'); + if (response.payload.isTemporary) { + this.store.dispatch( + PlaylistActions.setChannels({ + channels: response.payload.playlist.items, + }) + ); + } else { + this.store.dispatch( + PlaylistActions.addPlaylist({ + playlist: response.payload, + }) + ); + } + this.sidebarView = 'CHANNELS'; }, }, ]; - /** Current sidebar view */ - sidebarView: SidebarView = 'CHANNELS'; - listeners = []; + isElectron = this.dataService.isElectron; + + sidebarView: SidebarView = 'CHANNELS'; + /** EPG overlay reference */ overlayRef: OverlayRef; - /** - * Creates an instance of VideoPlayerComponent - */ constructor( private activatedRoute: ActivatedRoute, - private store: Store, - public dataService: DataService, + private dataService: DataService, private ngZone: NgZone, private overlay: Overlay, - private router: Router, - private snackBar: MatSnackBar, - private storage: StorageMap - ) { - this.dataService.sendIpcEvent(PLAYLIST_GET_ALL); - } + private playlistsService: PlaylistsService, + private storage: StorageMap, + private store: Store + ) {} /** * Sets video player and subscribes to channel list from the store @@ -139,10 +120,27 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.applySettings(); this.setRendererListeners(); this.getPlaylistUrlAsParam(); + + this.channels$ = this.activatedRoute.params.pipe( + combineLatestWith(this.activatedRoute.queryParams), + switchMap(([params, queryParams]) => { + if (params.id) { + this.store.dispatch( + PlaylistActions.setActivePlaylist({ + playlistId: params.id, + }) + ); + return this.playlistsService.getPlaylistChannels(params.id); + } else if (queryParams.url) { + return this.store.select(selectChannels); + } + }) + ); } /** * Opens a playlist provided as a url param + * e.g. iptvnat.or?url=http://... */ getPlaylistUrlAsParam() { const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; @@ -161,7 +159,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { */ setRendererListeners(): void { this.commandsList.forEach((command) => { - if (this.dataService.isElectron) { + if (this.isElectron) { this.dataService.listenOn(command.id, (event, response) => this.ngZone.run(() => command.execute(response)) ); @@ -191,41 +189,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { }); } - /** - * Closes the channels sidebar - */ - close(): void { - this.sideNav.close(); - } - - /** - * Adds/removes a given channel to the favorites list - * @param channel channel to add - */ - addToFavorites(channel: Channel): void { - this.snackBar.open('Favorites were updated!', null, { duration: 2000 }); - this.store.dispatch(updateFavorites({ channel })); - } - - /** - * Switches the sidebar view to the specified value - * @param view view to change - */ - setSidebarView(view: SidebarView) { - this.sidebarView = view; - } - - /** Navigates back */ - goBack(): void { - if (this.sidebarView === 'PLAYLISTS') { - this.router.navigate(['/']); - } else { - this.sidebarView = 'PLAYLISTS'; - } - } - ngOnDestroy() { - if (this.dataService.isElectron) { + if (this.isElectron) { this.dataService.removeAllListeners(PLAYLIST_PARSE_RESPONSE); } else { this.listeners.forEach((listener) => diff --git a/src/app/player/player.module.ts b/src/app/player/player.module.ts index f47a1c0d9..69be27214 100644 --- a/src/app/player/player.module.ts +++ b/src/app/player/player.module.ts @@ -11,6 +11,8 @@ import { EpgListComponent } from './components/epg-list/epg-list.component'; import { HtmlVideoPlayerComponent } from './components/html-video-player/html-video-player.component'; import { InfoOverlayComponent } from './components/info-overlay/info-overlay.component'; import { MultiEpgContainerComponent } from './components/multi-epg/multi-epg-container.component'; +import { SidebarComponent } from './components/video-player/sidebar/sidebar.component'; +import { ToolbarComponent } from './components/video-player/toolbar/toolbar.component'; import { VideoPlayerComponent } from './components/video-player/video-player.component'; import { VjsPlayerComponent } from './components/vjs-player/vjs-player.component'; @@ -32,6 +34,8 @@ const routes: Routes = [{ path: '', component: VideoPlayerComponent }]; InfoOverlayComponent, HtmlVideoPlayerComponent, MultiEpgContainerComponent, + SidebarComponent, + ToolbarComponent, VideoPlayerComponent, VjsPlayerComponent, ], diff --git a/src/app/services/playlists.service.ts b/src/app/services/playlists.service.ts new file mode 100644 index 000000000..ff14a543b --- /dev/null +++ b/src/app/services/playlists.service.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Injectable } from '@angular/core'; +import { parse } from 'iptv-playlist-parser'; +import { NgxIndexedDBService } from 'ngx-indexed-db'; +import { combineLatest, map, Observable, 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'; +import { DbStores } from '../indexed-db.config'; +import { PlaylistMeta } from '../shared/playlist-meta.type'; + +@Injectable({ + providedIn: 'root', +}) +export class PlaylistsService { + constructor(private dbService: NgxIndexedDBService) {} + + getAllPlaylists() { + return this.dbService.getAll(DbStores.Playlists).pipe( + map((data) => + data.map(({ playlist, items, header, ...rest }) => ({ + ...rest, + })) + ), + map((playlists) => + playlists.sort((a, b) => a.position - b.position) + ) + ); + } + + addPlaylist(playlist) { + return this.dbService.add(DbStores.Playlists, playlist); + } + + getPlaylistChannels(id: string) { + let playlist$: Observable>; + if (id === GLOBAL_FAVORITES_PLAYLIST_ID) { + playlist$ = this.getPlaylistWithGlobalFavorites(); + } else { + playlist$ = this.dbService.getByID( + DbStores.Playlists, + id + ); + } + return playlist$.pipe( + map((data: Playlist) => data.playlist.items as Channel[]) + ); + } + + deletePlaylist(playlistId: string) { + return this.dbService.delete(DbStores.Playlists, playlistId); + } + + updatePlaylist(playlistId: string, updatedPlaylist: Playlist) { + return this.getPlaylistById(playlistId).pipe( + switchMap((currentPlaylist: Playlist) => + this.dbService.update(DbStores.Playlists, { + ...currentPlaylist, + ...updatedPlaylist, + count: updatedPlaylist.playlist.items.length, + updateDate: Date.now(), + updateState: PlaylistUpdateState.UPDATED, + }) + ) + ); + } + + getPlaylistById(id: string) { + return this.dbService.getByID(DbStores.Playlists, id); + } + + updatePlaylistMeta(updatedPlaylist: PlaylistMeta) { + return this.getPlaylistById(updatedPlaylist._id).pipe( + switchMap((playlist) => + this.dbService.update(DbStores.Playlists, { + ...playlist, + title: updatedPlaylist.title, + autoRefresh: updatedPlaylist.autoRefresh, + }) + ) + ); + } + + updateFavorites(id: string, favorites: string[]) { + return this.getPlaylistById(id).pipe( + switchMap((playlist) => + this.dbService.update(DbStores.Playlists, { + ...playlist, + favorites, + }) + ) + ); + } + + updateManyPlaylists(playlists: Playlist[]) { + return combineLatest( + playlists.map((playlist) => { + return this.dbService.update(DbStores.Playlists, { + ...playlist, + updateDate: Date.now(), + autoRefresh: true, + }); + }) + ); + } + + getFavoriteChannels(playlistId: string) { + return this.dbService + .getByID(DbStores.Playlists, playlistId) + .pipe( + map((data) => + data.playlist.items.filter((channel) => + data.favorites.includes((channel as Channel).id) + ) + ) + ); + } + + updatePlaylistPositions( + positionUpdates: { + id: string; + changes: { position: number }; + }[] + ) { + return combineLatest( + positionUpdates.map((item, index) => { + return this.dbService.getByID(DbStores.Playlists, item.id).pipe( + switchMap((playlist: Playlist) => + this.dbService.update(DbStores.Playlists, { + ...playlist, + position: item.changes.position, + }) + ) + ); + }) + ); + } + + handlePlaylistParsing( + uploadType: 'FILE' | 'URL' | 'TEXT', + playlist: string, + title: string, + path?: string + ) { + const parsedPlaylist = parse(playlist); + return createPlaylistObject(title, parsedPlaylist, path, uploadType); + } + + getPlaylistWithGlobalFavorites() { + return this.dbService.getAll(DbStores.Playlists).pipe( + map((playlists: Playlist[]) => { + const favoriteChannels = aggregateFavoriteChannels(playlists); + const favPlaylist = createFavoritesPlaylist(favoriteChannels); + return favPlaylist; + }) + ); + } + + 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/services/pwa.service.ts b/src/app/services/pwa.service.ts index 9f0b1482f..3877283d9 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -2,77 +2,41 @@ import { HttpClient } from '@angular/common/http'; import { ApplicationRef, inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { SwUpdate } from '@angular/service-worker'; +import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { parse } from 'iptv-playlist-parser'; -import { NgxIndexedDBService } from 'ngx-indexed-db'; -import { - catchError, - combineLatest, - map, - of, - switchMap, - throwError, -} from 'rxjs'; -import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../../../shared/constants'; +import { catchError, throwError } from 'rxjs'; import { ERROR, - PLAYLIST_GET_ALL, - PLAYLIST_GET_ALL_RESPONSE, - PLAYLIST_GET_BY_ID, - PLAYLIST_PARSE, PLAYLIST_PARSE_BY_URL, PLAYLIST_PARSE_RESPONSE, - PLAYLIST_PARSE_TEXT, - PLAYLIST_REMOVE_BY_ID, - PLAYLIST_REMOVE_BY_ID_RESPONSE, - PLAYLIST_SAVE_DETAILS, PLAYLIST_UPDATE, - PLAYLIST_UPDATE_FAVORITES, - PLAYLIST_UPDATE_POSITIONS, - PLAYLIST_UPDATE_RESPONSE, } from '../../../shared/ipc-commands'; -import { - Playlist, - PlaylistUpdateState, -} from '../../../shared/playlist.interface'; -import { - aggregateFavoriteChannels, - createFavoritesPlaylist, - getUUID, -} from '../../../shared/playlist.utils'; +import { Playlist } from '../../../shared/playlist.interface'; import { AppConfig } from '../../environments/environment'; -import { ParsedPlaylist } from '../../typings'; -import { DbStores } from '../indexed-db.config'; +import * as PlaylistActions from '../state/actions'; import { DataService } from './data.service'; +import { PlaylistsService } from './playlists.service'; @Injectable({ providedIn: 'root', }) export class PwaService extends DataService { - /** Proxy URL to avoid CORS issues */ - corsProxyUrl = AppConfig.BACKEND_URL; - appRef = inject(ApplicationRef); + playlistService = inject(PlaylistsService); snackBar = inject(MatSnackBar); + store = inject(Store); swUpdate = inject(SwUpdate); translateService = inject(TranslateService); - /** - * Creates an instance of PwaService. - * @param dbService database service - * @param http angular http client - */ - constructor( - private dbService: NgxIndexedDBService, - private http: HttpClient - ) { + /** Proxy URL to avoid CORS issues */ + corsProxyUrl = AppConfig.BACKEND_URL; + + constructor(private http: HttpClient) { super(); console.log('PWA service initialized...'); } - /** - * Uses service worker mechanism to check for available application updates - */ + /** Uses service worker mechanism to check for available application updates */ checkUpdates() { this.swUpdate.versionUpdates.subscribe(() => { this.snackBar @@ -87,251 +51,58 @@ export class PwaService extends DataService { }); } - /** - * Returns the current version of the app - */ getAppVersion(): string { return AppConfig.version; } - /** - * Returns the count of favorite channels from all playlists - */ - getGlobalFavoritesCount() { - return this.dbService.getAll(DbStores.Playlists).pipe( - map((playlists: Playlist[]) => { - let count = 0; - playlists.forEach((playlist) => { - if (playlist.favorites.length > 0) { - count = count + playlist.favorites.length; - } - }); - return count; - }) - ); - } - - /** - * Sends a message with playlist that contains favorite channels from all available playlists - */ - sendPlaylistWithGlobalFavorites() { - this.dbService - .getAll(DbStores.Playlists) - .pipe( - map((playlists: Playlist[]) => { - const favoriteChannels = - aggregateFavoriteChannels(playlists); - const favPlaylist = - createFavoritesPlaylist(favoriteChannels); - return favPlaylist; - }) - ) - .subscribe((playlist) => { - window.postMessage({ - type: PLAYLIST_PARSE_RESPONSE, - payload: playlist, - }); - }); - } - /** * Handles incoming IPC commands * @param type ipc command type * @param payload payload */ - sendIpcEvent(type: string, payload?: any): void { - if (type === PLAYLIST_PARSE) { - const parsedPlaylist = this.parsePlaylist(payload.playlist); - const playlistObject = this.createPlaylistObject( - payload.title, - parsedPlaylist, - payload.path, - 'FILE' - ); - - // save to db - this.dbService - .add(DbStores.Playlists, playlistObject) - .subscribe(() => { - console.log('playlist was saved to db...'); - }); - - window.postMessage({ - type: PLAYLIST_PARSE_RESPONSE, - payload: playlistObject, - }); - } else if (type === PLAYLIST_GET_BY_ID) { - if (payload.id === GLOBAL_FAVORITES_PLAYLIST_ID) { - this.sendPlaylistWithGlobalFavorites(); - } else { - this.dbService - .getByIndex(DbStores.Playlists, '_id', payload.id) - .subscribe((playlist) => { - window.postMessage({ - type: PLAYLIST_PARSE_RESPONSE, - payload: playlist, - }); - }); - } - } else if (type === PLAYLIST_REMOVE_BY_ID) { - this.dbService - .delete(DbStores.Playlists, payload.id) - .subscribe((playlist) => { - window.postMessage({ - type: PLAYLIST_REMOVE_BY_ID_RESPONSE, - payload: playlist, - }); - }); - } else if (type === PLAYLIST_PARSE_TEXT) { - try { - const parsedPlaylist = this.parsePlaylist( - payload.text.split('\n') - ); - const playlistObject = this.createPlaylistObject( - 'Imported as text', - parsedPlaylist - ); - - // save to db - this.dbService - .add(DbStores.Playlists, playlistObject) - .subscribe(() => { - console.log('playlist was saved to db...'); - }); - - window.postMessage({ - type: PLAYLIST_PARSE_RESPONSE, - payload: playlistObject, - }); - } catch (error) { - window.postMessage({ - type: ERROR, - status: '', - message: 'Validation error - invalid playlist', - }); - } - } else if (type === PLAYLIST_GET_ALL) { - this.dbService - .getAll(DbStores.Playlists) - .subscribe((playlists: Playlist[]) => { - window.postMessage({ - type: PLAYLIST_GET_ALL_RESPONSE, - payload: playlists.sort( - (a, b) => a.position - b.position - ), - }); - }); - } else if (type === PLAYLIST_SAVE_DETAILS) { - this.dbService - .getByID(DbStores.Playlists, payload._id) - .pipe( - switchMap((playlist: Playlist) => { - return this.dbService.update(DbStores.Playlists, { - ...playlist, - title: payload.title, - }); - }) - ) - .subscribe((playlists) => { - window.postMessage({ - type: PLAYLIST_GET_ALL_RESPONSE, - payload: playlists, - }); - }); - } else if (type === PLAYLIST_UPDATE_FAVORITES) { - this.dbService - .getByID(DbStores.Playlists, payload.id) - .pipe( - switchMap((playlist: Playlist) => { - return this.dbService.update(DbStores.Playlists, { - ...playlist, - favorites: payload.favorites, - }); - }) - ) - .subscribe(() => { - console.log('favorites were updated...'); - }); - } else if (type === PLAYLIST_PARSE_BY_URL) { + sendIpcEvent(type: string, payload?: unknown): void { + if (type === PLAYLIST_PARSE_BY_URL) { this.fetchFromUrl(payload); } else if (type === PLAYLIST_UPDATE) { - this.http - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - .get(`${this.corsProxyUrl}/parse?url=${payload.url}`, { - responseType: 'text', - }) - .pipe( - catchError((error) => { - window.postMessage({ - type: ERROR, - }); - return throwError(() => error); - }), - map((response) => - this.convertFileStringToPlaylist(response) - ), - switchMap((refreshedPlaylist) => - this.dbService - .getByID(DbStores.Playlists, payload.id) - .pipe( - switchMap((currentPlaylist: Playlist) => - this.dbService.update(DbStores.Playlists, { - ...currentPlaylist, - ...refreshedPlaylist, - count: refreshedPlaylist.items.length, - updateDate: Date.now(), - updateState: - PlaylistUpdateState.UPDATED, - }) - ) - ) - ) - ) - .subscribe((playlists) => { - console.log('playlist was updated...'); - window.postMessage({ - type: PLAYLIST_GET_ALL_RESPONSE, - payload: playlists, - }); - - window.postMessage({ - type: PLAYLIST_UPDATE_RESPONSE, - message: `Success! The playlist was successfully updated.`, - }); - }); - } else if (type === PLAYLIST_UPDATE_POSITIONS) { - const requests = payload.map((playlist, index) => { - return this.dbService - .getByID(DbStores.Playlists, playlist._id) - .pipe( - switchMap((playlist: Playlist) => { - return this.dbService.update(DbStores.Playlists, { - ...playlist, - position: index, - }); - }) - ); - }); - - combineLatest(requests).subscribe(() => { - console.log('playlist positions were updated...'); - }); + this.refreshPlaylist(payload); } } + refreshPlaylist(payload: Partial) { + this.getPlaylistFromUrl(payload.url) + .pipe( + catchError((error) => { + window.postMessage({ + type: ERROR, + }); + return throwError(() => error); + }) + ) + .subscribe((playlist: Playlist) => { + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlist, + playlistId: payload.id, + }) + ); + + this.snackBar.open( + this.translateService.instant( + 'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS' + ), + null, + { duration: 2000 } + ); + }); + } + /** * Fetches playlist from the specified url * @param payload playlist payload */ fetchFromUrl(payload: Partial): void { - this.http - .get(`${this.corsProxyUrl}/parse?url=${payload.url}`) + this.getPlaylistFromUrl(payload.url) .pipe( - switchMap((response) => - payload.isTemporary - ? of(response) - : this.dbService.add(DbStores.Playlists, response) - ), catchError((error) => { window.postMessage({ type: ERROR, @@ -344,7 +115,7 @@ export class PwaService extends DataService { .subscribe((response: any) => { window.postMessage({ type: PLAYLIST_PARSE_RESPONSE, - payload: response, + payload: { ...response, isTemporary: payload.isTemporary }, }); }); } @@ -365,57 +136,10 @@ export class PwaService extends DataService { return message; } - /** - * Converts file string to playlist object - * @param m3uString - */ - convertFileStringToPlaylist(m3uString: string): ParsedPlaylist { - return this.parsePlaylist(m3uString.split('\n')); - } - - /** - * Saves playlist to the localStorage - * @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 - */ - createPlaylistObject( - name: string, - playlist: ParsedPlaylist, - urlOrPath?: string, - uploadType?: 'URL' | 'FILE' - ): Playlist { - const id = getUUID(); - return { - id, - _id: id, - filename: name, - title: name, - count: playlist.items.length, - playlist: { - ...playlist, - items: playlist.items.map((item) => ({ - id: getUUID(), - ...item, - })), - }, - importDate: new Date().toISOString(), - lastUsage: new Date().toISOString(), - favorites: [], - autoRefresh: false, - ...(uploadType === 'URL' ? { url: urlOrPath } : {}), - ...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}), - }; - } - - /** - * Parses string based array to playlist object - * @param m3uArray m3u playlist as array with strings - */ - parsePlaylist(m3uArray: string[]): ParsedPlaylist { - const playlistAsString = m3uArray.join('\n'); - return parse(playlistAsString); + getPlaylistFromUrl(url: string) { + return this.http.get(`${this.corsProxyUrl}/parse`, { + params: { url }, + }); } removeAllListeners(): void { diff --git a/src/app/shared/playlist-meta.type.ts b/src/app/shared/playlist-meta.type.ts index 0cacd9189..33d178a9a 100644 --- a/src/app/shared/playlist-meta.type.ts +++ b/src/app/shared/playlist-meta.type.ts @@ -13,4 +13,6 @@ export type PlaylistMeta = Pick< | 'updateDate' | 'updateState' | 'position' + | 'autoRefresh' + | 'favorites' >; diff --git a/src/app/state/actions.ts b/src/app/state/actions.ts index b933c92b7..dd5a62a10 100644 --- a/src/app/state/actions.ts +++ b/src/app/state/actions.ts @@ -2,8 +2,64 @@ import { createAction, props } from '@ngrx/store'; import { Channel } from '../../../shared/channel.interface'; import { Playlist } from '../../../shared/playlist.interface'; import { EpgProgram } from '../player/models/epg-program.model'; +import { PlaylistMeta } from '../shared/playlist-meta.type'; -const STORE_KEY = '[PLAYLIST STORE]'; +const STORE_KEY = '[GLOBAL STORE]'; +const PLAYLISTS_STORE_KEY = '[PLAYLISTS STORE]'; + +export const loadPlaylists = createAction( + `${PLAYLISTS_STORE_KEY} Load playlists from db` +); + +export const loadPlaylistsSuccess = createAction( + `${PLAYLISTS_STORE_KEY} Successfully loaded playlists from db`, + props<{ playlists: PlaylistMeta[] }>() +); + +export const addPlaylist = createAction( + `${PLAYLISTS_STORE_KEY} Add new playlist`, + props<{ playlist: Playlist }>() +); + +export const addManyPlaylists = createAction( + `${PLAYLISTS_STORE_KEY} Add many playlists`, + props<{ playlists: Playlist[] }>() +); + +export const removePlaylist = createAction( + `${PLAYLISTS_STORE_KEY} Remove playlist by id`, + props<{ playlistId: string }>() +); + +export const updatePlaylistMeta = createAction( + `${PLAYLISTS_STORE_KEY} update playlist meta`, + props<{ playlist: PlaylistMeta }>() +); + +export const updatePlaylist = createAction( + `${PLAYLISTS_STORE_KEY} update playlist`, + 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<{ + uploadType: 'FILE' | 'URL' | 'TEXT'; + playlist: string; + title: string; + path?: string; + }>() +); + +export const setActivePlaylist = createAction( + `${PLAYLISTS_STORE_KEY} set active playlist`, + props<{ playlistId: string }>() +); export const updateFavorites = createAction( `${STORE_KEY} Add/remove provided channel to the favorites`, @@ -15,6 +71,10 @@ export const setActiveChannel = createAction( props<{ channel: Channel }>() ); +export const resetActiveChannel = createAction( + `${STORE_KEY} Reset active channel` +); + export const setActiveChannelSuccess = createAction( `${STORE_KEY} Set active channel success`, props<{ channel: Channel }>() @@ -39,7 +99,14 @@ export const setEpgAvailableFlag = createAction( props<{ value: boolean }>() ); -export const setPlaylist = createAction( - `${STORE_KEY} Set playlist as active`, - props<{ playlist: Playlist }>() +export const setChannels = createAction( + `${STORE_KEY} Set channels`, + props<{ channels: Channel[] }>() +); + +export const updatePlaylistPositions = createAction( + `${STORE_KEY} Update playlist positions`, + props<{ + positionUpdates: { id: string; changes: { position: number } }[]; + }>() ); diff --git a/src/app/state/effects.ts b/src/app/state/effects.ts index a15513d33..6e02750af 100644 --- a/src/app/state/effects.ts +++ b/src/app/state/effects.ts @@ -1,19 +1,20 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; -import { combineLatestWith, map } from 'rxjs/operators'; +import { combineLatestWith, map, switchMap, tap } from 'rxjs/operators'; import { CHANNEL_SET_USER_AGENT, EPG_GET_PROGRAM, - PLAYLIST_UPDATE_FAVORITES, } from '../../../shared/ipc-commands'; import { DataService } from '../services/data.service'; +import { PlaylistsService } from '../services/playlists.service'; import * as PlaylistActions from './actions'; import { + selectActivePlaylistId, selectFavorites, selectIsEpgAvailable, - selectPlaylistId, } from './selectors'; @Injectable({ providedIn: 'any' }) @@ -24,14 +25,11 @@ export class PlaylistEffects { ofType(PlaylistActions.updateFavorites), combineLatestWith( this.store.select(selectFavorites), - this.store.select(selectPlaylistId) + this.store.select(selectActivePlaylistId) ), - map(([action, favorites, playlistId]) => { - this.dataService.sendIpcEvent(PLAYLIST_UPDATE_FAVORITES, { - id: playlistId, - favorites, - }); - }) + switchMap(([action, favorites, playlistId]) => + this.playlistsService.updateFavorites(playlistId, favorites) + ) ); }, { dispatch: false } @@ -61,9 +59,137 @@ export class PlaylistEffects { ); }); + loadPlaylists$ = createEffect(() => { + return this.actions$.pipe( + ofType(PlaylistActions.loadPlaylists), + switchMap(() => + this.playlistsService.getAllPlaylists().pipe( + map((playlists) => + PlaylistActions.loadPlaylistsSuccess({ + playlists, + }) + ) + ) + ) + ); + }); + + removePlaylist$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.removePlaylist), + switchMap((action) => + this.playlistsService.deletePlaylist(action.playlistId) + ) + ); + }, + { dispatch: false } + ); + + updatePlaylist$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.updatePlaylist), + switchMap((action) => + this.playlistsService.updatePlaylist(action.playlistId, { + ...action.playlist, + _id: action.playlistId, + }) + ) + ); + }, + { dispatch: false } + ); + + parsePlaylist$ = createEffect(() => { + return this.actions$.pipe( + ofType(PlaylistActions.parsePlaylist), + map((action) => + this.playlistsService.handlePlaylistParsing( + action.uploadType, + action.playlist, + action.title, + action.path + ) + ), + map((playlist) => + PlaylistActions.addPlaylist({ + playlist, + }) + ) + ); + }); + + addPlaylist$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.addPlaylist), + switchMap((action) => + this.playlistsService.addPlaylist(action.playlist) + ), + tap((playlist) => { + this.router.navigate(['/playlists/', playlist._id]); + }) + ); + }, + { dispatch: false } + ); + + updatePlaylistMeta$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.updatePlaylistMeta), + switchMap((action) => + this.playlistsService.updatePlaylistMeta(action.playlist) + ) + ); + }, + { dispatch: false } + ); + + updatePlaylistPositions$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.updatePlaylistPositions), + switchMap((action) => + this.playlistsService.updatePlaylistPositions( + action.positionUpdates + ) + ) + ); + }, + { dispatch: false } + ); + + addManyPlaylists$ = createEffect( + () => { + return this.actions$.pipe( + ofType(PlaylistActions.addManyPlaylists), + switchMap((action) => + this.playlistsService.addManyPlaylists(action.playlists) + ) + ); + }, + { 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, private dataService: DataService, - private store: Store + private store: Store, + private router: Router ) {} } diff --git a/src/app/state/playlists.state.ts b/src/app/state/playlists.state.ts new file mode 100644 index 000000000..bc5c80a9c --- /dev/null +++ b/src/app/state/playlists.state.ts @@ -0,0 +1,26 @@ +import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity'; +import { Playlist } from '../../../shared/playlist.interface'; +import { PlaylistMeta } from '../shared/playlist-meta.type'; + +export const playlistsAdapter: EntityAdapter = + createEntityAdapter({ + selectId: (item: Playlist) => item._id, + }); + +export interface PlaylistMetaState extends EntityState { + selectedId: string; + allPlaylistsLoaded: boolean; +} + +export const initialPlaylistMetaState: PlaylistMetaState = + playlistsAdapter.getInitialState({ + selectedId: '', + allPlaylistsLoaded: false, + }); + +export const { + selectIds, + selectEntities: getPlaylistMetaEntities, + selectAll: getAllPlaylistsMeta, + selectTotal, +} = playlistsAdapter.getSelectors(); diff --git a/src/app/state/reducers.ts b/src/app/state/reducers.ts index db592ea0a..b4c76a26f 100644 --- a/src/app/state/reducers.ts +++ b/src/app/state/reducers.ts @@ -1,7 +1,7 @@ import { createReducer, on } from '@ngrx/store'; import * as moment from 'moment'; -import { createChannel } from '../shared/channel.model'; import * as PlaylistActions from './actions'; +import { playlistsAdapter } from './playlists.state'; import { initialState, PlaylistState } from './state'; export const playlistReducer = createReducer( @@ -33,6 +33,12 @@ export const playlistReducer = createReducer( }; } ), + on(PlaylistActions.resetActiveChannel, (state): PlaylistState => { + return { + ...state, + active: undefined, + }; + }), on( PlaylistActions.setCurrentEpgProgram, (state, action): PlaylistState => ({ @@ -47,39 +53,153 @@ export const playlistReducer = createReducer( epgAvailable: action.value, }) ), - on(PlaylistActions.setPlaylist, (state, action): PlaylistState => { - const { playlist } = action; - - return { - ...state, - channels: playlist?.playlist.items.map((element) => - createChannel(element) - ), - favorites: playlist?.favorites || [], - active: undefined, - playlistId: playlist._id, - playlistFilename: playlist.title || playlist.filename, - }; - }), on(PlaylistActions.updateFavorites, (state, action): PlaylistState => { let favorites; const { channel } = action; - if (state.favorites.includes(channel.id)) { - favorites = [...state.favorites.filter((id) => id !== channel.id)]; + const playlistFavorites = + state.playlists.entities[state.playlists.selectedId].favorites; + if (playlistFavorites.includes(channel.id)) { + favorites = [ + ...playlistFavorites.filter((id) => id !== channel.id), + ]; } else { - favorites = [...state.favorites, channel.id]; + favorites = [...playlistFavorites, channel.id]; } - return { ...state, favorites }; + return { + ...state, + playlists: { + ...state.playlists, + entities: { + ...state.playlists.entities, + [state.playlists.selectedId]: { + ...state.playlists.entities[state.playlists.selectedId], + favorites, + }, + }, + }, + }; + }), + on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.addMany(action.playlists, { + ...state.playlists, + allPlaylistsLoaded: true, + }), + }; + }), + on(PlaylistActions.removePlaylist, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.removeOne( + action.playlistId, + state.playlists + ), + }; + }), + on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.updateOne( + { + id: action.playlistId, + changes: { + ...action.playlist, + _id: action.playlistId, + updateDate: Date.now(), + favorites: [], + count: action.playlist.playlist.items.length, + }, + }, + state.playlists + ), + }; + }), + on(PlaylistActions.addPlaylist, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.addOne( + action.playlist, + state.playlists + ), + }; + }), + on(PlaylistActions.addManyPlaylists, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.addMany( + action.playlists, + state.playlists + ), + }; + }), + on(PlaylistActions.setActivePlaylist, (state, action): PlaylistState => { + return { + ...state, + playlists: { + ...state.playlists, + selectedId: action.playlistId, + }, + }; + }), + on( + PlaylistActions.updatePlaylistPositions, + (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.updateMany( + action.positionUpdates, + state.playlists + ), + }; + } + ), + on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.updateOne( + { + id: action.playlist._id, + changes: { + title: action.playlist.title, + autoRefresh: action.playlist.autoRefresh || false, + }, + }, + state.playlists + ), + }; + }), + on(PlaylistActions.setChannels, (state, action): PlaylistState => { + return { + ...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 + ), + }; }) ); -export const selectFavorites = (state: PlaylistState) => state.favorites; -export const selectPlaylistId = (state: PlaylistState) => state.playlistId; -export const selectPlaylistFilename = (state: PlaylistState) => - state.playlistFilename; export const selectIsEpgAvailable = (state: PlaylistState) => state.epgAvailable; export const selectActive = (state: PlaylistState) => state.active; export const selectCurrentEpgProgram = (state: PlaylistState) => state.currentEpgProgram; export const selectChannels = (state: PlaylistState) => state.channels; +export const selectPlaylists = (state: PlaylistState) => state.playlists; +export const selectPlaylistId = (state: PlaylistState) => + state.playlists?.selectedId; +export const selectPlaylistsLoadingFlag = (state: PlaylistState) => + state.playlists?.allPlaylistsLoaded; diff --git a/src/app/state/selectors.ts b/src/app/state/selectors.ts index f9280915d..ac7d12db4 100644 --- a/src/app/state/selectors.ts +++ b/src/app/state/selectors.ts @@ -1,30 +1,16 @@ import { createFeatureSelector, createSelector } from '@ngrx/store'; +import * as fromPlaylistMetaState from './playlists.state'; import * as fromPlaylistState from './reducers'; import { PlaylistState } from './state'; export const selectPlaylistState = createFeatureSelector('playlistState'); -export const selectFavorites = createSelector( - selectPlaylistState, - fromPlaylistState.selectFavorites -); - -export const selectPlaylistId = createSelector( - selectPlaylistState, - fromPlaylistState.selectPlaylistId -); - export const selectIsEpgAvailable = createSelector( selectPlaylistState, fromPlaylistState.selectIsEpgAvailable ); -export const selectPlaylistFilename = createSelector( - selectPlaylistState, - fromPlaylistState.selectPlaylistFilename -); - export const selectActive = createSelector( selectPlaylistState, fromPlaylistState.selectActive @@ -39,3 +25,72 @@ export const selectChannels = createSelector( selectPlaylistState, fromPlaylistState.selectChannels ); + +export const selectPlaylistsLoadingFlag = createSelector( + selectPlaylistState, + fromPlaylistState.selectPlaylistsLoadingFlag +); + +/** Playlist entity store selectors */ +export const selectPlaylistsMetaState = createSelector( + selectPlaylistState, + fromPlaylistState.selectPlaylists +); + +export const selectAllPlaylistsMeta = createSelector( + selectPlaylistsMetaState, + fromPlaylistMetaState.getAllPlaylistsMeta +); + +export const selectPlaylistEntity = (id: string) => + createSelector( + selectPlaylistsMetaState, + fromPlaylistMetaState.getPlaylistMetaEntities, + (data) => { + return data.entities[id]; + } + ); + +export const selectActivePlaylistId = createSelector( + selectPlaylistsMetaState, + fromPlaylistMetaState.getPlaylistMetaEntities, + (data) => data.selectedId +); + +export const selectPlaylistTitle = createSelector( + selectPlaylistsMetaState, + fromPlaylistMetaState.getPlaylistMetaEntities, + fromPlaylistState.selectPlaylistId, + (data) => { + if ( + data.entities && + data.selectedId && + data.entities[data.selectedId] + ) { + return ( + data.entities[data.selectedId].title || + data.entities[data.selectedId].filename + ); + } else return 'Untitled playlist'; + } +); + +export const selectPlaylistEntities = createSelector( + selectPlaylistsMetaState, + fromPlaylistMetaState.getPlaylistMetaEntities +); + +export const selectFavorites = createSelector( + selectPlaylistsMetaState, + fromPlaylistMetaState.getPlaylistMetaEntities, + fromPlaylistState.selectPlaylistId, + (data) => { + if ( + data.entities && + data.selectedId && + data.entities[data.selectedId] + ) { + return data.entities[data.selectedId].favorites || []; + } else return []; + } +); diff --git a/src/app/state/state.ts b/src/app/state/state.ts index 4e06b6bf9..cbca3d34c 100644 --- a/src/app/state/state.ts +++ b/src/app/state/state.ts @@ -1,22 +1,20 @@ import { Channel } from '../../../shared/channel.interface'; +import { initialPlaylistMetaState, PlaylistMetaState } from './playlists.state'; + import { EpgProgram } from '../player/models/epg-program.model'; export interface PlaylistState { active: Channel | undefined; currentEpgProgram: EpgProgram | undefined; epgAvailable: boolean; - favorites: string[]; - playlistId: string; - playlistFilename: string; channels: Channel[]; // TODO: use entity store + playlists: PlaylistMetaState; } export const initialState: PlaylistState = { active: undefined, currentEpgProgram: undefined, epgAvailable: false, - favorites: [], - playlistId: '', - playlistFilename: '', channels: [], + playlists: initialPlaylistMetaState, }; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index ebef663d5..937f36405 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -42,7 +42,9 @@ "REFRESH": "Refresh playlist", "GLOBAL_FAVORITES": "Global favorites", "GLOBAL_FAVORITES_DESCRIPTION": "Auto-generated playlist with aggregated favorites from all playlists", - "MY_PLAYLISTS": "My playlists" + "MY_PLAYLISTS": "My playlists", + "MY_PLAYLISTS_SUBTITLE": "all available playlists", + "PLAYLIST_UPDATE_SUCCESS": "Success! The playlist was successfully updated." }, "FILE_UPLOAD": { "DRAG_DROP": "Drag & drop files here", @@ -102,7 +104,8 @@ "FAVORITES": "Favorites", "NO_FAVORITES": "No favorites yet", "USE_STAR_TO_FAVORITE": "Please use the 'star' icon to add channels to this list.", - "REMOVE_FAVORITE": "Remove from favorites" + "REMOVE_FAVORITE": "Remove from favorites", + "FAVORITES_UPDATED": "Favorites were updated!" }, "TOP_MENU": { "OPEN_CHANNELS_LIST": "Open channel list",