added basic subscriptions support for playlists and channels

update youtube-dl binary on windows

updated favicon to the new icon
pull/22/head
Isaac Grynsztein 6 years ago
parent a755b0b281
commit a70abb3945

@ -1,4 +1,5 @@
var async = require('async');
const { uuid } = require('uuidv4');
var fs = require('fs');
var path = require('path');
var youtubedl = require('youtube-dl');
@ -10,7 +11,9 @@ var archiver = require('archiver');
const low = require('lowdb')
var URL = require('url').URL;
const shortid = require('shortid')
const url_api = require('url');
var config_api = require('./config.js');
var subscriptions_api = require('./subscriptions')
var app = express();
@ -25,7 +28,8 @@ db.defaults(
audio: [],
video: []
},
configWriteFlag: false
configWriteFlag: false,
subscriptions: []
}).write();
// config values
@ -39,6 +43,8 @@ var videoFolderPath = null;
var downloadOnlyMode = null;
var useDefaultDownloadingAgent = null;
var customDownloadingAgent = null;
var allowSubscriptions = null;
var subscriptionsCheckInterval = null;
// other needed values
var options = null; // encryption options
@ -129,6 +135,9 @@ async function loadConfig() {
downloadOnlyMode = config_api.getConfigItem('ytdl_download_only_mode');
useDefaultDownloadingAgent = config_api.getConfigItem('ytdl_use_default_downloading_agent');
customDownloadingAgent = config_api.getConfigItem('ytdl_custom_downloading_agent');
allowSubscriptions = config_api.getConfigItem('ytdl_allow_subscriptions');
subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
if (!useDefaultDownloadingAgent && validDownloadingAgents.indexOf(customDownloadingAgent) !== -1 ) {
console.log(`INFO: Using non-default downloading agent \'${customDownloadingAgent}\'`)
}
@ -149,6 +158,11 @@ async function loadConfig() {
url_domain = new URL(url);
// get subscriptions
if (allowSubscriptions) {
watchSubscriptions();
}
// start the server here
startServer();
@ -157,6 +171,34 @@ async function loadConfig() {
}
function calculateSubcriptionRetrievalDelay(amount) {
// frequency is 5 mins
let frequency_in_ms = subscriptionsCheckInterval * 1000;
let minimum_frequency = 60 * 1000;
const first_frequency = frequency_in_ms/amount;
return (first_frequency < minimum_frequency) ? minimum_frequency : first_frequency;
}
function watchSubscriptions() {
let subscriptions = subscriptions_api.getAllSubscriptions();
let subscriptions_amount = subscriptions.length;
let delay_interval = calculateSubcriptionRetrievalDelay(subscriptions_amount);
let current_delay = 0;
for (let i = 0; i < subscriptions.length; i++) {
let sub = subscriptions[i];
console.log('watching ' + sub.name + ' with delay interval of ' + delay_interval);
setTimeout(() => {
setInterval(() => {
subscriptions_api.getVideosForSub(sub);
}, subscriptionsCheckInterval * 1000);
}, current_delay);
current_delay += delay_interval;
if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0;
}
}
function getOrigin() {
return url_domain.origin;
}
@ -239,9 +281,14 @@ function getJSONMp3(name)
return obj;
}
function getJSONMp4(name)
function getJSONMp4(name, customPath = null)
{
var jsonPath = videoFolderPath+name+".info.json";
let jsonPath = null;
if (!customPath) {
jsonPath = videoFolderPath+name+".info.json";
} else {
jsonPath = customPath + name + ".info.json";
}
if (fs.existsSync(jsonPath))
{
var obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
@ -802,6 +849,109 @@ app.post('/api/getMp4s', function(req, res) {
res.end("yes");
});
app.post('/api/subscribe', async (req, res) => {
let name = req.body.name;
let url = req.body.url;
let timerange = req.body.timerange;
const new_sub = {
name: name,
url: url,
id: uuid()
};
// adds timerange if it exists, otherwise all videos will be downloaded
if (timerange) {
new_sub.timerange = timerange;
}
const result_obj = await subscriptions_api.subscribe(new_sub);
if (result_obj.success) {
res.send({
new_sub: new_sub
});
} else {
res.send({
new_sub: null,
error: result_obj.error
})
}
});
app.post('/api/unsubscribe', async (req, res) => {
let deleteMode = req.body.deleteMode
let sub = req.body.sub;
let result_obj = subscriptions_api.unsubscribe(sub, deleteMode);
if (result_obj.success) {
res.send({
success: result_obj.success
});
} else {
res.send({
success: false,
error: result_obj.error
});
}
});
app.post('/api/getSubscription', async (req, res) => {
let subID = req.body.id;
// get sub from db
let subscription = subscriptions_api.getSubscription(subID);
if (!subscription) {
// failed to get subscription from db, send 400 error
res.sendStatus(400);
return;
}
// get sub videos
let base_path = config_api.getConfigItem('ytdl_subscriptions_base_path');
let appended_base_path = path.join(base_path, subscription.isPlaylist ? 'playlists' : 'channels', subscription.name, '/');
var files = recFindByExt(appended_base_path, 'mp4');
var parsed_files = [];
for (let i = 0; i < files.length; i++) {
let file = files[i];
var file_path = file.substring(appended_base_path.length, file.length);
var id = file_path.substring(0, file_path.length-4);
var jsonobj = getJSONMp4(id, appended_base_path);
if (!jsonobj) continue;
var title = jsonobj.title;
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = false;
var file_obj = new File(id, title, thumbnail, isaudio, duration);
parsed_files.push(file_obj);
}
res.send({
subscription: subscription,
files: parsed_files
});
});
app.post('/api/downloadVideosForSubscription', async (req, res) => {
let subID = req.body.subID;
let sub = subscriptions_api.getSubscription(subID);
subscriptions_api.getVideosForSub(sub);
res.send({
success: true
});
});
app.post('/api/getAllSubscriptions', async (req, res) => {
// get subs from api
let subscriptions = subscriptions_api.getAllSubscriptions();
res.send({
subscriptions: subscriptions
});
});
app.post('/api/createPlaylist', async (req, res) => {
let playlistName = req.body.playlistName;
let fileNames = req.body.fileNames;
@ -948,8 +1098,15 @@ app.post('/api/deleteFile', async (req, res) => {
app.get('/api/video/:id', function(req , res){
var head;
let optionalParams = url_api.parse(req.url,true).query;
let id = decodeURIComponent(req.params.id);
const path = "video/" + id + '.mp4';
let path = "video/" + id + '.mp4';
if (optionalParams['subName']) {
let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const isPlaylist = optionalParams['subPlaylist'];
basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/');
path = basePath + optionalParams['subName'] + '/' + id + '.mp4';
}
const stat = fs.statSync(path)
const fileSize = stat.size
const range = req.headers.range

@ -56,7 +56,10 @@ function setConfigFile(config) {
function getConfigItem(key) {
let config_json = getConfigFile();
if (!CONFIG_ITEMS[key]) console.log('cannot find config with key ' + key);
if (!CONFIG_ITEMS[key]) {
console.log('cannot find config with key ' + key);
return null;
}
let path = CONFIG_ITEMS[key]['path'];
return Object.byString(config_json, path);
};

@ -28,6 +28,12 @@
"default_theme": "default",
"allow_theme_change": true
},
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",

@ -28,6 +28,12 @@
"default_theme": "default",
"allow_theme_change": true
},
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",

@ -56,7 +56,6 @@ let CONFIG_ITEMS = {
'key': 'ytdl_allow_multi_download_mode',
'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode'
},
// API
'ytdl_use_youtube_api': {
@ -78,6 +77,28 @@ let CONFIG_ITEMS = {
'path': 'YoutubeDLMaterial.Themes.allow_theme_change'
},
// Subscriptions
'ytdl_allow_subscriptions': {
'key': 'ytdl_allow_subscriptions',
'path': 'YoutubeDLMaterial.Subscriptions.allow_subscriptions'
},
'ytdl_subscriptions_base_path': {
'key': 'ytdl_subscriptions_base_path',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_base_path'
},
'ytdl_subscriptions_check_interval': {
'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval'
},
'ytdl_subscriptions_check_interval': {
'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval'
},
'ytdl_subscriptions_use_youtubedl_archive': {
'key': 'ytdl_use_youtubedl_archive',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_use_youtubedl_archive'
},
// Advanced
'ytdl_use_default_downloading_agent': {
'key': 'ytdl_use_default_downloading_agent',

@ -26,6 +26,7 @@
"express": "^4.17.1",
"lowdb": "^1.0.0",
"shortid": "^2.2.15",
"uuidv4": "^6.0.6",
"youtube-dl": "^3.0.2"
}
}

@ -0,0 +1,203 @@
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
var fs = require('fs');
const { uuid } = require('uuidv4');
var path = require('path');
var youtubedl = require('youtube-dl');
const config_api = require('./config');
const adapter = new FileSync('db.json');
const db = low(adapter)
let debugMode = process.env.YTDL_MODE === 'debug';
async function subscribe(sub) {
const result_obj = {
success: false,
error: ''
};
return new Promise(async resolve => {
// sub should just have url and name. here we will get isPlaylist and path
sub.isPlaylist = sub.url.includes('playlist');
if (db.get('subscriptions').find({url: sub.url}).value()) {
console.log('Sub already exists');
result_obj.error = 'Subcription with URL ' + sub.url + ' already exists!';
resolve(result_obj);
return;
}
// add sub to db
db.get('subscriptions').push(sub).write();
await getVideosForSub(sub);
result_obj.success = true;
result_obj.sub = sub;
resolve(result_obj);
});
}
async function unsubscribe(sub, deleteMode) {
return new Promise(async resolve => {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let result_obj = { success: false, error: '' };
let id = sub.id;
db.get('subscriptions').remove({id: id}).write();
const appendedBasePath = getAppendedBasePath(sub, basePath);
if (deleteMode && fs.existsSync(appendedBasePath)) {
if (sub.archive && fs.existsSync(sub.archive)) {
const archive_file_path = path.join(sub.archive, 'archive.txt');
// deletes archive if it exists
if (fs.existsSync(archive_file_path)) {
fs.unlinkSync(archive_file_path);
}
fs.rmdirSync(sub.archive);
}
deleteFolderRecursive(appendedBasePath);
}
});
}
async function getVideosForSub(sub) {
return new Promise(resolve => {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive');
const appendedBasePath = basePath + (sub.isPlaylist ? 'playlists/%(playlist_title)s' : 'channels/%(uploader)s');
let downloadConfig = ['-o', appendedBasePath + '/%(title)s.mp4', '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4', '-ciw', '--write-annotations', '--write-thumbnail', '--write-info-json', '--print-json'];
if (sub.timerange) {
downloadConfig.push('--dateafter', sub.timerange);
}
let archive_dir = null;
let archive_path = null;
let usingTempArchive = false;
if (useArchive) {
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
} else {
usingTempArchive = true;
// set temporary archive
archive_dir = basePath + 'archives/' + sub.id;
archive_path = path.join(archive_dir, sub.id + '.txt');
// create temporary dir and archive txt
if (!fs.existsSync(archive_dir)) {
fs.mkdirSync(archive_dir);
fs.closeSync(fs.openSync(archive_path, 'w'));
}
}
downloadConfig.push('--download-archive', archive_path);
}
// get videos
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
if (debugMode) {
console.log('Subscribe: got videos for subscription ' + sub.name);
}
if (err) {
console.log(err.stderr);
resolve(false);
} else if (output) {
if (output.length === 0) {
if (debugMode) console.log('No additional videos to download for ' + sub.name);
}
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
if (!sub.name && output_json) {
sub.name = sub.isPlaylist ? output_json.playlist_title : output_json.uploader;
// if it's now valid, update
if (sub.name) {
db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
}
}
if (usingTempArchive && !sub.archive && sub.name) {
let new_archive_dir = basePath + 'archives/' + sub.name;
// TODO: clean up, code looks ugly
if (fs.existsSync(new_archive_dir)) {
if (fs.existsSync(new_archive_dir + '/archive.txt')) {
console.log('INFO: Archive file already exists. Rewriting archive.');
fs.unlinkSync(new_archive_dir + '/archive.txt')
}
} else {
// creates archive directory for subscription
fs.mkdirSync(new_archive_dir);
}
// moves archive
fs.copyFileSync(archive_path, new_archive_dir + '/archive.txt');
// updates subscription
sub.archive = new_archive_dir;
db.get('subscriptions').find({id: sub.id}).assign({archive: new_archive_dir}).write();
// remove temporary archive directory
fs.unlinkSync(archive_path);
fs.rmdirSync(archive_dir);
}
}
resolve(true);
}
});
});
}
function getAllSubscriptions() {
const subscriptions = db.get('subscriptions').value();
return subscriptions;
}
function getSubscription(subID) {
return db.get('subscriptions').find({id: subID}).value();
}
// helper functions
function getAppendedBasePath(sub, base_path) {
return base_path + (sub.isPlaylist ? 'playlists/' : 'channels/') + sub.name;
}
// https://stackoverflow.com/a/32197381/8088021
const deleteFolderRecursive = function(folder_to_delete) {
if (fs.existsSync(folder_to_delete)) {
fs.readdirSync(folder_to_delete).forEach((file, index) => {
const curPath = path.join(folder_to_delete, file);
if (fs.lstatSync(curPath).isDirectory()) { // recurse
deleteFolderRecursive(curPath);
} else { // delete file
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(folder_to_delete);
}
};
module.exports = {
getSubscription : getSubscription,
getAllSubscriptions: getAllSubscriptions,
subscribe : subscribe,
unsubscribe : unsubscribe,
getVideosForSub : getVideosForSub
}

Binary file not shown.

@ -2,9 +2,13 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { MainComponent } from './main/main.component';
import { PlayerComponent } from './player/player.component';
import { SubscriptionsComponent } from './subscriptions/subscriptions.component';
import { SubscriptionComponent } from './subscription/subscription/subscription.component';
const routes: Routes = [
{ path: 'home', component: MainComponent },
{ path: 'player', component: PlayerComponent},
{ path: 'subscriptions', component: SubscriptionsComponent },
{ path: 'subscription', component: SubscriptionComponent },
{ path: '', redirectTo: '/home', pathMatch: 'full' },
];

@ -1,17 +1,32 @@
<div [style.background]="postsService.theme ? postsService.theme.background_color : null" style="width: 100%; min-height: 100%;">
<mat-toolbar color="primary" class="top">
<div class="flex-row" width="100%" height="100%">
<div class="flex-column" style="text-align: left; margin-top: 1px;">
<button (click)="goBack()" *ngIf="router.url.split(';')[0] === '/player'" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
<div [style.background]="postsService.theme ? postsService.theme.background_color : null" style="width: 100%; height: 100%;">
<div>
<mat-toolbar color="primary" class="top">
<div class="flex-row" width="100%" height="100%">
<div class="flex-column" style="text-align: left; margin-top: 1px;">
<button class="no-outline" *ngIf="router.url.split(';')[0] !== '/player'" mat-icon-button aria-label="Toggle side navigation" (click)="toggleSidenav()"><mat-icon>menu</mat-icon></button>
<button (click)="goBack()" *ngIf="router.url.split(';')[0] === '/player'" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
</div>
<div class="flex-column" style="text-align: center; margin-top: 5px;">
<div>{{topBarTitle}}</div>
</div>
<div class="flex-column" style="text-align: right; align-items: flex-end;">
<button *ngIf="allowThemeChange" mat-icon-button (click)="flipTheme()"><mat-icon>{{(postsService.theme.key === 'default') ? 'brightness_5' : 'brightness_2'}}</mat-icon></button>
</div>
</div>
<div class="flex-column" style="text-align: center; margin-top: 5px;">
<div>{{topBarTitle}}</div>
</div>
<div class="flex-column" style="text-align: right; align-items: flex-end;">
<button *ngIf="allowThemeChange" mat-icon-button (click)="flipTheme()"><mat-icon>{{(postsService.theme.key === 'default') ? 'brightness_5' : 'brightness_2'}}</mat-icon></button>
</div>
</div>
</mat-toolbar>
</mat-toolbar>
</div>
<router-outlet></router-outlet>
<div style="height: calc(100% - 64px)">
<mat-sidenav-container style="height: 100%">
<mat-sidenav #sidenav>
<mat-nav-list>
<a mat-list-item routerLink='/home'>Home</a>
<a mat-list-item routerLink='/subscriptions'>Subscriptions</a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content [style.background]="postsService.theme ? postsService.theme.background_color : null">
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>
</div>
</div>

@ -4,7 +4,7 @@ import {FileCardComponent} from './file-card/file-card.component';
import { Observable } from 'rxjs/Observable';
import {FormControl, Validators} from '@angular/forms';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {MatSnackBar} from '@angular/material';
import {MatSnackBar, MatSidenav} from '@angular/material';
import { saveAs } from 'file-saver';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/mapTo';
@ -15,7 +15,7 @@ import 'rxjs/add/operator/debounceTime'
import 'rxjs/add/operator/do'
import 'rxjs/add/operator/switch'
import { YoutubeSearchService, Result } from './youtube-search.service';
import { Router } from '@angular/router';
import { Router, NavigationStart } from '@angular/router';
import { OverlayContainer } from '@angular/cdk/overlay';
import { THEMES_CONFIG } from '../themes';
@ -34,11 +34,18 @@ export class AppComponent implements OnInit {
defaultTheme = null;
allowThemeChange = null;
@ViewChild('urlinput', { read: ElementRef, static: false }) urlInput: ElementRef;
@ViewChild('sidenav', {static: false}) sidenav: MatSidenav;
navigator: string = null;
constructor(public postsService: PostsService, public snackBar: MatSnackBar,
public router: Router, public overlayContainer: OverlayContainer, private elementRef: ElementRef) {
this.navigator = localStorage.getItem('player_navigator');
// runs on navigate, captures the route that navigated to the player (if needed)
this.router.events.subscribe((e) => { if (e instanceof NavigationStart) {
this.navigator = localStorage.getItem('player_navigator');
} });
// loading config
this.postsService.loadNavItems().subscribe(res => { // loads settings
const result = !this.postsService.debugMode ? res['config_file'] : res;
@ -57,6 +64,10 @@ export class AppComponent implements OnInit {
}
toggleSidenav() {
this.sidenav.toggle();
}
// theme stuff
setTheme(theme) {
@ -115,7 +126,11 @@ onSetTheme(theme, old_theme) {
goBack() {
this.router.navigate(['/home']);
if (!this.navigator) {
this.router.navigate(['/home']);
} else {
this.router.navigateByUrl(this.navigator);
}
}
}

@ -5,7 +5,9 @@ import {MatNativeDateModule, MatRadioModule, MatInputModule, MatButtonModule, Ma
MatProgressBarModule, MatExpansionModule,
MatProgressSpinnerModule,
MatButtonToggleModule,
MatDialogModule} from '@angular/material';
MatDialogModule,
MatRippleModule,
MatMenuModule} from '@angular/material';
import {DragDropModule} from '@angular/cdk/drag-drop';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import { AppComponent } from './app.component';
@ -28,6 +30,11 @@ import { NgxContentLoadingModule } from 'ngx-content-loading';
import { audioFilesMouseHovering, videoFilesMouseHovering, audioFilesOpened, videoFilesOpened } from './main/main.component';
import { CreatePlaylistComponent } from './create-playlist/create-playlist.component';
import { DownloadItemComponent } from './download-item/download-item.component';
import { SubscriptionsComponent } from './subscriptions/subscriptions.component';
import { SubscribeDialogComponent } from './dialogs/subscribe-dialog/subscribe-dialog.component';
import { SubscriptionComponent } from './subscription//subscription/subscription.component';
import { SubscriptionFileCardComponent } from './subscription/subscription-file-card/subscription-file-card.component';
import { SubscriptionInfoDialogComponent } from './dialogs/subscription-info-dialog/subscription-info-dialog.component';
export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps<any>) {
return (element.id === 'video' ? videoFilesMouseHovering || videoFilesOpened : audioFilesMouseHovering || audioFilesOpened);
@ -41,7 +48,12 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
PlayerComponent,
InputDialogComponent,
CreatePlaylistComponent,
DownloadItemComponent
DownloadItemComponent,
SubscriptionsComponent,
SubscribeDialogComponent,
SubscriptionComponent,
SubscriptionFileCardComponent,
SubscriptionInfoDialogComponent
],
imports: [
BrowserModule,
@ -67,6 +79,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
MatProgressBarModule,
MatProgressSpinnerModule,
MatButtonToggleModule,
MatRippleModule,
MatMenuModule,
MatDialogModule,
DragDropModule,
VgCoreModule,
@ -80,7 +94,9 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
],
entryComponents: [
InputDialogComponent,
CreatePlaylistComponent
CreatePlaylistComponent,
SubscribeDialogComponent,
SubscriptionInfoDialogComponent
],
providers: [PostsService],
bootstrap: [AppComponent]

@ -0,0 +1,43 @@
<h4 mat-dialog-title>Subscribe to playlist or channel</h4>
<mat-dialog-content>
<div class="container-fluid">
<div class="row">
<div class="col-12">
<mat-form-field color="accent">
<input [(ngModel)]="url" matInput placeholder="URL" required aria-required="true">
<mat-hint>The playlist or channel URL</mat-hint>
</mat-form-field>
</div>
<div class="col-12">
<mat-form-field color="accent">
<input [(ngModel)]="name" matInput placeholder="Custom name">
<mat-hint>This is optional</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-3">
<mat-checkbox [(ngModel)]="download_all">Download all uploads</mat-checkbox>
</div>
<div class="col-12" *ngIf="!download_all">
Download videos uploaded in the last
<mat-form-field color="accent" style="width: 50px; text-align: center">
<input type="number" matInput [(ngModel)]="timerange_amount">
</mat-form-field>
<mat-select color="accent" class="unit-select" [(ngModel)]="timerange_unit">
<mat-option *ngFor="let time_unit of time_units" [value]="time_unit + (timerange_amount === 1 ? '' : 's')">
{{time_unit + (timerange_amount === 1 ? '' : 's')}}
</mat-option>
</mat-select>
</div>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>Cancel</button>
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
<button mat-button [disabled]="!url" type="submit" (click)="subscribeClicked()">Subscribe</button>
<div class="mat-spinner" *ngIf="subscribing">
<mat-spinner [diameter]="25"></mat-spinner>
</div>
</mat-dialog-actions>

@ -0,0 +1,8 @@
.unit-select {
width: 75px;
margin-left: 20px;
}
.mat-spinner {
margin-left: 5%;
}

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SubscribeDialogComponent } from './subscribe-dialog.component';
describe('SubscribeDialogComponent', () => {
let component: SubscribeDialogComponent;
let fixture: ComponentFixture<SubscribeDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SubscribeDialogComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SubscribeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,69 @@
import { Component, OnInit } from '@angular/core';
import { MatSnackBar, MatDialogRef } from '@angular/material';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-subscribe-dialog',
templateUrl: './subscribe-dialog.component.html',
styleUrls: ['./subscribe-dialog.component.scss']
})
export class SubscribeDialogComponent implements OnInit {
// inputs
timerange_amount;
timerange_unit = 'days';
download_all = true;
url = null;
name = null;
// state
subscribing = false;
time_units = [
'day',
'week',
'month',
'year'
]
constructor(private postsService: PostsService,
private snackBar: MatSnackBar,
public dialogRef: MatDialogRef<SubscribeDialogComponent>) { }
ngOnInit() {
}
subscribeClicked() {
if (this.url && this.url !== '') {
// timerange must be specified if download_all is false
if (!this.download_all && !this.timerange_amount) {
this.openSnackBar('You must specify an amount of time');
return;
}
this.subscribing = true;
let timerange = null;
if (!this.download_all) {
timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit;
}
this.postsService.createSubscription(this.url, this.name, timerange).subscribe(res => {
this.subscribing = false;
if (res['new_sub']) {
this.dialogRef.close(res['new_sub']);
} else {
if (res['error']) {
this.openSnackBar('ERROR: ' + res['error']);
}
this.dialogRef.close();
}
});
}
}
public openSnackBar(message: string, action = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

@ -0,0 +1,10 @@
<h4 mat-dialog-title>{{sub.name}}</h4>
<mat-dialog-content>
<strong>Type:</strong> {{(sub.isPlaylist ? 'Playlist' : 'Channel')}}
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>Close</button>
<button mat-button (click)="unsubscribe()" color="warn">Unsubscribe</button>
</mat-dialog-actions>

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SubscriptionInfoDialogComponent } from './subscription-info-dialog.component';
describe('SubscriptionInfoDialogComponent', () => {
let component: SubscriptionInfoDialogComponent;
let fixture: ComponentFixture<SubscriptionInfoDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SubscriptionInfoDialogComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SubscriptionInfoDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,32 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-subscription-info-dialog',
templateUrl: './subscription-info-dialog.component.html',
styleUrls: ['./subscription-info-dialog.component.scss']
})
export class SubscriptionInfoDialogComponent implements OnInit {
sub = null;
unsubbedEmitter = null;
constructor(public dialogRef: MatDialogRef<SubscriptionInfoDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any, private postsService: PostsService) { }
ngOnInit() {
if (this.data) {
this.sub = this.data.sub;
this.unsubbedEmitter = this.data.unsubbedEmitter;
}
}
unsubscribe() {
this.postsService.unsubscribe(this.sub, true).subscribe(res => {
this.unsubbedEmitter.emit(true);
this.dialogRef.close();
});
}
}

@ -372,6 +372,7 @@ export class MainComponent implements OnInit {
this.downloading_content[type][playlistID] = true;
this.downloadPlaylist(playlist.fileNames, type, playlist.name, playlistID);
} else {
localStorage.setItem('player_navigator', this.router.url);
const fileNames = playlist.fileNames;
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID}]);
}
@ -444,6 +445,7 @@ export class MainComponent implements OnInit {
this.downloadAudioFile(decodeURI(name));
}
} else {
localStorage.setItem('player_navigator', this.router.url);
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'audio'}]);
} else {
@ -481,6 +483,7 @@ export class MainComponent implements OnInit {
this.downloadVideoFile(decodeURI(name));
}
} else {
localStorage.setItem('player_navigator', this.router.url);
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'video'}]);
} else {

@ -31,16 +31,19 @@ export class PlayerComponent implements OnInit {
// params
fileNames: string[];
type: string;
id = null; // used for playlists (not subscription)
subscriptionName = null;
subPlaylist = null;
baseStreamPath = null;
audioFolderPath = null;
videoFolderPath = null;
subscriptionFolderPath = null;
innerWidth: number;
downloading = false;
id = null;
@HostListener('window:resize', ['$event'])
onResize(event) {
this.innerWidth = window.innerWidth;
@ -52,6 +55,8 @@ export class PlayerComponent implements OnInit {
this.fileNames = this.route.snapshot.paramMap.get('fileNames').split('|nvr|');
this.type = this.route.snapshot.paramMap.get('type');
this.id = this.route.snapshot.paramMap.get('id');
this.subscriptionName = this.route.snapshot.paramMap.get('subscriptionName');
this.subPlaylist = this.route.snapshot.paramMap.get('subPlaylist');
// loading config
this.postsService.loadNavItems().subscribe(res => { // loads settings
@ -59,6 +64,7 @@ export class PlayerComponent implements OnInit {
this.baseStreamPath = this.postsService.path;
this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio'];
this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video'];
this.subscriptionFolderPath = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_base_path'];
let fileType = null;
@ -66,15 +72,27 @@ export class PlayerComponent implements OnInit {
fileType = 'audio/mp3';
} else if (this.type === 'video') {
fileType = 'video/mp4';
} else if (this.type === 'subscription') {
// only supports mp4 for now
fileType = 'video/mp4';
} else {
// error
console.error('Must have valid file type! Use \'audio\' or \video\'');
console.error('Must have valid file type! Use \'audio\', \'video\', or \'subscription\'.');
}
for (let i = 0; i < this.fileNames.length; i++) {
const fileName = this.fileNames[i];
const baseLocation = (this.type === 'audio') ? this.audioFolderPath : this.videoFolderPath;
const fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName);
let baseLocation = null;
let fullLocation = null;
if (!this.subscriptionName) {
baseLocation = this.type + '/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName);
} else {
// default to video but include subscription name param
baseLocation = 'video/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist;
}
// if it has a slash (meaning it's in a directory), only get the file name for the label
let label = null;
const decodedName = decodeURIComponent(fileName);

@ -136,6 +136,22 @@ export class PostsService {
removePlaylist(playlistID, type) {
return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type});
}
createSubscription(url, name, timerange = null) {
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange})
}
unsubscribe(sub, deleteMode = false) {
return this.http.post(this.path + 'unsubscribe', {sub: sub, deleteMode: deleteMode})
}
getSubscription(id) {
return this.http.post(this.path + 'getSubscription', {id: id});
}
getAllSubscriptions() {
return this.http.post(this.path + 'getAllSubscriptions', {});
}
}

@ -0,0 +1,17 @@
<div style="position: relative; width: fit-content;">
<button [matMenuTriggerFor]="action_menu" class="menuButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #action_menu="matMenu">
<button mat-menu-item><mat-icon>info</mat-icon>Info</button>
<button mat-menu-item><mat-icon>restore</mat-icon>Delete and redownload</button>
<button mat-menu-item><mat-icon>delete_forever</mat-icon>Delete forever</button>
</mat-menu>
<mat-card (click)="goToFile(file.name)" matRipple class="example-card mat-elevation-z6">
<div style="padding:5px">
<div *ngIf="!image_errored && file.thumbnailURL" class="img-div">
<img class="image" (error)="onImgError($event)" [src]="file.thumbnailURL" alt="Thumbnail">
</div>
<span class="max-two-lines"><strong>{{file.title}}</strong></span>
</div>
</mat-card>
</div>

@ -0,0 +1,69 @@
.example-card {
width: 200px;
height: 200px;
padding: 0px;
cursor: pointer;
}
.menuButton {
right: 0px;
top: -1px;
position: absolute;
z-index: 999;
}
/* Coerce the <span> icon container away from display:inline */
.mat-icon-button .mat-button-wrapper {
display: flex;
justify-content: center;
}
.image {
width: 200px;
height: 112.5px;
object-fit: cover;
}
.example-full-width-height {
width: 100%;
height: 100%
}
.centered {
margin: 0 auto;
top: 50%;
left: 50%;
}
.img-div {
max-height: 80px;
padding: 0px;
margin: 32px 0px 0px -5px;
width: calc(100% + 5px + 5px);
}
.max-two-lines {
display: -webkit-box;
display: -moz-box;
max-height: 2.4em;
line-height: 1.2em;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
bottom: 5px;
position: absolute;
}
@media (max-width: 576px){
.example-card {
width: 175px !important;
}
.image {
width: 175px;
}
}

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SubscriptionFileCardComponent } from './subscription-file-card.component';
describe('SubscriptionFileCardComponent', () => {
let component: SubscriptionFileCardComponent;
let fixture: ComponentFixture<SubscriptionFileCardComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SubscriptionFileCardComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SubscriptionFileCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,56 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { MatSnackBar } from '@angular/material';
import { Router } from '@angular/router';
@Component({
selector: 'app-subscription-file-card',
templateUrl: './subscription-file-card.component.html',
styleUrls: ['./subscription-file-card.component.scss']
})
export class SubscriptionFileCardComponent implements OnInit {
image_errored = false;
image_loaded = false;
scrollSubject;
scrollAndLoad;
@Input() file;
@Output() goToFileEmit = new EventEmitter<any>();
constructor(private snackBar: MatSnackBar) {
this.scrollSubject = new Subject();
this.scrollAndLoad = Observable.merge(
Observable.fromEvent(window, 'scroll'),
this.scrollSubject
);
}
ngOnInit() {
}
onImgError(event) {
this.image_errored = true;
}
onHoverResponse() {
this.scrollSubject.next();
}
imageLoaded(loaded) {
this.image_loaded = true;
}
goToFile() {
this.goToFileEmit.emit(this.file.title);
}
public openSnackBar(message: string, action: string) {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

@ -0,0 +1,20 @@
<br/>
<button class="back-button" (click)="goBack()" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
<div style="margin-bottom: 15px;">
<h2 style="text-align: center;" *ngIf="subscription">
{{subscription.name}}
</h2>
</div>
<mat-divider style="width: 80%; margin: 0 auto"></mat-divider>
<br/>
<div *ngIf="subscription">
<h4 style="text-align: center; margin-bottom: 20px;">Videos</h4>
<div class="container">
<div class="row">
<div *ngFor="let file of files" class="col mb-4 sub-file-col">
<app-subscription-file-card (goToFileEmit)="goToFile($event)" [file]="file"></app-subscription-file-card>
</div>
</div>
</div>
</div>

@ -0,0 +1,9 @@
.sub-file-col {
max-width: 240px;
}
.back-button {
float: left;
position: absolute;
left: 15px;
}

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SubscriptionComponent } from './subscription.component';
describe('SubscriptionComponent', () => {
let component: SubscriptionComponent;
let fixture: ComponentFixture<SubscriptionComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SubscriptionComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SubscriptionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,44 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-subscription',
templateUrl: './subscription.component.html',
styleUrls: ['./subscription.component.scss']
})
export class SubscriptionComponent implements OnInit {
id = null;
subscription = null;
files: any[] = null;
constructor(private postsService: PostsService, private route: ActivatedRoute, private router: Router) { }
ngOnInit() {
if (this.route.snapshot.paramMap.get('id')) {
this.id = this.route.snapshot.paramMap.get('id');
this.getSubscription();
}
}
goBack() {
this.router.navigate(['/subscriptions']);
}
getSubscription() {
this.postsService.getSubscription(this.id).subscribe(res => {
this.subscription = res['subscription'];
console.log(res['files']);
this.files = res['files'];
});
}
goToFile(name) {
localStorage.setItem('player_navigator', this.router.url);
this.router.navigate(['/player', {fileNames: name, type: 'subscription', subscriptionName: this.subscription.name,
subPlaylist: this.subscription.isPlaylist}]);
}
}

@ -0,0 +1,54 @@
<br/>
<h2 style="text-align: center; margin-bottom: 15px;">Your subscriptions</h2>
<mat-divider style="width: 80%; margin: 0 auto"></mat-divider>
<br/>
<h4 style="text-align: center;">Channels</h4>
<mat-nav-list class="sub-nav-list">
<mat-list-item *ngFor="let sub of channel_subscriptions">
<a class="a-list-item" matLine (click)="goToSubscription(sub)" href="javascript:void(0)">
<strong *ngIf="sub.name">{{ sub.name }}</strong>
<div *ngIf="!sub.name">
<ngx-content-loading [width]="200" [height]="20">
<svg:g ngx-rect width="200" height="20" y="0" x="0" rx="4" ry="4"></svg:g>
</ngx-content-loading>
</div>
</a>
<button mat-icon-button (click)="showSubInfo(sub)">
<mat-icon>info</mat-icon>
</button>
</mat-list-item>
</mat-nav-list>
<div style="width: 80%; margin: 0 auto; padding-left: 15px;" *ngIf="channel_subscriptions.length === 0 && subscriptions">
<p>You have no channel subscriptions.</p>
</div>
<h4 style="text-align: center;">Playlists</h4>
<mat-nav-list class="sub-nav-list">
<mat-list-item *ngFor="let sub of playlist_subscriptions">
<a class="a-list-item" matLine (click)="goToSubscription(sub)" href="javascript:void(0)">
<strong>{{ sub.name }}</strong>
<div class="content-loading-div" *ngIf="!sub.name">
<ngx-content-loading [primaryColor]="postsService.theme.background_color" [secondaryColor]="postsService.theme.alternate_color" [width]="200" [height]="20">
<svg:g ngx-rect width="200" height="20" y="0" x="0" rx="4" ry="4"></svg:g>
</ngx-content-loading>
</div>
</a>
<button mat-icon-button (click)="showSubInfo(sub)">
<mat-icon>info</mat-icon>
</button>
</mat-list-item>
</mat-nav-list>
<div style="width: 80%; margin: 0 auto; padding-left: 15px;" *ngIf="!playlist_subscriptions && subscriptions">
<p>You have no playlist subscriptions.</p>
</div>
<div style="margin: 0 auto" *ngIf="subscriptions_loading">
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div>
<button class="add-subscription-button" (click)="openSubscribeDialog()" mat-fab><mat-icon>add</mat-icon></button>

@ -0,0 +1,27 @@
.add-subscription-button {
position: fixed;
bottom: 30px;
right: 30px;
}
.subscription-card {
height: 200px;
width: 300px;
}
.content-loading-div {
position: absolute;
width: 200px;
height: 50px;
bottom: -18px;
}
.a-list-item {
height: 48px;
padding-top: 12px !important;
}
.sub-nav-list {
margin: 0 auto;
width: 80%;
}

@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SubscriptionsComponent } from './subscriptions.component';
describe('SubscriptionsComponent', () => {
let component: SubscriptionsComponent;
let fixture: ComponentFixture<SubscriptionsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SubscriptionsComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SubscriptionsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

@ -0,0 +1,93 @@
import { Component, OnInit, EventEmitter } from '@angular/core';
import { MatDialog, MatSnackBar } from '@angular/material';
import { SubscribeDialogComponent } from 'app/dialogs/subscribe-dialog/subscribe-dialog.component';
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { SubscriptionInfoDialogComponent } from 'app/dialogs/subscription-info-dialog/subscription-info-dialog.component';
@Component({
selector: 'app-subscriptions',
templateUrl: './subscriptions.component.html',
styleUrls: ['./subscriptions.component.scss']
})
export class SubscriptionsComponent implements OnInit {
playlist_subscriptions = [];
channel_subscriptions = [];
subscriptions = null;
subscriptions_loading = false;
constructor(private dialog: MatDialog, private postsService: PostsService, private router: Router, private snackBar: MatSnackBar) { }
ngOnInit() {
this.getSubscriptions();
}
getSubscriptions() {
this.subscriptions_loading = true;
this.subscriptions = [];
this.channel_subscriptions = [];
this.playlist_subscriptions = [];
this.postsService.getAllSubscriptions().subscribe(res => {
this.subscriptions_loading = false;
this.subscriptions = res['subscriptions'];
for (let i = 0; i < this.subscriptions.length; i++) {
const sub = this.subscriptions[i];
// parse subscriptions into channels and playlists
if (sub.isPlaylist) {
this.playlist_subscriptions.push(sub);
} else {
this.channel_subscriptions.push(sub);
}
}
});
}
goToSubscription(sub) {
this.router.navigate(['/subscription', {id: sub.id}]);
}
openSubscribeDialog() {
const dialogRef = this.dialog.open(SubscribeDialogComponent, {
maxWidth: 500,
width: '80vw'
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
if (result.isPlaylist) {
this.playlist_subscriptions.push(result);
} else {
this.channel_subscriptions.push(result);
}
}
});
}
showSubInfo(sub) {
const unsubbedEmitter = new EventEmitter<any>();
const dialogRef = this.dialog.open(SubscriptionInfoDialogComponent, {
data: {
sub: sub,
unsubbedEmitter: unsubbedEmitter
}
});
unsubbedEmitter.subscribe(success => {
if (success) {
this.openSnackBar(`${sub.name} successfully deleted!`)
this.getSubscriptions();
}
})
}
// snackbar helper
public openSnackBar(message: string, action = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

@ -28,6 +28,12 @@
"default_theme": "default",
"allow_theme_change": true
},
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@ -2,12 +2,14 @@ const THEMES_CONFIG = {
'default': {
'key': 'default',
'background_color': 'ghostwhite',
'alternate_color': 'gray',
'css_label': 'default-theme',
'social_theme': 'material-light'
},
'dark': {
'key': 'dark',
'background_color': '#757575',
'alternate_color': '#695959',
'css_label': 'dark-theme',
'social_theme': 'material-dark'
},

Loading…
Cancel
Save