refactor: use indexed-db as playlist storage

This commit is contained in:
4gray committed 2023-02-22 14:14:10 +01:00
1 parent 6f3fa14133
commit 585d0914d9
18 files changed
+681 -1115

No files matched your search

+171 -395
View File
@@ -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<Playlist[]>) =>
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<Playlist>
): 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<void> {
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<void> {
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,
});
}
});
}
}
+4 -1
View File
@@ -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);
});
+1 -1
View File
@@ -99,7 +99,7 @@ function registerGlobalShortcuts() {
*/
function createEpgWorkerWindow() {
const window = new BrowserWindow({
show: false,
show: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
+8 -9
View File
@@ -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';
+36 -11
View File
@@ -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<Playlist> {
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 } : {}),
};
};
+4 -1
View File
@@ -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();
+25 -27
View File
@@ -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',
})
);
}
/**
@@ -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<Playlist, '_id' | 'title' | 'userAgent' | 'autoRefresh'>
): void {
this.electronService.sendIpcEvent(PLAYLIST_SAVE_DETAILS, data);
saveChanges(playlist: PlaylistMeta): void {
this.store.dispatch(PlaylistActions.updatePlaylistMeta({ playlist }));
}
}
@@ -1,30 +1,5 @@
<div id="skeleton-container" *ngIf="loading; else playlistsView">
<div class="skeleton-item" *ngFor="let a of [].constructor(10)">
<ngx-skeleton-loader
count="1"
appearance="circle"
></ngx-skeleton-loader>
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
></ngx-skeleton-loader>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
></ngx-skeleton-loader>
</div>
<div class="actions">
<ngx-skeleton-loader
count="2"
appearance="circle"
></ngx-skeleton-loader>
</div>
</div>
</div>
<ng-template #playlistsView>
<mat-nav-list cdkDropList (cdkDropListDropped)="drop($event)">
<ng-container *ngIf="playlists$ | async as playlists; else skeletonView">
<mat-nav-list cdkDropList (cdkDropListDropped)="drop($event, playlists)">
<mat-list-item *ngIf="playlists.length === 0">
<mat-icon class="favorites-icon" mat-list-icon>search</mat-icon>
<div mat-line>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</div>
@@ -54,5 +29,54 @@
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
></app-playlist-item>
<div *ngIf="isMigrationPossible" id="migration-container">
<p>{{ migrationMessage }}</p>
<button
mat-button
(click)="migratePlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>playlist_add</mat-icon>
Migrate playlists</button
>&nbsp;
<button
mat-button
(click)="deleteMigratedPlaylists()"
class="migration-btn"
color="accent"
>
<mat-icon>delete</mat-icon>
Delete all playlists
</button>
</div>
</mat-nav-list>
</ng-container>
<ng-template #skeletonView>
<div id="skeleton-container">
<div class="skeleton-item" *ngFor="let a of [].constructor(10)">
<ngx-skeleton-loader
count="1"
appearance="circle"
></ngx-skeleton-loader>
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
></ngx-skeleton-loader>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
></ngx-skeleton-loader>
</div>
<div class="actions">
<ngx-skeleton-loader
count="2"
appearance="circle"
></ngx-skeleton-loader>
</div>
</div>
</div>
</ng-template>
@@ -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;
}
}
@@ -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');
@@ -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<PlaylistMeta[]> }) => {
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<string>();
/**
* 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<PlaylistMeta[]>): void {
moveItemInArray(
this.playlists,
event.previousIndex,
event.currentIndex
);
this.electronService.sendIpcEvent(
PLAYLIST_UPDATE_POSITIONS,
this.playlists
drop(event: CdkDragDrop<PlaylistMeta[]>, 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);
});
}
}
}
@@ -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 }));
}
@@ -1,117 +1,14 @@
<mat-drawer-container class="main-container" (backdropClick)="close()">
<mat-drawer-container class="main-container">
<!-- sidebar content -->
<mat-drawer
#drawer
mode="side"
opened
(keydown.escape)="close()"
disableClose
>
<ng-container *ngIf="channels$ | async as channels">
<div id="playlists-navigation">
<div class="current-playlist">
<button
mat-icon-button
matTooltip="Back"
(click)="goBack()"
>
<mat-icon>arrow_back_ios</mat-icon>
</button>
<div class="playlist-info">
<div class="name">
<ng-container
*ngIf="
sidebarView === 'CHANNELS';
else allPlaylists
"
>
{{ playlistTitle$ | async }}
</ng-container>
<ng-template #allPlaylists>
{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}
</ng-template>
</div>
<div class="channels-count">
{{
sidebarView === 'CHANNELS'
? channels.length + ' channels'
: 'all available playlists'
}}
</div>
</div>
<button
mat-icon-button
*ngIf="sidebarView === 'CHANNELS'"
routerLink="/"
[matTooltip]="
'CHANNELS.UPLOAD_OR_SELECT_OTHER_PLAYLIST'
| translate
"
>
<mat-icon>playlist_add</mat-icon>
</button>
</div>
</div>
<mat-divider></mat-divider>
<app-channel-list-container
*ngIf="sidebarView === 'CHANNELS'; else playlistsTemplate"
[channelList]="channels"
>
</app-channel-list-container>
<ng-template #playlistsTemplate>
<app-recent-playlists
class="recent-playlists"
[class.electron]="dataService.isElectron"
></app-recent-playlists>
</ng-template>
</ng-container>
<mat-drawer #drawer mode="side" opened disableClose>
<app-sidebar
*ngIf="channels$ | async as channels"
[channels]="channels" />
</mat-drawer>
<mat-drawer-content>
<ng-container *ngIf="activeChannel$ | async as activeChannel">
<!-- toolbar with drawer icon -->
<mat-toolbar color="primary">
<button
mat-icon-button
(click)="drawer.toggle()"
[matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate"
>
<mat-icon>menu</mat-icon>
</button>
<button
*ngIf="(playlistId$ | async) !== 'GLOBAL_FAVORITES'"
mat-icon-button
(click)="addToFavorites(activeChannel)"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{
(favorites$ | async).includes(activeChannel?.id)
? 'star'
: 'star_outline'
}}</mat-icon
>
</button>
{{ activeChannel?.name }}
<div class="spacer"></div>
<button
*ngIf="epgAvailable$ | async"
mat-icon-button
(click)="openMultiEpgView()"
[matTooltip]="'TOP_MENU.OPEN_MULTI_EPG' | translate"
>
<mat-icon>view_list</mat-icon>
</button>
<button
*ngIf="epgAvailable$ | async"
mat-button
(click)="drawerRight.toggle()"
[matTooltip]="'TOP_MENU.OPEN_EPG_LIST' | translate"
>
EPG
</button>
</mat-toolbar>
<app-toolbar (toggleLeftDrawerClicked)="drawer.toggle()" (toggleRightDrawerClicked)="drawerRight.toggle()" [activeChannel]="activeChannel" />
<!-- video.js player -->
<app-vjs-player
@@ -142,13 +39,7 @@
></app-info-overlay>
</mat-drawer-content>
<!-- right sidebar content -->
<mat-drawer
position="end"
#drawerRight
mode="side"
(keydown.escape)="close()"
disableClose
>
<mat-drawer position="end" #drawerRight mode="side" disableClose>
<app-epg-list *ngIf="isElectron"></app-epg-list>
</mat-drawer>
</mat-drawer-container>
@@ -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;
}
}
}
@@ -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<Channel[]>;
/** Current epg program */
epgProgram$ = this.store.select(selectCurrentEpgProgram);
/** Favorites list */
favorites$ = this.store.select(selectFavorites);
/** Selected video player options */
playerSettings: Partial<Settings> = {
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) =>
+170
View File
@@ -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<Playlist>(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<Partial<Playlist>>;
if (id === GLOBAL_FAVORITES_PLAYLIST_ID) {
playlist$ = this.getPlaylistWithGlobalFavorites();
} else {
playlist$ = this.dbService.getByID<Playlist>(
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<Playlist>(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<Playlist>(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
}
}
+31 -291
View File
@@ -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<Playlist>): 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 {