mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
Merge pull request #43 from 4gray/feature/refresh-playlists
Feature: Refresh playlists from fs or by url
This commit is contained in:
35 files changed
+3143
-238
No files matched your search
@@ -11,6 +11,7 @@
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"prettier/@typescript-eslint",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking"
|
||||
],
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- electron
|
||||
pull_request:
|
||||
branches:
|
||||
- electron
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
|
||||
[ -n "$CI" ] && exit 0
|
||||
|
||||
npx stylelint "**/*.scss"
|
||||
npx --no-install commitlint --edit
|
||||
npm test -- --coverage
|
||||
@@ -1 +1,4 @@
|
||||
*.yml
|
||||
dist
|
||||
coverage
|
||||
release
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "stylelint-config-standard",
|
||||
"rules": {
|
||||
"indentation": 4,
|
||||
"selector-pseudo-element-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoElements": ["ng-deep"]
|
||||
}
|
||||
],
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreAtRules": ["function", "if", "each", "include", "mixin"]
|
||||
}
|
||||
],
|
||||
"value-keyword-case": null
|
||||
},
|
||||
"plugins": ["stylelint-scss"],
|
||||
"syntax": "scss"
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
os:
|
||||
- linux
|
||||
- osx
|
||||
language: node_js
|
||||
node_js:
|
||||
- '12'
|
||||
- '10'
|
||||
dist: xenial
|
||||
sudo: required
|
||||
services:
|
||||
- xvfb
|
||||
before_script:
|
||||
- export DISPLAY=:99.0
|
||||
install:
|
||||
- npm set progress=false
|
||||
- npm install
|
||||
script:
|
||||
- ng lint
|
||||
- npm run test
|
||||
- npm run e2e
|
||||
- npm run build
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import { parse } from 'iptv-playlist-parser';
|
||||
import axios from 'axios';
|
||||
@@ -11,7 +10,12 @@ import {
|
||||
EPG_FETCH_DONE,
|
||||
EPG_GET_PROGRAM,
|
||||
EPG_GET_PROGRAM_DONE,
|
||||
ERROR,
|
||||
PLAYLIST_SAVE_DETAILS,
|
||||
PLAYLIST_PARSE,
|
||||
PLAYLIST_PARSE_RESPONSE,
|
||||
PLAYLIST_UPDATE,
|
||||
PLAYLIST_UPDATE_RESPONSE,
|
||||
} from './ipc-commands';
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -35,34 +39,40 @@ export class Api {
|
||||
ipcMain.on('parse-playlist-by-url', async (event, args) => {
|
||||
try {
|
||||
await axios.get(args.url).then((result) => {
|
||||
const array = result.data.split('\n');
|
||||
const parsedPlaylist = this.parsePlaylist(array);
|
||||
const parsedPlaylist = this.convertFileStringToPlaylist(
|
||||
result.data
|
||||
);
|
||||
const playlistObject = this.createPlaylistObject(
|
||||
args.title,
|
||||
parsedPlaylist,
|
||||
args.url
|
||||
args.url,
|
||||
'URL'
|
||||
);
|
||||
this.insertToDb(playlistObject);
|
||||
event.sender.send('parse-response', {
|
||||
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
|
||||
payload: playlistObject,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
event.sender.send('error', {
|
||||
event.sender.send(ERROR, {
|
||||
message: err.response.statusText,
|
||||
status: err.response.status,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('parse-playlist', (event, args) => {
|
||||
ipcMain.on(PLAYLIST_PARSE, (event, args) => {
|
||||
const parsedPlaylist = this.parsePlaylist(args.playlist);
|
||||
const playlistObject = this.createPlaylistObject(
|
||||
args.title,
|
||||
parsedPlaylist
|
||||
parsedPlaylist,
|
||||
args.path,
|
||||
'FILE'
|
||||
);
|
||||
this.insertToDb(playlistObject);
|
||||
event.sender.send('parse-response', { payload: playlistObject });
|
||||
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
|
||||
payload: playlistObject,
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('playlists-all', (event) => this.sendAllPlaylists(event));
|
||||
@@ -70,7 +80,7 @@ export class Api {
|
||||
ipcMain.on('playlist-by-id', async (event, args) => {
|
||||
const playlist = await db.findOne({ _id: args.id });
|
||||
this.setUserAgent(playlist.userAgent);
|
||||
event.sender.send('parse-response', {
|
||||
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
|
||||
payload: playlist,
|
||||
});
|
||||
});
|
||||
@@ -86,22 +96,23 @@ export class Api {
|
||||
|
||||
// open playlist from file system
|
||||
ipcMain.on('open-file', (event, args) => {
|
||||
fs.readFile(args.filePath, 'utf-8', (err, data) => {
|
||||
fs.readFile(args.filePath, 'utf-8', (err, data: string) => {
|
||||
if (err) {
|
||||
console.log(
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
'An error ocurred reading the file :' + err.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
const array = (data as string).split('\n');
|
||||
const parsedPlaylist = this.parsePlaylist(array);
|
||||
|
||||
const parsedPlaylist = this.convertFileStringToPlaylist(data);
|
||||
const playlistObject = this.createPlaylistObject(
|
||||
args.fileName,
|
||||
parsedPlaylist
|
||||
parsedPlaylist,
|
||||
args.filePath,
|
||||
'FILE'
|
||||
);
|
||||
this.insertToDb(playlistObject);
|
||||
event.sender.send('parse-response', {
|
||||
event.sender.send(PLAYLIST_PARSE_RESPONSE, {
|
||||
payload: playlistObject,
|
||||
});
|
||||
});
|
||||
@@ -138,13 +149,30 @@ export class Api {
|
||||
ipcMain.on(PLAYLIST_SAVE_DETAILS, async (event, args) => {
|
||||
const updated = await db.update(
|
||||
{ _id: args._id },
|
||||
{ $set: { title: args.title, userAgent: args.userAgent } }
|
||||
{
|
||||
$set: {
|
||||
title: args.title,
|
||||
userAgent: args.userAgent,
|
||||
autoRefresh: args.autoRefresh,
|
||||
},
|
||||
}
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +190,9 @@ export class Api {
|
||||
importDate: 1,
|
||||
userAgent: 1,
|
||||
filename: 1,
|
||||
filePath: 1,
|
||||
autoRefresh: 1,
|
||||
updateDate: 1,
|
||||
}
|
||||
);
|
||||
event.sender.send('playlist-all-result', {
|
||||
@@ -204,9 +235,15 @@ export class Api {
|
||||
* Saves playlist to the localStorage
|
||||
* @param name name of the playlist
|
||||
* @param playlist playlist to save
|
||||
* @param url url of the playlist
|
||||
* @param urlOrPath absolute fs path or url of the playlist
|
||||
* @param uploadType upload type - by file or via an url
|
||||
*/
|
||||
createPlaylistObject(name: string, playlist: any, url?: string): Playlist {
|
||||
createPlaylistObject(
|
||||
name: string,
|
||||
playlist: any,
|
||||
urlOrPath?: string,
|
||||
uploadType?: 'URL' | 'FILE'
|
||||
): Playlist {
|
||||
return {
|
||||
id: guid(),
|
||||
_id: guid(),
|
||||
@@ -223,10 +260,131 @@ export class Api {
|
||||
importDate: new Date().toISOString(),
|
||||
lastUsage: new Date().toISOString(),
|
||||
favorites: [],
|
||||
...(url ? { url } : {}),
|
||||
autoRefresh: false,
|
||||
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
|
||||
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
saveUpdatedPlaylist(
|
||||
id: string,
|
||||
playlist: any
|
||||
): Promise<{
|
||||
numAffected: number;
|
||||
upsert: boolean;
|
||||
}> {
|
||||
return db.update(
|
||||
{ _id: id },
|
||||
{
|
||||
$set: {
|
||||
playlist,
|
||||
count: playlist.items.length,
|
||||
updateDate: Date.now(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the fetched playlist string to the playlist object, updates it in the database and sends the updated playlists array back to the renderer
|
||||
* @param id id of the playlist to update
|
||||
* @param playlistString updated playlist as string
|
||||
* @param event ipc event to send the response back to the renderer
|
||||
*/
|
||||
async handlePlaylistRefresh(
|
||||
id: string,
|
||||
playlistString: any,
|
||||
event: Electron.IpcMainEvent
|
||||
): Promise<void> {
|
||||
const playlist = this.convertFileStringToPlaylist(playlistString);
|
||||
const updated = await this.saveUpdatedPlaylist(id, playlist);
|
||||
if (!updated.numAffected || updated.numAffected === 0) {
|
||||
console.error('Error: Playlist details were not updated');
|
||||
}
|
||||
|
||||
event.sender.send(PLAYLIST_UPDATE_RESPONSE, {
|
||||
message: `Success! The playlist was successfully updated (${playlist.items.length} channels)`,
|
||||
});
|
||||
|
||||
// send all playlists back to the renderer process
|
||||
this.sendAllPlaylists(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the playlist from the given url and triggers the update operation
|
||||
* @param id id of the playlist to update
|
||||
* @param playlistString updated playlist as string
|
||||
* @param event ipc event to send the response back to the renderer
|
||||
*/
|
||||
async fetchPlaylistByUrl(
|
||||
id: string,
|
||||
url: string,
|
||||
event: Electron.IpcMainEvent
|
||||
): Promise<void> {
|
||||
try {
|
||||
await axios
|
||||
.get(url)
|
||||
.then((result) =>
|
||||
this.handlePlaylistRefresh(id, result.data, event)
|
||||
);
|
||||
} catch (err) {
|
||||
event.sender.send(ERROR, {
|
||||
message: `${err.response.statusText}. Please check the entered playlist URL again.`,
|
||||
status: err.response.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the playlist from the given path from the file system and triggers the update operation
|
||||
* @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
|
||||
*/
|
||||
fetchPlaylistByFilePath(
|
||||
id: string,
|
||||
path: string,
|
||||
event: Electron.IpcMainEvent
|
||||
): void {
|
||||
try {
|
||||
fs.readFile(path, 'utf-8', async (err, data) => {
|
||||
if (err) {
|
||||
this.handleFileNotFoundError(err, event);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlePlaylistRefresh(id, data, event);
|
||||
});
|
||||
} catch (err) {
|
||||
this.handleFileNotFoundError(err, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an error message to the renderer process
|
||||
* @param error
|
||||
* @param event
|
||||
*/
|
||||
handleFileNotFoundError(
|
||||
error: {
|
||||
errno: string;
|
||||
code: string;
|
||||
syscall: string;
|
||||
path: string;
|
||||
},
|
||||
event: Electron.IpcMainEvent
|
||||
) {
|
||||
console.error(error);
|
||||
event.sender.send(ERROR, {
|
||||
message: `Sorry, playlist was not found (${error.path})`,
|
||||
status: 'ENOENT',
|
||||
});
|
||||
}
|
||||
|
||||
convertFileStringToPlaylist(m3uString: string): any {
|
||||
return this.parsePlaylist(m3uString.split('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses string based array to playlist object
|
||||
* @param m3uArray m3u playlist as array with strings
|
||||
|
||||
@@ -7,6 +7,11 @@ export const EPG_GET_PROGRAM_DONE = 'EPG:GET_PROGRAM_DONE';
|
||||
|
||||
// Playlist related commands
|
||||
export const PLAYLIST_SAVE_DETAILS = 'PLAYLIST:SAVE_DETAILS';
|
||||
export const PLAYLIST_PARSE = 'PLAYLIST:PARSE_PLAYLIST';
|
||||
export const PLAYLIST_PARSE_RESPONSE = 'PLAYLIST:PARSE_PLAYLIST_RESPONSE';
|
||||
export const PLAYLIST_UPDATE = 'PLAYLIST:UPDATE';
|
||||
export const PLAYLIST_UPDATE_RESPONSE = 'PLAYLIST:UPDATE_RESPONSE';
|
||||
|
||||
// General
|
||||
export const SHOW_WHATS_NEW = 'SHOW_WHATS_NEW';
|
||||
export const ERROR = 'error';
|
||||
Generated
+2680
-86
File diff suppressed because it is too large.
Load diff
+5
-1
@@ -96,9 +96,13 @@
|
||||
"mocha": "7.2.0",
|
||||
"ng-mocks": "11.5.0",
|
||||
"npm-run-all": "4.1.5",
|
||||
"prettier": "2.3.0",
|
||||
"rxjs": "6.6.3",
|
||||
"semantic-release": "17.2.3",
|
||||
"spectron": "12.0.0",
|
||||
"stylelint": "13.13.1",
|
||||
"stylelint-config-standard": "22.0.0",
|
||||
"stylelint-scss": "3.19.0",
|
||||
"ts-node": "8.10.2",
|
||||
"tslib": "2.1.0",
|
||||
"typescript": "4.1.3",
|
||||
@@ -150,4 +154,4 @@
|
||||
"lcov"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
:host {
|
||||
|
||||
}
|
||||
@@ -28,7 +28,6 @@ new Titlebar({
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
})
|
||||
export class AppComponent {
|
||||
/** Visibility flag of the "what is new" modal dialog */
|
||||
@@ -157,9 +156,8 @@ export class AppComponent {
|
||||
this.storage.get('version').subscribe((version) => {
|
||||
const isNewVersion = semver.gt(actualVersion, version || '0.0.0');
|
||||
if (!version || isNewVersion) {
|
||||
this.modals = this.whatsNewService.getModalsByVersion(
|
||||
actualVersion
|
||||
);
|
||||
this.modals =
|
||||
this.whatsNewService.getModalsByVersion(actualVersion);
|
||||
this.setDialogVisibility(true);
|
||||
}
|
||||
this.setVersion(actualVersion);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
|
||||
.drop-container {
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
height: calc(100vh - 230px);
|
||||
height: calc(100vh - 235px);
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
@@ -36,6 +35,9 @@
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
line-height: 35px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.input-file:focus + label,
|
||||
@@ -43,12 +45,6 @@
|
||||
background-color: #202b69;
|
||||
}
|
||||
|
||||
.input-file + label {
|
||||
cursor: pointer;
|
||||
line-height: 35px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 80px;
|
||||
width: 80px;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<app-recent-playlists
|
||||
[playlists]="playlists"
|
||||
(playlistClicked)="getPlaylist($event)"
|
||||
(refreshClicked)="refreshPlaylist($event)"
|
||||
(removeClicked)="removePlaylist($event)"
|
||||
>
|
||||
</app-recent-playlists>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { UploadFile } from 'ngx-uploader';
|
||||
import { PLAYLIST_PARSE } from './../../../electron/ipc-commands';
|
||||
import { PLAYLIST_UPDATE } from './../../../ipc-commands';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MockComponent, MockModule, MockPipe } from 'ng-mocks';
|
||||
import { HomeComponent } from './home.component';
|
||||
import { HomeComponent, PlaylistMeta } from './home.component';
|
||||
import { HeaderComponent } from '../shared/components/header/header.component';
|
||||
import { RecentPlaylistsComponent } from '../home/recent-playlists/recent-playlists.component';
|
||||
import { FileUploadComponent } from '../home/file-upload/file-upload.component';
|
||||
@@ -12,6 +15,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { ElectronService } from '../services/electron.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
class MatSnackBarStub {
|
||||
open(): void {}
|
||||
@@ -34,6 +38,8 @@ export class ElectronServiceStub {
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
let electronService: ElectronService;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -50,7 +56,7 @@ describe('HomeComponent', () => {
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatProgressBarModule),
|
||||
MockModule(MatSnackBarModule),
|
||||
RouterTestingModule,
|
||||
MockModule(RouterTestingModule),
|
||||
],
|
||||
providers: [
|
||||
{ provide: MatSnackBar, useClass: MatSnackBarStub },
|
||||
@@ -62,6 +68,8 @@ describe('HomeComponent', () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
electronService = TestBed.inject(ElectronService);
|
||||
router = TestBed.inject(Router);
|
||||
TestBed.inject(ElectronService);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -69,4 +77,116 @@ describe('HomeComponent', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should send an event to the main process to remove a playlist', () => {
|
||||
const playlistId = '12345';
|
||||
spyOn(electronService.ipcRenderer, 'send');
|
||||
component.removePlaylist(playlistId);
|
||||
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
|
||||
'playlist-remove-by-id',
|
||||
{ id: playlistId }
|
||||
);
|
||||
});
|
||||
|
||||
it('should send an event to the main process to refresh a playlist', () => {
|
||||
const playlistMeta: PlaylistMeta = {
|
||||
_id: 'iptv1',
|
||||
filePath: '/home/user/lists/iptv.m3u',
|
||||
} as PlaylistMeta;
|
||||
spyOn(electronService.ipcRenderer, 'send');
|
||||
component.refreshPlaylist(playlistMeta);
|
||||
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
|
||||
PLAYLIST_UPDATE,
|
||||
{ id: playlistMeta._id, filePath: playlistMeta.filePath }
|
||||
);
|
||||
});
|
||||
|
||||
it('should send an event to the main process to get a playlist', () => {
|
||||
const playlistId = '6789';
|
||||
spyOn(electronService.ipcRenderer, 'send');
|
||||
component.getPlaylist(playlistId);
|
||||
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
|
||||
'playlist-by-id',
|
||||
{
|
||||
id: playlistId,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should send an event to the main process to get a playlist by URL', () => {
|
||||
const playlistTitle = 'playlist.m3u';
|
||||
const playlistUrl = 'http://test.com/' + playlistTitle;
|
||||
spyOn(electronService.ipcRenderer, 'send');
|
||||
component.sendPlaylistsUrl(playlistUrl);
|
||||
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
|
||||
'parse-playlist-by-url',
|
||||
{
|
||||
title: playlistTitle,
|
||||
url: playlistUrl,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should send an event to the main process to parse a playlist', () => {
|
||||
spyOn(electronService.ipcRenderer, 'send');
|
||||
const title = 'my-list.m3u';
|
||||
const path = '/home/user/iptv/' + title;
|
||||
const playlistContent = 'test';
|
||||
const file: UploadFile = {
|
||||
nativeFile: { path },
|
||||
name: title,
|
||||
} as unknown as UploadFile;
|
||||
const uploadEvent: Event = {
|
||||
target: { result: playlistContent },
|
||||
} as unknown as Event;
|
||||
component.handlePlaylist({ file, uploadEvent });
|
||||
expect(electronService.ipcRenderer.send).toHaveBeenCalledWith(
|
||||
PLAYLIST_PARSE,
|
||||
{ title, playlist: [playlistContent], path }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the last last segment from an url', () => {
|
||||
expect(component.getLastUrlSegment('http://example.com')).toEqual(
|
||||
'example.com'
|
||||
);
|
||||
expect(
|
||||
component.getLastUrlSegment('http://example.com/playlist.m3u')
|
||||
).toEqual('playlist.m3u');
|
||||
expect(
|
||||
component.getLastUrlSegment('http://example.com/playlist.m3u/')
|
||||
).toEqual('');
|
||||
});
|
||||
|
||||
it('should set IPC event listeners', () => {
|
||||
spyOn(electronService.ipcRenderer, 'on');
|
||||
component.setRendererListeners();
|
||||
expect(electronService.ipcRenderer.on).toHaveBeenCalledTimes(
|
||||
component.commandsList.length
|
||||
);
|
||||
});
|
||||
|
||||
it('should send notification on file reject', () => {
|
||||
spyOn(component, 'showNotification');
|
||||
component.rejectFile('wrong-file.txt');
|
||||
expect(component.showNotification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should navigate to the player view', () => {
|
||||
spyOn(router, 'navigateByUrl');
|
||||
component.navigateToPlayer();
|
||||
expect(router.navigateByUrl).toHaveBeenCalledTimes(1);
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(
|
||||
'/iptv',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove all ipc listeners on destroy', () => {
|
||||
spyOn(electronService.ipcRenderer, 'removeAllListeners');
|
||||
component.ngOnDestroy();
|
||||
expect(
|
||||
electronService.ipcRenderer.removeAllListeners
|
||||
).toHaveBeenCalledTimes(component.commandsList.length);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Component, NgZone } from '@angular/core';
|
||||
import { UploadFile } from 'ngx-uploader';
|
||||
import { ChannelStore, createChannel } from '../state';
|
||||
@@ -6,11 +5,26 @@ import { Router } from '@angular/router';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Playlist } from './playlist.interface';
|
||||
import { ElectronService } from '../services/electron.service';
|
||||
import {
|
||||
ERROR,
|
||||
PLAYLIST_PARSE,
|
||||
PLAYLIST_PARSE_RESPONSE,
|
||||
PLAYLIST_UPDATE,
|
||||
PLAYLIST_UPDATE_RESPONSE,
|
||||
} from './../../../ipc-commands';
|
||||
|
||||
/** Type to describe meta data of a playlist */
|
||||
export type PlaylistMeta = Pick<
|
||||
Playlist,
|
||||
'count' | 'title' | 'filename' | '_id' | 'url' | 'importDate' | 'userAgent'
|
||||
| 'count'
|
||||
| 'title'
|
||||
| 'filename'
|
||||
| '_id'
|
||||
| 'url'
|
||||
| 'importDate'
|
||||
| 'userAgent'
|
||||
| 'filePath'
|
||||
| 'updateDate'
|
||||
>;
|
||||
|
||||
@Component({
|
||||
@@ -26,7 +40,7 @@ export class HomeComponent {
|
||||
/** IPC Renderer commands list with callbacks */
|
||||
commandsList = [
|
||||
{
|
||||
id: 'parse-response',
|
||||
id: PLAYLIST_PARSE_RESPONSE,
|
||||
execute: (response: any) => this.setPlaylist(response.payload),
|
||||
},
|
||||
{
|
||||
@@ -46,14 +60,19 @@ export class HomeComponent {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'error',
|
||||
id: ERROR,
|
||||
execute: (response: { message: string; status: number }) => {
|
||||
this.isLoading = false;
|
||||
this.showNotification(
|
||||
`Error: ${response.status} ${response.message}. Please check the entered playlist URL again.`
|
||||
`Error: ${response.status} ${response.message}.`
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: PLAYLIST_UPDATE_RESPONSE,
|
||||
execute: (response: { message: string }) =>
|
||||
this.showNotification(response.message),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -110,9 +129,10 @@ export class HomeComponent {
|
||||
this.isLoading = true;
|
||||
const result = (payload.uploadEvent.target as FileReader).result;
|
||||
const array = (result as string).split('\n');
|
||||
this.electronService.ipcRenderer.send('parse-playlist', {
|
||||
this.electronService.ipcRenderer.send(PLAYLIST_PARSE, {
|
||||
title: payload.file.name,
|
||||
playlist: array,
|
||||
path: payload.file.nativeFile.path,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,6 +186,17 @@ export class HomeComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an IPC event with the playlist details to the main process to trigger the refresh operation
|
||||
* @param item playlist to update
|
||||
*/
|
||||
refreshPlaylist(item: PlaylistMeta): void {
|
||||
this.electronService.ipcRenderer.send(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
...(item.url ? { url: item.url } : { filePath: item.filePath }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests playlist by id
|
||||
* @param playlistId playlist id
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { ID } from '@datorama/akita';
|
||||
|
||||
/**
|
||||
* An interface that describe the possible states of the playlist update/refresh process
|
||||
*/
|
||||
export enum PlaylistUpdateState {
|
||||
UPDATED,
|
||||
IN_PROGRESS,
|
||||
NOT_UPDATED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes playlist interface
|
||||
*/
|
||||
@@ -15,4 +24,8 @@ export interface Playlist {
|
||||
count: number;
|
||||
url?: string;
|
||||
userAgent?: string;
|
||||
filePath?: string;
|
||||
autoRefresh: boolean;
|
||||
updateDate?: string;
|
||||
updateState?: PlaylistUpdateState;
|
||||
}
|
||||
Whitespace-only changes.
@@ -15,7 +15,6 @@ import { Playlist } from '../../playlist.interface';
|
||||
@Component({
|
||||
selector: 'app-playlist-info',
|
||||
templateUrl: './playlist-info.component.html',
|
||||
styleUrls: ['./playlist-info.component.scss'],
|
||||
providers: [DatePipe],
|
||||
})
|
||||
export class PlaylistInfoComponent {
|
||||
|
||||
@@ -32,7 +32,20 @@
|
||||
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
|
||||
{{ item.importDate | date }}
|
||||
</ng-container>
|
||||
<ng-container *ngIf="item.updateDate">
|
||||
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
|
||||
{{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }}
|
||||
</ng-container>
|
||||
</div>
|
||||
<button
|
||||
*ngIf="item.url || item.filePath"
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
(click)="$event.stopPropagation(); refreshClicked.emit(item)"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
mat-list-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mat-list-base {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
mat-nav-list {
|
||||
height: calc(100vh - 192px);
|
||||
overflow: auto;
|
||||
@@ -13,4 +15,4 @@ mat-nav-list {
|
||||
font-size: 12px !important;
|
||||
color: #666;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { Playlist } from '../playlist.interface';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
|
||||
import { PlaylistMeta } from './../home.component';
|
||||
@@ -16,12 +15,12 @@ export class RecentPlaylistsComponent {
|
||||
/** Emits on playlist selection */
|
||||
@Output() playlistClicked: EventEmitter<string> = new EventEmitter();
|
||||
|
||||
/** Emits on playlist refresh click */
|
||||
@Output() refreshClicked: EventEmitter<PlaylistMeta> = new EventEmitter();
|
||||
|
||||
/** Emits on playlist remove click */
|
||||
@Output() removeClicked: EventEmitter<string> = new EventEmitter();
|
||||
|
||||
/** Emits on playlist rename click */
|
||||
@Output() renameClicked: EventEmitter<Playlist> = new EventEmitter();
|
||||
|
||||
/**
|
||||
* Creates an instance of the component
|
||||
* @param dialog angular material dialog reference
|
||||
|
||||
@@ -4,4 +4,4 @@ form {
|
||||
|
||||
button {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling';
|
||||
import { MatGridListModule } from '@angular/material/grid-list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
|
||||
@NgModule({
|
||||
exports: [
|
||||
@@ -34,6 +35,7 @@ import { MatDialogModule } from '@angular/material/dialog';
|
||||
ScrollingModule,
|
||||
MatGridListModule,
|
||||
MatTooltipModule,
|
||||
MatCheckboxModule,
|
||||
],
|
||||
})
|
||||
export class MaterialModule {}
|
||||
+5
-6
@@ -2,12 +2,6 @@
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.dark-theme .active {
|
||||
background: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-list-base {
|
||||
padding-top: 0;
|
||||
}
|
||||
@@ -39,9 +33,14 @@
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.dark-theme .active {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.mat-tab-label-content {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mat-expansion-panel {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
@@ -26,4 +26,4 @@
|
||||
.description-icon {
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#video-player {
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,4 @@
|
||||
position: relative !important;
|
||||
width: 100% !important;
|
||||
height: calc(100vh - 85px);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class MatSnackBarStub {
|
||||
open(): void {}
|
||||
}
|
||||
|
||||
class MockRouter {
|
||||
export class MockRouter {
|
||||
navigateByUrl(url: string) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.1em
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
margin-top: 10px
|
||||
}
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -58,7 +58,9 @@
|
||||
"USER_AGENT": "User agent",
|
||||
"SAVE": "Speichern",
|
||||
"ORIGINAL_FILENAME": "Original-Dateinamen"
|
||||
}
|
||||
},
|
||||
"UPDATED": "Aktualisiert am",
|
||||
"REFRESH": "Aktualisieren"
|
||||
},
|
||||
"TABS": {
|
||||
"FILE_UPLOAD": "Aus Dateisystem hochladen",
|
||||
@@ -102,4 +104,4 @@
|
||||
"OPEN_EPG_LIST": "TV-Guide (EPG) öffnen",
|
||||
"TOGGLE_FAVORITE_FLAG": "Als Favoriten hinzufügen/entfernen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,9 @@
|
||||
"SAVE": "Save",
|
||||
"USER_AGENT": "User agent",
|
||||
"ORIGINAL_FILENAME": "Original filename"
|
||||
}
|
||||
},
|
||||
"UPDATED": "Updated",
|
||||
"REFRESH": "Refresh playlist"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Drag & drop files here",
|
||||
@@ -102,4 +104,4 @@
|
||||
"RUSSIAN": "Русский",
|
||||
"GERMAN": "Deutsch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,9 @@
|
||||
"SAVE": "Сохранить",
|
||||
"USER_AGENT": "User agent",
|
||||
"ORIGINAL_FILENAME": "Имя файла при загрузке"
|
||||
}
|
||||
},
|
||||
"UPDATED": "Обновлен",
|
||||
"REFRESH": "Обновить плейлист"
|
||||
},
|
||||
"FILE_UPLOAD": {
|
||||
"DRAG_DROP": "Перетащите файл в эту область",
|
||||
@@ -102,4 +104,4 @@
|
||||
"RUSSIAN": "Русский",
|
||||
"GERMAN": "Deutsch"
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-46
@@ -1,66 +1,25 @@
|
||||
$material-icons-font-path: '~material-icons/iconfont/';
|
||||
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@import '~@angular/material/prebuilt-themes/indigo-pink.css';
|
||||
@import '~material-icons/iconfont/material-icons.scss';
|
||||
@import '~@angular/material/theming';
|
||||
@include mat-core();
|
||||
@import 'themes/';
|
||||
|
||||
@include mat-core();
|
||||
|
||||
body,
|
||||
html {
|
||||
background: #fff;
|
||||
font-family: caption;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mat-toolbar.mat-primary {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
height: 100%;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* CAN (MUST) BE REMOVED ! Sample Global style */
|
||||
.container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.title {
|
||||
color: white;
|
||||
margin: 0;
|
||||
padding: 50px 20px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #fff !important;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
background: #ed3330;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
border: none;
|
||||
transition: all 0.4s ease 0s;
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
color: #ed3330 !important;
|
||||
letter-spacing: 1px;
|
||||
-webkit-box-shadow: 0px 5px 40px -10px rgba(0, 0, 0, 0.57);
|
||||
-moz-box-shadow: 0px 5px 40px -10px rgba(0, 0, 0, 0.57);
|
||||
box-shadow: 5px 40px -10px rgba(0, 0, 0, 0.57);
|
||||
transition: all 0.4s ease 0s;
|
||||
}
|
||||
}
|
||||
.mat-toolbar.mat-primary {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
|
||||
+16
-15
@@ -1,18 +1,18 @@
|
||||
$black: (
|
||||
50: #000000,
|
||||
100: #000000,
|
||||
200: #000000,
|
||||
300: #000000,
|
||||
400: #000000,
|
||||
500: #000000,
|
||||
600: #000000,
|
||||
700: #000000,
|
||||
800: #000000,
|
||||
900: #000000,
|
||||
A100: #000000,
|
||||
A200: #000000,
|
||||
A400: #000000,
|
||||
A700: #000000,
|
||||
50: #000,
|
||||
100: #000,
|
||||
200: #000,
|
||||
300: #000,
|
||||
400: #000,
|
||||
500: #000,
|
||||
600: #000,
|
||||
700: #000,
|
||||
800: #000,
|
||||
900: #000,
|
||||
A100: #000,
|
||||
A200: #000,
|
||||
A400: #000,
|
||||
A700: #000,
|
||||
contrast: (
|
||||
50: $light-primary-text,
|
||||
100: $light-primary-text,
|
||||
@@ -43,6 +43,7 @@ $angular-default-theme: mat-light-theme(
|
||||
),
|
||||
)
|
||||
);
|
||||
@include angular-material-theme($angular-default-theme);
|
||||
|
||||
@import 'dark';
|
||||
|
||||
@include angular-material-theme($angular-default-theme);
|
||||
Reference in new issue
Block a user