From 585d0914d9b2429d1f08d3d9af33f2566a0cbd61 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:14:10 +0100 Subject: [PATCH] refactor: use indexed-db as playlist storage --- electron/api.ts | 566 ++++++------------ electron/epg-worker.ts | 5 +- electron/main.ts | 2 +- shared/ipc-commands.ts | 17 +- shared/playlist.utils.ts | 47 +- src/app/app.component.ts | 5 +- src/app/home/home.component.ts | 52 +- .../playlist-info/playlist-info.component.ts | 32 +- .../recent-playlists.component.html | 78 ++- .../recent-playlists.component.scss | 16 + .../recent-playlists.component.spec.ts | 2 +- .../recent-playlists.component.ts | 143 +++-- .../channel-list-container.component.ts | 44 +- .../video-player/video-player.component.html | 123 +--- .../video-player/video-player.component.scss | 49 -- .../video-player/video-player.component.ts | 123 +--- src/app/services/playlists.service.ts | 170 ++++++ src/app/services/pwa.service.ts | 322 +--------- 18 files changed, 681 insertions(+), 1115 deletions(-) create mode 100644 src/app/services/playlists.service.ts diff --git a/electron/api.ts b/electron/api.ts index 098f2d04e..7a488c2a0 100644 --- a/electron/api.ts +++ b/electron/api.ts @@ -1,9 +1,9 @@ import axios from 'axios'; import { app, BrowserWindow, ipcMain, session } from 'electron'; import { parse } from 'iptv-playlist-parser'; -import { GLOBAL_FAVORITES_PLAYLIST_ID } from '../shared/constants'; import { CHANNEL_SET_USER_AGENT, + DELETE_ALL_PLAYLISTS, EPG_ERROR, EPG_FETCH, EPG_FETCH_DONE, @@ -15,39 +15,35 @@ 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 { 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 +51,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 +65,113 @@ 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(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,111 +212,14 @@ 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 - */ - sendPlaylistWithGlobalFavorites(event: Electron.IpcMainEvent) { - db.find({ type: { $exists: false } }).then((playlists: Playlist[]) => { - const favoriteChannels = aggregateFavoriteChannels(playlists); - const favPlaylist = createFavoritesPlaylist(favoriteChannels); - - event.sender.send(PLAYLIST_PARSE_RESPONSE, { - type: PLAYLIST_PARSE_RESPONSE, - payload: favPlaylist, - }); - }); - } - - /** - * Set default listeners for custom-titlebar - */ - setTitleBarListeners() { - ipcMain.on('window-minimize', function (event) { - BrowserWindow.fromWebContents(event.sender).minimize(); - }); - - ipcMain.on('window-maximize', function (event) { - const window = BrowserWindow.fromWebContents(event.sender); - window.isMaximized() ? window.unmaximize() : window.maximize(); - }); - - ipcMain.on('window-close', function (event) { - BrowserWindow.fromWebContents(event.sender).close(); - }); - - ipcMain.on('window-is-maximized', function (event) { - event.returnValue = BrowserWindow.fromWebContents( - event.sender - ).isMaximized(); - }); + // this.refreshPlaylists(); } /** * Starts the update process for all the playlists with the enabled auto-refresh flag */ - refreshPlaylists(): void { + // TODO: implement the same mechanism for self-hosted PWAs (ignore vercel instance) + /* refreshPlaylists(): void { this.getAllPlaylistsMeta().then((playlists) => { playlists.forEach((playlist) => { if (playlist.autoRefresh && playlist.autoRefresh === true) { @@ -358,46 +236,7 @@ export class Api { } }); }); - } - - /** - * 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 @@ -412,6 +251,7 @@ export class Api { 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 }); } @@ -438,92 +278,33 @@ export class Api { 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, + handlePlaylistRefresh( + args: { id: string; title: string; filePath?: string; url?: 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) { + const parsedPlaylist: ParsedPlaylist = + this.parsePlaylist(playlistString); + const playlist = createPlaylistObject( + args.title, + parsedPlaylist, + args.url ? args.url : args.filePath, + args.url ? 'URL' : 'FILE' + ); event.sender.send(PLAYLIST_UPDATE_RESPONSE, { - message: `Success! The playlist was successfully updated (${playlist.items.length} channels)`, + message: `Success! The playlist was successfully updated (${parsedPlaylist.items.length} channels)`, + playlist: { + ...playlist, + _id: args.id, + }, }); - - // send all playlists back to the renderer process - this.sendAllPlaylists(event); } } @@ -534,24 +315,21 @@ 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 }) + .get(args.url, { httpsAgent: agent }) .then((result) => - this.handlePlaylistRefresh(id, result.data, event) + this.handlePlaylistRefresh(args, result.data, event) ); } 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, }); } } @@ -563,30 +341,25 @@ export class Api { * @param event ipc event to send the response back to the renderer */ fetchPlaylistByFilePath( - id: string, - path: string, + args: { id: string; title: string; filePath?: string }, event?: Electron.IpcMainEvent ): void { + if (!args.filePath) return; try { - fs.readFile(path, 'utf-8', (err, data) => { + fs.readFile(args.filePath, 'utf-8', (err, data) => { if (err) { - this.handleFileNotFoundError(err, id, event); + this.handleFileNotFoundError(err, event); return; } - this.handlePlaylistRefresh(id, data, event); + this.handlePlaylistRefresh(args, data, event); }); } catch (err) { - this.handleFileNotFoundError(err, id, event); + this.handleFileNotFoundError(err, event); } } - /** - * 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 +367,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 +378,35 @@ 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 */ + removeAllPlaylists(event: Electron.IpcMainEvent) { + fs.unlink(dbPath, (err) => { + if (err && err.code == 'ENOENT') { + // file doesn't exist + console.info("File doesn't exist, won't remove it."); + } else if (err) { + console.error('Error occurred while trying to remove file'); + } else { + console.log(`${dbPath} was deleted`); + 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/electron/main.ts b/electron/main.ts index ea8ac697a..d91b7d552 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -99,7 +99,7 @@ function registerGlobalShortcuts() { */ function createEpgWorkerWindow() { const window = new BrowserWindow({ - show: false, + show: true, webPreferences: { nodeIntegration: true, contextIsolation: false, diff --git a/shared/ipc-commands.ts b/shared/ipc-commands.ts index 612ecf4f5..7663372f2 100644 --- a/shared/ipc-commands.ts +++ b/shared/ipc-commands.ts @@ -12,20 +12,11 @@ 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 +29,11 @@ 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'; 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.component.ts b/src/app/app.component.ts index 26b06c1c7..30f888777 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,6 +1,7 @@ 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 * as semver from 'semver'; @@ -22,7 +23,7 @@ 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 */ @@ -65,6 +66,7 @@ export class AppComponent { private epgService: EpgService, private ngZone: NgZone, private router: Router, + private store: Store, private snackBar: MatSnackBar, private translate: TranslateService, private settingsService: SettingsService, @@ -95,6 +97,7 @@ export class AppComponent { * Starts all the functions to initialize the component */ ngOnInit(): void { + this.store.dispatch(PlaylistActions.loadPlaylists()); this.translate.setDefaultLang(this.DEFAULT_LANG); this.setRendererListeners(); diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index ee2fec3b1..1936556eb 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -5,15 +5,13 @@ 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 +28,11 @@ 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(); + this.navigateToPlayer(response.payload._id); }, }, { @@ -43,15 +43,6 @@ 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, @@ -103,22 +94,25 @@ 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, - }); + + this.store.dispatch( + parsePlaylist({ + uploadType: 'FILE', + playlist, + title: payload.file.name, + path: payload.file.path, + }) + ); } /** * Navigates to the video player route */ - navigateToPlayer(): void { + navigateToPlayer(playlistId: string): void { this.isLoading = false; - this.router.navigateByUrl('/iptv', { skipLocationChange: true }); + this.router.navigateByUrl(`/playlists/${playlistId}`); } /** @@ -137,11 +131,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.ts b/src/app/home/recent-playlists/playlist-info/playlist-info.component.ts index 64eca239d..ea830c359 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', @@ -53,7 +55,8 @@ export class PlaylistInfoComponent { private datePipe: DatePipe, private formBuilder: UntypedFormBuilder, private electronService: DataService, - @Inject(MAT_DIALOG_DATA) playlist: Playlist + @Inject(MAT_DIALOG_DATA) playlist: Playlist, + private store: Store ) { this.playlist = playlist; } @@ -64,32 +67,29 @@ 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), }); } @@ -97,9 +97,7 @@ export class PlaylistInfoComponent { * 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..830d64748 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.html +++ b/src/app/home/recent-playlists/recent-playlists.component.html @@ -1,30 +1,5 @@ -
-
- -
- - -
-
- -
-
-
- - - + + search
{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}
@@ -54,5 +29,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..5eebdf0b5 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.scss +++ b/src/app/home/recent-playlists/recent-playlists.component.scss @@ -50,3 +50,19 @@ } } } + +#migration-container { + padding: 10px; + border: 1px solid #999; + border-radius: 5px; + margin: 5px; + + p { + margin-left: 15px; + } + + .migration-btn { + text-transform: uppercase; + } + +} \ No newline at end of file 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..7b156b423 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.spec.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.spec.ts @@ -102,7 +102,7 @@ describe('RecentPlaylistsComponent', () => { it('should send an event to the main process to refresh a playlist', () => { const playlistMeta: PlaylistMeta = { - _id: 'iptv1', + id: 'iptv1', filePath: '/home/user/lists/iptv.m3u', } as PlaylistMeta; jest.spyOn(electronService, 'sendIpcEvent'); diff --git a/src/app/home/recent-playlists/recent-playlists.component.ts b/src/app/home/recent-playlists/recent-playlists.component.ts index 19495e9ab..2a64bd592 100644 --- a/src/app/home/recent-playlists/recent-playlists.component.ts +++ b/src/app/home/recent-playlists/recent-playlists.component.ts @@ -1,18 +1,29 @@ 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 { selectAllPlaylistsMeta } from '../../state/selectors'; import { - PLAYLIST_GET_ALL, - PLAYLIST_GET_ALL_RESPONSE, - PLAYLIST_GET_BY_ID, - PLAYLIST_REMOVE_BY_ID, - PLAYLIST_REMOVE_BY_ID_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 +36,71 @@ import { PlaylistInfoComponent } from './playlist-info/playlist-info.component'; styleUrls: ['./recent-playlists.component.scss'], }) export class RecentPlaylistsComponent implements OnDestroy { - /** All available playlists */ - playlists: PlaylistMeta[] = []; - - loading = true; + playlists$ = this.store.select(selectAllPlaylistsMeta).pipe( + // eslint-disable-next-line @ngrx/avoid-mapping-selectors + map((playlists) => playlists.sort((a, b) => a.position - b.position)) + ); /** 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 }) => { + console.log(response.playlist); + this.snackBar.open(response.message, null, { duration: 2000 }); + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlistId: '', + 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 } + ); } ), ]; - 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 +112,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,15 +130,15 @@ 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 }, + })), + }) ); } @@ -130,9 +147,8 @@ export class RecentPlaylistsComponent implements OnDestroy { * @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 +170,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 +180,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 +201,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.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/video-player/video-player.component.html b/src/app/player/components/video-player/video-player.component.html index 6cd802ba6..34fb32b89 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,14 @@ - + - - -
-
- -
-
- - {{ 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.ts b/src/app/player/components/video-player/video-player.component.ts index 627befba0..88038dc66 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,37 @@ 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 { 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'], }) @@ -56,81 +48,55 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { .pipe(filter((channel) => Boolean(channel))); /** 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 }) + PlaylistActions.addPlaylist({ + playlist: response.payload, + }) ); - this.setSidebarView('CHANNELS'); + 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 +105,20 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.applySettings(); this.setRendererListeners(); this.getPlaylistUrlAsParam(); + + this.channels$ = this.activatedRoute.params.pipe( + switchMap((params) => { + this.store.dispatch( + PlaylistActions.setActivePlaylist({ playlistId: params.id }) + ); + return this.playlistsService.getPlaylistChannels(params.id); + }) + ); } /** * Opens a playlist provided as a url param + * e.g. iptvnat.or?url=http://... */ getPlaylistUrlAsParam() { const URL_REGEX = /^(http|https|file):\/\/[^ "]+$/; @@ -161,7 +137,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 +167,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/services/playlists.service.ts b/src/app/services/playlists.service.ts new file mode 100644 index 000000000..621e96367 --- /dev/null +++ b/src/app/services/playlists.service.ts @@ -0,0 +1,170 @@ +/* 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, + }) + ) + ); + } + + // TODO: for-loop + updatePlaylistPosition(id: string, position: number) { + return this.getPlaylistById(id).pipe( + switchMap((playlist) => + this.dbService.update(DbStores.Playlists, { + ...playlist, + position, + }) + ) + ); + } + + 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 + } +} diff --git a/src/app/services/pwa.service.ts b/src/app/services/pwa.service.ts index 9f0b1482f..62883c81c 100644 --- a/src/app/services/pwa.service.ts +++ b/src/app/services/pwa.service.ts @@ -1,62 +1,39 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ 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, of, switchMap, 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); + /** Proxy URL to avoid CORS issues */ + corsProxyUrl = AppConfig.BACKEND_URL; + /** * Creates an instance of PwaService. * @param dbService database service @@ -94,228 +71,39 @@ export class PwaService extends DataService { 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) { + 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', - }) + this.getPlaylistFromUrl(payload.url) .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, - }); + .subscribe((playlist: Playlist) => { + this.store.dispatch( + PlaylistActions.updatePlaylist({ + playlist, + playlistId: payload.id, }) ); - }); + console.log('playlist was updated...'); - combineLatest(requests).subscribe(() => { - console.log('playlist positions were updated...'); - }); + // TODO: show notification + /* window.postMessage({ + type: PLAYLIST_UPDATE_RESPONSE, + message: `Success! The playlist was successfully updated.`, + }); */ + }); } } @@ -324,8 +112,7 @@ export class PwaService extends DataService { * @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 @@ -365,57 +152,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 {