Compare commits

..

33 Commits
v3.3 ... v3.4

Author SHA1 Message Date
Isaac Grynsztein
f92a5549b5 implemented allowSubscriptions in frontend 2020-03-10 02:28:48 -04:00
Isaac Grynsztein
69bf4e1ad5 updated docker version 2020-03-10 02:10:36 -04:00
Isaac Grynsztein
9d1aaf95ed Refactored subscribing process to remove bugs in the old system
images are now deleted from subscription videos when unsubscribing
2020-03-10 02:03:10 -04:00
Isaac Grynsztein
bb925ac0c8 fixed bug where video titles were used instead of IDs for the player component
fixed bug that caused a crash when no subscriptions existed
2020-03-09 01:19:16 -04:00
Isaac Grynsztein
74cda25c63 added search functionality
made subscription file cards more responsive on mobile layouts

removed unused shell code
2020-03-09 00:22:03 -04:00
Isaac Grynsztein
54dcbe452e fixed compiler error 2020-03-08 22:52:29 -04:00
Isaac Grynsztein
946abd2e92 implemented global custom args functionality
fixed bad logic in settings
2020-03-08 22:47:08 -04:00
Isaac Grynsztein
73d4cca615 added new config items to docker compose 2020-03-08 22:30:43 -04:00
Isaac Grynsztein
846dd7e250 Added the ability to download (export) archives from subscriptions 2020-03-08 22:24:59 -04:00
Isaac Grynsztein
6f3e94cf24 hamburger menu button now avoids focus and has no outline
theme change behavior slightly modified to improve accessibility

added hammerjs

settings menu now has minimum width, updated colors, and additional hints
2020-03-08 22:23:50 -04:00
Isaac Grynsztein
3cbb517d64 cleaned up some code
youtube-dl commands are now simulated and displayed in the advanced panel
2020-03-08 22:21:34 -04:00
Isaac Grynsztein
5d945d729b hotfix to readme formatting 2020-03-08 17:27:15 -04:00
Isaac Grynsztein
cda842a6c7 Updated readme to include API info 2020-03-08 17:26:13 -04:00
Isaac Grynsztein
480ed7d000 added new custom args setting 2020-03-08 10:15:24 -04:00
Isaac Grynsztein
881a103051 Added duration of video in subscription file card along with implementations of deleting subscribed videos. Subscribed videos now get reloaded after deletion
sidenav now closes when navigating

Updated subscription info to include more info
2020-03-07 17:00:50 -05:00
Isaac Grynsztein
4172b0b355 fixed bug where in chrome, sometimes the video player would not appear 2020-03-07 13:16:16 -05:00
Isaac Grynsztein
f6b7c41666 fixed router outlet in sidenav
subscription settings implemented
2020-03-06 03:05:51 -05:00
Tzahi12345
3d1874c69b Merge pull request #22 from Tzahi12345/subscribe_to_channel_and_playlist
Adds the ability to subscribe to channels and playlists
2020-03-05 22:59:08 -05:00
Tzahi12345
ccfe7901c9 Merge branch 'master' into subscribe_to_channel_and_playlist 2020-03-05 22:57:57 -05:00
Tzahi12345
17e85196ae Merge pull request #21 from Tzahi12345/settings
Add settings page
2020-03-05 22:48:45 -05:00
Isaac Grynsztein
ae605d5f70 Added ability to set config from settings
theme slide toggle is now in top right menu
2020-03-05 22:38:23 -05:00
Isaac Grynsztein
e57839e8de updated .gitignore 2020-03-05 21:25:29 -05:00
Isaac Grynsztein
09bcac1c14 added settings 2020-03-05 21:24:29 -05:00
Isaac Grynsztein
f5073b83ed subscriptions without names will not have files retrieved any longer 2020-03-05 21:19:36 -05:00
Isaac Grynsztein
41bfc80c4e fixed bug in retrieving videos for subscription when name was not present 2020-03-05 21:18:36 -05:00
Isaac Grynsztein
3d2e138f50 updated chrome extension 2020-03-05 21:17:30 -05:00
Isaac Grynsztein
717f024c42 updated .gitignore 2020-03-05 20:16:23 -05:00
Isaac Grynsztein
a70abb3945 added basic subscriptions support for playlists and channels
update youtube-dl binary on windows

updated favicon to the new icon
2020-03-05 20:14:36 -05:00
Isaac Grynsztein
a755b0b281 fixed bug that prevented custom quality path from working 2020-03-01 16:18:01 -05:00
Isaac Grynsztein
434f6751d0 added releases to repo (will only include latest release) 2020-03-01 16:17:41 -05:00
Isaac Grynsztein
dfecf3645b updated README
renamed chrome extension
2020-03-01 03:07:52 -05:00
Isaac Grynsztein
62a000b631 fixed bug where custom paths failed to stream 2020-03-01 02:31:47 -05:00
Isaac Grynsztein
2673f4ee98 updated README 2020-03-01 00:54:11 -05:00
56 changed files with 2093 additions and 120 deletions

6
.gitignore vendored
View File

@@ -48,5 +48,11 @@ YoutubeDL-Material/node_modules/*
backend/video/*
backend/audio/*
backend/public/*
backend/subscriptions/archives/*
backend/subscriptions/playlists/*
backend/subscriptions/channels/*
backend/db.json
backend/subscriptions/channels/*
backend/subscriptions/playlists/*
backend/subscriptions/archives/*
src/assets/default.json

View File

@@ -34,7 +34,7 @@ sudo apt-get install nodejs youtube-dl
1. First, download the [latest release](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest)!
2. Drag all the files in `youtubedl-material` to an easily accessible directory. Navigate to the `config` folder and edit the `default.json` file. If you're using SSL encryption, look at the `encrypted.json` file for a template.
2. Drag the `youtubedl-material` directory to an easily accessible directory. Navigate to the `config` folder and edit the `default.json` file. If you're using SSL encryption, look at the `encrypted.json` file for a template.
NOTE: If you are intending to use a reverse proxy, this next step is not necessary
3. Port forward the port listed in `default.json`, which defaults to `17442`.
@@ -69,7 +69,7 @@ Here is an explanation for the configuration entries. Check out the [default con
| allow_theme_change | true if you want the icon in the top toolbar that toggles dark mode | true |
| use_default_downloading_agent | true if you want to use youtube-dl's default downloader | true |
| custom_downloading_agent | If not using the default downloader, this is the downloader you want to use | "" |
| allow_advanced_download | true if you want to use the Advanced download options - NOT FULLY IMPLEMENTED | false |
| allow_advanced_download | true if you want to use the Advanced download options | false |
## Deployment
@@ -77,9 +77,9 @@ If you'd like to install YoutubeDL-Material, go to the Installation section. If
To deploy, simply clone the repository, and go into the `youtubedl-material` directory. Type `npm install` and all the dependencies will install. Then type `cd backend` and again type `npm install` to install the dependencies for the backend.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/backend/config`, go back into the `youtubedl-material` directory, and type `ng build --prod`. This will build the app, and put the output files in the `youtubedl-material/dist` folder. Drag those files into the `public` directory in the `backend` folder.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/config`, go back into the `youtubedl-material` directory, and type `ng build --prod`. This will build the app, and put the output files in the `youtubedl-material/dist` folder. Drag those files into the `public` directory in the `backend` folder.
The frontend is now complete. The backend is much easier. Just go into the `backend` folder, and type `nodejs app.js`.
The frontend is now complete. The backend is much easier. Just go into the `youtubedl-material` folder, and type `nodejs app.js`.
Finally, port forward the port specified in the config (defaults to `17442`) and point it to the server's IP address. Make sure the port is also allowed through the server's firewall.
@@ -93,6 +93,20 @@ If you are looking to setup YoutubeDL-Material with Docker, this section is for
4. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 8998" or something similar.
5. Make sure you can connect to the specified URL + port, and if so, you are done!
## API
You can use the internal API on your server to run downloads on your instance without using the frontend. All of the available endpoints can be seen over [here](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/backend/app.js) -- search for '/api/' on the page to find all the endpoints. I will expand on the available endpoints in the future, but for now I'd like to highlight the two most useful ones:
#### Downloading audio files
`curl -XPOST -H "Content-type: application/json" -d '{"url": "<your youtube url>"}' 'http://localhost:17442/api/tomp3'`
Remember to replace `<your video url>` with the actual URL.
#### Downloading video files
`curl -XPOST -H "Content-type: application/json" -d '{"url": "<your youtube url>"}' 'http://localhost:17442/api/tomp4'`
Remember to replace `<your video url>` with the actual URL.
## Contributing
Feel free to submit a pull request! I have no guidelines as of yet, so no need to worry about that.

View File

@@ -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,15 @@ async function loadConfig() {
url_domain = new URL(url);
// get subscriptions
if (allowSubscriptions) {
// runs initially, then runs every ${subscriptionCheckInterval} seconds
watchSubscriptions();
setInterval(() => {
watchSubscriptions();
}, subscriptionsCheckInterval * 1000);
}
// start the server here
startServer();
@@ -157,6 +175,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();
if (!subscriptions) return;
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];
if (debugMode) console.log('watching ' + sub.name + ' with delay interval of ' + delay_interval);
setTimeout(() => {
subscriptions_api.getVideosForSub(sub);
}, current_delay);
current_delay += delay_interval;
if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0;
}
}
function getOrigin() {
return url_domain.origin;
}
@@ -239,9 +285,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'));
@@ -368,10 +419,11 @@ function deleteAudioFile(name) {
});
}
async function deleteVideoFile(name) {
async function deleteVideoFile(name, customPath = null) {
return new Promise(resolve => {
var jsonPath = path.join(videoFolderPath,name+'.info.json');
var videoFilePath = path.join(videoFolderPath,name+'.mp4');
let filePath = customPath ? customPath : videoFolderPath;
var jsonPath = path.join(filePath,name+'.info.json');
var videoFilePath = path.join(filePath,name+'.mp4');
jsonPath = path.join(__dirname, jsonPath);
videoFilePath = path.join(__dirname, videoFilePath);
@@ -510,6 +562,20 @@ app.get('/api/config', function(req, res) {
});
});
app.post('/api/setConfig', function(req, res) {
let new_config_file = req.body.new_config_file;
if (new_config_file && new_config_file['YoutubeDLMaterial']) {
let success = config_api.setConfigFile(new_config_file);
res.send({
success: success
});
} else {
console.log('ERROR: Tried to save invalid config file!')
res.sendStatus(400);
}
});
app.get('/api/using-encryption', function(req, res) {
res.send(usingEncryption);
});
@@ -521,6 +587,7 @@ app.post('/api/tomp3', function(req, res) {
var customQualityConfiguration = req.body.customQualityConfiguration;
var maxBitrate = req.body.maxBitrate;
var globalArgs = config_api.getConfigItem('ytdl_custom_args');
var customArgs = req.body.customArgs;
var customOutput = req.body.customOutput;
var youtubeUsername = req.body.youtubeUsername;
@@ -533,12 +600,6 @@ app.post('/api/tomp3', function(req, res) {
if (customArgs) {
downloadConfig = customArgs.split(' ');
} else {
if (customOutput) {
downloadConfig = ['-o', audioFolderPath + customOutput + '.mp3', '-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
} else {
downloadConfig = ['-o', audioFolderPath + audiopath + ".mp3", '-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
}
if (customQualityConfiguration) {
qualityPath = `-f ${customQualityConfiguration}`;
} else if (maxBitrate) {
@@ -546,6 +607,12 @@ app.post('/api/tomp3', function(req, res) {
qualityPath = `--audio-quality ${maxBitrate}`
}
if (customOutput) {
downloadConfig = ['-o', audioFolderPath + customOutput + '.mp3', '-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
} else {
downloadConfig = ['-o', audioFolderPath + audiopath + ".mp3", '-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
}
if (youtubeUsername && youtubePassword) {
downloadConfig.push('--username', youtubeUsername, '--password', youtubePassword);
}
@@ -557,6 +624,11 @@ app.post('/api/tomp3', function(req, res) {
if (!useDefaultDownloadingAgent && customDownloadingAgent === 'aria2c') {
downloadConfig.splice(0, 0, '--external-downloader', 'aria2c');
}
if (globalArgs && globalArgs !== '') {
// adds global args
downloadConfig = downloadConfig.concat(globalArgs.split(' '));
}
}
youtubedl.exec(url, downloadConfig, {}, function(err, output) {
@@ -607,6 +679,7 @@ app.post('/api/tomp4', function(req, res) {
var date = Date.now();
var path = videoFolderPath;
var videopath = '%(title)s';
var globalArgs = config_api.getConfigItem('ytdl_custom_args');
var customArgs = req.body.customArgs;
var customOutput = req.body.customOutput;
@@ -621,18 +694,18 @@ app.post('/api/tomp4', function(req, res) {
if (customArgs) {
downloadConfig = customArgs.split(' ');
} else {
if (customOutput) {
downloadConfig = ['-o', path + customOutput + ".mp4", '-f', qualityPath, '--write-info-json', '--print-json'];
} else {
downloadConfig = ['-o', path + videopath + ".mp4", '-f', qualityPath, '--write-info-json', '--print-json'];
}
if (customQualityConfiguration) {
qualityPath = customQualityConfiguration;
} else if (selectedHeight && selectedHeight !== '') {
qualityPath = `bestvideo[height=${selectedHeight}]+bestaudio/best[height=${selectedHeight}]`;
}
if (customOutput) {
downloadConfig = ['-o', path + customOutput + ".mp4", '-f', qualityPath, '--write-info-json', '--print-json'];
} else {
downloadConfig = ['-o', path + videopath + ".mp4", '-f', qualityPath, '--write-info-json', '--print-json'];
}
if (youtubeUsername && youtubePassword) {
downloadConfig.push('--username', youtubeUsername, '--password', youtubePassword);
}
@@ -640,6 +713,11 @@ app.post('/api/tomp4', function(req, res) {
if (!useDefaultDownloadingAgent && customDownloadingAgent === 'aria2c') {
downloadConfig.splice(0, 0, '--external-downloader', 'aria2c');
}
if (globalArgs && globalArgs !== '') {
// adds global args
downloadConfig = downloadConfig.concat(globalArgs.split(' '));
}
}
youtubedl.exec(url, downloadConfig, {}, function(err, output) {
@@ -697,7 +775,7 @@ app.post('/api/tomp4', function(req, res) {
// gets the status of the mp3 file that's being downloaded
app.post('/api/fileStatusMp3', function(req, res) {
var name = decodeURI(req.body.name + "");
var name = decodeURIComponent(req.body.name + "");
var exists = "";
var fullpath = audioFolderPath + name + ".mp3";
if (fs.existsSync(fullpath)) {
@@ -719,7 +797,7 @@ app.post('/api/fileStatusMp3', function(req, res) {
// gets the status of the mp4 file that's being downloaded
app.post('/api/fileStatusMp4', function(req, res) {
var name = decodeURI(req.body.name);
var name = decodeURIComponent(req.body.name);
var exists = "";
var fullpath = videoFolderPath + name + ".mp4";
if (fs.existsSync(fullpath)) {
@@ -802,6 +880,141 @@ 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/deleteSubscriptionFile', async (req, res) => {
let deleteForever = req.body.deleteForever;
let file = req.body.file;
let sub = req.body.sub;
let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever);
if (success) {
res.send({
success: success
});
} else {
res.sendStatus(500);
}
});
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
if (subscription.name) {
let base_path = config_api.getConfigItem('ytdl_subscriptions_base_path');
let appended_base_path = path.join(base_path, subscription.isPlaylist ? 'playlists' : 'channels', subscription.name, '/');
let files;
try {
files = recFindByExt(appended_base_path, 'mp4');
} catch(e) {
files = null;
console.log('Failed to get folder for subscription: ' + subscription.name + ' at path ' + appended_base_path);
res.sendStatus(500);
return;
}
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
});
} else {
res.sendStatus(500);
}
});
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;
@@ -919,7 +1132,7 @@ app.post('/api/downloadFile', async (req, res) => {
let outputName = req.body.outputName;
let file = null;
if (!is_playlist) {
fileNames = decodeURI(fileNames);
fileNames = decodeURIComponent(fileNames);
if (type === 'audio') {
file = __dirname + '/' + audioFolderPath + fileNames + '.mp3';
} else if (type === 'video') {
@@ -927,7 +1140,7 @@ app.post('/api/downloadFile', async (req, res) => {
}
} else {
for (let i = 0; i < fileNames.length; i++) {
fileNames[i] = decodeURI(fileNames[i]);
fileNames[i] = decodeURIComponent(fileNames[i]);
}
file = await createPlaylistZipFile(fileNames, type, outputName);
}
@@ -946,10 +1159,31 @@ app.post('/api/deleteFile', async (req, res) => {
res.send()
});
app.post('/api/downloadArchive', async (req, res) => {
let sub = req.body.sub;
let archive_dir = sub.archive;
let full_archive_path = path.join(__dirname, archive_dir, 'archive.txt');
if (fs.existsSync(full_archive_path)) {
res.sendFile(full_archive_path);
} else {
res.sendStatus(404);
}
});
app.get('/api/video/:id', function(req , res){
var head;
let id = decodeURI(req.params.id);
const path = "video/" + id + '.mp4';
let optionalParams = url_api.parse(req.url,true).query;
let id = decodeURIComponent(req.params.id);
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
@@ -988,7 +1222,7 @@ app.get('/api/video/:id', function(req , res){
app.get('/api/audio/:id', function(req , res){
var head;
let id = decodeURI(req.params.id);
let id = decodeURIComponent(req.params.id);
let path = "audio/" + id + '.mp3';
path = path.replace(/\"/g, '\'');
const stat = fs.statSync(path)

View File

@@ -1,8 +1,9 @@
const fs = require('fs');
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
const debugMode = process.env.YTDL_MODE === 'debug';
let configPath = 'config/default.json';
let configPath = debugMode ? '../src/assets/default.json' : 'config/default.json';
// https://stackoverflow.com/questions/6491463/accessing-nested-javascript-objects-with-string-key
Object.byString = function(o, s) {
@@ -56,7 +57,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);
};
@@ -109,5 +113,6 @@ module.exports = {
setConfigItem: setConfigItem,
setConfigItems: setConfigItems,
getConfigFile: getConfigFile,
setConfigFile: setConfigFile,
CONFIG_ITEMS: CONFIG_ITEMS
}

View File

@@ -11,7 +11,8 @@
},
"Downloader": {
"path-audio": "audio/",
"path-video": "video/"
"path-video": "video/",
"custom_args": ""
},
"Extra": {
"title_top": "Youtube Downloader",
@@ -28,6 +29,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": "",

View File

@@ -11,7 +11,8 @@
},
"Downloader": {
"path-audio": "audio/",
"path-video": "video/"
"path-video": "video/",
"custom_args": ""
},
"Extra": {
"title_top": "Youtube Downloader",
@@ -28,6 +29,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": "",

View File

@@ -34,6 +34,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_video_folder_path',
'path': 'YoutubeDLMaterial.Downloader.path-video'
},
'ytdl_custom_args': {
'key': 'ytdl_custom_args',
'path': 'YoutubeDLMaterial.Downloader.custom_args'
},
// Extra
'ytdl_title_top': {
@@ -56,7 +60,6 @@ let CONFIG_ITEMS = {
'key': 'ytdl_allow_multi_download_mode',
'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode'
},
// API
'ytdl_use_youtube_api': {
@@ -78,6 +81,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',

View File

@@ -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"
}
}

315
backend/subscriptions.js Normal file
View File

@@ -0,0 +1,315 @@
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)
const 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();
let success = await getSubscriptionInfo(sub);
result_obj.success = success;
result_obj.sub = sub;
getVideosForSub(sub);
resolve(result_obj);
});
}
async function getSubscriptionInfo(sub) {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
return new Promise(resolve => {
// get videos
let downloadConfig = ['--dump-json', '--playlist-end', '1']
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
if (debugMode) {
console.log('Subscribe: got info for subscription ' + sub.id);
}
if (err) {
console.log(err.stderr);
resolve(false);
} else if (output) {
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
if (debugMode) console.log('Could not get info for ' + sub.id);
resolve(false);
}
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) {
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 (!sub.archive) {
// must create the archive
const archive_dir = basePath + 'archives/' + sub.name;
const archive_path = path.join(archive_dir, 'archive.txt');
// creates archive directory and text file if it doesn't exist
if (!fs.existsSync(archive_dir)) {
fs.mkdirSync(archive_dir);
fs.closeSync(fs.openSync(archive_path, 'w'));
} else if (!fs.existsSync(archive_path)) {
fs.closeSync(fs.openSync(archive_path, 'w'));
}
// updates subscription
sub.archive = archive_dir;
db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
}
// TODO: get even more info
resolve(true);
}
resolve(false);
}
});
});
}
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 deleteSubscriptionFile(sub, file, deleteForever) {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const name = file;
let retrievedID = null;
return new Promise(resolve => {
let filePath = appendedBasePath;
var jsonPath = path.join(__dirname,filePath,name+'.info.json');
var videoFilePath = path.join(__dirname,filePath,name+'.mp4');
var imageFilePath = path.join(__dirname,filePath,name+'.jpg');
jsonExists = fs.existsSync(jsonPath);
videoFileExists = fs.existsSync(videoFilePath);
imageFileExists = fs.existsSync(imageFilePath);
if (jsonExists) {
retrievedID = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))['id'];
fs.unlinkSync(jsonPath);
}
if (imageFileExists) {
fs.unlinkSync(imageFilePath);
}
if (videoFileExists) {
fs.unlink(videoFilePath, function(err) {
if (fs.existsSync(jsonPath) || fs.existsSync(videoFilePath)) {
resolve(false);
} else {
// check if the user wants the video to be redownloaded (deleteForever === false)
if (!deleteForever && useArchive && sub.archive && retrievedID) {
const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID
if (fs.existsSync(archive_path)) {
removeIDFromArchive(archive_path, retrievedID);
}
}
resolve(true);
}
});
} else {
// TODO: tell user that the file didn't exist
resolve(true);
}
});
}
async function getVideosForSub(sub) {
return new Promise(resolve => {
if (!subExists(sub.id)) {
resolve(false);
return;
}
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive');
let appendedBasePath = null
if (sub.name) {
appendedBasePath = getAppendedBasePath(sub, basePath);
} else {
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;
if (useArchive) {
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
}
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 || (output.length === 1 && output[0] === '')) {
if (debugMode) console.log('No additional videos to download for ' + sub.name);
resolve(true);
}
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;
}
// TODO: Potentially store downloaded files in db?
}
resolve(true);
}
});
});
}
function getAllSubscriptions() {
const subscriptions = db.get('subscriptions').value();
return subscriptions;
}
function getSubscription(subID) {
return db.get('subscriptions').find({id: subID}).value();
}
function subExists(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);
}
};
function removeIDFromArchive(archive_path, id) {
fs.readFile(archive_path, {encoding: 'utf-8'}, function(err, data) {
if (err) throw error;
let dataArray = data.split('\n'); // convert file data in an array
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
let lastIndex = -1; // let say, we have not found the keyword
for (let index=0; index<dataArray.length; index++) {
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
lastIndex = index; // found a line includes a id keyword
break;
}
}
dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
fs.writeFile(archive_path, updatedData, (err) => {
if (err) throw err;
// console.log ('Successfully updated the file data');
});
});
}
module.exports = {
getSubscription : getSubscription,
getAllSubscriptions : getAllSubscriptions,
subscribe : subscribe,
unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub
}

Binary file not shown.

Binary file not shown.

View File

@@ -12,6 +12,7 @@ services:
ytdl_key_file_path: /etc/letsencrypt/live/example.com/privkey.pem
ytdl_audio_folder_path: audio/
ytdl_video_folder_path: video/
ytdl_custom_args: ''
ytdl_title_top: Youtube Downloader
ytdl_file_manager_enabled: 'true'
ytdl_allow_quality_select: 'true'
@@ -21,6 +22,10 @@ services:
ytdl_youtube_api_key: 'false'
ytdl_default_theme: default
ytdl_allow_theme_change: 'true'
ytdl_allow_subscriptions: 'true'
ytdl_subscriptions_base_path: subscriptions/
ytdl_subscriptions_check_interval: '300'
ytdl_subscriptions_use_youtubedl_archive: 'true'
ytdl_use_default_downloading_agent: 'true'
ytdl_custom_downloading_agent: 'false'
ytdl_allow_advanced_download: 'false'
@@ -30,4 +35,4 @@ services:
restart: always
ports:
- "8998:17442"
image: tzahi12345/youtubedl-material:3.3
image: tzahi12345/youtubedl-material:3.4

View File

@@ -1,39 +0,0 @@
#!/bin/bash
cd backend
# Start the first process
node app.js &
status=$?
if [ $status -ne 0 ]; then
echo "Failed to start my_first_process: $status"
exit $status
fi
# Start the second process
apachectl -DFOREGROUND
status=$?
if [ $status -ne 0 ]; then
echo "Failed to start my_second_process: $status"
exit $status
fi
# Naive check runs checks once a minute to see if either of the processes exited.
# This illustrates part of the heavy lifting you need to do if you want to run
# more than one service in a container. The container will exit with an error
# if it detects that either of the processes has exited.
# Otherwise it will loop forever, waking up every 60 seconds
while /bin/true; do
ps aux |grep node\ app.js # |grep -q -v grep
PROCESS_1_STATUS=$?
ps aux |grep apache2 # |grep -q -v grep
PROCESS_2_STATUS=$?
# If the greps above find anything, they will exit with 0 status
# If they are not both 0, then something is wrong
if [ $PROCESS_1_STATUS -ne 0 -o $PROCESS_2_STATUS -ne 0 ]; then
echo "One of the processes has already exited."
exit -1
fi
sleep 60
done

View File

@@ -31,16 +31,17 @@
"@angular/router": "^8.2.11",
"core-js": "^2.4.1",
"file-saver": "^2.0.2",
"hammerjs": "^2.0.8",
"ng-lazyload-image": "^7.0.1",
"ng4-configure": "^0.1.7",
"ngx-content-loading": "^0.1.3",
"rxjs": "^6.5.3",
"rxjs-compat": "^6.0.0-rc.0",
"tslib": "^1.10.0",
"typescript": "~3.5.3",
"videogular2": "^7.0.1",
"web-animations-js": "^2.3.2",
"zone.js": "~0.9.1",
"typescript": "~3.5.3"
"zone.js": "~0.9.1"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.803.24",

Binary file not shown.

Binary file not shown.

View File

@@ -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' },
];

View File

@@ -10,4 +10,10 @@
flex-direction: column;
flex-basis: 100%;
flex: 1;
}
.theme-slide-toggle {
top: 2px;
left: 10px;
position: relative;
}

View File

@@ -1,17 +1,42 @@
<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 #hamburgerMenu style="outline: none" *ngIf="router.url.split(';')[0] !== '/player' && allowSubscriptions" 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 [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #menuSettings="matMenu">
<button (click)="themeMenuItemClicked($event)" *ngIf="allowThemeChange" mat-menu-item>
<mat-icon>{{(postsService.theme.key === 'default') ? 'brightness_5' : 'brightness_2'}}</mat-icon>
<span>Dark</span>
<mat-slide-toggle class="theme-slide-toggle" [checked]="postsService.theme.key === 'dark'"></mat-slide-toggle>
</button>
<button (click)="openSettingsDialog()" mat-menu-item>
<mat-icon>settings</mat-icon>
<span>Settings</span>
</button>
</mat-menu>
</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>
<router-outlet></router-outlet>
</mat-toolbar>
</div>
<div style="height: calc(100% - 64px)">
<mat-sidenav-container style="height: 100%">
<mat-sidenav #sidenav>
<mat-nav-list>
<a mat-list-item (click)="sidenav.close()" routerLink='/home'>Home</a>
<a mat-list-item (click)="sidenav.close()" 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>

View File

@@ -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, MatDialog, MatSidenav} from '@angular/material';
import { saveAs } from 'file-saver';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/mapTo';
@@ -15,9 +15,10 @@ 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, NavigationEnd } from '@angular/router';
import { OverlayContainer } from '@angular/cdk/overlay';
import { THEMES_CONFIG } from '../themes';
import { SettingsComponent } from './settings/settings.component';
@Component({
selector: 'app-root',
@@ -33,12 +34,27 @@ export class AppComponent implements OnInit {
topBarTitle = 'Youtube Downloader';
defaultTheme = null;
allowThemeChange = null;
allowSubscriptions = false;
@ViewChild('urlinput', { read: ElementRef, static: false }) urlInput: ElementRef;
@ViewChild('sidenav', {static: false}) sidenav: MatSidenav;
@ViewChild('hamburgerMenu', {static: false, read: ElementRef}) hamburgerMenuButton: ElementRef;
navigator: string = null;
constructor(public postsService: PostsService, public snackBar: MatSnackBar,
constructor(public postsService: PostsService, public snackBar: MatSnackBar, private dialog: MatDialog,
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');
} else if (e instanceof NavigationEnd) {
// blurs hamburger menu if it exists, as the sidenav likes to focus on it after closing
if (this.hamburgerMenuButton && this.hamburgerMenuButton.nativeElement) {
this.hamburgerMenuButton.nativeElement.blur();
}
}
});
// loading config
this.postsService.loadNavItems().subscribe(res => { // loads settings
const result = !this.postsService.debugMode ? res['config_file'] : res;
@@ -46,6 +62,7 @@ export class AppComponent implements OnInit {
const themingExists = result['YoutubeDLMaterial']['Themes'];
this.defaultTheme = themingExists ? result['YoutubeDLMaterial']['Themes']['default_theme'] : 'default';
this.allowThemeChange = themingExists ? result['YoutubeDLMaterial']['Themes']['allow_theme_change'] : true;
this.allowSubscriptions = result['YoutubeDLMaterial']['Subscriptions']['allow_subscriptions'];
// sets theme to config default if it doesn't exist
if (!localStorage.getItem('theme')) {
@@ -57,6 +74,10 @@ export class AppComponent implements OnInit {
}
toggleSidenav() {
this.sidenav.toggle();
}
// theme stuff
setTheme(theme) {
@@ -105,6 +126,11 @@ onSetTheme(theme, old_theme) {
}
}
themeMenuItemClicked(event) {
this.flipTheme();
event.stopPropagation();
}
ngOnInit() {
if (localStorage.getItem('theme')) {
this.setTheme(localStorage.getItem('theme'));
@@ -115,7 +141,18 @@ onSetTheme(theme, old_theme) {
goBack() {
this.router.navigate(['/home']);
if (!this.navigator) {
this.router.navigate(['/home']);
} else {
this.router.navigateByUrl(this.navigator);
}
}
openSettingsDialog() {
const dialogRef = this.dialog.open(SettingsComponent, {
width: '80vw'
});
}
}

View File

@@ -5,7 +5,10 @@ import {MatNativeDateModule, MatRadioModule, MatInputModule, MatButtonModule, Ma
MatProgressBarModule, MatExpansionModule,
MatProgressSpinnerModule,
MatButtonToggleModule,
MatDialogModule} from '@angular/material';
MatDialogModule,
MatRippleModule,
MatSlideToggleModule,
MatMenuModule} from '@angular/material';
import {DragDropModule} from '@angular/cdk/drag-drop';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import { AppComponent } from './app.component';
@@ -28,6 +31,12 @@ 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';
import { SettingsComponent } from './settings/settings.component';
export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps<any>) {
return (element.id === 'video' ? videoFilesMouseHovering || videoFilesOpened : audioFilesMouseHovering || audioFilesOpened);
@@ -41,7 +50,13 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
PlayerComponent,
InputDialogComponent,
CreatePlaylistComponent,
DownloadItemComponent
DownloadItemComponent,
SubscriptionsComponent,
SubscribeDialogComponent,
SubscriptionComponent,
SubscriptionFileCardComponent,
SubscriptionInfoDialogComponent,
SettingsComponent
],
imports: [
BrowserModule,
@@ -67,7 +82,11 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
MatProgressBarModule,
MatProgressSpinnerModule,
MatButtonToggleModule,
MatRippleModule,
MatMenuModule,
MatDialogModule,
MatSlideToggleModule,
MatMenuModule,
DragDropModule,
VgCoreModule,
VgControlsModule,
@@ -80,7 +99,10 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
],
entryComponents: [
InputDialogComponent,
CreatePlaylistComponent
CreatePlaylistComponent,
SubscribeDialogComponent,
SubscriptionInfoDialogComponent,
SettingsComponent
],
providers: [PostsService],
bootstrap: [AppComponent]

View File

@@ -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>

View File

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

View File

@@ -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();
});
});

View File

@@ -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,
});
}
}

View File

@@ -0,0 +1,27 @@
<h4 mat-dialog-title>{{sub.name}}</h4>
<mat-dialog-content>
<div class="info-item">
<strong>Type: </strong>
<span class="info-item-value">{{(sub.isPlaylist ? 'Playlist' : 'Channel')}}</span>
</div>
<div class="info-item">
<strong>URL: </strong>
<span class="info-item-value">{{sub.url}}</span>
</div>
<div class="info-item">
<strong>ID: </strong>
<span class="info-item-value">{{sub.id}}</span>
</div>
<div class="info-item" *ngIf="sub.archive">
<strong>Archive: </strong>
<span class="info-item-value">{{sub.archive}}</span>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>Close</button>
<button mat-stroked-button (click)="downloadArchive()" color="accent">Export Archive</button>
<span class="spacer"></span>
<button mat-button (click)="unsubscribe()" color="warn">Unsubscribe</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,9 @@
.info-item {
margin-bottom: 12px;
}
.info-item-value {
font-size: 13px;
}
.spacer {flex: 1 1 auto;}

View File

@@ -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();
});
});

View File

@@ -0,0 +1,39 @@
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();
});
}
downloadArchive() {
this.postsService.downloadArchive(this.sub).subscribe(res => {
const blob: Blob = res;
saveAs(blob, 'archive.txt');
});
}
}

View File

@@ -69,6 +69,7 @@
Advanced
</mat-panel-title>
</mat-expansion-panel-header>
<p *ngIf="this.simulatedOutput">Simulated command: <i>{{this.simulatedOutput}}</i></p>
<div class="container" style="padding-bottom: 20px;">
<div class="row">
<div class="col-12 col-sm-6">

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, ElementRef, ViewChild, ViewChildren, QueryList, isDevMode } from '@angular/core';
import { Component, OnInit, ElementRef, ViewChild, ViewChildren, QueryList } from '@angular/core';
import {PostsService} from '../posts.services';
import {FileCardComponent} from '../file-card/file-card.component';
import { Observable } from 'rxjs/Observable';
@@ -45,6 +45,7 @@ export class MainComponent implements OnInit {
iOS = false;
// local settings
determinateProgress = false;
downloadingfile = false;
audioOnly: boolean;
@@ -63,15 +64,19 @@ export class MainComponent implements OnInit {
percentDownloaded: number;
autoStartDownload = false;
// settings
// global settings
fileManagerEnabled = false;
allowQualitySelect = false;
downloadOnlyMode = false;
allowMultiDownloadMode = false;
audioFolderPath;
videoFolderPath;
globalCustomArgs = null;
allowAdvancedDownload = false;
useDefaultDownloadingAgent = true;
customDownloadingAgent = null;
// formats cache
cachedAvailableFormats = {};
// youtube api
@@ -202,6 +207,8 @@ export class MainComponent implements OnInit {
is_playlist: false
};
simulatedOutput = '';
constructor(private postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
private router: Router, public dialog: MatDialog, private platform: Platform, private route: ActivatedRoute) {
this.audioOnly = false;
@@ -214,11 +221,15 @@ export class MainComponent implements OnInit {
this.allowMultiDownloadMode = result['YoutubeDLMaterial']['Extra']['allow_multi_download_mode'];
this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio'];
this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video'];
this.globalCustomArgs = result['YoutubeDLMaterial']['Downloader']['custom_args'];
this.youtubeSearchEnabled = result['YoutubeDLMaterial']['API'] && result['YoutubeDLMaterial']['API']['use_youtube_API'] &&
result['YoutubeDLMaterial']['API']['youtube_API_key'];
this.youtubeAPIKey = this.youtubeSearchEnabled ? result['YoutubeDLMaterial']['API']['youtube_API_key'] : null;
this.allowQualitySelect = result['YoutubeDLMaterial']['Extra']['allow_quality_select'];
this.allowAdvancedDownload = result['YoutubeDLMaterial']['Advanced']['allow_advanced_download'];
this.useDefaultDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['use_default_downloading_agent'];
this.customDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['custom_downloading_agent'];
if (this.fileManagerEnabled) {
@@ -259,6 +270,8 @@ export class MainComponent implements OnInit {
this.downloadClicked();
}
setInterval(() => this.getSimulatedOutput(), 1000);
}, error => {
console.log(error);
});
@@ -372,6 +385,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 +458,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 +496,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 {
@@ -544,11 +560,7 @@ export class MainComponent implements OnInit {
let customQualityConfiguration = null;
if (this.selectedQuality !== '') {
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio'];
customQualityConfiguration = audio_formats[this.selectedQuality]['format_id'];
}
customQualityConfiguration = this.getSelectedAudioFormat();
}
this.postsService.makeMP3(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
@@ -581,14 +593,7 @@ export class MainComponent implements OnInit {
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
this.downloadingfile = true;
let customQualityConfiguration = null;
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const video_formats = this.cachedAvailableFormats[this.url]['formats']['video'];
if (video_formats['best_audio_format'] && this.selectedQuality !== '') {
customQualityConfiguration = video_formats[this.selectedQuality]['format_id'] + '+' + video_formats['best_audio_format'];
}
}
const customQualityConfiguration = this.getSelectedVideoFormat();
this.postsService.makeMP4(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword).subscribe(posts => {
@@ -629,6 +634,29 @@ export class MainComponent implements OnInit {
this.current_download = null;
}
getSelectedAudioFormat() {
if (this.selectedQuality === '') { return null };
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio'];
return audio_formats[this.selectedQuality]['format_id'];
} else {
return null;
}
}
getSelectedVideoFormat() {
if (this.selectedQuality === '') { return null };
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const video_formats = this.cachedAvailableFormats[this.url]['formats']['video'];
if (video_formats['best_audio_format'] && this.selectedQuality !== '') {
return video_formats[this.selectedQuality]['format_id'] + '+' + video_formats['best_audio_format'];
}
}
return null;
}
getDownloadByUID(uid) {
const index = this.downloads.findIndex(download => download.uid === uid);
if (index !== -1) {
@@ -771,6 +799,77 @@ export class MainComponent implements OnInit {
}
}
getSimulatedOutput() {
const customArgsExists = this.customArgsEnabled && this.customArgs;
const globalArgsExists = this.globalCustomArgs && this.globalCustomArgs !== '';
let full_string_array: string[] = [];
const base_string_array = ['youtube-dl', this.url];
if (customArgsExists) {
this.simulatedOutput = base_string_array.join(' ') + ' ' + this.customArgs;
return this.simulatedOutput;
}
full_string_array.push(...base_string_array);
const base_path = this.audioOnly ? this.audioFolderPath : this.videoFolderPath;
const ext = this.audioOnly ? '.mp3' : '.mp4';
// gets output
let output_string_array = ['-o', base_path + '%(title)s' + ext];
if (this.customOutputEnabled && this.customOutput) {
output_string_array = ['-o', this.customOutput + ext];
}
// before pushing output, should check if using an external downloader
if (!this.useDefaultDownloadingAgent && this.customDownloadingAgent === 'aria2c') {
full_string_array.push('--external-downloader', 'aria2c');
}
// pushes output
full_string_array.push(...output_string_array);
// logic splits into audio and video modes
if (this.audioOnly) {
// adds base audio string
const format_array = [];
const audio_format = this.getSelectedAudioFormat();
if (audio_format) {
format_array.push('-f', audio_format);
} else if (this.selectedQuality) {
format_array.push('--audio-quality', this.selectedQuality);
}
// pushes formats
full_string_array.splice(2, 0, ...format_array);
const additional_params = ['-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
full_string_array.push(...additional_params);
} else {
// adds base video string
let format_array = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4'];
const video_format = this.getSelectedVideoFormat();
if (video_format) {
format_array = ['-f', video_format];
} else if (this.selectedQuality) {
format_array = [`bestvideo[height=${this.selectedQuality}]+bestaudio/best[height=${this.selectedQuality}]`];
}
// pushes formats
full_string_array.splice(2, 0, ...format_array);
const additional_params = ['--write-info-json', '--print-json'];
full_string_array.push(...additional_params);
}
if (globalArgsExists) {
full_string_array = full_string_array.concat(this.globalCustomArgs.split(' '));
}
this.simulatedOutput = full_string_array.join(' ');
return this.simulatedOutput;
}
errorFormats(url) {
this.cachedAvailableFormats[url]['formats_loading'] = false;
console.error('Could not load formats for url ' + url);

View File

@@ -1,5 +1,6 @@
.video-player {
margin: 0 auto;
min-width: 300px;
}
.video-player:focus {

View File

@@ -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 + encodeURI(fileName); // + (this.type === 'audio' ? '.mp3' : '.mp4');
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);

View File

@@ -92,6 +92,10 @@ export class PostsService {
}
}
setConfig(config) {
return this.http.post(this.path + 'setConfig', {new_config_file: config});
}
deleteFile(name: string, isAudio: boolean) {
if (isAudio) {
return this.http.post(this.path + 'deleteMp3', {name: name});
@@ -116,6 +120,10 @@ export class PostsService {
{responseType: 'blob'});
}
downloadArchive(sub) {
return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob'});
}
getFileInfo(fileNames, type, urlMode) {
return this.http.post(this.path + 'getVideoInfos', {fileNames: fileNames, type: type, urlMode: urlMode});
}
@@ -136,6 +144,26 @@ 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})
}
deleteSubscriptionFile(sub, file, deleteForever) {
return this.http.post(this.path + 'deleteSubscriptionFile', {sub: sub, file: file, deleteForever: deleteForever})
}
getSubscription(id) {
return this.http.post(this.path + 'getSubscription', {id: id});
}
getAllSubscriptions() {
return this.http.post(this.path + 'getAllSubscriptions', {});
}
}

View File

@@ -0,0 +1,231 @@
<h4 mat-dialog-title>Settings</h4>
<mat-dialog-content>
<!-- Host -->
<mat-expansion-panel class="settings-expansion-panel">
<mat-expansion-panel-header>
<mat-panel-title>
Host
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12">
<mat-form-field color="accent">
<input [(ngModel)]="new_config['Host']['url']" matInput placeholder="URL" required>
<mat-hint>Base URL this app will be accessed from, without the port.</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-4">
<mat-form-field color="accent">
<input [(ngModel)]="new_config['Host']['port']" matInput placeholder="Port" required>
<mat-hint>The desired port. Default is 17442.</mat-hint>
</mat-form-field>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Encryption -->
<mat-expansion-panel class="settings-expansion-panel">
<mat-expansion-panel-header>
<mat-panel-title>
Encryption
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Encryption']['use-encryption']">Use encryption</mat-checkbox>
</div>
<div class="col-12">
<mat-form-field color="accent">
<input [disabled]="!new_config['Encryption']['use-encryption']" [(ngModel)]="new_config['Encryption']['cert-file-path']" matInput placeholder="Cert file path">
</mat-form-field>
</div>
<div class="col-12">
<mat-form-field color="accent">
<input [disabled]="!new_config['Encryption']['use-encryption']" [(ngModel)]="new_config['Encryption']['key-file-path']" matInput placeholder="Key file path">
</mat-form-field>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Downloader -->
<mat-expansion-panel class="settings-expansion-panel">
<mat-expansion-panel-header>
<mat-panel-title>
Downloader
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12">
<mat-form-field color="accent">
<input matInput [(ngModel)]="new_config['Downloader']['path-audio']" placeholder="Audio folder path" required>
<mat-hint>Path for audio only downloads. It is relative to YTDL-Material's root folder.</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-4">
<mat-form-field color="accent">
<input matInput [(ngModel)]="new_config['Downloader']['path-video']" placeholder="Video folder path" required>
<mat-hint>Path for video downloads. It is relative to YTDL-Material's root folder.</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-4">
<mat-form-field color="accent">
<textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Custom args"></textarea>
<mat-hint>Global custom args for downloads on the home page.</mat-hint>
</mat-form-field>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Extra -->
<mat-expansion-panel class="settings-expansion-panel">
<mat-expansion-panel-header>
<mat-panel-title>
Extra
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12">
<mat-form-field color="accent">
<input [(ngModel)]="new_config['Extra']['title_top']" matInput placeholder="Top title" required>
<mat-hint></mat-hint>
</mat-form-field>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['file_manager_enabled']">File manager enabled</mat-checkbox>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_quality_select']">Allow quality select</mat-checkbox>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['download_only_mode']">Download only mode</mat-checkbox>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_multi_download_mode']">Allow multi-download mode</mat-checkbox>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- API -->
<mat-expansion-panel class="settings-expansion-panel">
<mat-expansion-panel-header>
<mat-panel-title>
API
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_youtube_api']">Use YouTube API</mat-checkbox>
</div>
<div class="col-12">
<mat-form-field color="accent">
<input [disabled]="!new_config['API']['use_youtube_api']" [(ngModel)]="new_config['API']['youtube_API_key']" matInput placeholder="Youtube API Key" required>
<mat-hint><a target="_blank" href="https://developers.google.com/youtube/v3/getting-started">Generating a key</a> is easy!</mat-hint>
</mat-form-field>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Themes -->
<mat-expansion-panel class="settings-expansion-panel">
<mat-expansion-panel-header>
<mat-panel-title>
Themes
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12">
<mat-select color="accent" style="width: 100px" [(ngModel)]="new_config['Themes']['default_theme']">
<mat-option value="default">Default</mat-option>
<mat-option value="dark">Dark</mat-option>
</mat-select>
</div>
<div class="col-12 mt-4">
<mat-checkbox color="accent" [(ngModel)]="new_config['Themes']['allow_theme_change']">Allow theme change</mat-checkbox>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Subscriptions -->
<mat-expansion-panel class="settings-expansion-panel">
<mat-expansion-panel-header>
<mat-panel-title>
Subscriptions
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Subscriptions']['allow_subscriptions']">Allow subscriptions</mat-checkbox>
</div>
<div class="col-12">
<mat-form-field color="accent">
<input [disabled]="!new_config['Subscriptions']['allow_subscriptions']" [(ngModel)]="new_config['Subscriptions']['subscriptions_base_path']" matInput placeholder="Subscriptions base path">
<mat-hint>Base path for videos from your subscribed channels and playlists. It is relative to YTDL-Material's root folder.</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-5">
<mat-form-field color="accent">
<input [disabled]="!new_config['Subscriptions']['allow_subscriptions']" [(ngModel)]="new_config['Subscriptions']['subscriptions_check_interval']" matInput placeholder="Check interval">
<mat-hint>Unit is seconds, only include numbers.</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-4">
<mat-checkbox color="accent" [disabled]="!new_config['Subscriptions']['allow_subscriptions']" [(ngModel)]="new_config['Subscriptions']['subscriptions_use_youtubedl_archive']">Use youtube-dl archive</mat-checkbox>
<p>With youtube-dl's <a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#how-do-i-download-only-new-videos-from-a-playlist">archive</a> feature, downloaded videos from your subscriptions get recorded in a text file in the subscriptions <i>archive</i> sub-directory.</p>
<p>This enables the ability to permanently delete videos from your subscriptions without unsubscribing, and allows you to record which videos you downloaded in case of data loss.</p>
</div>
</div>
</div>
</mat-expansion-panel>
<!-- Advanced -->
<mat-expansion-panel class="settings-expansion-panel">
<mat-expansion-panel-header>
<mat-panel-title>
Advanced
</mat-panel-title>
</mat-expansion-panel-header>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Advanced']['use_default_downloading_agent']">Use default downloading agent</mat-checkbox>
</div>
<div class="col-12">
<mat-form-field color="accent">
<input [disabled]="new_config['Advanced']['use_default_downloading_agent']" [(ngModel)]="new_config['Advanced']['custom_downloading_agent']" matInput placeholder="Custom agent" required>
<mat-hint></mat-hint>
</mat-form-field>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Advanced']['allow_advanced_download']">Allow advanced download</mat-checkbox>
</div>
</div>
</div>
</mat-expansion-panel>
</mat-dialog-content>
<mat-dialog-actions>
<div style="margin-bottom: 10px;">
<button color="accent" (click)="saveSettings()" [disabled]="settingsSame()" mat-raised-button><mat-icon>done</mat-icon>&nbsp;&nbsp;Save</button>
<button mat-flat-button [mat-dialog-close]="false"><mat-icon>cancel</mat-icon>&nbsp;&nbsp;Cancel</button>
</div>
</mat-dialog-actions>

View File

@@ -0,0 +1,3 @@
.settings-expansion-panel {
margin-bottom: 20px;
}

View File

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

View File

@@ -0,0 +1,48 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-settings',
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss']
})
export class SettingsComponent implements OnInit {
initial_config = null;
new_config = null
loading_config = false;
constructor(private postsService: PostsService) { }
ngOnInit() {
this.getConfig();
}
getConfig() {
this.loading_config = true;
this.postsService.loadNavItems().subscribe(res => {
this.loading_config = false;
// successfully loaded config
this.initial_config = !this.postsService.debugMode ? res['config_file']['YoutubeDLMaterial'] : res['YoutubeDLMaterial'];
this.new_config = JSON.parse(JSON.stringify(this.initial_config));
});
}
settingsSame() {
return JSON.stringify(this.new_config) === JSON.stringify(this.initial_config);
}
saveSettings() {
const settingsToSave = {'YoutubeDLMaterial': this.new_config};
this.postsService.setConfig(settingsToSave).subscribe(res => {
if (res['success']) {
// sets new config as old config
this.initial_config = JSON.parse(JSON.stringify(this.new_config));
}
}, err => {
console.error('Failed to save config!');
})
}
}

View File

@@ -0,0 +1,19 @@
<div style="position: relative; width: fit-content;">
<div class="duration-time">
Length: {{formattedDuration}}
</div>
<button [matMenuTriggerFor]="action_menu" class="menuButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #action_menu="matMenu">
<button (click)="deleteAndRedownload()" mat-menu-item><mat-icon>restore</mat-icon>Delete and redownload</button>
<button (click)="deleteForever()" mat-menu-item *ngIf="sub.archive && use_youtubedl_archive"><mat-icon>delete_forever</mat-icon>Delete forever</button>
</mat-menu>
<mat-card (click)="goToFile()" 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>

View File

@@ -0,0 +1,76 @@
.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;
}
.duration-time {
position: absolute;
left: 5px;
top: 5px;
z-index: 99999;
}
@media (max-width: 576px){
.example-card {
width: 175px !important;
}
.image {
width: 175px;
}
}

View File

@@ -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();
});
});

View File

@@ -0,0 +1,97 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { MatSnackBar } from '@angular/material';
import { Router } from '@angular/router';
import { PostsService } from 'app/posts.services';
@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;
formattedDuration = null;
@Input() file;
@Input() sub;
@Input() use_youtubedl_archive = false;
@Output() goToFileEmit = new EventEmitter<any>();
@Output() reloadSubscription = new EventEmitter<boolean>();
constructor(private snackBar: MatSnackBar, private postsService: PostsService) {
this.scrollSubject = new Subject();
this.scrollAndLoad = Observable.merge(
Observable.fromEvent(window, 'scroll'),
this.scrollSubject
);
}
ngOnInit() {
if (this.file.duration) {
this.formattedDuration = fancyTimeFormat(this.file.duration);
}
}
onImgError(event) {
this.image_errored = true;
}
onHoverResponse() {
this.scrollSubject.next();
}
imageLoaded(loaded) {
this.image_loaded = true;
}
goToFile() {
this.goToFileEmit.emit(this.file.id);
}
deleteAndRedownload() {
this.postsService.deleteSubscriptionFile(this.sub, this.file.id, false).subscribe(res => {
this.reloadSubscription.emit(true);
this.openSnackBar(`Successfully deleted file: '${this.file.id}'`, 'Dismiss.');
});
}
deleteForever() {
this.postsService.deleteSubscriptionFile(this.sub, this.file.id, true).subscribe(res => {
this.reloadSubscription.emit(true);
this.openSnackBar(`Successfully deleted file: '${this.file.id}'`, 'Dismiss.');
});
}
public openSnackBar(message: string, action: string) {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}
function fancyTimeFormat(time)
{
// Hours, minutes and seconds
const hrs = ~~(time / 3600);
const mins = ~~((time % 3600) / 60);
const secs = ~~time % 60;
// Output like "1:01" or "4:03:59" or "123:03:59"
let ret = '';
if (hrs > 0) {
ret += '' + hrs + ':' + (mins < 10 ? '0' : '');
}
ret += '' + mins + ':' + (secs < 10 ? '0' : '');
ret += '' + secs;
return ret;
}

View File

@@ -0,0 +1,31 @@
<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">
<div class="flex-grid">
<div class="col"></div>
<div class="col">
<h4 style="text-align: center; margin-bottom: 20px;">Videos</h4>
</div>
<div class="col">
<mat-form-field [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
<input (focus)="searchIsFocused = true" (blur)="searchIsFocused = false" class="search-input" type="text" placeholder="Search" [(ngModel)]="search_text" (ngModelChange)="onSearchInputChanged($event)" matInput>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
</div>
</div>
<div class="container">
<div class="row">
<div *ngFor="let file of filtered_files" class="col-6 col-lg-4 mb-2 mt-2 sub-file-col">
<app-subscription-file-card (reloadSubscription)="getSubscription()" (goToFileEmit)="goToFile($event)" [file]="file" [sub]="subscription" [use_youtubedl_archive]="use_youtubedl_archive"></app-subscription-file-card>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,36 @@
.sub-file-col {
max-width: 240px;
}
.back-button {
float: left;
position: absolute;
left: 15px;
}
.search-bar {
transition: all .5s ease;
position: relative;
float: right;
}
.search-bar-unfocused {
width: 100px;
}
.search-input {
transition: all .5s ease;
}
.search-bar-focused {
width: 100%;
}
.flex-grid {
width: 100%;
display: block;
}
.col {
width: 33%;
display: inline-block;
}

View File

@@ -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();
});
});

View File

@@ -0,0 +1,75 @@
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;
filtered_files: any[] = null;
use_youtubedl_archive = false;
search_mode = false;
search_text = '';
searchIsFocused = false;
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();
this.getConfig();
}
}
goBack() {
this.router.navigate(['/subscriptions']);
}
getSubscription() {
this.postsService.getSubscription(this.id).subscribe(res => {
this.subscription = res['subscription'];
this.files = res['files'];
if (this.search_mode) {
this.filterFiles(this.search_text);
} else {
this.filtered_files = this.files;
}
});
}
getConfig() {
this.postsService.loadNavItems().subscribe(res => {
const result = !this.postsService.debugMode ? res['config_file'] : res;
this.use_youtubedl_archive = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_use_youtubedl_archive'];
});
}
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}]);
}
onSearchInputChanged(newvalue) {
if (newvalue.length > 0) {
this.search_mode = true;
this.filterFiles(newvalue);
} else {
this.search_mode = false;
}
}
private filterFiles(value: string) {
const filterValue = value.toLowerCase();
this.filtered_files = this.files.filter(option => option.id.toLowerCase().includes(filterValue));
}
}

View File

@@ -0,0 +1,56 @@
<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">
Name not available. Channel retrieval in progress.
<ngx-content-loading *ngIf="false" [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; margin-top: 10px;">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">
Name not available. Playlist retrieval in progress.
<ngx-content-loading *ngIf="false" [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.length === 0 && subscriptions">
<p>You have no playlist subscriptions.</p>
</div>
<div style="margin: 0 auto; width: 80%" *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>

View File

@@ -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%;
}

View File

@@ -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();
});
});

View File

@@ -0,0 +1,97 @@
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 = null;
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);
}
}
}, err => {
this.subscriptions_loading = false;
console.error('Failed to get subscriptions');
this.openSnackBar('ERROR: Failed to get subscriptions!', 'OK.');
});
}
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,
});
}
}

View File

@@ -11,7 +11,8 @@
},
"Downloader": {
"path-audio": "audio/",
"path-video": "video/"
"path-video": "video/",
"custom_args": ""
},
"Extra": {
"title_top": "Youtube Downloader",
@@ -28,6 +29,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

View File

@@ -1,5 +1,6 @@
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import 'hammerjs';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

View File

@@ -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'
},

Binary file not shown.