Compare commits

...

42 Commits
v3.0 ... v3.2

Author SHA1 Message Date
Isaac Grynsztein
12c227badb temporarily disabled advanced mode until further testing 2020-02-27 03:47:11 -05:00
Isaac Grynsztein
181a9f842c fixed bug where downloading files failed if the name had to be encoded 2020-02-27 03:46:57 -05:00
Isaac Grynsztein
b79d801c0f Added support for custom arguments and custom output patch 2020-02-27 03:27:57 -05:00
Isaac Grynsztein
fc3691336d added allow multi download mode setting frontend implementation 2020-02-27 01:10:23 -05:00
Isaac Grynsztein
bcd879ebc8 added multiple download support
lazy loaded images now reload after a new download
2020-02-27 01:06:32 -05:00
Isaac Grynsztein
b646db4828 Added the ability to cancel downloads
Audio only checkbox now disabled when downloading

Laid the groundwork for multiple simulataneous downloads
2020-02-26 19:04:02 -05:00
Isaac Grynsztein
426d52e359 fixed tabindex ordering of file cards (delete came before url) 2020-02-26 19:03:13 -05:00
Isaac Grynsztein
17199dd9c0 Updated readme 2020-02-26 18:11:37 -05:00
Isaac Grynsztein
c680c2827b reworded docker steps 2020-02-26 03:18:19 -05:00
Isaac Grynsztein
2dbf8d31f7 Updated docker info in readme 2020-02-26 03:17:29 -05:00
Isaac Grynsztein
a3753e557c Updated docker instructions 2020-02-26 03:16:42 -05:00
Isaac Grynsztein
ec80abdc8e Updated readme.md to include docker steps 2020-02-26 03:07:22 -05:00
Isaac Grynsztein
1ef7d24c22 downgraded required docker-compose.yml to 2 2020-02-26 01:35:21 -05:00
Isaac Grynsztein
4b6f6996ae fixed bug in docker during building 2020-02-26 01:28:46 -05:00
Isaac Grynsztein
c930ee94c5 added docker support
reworked backend to allow for containerization. config items can now be overwritten by environment vars

fixed bug during building

updated youtube-dl version in backend
2020-02-26 00:34:13 -05:00
Isaac Grynsztein
e88edbef5a Updated README to fix missing step 2020-02-24 16:22:54 -05:00
Isaac Grynsztein
ac13ed3359 updated package.json to remove bug during building 2020-02-24 16:18:09 -05:00
Isaac Grynsztein
faae0d44e6 make it better 2020-02-24 07:47:36 -05:00
Isaac Grynsztein
7d8ec04ad6 make it better 2020-02-24 07:39:00 -05:00
Isaac Grynsztein
8629e6ae9e make it better 2020-02-24 07:28:11 -05:00
Isaac Grynsztein
6e311d46a6 make it better 2020-02-24 06:41:13 -05:00
Isaac Grynsztein
006e983c14 make it better 2020-02-24 06:13:56 -05:00
Isaac Grynsztein
5db3e06a81 make it better 2020-02-24 06:05:24 -05:00
Isaac Grynsztein
2ced7b7f91 researching heroku support 2020-02-24 05:58:25 -05:00
Isaac Grynsztein
042baa418b updated .gitignore 2020-02-24 04:12:45 -05:00
Isaac Grynsztein
deb928da12 sorting and updating now only possible on favorited (saved) playlists
fixed compilation bug in app.module
2020-02-24 04:11:22 -05:00
Isaac Grynsztein
a7f5cc01d3 update youtube-dl binary 2020-02-24 03:51:27 -05:00
Isaac Grynsztein
414b6a26d9 backend playlist updating endpoint implemented
tomp3/tomp4 errors are now logged
2020-02-24 03:51:11 -05:00
Isaac Grynsztein
c069672e62 ngmodule drag and drop import commit 2020-02-24 03:50:29 -05:00
Isaac Grynsztein
167d9dafa2 added title to create playlist dialog 2020-02-24 03:50:10 -05:00
Isaac Grynsztein
9302084f60 playlists can now be rearranged and updated 2020-02-24 03:49:43 -05:00
Isaac Grynsztein
ac0199f596 iOS is now checked by the cdk platform component 2020-02-24 03:49:01 -05:00
Isaac Grynsztein
8e8ab7ac6c added min-height to app component 2020-02-23 22:30:09 -05:00
Isaac Grynsztein
f06c9ba44a fixed bug where non-themed white space that appeared when file manager was expanded 2020-02-23 22:29:42 -05:00
Isaac Grynsztein
6e593472d9 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2020-02-23 03:21:13 -05:00
Isaac Grynsztein
0bddbda36d updated favicon 2020-02-23 03:20:24 -05:00
Isaac Grynsztein
23feb05fab downloading agent is now the default of youtube-dl by default instead of aria2c. testing showed it performed better over multipled trials
added a setting to use aria2c optionally

added debug timing to getURLInfos
2020-02-23 03:20:07 -05:00
Isaac Grynsztein
a0eff4d96d images on file cards now load when the accordion is hovered over to increase responsiveness. images are loading maybe a second before clicking so hopefully they're done by the time the expansion finishes
added the ability to create playlists in the gui through a new dialog

reloading mp3s/mp4s doesn't cause an image refresh anymore when the list is unchanged

fixed loading spinner of available formats so it now only shows when it is loading the current url

file card images now don't show when errored or thumbnailurl doesn't exist
2020-02-23 03:18:26 -05:00
Isaac Grynsztein
b87b49d77b removed outline for video player 2020-02-23 03:13:09 -05:00
Tzahi12345
c05026aa15 Update README.md 2020-02-21 01:33:31 -05:00
Tzahi12345
883df63d2f Update README.md 2020-02-20 21:22:53 -05:00
Tzahi12345
da1d49b541 Update README.md 2020-02-20 21:22:25 -05:00
41 changed files with 15551 additions and 273 deletions

1
.gitignore vendored
View File

@@ -46,4 +46,5 @@ backend/node_modules/*
YoutubeDL-Material/node_modules/*
backend/video/*
backend/audio/*
backend/db.json
src/assets/default.json

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM ubuntu:18.04
RUN apt-get update && apt-get install -y \
nodejs \
apache2 \
npm \
youtube-dl
# Change directory so that our commands run inside this new directory
WORKDIR /var/www/html
# Copy dependency definitions
COPY ./ /var/www/html/
# Change directory to backend
WORKDIR /var/www/html/backend
# Install dependencies on backend
RUN npm install
# Change back to original directory
WORKDIR /var/www/html
# Expose the port the app runs in
EXPOSE 80
# Run the specified command within the container.
CMD ./docker_wrapper.sh

1
Procfile Normal file
View File

@@ -0,0 +1 @@
web: cd backend && node app.js

View File

@@ -1,6 +1,8 @@
# YoutubeDL-Material
YoutubeDL-Material is a material design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 5](https://angular.io/) for the frontend, and [Nodejs](https://nodejs.org/) on the backend.
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 8](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
Now with [Docker](#Docker) support!
## Getting Started
@@ -8,18 +10,24 @@ Check out the prerequisites, and go to the installation section. Easy as pie!
Here's an image of what it'll look like once you're done:
![frontpage](https://i.imgur.com/m3xozES.png)
![frontpage](https://i.imgur.com/rOxWIys.png)
With optional file management enabled (default):
![frontpage_with_files](https://i.imgur.com/z9vME2O.png)
![frontpage_with_files](https://i.imgur.com/UTUROLl.png)
Dark mode:
![dark_mode](https://i.imgur.com/9TMkHF6.png?1)
### Prerequisites
You need to have a functioning web server for this to work. Also make sure you have these dependencies installed on your system: ffmpeg, nodejs, python. If you don't, run this command:
NOTE: If you would like to use Docker, you can go down to the [Docker](#Docker) section for a setup guide.
You need to have a functioning web server for this to work. Also make sure you have these dependencies installed on your system: nodejs and youtube-dl. If you don't, run this command:
```
sudo apt-get install ffmpeg nodejs python
sudo apt-get install nodejs youtube-dl
```
### Installing
@@ -30,10 +38,33 @@ Drag all the files in `youtubedl-material` to a location accessible to a web ser
Port forward `17442` if you're going to access YoutubeDL-Material from out of your network. This is an important step. Make sure the configuration reflects this appropriately.
Once the configuration is done, type `sudo nodejs app.js`. This will run the backend server. On your browser, navigate to your installation folder. Try putting in a youtube link to see if it works. If it does, viola! YoutubeDL-Material is now up and running.
Once the configuration is done, run `npm install` to install all the backend dependencies. Once that is finished, type `nodejs app.js`. This will run the backend server. On your browser, navigate to your installation folder. Try putting in a youtube link to see if it works. If it does, viola! YoutubeDL-Material is now up and running.
If you experience problems, know that it's usually caused by a configuration problem. The first thing you should do is check the console. To get there, right click anywhere on the page and click "Inspect element." Then on the menu that pops up, click console. Look at the error there, and try to investigate.
### Configuration
Here is an explanation for the configuration entries. Check out the [default config](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/backend/config/default.json) for more context.
| Config item | Description | Default |
| ------------- | ------------- | ------------- |
| frontendurl | URL to the webserver hosting YTDL-Material | "http://example.com" |
| backendurl | URL to the YTDL-Material's backend, should include port 17442 | "http://example.com:17442/" |
| use-encryption | true if you intend to use SSL encryption (https) | false |
| cert-file-path | Cert file path - required if using encryption | "/etc/letsencrypt/live/example.com/fullchain.pem" |
| key-file-path | Private key file path - required if using encryption | "/etc/letsencrypt/live/example.com/privkey.pem" |
| path-base | Audio/video stream URL. Usually the same as backendurl | "http://example.com:17442/" |
| path-audio | Path to audio folder for saved mp3s | "audio/" |
| path-video | Path to video folder for saved mp4s | "video/" |
| title_top | Title shown on the top toolbar | "Youtube Downloader" |
| file_manager_enabled | true if you want to use the file manager | true |
| allow_quality_select | true if you want to select a videos quality level before downloading | true |
| download_only_mode | true if you want files to directly download to the client with no media player | false |
| use_youtube_API | true if you want to use the Youtube API which is used for YT searches | false |
| youtube_API_key | Youtube API key. Required if use_youtube_API is enabled | "" |
| default_theme | Default theme to use. Options are "default" and "dark" | "default" |
| allow_theme_change | true if you want the icon in the top toolbar that toggles dark mode | true |
## Deployment
If you'd like to install YoutubeDL-Material, go to the Installation section. If you want to build it yourself and/or develop the repository, then this section is for you.
@@ -46,6 +77,16 @@ The frontend is now complete. The backend is much easier. Just go into the `yout
Finally, port forward the port `17442` and point it to the server's IP address. Make sure the port is also allowed through the firewall.
## Docker
If you are looking to setup YoutubeDL-Material with Docker, this section is for you. And you're in luck! Docker setup is quite simple.
1. Run `curl https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest/download/docker-compose.yml -o docker-compose.yml` to download the latest release of `docker-compose.yml`, or go to the [releases](https://github.com/Tzahi12345/YoutubeDL-Material/releases/) page to grab the version you'd like.
2. Modify the config items in the `environment` section of `docker-compose.yml` to your liking. Otherwise, the default options will work and point to `http://localhost:8998`. You can find an explanation of these configuration items in [Configuration](#Configuration) section.
3. Make sure the port in the `frontend_url` environment variable lines up with the port in the `ports` section.
4. Run `docker-compose pull`. This will download the official YoutubeDL-Material docker image.
5. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 17442" or something similar. Make sure you can connect to the frontend, and if so, you are done!
## Contributing
Feel free to submit a pull request! I have no guidelines as of yet, so no need to worry about that.

View File

@@ -2,7 +2,6 @@ var async = require('async');
var fs = require('fs');
var path = require('path');
var youtubedl = require('youtube-dl');
var config = require('config');
var https = require('https');
var express = require("express");
var bodyParser = require("body-parser");
@@ -10,6 +9,7 @@ var archiver = require('archiver');
const low = require('lowdb')
var URL = require('url').URL;
const shortid = require('shortid')
var config_api = require('./config.js');
var app = express();
@@ -18,57 +18,56 @@ const adapter = new FileSync('db.json');
const db = low(adapter)
// Set some defaults
db.defaults({ playlists: {
audio: [],
video: []
}}).write();
db.defaults(
{
playlists: {
audio: [],
video: []
},
configWriteFlag: false
}).write();
// config values
var frontendUrl = null;
var backendUrl = null;
var backendPort = 17442;
var usingEncryption = null;
var basePath = null;
var audioFolderPath = null;
var videoFolderPath = null;
var downloadOnlyMode = null;
var useDefaultDownloadingAgent = null;
var customDownloadingAgent = null;
// other needed values
var options = null; // encryption options
var url_domain = null;
// check if debug mode
let debugMode = process.env.YTDL_MODE === 'debug';
if (debugMode) console.log('YTDL-Material in debug mode!');
var frontendUrl = !debugMode ? config.get("YoutubeDLMaterial.Host.frontendurl") : 'http://localhost:4200';
var backendUrl = config.get("YoutubeDLMaterial.Host.backendurl")
var backendPort = 17442;
var usingEncryption = config.get("YoutubeDLMaterial.Encryption.use-encryption");
var basePath = config.get("YoutubeDLMaterial.Downloader.path-base");
var audioFolderPath = config.get("YoutubeDLMaterial.Downloader.path-audio");
var videoFolderPath = config.get("YoutubeDLMaterial.Downloader.path-video");
var downloadOnlyMode = config.get("YoutubeDLMaterial.Extra.download_only_mode")
var validDownloadingAgents = [
'aria2c'
]
// don't overwrite config if it already happened.. NOT
// let alreadyWritten = db.get('configWriteFlag').value();
let writeConfigMode = process.env.write_ytdl_config;
var config = null;
if (writeConfigMode) {
setAndLoadConfig();
} else {
loadConfig();
}
var descriptors = {};
if (usingEncryption)
{
var certFilePath = path.resolve(config.get("YoutubeDLMaterial.Encryption.cert-file-path"));
var keyFilePath = path.resolve(config.get("YoutubeDLMaterial.Encryption.key-file-path"));
var certKeyFile = fs.readFileSync(keyFilePath);
var certFile = fs.readFileSync(certFilePath);
var options = {
key: certKeyFile,
cert: certFile
};
}
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
var url_domain = new URL(frontendUrl);
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", url_domain.origin);
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
app.get('/using-encryption', function(req, res) {
res.send(usingEncryption);
res.end("yes");
@@ -86,6 +85,109 @@ function File(id, title, thumbnailURL, isAudio, duration) {
// actual functions
function startServer() {
if (usingEncryption)
{
https.createServer(options, app).listen(backendPort, function() {
console.log('HTTPS: Anchor set on 17442');
});
}
else
{
app.listen(backendPort,function(){
console.log("HTTP: Started on PORT " + backendPort);
});
}
}
async function setAndLoadConfig() {
await setConfigFromEnv();
await loadConfig();
// console.log(backendUrl);
}
async function setConfigFromEnv() {
return new Promise(resolve => {
let config_items = getEnvConfigItems();
let success = config_api.setConfigItems(config_items);
if (success) {
console.log('Config items set using ENV variables.');
setTimeout(() => resolve(true), 100);
} else {
console.log('ERROR: Failed to set config items using ENV variables.');
resolve(false);
}
});
}
async function loadConfig() {
return new Promise(resolve => {
// get config library
// config = require('config');
frontendUrl = !debugMode ? config_api.getConfigItem('ytdl_frontend_url') : 'http://localhost:4200';
backendUrl = config_api.getConfigItem('ytdl_backend_url')
backendPort = 17442;
usingEncryption = config_api.getConfigItem('ytdl_use_encryption');
basePath = config_api.getConfigItem('ytdl_base_path');
audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
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');
if (!useDefaultDownloadingAgent && validDownloadingAgents.indexOf(customDownloadingAgent) !== -1 ) {
console.log(`INFO: Using non-default downloading agent \'${customDownloadingAgent}\'`)
}
if (usingEncryption)
{
var certFilePath = path.resolve(config_api.getConfigItem('ytdl_cert_file_path'));
var keyFilePath = path.resolve(config_api.getConfigItem('ytdl_key_file_path'));
var certKeyFile = fs.readFileSync(keyFilePath);
var certFile = fs.readFileSync(certFilePath);
options = {
key: certKeyFile,
cert: certFile
};
}
url_domain = new URL(frontendUrl);
// start the server here
startServer();
resolve(true);
});
}
function getOrigin() {
return url_domain.origin;
}
// gets a list of config items that are stored as an environment variable
function getEnvConfigItems() {
let config_items = [];
let config_item_keys = Object.keys(config_api.CONFIG_ITEMS);
for (let i = 0; i < config_item_keys.length; i++) {
let key = config_item_keys[i];
if (process['env'][key]) {
const config_item = generateEnvVarConfigItem(key);
config_items.push(config_item);
}
}
return config_items;
}
// gets value of a config item and stores it in an object
function generateEnvVarConfigItem(key) {
return {key: key, value: process['env'][key]};
}
function getThumbnailMp3(name)
{
var obj = getJSONMp3(name);
@@ -311,6 +413,30 @@ async function deleteVideoFile(name) {
});
}
function recFindByExt(base,ext,files,result)
{
files = files || fs.readdirSync(base)
result = result || []
files.forEach(
function (file) {
var newbase = path.join(base,file)
if ( fs.statSync(newbase).isDirectory() )
{
result = recFindByExt(newbase,ext,fs.readdirSync(newbase),result)
}
else
{
if ( file.substr(-1*(ext.length+1)) == '.' + ext )
{
result.push(newbase)
}
}
}
)
return result
}
function getAudioInfos(fileNames) {
let result = [];
for (let i = 0; i < fileNames.length; i++) {
@@ -347,9 +473,15 @@ function getVideoInfos(fileNames) {
// currently only works for single urls
async function getUrlInfos(urls) {
let startDate = Date.now();
let result = [];
return new Promise(resolve => {
youtubedl.exec(urls.join(' '), ['--external-downloader', 'aria2c', '--dump-json'], {}, (err, output) => {
youtubedl.exec(urls.join(' '), ['--dump-json'], {}, (err, output) => {
if (debugMode) {
let new_date = Date.now();
let difference = (new_date - startDate)/1000;
console.log(`URL info retrieval delay: ${difference} seconds.`);
}
if (err) {
console.log('Error during parsing:' + err);
resolve(null);
@@ -360,35 +492,57 @@ async function getUrlInfos(urls) {
result = try_putput;
} catch(e) {
// probably multiple urls
console.log('failed to parse');
console.log(output);
console.log('failed to parse for urls starting with ' + urls[0]);
// console.log(output);
}
resolve(result);
});
});
}
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", getOrigin());
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
app.post('/tomp3', function(req, res) {
var url = req.body.url;
var date = Date.now();
var path = audioFolderPath;
var audiopath = '%(title)s';
var customQualityConfiguration = req.body.customQualityConfiguration;
var maxBitrate = req.body.maxBitrate;
var customArgs = req.body.customArgs;
var customOutput = req.body.customOutput;
let downloadConfig = ['--external-downloader', 'aria2c', '-o', path + audiopath + ".mp3", '-x', '--audio-format', 'mp3', '--write-info-json', '--print-json']
let downloadConfig = null;
let qualityPath = '';
if (customQualityConfiguration) {
qualityPath = `-f ${customQualityConfiguration}`;
} else if (maxBitrate) {
if (!maxBitrate || maxBitrate === '') maxBitrate = '0';
qualityPath = `--audio-quality ${maxBitrate}`
}
if (customArgs) {
downloadConfig = [customArgs];
} 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 (qualityPath !== '') {
downloadConfig.splice(2, 0, qualityPath);
if (customQualityConfiguration) {
qualityPath = `-f ${customQualityConfiguration}`;
} else if (maxBitrate) {
if (!maxBitrate || maxBitrate === '') maxBitrate = '0';
qualityPath = `--audio-quality ${maxBitrate}`
}
if (qualityPath !== '') {
downloadConfig.splice(2, 0, qualityPath);
}
if (!useDefaultDownloadingAgent && customDownloadingAgent === 'aria2c') {
downloadConfig.splice(0, 0, '--external-downloader', 'aria2c');
}
}
youtubedl.exec(url, downloadConfig, {}, function(err, output) {
@@ -399,6 +553,7 @@ app.post('/tomp3', function(req, res) {
}
if (err) {
audiopath = "-1";
console.log(err.stderr);
res.sendStatus(500);
throw err;
} else if (output) {
@@ -411,16 +566,18 @@ app.post('/tomp3', function(req, res) {
output_json = null;
}
if (!output_json) {
// only run on first go
return;
// if invalid, continue onto the next
continue;
}
var file_name = output_json['_filename'].replace(/^.*[\\\/]/, '');
var file_path = output_json['_filename'].substring(audioFolderPath.length, output_json['_filename'].length);
var alternate_file_path = file_path.substring(0, file_path.length-4);
var alternate_file_name = file_name.substring(0, file_name.length-4);
if (alternate_file_name) file_names.push(alternate_file_name);
if (alternate_file_path) file_names.push(alternate_file_path);
}
let is_playlist = file_names.length > 1;
if (!is_playlist) audiopath = file_names[0];
// if (!is_playlist) audiopath = file_names[0];
var audiopathEncoded = encodeURIComponent(file_names[0]);
res.send({
@@ -436,19 +593,36 @@ app.post('/tomp4', function(req, res) {
var date = Date.now();
var path = videoFolderPath;
var videopath = '%(title)s';
var customArgs = req.body.customArgs;
var customOutput = req.body.customOutput;
var selectedHeight = req.body.selectedHeight;
var customQualityConfiguration = req.body.customQualityConfiguration;
let downloadConfig = null;
let qualityPath = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4';
if (customQualityConfiguration) {
qualityPath = customQualityConfiguration;
} else if (selectedHeight && selectedHeight !== '') {
qualityPath = `bestvideo[height=${selectedHeight}]+bestaudio/best[height=${selectedHeight}]`;
if (customArgs) {
downloadConfig = [customArgs];
} 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 (!useDefaultDownloadingAgent && customDownloadingAgent === 'aria2c') {
downloadConfig.splice(0, 0, '--external-downloader', 'aria2c');
}
}
youtubedl.exec(url, ['--external-downloader', 'aria2c', '-o', path + videopath + ".mp4", '-f', qualityPath, '--write-info-json', '--print-json'], {}, function(err, output) {
youtubedl.exec(url, downloadConfig, {}, function(err, output) {
if (debugMode) {
let new_date = Date.now();
let difference = (new_date - date)/1000;
@@ -456,6 +630,7 @@ app.post('/tomp4', function(req, res) {
}
if (err) {
videopath = "-1";
console.log(err.stderr);
res.sendStatus(500);
throw err;
} else if (output) {
@@ -482,7 +657,9 @@ app.post('/tomp4', function(req, res) {
}
}
var alternate_file_name = file_name.substring(0, file_name.length-4);
if (alternate_file_name) file_names.push(alternate_file_name);
var file_path = output_json['_filename'].substring(audioFolderPath.length, output_json['_filename'].length);
var alternate_file_path = file_path.substring(0, file_path.length-4);
if (alternate_file_name) file_names.push(alternate_file_path);
}
let is_playlist = file_names.length > 1;
@@ -544,31 +721,25 @@ app.post('/fileStatusMp4', function(req, res) {
app.post('/getMp3s', function(req, res) {
var mp3s = [];
var playlists = db.get('playlists.audio').value();
var fullpath = audioFolderPath;
var files = fs.readdirSync(audioFolderPath);
for (var i in files)
{
var nameLength = path.basename(files[i]).length;
var ext = path.basename(files[i]).substring(nameLength-4, nameLength);
if (ext == ".mp3")
var files = recFindByExt(audioFolderPath, 'mp3'); // fs.readdirSync(audioFolderPath);
for (let i = 0; i < files.length; i++) {
let file = files[i];
var file_path = file.substring(audioFolderPath.length, file.length);
var id = file_path.substring(0, file_path.length-4);
var jsonobj = getJSONMp3(id);
if (!jsonobj) continue;
var title = jsonobj.title;
if (title.length > 14) // edits title if it's too long
{
var jsonobj = getJSONMp3(path.basename(files[i]).substring(0, path.basename(files[i]).length-4));
if (!jsonobj) continue;
var id = path.basename(files[i]).substring(0, path.basename(files[i]).length-4);
var title = jsonobj.title;
if (title.length > 14) // edits title if it's too long
{
title = title.substring(0,12) + "...";
}
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = true;
var file = new File(id, title, thumbnail, isaudio, duration);
mp3s.push(file);
title = title.substring(0,12) + "...";
}
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = true;
var file_obj = new File(id, title, thumbnail, isaudio, duration);
mp3s.push(file_obj);
}
res.send({
@@ -583,30 +754,25 @@ app.post('/getMp4s', function(req, res) {
var mp4s = [];
var playlists = db.get('playlists.video').value();
var fullpath = videoFolderPath;
var files = fs.readdirSync(videoFolderPath);
for (var i in files)
{
var nameLength = path.basename(files[i]).length;
var ext = path.basename(files[i]).substring(nameLength-4, nameLength);
if (ext == ".mp4")
var files = recFindByExt(videoFolderPath, 'mp4');
for (let i = 0; i < files.length; i++) {
let file = files[i];
var file_path = file.substring(videoFolderPath.length, file.length);
var id = file_path.substring(0, file_path.length-4);
var jsonobj = getJSONMp4(id);
if (!jsonobj) continue;
var title = jsonobj.title;
if (title.length > 14) // edits title if it's too long
{
var jsonobj = getJSONMp4(path.basename(files[i]).substring(0, path.basename(files[i]).length-4));
if (!jsonobj) continue;
var id = path.basename(files[i]).substring(0, path.basename(files[i]).length-4);
var title = jsonobj.title;
if (title.length > 14) // edits title if it's too long
{
title = title.substring(0,12) + "...";
}
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = false;
var file = new File(id, title, thumbnail, isaudio, duration);
mp4s.push(file);
title = title.substring(0,12) + "...";
}
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = false;
var file_obj = new File(id, title, thumbnail, isaudio, duration);
mp4s.push(file_obj);
}
res.send({
@@ -639,6 +805,32 @@ app.post('/createPlaylist', async (req, res) => {
})
});
app.post('/updatePlaylist', async (req, res) => {
let playlistID = req.body.playlistID;
let fileNames = req.body.fileNames;
let type = req.body.type;
let success = false;
try {
db.get(`playlists.${type}`)
.find({id: playlistID})
.assign({fileNames: fileNames})
.write();
/*console.log('success!');
let new_val = db.get(`playlists.${type}`)
.find({id: playlistID})
.value();
console.log(new_val);*/
success = true;
} catch(e) {
console.error(`Failed to find playlist with ID ${playlistID}`);
}
res.send({
success: success
})
});
app.post('/deletePlaylist', async (req, res) => {
let playlistID = req.body.playlistID;
let type = req.body.type;
@@ -707,12 +899,16 @@ app.post('/downloadFile', async (req, res) => {
let outputName = req.body.outputName;
let file = null;
if (!is_playlist) {
fileNames = decodeURI(fileNames);
if (type === 'audio') {
file = __dirname + '/' + 'audio/' + fileNames + '.mp3';
file = __dirname + '/' + audioFolderPath + fileNames + '.mp3';
} else if (type === 'video') {
file = __dirname + '/' + 'video/' + fileNames + '.mp4';
file = __dirname + '/' + videoFolderPath + fileNames + '.mp4';
}
} else {
for (let i = 0; i < fileNames.length; i++) {
fileNames[i] = decodeURI(fileNames[i]);
}
file = await createPlaylistZipFile(fileNames, type, outputName);
}
@@ -732,46 +928,48 @@ app.post('/deleteFile', async (req, res) => {
app.get('/video/:id', function(req , res){
var head;
const path = "video/" + req.params.id + '.mp4';
const stat = fs.statSync(path)
const fileSize = stat.size
const range = req.headers.range
if (range) {
const parts = range.replace(/bytes=/, "").split("-")
const start = parseInt(parts[0], 10)
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1
const chunksize = (end-start)+1
const file = fs.createReadStream(path, {start, end})
if (descriptors[req.params.id]) descriptors[req.params.id].push(file);
else descriptors[req.params.id] = [file];
file.on('close', function() {
let index = descriptors[req.params.id].indexOf(file);
descriptors[req.params.id].splice(index, 1);
if (debugMode) console.log('Successfully closed stream and removed file reference.');
});
head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
let id = decodeURI(req.params.id);
const path = "video/" + id + '.mp4';
const stat = fs.statSync(path)
const fileSize = stat.size
const range = req.headers.range
if (range) {
const parts = range.replace(/bytes=/, "").split("-")
const start = parseInt(parts[0], 10)
const end = parts[1]
? parseInt(parts[1], 10)
: fileSize-1
const chunksize = (end-start)+1
const file = fs.createReadStream(path, {start, end})
if (descriptors[id]) descriptors[id].push(file);
else descriptors[id] = [file];
file.on('close', function() {
let index = descriptors[id].indexOf(file);
descriptors[id].splice(index, 1);
if (debugMode) console.log('Successfully closed stream and removed file reference.');
});
head = {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
}
res.writeHead(206, head);
file.pipe(res);
} else {
head = {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
}
res.writeHead(200, head)
fs.createReadStream(path).pipe(res)
}
res.writeHead(206, head);
file.pipe(res);
} else {
head = {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
}
res.writeHead(200, head)
fs.createReadStream(path).pipe(res)
}
});
app.get('/audio/:id', function(req , res){
var head;
let path = "audio/" + req.params.id + '.mp3';
let id = decodeURI(req.params.id);
let path = "audio/" + id + '.mp3';
path = path.replace(/\"/g, '\'');
const stat = fs.statSync(path)
const fileSize = stat.size
@@ -784,11 +982,11 @@ app.get('/audio/:id', function(req , res){
: fileSize-1
const chunksize = (end-start)+1
const file = fs.createReadStream(path, {start, end});
if (descriptors[req.params.id]) descriptors[req.params.id].push(file);
else descriptors[req.params.id] = [file];
if (descriptors[id]) descriptors[id].push(file);
else descriptors[id] = [file];
file.on('close', function() {
let index = descriptors[req.params.id].indexOf(file);
descriptors[req.params.id].splice(index, 1);
let index = descriptors[id].indexOf(file);
descriptors[id].splice(index, 1);
if (debugMode) console.log('Successfully closed stream and removed file reference.');
});
head = {
@@ -829,19 +1027,3 @@ app.get('/audio/:id', function(req , res){
success: !!result
})
});
if (usingEncryption)
{
https.createServer(options, app).listen(backendPort, function() {
console.log('HTTPS: Anchor set on 17442');
});
}
else
{
app.listen(backendPort,function(){
console.log("HTTP: Started on PORT " + backendPort);
});
}

112
backend/config.js Normal file
View File

@@ -0,0 +1,112 @@
const fs = require('fs');
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
let configPath = 'config/default.json';
// https://stackoverflow.com/questions/6491463/accessing-nested-javascript-objects-with-string-key
Object.byString = function(o, s) {
s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
s = s.replace(/^\./, ''); // strip a leading dot
var a = s.split('.');
for (var i = 0, n = a.length; i < n; ++i) {
var k = a[i];
if (k in o) {
o = o[k];
} else {
return;
}
}
return o;
}
function getParentPath(path) {
let elements = path.split('.');
elements.splice(elements.length - 1, 1);
return elements.join('.');
}
function getElementNameInConfig(path) {
let elements = path.split('.');
return elements[elements.length - 1];
}
/*
* Gets config file and returns as a json
*/
function getConfigFile() {
let raw_data = fs.readFileSync(configPath);
try {
let parsed_data = JSON.parse(raw_data);
return parsed_data;
} catch(e) {
console.log('ERROR: Failed to get config file');
return null;
}
}
function setConfigFile(config) {
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return true;
} catch(e) {
return false;
}
}
function getConfigItem(key) {
let config_json = getConfigFile();
if (!CONFIG_ITEMS[key]) console.log('cannot find config with key ' + key);
let path = CONFIG_ITEMS[key]['path'];
return Object.byString(config_json, path);
};
function setConfigItem(key, value) {
let success = false;
let config_json = getConfigFile();
let path = CONFIG_ITEMS[key]['path'];
let parent_path = getParentPath(path);
let element_name = getElementNameInConfig(path);
let parent_object = Object.byString(config_json, parent_path);
if (value === 'false' || value === 'true') {
parent_object[element_name] = (value === 'true');
} else {
parent_object[element_name] = value;
}
success = setConfigFile(config_json);
return success;
};
function setConfigItems(items) {
let success = false;
let config_json = getConfigFile();
for (let i = 0; i < items.length; i++) {
let key = items[i].key;
let value = items[i].value;
// if boolean strings, set to booleans again
if (value === 'false' || value === 'true') {
value = (value === 'true');
}
let item_path = CONFIG_ITEMS[key]['path'];
let item_parent_path = getParentPath(item_path);
let item_element_name = getElementNameInConfig(item_path);
let item_parent_object = Object.byString(config_json, item_parent_path);
item_parent_object[item_element_name] = value;
}
success = setConfigFile(config_json);
return success;
}
module.exports = {
getConfigItem: getConfigItem,
setConfigItem: setConfigItem,
setConfigItems: setConfigItems,
CONFIG_ITEMS: CONFIG_ITEMS
}

View File

@@ -18,7 +18,8 @@
"title_top": "Youtube Downloader",
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false
"download_only_mode": false,
"allow_multi_download_mode": true
},
"API": {
"use_youtube_API": false,
@@ -27,6 +28,11 @@
"Themes": {
"default_theme": "default",
"allow_theme_change": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"allow_advanced_download": false
}
}
}

View File

@@ -18,7 +18,8 @@
"title_top": "Youtube Downloader",
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false
"download_only_mode": false,
"allow_multi_download_mode": true
},
"API": {
"use_youtube_API": false,
@@ -27,6 +28,11 @@
"Themes": {
"default_theme": "default",
"allow_theme_change": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"allow_advanced_download": false
}
}
}

100
backend/consts.js Normal file
View File

@@ -0,0 +1,100 @@
var config = require('config');
let CONFIG_ITEMS = {
// Host
'ytdl_frontend_url': {
'key': 'ytdl_frontend_url',
'path': 'YoutubeDLMaterial.Host.frontendurl'
},
'ytdl_backend_url': {
'key': 'ytdl_backend_url',
'path': 'YoutubeDLMaterial.Host.backendurl'
},
// Encryption
'ytdl_use_encryption': {
'key': 'ytdl_use_encryption',
'path': 'YoutubeDLMaterial.Encryption.use-encryption'
},
'ytdl_cert_file_path': {
'key': 'ytdl_cert_file_path',
'path': 'YoutubeDLMaterial.Encryption.cert-file-path'
},
'ytdl_key_file_path': {
'key': 'ytdl_key_file_path',
'path': 'YoutubeDLMaterial.Encryption.key-file-path'
},
// Downloader
'ytdl_base_path': {
'key': 'ytdl_base_path',
'path': 'YoutubeDLMaterial.Downloader.path-base'
},
'ytdl_audio_folder_path': {
'key': 'ytdl_audio_folder_path',
'path': 'YoutubeDLMaterial.Downloader.path-audio'
},
'ytdl_video_folder_path': {
'key': 'ytdl_video_folder_path',
'path': 'YoutubeDLMaterial.Downloader.path-video'
},
// Extra
'ytdl_title_top': {
'key': 'ytdl_title_top',
'path': 'YoutubeDLMaterial.Extra.title_top'
},
'ytdl_file_manager_enabled': {
'key': 'ytdl_file_manager_enabled',
'path': 'YoutubeDLMaterial.Extra.file_manager_enabled'
},
'ytdl_allow_quality_select': {
'key': 'ytdl_allow_quality_select',
'path': 'YoutubeDLMaterial.Extra.allow_quality_select'
},
'ytdl_download_only_mode': {
'key': 'ytdl_download_only_mode',
'path': 'YoutubeDLMaterial.Extra.download_only_mode'
},
'ytdl_allow_multi_download_mode': {
'key': 'ytdl_allow_multi_download_mode',
'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode'
},
// API
'ytdl_use_youtube_api': {
'key': 'ytdl_use_youtube_api',
'path': 'YoutubeDLMaterial.API.use_youtube_API'
},
'ytdl_youtube_api_key': {
'key': 'ytdl_youtube_api_key',
'path': 'YoutubeDLMaterial.API.youtube_API_key'
},
// Themes
'ytdl_default_theme': {
'key': 'ytdl_default_theme',
'path': 'YoutubeDLMaterial.Themes.default_theme'
},
'ytdl_allow_theme_change': {
'key': 'ytdl_allow_theme_change',
'path': 'YoutubeDLMaterial.Themes.allow_theme_change'
},
// Advanced
'ytdl_use_default_downloading_agent': {
'key': 'ytdl_use_default_downloading_agent',
'path': 'YoutubeDLMaterial.Advanced.use_default_downloading_agent'
},
'ytdl_custom_downloading_agent': {
'key': 'ytdl_custom_downloading_agent',
'path': 'YoutubeDLMaterial.Advanced.custom_downloading_agent'
},
'ytdl_allow_advanced_download': {
'key': 'ytdl_allow_advanced_download',
'path': 'YoutubeDLMaterial.Advanced.allow_advanced_download'
},
};
module.exports.CONFIG_ITEMS = CONFIG_ITEMS;

1345
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,8 @@
"description": "backend for YoutubeDL-Material",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node app.js"
},
"repository": {
"type": "git",
@@ -24,6 +25,6 @@
"express": "^4.17.1",
"lowdb": "^1.0.0",
"shortid": "^2.2.15",
"youtube-dl": "^2.3.0"
"youtube-dl": "^3.0.2"
}
}

Binary file not shown.

6
db.json Normal file
View File

@@ -0,0 +1,6 @@
{
"playlists": {
"audio": [],
"video": []
}
}

35
docker-compose.yml Normal file
View File

@@ -0,0 +1,35 @@
version: "2"
services:
ytdl_material:
build: .
environment:
# config items
ytdl_frontend_url: http://localhost:8998
ytdl_backend_url: http://localhost:17442/
ytdl_use_encryption: 'false'
ytdl_cert_file_path: /etc/letsencrypt/live/example.com/fullchain.pem
ytdl_key_file_path: /etc/letsencrypt/live/example.com/privkey.pem
ytdl_base_path: http://localhost:17442/
ytdl_audio_folder_path: audio/
ytdl_video_folder_path: video/
ytdl_title_top: Youtube Downloader
ytdl_file_manager_enabled: 'true'
ytdl_allow_quality_select: 'true'
ytdl_download_only_mode: 'false'
ytdl_allow_multi_download_mode: true
ytdl_use_youtube_api: 'false'
ytdl_youtube_api_key: 'false'
ytdl_default_theme: default
ytdl_allow_theme_change: 'true'
ytdl_use_default_downloading_agent: 'true'
ytdl_custom_downloading_agent: 'false'
ytdl_allow_advanced_download: 'false'
# do not touch this
write_ytdl_config: 'true'
ALLOW_CONFIG_MUTATIONS: 'true'
restart: always
ports:
- "17442:17442"
- "8998:80"
image: tzahi12345/youtubedl-material:3.1

39
docker_wrapper.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/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

0
installer.py Normal file
View File

12768
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,10 @@
"e2e": "ng e2e",
"electron": "ng build --base-href ./ && electron ."
},
"engines": {
"node": "12.3.1",
"npm": "6.10.3"
},
"private": true,
"dependencies": {
"@angular-devkit/core": "^8.3.12",
@@ -35,7 +39,8 @@
"tslib": "^1.10.0",
"videogular2": "^7.0.1",
"web-animations-js": "^2.3.2",
"zone.js": "~0.9.1"
"zone.js": "~0.9.1",
"typescript": "~3.5.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.803.24",

View File

@@ -1,4 +1,4 @@
<div [style.background]="postsService.theme ? postsService.theme.background_color : null" style="width: 100%; height: 100%;">
<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;">

View File

@@ -37,7 +37,7 @@ export class AppComponent implements OnInit {
@ViewChild('urlinput', { read: ElementRef, static: false }) urlInput: ElementRef;
constructor(public postsService: PostsService, public snackBar: MatSnackBar,
public router: Router, public overlayContainer: OverlayContainer) {
public router: Router, public overlayContainer: OverlayContainer, private elementRef: ElementRef) {
// loading config
this.postsService.loadNavItems().subscribe(result => { // loads settings
@@ -76,6 +76,7 @@ export class AppComponent implements OnInit {
}
}
localStorage.setItem('theme', theme);
this.elementRef.nativeElement.ownerDocument.body.style.backgroundColor = this.THEMES_CONFIG[theme]['background_color'];
} else {
console.error('Invalid theme: ' + theme);
return;

View File

@@ -3,17 +3,16 @@ import { NgModule } from '@angular/core';
import {MatNativeDateModule, MatRadioModule, MatInputModule, MatButtonModule, MatSidenavModule, MatIconModule, MatListModule,
MatSnackBarModule, MatCardModule, MatSelectModule, MatToolbarModule, MatCheckboxModule, MatGridListModule,
MatProgressBarModule, MatExpansionModule,
MatGridList,
MatProgressSpinnerModule,
MatButtonToggleModule,
MatDialogModule} from '@angular/material';
import {DragDropModule} from '@angular/cdk/drag-drop';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import { AppComponent } from './app.component';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import { HttpModule } from '@angular/http';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { PostsService } from 'app/posts.services';
import {APP_BASE_HREF} from '@angular/common';
import { FileCardComponent } from './file-card/file-card.component';
import {RouterModule} from '@angular/router';
import { AppRoutingModule } from './app-routing.module';
@@ -24,8 +23,15 @@ import {VgControlsModule} from 'videogular2/compiled/controls';
import {VgOverlayPlayModule} from 'videogular2/compiled/overlay-play';
import {VgBufferingModule} from 'videogular2/compiled/buffering';
import { InputDialogComponent } from './input-dialog/input-dialog.component';
import { LazyLoadImageModule } from 'ng-lazyload-image';
import { LazyLoadImageModule, IsVisibleProps } from 'ng-lazyload-image';
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';
export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps<any>) {
return (element.id === 'video' ? videoFilesMouseHovering || videoFilesOpened : audioFilesMouseHovering || audioFilesOpened);
}
@NgModule({
declarations: [
@@ -33,7 +39,9 @@ import { NgxContentLoadingModule } from 'ngx-content-loading';
FileCardComponent,
MainComponent,
PlayerComponent,
InputDialogComponent
InputDialogComponent,
CreatePlaylistComponent,
DownloadItemComponent
],
imports: [
BrowserModule,
@@ -60,17 +68,19 @@ import { NgxContentLoadingModule } from 'ngx-content-loading';
MatProgressSpinnerModule,
MatButtonToggleModule,
MatDialogModule,
DragDropModule,
VgCoreModule,
VgControlsModule,
VgOverlayPlayModule,
VgBufferingModule,
LazyLoadImageModule,
LazyLoadImageModule.forRoot({ isVisible }),
NgxContentLoadingModule,
RouterModule,
AppRoutingModule,
],
entryComponents: [
InputDialogComponent
InputDialogComponent,
CreatePlaylistComponent
],
providers: [PostsService],
bootstrap: [AppComponent]

View File

@@ -0,0 +1,19 @@
<h4 mat-dialog-title>Create a playlist</h4>
<form>
<div>
<mat-form-field color="accent">
<input [(ngModel)]="name" matInput placeholder="Name" type="text" required aria-required [ngModelOptions]="{standalone: true}">
</mat-form-field>
</div>
<div>
<mat-form-field color="accent">
<mat-label>{{(type === 'audio') ? 'Audio files' : 'Videos'}}</mat-label>
<mat-select [formControl]="filesSelect" multiple required aria-required>
<mat-option *ngFor="let file of filesToSelectFrom" [value]="file.id">{{file.id}}</mat-option>
</mat-select>
</mat-form-field>
</div>
</form>
<div *ngIf="create_in_progress" style="float: left"><mat-spinner [diameter]="25"></mat-spinner></div>
<button (click)="createPlaylist()" [disabled]="!name || !filesSelect.value || filesSelect.value.length === 0" color="primary" style="float: right" mat-flat-button>Create</button>

View File

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

View File

@@ -0,0 +1,58 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { FormControl } from '@angular/forms';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-create-playlist',
templateUrl: './create-playlist.component.html',
styleUrls: ['./create-playlist.component.scss']
})
export class CreatePlaylistComponent implements OnInit {
// really "createPlaylistDialogComponent"
filesToSelectFrom = null;
type = null;
filesSelect = new FormControl();
name = '';
create_in_progress = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any,
private postsService: PostsService,
public dialogRef: MatDialogRef<CreatePlaylistComponent>) { }
ngOnInit() {
if (this.data) {
this.filesToSelectFrom = this.data.filesToSelectFrom;
this.type = this.data.type;
}
}
createPlaylist() {
const thumbnailURL = this.getThumbnailURL();
this.create_in_progress = true;
this.postsService.createPlaylist(this.name, this.filesSelect.value, this.type, thumbnailURL).subscribe(res => {
this.create_in_progress = false;
if (res['success']) {
this.dialogRef.close(true);
} else {
this.dialogRef.close(false);
}
});
}
getThumbnailURL() {
for (let i = 0; i < this.filesToSelectFrom.length; i++) {
const file = this.filesToSelectFrom[i];
if (file.id === this.filesSelect.value[0]) {
// different services store the thumbnail in different places
if (file.thumbnailURL) { return file.thumbnailURL };
if (file.thumbnail) { return file.thumbnail };
}
}
return null;
}
}

View File

@@ -0,0 +1,16 @@
<div>
<mat-grid-list [rowHeight]="50" [cols]="24">
<mat-grid-tile [colspan]="2">
<h5 style="display: inline-block; margin-right: 5px; position: relative; top: 5px;">{{queueNumber}}.</h5>
</mat-grid-tile>
<mat-grid-tile [colspan]="6">
<div style="display: inline-block; text-align: center;">ID: {{url_id}}</div>
</mat-grid-tile>
<mat-grid-tile [colspan]="13">
<mat-progress-bar style="width: 80%" [value]="download.percent_complete" [mode]="(download.percent_complete === 0) ? 'indeterminate' : 'determinate'"></mat-progress-bar>
</mat-grid-tile>
<mat-grid-tile [colspan]="3">
<button (click)="cancelTheDownload()" mat-icon-button color="warn"><mat-icon fontSet="material-icons-outlined">cancel</mat-icon></button>
</mat-grid-tile>
</mat-grid-list>
</div>

View File

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

View File

@@ -0,0 +1,41 @@
import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
import { Download } from 'app/main/main.component';
@Component({
selector: 'app-download-item',
templateUrl: './download-item.component.html',
styleUrls: ['./download-item.component.scss']
})
export class DownloadItemComponent implements OnInit {
@Input() download: Download = {
uid: null,
type: 'audio',
percent_complete: 0,
url: 'http://youtube.com/watch?v=17848rufj',
downloading: true,
is_playlist: false
};
@Output() cancelDownload = new EventEmitter<Download>();
@Input() queueNumber = null;
url_id = null;
constructor() { }
ngOnInit() {
if (this.download && this.download.url && this.download.url.includes('youtube')) {
const string_id = (this.download.is_playlist ? '?list=' : '?v=')
const index_offset = (this.download.is_playlist ? 6 : 3);
const end_index = this.download.url.indexOf(string_id) + index_offset;
this.url_id = this.download.url.substring(end_index, this.download.url.length);
}
}
cancelTheDownload() {
this.cancelDownload.emit(this.download);
}
}

View File

@@ -1,21 +1,17 @@
<mat-card class="example-card mat-elevation-z6">
<button (click)="deleteFile()" class="deleteButton" mat-icon-button><mat-icon>delete_forever</mat-icon></button>
<div style="padding:5px">
<b><a href="javascript:void(0)" (click)="!isPlaylist ? mainComponent.goToFile(name, isAudio) : mainComponent.goToPlaylist(name, type)">{{title}}</a></b>
<br/>
<span class="max-two-lines">ID: {{name}}</span>
<div *ngIf="isPlaylist">Count: {{count}}</div>
<div class="img-div">
<img class="image" [lazyLoad]="thumbnailURL" (onLoad)="imageLoaded($event)" alt="Thumbnail">
<div *ngIf="!image_errored && thumbnailURL" class="img-div">
<img class="image" (error) ="onImgError($event)" [id]="type" [lazyLoad]="thumbnailURL" [customObservable]="scrollAndLoad" (onLoad)="imageLoaded($event)" alt="Thumbnail">
<span *ngIf="!image_loaded">
<ngx-content-loading [width]="500" [height]="360">
<svg:g ngx-rect width="500" height="360" y="0" x="0" rx="4" ry="4"></svg:g>
</ngx-content-loading>
</span>
</div>
</div>
</div>
<button (click)="deleteFile()" class="deleteButton" mat-icon-button><mat-icon>delete_forever</mat-icon></button>
</mat-card>

View File

@@ -3,6 +3,8 @@ import {PostsService} from '../posts.services';
import {MatSnackBar} from '@angular/material';
import {EventEmitter} from '@angular/core';
import { MainComponent } from 'app/main/main.component';
import { Subject, Observable } from 'rxjs';
import 'rxjs/add/observable/merge';
@Component({
selector: 'app-file-card',
@@ -21,8 +23,18 @@ export class FileCardComponent implements OnInit {
@Input() count = null;
type;
image_loaded = false;
image_errored = false;
constructor(private postsService: PostsService, public snackBar: MatSnackBar, public mainComponent: MainComponent) { }
scrollSubject;
scrollAndLoad;
constructor(private postsService: PostsService, public snackBar: MatSnackBar, public mainComponent: MainComponent) {
this.scrollSubject = new Subject();
this.scrollAndLoad = Observable.merge(
Observable.fromEvent(window, 'scroll'),
this.scrollSubject
);
}
ngOnInit() {
this.type = this.isAudio ? 'audio' : 'video';
@@ -44,6 +56,14 @@ export class FileCardComponent implements OnInit {
}
onImgError(event) {
this.image_errored = true;
}
onHoverResponse() {
this.scrollSubject.next();
}
imageLoaded(loaded) {
this.image_loaded = true;
}

View File

@@ -111,4 +111,14 @@ mat-form-field.mat-form-field {
position: absolute;
bottom: 0px;
width: 150px;
}
.add-playlist-button {
float: right;
}
.advanced-input {
width: 100%;
margin-top: 20px;
margin-bottom: 20px;
}

View File

@@ -11,7 +11,7 @@
<div class="row">
<div [ngClass]="allowQualitySelect ? 'col-sm-9' : null" class="col-12">
<mat-form-field color="accent" class="example-full-width">
<input style="padding-right: 25px;" matInput (ngModelChange)="inputChanged($event)" [(ngModel)]="url" [placeholder]="'URL' + (youtubeSearchEnabled ? ' or search' : '')" type="url" name="url" [formControl]="urlForm" required #urlinput>
<input style="padding-right: 25px;" matInput (ngModelChange)="inputChanged($event)" [(ngModel)]="url" [placeholder]="'URL' + (youtubeSearchEnabled ? ' or search' : '')" type="url" name="url" [formControl]="urlForm" required #urlinput>
<mat-error *ngIf="urlError || urlForm.invalid">Please enter a valid URL!</mat-error>
<button class="input-clear-button" mat-icon-button (click)="clearInput()"><mat-icon>clear</mat-icon></button>
</mat-form-field>
@@ -21,12 +21,12 @@
<mat-label>Quality</mat-label>
<mat-select [ngModelOptions]="{standalone: true}" [(ngModel)]="selectedQuality">
<ng-container *ngFor="let option of qualityOptions[(audioOnly) ? 'audio' : 'video']">
<mat-option *ngIf="option.value === '' || url && cachedAvailableFormats[url] && cachedAvailableFormats[url] && cachedAvailableFormats[url][(audioOnly) ? 'audio' : 'video'][option.value]" [value]="option.value">
<mat-option *ngIf="option.value === '' || url && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats'] && cachedAvailableFormats[url]['formats'][(audioOnly) ? 'audio' : 'video'][option.value]" [value]="option.value">
{{option.label}}
</mat-option>
</ng-container>
</mat-select>
<div class="spinner-div" *ngIf="formats_loading && !cachedAvailableFormats[url]">
<div class="spinner-div" *ngIf="url !== '' && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats_loading']">
<mat-spinner [diameter]="25"></mat-spinner>
</div>
</mat-form-field>
@@ -49,18 +49,61 @@
</div>
</form>
<br/>
<mat-checkbox (change)="videoModeChanged($event)" [(ngModel)]="audioOnly" style="float: left; margin-top: -12px">Only Audio</mat-checkbox>
<mat-checkbox [disabled]="current_download" (change)="videoModeChanged($event)" [(ngModel)]="audioOnly" style="float: left; margin-top: -12px">Only Audio</mat-checkbox>
<mat-checkbox *ngIf="allowMultiDownloadMode" [disabled]="current_download" (change)="multiDownloadModeChanged($event)" [(ngModel)]="multiDownloadMode" style="float: right; margin-top: -12px">Multi-download mode</mat-checkbox>
</div>
</mat-card-content>
<mat-card-actions>
<button style="margin-left: 8px; margin-bottom: 8px" (click)="downloadClicked()" [disabled]="downloadingfile" type="submit" mat-stroked-button
color="accent">Download</button>
<button (click)="cancelDownload()" style="float: right" *ngIf="!!current_download" mat-stroked-button color="warn">Cancel</button>
</mat-card-actions>
</mat-card>
</div>
<div *ngIf="false && allowAdvancedDownload" class="big demo-basic">
<form style="margin-left: 20px; margin-right: 20px;">
<mat-expansion-panel class="big">
<mat-expansion-panel-header>
<mat-panel-title>
Advanced
</mat-panel-title>
</mat-expansion-panel-header>
<div class="container" style="padding-bottom: 20px;">
<div class="row">
<div class="col">
<mat-checkbox color="accent" [disabled]="current_download" (change)="customArgsEnabledChanged($event)" [(ngModel)]="customArgsEnabled" style="position: absolute; z-index: 999" [ngModelOptions]="{standalone: true}">Use custom args</mat-checkbox>
<mat-form-field color="accent" class="advanced-input">
<input [(ngModel)]="customArgs" [ngModelOptions]="{standalone: true}" [disabled]="!customArgsEnabled" matInput placeholder="Custom args">
<mat-hint>No need to include URL, just everything after.</mat-hint>
</mat-form-field>
</div>
<div class="col">
<mat-checkbox color="accent" [disabled]="current_download" (change)="customOutputEnabledChanged($event)" [(ngModel)]="customOutputEnabled" style="position: absolute; z-index: 999" [ngModelOptions]="{standalone: true}">Use custom output</mat-checkbox>
<mat-form-field color="accent" class="advanced-input">
<input [(ngModel)]="customOutput" [ngModelOptions]="{standalone: true}" [disabled]="!customOutputEnabled" matInput placeholder="Custom output">
<mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">This link</a> will be helpful. Path is relative to the config download path.</mat-hint>
</mat-form-field>
</div>
</div>
</div>
</mat-expansion-panel>
</form>
</div>
<div *ngIf="multiDownloadMode && downloads.length > 0 && !current_download" style="margin-top: 15px;" class="big demo-basic">
<mat-card id="card" style="margin-right: 20px; margin-left: 20px;">
<div class="container">
<div *ngFor="let download of downloads; let i = index;" class="row">
<ng-container *ngIf="current_download !== download">
<app-download-item style="width: 100%" [download]="download" [queueNumber]="i+1" (cancelDownload)="cancelDownload($event)"></app-download-item>
<mat-divider style="position: relative" *ngIf="i !== downloads.length - 1"></mat-divider>
</ng-container>
</div>
</div>
</mat-card>
</div>
<br/>
<div class="centered big" id="bar_div" *ngIf="downloadingfile;else nofile">
<div class="centered big" id="bar_div" *ngIf="current_download && current_download.downloading; else nofile">
<div class="margined">
<div [ngClass]="(determinateProgress && percentDownloaded === 100)?'make-room-for-spinner':'equal-sizes'" style="display: inline-block; width: 100%; padding-left: 20px" *ngIf="determinateProgress;else indeterminateprogress">
<mat-progress-bar mode="determinate" value="{{percentDownloaded}}"></mat-progress-bar>
@@ -80,7 +123,7 @@
</ng-template>
<div style="margin: 20px" *ngIf="fileManagerEnabled">
<mat-accordion>
<mat-expansion-panel class="big">
<mat-expansion-panel (opened)="accordionOpened('audio')" (closed)="accordionClosed('audio')" (mouseleave)="accordionLeft('audio')" (mouseenter)="accordionEntered('audio')" class="big">
<mat-expansion-panel-header>
<mat-panel-title>
Audio
@@ -92,26 +135,30 @@
<div *ngIf="mp3s.length > 0;else nomp3s">
<mat-grid-list style="margin-bottom: 15px;" (window:resize)="onResize($event)" [cols]="files_cols" rowHeight="150px">
<mat-grid-tile *ngFor="let file of mp3s; index as i;">
<app-file-card (removeFile)="removeFromMp3($event)" [title]="file.title" [name]="file.id" [thumbnailURL]="file.thumbnailURL"
<app-file-card #audiofilecard (removeFile)="removeFromMp3($event)" [title]="file.title" [name]="file.id" [thumbnailURL]="file.thumbnailURL"
[length]="file.duration" [isAudio]="true"></app-file-card>
<mat-progress-bar *ngIf="downloading_content['audio'][file.id]" class="download-progress-bar" mode="indeterminate"></mat-progress-bar>
</mat-grid-tile>
</mat-grid-list>
<mat-divider *ngIf="playlists.audio.length > 0"></mat-divider>
<div style="width: 100%; text-align: center; margin-top: 10px;" *ngIf="playlists.audio.length > 0">
<mat-divider></mat-divider>
<div style="width: 100%; text-align: center; margin-top: 10px;">
<h6>Playlists</h6>
</div>
<mat-grid-list *ngIf="playlists.audio.length > 0" (window:resize)="onResize($event)" [cols]="files_cols" rowHeight="150px">
<mat-grid-tile *ngFor="let playlist of playlists.audio; let i = index;">
<app-file-card (removeFile)="removePlaylistMp3(playlist.id, i)" [title]="playlist.name" [name]="playlist.id" [thumbnailURL]="playlist_thumbnails[playlist.id]"
<app-file-card #audiofilecard (removeFile)="removePlaylistMp3(playlist.id, i)" [title]="playlist.name" [name]="playlist.id" [thumbnailURL]="playlist_thumbnails[playlist.id]"
[length]="null" [isAudio]="true" [isPlaylist]="true" [count]="playlist.fileNames.length"></app-file-card>
<mat-progress-bar *ngIf="downloading_content['audio'][playlist.id]" class="download-progress-bar" mode="indeterminate"></mat-progress-bar>
</mat-grid-tile>
</mat-grid-list>
<div class="add-playlist-button"><button (click)="openCreatePlaylistDialog('audio')" mat-fab><mat-icon>add</mat-icon></button></div>
<div *ngIf="playlists.audio.length === 0">
No playlists available. Create one from your downloading audio files by clicking the blue plus button.
</div>
</div>
</mat-expansion-panel>
<mat-expansion-panel class="big">
<mat-expansion-panel (opened)="accordionOpened('video')" (closed)="accordionClosed('video')" (mouseleave)="accordionLeft('video')" (mouseenter)="accordionEntered('video')" class="big">
<mat-expansion-panel-header>
<mat-panel-title>
Video
@@ -123,23 +170,29 @@
<div *ngIf="mp4s.length > 0;else nomp4s">
<mat-grid-list style="margin-bottom: 15px;" (window:resize)="onResize($event)" [cols]="files_cols" rowHeight="150px">
<mat-grid-tile *ngFor="let file of mp4s; index as i;">
<app-file-card (removeFile)="removeFromMp4($event)" [title]="file.title" [name]="file.id" [thumbnailURL]="file.thumbnailURL"
<app-file-card #videofilecard (removeFile)="removeFromMp4($event)" [title]="file.title" [name]="file.id" [thumbnailURL]="file.thumbnailURL"
[length]="file.duration" [isAudio]="false"></app-file-card>
<mat-progress-bar *ngIf="downloading_content['video'][file.id]" class="download-progress-bar" mode="indeterminate"></mat-progress-bar>
</mat-grid-tile>
</mat-grid-list>
<mat-divider *ngIf="playlists.video.length > 0"></mat-divider>
<mat-divider></mat-divider>
<div style="width: 100%; text-align: center; margin-top: 10px;" *ngIf="playlists.video.length > 0">
<div style="width: 100%; text-align: center; margin-top: 10px;">
<h6>Playlists</h6>
</div>
<mat-grid-list *ngIf="playlists.video.length > 0" (window:resize)="onResize($event)" [cols]="files_cols" rowHeight="150px">
<mat-grid-tile *ngFor="let playlist of playlists.video; let i = index;">
<app-file-card (removeFile)="removePlaylistMp4(playlist.id, i)" [title]="playlist.name" [name]="playlist.id" [thumbnailURL]="playlist_thumbnails[playlist.id]"
<app-file-card #videofilecard (removeFile)="removePlaylistMp4(playlist.id, i)" [title]="playlist.name" [name]="playlist.id" [thumbnailURL]="playlist_thumbnails[playlist.id]"
[length]="null" [isAudio]="false" [isPlaylist]="true" [count]="playlist.fileNames.length"></app-file-card>
<mat-progress-bar *ngIf="downloading_content['video'][playlist.id]" class="download-progress-bar" mode="indeterminate"></mat-progress-bar>
</mat-grid-tile>
</mat-grid-list>
<!-- Add video playlist button -->
<div class="add-playlist-button"><button (click)="openCreatePlaylistDialog('video')" mat-fab><mat-icon>add</mat-icon></button></div>
<div *ngIf="playlists.video.length === 0">
No playlists available. Create one from your downloading video files by clicking the blue plus button.
</div>
</div>
</mat-expansion-panel>
</mat-accordion>

View File

@@ -1,10 +1,10 @@
import { Component, OnInit, ElementRef, ViewChild } 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';
import {FormControl, Validators} from '@angular/forms';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {MatSnackBar} from '@angular/material';
import {MatSnackBar, MatDialog} from '@angular/material';
import { saveAs } from 'file-saver';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/mapTo';
@@ -16,6 +16,24 @@ import 'rxjs/add/operator/do'
import 'rxjs/add/operator/switch'
import { YoutubeSearchService, Result } from '../youtube-search.service';
import { Router } from '@angular/router';
import { CreatePlaylistComponent } from 'app/create-playlist/create-playlist.component';
import { Platform } from '@angular/cdk/platform';
import { v4 as uuid } from 'uuid';
export let audioFilesMouseHovering = false;
export let videoFilesMouseHovering = false;
export let audioFilesOpened = false;
export let videoFilesOpened = false;
export interface Download {
uid: string;
type: string;
url: string;
percent_complete: number;
downloading: boolean;
is_playlist: boolean;
fileNames?: string[];
}
@Component({
selector: 'app-root',
@@ -28,6 +46,11 @@ export class MainComponent implements OnInit {
determinateProgress = false;
downloadingfile = false;
audioOnly: boolean;
multiDownloadMode = false;
customArgsEnabled = false;
customArgs = null;
customOutputEnabled = false;
customOutput = null;
urlError = false;
path = '';
url = '';
@@ -38,9 +61,11 @@ export class MainComponent implements OnInit {
fileManagerEnabled = false;
allowQualitySelect = false;
downloadOnlyMode = false;
allowMultiDownloadMode = false;
baseStreamPath;
audioFolderPath;
videoFolderPath;
allowAdvancedDownload = false;
cachedAvailableFormats = {};
@@ -57,6 +82,8 @@ export class MainComponent implements OnInit {
playlists = {'audio': [], 'video': []};
playlist_thumbnails = {};
downloading_content = {'audio': {}, 'video': {}};
downloads: Download[] = [];
current_download: Download = null;
urlForm = new FormControl('', [Validators.required]);
@@ -156,19 +183,30 @@ export class MainComponent implements OnInit {
formats_loading = false;
@ViewChild('urlinput', { read: ElementRef, static: false }) urlInput: ElementRef;
@ViewChildren('audiofilecard') audioFileCards: QueryList<FileCardComponent>;
@ViewChildren('videofilecard') videoFileCards: QueryList<FileCardComponent>;
last_valid_url = '';
last_url_check = 0;
constructor(private postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
private router: Router) {
this.audioOnly = false;
test_download: Download = {
uid: null,
type: 'audio',
percent_complete: 0,
url: 'http://youtube.com/watch?v=17848rufj',
downloading: true,
is_playlist: false
};
constructor(private postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
private router: Router, public dialog: MatDialog, private platform: Platform) {
this.audioOnly = false;
// loading config
this.postsService.loadNavItems().subscribe(result => { // loads settings
const backendUrl = result['YoutubeDLMaterial']['Host']['backendurl'];
this.fileManagerEnabled = result['YoutubeDLMaterial']['Extra']['file_manager_enabled'];
this.downloadOnlyMode = result['YoutubeDLMaterial']['Extra']['download_only_mode'];
this.allowMultiDownloadMode = result['YoutubeDLMaterial']['Extra']['allow_multi_download_mode'];
this.baseStreamPath = result['YoutubeDLMaterial']['Downloader']['path-base'];
this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio'];
this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video'];
@@ -176,6 +214,7 @@ export class MainComponent implements OnInit {
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.postsService.path = backendUrl;
this.postsService.startPath = backendUrl;
@@ -190,6 +229,18 @@ export class MainComponent implements OnInit {
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
this.attachToInput();
}
// set final cache items
if (this.allowAdvancedDownload) {
if (localStorage.getItem('customArgsEnabled') !== null) {
this.customArgsEnabled = localStorage.getItem('customArgsEnabled') === 'true';
}
if (localStorage.getItem('customOutputEnabled') !== null) {
this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true';
}
}
}, error => {
console.log(error);
});
@@ -202,7 +253,8 @@ export class MainComponent implements OnInit {
this.postsService.getMp3s().subscribe(result => {
const mp3s = result['mp3s'];
const playlists = result['playlists'];
this.mp3s = mp3s;
// if they are different
if (JSON.stringify(this.mp3s) !== JSON.stringify(mp3s)) { this.mp3s = mp3s };
this.playlists.audio = playlists;
// get thumbnail url by using first video. this is a temporary hack
@@ -216,7 +268,7 @@ export class MainComponent implements OnInit {
}
}
this.playlist_thumbnails[playlist.id] = videoToExtractThumbnail.thumbnailURL;
if (videoToExtractThumbnail) { this.playlist_thumbnails[playlist.id] = videoToExtractThumbnail.thumbnailURL; }
}
}, error => {
console.log(error);
@@ -227,7 +279,8 @@ export class MainComponent implements OnInit {
this.postsService.getMp4s().subscribe(result => {
const mp4s = result['mp4s'];
const playlists = result['playlists'];
this.mp4s = mp4s;
// if they are different
if (JSON.stringify(this.mp4s) !== JSON.stringify(mp4s)) { this.mp4s = mp4s };
this.playlists.video = playlists;
// get thumbnail url by using first video. this is a temporary hack
@@ -241,7 +294,7 @@ export class MainComponent implements OnInit {
}
}
this.playlist_thumbnails[playlist.id] = videoToExtractThumbnail.thumbnailURL;
if (videoToExtractThumbnail) { this.playlist_thumbnails[playlist.id] = videoToExtractThumbnail.thumbnailURL; }
}
},
error => {
@@ -322,66 +375,95 @@ export class MainComponent implements OnInit {
// app initialization.
ngOnInit() {
this.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window['MSStream'];
// this.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window['MSStream'];
this.iOS = this.platform.IOS;
if (localStorage.getItem('audioOnly') !== null) {
this.audioOnly = localStorage.getItem('audioOnly') === 'true';
}
if (localStorage.getItem('multiDownloadMode') !== null) {
this.multiDownloadMode = localStorage.getItem('multiDownloadMode') === 'true';
}
}
// download helpers
downloadHelperMp3(name, is_playlist = false, forceView = false) {
downloadHelperMp3(name, is_playlist = false, forceView = false, new_download = null) {
this.downloadingfile = false;
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode && !this.iOS) {
if (is_playlist) {
const zipName = name[0].split(' ')[0] + name[1].split(' ')[0];
this.downloadPlaylist(name, 'audio', zipName);
if (new_download && this.current_download !== new_download) {
// console.log('mismatched downloads');
} else if (!this.multiDownloadMode) {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode && !this.iOS) {
if (is_playlist) {
const zipName = name[0].split(' ')[0] + name[1].split(' ')[0];
this.downloadPlaylist(name, 'audio', zipName);
} else {
this.downloadAudioFile(decodeURI(name));
}
} else {
this.downloadAudioFile(decodeURI(name));
}
} else {
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'audio'}]);
// window.location.href = this.baseStreamPath + this.audioFolderPath + name[0] + '.mp3';
} else {
this.router.navigate(['/player', {fileNames: name, type: 'audio'}]);
// window.location.href = this.baseStreamPath + this.audioFolderPath + name + '.mp3';
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'audio'}]);
// window.location.href = this.baseStreamPath + this.audioFolderPath + name[0] + '.mp3';
} else {
this.router.navigate(['/player', {fileNames: name, type: 'audio'}]);
// window.location.href = this.baseStreamPath + this.audioFolderPath + name + '.mp3';
}
}
}
// remove download from current downloads
this.removeDownloadFromCurrentDownloads(new_download);
// reloads mp3s
if (this.fileManagerEnabled) {
this.getMp3s();
setTimeout(() => {
this.audioFileCards.forEach(filecard => {
filecard.onHoverResponse();
});
}, 200);
}
}
downloadHelperMp4(name, is_playlist = false, forceView = false) {
downloadHelperMp4(name, is_playlist = false, forceView = false, new_download = null) {
this.downloadingfile = false;
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode) {
if (is_playlist) {
const zipName = name[0].split(' ')[0] + name[1].split(' ')[0];
this.downloadPlaylist(name, 'video', zipName);
if (new_download && this.current_download !== new_download) {
// console.log('mismatched downloads');
} else if (!this.multiDownloadMode) {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode) {
if (is_playlist) {
const zipName = name[0].split(' ')[0] + name[1].split(' ')[0];
this.downloadPlaylist(name, 'video', zipName);
} else {
this.downloadVideoFile(decodeURI(name));
}
} else {
this.downloadVideoFile(decodeURI(name));
}
} else {
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'video'}]);
// window.location.href = this.baseStreamPath + this.videoFolderPath + name[0] + '.mp4';
} else {
this.router.navigate(['/player', {fileNames: name, type: 'video'}]);
// window.location.href = this.baseStreamPath + this.videoFolderPath + name + '.mp4';
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'video'}]);
// window.location.href = this.baseStreamPath + this.videoFolderPath + name[0] + '.mp4';
} else {
this.router.navigate(['/player', {fileNames: name, type: 'video'}]);
// window.location.href = this.baseStreamPath + this.videoFolderPath + name + '.mp4';
}
}
}
// remove download from current downloads
this.removeDownloadFromCurrentDownloads(new_download);
// reloads mp4s
if (this.fileManagerEnabled) {
this.getMp4s();
setTimeout(() => {
this.videoFileCards.forEach(filecard => {
filecard.onHoverResponse();
});
}, 200);
}
}
@@ -392,55 +474,131 @@ export class MainComponent implements OnInit {
this.path = '';
if (this.audioOnly) {
// create download object
const new_download: Download = {
uid: uuid(),
type: 'audio',
percent_complete: 0,
url: this.url,
downloading: true,
is_playlist: this.url.includes('playlist')
};
this.downloads.push(new_download);
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
this.downloadingfile = true;
let customQualityConfiguration = null;
if (this.selectedQuality !== '') {
const cachedFormatsExists = this.cachedAvailableFormats[this.url];
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const audio_formats = this.cachedAvailableFormats[this.url]['audio'];
const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio'];
customQualityConfiguration = audio_formats[this.selectedQuality]['format_id'];
}
}
const customArgs = (this.customArgsEnabled ? this.customArgs : null);
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
this.postsService.makeMP3(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration).subscribe(posts => {
customQualityConfiguration, customArgs, customOutput).subscribe(posts => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
const is_playlist = !!(posts['file_names']);
this.path = is_playlist ? posts['file_names'] : posts['audiopathEncoded'];
if (this.path !== '-1') {
this.downloadHelperMp3(this.path, is_playlist);
this.downloadHelperMp3(this.path, is_playlist, false, new_download);
}
}, error => { // can't access server
this.downloadingfile = false;
this.openSnackBar('Download failed!', 'OK.');
});
} else {
// create download object
const new_download: Download = {
uid: uuid(),
type: 'video',
percent_complete: 0,
url: this.url,
downloading: true,
is_playlist: this.url.includes('playlist')
};
this.downloads.push(new_download);
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
this.downloadingfile = true;
let customQualityConfiguration = null;
const cachedFormatsExists = this.cachedAvailableFormats[this.url];
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const video_formats = this.cachedAvailableFormats[this.url]['video'];
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'];
}
}
this.downloadingfile = true;
const customArgs = (this.customArgsEnabled ? this.customArgs : null);
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
this.postsService.makeMP4(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration).subscribe(posts => {
customQualityConfiguration, customArgs, customOutput).subscribe(posts => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
const is_playlist = !!(posts['file_names']);
this.path = is_playlist ? posts['file_names'] : posts['videopathEncoded'];
if (this.path !== '-1') {
this.downloadHelperMp4(this.path, is_playlist);
this.downloadHelperMp4(this.path, is_playlist, false, new_download);
}
}, error => { // can't access server
this.downloadingfile = false;
this.openSnackBar('Download failed!', 'OK.');
});
}
if (this.multiDownloadMode) {
this.url = '';
this.downloadingfile = false;
}
} else {
this.urlError = true;
}
}
// download canceled handler
cancelDownload(download_to_cancel = null) {
// if one is provided, cancel that one. otherwise, remove the current one
if (download_to_cancel) {
this.removeDownloadFromCurrentDownloads(download_to_cancel)
return;
}
this.downloadingfile = false;
this.current_download.downloading = false;
this.current_download = null;
}
getDownloadByUID(uid) {
const index = this.downloads.findIndex(download => download.uid === uid);
if (index !== -1) {
return this.downloads[index];
} else {
return null;
}
}
removeDownloadFromCurrentDownloads(download_to_remove) {
const index = this.downloads.indexOf(download_to_remove);
if (index !== -1) {
this.downloads.splice(index, 1);
return true;
} else {
return false;
}
}
downloadAudioFile(name) {
this.downloading_content['audio'][name] = true;
this.postsService.downloadFileFromServer(name, 'audio').subscribe(res => {
@@ -524,7 +682,7 @@ export class MainComponent implements OnInit {
// tslint:disable-next-line: max-line-length
const youtubeStrRegex = /(?:http(?:s)?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:(?:watch)?\?(?:.*&)?v(?:i)?=|(?:embed|v|vi|user)\/))([^\?&\"'<> #]+)/;
const reYT = new RegExp(youtubeStrRegex);
const ytValid = reYT.test(str);
const ytValid = true || reYT.test(str);
if (valid && ytValid && Date.now() - this.last_url_check > 1000) {
if (str !== this.last_valid_url && this.allowQualitySelect) {
// get info
@@ -543,18 +701,32 @@ export class MainComponent implements OnInit {
}
getURLInfo(url) {
if (!(this.cachedAvailableFormats[url])) {
this.formats_loading = true;
if (!this.cachedAvailableFormats[url]) {
this.cachedAvailableFormats[url] = {};
}
if (!(this.cachedAvailableFormats[url] && this.cachedAvailableFormats[url]['formats'])) {
this.cachedAvailableFormats[url]['formats_loading'] = true;
this.postsService.getFileInfo([url], 'irrelevant', true).subscribe(res => {
if (url === this.url) { this.formats_loading = false; }
this.cachedAvailableFormats[url]['formats_loading'] = false;
const infos = res['result'];
if (!infos || !infos.formats) {
this.errorFormats(url);
return;
}
const parsed_infos = this.getAudioAndVideoFormats(infos.formats);
const available_formats = {audio: parsed_infos[0], video: parsed_infos[1]};
this.cachedAvailableFormats[url] = available_formats;
this.cachedAvailableFormats[url]['formats'] = available_formats;
}, err => {
this.errorFormats(url);
});
}
}
errorFormats(url) {
this.cachedAvailableFormats[url]['formats_loading'] = false;
console.error('Could not load formats for url ' + url);
}
attachToInput() {
Observable.fromEvent(this.urlInput.nativeElement, 'keyup')
.map((e: any) => e.target.value) // extract the value of input
@@ -593,6 +765,24 @@ export class MainComponent implements OnInit {
localStorage.setItem('audioOnly', new_val.checked.toString());
}
multiDownloadModeChanged(new_val) {
localStorage.setItem('multiDownloadMode', new_val.checked.toString());
}
customArgsEnabledChanged(new_val) {
localStorage.setItem('customArgsEnabled', new_val.checked.toString());
if (new_val.checked === true && this.customOutputEnabled) {
this.customOutputEnabled = false;
}
}
customOutputEnabledChanged(new_val) {
localStorage.setItem('customOutputEnabled', new_val.checked.toString());
if (new_val.checked === true && this.customArgsEnabled) {
this.customArgsEnabled = false;
}
}
getAudioAndVideoFormats(formats): any[] {
const audio_formats = {};
const video_formats = {};
@@ -653,5 +843,61 @@ export class MainComponent implements OnInit {
}
return best_audio_format_for_mp4;
}
}
accordionEntered(type) {
if (type === 'audio') {
audioFilesMouseHovering = true;
this.audioFileCards.forEach(filecard => {
filecard.onHoverResponse();
});
} else if (type === 'video') {
videoFilesMouseHovering = true;
this.videoFileCards.forEach(filecard => {
filecard.onHoverResponse();
});
}
}
accordionLeft(type) {
if (type === 'audio') {
audioFilesMouseHovering = false;
} else if (type === 'video') {
videoFilesMouseHovering = false;
}
}
accordionOpened(type) {
if (type === 'audio') {
audioFilesOpened = true;
} else if (type === 'video') {
videoFilesOpened = true;
}
}
accordionClosed(type) {
if (type === 'audio') {
audioFilesOpened = false;
} else if (type === 'video') {
videoFilesOpened = false;
}
}
// creating a playlist
openCreatePlaylistDialog(type) {
const dialogRef = this.dialog.open(CreatePlaylistComponent, {
data: {
filesToSelectFrom: (type === 'audio') ? this.mp3s : this.mp4s,
type: type
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
if (type === 'audio') { this.getMp3s() };
if (type === 'video') { this.getMp4s() };
this.openSnackBar('Successfully created playlist!', '');
} else if (result === false) {
this.openSnackBar('ERROR: failed to create playlist!', '');
}
});
}
}

View File

@@ -2,6 +2,10 @@
margin: 0 auto;
}
.video-player:focus {
outline: none;
}
.audio-styles {
height: 50px;
background-color: transparent;
@@ -58,4 +62,18 @@
.save-icon {
bottom: 1px;
position: relative;
}
.update-playlist-button-div {
float: right;
margin-right: 30px;
margin-top: 25px;
margin-bottom: 15px;
}
.spinner-div {
position: relative;
display: inline-block;
margin-right: 12px;
top: 8px;
}

View File

@@ -8,13 +8,21 @@
</vg-player>
</div>
<div class="col-12 my-2">
<mat-button-toggle-group style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
<mat-button-toggle *ngFor="let name of fileNames; let i = index" [checked]="currentItem.title === name" (click)="onClickPlaylistItem(playlist[i], i)" class="toggle-button" [value]="name">{{decodeURI(name)}}</mat-button-toggle>
<mat-button-toggle-group cdkDropList [cdkDropListSortingDisabled]="!id" (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
<mat-button-toggle cdkDrag *ngFor="let playlist_item of playlist; let i = index" [checked]="currentItem.title === playlist_item.title" (click)="onClickPlaylistItem(playlist_item, i)" class="toggle-button" [value]="playlist_item.title">{{decodeURI(playlist_item.title)}}</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>
</div>
<div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
<div class="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</div>
<button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button>Save changes <mat-icon>update</mat-icon></button>
</div>
<div *ngIf="playlist.length > 1">
<button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!id" color="accent" class="favorite-button" color="primary" (click)="namePlaylistDialog()" mat-fab><mat-icon class="save-icon">favorite</mat-icon></button>

View File

@@ -4,6 +4,7 @@ import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog, MatSnackBar } from '@angular/material';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
export interface IMedia {
title: string;
@@ -19,6 +20,8 @@ export interface IMedia {
export class PlayerComponent implements OnInit {
playlist: Array<IMedia> = [];
original_playlist: string = null;
playlist_updating = false;
currentIndex = 0;
currentItem: IMedia = null;
@@ -73,7 +76,7 @@ export class PlayerComponent implements OnInit {
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 + fileName; // + (this.type === 'audio' ? '.mp3' : '.mp4');
const fullLocation = this.baseStreamPath + baseLocation + encodeURI(fileName); // + (this.type === 'audio' ? '.mp3' : '.mp4');
const mediaObject: IMedia = {
title: fileName,
src: fullLocation,
@@ -82,6 +85,7 @@ export class PlayerComponent implements OnInit {
this.playlist.push(mediaObject);
}
this.currentItem = this.playlist[this.currentIndex];
this.original_playlist = JSON.stringify(this.playlist);
});
// this.getFileInfos();
@@ -122,11 +126,20 @@ export class PlayerComponent implements OnInit {
}
getFileInfos() {
this.postsService.getFileInfo(this.fileNames, this.type, false).subscribe(res => {
const fileNames = this.getFileNames();
this.postsService.getFileInfo(fileNames, this.type, false).subscribe(res => {
});
}
getFileNames() {
const fileNames = [];
for (let i = 0; i < this.playlist.length; i++) {
fileNames.push(this.playlist[i].title);
}
return fileNames;
}
decodeURI(string) {
return decodeURI(string);
}
@@ -179,7 +192,8 @@ export class PlayerComponent implements OnInit {
// Eventually do additional checks on name
if (name) {
this.postsService.createPlaylist(name, this.fileNames, this.type, null).subscribe(res => {
const fileNames = this.getFileNames();
this.postsService.createPlaylist(name, fileNames, this.type, null).subscribe(res => {
if (res['success']) {
dialogRef.close();
const new_playlist = res['new_playlist'];
@@ -208,6 +222,30 @@ export class PlayerComponent implements OnInit {
this.router.navigateByUrl(this.router.url + ';id=' + playlistID);
}
drop(event: CdkDragDrop<string[]>) {
moveItemInArray(this.playlist, event.previousIndex, event.currentIndex);
}
playlistChanged() {
return JSON.stringify(this.playlist) !== this.original_playlist;
}
updatePlaylist() {
const fileNames = this.getFileNames();
this.playlist_updating = true;
this.postsService.updatePlaylist(this.id, fileNames, this.type).subscribe(res => {
this.playlist_updating = false;
if (res['success']) {
const fileNamesEncoded = fileNames.join('|nvr|');
this.router.navigate(['/player', {fileNames: fileNamesEncoded, type: 'video', id: this.id}]);
this.openSnackBar('Successfully updated playlist.', '');
this.original_playlist = JSON.stringify(this.playlist);
} else {
this.openSnackBar('ERROR: Failed to update playlist.', '');
}
})
}
// snackbar helper
public openSnackBar(message: string, action: string) {
this.snackBar.open(message, action, {

View File

@@ -43,16 +43,22 @@ export class PostsService {
return this.http.get(this.startPath + 'audiofolder');
}
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string) {
// tslint:disable-next-line: max-line-length
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null) {
return this.http.post(this.path + 'tomp3', {url: url,
maxBitrate: selectedQuality,
customQualityConfiguration: customQualityConfiguration});
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
customOutput: customOutput});
}
makeMP4(url: string, selectedQuality: string, customQualityConfiguration: string) {
// tslint:disable-next-line: max-line-length
makeMP4(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null) {
return this.http.post(this.path + 'tomp4', {url: url,
selectedHeight: selectedQuality,
customQualityConfiguration: customQualityConfiguration});
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
customOutput: customOutput});
}
getFileStatusMp3(name: string) {
@@ -107,6 +113,12 @@ export class PostsService {
thumbnailURL: thumbnailURL});
}
updatePlaylist(playlistID, fileNames, type) {
return this.http.post(this.path + 'updatePlaylist', {playlistID: playlistID,
fileNames: fileNames,
type: type});
}
removePlaylist(playlistID, type) {
return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type});
}

View File

@@ -18,7 +18,8 @@
"title_top": "Youtube Downloader",
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false
"download_only_mode": false,
"allow_multi_download_mode": true
},
"API": {
"use_youtube_API": false,
@@ -27,6 +28,11 @@
"Themes": {
"default_theme": "default",
"allow_theme_change": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"allow_advanced_download": true
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -10,7 +10,7 @@
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined" rel="stylesheet"/>
</head>
<body>
<app-root></app-root>