Compare commits

...

54 Commits
v3.2 ... v3.4

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

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

added hammerjs

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

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

updated favicon to the new icon
2020-03-05 20:14:36 -05:00
Isaac Grynsztein
a755b0b281 fixed bug that prevented custom quality path from working 2020-03-01 16:18:01 -05:00
Isaac Grynsztein
434f6751d0 added releases to repo (will only include latest release) 2020-03-01 16:17:41 -05:00
Isaac Grynsztein
dfecf3645b updated README
renamed chrome extension
2020-03-01 03:07:52 -05:00
Isaac Grynsztein
62a000b631 fixed bug where custom paths failed to stream 2020-03-01 02:31:47 -05:00
Isaac Grynsztein
2673f4ee98 updated README 2020-03-01 00:54:11 -05:00
Tzahi12345
a8d2e1d890 Merge pull request #12 from Tzahi12345/serve-nodejs
Serve frontend app through nodejs
2020-03-01 00:50:20 -05:00
Isaac Grynsztein
0511996b26 fixed margins on advanced mode UI and temporarily disabled youtube auth until youtube-dl fixes it
advanced mode inputs now get saved in cookies

fixed bug in UI where delete button was missing by making it more mobile-friendly
2020-03-01 00:48:22 -05:00
Isaac Grynsztein
f29a29bf2f fixed bug that prevented custom args from working 2020-03-01 00:46:42 -05:00
Isaac Grynsztein
24d4107311 Clarified old config in README 2020-02-29 04:31:23 -05:00
Isaac Grynsztein
a46f9c37c6 fixed bug where old config item was fetched 2020-02-29 04:31:06 -05:00
Isaac Grynsztein
2b1c68bad0 update docker-compose and dockerfile 2020-02-29 04:30:56 -05:00
Isaac Grynsztein
e2d23404ce removed unused variable 2020-02-29 04:30:34 -05:00
Isaac Grynsztein
db208ed55e Updated README to reflect changes in the config 2020-02-29 03:30:21 -05:00
Isaac Grynsztein
b87a9f1e2f fixed bug where playlist titles included their relative path 2020-02-29 03:08:02 -05:00
Tzahi12345
a1ac1e450d Merge pull request #11 from Tzahi12345/auth-params
Add the ability to use youtube authentication
2020-02-28 22:12:29 -05:00
Tzahi12345
6764ea6c3b Merge pull request #10 from Tzahi12345/url_params
Add URL params home page
2020-02-28 22:07:53 -05:00
Isaac Grynsztein
8a52020186 resized favicon 2020-02-28 22:05:16 -05:00
Isaac Grynsztein
695b836852 added url params on home page to auto download content
created chrome extension to facilitate this feature
2020-02-28 21:21:17 -05:00
Isaac Grynsztein
71d7c30032 updated backend to support youtube auth
frontend now support youtube auth as well
2020-02-28 20:09:59 -05:00
Isaac Grynsztein
5ca4f036c7 fixed bug where if multi mode was enabled, click on file card URLs didn't work 2020-02-28 00:32:33 -05:00
Isaac Grynsztein
1ffe61f01f removed path-base and updated docker-compose.yml & README 2020-02-28 00:20:08 -05:00
Isaac Grynsztein
5e331b9ffa config settings now just have url and port
fixed bug where multi download mode would not allow file card link clicking
2020-02-28 00:14:46 -05:00
Isaac Grynsztein
09bdae90e2 refactored code so node.js serves the angular app, and all the backend routes are prepended with /api/
nodejs now compressed requests
2020-02-27 22:52:50 -05:00
Isaac Grynsztein
37cc8f4fe1 fixed error in docker compose 2020-02-27 04:15:19 -05:00
Isaac Grynsztein
925e083b8d added new config settings to README 2020-02-27 04:12:27 -05:00
Isaac Grynsztein
6dc42278c4 updated docker-compose to new youtubedl-material version number 2020-02-27 03:57:41 -05:00
64 changed files with 2499 additions and 271 deletions

8
.gitignore vendored
View File

@@ -43,8 +43,16 @@ Thumbs.db
node_modules/*
backend/node_modules/*
backend/public/*
YoutubeDL-Material/node_modules/*
backend/video/*
backend/audio/*
backend/public/*
backend/subscriptions/archives/*
backend/subscriptions/playlists/*
backend/subscriptions/channels/*
backend/db.json
backend/subscriptions/channels/*
backend/subscriptions/playlists/*
backend/subscriptions/archives/*
src/assets/default.json

View File

@@ -1,28 +1,21 @@
FROM ubuntu:18.04
FROM alpine:3.11
RUN apt-get update && apt-get install -y \
nodejs \
apache2 \
npm \
youtube-dl
RUN apk add --update npm python ffmpeg
# Change directory so that our commands run inside this new directory
WORKDIR /var/www/html
WORKDIR /app
# Copy dependency definitions
COPY ./ /var/www/html/
COPY ./ /app/
# Change directory to backend
WORKDIR /var/www/html/backend
WORKDIR /app
# 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
EXPOSE 17442
# Run the specified command within the container.
CMD ./docker_wrapper.sh
CMD [ "node", "app.js" ]

View File

@@ -22,7 +22,7 @@ Dark mode:
### Prerequisites
NOTE: If you would like to use Docker, you can go down to the [Docker](#Docker) section for a setup guide.
NOTE: If you would like to use Docker, you can skip 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:
@@ -32,38 +32,44 @@ sudo apt-get install nodejs youtube-dl
### Installing
First, download the [latest release](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest)!
1. First, download the [latest release](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest)!
Drag all the files in `youtubedl-material` to a location accessible to a web server. It works best if it's the root (usually right inside `public_html`. Once that's done, navigate to `backend` and edit the `default.json` file. If you're using SSL encryption, look at the `encrypted.json` file for a template.
2. Drag the `youtubedl-material` directory to an easily accessible directory. Navigate to the `config` folder and edit the `default.json` file. If you're using SSL encryption, look at the `encrypted.json` file for a template.
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.
NOTE: If you are intending to use a reverse proxy, this next step is not necessary
3. Port forward the port listed in `default.json`, which defaults to `17442`.
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.
4. 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, which serves the frontend as well. On your browser, navigate to to the server (url with the specified port). 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
NOTE: If you are using YoutubeDL-Material v3.2 or lower, click [here](https://github.com/Tzahi12345/YoutubeDL-Material/blob/b87a9f1e2fd896b8e3b2f12429b7ffb15ea4521b/README.md#configuration) for the old README
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/" |
| url | URL to the server hosting YoutubeDL-Material | "http://example.com" |
| port | Desired port for YoutubeDL-Material | "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 |
| allow_multi_download_mode | true if you want the ability to download multiple videos at the same time | true |
| 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 |
| use_default_downloading_agent | true if you want to use youtube-dl's default downloader | true |
| custom_downloading_agent | If not using the default downloader, this is the downloader you want to use | "" |
| allow_advanced_download | true if you want to use the Advanced download options | false |
## Deployment
@@ -71,21 +77,35 @@ If you'd like to install YoutubeDL-Material, go to the Installation section. If
To deploy, simply clone the repository, and go into the `youtubedl-material` directory. Type `npm install` and all the dependencies will install. Then type `cd backend` and again type `npm install` to install the dependencies for the backend.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/backend/config`, go back into the `youtubedl-material` directory, and type `ng build --prod`. This will build the app, and put the output files in the `youtubedl-material/dist` folder. Drag those files into a web server, and drag the `backend` directory into the same folder. This folder should have `index.html` in it as well. If it does **not**, you're in the wrong directory.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/config`, go back into the `youtubedl-material` directory, and type `ng build --prod`. This will build the app, and put the output files in the `youtubedl-material/dist` folder. Drag those files into the `public` directory in the `backend` folder.
The frontend is now complete. The backend is much easier. Just go into the `youtubedl-material/backend` folder, and type `sudo nodejs app.js`.
The frontend is now complete. The backend is much easier. Just go into the `youtubedl-material` folder, and type `nodejs app.js`.
Finally, port forward the port `17442` and point it to the server's IP address. Make sure the port is also allowed through the firewall.
Finally, port forward the port specified in the config (defaults to `17442`) and point it to the server's IP address. Make sure the port is also allowed through the server's firewall.
## 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!
1. Run `curl -L 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. The default options will work, however, and point to `http://localhost:8998`. You can find an explanation of these configuration items in [Configuration](#Configuration) section.
3. Run `docker-compose pull`. This will download the official YoutubeDL-Material docker image.
4. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 8998" or something similar.
5. Make sure you can connect to the specified URL + port, and if so, you are done!
## API
You can use the internal API on your server to run downloads on your instance without using the frontend. All of the available endpoints can be seen over [here](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/backend/app.js) -- search for '/api/' on the page to find all the endpoints. I will expand on the available endpoints in the future, but for now I'd like to highlight the two most useful ones:
#### Downloading audio files
`curl -XPOST -H "Content-type: application/json" -d '{"url": "<your youtube url>"}' 'http://localhost:17442/api/tomp3'`
Remember to replace `<your video url>` with the actual URL.
#### Downloading video files
`curl -XPOST -H "Content-type: application/json" -d '{"url": "<your youtube url>"}' 'http://localhost:17442/api/tomp4'`
Remember to replace `<your video url>` with the actual URL.
## Contributing

View File

@@ -1,7 +1,9 @@
var async = require('async');
const { uuid } = require('uuidv4');
var fs = require('fs');
var path = require('path');
var youtubedl = require('youtube-dl');
var compression = require('compression');
var https = require('https');
var express = require("express");
var bodyParser = require("body-parser");
@@ -9,7 +11,9 @@ var archiver = require('archiver');
const low = require('lowdb')
var URL = require('url').URL;
const shortid = require('shortid')
const url_api = require('url');
var config_api = require('./config.js');
var subscriptions_api = require('./subscriptions')
var app = express();
@@ -24,13 +28,14 @@ db.defaults(
audio: [],
video: []
},
configWriteFlag: false
configWriteFlag: false,
subscriptions: []
}).write();
// config values
var frontendUrl = null;
var backendUrl = null;
var backendPort = 17442;
var backendPort = null;
var usingEncryption = null;
var basePath = null;
var audioFolderPath = null;
@@ -38,6 +43,8 @@ var videoFolderPath = null;
var downloadOnlyMode = null;
var useDefaultDownloadingAgent = null;
var customDownloadingAgent = null;
var allowSubscriptions = null;
var subscriptionsCheckInterval = null;
// other needed values
var options = null; // encryption options
@@ -68,11 +75,6 @@ var descriptors = {};
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.get('/using-encryption', function(req, res) {
res.send(usingEncryption);
res.end("yes");
});
// objects
function File(id, title, thumbnailURL, isAudio, duration) {
@@ -89,7 +91,7 @@ function startServer() {
if (usingEncryption)
{
https.createServer(options, app).listen(backendPort, function() {
console.log('HTTPS: Anchor set on 17442');
console.log('HTTPS: Started on PORT ' + backendPort);
});
}
else
@@ -125,16 +127,17 @@ async function loadConfig() {
// 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;
url = !debugMode ? config_api.getConfigItem('ytdl_url') : 'http://localhost:4200';
backendPort = config_api.getConfigItem('ytdl_port');
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');
allowSubscriptions = config_api.getConfigItem('ytdl_allow_subscriptions');
subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval');
if (!useDefaultDownloadingAgent && validDownloadingAgents.indexOf(customDownloadingAgent) !== -1 ) {
console.log(`INFO: Using non-default downloading agent \'${customDownloadingAgent}\'`)
}
@@ -153,7 +156,16 @@ async function loadConfig() {
};
}
url_domain = new URL(frontendUrl);
url_domain = new URL(url);
// get subscriptions
if (allowSubscriptions) {
// runs initially, then runs every ${subscriptionCheckInterval} seconds
watchSubscriptions();
setInterval(() => {
watchSubscriptions();
}, subscriptionsCheckInterval * 1000);
}
// start the server here
startServer();
@@ -163,6 +175,34 @@ async function loadConfig() {
}
function calculateSubcriptionRetrievalDelay(amount) {
// frequency is 5 mins
let frequency_in_ms = subscriptionsCheckInterval * 1000;
let minimum_frequency = 60 * 1000;
const first_frequency = frequency_in_ms/amount;
return (first_frequency < minimum_frequency) ? minimum_frequency : first_frequency;
}
function watchSubscriptions() {
let subscriptions = subscriptions_api.getAllSubscriptions();
if (!subscriptions) return;
let subscriptions_amount = subscriptions.length;
let delay_interval = calculateSubcriptionRetrievalDelay(subscriptions_amount);
let current_delay = 0;
for (let i = 0; i < subscriptions.length; i++) {
let sub = subscriptions[i];
if (debugMode) console.log('watching ' + sub.name + ' with delay interval of ' + delay_interval);
setTimeout(() => {
subscriptions_api.getVideosForSub(sub);
}, current_delay);
current_delay += delay_interval;
if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0;
}
}
function getOrigin() {
return url_domain.origin;
}
@@ -245,9 +285,14 @@ function getJSONMp3(name)
return obj;
}
function getJSONMp4(name)
function getJSONMp4(name, customPath = null)
{
var jsonPath = videoFolderPath+name+".info.json";
let jsonPath = null;
if (!customPath) {
jsonPath = videoFolderPath+name+".info.json";
} else {
jsonPath = customPath + name + ".info.json";
}
if (fs.existsSync(jsonPath))
{
var obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
@@ -374,10 +419,11 @@ function deleteAudioFile(name) {
});
}
async function deleteVideoFile(name) {
async function deleteVideoFile(name, customPath = null) {
return new Promise(resolve => {
var jsonPath = path.join(videoFolderPath,name+'.info.json');
var videoFilePath = path.join(videoFolderPath,name+'.mp4');
let filePath = customPath ? customPath : videoFolderPath;
var jsonPath = path.join(filePath,name+'.info.json');
var videoFilePath = path.join(filePath,name+'.mp4');
jsonPath = path.join(__dirname, jsonPath);
videoFilePath = path.join(__dirname, videoFilePath);
@@ -506,34 +552,69 @@ app.use(function(req, res, next) {
next();
});
app.post('/tomp3', function(req, res) {
app.use(compression());
app.get('/api/config', function(req, res) {
let config_file = config_api.getConfigFile();
res.send({
config_file: config_file,
success: !!config_file
});
});
app.post('/api/setConfig', function(req, res) {
let new_config_file = req.body.new_config_file;
if (new_config_file && new_config_file['YoutubeDLMaterial']) {
let success = config_api.setConfigFile(new_config_file);
res.send({
success: success
});
} else {
console.log('ERROR: Tried to save invalid config file!')
res.sendStatus(400);
}
});
app.get('/api/using-encryption', function(req, res) {
res.send(usingEncryption);
});
app.post('/api/tomp3', function(req, res) {
var url = req.body.url;
var date = Date.now();
var audiopath = '%(title)s';
var customQualityConfiguration = req.body.customQualityConfiguration;
var maxBitrate = req.body.maxBitrate;
var globalArgs = config_api.getConfigItem('ytdl_custom_args');
var customArgs = req.body.customArgs;
var customOutput = req.body.customOutput;
var youtubeUsername = req.body.youtubeUsername;
var youtubePassword = req.body.youtubePassword;
let downloadConfig = null;
let qualityPath = '';
if (customArgs) {
downloadConfig = [customArgs];
downloadConfig = customArgs.split(' ');
} else {
if (customQualityConfiguration) {
qualityPath = `-f ${customQualityConfiguration}`;
} else if (maxBitrate) {
if (!maxBitrate || maxBitrate === '') maxBitrate = '0';
qualityPath = `--audio-quality ${maxBitrate}`
}
if (customOutput) {
downloadConfig = ['-o', audioFolderPath + customOutput + '.mp3', '-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
} else {
downloadConfig = ['-o', audioFolderPath + audiopath + ".mp3", '-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
}
if (customQualityConfiguration) {
qualityPath = `-f ${customQualityConfiguration}`;
} else if (maxBitrate) {
if (!maxBitrate || maxBitrate === '') maxBitrate = '0';
qualityPath = `--audio-quality ${maxBitrate}`
if (youtubeUsername && youtubePassword) {
downloadConfig.push('--username', youtubeUsername, '--password', youtubePassword);
}
if (qualityPath !== '') {
@@ -543,6 +624,11 @@ app.post('/tomp3', function(req, res) {
if (!useDefaultDownloadingAgent && customDownloadingAgent === 'aria2c') {
downloadConfig.splice(0, 0, '--external-downloader', 'aria2c');
}
if (globalArgs && globalArgs !== '') {
// adds global args
downloadConfig = downloadConfig.concat(globalArgs.split(' '));
}
}
youtubedl.exec(url, downloadConfig, {}, function(err, output) {
@@ -588,38 +674,50 @@ app.post('/tomp3', function(req, res) {
});
});
app.post('/tomp4', function(req, res) {
app.post('/api/tomp4', function(req, res) {
var url = req.body.url;
var date = Date.now();
var path = videoFolderPath;
var videopath = '%(title)s';
var globalArgs = config_api.getConfigItem('ytdl_custom_args');
var customArgs = req.body.customArgs;
var customOutput = req.body.customOutput;
var selectedHeight = req.body.selectedHeight;
var customQualityConfiguration = req.body.customQualityConfiguration;
var youtubeUsername = req.body.youtubeUsername;
var youtubePassword = req.body.youtubePassword;
let downloadConfig = null;
let qualityPath = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4';
if (customArgs) {
downloadConfig = [customArgs];
downloadConfig = customArgs.split(' ');
} else {
if (customQualityConfiguration) {
qualityPath = customQualityConfiguration;
} else if (selectedHeight && selectedHeight !== '') {
qualityPath = `bestvideo[height=${selectedHeight}]+bestaudio/best[height=${selectedHeight}]`;
}
if (customOutput) {
downloadConfig = ['-o', path + customOutput + ".mp4", '-f', qualityPath, '--write-info-json', '--print-json'];
} else {
downloadConfig = ['-o', path + videopath + ".mp4", '-f', qualityPath, '--write-info-json', '--print-json'];
}
if (customQualityConfiguration) {
qualityPath = customQualityConfiguration;
} else if (selectedHeight && selectedHeight !== '') {
qualityPath = `bestvideo[height=${selectedHeight}]+bestaudio/best[height=${selectedHeight}]`;
if (youtubeUsername && youtubePassword) {
downloadConfig.push('--username', youtubeUsername, '--password', youtubePassword);
}
if (!useDefaultDownloadingAgent && customDownloadingAgent === 'aria2c') {
downloadConfig.splice(0, 0, '--external-downloader', 'aria2c');
}
if (globalArgs && globalArgs !== '') {
// adds global args
downloadConfig = downloadConfig.concat(globalArgs.split(' '));
}
}
youtubedl.exec(url, downloadConfig, {}, function(err, output) {
@@ -676,8 +774,8 @@ app.post('/tomp4', function(req, res) {
});
// gets the status of the mp3 file that's being downloaded
app.post('/fileStatusMp3', function(req, res) {
var name = decodeURI(req.body.name + "");
app.post('/api/fileStatusMp3', function(req, res) {
var name = decodeURIComponent(req.body.name + "");
var exists = "";
var fullpath = audioFolderPath + name + ".mp3";
if (fs.existsSync(fullpath)) {
@@ -698,8 +796,8 @@ app.post('/fileStatusMp3', function(req, res) {
});
// gets the status of the mp4 file that's being downloaded
app.post('/fileStatusMp4', function(req, res) {
var name = decodeURI(req.body.name);
app.post('/api/fileStatusMp4', function(req, res) {
var name = decodeURIComponent(req.body.name);
var exists = "";
var fullpath = videoFolderPath + name + ".mp4";
if (fs.existsSync(fullpath)) {
@@ -718,7 +816,7 @@ app.post('/fileStatusMp4', function(req, res) {
});
// gets all download mp3s
app.post('/getMp3s', function(req, res) {
app.post('/api/getMp3s', function(req, res) {
var mp3s = [];
var playlists = db.get('playlists.audio').value();
var files = recFindByExt(audioFolderPath, 'mp3'); // fs.readdirSync(audioFolderPath);
@@ -750,7 +848,7 @@ app.post('/getMp3s', function(req, res) {
});
// gets all download mp4s
app.post('/getMp4s', function(req, res) {
app.post('/api/getMp4s', function(req, res) {
var mp4s = [];
var playlists = db.get('playlists.video').value();
var fullpath = videoFolderPath;
@@ -782,7 +880,142 @@ app.post('/getMp4s', function(req, res) {
res.end("yes");
});
app.post('/createPlaylist', async (req, res) => {
app.post('/api/subscribe', async (req, res) => {
let name = req.body.name;
let url = req.body.url;
let timerange = req.body.timerange;
const new_sub = {
name: name,
url: url,
id: uuid()
};
// adds timerange if it exists, otherwise all videos will be downloaded
if (timerange) {
new_sub.timerange = timerange;
}
const result_obj = await subscriptions_api.subscribe(new_sub);
if (result_obj.success) {
res.send({
new_sub: new_sub
});
} else {
res.send({
new_sub: null,
error: result_obj.error
})
}
});
app.post('/api/unsubscribe', async (req, res) => {
let deleteMode = req.body.deleteMode
let sub = req.body.sub;
let result_obj = subscriptions_api.unsubscribe(sub, deleteMode);
if (result_obj.success) {
res.send({
success: result_obj.success
});
} else {
res.send({
success: false,
error: result_obj.error
});
}
});
app.post('/api/deleteSubscriptionFile', async (req, res) => {
let deleteForever = req.body.deleteForever;
let file = req.body.file;
let sub = req.body.sub;
let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever);
if (success) {
res.send({
success: success
});
} else {
res.sendStatus(500);
}
});
app.post('/api/getSubscription', async (req, res) => {
let subID = req.body.id;
// get sub from db
let subscription = subscriptions_api.getSubscription(subID);
if (!subscription) {
// failed to get subscription from db, send 400 error
res.sendStatus(400);
return;
}
// get sub videos
if (subscription.name) {
let base_path = config_api.getConfigItem('ytdl_subscriptions_base_path');
let appended_base_path = path.join(base_path, subscription.isPlaylist ? 'playlists' : 'channels', subscription.name, '/');
let files;
try {
files = recFindByExt(appended_base_path, 'mp4');
} catch(e) {
files = null;
console.log('Failed to get folder for subscription: ' + subscription.name + ' at path ' + appended_base_path);
res.sendStatus(500);
return;
}
var parsed_files = [];
for (let i = 0; i < files.length; i++) {
let file = files[i];
var file_path = file.substring(appended_base_path.length, file.length);
var id = file_path.substring(0, file_path.length-4);
var jsonobj = getJSONMp4(id, appended_base_path);
if (!jsonobj) continue;
var title = jsonobj.title;
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = false;
var file_obj = new File(id, title, thumbnail, isaudio, duration);
parsed_files.push(file_obj);
}
res.send({
subscription: subscription,
files: parsed_files
});
} else {
res.sendStatus(500);
}
});
app.post('/api/downloadVideosForSubscription', async (req, res) => {
let subID = req.body.subID;
let sub = subscriptions_api.getSubscription(subID);
subscriptions_api.getVideosForSub(sub);
res.send({
success: true
});
});
app.post('/api/getAllSubscriptions', async (req, res) => {
// get subs from api
let subscriptions = subscriptions_api.getAllSubscriptions();
res.send({
subscriptions: subscriptions
});
});
app.post('/api/createPlaylist', async (req, res) => {
let playlistName = req.body.playlistName;
let fileNames = req.body.fileNames;
let type = req.body.type;
@@ -805,7 +1038,7 @@ app.post('/createPlaylist', async (req, res) => {
})
});
app.post('/updatePlaylist', async (req, res) => {
app.post('/api/updatePlaylist', async (req, res) => {
let playlistID = req.body.playlistID;
let fileNames = req.body.fileNames;
let type = req.body.type;
@@ -831,7 +1064,7 @@ app.post('/updatePlaylist', async (req, res) => {
})
});
app.post('/deletePlaylist', async (req, res) => {
app.post('/api/deletePlaylist', async (req, res) => {
let playlistID = req.body.playlistID;
let type = req.body.type;
@@ -853,7 +1086,7 @@ app.post('/deletePlaylist', async (req, res) => {
});
// deletes mp3 file
app.post('/deleteMp3', async (req, res) => {
app.post('/api/deleteMp3', async (req, res) => {
var name = req.body.name;
var fullpath = audioFolderPath + name + ".mp3";
var wasDeleted = false;
@@ -873,7 +1106,7 @@ app.post('/deleteMp3', async (req, res) => {
});
// deletes mp4 file
app.post('/deleteMp4', async (req, res) => {
app.post('/api/deleteMp4', async (req, res) => {
var name = req.body.name;
var fullpath = videoFolderPath + name + ".mp4";
var wasDeleted = false;
@@ -892,14 +1125,14 @@ app.post('/deleteMp4', async (req, res) => {
}
});
app.post('/downloadFile', async (req, res) => {
app.post('/api/downloadFile', async (req, res) => {
let fileNames = req.body.fileNames;
let is_playlist = req.body.is_playlist;
let type = req.body.type;
let outputName = req.body.outputName;
let file = null;
if (!is_playlist) {
fileNames = decodeURI(fileNames);
fileNames = decodeURIComponent(fileNames);
if (type === 'audio') {
file = __dirname + '/' + audioFolderPath + fileNames + '.mp3';
} else if (type === 'video') {
@@ -907,7 +1140,7 @@ app.post('/downloadFile', async (req, res) => {
}
} else {
for (let i = 0; i < fileNames.length; i++) {
fileNames[i] = decodeURI(fileNames[i]);
fileNames[i] = decodeURIComponent(fileNames[i]);
}
file = await createPlaylistZipFile(fileNames, type, outputName);
}
@@ -915,7 +1148,7 @@ app.post('/downloadFile', async (req, res) => {
res.sendFile(file);
});
app.post('/deleteFile', async (req, res) => {
app.post('/api/deleteFile', async (req, res) => {
let fileName = req.body.fileName;
let type = req.body.type;
if (type === 'audio') {
@@ -926,10 +1159,31 @@ app.post('/deleteFile', async (req, res) => {
res.send()
});
app.get('/video/:id', function(req , res){
app.post('/api/downloadArchive', async (req, res) => {
let sub = req.body.sub;
let archive_dir = sub.archive;
let full_archive_path = path.join(__dirname, archive_dir, 'archive.txt');
if (fs.existsSync(full_archive_path)) {
res.sendFile(full_archive_path);
} else {
res.sendStatus(404);
}
});
app.get('/api/video/:id', function(req , res){
var head;
let id = decodeURI(req.params.id);
const path = "video/" + id + '.mp4';
let optionalParams = url_api.parse(req.url,true).query;
let id = decodeURIComponent(req.params.id);
let path = "video/" + id + '.mp4';
if (optionalParams['subName']) {
let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const isPlaylist = optionalParams['subPlaylist'];
basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/');
path = basePath + optionalParams['subName'] + '/' + id + '.mp4';
}
const stat = fs.statSync(path)
const fileSize = stat.size
const range = req.headers.range
@@ -966,9 +1220,9 @@ app.get('/video/:id', function(req , res){
}
});
app.get('/audio/:id', function(req , res){
app.get('/api/audio/:id', function(req , res){
var head;
let id = decodeURI(req.params.id);
let id = decodeURIComponent(req.params.id);
let path = "audio/" + id + '.mp3';
path = path.replace(/\"/g, '\'');
const stat = fs.statSync(path)
@@ -1008,7 +1262,7 @@ app.get('/audio/:id', function(req , res){
});
app.post('/getVideoInfos', async (req, res) => {
app.post('/api/getVideoInfos', async (req, res) => {
let fileNames = req.body.fileNames;
let urlMode = !!req.body.urlMode;
let type = req.body.type;
@@ -1027,3 +1281,22 @@ app.get('/audio/:id', function(req , res){
success: !!result
})
});
app.use(function(req, res, next) {
//if the request is not html then move along
var accept = req.accepts('html', 'json', 'xml');
if (accept !== 'html') {
return next();
}
// if the request has a '.' assume that it's for a file, move along
var ext = path.extname(req.path);
if (ext !== '') {
return next();
}
fs.createReadStream('./public/index.html').pipe(res);
});
app.use(express.static('./public'));

View File

@@ -1,8 +1,9 @@
const fs = require('fs');
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
const debugMode = process.env.YTDL_MODE === 'debug';
let configPath = 'config/default.json';
let configPath = debugMode ? '../src/assets/default.json' : 'config/default.json';
// https://stackoverflow.com/questions/6491463/accessing-nested-javascript-objects-with-string-key
Object.byString = function(o, s) {
@@ -56,7 +57,10 @@ function setConfigFile(config) {
function getConfigItem(key) {
let config_json = getConfigFile();
if (!CONFIG_ITEMS[key]) console.log('cannot find config with key ' + key);
if (!CONFIG_ITEMS[key]) {
console.log('cannot find config with key ' + key);
return null;
}
let path = CONFIG_ITEMS[key]['path'];
return Object.byString(config_json, path);
};
@@ -108,5 +112,7 @@ module.exports = {
getConfigItem: getConfigItem,
setConfigItem: setConfigItem,
setConfigItems: setConfigItems,
getConfigFile: getConfigFile,
setConfigFile: setConfigFile,
CONFIG_ITEMS: CONFIG_ITEMS
}

View File

@@ -1,8 +1,8 @@
{
"YoutubeDLMaterial": {
"Host": {
"frontendurl": "http://example.com",
"backendurl": "http://example.com:17442/"
"url": "http://example.com",
"port": "17442"
},
"Encryption": {
"use-encryption": false,
@@ -10,9 +10,9 @@
"key-file-path": "/etc/letsencrypt/live/example.com/privkey.pem"
},
"Downloader": {
"path-base": "http://example.com:17442/",
"path-audio": "audio/",
"path-video": "video/"
"path-video": "video/",
"custom_args": ""
},
"Extra": {
"title_top": "Youtube Downloader",
@@ -29,6 +29,12 @@
"default_theme": "default",
"allow_theme_change": true
},
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",

View File

@@ -1,18 +1,18 @@
{
"YoutubeDLMaterial": {
"Host": {
"frontendurl": "https://example.com",
"backendurl": "https://example.com:17442/"
},
"Host": {
"url": "https://example.com",
"port": "17442"
},
"Encryption": {
"use-encryption": true,
"cert-file-path": "/etc/letsencrypt/live/example.com/fullchain.pem",
"key-file-path": "/etc/letsencrypt/live/example.com/privkey.pem"
},
"Downloader": {
"path-base": "https://example.com:17442/",
"path-audio": "audio/",
"path-video": "video/"
"path-video": "video/",
"custom_args": ""
},
"Extra": {
"title_top": "Youtube Downloader",
@@ -29,6 +29,12 @@
"default_theme": "default",
"allow_theme_change": true
},
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",

View File

@@ -2,13 +2,13 @@ var config = require('config');
let CONFIG_ITEMS = {
// Host
'ytdl_frontend_url': {
'key': 'ytdl_frontend_url',
'path': 'YoutubeDLMaterial.Host.frontendurl'
'ytdl_url': {
'key': 'ytdl_url',
'path': 'YoutubeDLMaterial.Host.url'
},
'ytdl_backend_url': {
'key': 'ytdl_backend_url',
'path': 'YoutubeDLMaterial.Host.backendurl'
'ytdl_port': {
'key': 'ytdl_port',
'path': 'YoutubeDLMaterial.Host.port'
},
// Encryption
@@ -26,10 +26,6 @@ let CONFIG_ITEMS = {
},
// 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'
@@ -38,6 +34,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_video_folder_path',
'path': 'YoutubeDLMaterial.Downloader.path-video'
},
'ytdl_custom_args': {
'key': 'ytdl_custom_args',
'path': 'YoutubeDLMaterial.Downloader.custom_args'
},
// Extra
'ytdl_title_top': {
@@ -60,7 +60,6 @@ let CONFIG_ITEMS = {
'key': 'ytdl_allow_multi_download_mode',
'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode'
},
// API
'ytdl_use_youtube_api': {
@@ -82,6 +81,28 @@ let CONFIG_ITEMS = {
'path': 'YoutubeDLMaterial.Themes.allow_theme_change'
},
// Subscriptions
'ytdl_allow_subscriptions': {
'key': 'ytdl_allow_subscriptions',
'path': 'YoutubeDLMaterial.Subscriptions.allow_subscriptions'
},
'ytdl_subscriptions_base_path': {
'key': 'ytdl_subscriptions_base_path',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_base_path'
},
'ytdl_subscriptions_check_interval': {
'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval'
},
'ytdl_subscriptions_check_interval': {
'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval'
},
'ytdl_subscriptions_use_youtubedl_archive': {
'key': 'ytdl_use_youtubedl_archive',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_use_youtubedl_archive'
},
// Advanced
'ytdl_use_default_downloading_agent': {
'key': 'ytdl_use_default_downloading_agent',

View File

@@ -20,11 +20,13 @@
"dependencies": {
"archiver": "^3.1.1",
"async": "^3.1.0",
"compression": "^1.7.4",
"config": "^3.2.3",
"exe": "^1.0.2",
"express": "^4.17.1",
"lowdb": "^1.0.0",
"shortid": "^2.2.15",
"uuidv4": "^6.0.6",
"youtube-dl": "^3.0.2"
}
}

315
backend/subscriptions.js Normal file
View File

@@ -0,0 +1,315 @@
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
var fs = require('fs');
const { uuid } = require('uuidv4');
var path = require('path');
var youtubedl = require('youtube-dl');
const config_api = require('./config');
const adapter = new FileSync('db.json');
const db = low(adapter)
const debugMode = process.env.YTDL_MODE === 'debug';
async function subscribe(sub) {
const result_obj = {
success: false,
error: ''
};
return new Promise(async resolve => {
// sub should just have url and name. here we will get isPlaylist and path
sub.isPlaylist = sub.url.includes('playlist');
if (db.get('subscriptions').find({url: sub.url}).value()) {
console.log('Sub already exists');
result_obj.error = 'Subcription with URL ' + sub.url + ' already exists!';
resolve(result_obj);
return;
}
// add sub to db
db.get('subscriptions').push(sub).write();
let success = await getSubscriptionInfo(sub);
result_obj.success = success;
result_obj.sub = sub;
getVideosForSub(sub);
resolve(result_obj);
});
}
async function getSubscriptionInfo(sub) {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
return new Promise(resolve => {
// get videos
let downloadConfig = ['--dump-json', '--playlist-end', '1']
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
if (debugMode) {
console.log('Subscribe: got info for subscription ' + sub.id);
}
if (err) {
console.log(err.stderr);
resolve(false);
} else if (output) {
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
if (debugMode) console.log('Could not get info for ' + sub.id);
resolve(false);
}
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
if (!sub.name) {
sub.name = sub.isPlaylist ? output_json.playlist_title : output_json.uploader;
// if it's now valid, update
if (sub.name) {
db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
}
}
if (!sub.archive) {
// must create the archive
const archive_dir = basePath + 'archives/' + sub.name;
const archive_path = path.join(archive_dir, 'archive.txt');
// creates archive directory and text file if it doesn't exist
if (!fs.existsSync(archive_dir)) {
fs.mkdirSync(archive_dir);
fs.closeSync(fs.openSync(archive_path, 'w'));
} else if (!fs.existsSync(archive_path)) {
fs.closeSync(fs.openSync(archive_path, 'w'));
}
// updates subscription
sub.archive = archive_dir;
db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
}
// TODO: get even more info
resolve(true);
}
resolve(false);
}
});
});
}
async function unsubscribe(sub, deleteMode) {
return new Promise(async resolve => {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let result_obj = { success: false, error: '' };
let id = sub.id;
db.get('subscriptions').remove({id: id}).write();
const appendedBasePath = getAppendedBasePath(sub, basePath);
if (deleteMode && fs.existsSync(appendedBasePath)) {
if (sub.archive && fs.existsSync(sub.archive)) {
const archive_file_path = path.join(sub.archive, 'archive.txt');
// deletes archive if it exists
if (fs.existsSync(archive_file_path)) {
fs.unlinkSync(archive_file_path);
}
fs.rmdirSync(sub.archive);
}
deleteFolderRecursive(appendedBasePath);
}
});
}
async function deleteSubscriptionFile(sub, file, deleteForever) {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const name = file;
let retrievedID = null;
return new Promise(resolve => {
let filePath = appendedBasePath;
var jsonPath = path.join(__dirname,filePath,name+'.info.json');
var videoFilePath = path.join(__dirname,filePath,name+'.mp4');
var imageFilePath = path.join(__dirname,filePath,name+'.jpg');
jsonExists = fs.existsSync(jsonPath);
videoFileExists = fs.existsSync(videoFilePath);
imageFileExists = fs.existsSync(imageFilePath);
if (jsonExists) {
retrievedID = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))['id'];
fs.unlinkSync(jsonPath);
}
if (imageFileExists) {
fs.unlinkSync(imageFilePath);
}
if (videoFileExists) {
fs.unlink(videoFilePath, function(err) {
if (fs.existsSync(jsonPath) || fs.existsSync(videoFilePath)) {
resolve(false);
} else {
// check if the user wants the video to be redownloaded (deleteForever === false)
if (!deleteForever && useArchive && sub.archive && retrievedID) {
const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID
if (fs.existsSync(archive_path)) {
removeIDFromArchive(archive_path, retrievedID);
}
}
resolve(true);
}
});
} else {
// TODO: tell user that the file didn't exist
resolve(true);
}
});
}
async function getVideosForSub(sub) {
return new Promise(resolve => {
if (!subExists(sub.id)) {
resolve(false);
return;
}
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive');
let appendedBasePath = null
if (sub.name) {
appendedBasePath = getAppendedBasePath(sub, basePath);
} else {
appendedBasePath = basePath + (sub.isPlaylist ? 'playlists/%(playlist_title)s' : 'channels/%(uploader)s');
}
let downloadConfig = ['-o', appendedBasePath + '/%(title)s.mp4', '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4', '-ciw', '--write-annotations', '--write-thumbnail', '--write-info-json', '--print-json'];
if (sub.timerange) {
downloadConfig.push('--dateafter', sub.timerange);
}
let archive_dir = null;
let archive_path = null;
if (useArchive) {
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
}
downloadConfig.push('--download-archive', archive_path);
}
// get videos
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
if (debugMode) {
console.log('Subscribe: got videos for subscription ' + sub.name);
}
if (err) {
console.log(err.stderr);
resolve(false);
} else if (output) {
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
if (debugMode) console.log('No additional videos to download for ' + sub.name);
resolve(true);
}
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
// TODO: Potentially store downloaded files in db?
}
resolve(true);
}
});
});
}
function getAllSubscriptions() {
const subscriptions = db.get('subscriptions').value();
return subscriptions;
}
function getSubscription(subID) {
return db.get('subscriptions').find({id: subID}).value();
}
function subExists(subID) {
return !!db.get('subscriptions').find({id: subID}).value();
}
// helper functions
function getAppendedBasePath(sub, base_path) {
return base_path + (sub.isPlaylist ? 'playlists/' : 'channels/') + sub.name;
}
// https://stackoverflow.com/a/32197381/8088021
const deleteFolderRecursive = function(folder_to_delete) {
if (fs.existsSync(folder_to_delete)) {
fs.readdirSync(folder_to_delete).forEach((file, index) => {
const curPath = path.join(folder_to_delete, file);
if (fs.lstatSync(curPath).isDirectory()) { // recurse
deleteFolderRecursive(curPath);
} else { // delete file
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(folder_to_delete);
}
};
function removeIDFromArchive(archive_path, id) {
fs.readFile(archive_path, {encoding: 'utf-8'}, function(err, data) {
if (err) throw error;
let dataArray = data.split('\n'); // convert file data in an array
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
let lastIndex = -1; // let say, we have not found the keyword
for (let index=0; index<dataArray.length; index++) {
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
lastIndex = index; // found a line includes a id keyword
break;
}
}
dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
fs.writeFile(archive_path, updatedData, (err) => {
if (err) throw err;
// console.log ('Successfully updated the file data');
});
});
}
module.exports = {
getSubscription : getSubscription,
getAllSubscriptions : getAllSubscriptions,
subscribe : subscribe,
unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub
}

Binary file not shown.

28
chrome-extension.pem Normal file
View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDMX9Wk5SM5cIfY
6ReKX3ybY1rsbNbOzG8ceN7yyeXB0mor8pVsX1MOna2HewOyBuaaYNJRO4tJBxic
7a8zQErfgHL/i/QrVvVCpfJ7xKvq6zij5NYoqd/FBUwawqjeH5/voIcAp9z5Vmsr
kL0sxJUKy6b4IWNp3noU7Nvq2RwxnXQbKDhz8FrX6oQAnDC6gsG5a2OSPsaE4oqw
6nmonORJypmpP5hqyHY8ffXBT2lAxjHT7OnYbaCBe2TQP8+rH6rDBOhjVNtUJ089
ocTQL6LtQEPkcF4yKJmtcOwHl8OPGZs5l9i8xb4j9RuSPkm2lbzZX8sOsdGGoqJZ
q68nYhsHAgMBAAECggEAXmtKEzfPObq88B/kAcgSk+FngMHZzcmR7bgD3GwdSxnQ
dkRI9zvk7eQ35tcUwntAr4Lat6/ILjFqlBmVLxrdXHuF5Xz9jcZLYgKzz61xdYM9
dC6FKF0u5eGIIvbauGAo7jaeGFX1F3Zu5b4lP9kEOGwU1B7sxF0FzsQM5+dtCJgv
We/hWQeF+9gtoVnkCSS/Mq2p0UomXXHW0Bz4+HuHlTR9aiYbviYnotABiLUhZyzt
v5yUaktb9qniBfdLpRlq8cp06xYlTEA9gJpa4Pnok8OWUsbAiW6EiXUSaZ/cchVa
AnO8WWYvVOnnt6WHI3+QdFTnqVjE5TBX4N/7bVhHGQKBgQD0dtbFqp7vZK/jVYvE
z0WPdySOg2ZDmoSfk5ZlR1+Y9zWToHv0qu8zqoOjL8Ubxrh9fGlOow+cCVdkEuaa
jWC2AWetuRvW0Z5A3XMXr0/N/1fgOkTqtp3WNrUPjVJahEg3lN+90opgFoT8swSi
s1oxW0oLcVIlrjhGBXAPCfsAuQKBgQDWBLRhHsRAvGcK5wGuVnxVApTIyBOermsW
3bJt+7+UI+4sYrBAwkWdQG93IG0cQtn48TEPBgmR2fjRF5IFT9M4/u+QOeeByT7I
we7nVtHgSY5ByC9N0mjWbcmSg8fktz/LonjldNC4kWdOFb75fxGf8kOGS5rUaMA4
zHucfB6ZvwKBgQCPHJrysMXGY21MaqIeHzEboaX3ABl37hdBzAa5V6UxSVdGCydF
vmO2HVZey/JaJmWOoKyNaowSzq0oWqBBTg6VvhDR9JHFmoVId9uOvAS+FYN+Mt5x
gWK5KuGoLxVNBC+6yh6JY526TrSfsrU+Aj0Es+qO9FIg2PL8muZVB4S3kQKBgH/5
CDMaxpc/EQ5/2413wZjDllwI51J3USm3Hz6Mzp2ybnSz/lh60k2Zfg1polTH1Lb6
4i7tmUNRZ2sAARyUAuWN64n+VeRRhe1dqZFDZPQMh7fmEAMk0fOGaoXlrt2ghdEq
Mchi9Xun1nHmpu9hgBR4NNBU3RwuFuLfwvprbZDZAoGAWa62QJChE86xQGP1MrL2
SbIzw3cfeP5xdQ3MKldJiy5IkbMR7Z13WZ7FwvPTy0g/onLHD1rqlm1kUMsGRHpD
5vH06PNpKXQ6x8BYaRGtE6P39jLycO/X+WK/lYTrWo1bR+mGCebDh4B5XrwT3gI6
x4Gvz134pZCTyQCf5JCwbQs=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,20 @@
// background.js
// Called when the user clicks on the browser action.
chrome.browserAction.onClicked.addListener(function(tab) {
// get the frontend_url
chrome.storage.sync.get({
frontend_url: 'http://localhost',
audio_only: false
}, function(items) {
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
var activeTab = tabs[0];
var url = activeTab.url;
if (url.includes('youtube.com')) {
var new_url = items.frontend_url + '/#/home;url=' + encodeURIComponent(url) + ';audioOnly=' + items.audio_only;
chrome.tabs.create({ url: new_url });
}
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,20 @@
{
"manifest_version": 2,
"name": "YoutubeDL-Material",
"version": "0.1",
"description": "The official chrome extension of YoutubeDL-Material, an open-source and self-hosted YouTube downloader.",
"background": {
"scripts": ["background.js"]
},
"browser_action": {
"default_icon": "favicon.png"
},
"permissions": [
"tabs",
"storage"
],
"options_ui": {
"page": "options.html",
"open_in_tab": false
}
}

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head><title>YoutubeDL-Material Extension Options</title></head>
<body>
<h2>Settings</h2>
<div>
<h4>Frontend URL</h4>
<input placeholder="Frontend URL" type="text" id="frontend_url">
</div>
<br/>
<div>
<label>
<input type="checkbox" id="audio_only">
Audio only
</label>
</div>
<br/>
<div id="status"></div>
<button id="save">Save</button>
<script src="options.js"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
// Saves options to chrome.storage
function save_options() {
var frontend_url = document.getElementById('frontend_url').value;
var audio_only = document.getElementById('audio_only').checked;
chrome.storage.sync.set({
frontend_url: frontend_url,
audio_only: audio_only
}, function() {
// Update status to let user know options were saved.
var status = document.getElementById('status');
status.textContent = 'Options saved.';
setTimeout(function() {
status.textContent = '';
}, 750);
});
}
// Restores select box and checkbox state using the preferences
// stored in chrome.storage.
function restore_options() {
chrome.storage.sync.get({
frontend_url: 'http://localhost',
audio_only: false
}, function(items) {
document.getElementById('frontend_url').value = items.frontend_url;
document.getElementById('audio_only').checked = items.audio_only;
});
}
document.addEventListener('DOMContentLoaded', restore_options);
document.getElementById('save').addEventListener('click',
save_options);

View File

@@ -5,23 +5,27 @@ services:
build: .
environment:
# config items
ytdl_frontend_url: http://localhost:8998
ytdl_backend_url: http://localhost:17442/
ytdl_url: http://localhost:8998
ytdl_port: '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_custom_args: ''
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_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_allow_subscriptions: 'true'
ytdl_subscriptions_base_path: subscriptions/
ytdl_subscriptions_check_interval: '300'
ytdl_subscriptions_use_youtubedl_archive: 'true'
ytdl_use_default_downloading_agent: 'true'
ytdl_custom_downloading_agent: 'false'
ytdl_allow_advanced_download: 'false'
@@ -30,6 +34,5 @@ services:
ALLOW_CONFIG_MUTATIONS: 'true'
restart: always
ports:
- "17442:17442"
- "8998:80"
image: tzahi12345/youtubedl-material:3.1
- "8998:17442"
image: tzahi12345/youtubedl-material:3.4

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import {FileCardComponent} from './file-card/file-card.component';
import { Observable } from 'rxjs/Observable';
import {FormControl, Validators} from '@angular/forms';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {MatSnackBar} from '@angular/material';
import {MatSnackBar, MatDialog, MatSidenav} from '@angular/material';
import { saveAs } from 'file-saver';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/mapTo';
@@ -15,9 +15,10 @@ import 'rxjs/add/operator/debounceTime'
import 'rxjs/add/operator/do'
import 'rxjs/add/operator/switch'
import { YoutubeSearchService, Result } from './youtube-search.service';
import { Router } from '@angular/router';
import { Router, NavigationStart, NavigationEnd } from '@angular/router';
import { OverlayContainer } from '@angular/cdk/overlay';
import { THEMES_CONFIG } from '../themes';
import { SettingsComponent } from './settings/settings.component';
@Component({
selector: 'app-root',
@@ -33,18 +34,35 @@ export class AppComponent implements OnInit {
topBarTitle = 'Youtube Downloader';
defaultTheme = null;
allowThemeChange = null;
allowSubscriptions = false;
@ViewChild('urlinput', { read: ElementRef, static: false }) urlInput: ElementRef;
@ViewChild('sidenav', {static: false}) sidenav: MatSidenav;
@ViewChild('hamburgerMenu', {static: false, read: ElementRef}) hamburgerMenuButton: ElementRef;
navigator: string = null;
constructor(public postsService: PostsService, public snackBar: MatSnackBar,
constructor(public postsService: PostsService, public snackBar: MatSnackBar, private dialog: MatDialog,
public router: Router, public overlayContainer: OverlayContainer, private elementRef: ElementRef) {
this.navigator = localStorage.getItem('player_navigator');
// runs on navigate, captures the route that navigated to the player (if needed)
this.router.events.subscribe((e) => { if (e instanceof NavigationStart) {
this.navigator = localStorage.getItem('player_navigator');
} else if (e instanceof NavigationEnd) {
// blurs hamburger menu if it exists, as the sidenav likes to focus on it after closing
if (this.hamburgerMenuButton && this.hamburgerMenuButton.nativeElement) {
this.hamburgerMenuButton.nativeElement.blur();
}
}
});
// loading config
this.postsService.loadNavItems().subscribe(result => { // loads settings
this.postsService.loadNavItems().subscribe(res => { // loads settings
const result = !this.postsService.debugMode ? res['config_file'] : res;
this.topBarTitle = result['YoutubeDLMaterial']['Extra']['title_top'];
const themingExists = result['YoutubeDLMaterial']['Themes'];
this.defaultTheme = themingExists ? result['YoutubeDLMaterial']['Themes']['default_theme'] : 'default';
this.allowThemeChange = themingExists ? result['YoutubeDLMaterial']['Themes']['allow_theme_change'] : true;
this.allowSubscriptions = result['YoutubeDLMaterial']['Subscriptions']['allow_subscriptions'];
// sets theme to config default if it doesn't exist
if (!localStorage.getItem('theme')) {
@@ -56,6 +74,10 @@ export class AppComponent implements OnInit {
}
toggleSidenav() {
this.sidenav.toggle();
}
// theme stuff
setTheme(theme) {
@@ -104,6 +126,11 @@ onSetTheme(theme, old_theme) {
}
}
themeMenuItemClicked(event) {
this.flipTheme();
event.stopPropagation();
}
ngOnInit() {
if (localStorage.getItem('theme')) {
this.setTheme(localStorage.getItem('theme'));
@@ -114,7 +141,18 @@ onSetTheme(theme, old_theme) {
goBack() {
this.router.navigate(['/home']);
if (!this.navigator) {
this.router.navigate(['/home']);
} else {
this.router.navigateByUrl(this.navigator);
}
}
openSettingsDialog() {
const dialogRef = this.dialog.open(SettingsComponent, {
width: '80vw'
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -119,6 +119,4 @@ mat-form-field.mat-form-field {
.advanced-input {
width: 100%;
margin-top: 20px;
margin-bottom: 20px;
}

View File

@@ -61,7 +61,7 @@
</mat-card-actions>
</mat-card>
</div>
<div *ngIf="false && allowAdvancedDownload" class="big demo-basic">
<div *ngIf="allowAdvancedDownload" class="big demo-basic">
<form style="margin-left: 20px; margin-right: 20px;">
<mat-expansion-panel class="big">
<mat-expansion-panel-header>
@@ -69,20 +69,32 @@
Advanced
</mat-panel-title>
</mat-expansion-panel-header>
<p *ngIf="this.simulatedOutput">Simulated command: <i>{{this.simulatedOutput}}</i></p>
<div class="container" style="padding-bottom: 20px;">
<div class="row">
<div class="col">
<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">
<div class="col-12 col-sm-6">
<mat-checkbox color="accent" [disabled]="current_download" (change)="customArgsEnabledChanged($event)" [(ngModel)]="customArgsEnabled" style="z-index: 999" [ngModelOptions]="{standalone: true}">Use custom args</mat-checkbox>
<mat-form-field color="accent" style="margin-bottom: 42px;" 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">
<div class="col-12 col-sm-6">
<mat-checkbox color="accent" [disabled]="current_download" (change)="customOutputEnabledChanged($event)" [(ngModel)]="customOutputEnabled" style="z-index: 999" [ngModelOptions]="{standalone: true}">Use custom output</mat-checkbox>
<mat-form-field style="margin-bottom: 42px;" 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-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">Documentation</a>. Path is relative to the config download path. Don't include extension.</mat-hint>
</mat-form-field>
</div>
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-2">
<mat-checkbox color="accent" [disabled]="current_download" (change)="youtubeAuthEnabledChanged($event)" [(ngModel)]="youtubeAuthEnabled" style="z-index: 999" [ngModelOptions]="{standalone: true}">Use authentication</mat-checkbox>
<mat-form-field color="accent" class="advanced-input">
<input [(ngModel)]="youtubeUsername" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Username">
</mat-form-field>
</div>
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-2">
<mat-form-field style="margin-top: 31px;" color="accent" class="advanced-input">
<input [(ngModel)]="youtubePassword" type="password" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Password">
</mat-form-field>
</div>
</div>

View File

@@ -15,7 +15,7 @@ import 'rxjs/add/operator/debounceTime'
import 'rxjs/add/operator/do'
import 'rxjs/add/operator/switch'
import { YoutubeSearchService, Result } from '../youtube-search.service';
import { Router } from '@angular/router';
import { Router, ActivatedRoute } from '@angular/router';
import { CreatePlaylistComponent } from 'app/create-playlist/create-playlist.component';
import { Platform } from '@angular/cdk/platform';
import { v4 as uuid } from 'uuid';
@@ -41,8 +41,11 @@ export interface Download {
styleUrls: ['./main.component.css']
})
export class MainComponent implements OnInit {
youtubeAuthDisabledOverride = true;
iOS = false;
// local settings
determinateProgress = false;
downloadingfile = false;
audioOnly: boolean;
@@ -51,22 +54,29 @@ export class MainComponent implements OnInit {
customArgs = null;
customOutputEnabled = false;
customOutput = null;
youtubeAuthEnabled = false;
youtubeUsername = null;
youtubePassword = null;
urlError = false;
path = '';
url = '';
exists = '';
percentDownloaded: number;
autoStartDownload = false;
// settings
// global settings
fileManagerEnabled = false;
allowQualitySelect = false;
downloadOnlyMode = false;
allowMultiDownloadMode = false;
baseStreamPath;
audioFolderPath;
videoFolderPath;
globalCustomArgs = null;
allowAdvancedDownload = false;
useDefaultDownloadingAgent = true;
customDownloadingAgent = null;
// formats cache
cachedAvailableFormats = {};
// youtube api
@@ -78,7 +88,7 @@ export class MainComponent implements OnInit {
mp3s: any[] = [];
mp4s: any[] = [];
files_cols = (window.innerWidth <= 450) ? 2 : 4;
files_cols = null;
playlists = {'audio': [], 'video': []};
playlist_thumbnails = {};
downloading_content = {'audio': {}, 'video': {}};
@@ -197,28 +207,30 @@ export class MainComponent implements OnInit {
is_playlist: false
};
simulatedOutput = '';
constructor(private postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
private router: Router, public dialog: MatDialog, private platform: Platform) {
private router: Router, public dialog: MatDialog, private platform: Platform, private route: ActivatedRoute) {
this.audioOnly = false;
// loading config
this.postsService.loadNavItems().subscribe(result => { // loads settings
const backendUrl = result['YoutubeDLMaterial']['Host']['backendurl'];
this.postsService.loadNavItems().subscribe(res => { // loads settings
const result = !this.postsService.debugMode ? res['config_file'] : res;
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'];
this.globalCustomArgs = result['YoutubeDLMaterial']['Downloader']['custom_args'];
this.youtubeSearchEnabled = result['YoutubeDLMaterial']['API'] && result['YoutubeDLMaterial']['API']['use_youtube_API'] &&
result['YoutubeDLMaterial']['API']['youtube_API_key'];
this.youtubeAPIKey = this.youtubeSearchEnabled ? result['YoutubeDLMaterial']['API']['youtube_API_key'] : null;
this.allowQualitySelect = result['YoutubeDLMaterial']['Extra']['allow_quality_select'];
this.allowAdvancedDownload = result['YoutubeDLMaterial']['Advanced']['allow_advanced_download'];
this.useDefaultDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['use_default_downloading_agent'];
this.customDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['custom_downloading_agent'];
this.postsService.path = backendUrl;
this.postsService.startPath = backendUrl;
this.postsService.startPathSSL = backendUrl;
if (this.fileManagerEnabled) {
this.getMp3s();
@@ -239,14 +251,58 @@ export class MainComponent implements OnInit {
if (localStorage.getItem('customOutputEnabled') !== null) {
this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true';
}
if (localStorage.getItem('youtubeAuthEnabled') !== null) {
this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true';
}
// set advanced inputs
const customArgs = localStorage.getItem('customArgs');
const customOutput = localStorage.getItem('customOutput');
const youtubeUsername = localStorage.getItem('youtubeUsername');
if (customArgs && customArgs !== 'null') { this.customArgs = customArgs };
if (customOutput && customOutput !== 'null') { this.customOutput = customOutput };
if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername };
}
if (this.autoStartDownload) {
this.downloadClicked();
}
setInterval(() => this.getSimulatedOutput(), 1000);
}, error => {
console.log(error);
});
}
// app initialization.
ngOnInit() {
this.iOS = this.platform.IOS;
// get checkboxes
if (localStorage.getItem('audioOnly') !== null) {
this.audioOnly = localStorage.getItem('audioOnly') === 'true';
}
if (localStorage.getItem('multiDownloadMode') !== null) {
this.multiDownloadMode = localStorage.getItem('multiDownloadMode') === 'true';
}
// check if params exist
if (this.route.snapshot.paramMap.get('url')) {
this.url = decodeURIComponent(this.route.snapshot.paramMap.get('url'));
this.audioOnly = this.route.snapshot.paramMap.get('audioOnly') === 'true';
// set auto start flag to true
this.autoStartDownload = true;
}
this.setCols();
}
// file manager stuff
getMp3s() {
@@ -302,6 +358,18 @@ export class MainComponent implements OnInit {
});
}
public setCols() {
if (window.innerWidth <= 350) {
this.files_cols = 1;
} else if (window.innerWidth <= 500) {
this.files_cols = 2;
} else if (window.innerWidth <= 750) {
this.files_cols = 3
} else {
this.files_cols = 4;
}
}
public goToFile(name, isAudio) {
if (isAudio) {
this.downloadHelperMp3(name, false, false);
@@ -317,6 +385,7 @@ export class MainComponent implements OnInit {
this.downloading_content[type][playlistID] = true;
this.downloadPlaylist(playlist.fileNames, type, playlist.name, playlistID);
} else {
localStorage.setItem('player_navigator', this.router.url);
const fileNames = playlist.fileNames;
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID}]);
}
@@ -372,21 +441,6 @@ export class MainComponent implements OnInit {
});
}
// app initialization.
ngOnInit() {
// 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, new_download = null) {
@@ -394,7 +448,7 @@ export class MainComponent implements OnInit {
if (new_download && this.current_download !== new_download) {
// console.log('mismatched downloads');
} else if (!this.multiDownloadMode) {
} else if (!this.multiDownloadMode || !new_download) {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode && !this.iOS) {
if (is_playlist) {
@@ -404,12 +458,11 @@ export class MainComponent implements OnInit {
this.downloadAudioFile(decodeURI(name));
}
} else {
localStorage.setItem('player_navigator', this.router.url);
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'audio'}]);
// 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';
}
}
}
@@ -433,7 +486,7 @@ export class MainComponent implements OnInit {
if (new_download && this.current_download !== new_download) {
// console.log('mismatched downloads');
} else if (!this.multiDownloadMode) {
} else if (!this.multiDownloadMode || !new_download) {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode) {
if (is_playlist) {
@@ -443,12 +496,11 @@ export class MainComponent implements OnInit {
this.downloadVideoFile(decodeURI(name));
}
} else {
localStorage.setItem('player_navigator', this.router.url);
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'video'}]);
// 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';
}
}
}
@@ -473,6 +525,25 @@ export class MainComponent implements OnInit {
this.urlError = false;
this.path = '';
// get common args
const customArgs = (this.customArgsEnabled ? this.customArgs : null);
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
const youtubeUsername = (this.youtubeAuthEnabled && this.youtubeUsername ? this.youtubeUsername : null);
const youtubePassword = (this.youtubeAuthEnabled && this.youtubePassword ? this.youtubePassword : null);
// set advanced inputs
if (this.allowAdvancedDownload) {
if (customArgs) {
localStorage.setItem('customArgs', customArgs);
}
if (customOutput) {
localStorage.setItem('customOutput', customOutput);
}
if (youtubeUsername) {
localStorage.setItem('youtubeUsername', youtubeUsername);
}
}
if (this.audioOnly) {
// create download object
const new_download: Download = {
@@ -489,18 +560,11 @@ export class MainComponent implements OnInit {
let customQualityConfiguration = null;
if (this.selectedQuality !== '') {
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio'];
customQualityConfiguration = audio_formats[this.selectedQuality]['format_id'];
}
customQualityConfiguration = this.getSelectedAudioFormat();
}
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, customArgs, customOutput).subscribe(posts => {
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword).subscribe(posts => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
@@ -529,20 +593,10 @@ export class MainComponent implements OnInit {
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
this.downloadingfile = true;
let customQualityConfiguration = null;
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const video_formats = this.cachedAvailableFormats[this.url]['formats']['video'];
if (video_formats['best_audio_format'] && this.selectedQuality !== '') {
customQualityConfiguration = video_formats[this.selectedQuality]['format_id'] + '+' + video_formats['best_audio_format'];
}
}
const customArgs = (this.customArgsEnabled ? this.customArgs : null);
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
const customQualityConfiguration = this.getSelectedVideoFormat();
this.postsService.makeMP4(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput).subscribe(posts => {
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword).subscribe(posts => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
@@ -580,6 +634,29 @@ export class MainComponent implements OnInit {
this.current_download = null;
}
getSelectedAudioFormat() {
if (this.selectedQuality === '') { return null };
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio'];
return audio_formats[this.selectedQuality]['format_id'];
} else {
return null;
}
}
getSelectedVideoFormat() {
if (this.selectedQuality === '') { return null };
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const video_formats = this.cachedAvailableFormats[this.url]['formats']['video'];
if (video_formats['best_audio_format'] && this.selectedQuality !== '') {
return video_formats[this.selectedQuality]['format_id'] + '+' + video_formats['best_audio_format'];
}
}
return null;
}
getDownloadByUID(uid) {
const index = this.downloads.findIndex(download => download.uid === uid);
if (index !== -1) {
@@ -722,6 +799,77 @@ export class MainComponent implements OnInit {
}
}
getSimulatedOutput() {
const customArgsExists = this.customArgsEnabled && this.customArgs;
const globalArgsExists = this.globalCustomArgs && this.globalCustomArgs !== '';
let full_string_array: string[] = [];
const base_string_array = ['youtube-dl', this.url];
if (customArgsExists) {
this.simulatedOutput = base_string_array.join(' ') + ' ' + this.customArgs;
return this.simulatedOutput;
}
full_string_array.push(...base_string_array);
const base_path = this.audioOnly ? this.audioFolderPath : this.videoFolderPath;
const ext = this.audioOnly ? '.mp3' : '.mp4';
// gets output
let output_string_array = ['-o', base_path + '%(title)s' + ext];
if (this.customOutputEnabled && this.customOutput) {
output_string_array = ['-o', this.customOutput + ext];
}
// before pushing output, should check if using an external downloader
if (!this.useDefaultDownloadingAgent && this.customDownloadingAgent === 'aria2c') {
full_string_array.push('--external-downloader', 'aria2c');
}
// pushes output
full_string_array.push(...output_string_array);
// logic splits into audio and video modes
if (this.audioOnly) {
// adds base audio string
const format_array = [];
const audio_format = this.getSelectedAudioFormat();
if (audio_format) {
format_array.push('-f', audio_format);
} else if (this.selectedQuality) {
format_array.push('--audio-quality', this.selectedQuality);
}
// pushes formats
full_string_array.splice(2, 0, ...format_array);
const additional_params = ['-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
full_string_array.push(...additional_params);
} else {
// adds base video string
let format_array = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4'];
const video_format = this.getSelectedVideoFormat();
if (video_format) {
format_array = ['-f', video_format];
} else if (this.selectedQuality) {
format_array = [`bestvideo[height=${this.selectedQuality}]+bestaudio/best[height=${this.selectedQuality}]`];
}
// pushes formats
full_string_array.splice(2, 0, ...format_array);
const additional_params = ['--write-info-json', '--print-json'];
full_string_array.push(...additional_params);
}
if (globalArgsExists) {
full_string_array = full_string_array.concat(this.globalCustomArgs.split(' '));
}
this.simulatedOutput = full_string_array.join(' ');
return this.simulatedOutput;
}
errorFormats(url) {
this.cachedAvailableFormats[url]['formats_loading'] = false;
console.error('Could not load formats for url ' + url);
@@ -757,7 +905,7 @@ export class MainComponent implements OnInit {
}
onResize(event) {
this.files_cols = (event.target.innerWidth <= 450) ? 2 : 4;
this.setCols();
}
videoModeChanged(new_val) {
@@ -773,6 +921,10 @@ export class MainComponent implements OnInit {
localStorage.setItem('customArgsEnabled', new_val.checked.toString());
if (new_val.checked === true && this.customOutputEnabled) {
this.customOutputEnabled = false;
localStorage.setItem('customOutputEnabled', 'false');
this.youtubeAuthEnabled = false;
localStorage.setItem('youtubeAuthEnabled', 'false');
}
}
@@ -780,6 +932,15 @@ export class MainComponent implements OnInit {
localStorage.setItem('customOutputEnabled', new_val.checked.toString());
if (new_val.checked === true && this.customArgsEnabled) {
this.customArgsEnabled = false;
localStorage.setItem('customArgsEnabled', 'false');
}
}
youtubeAuthEnabledChanged(new_val) {
localStorage.setItem('youtubeAuthEnabled', new_val.checked.toString());
if (new_val.checked === true && this.customArgsEnabled) {
this.customArgsEnabled = false;
localStorage.setItem('customArgsEnabled', 'false');
}
}

View File

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

View File

@@ -9,7 +9,7 @@
</div>
<div class="col-12 my-2">
<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 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">{{playlist_item.label}}</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>

View File

@@ -10,6 +10,7 @@ export interface IMedia {
title: string;
src: string;
type: string;
label: string;
}
@Component({
@@ -30,16 +31,19 @@ export class PlayerComponent implements OnInit {
// params
fileNames: string[];
type: string;
id = null; // used for playlists (not subscription)
subscriptionName = null;
subPlaylist = null;
baseStreamPath = null;
audioFolderPath = null;
videoFolderPath = null;
subscriptionFolderPath = null;
innerWidth: number;
downloading = false;
id = null;
@HostListener('window:resize', ['$event'])
onResize(event) {
this.innerWidth = window.innerWidth;
@@ -51,36 +55,58 @@ export class PlayerComponent implements OnInit {
this.fileNames = this.route.snapshot.paramMap.get('fileNames').split('|nvr|');
this.type = this.route.snapshot.paramMap.get('type');
this.id = this.route.snapshot.paramMap.get('id');
this.subscriptionName = this.route.snapshot.paramMap.get('subscriptionName');
this.subPlaylist = this.route.snapshot.paramMap.get('subPlaylist');
// loading config
this.postsService.loadNavItems().subscribe(result => { // loads settings
this.baseStreamPath = result['YoutubeDLMaterial']['Downloader']['path-base'];
this.postsService.loadNavItems().subscribe(res => { // loads settings
const result = !this.postsService.debugMode ? res['config_file'] : res;
this.baseStreamPath = this.postsService.path;
this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio'];
this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video'];
const backendUrl = result['YoutubeDLMaterial']['Host']['backendurl'];
this.subscriptionFolderPath = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_base_path'];
this.postsService.path = backendUrl;
this.postsService.startPath = backendUrl;
this.postsService.startPathSSL = backendUrl;
let fileType = null;
if (this.type === 'audio') {
fileType = 'audio/mp3';
} else if (this.type === 'video') {
fileType = 'video/mp4';
} else if (this.type === 'subscription') {
// only supports mp4 for now
fileType = 'video/mp4';
} else {
// error
console.error('Must have valid file type! Use \'audio\' or \video\'');
console.error('Must have valid file type! Use \'audio\', \'video\', or \'subscription\'.');
}
for (let i = 0; i < this.fileNames.length; i++) {
const fileName = this.fileNames[i];
const baseLocation = (this.type === 'audio') ? this.audioFolderPath : this.videoFolderPath;
const fullLocation = this.baseStreamPath + baseLocation + encodeURI(fileName); // + (this.type === 'audio' ? '.mp3' : '.mp4');
let baseLocation = null;
let fullLocation = null;
if (!this.subscriptionName) {
baseLocation = this.type + '/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName);
} else {
// default to video but include subscription name param
baseLocation = 'video/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist;
}
// if it has a slash (meaning it's in a directory), only get the file name for the label
let label = null;
const decodedName = decodeURIComponent(fileName);
const hasSlash = decodedName.includes('/') || decodedName.includes('\\');
if (hasSlash) {
label = decodedName.replace(/^.*[\\\/]/, '');
} else {
label = decodedName;
}
const mediaObject: IMedia = {
title: fileName,
src: fullLocation,
type: fileType
type: fileType,
label: label
}
this.playlist.push(mediaObject);
}

View File

@@ -1,4 +1,4 @@
import {Injectable, isDevMode} from '@angular/core';
import {Injectable, isDevMode, Inject} from '@angular/core';
import { HttpClient, HttpHeaders, HttpRequest, HttpResponseBase } from '@angular/common/http';
import config from '../assets/default.json';
import 'rxjs/add/operator/map';
@@ -7,20 +7,31 @@ import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';
import { THEMES_CONFIG } from '../themes';
import { Router } from '@angular/router';
import { DOCUMENT } from '@angular/common';
@Injectable()
export class PostsService {
path = '';
audioFolder = '';
videoFolder = '';
startPath = 'http://localhost:17442/';
startPathSSL = 'https://localhost:17442/'
startPath = null; // 'http://localhost:17442/';
startPathSSL = null; // 'https://localhost:17442/'
handShakeComplete = false;
THEMES_CONFIG = THEMES_CONFIG;
theme;
constructor(private http: HttpClient) {
debugMode = false;
constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document) {
console.log('PostsService Initialized...');
// this.startPath = window.location.href + '/api/';
// this.startPathSSL = window.location.href + '/api/';
this.path = this.document.location.origin + '/api/';
if (isDevMode()) {
this.debugMode = true;
this.path = 'http://localhost:17442/api/';
}
}
setTheme(theme) {
@@ -44,21 +55,25 @@ export class PostsService {
}
// tslint:disable-next-line: max-line-length
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null) {
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null) {
return this.http.post(this.path + 'tomp3', {url: url,
maxBitrate: selectedQuality,
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
customOutput: customOutput});
customOutput: customOutput,
youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword});
}
// tslint:disable-next-line: max-line-length
makeMP4(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null) {
makeMP4(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null) {
return this.http.post(this.path + 'tomp4', {url: url,
selectedHeight: selectedQuality,
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
customOutput: customOutput});
customOutput: customOutput,
youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword});
}
getFileStatusMp3(name: string) {
@@ -72,10 +87,13 @@ export class PostsService {
loadNavItems() {
if (isDevMode()) {
return this.http.get('./assets/default.json');
} else {
return this.http.get(this.path + 'config');
}
const locations = window.location.href.split('#');
const current_location = locations[0];
return this.http.get(current_location + 'backend/config/default.json');
}
setConfig(config) {
return this.http.post(this.path + 'setConfig', {new_config_file: config});
}
deleteFile(name: string, isAudio: boolean) {
@@ -102,6 +120,10 @@ export class PostsService {
{responseType: 'blob'});
}
downloadArchive(sub) {
return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob'});
}
getFileInfo(fileNames, type, urlMode) {
return this.http.post(this.path + 'getVideoInfos', {fileNames: fileNames, type: type, urlMode: urlMode});
}
@@ -122,6 +144,26 @@ export class PostsService {
removePlaylist(playlistID, type) {
return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type});
}
createSubscription(url, name, timerange = null) {
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange})
}
unsubscribe(sub, deleteMode = false) {
return this.http.post(this.path + 'unsubscribe', {sub: sub, deleteMode: deleteMode})
}
deleteSubscriptionFile(sub, file, deleteForever) {
return this.http.post(this.path + 'deleteSubscriptionFile', {sub: sub, file: file, deleteForever: deleteForever})
}
getSubscription(id) {
return this.http.post(this.path + 'getSubscription', {id: id});
}
getAllSubscriptions() {
return this.http.post(this.path + 'getAllSubscriptions', {});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
<br/>
<button class="back-button" (click)="goBack()" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
<div style="margin-bottom: 15px;">
<h2 style="text-align: center;" *ngIf="subscription">
{{subscription.name}}
</h2>
</div>
<mat-divider style="width: 80%; margin: 0 auto"></mat-divider>
<br/>
<div *ngIf="subscription">
<div class="flex-grid">
<div class="col"></div>
<div class="col">
<h4 style="text-align: center; margin-bottom: 20px;">Videos</h4>
</div>
<div class="col">
<mat-form-field [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
<input (focus)="searchIsFocused = true" (blur)="searchIsFocused = false" class="search-input" type="text" placeholder="Search" [(ngModel)]="search_text" (ngModelChange)="onSearchInputChanged($event)" matInput>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
</div>
</div>
<div class="container">
<div class="row">
<div *ngFor="let file of filtered_files" class="col-6 col-lg-4 mb-2 mt-2 sub-file-col">
<app-subscription-file-card (reloadSubscription)="getSubscription()" (goToFileEmit)="goToFile($event)" [file]="file" [sub]="subscription" [use_youtubedl_archive]="use_youtubedl_archive"></app-subscription-file-card>
</div>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-subscription',
templateUrl: './subscription.component.html',
styleUrls: ['./subscription.component.scss']
})
export class SubscriptionComponent implements OnInit {
id = null;
subscription = null;
files: any[] = null;
filtered_files: any[] = null;
use_youtubedl_archive = false;
search_mode = false;
search_text = '';
searchIsFocused = false;
constructor(private postsService: PostsService, private route: ActivatedRoute, private router: Router) { }
ngOnInit() {
if (this.route.snapshot.paramMap.get('id')) {
this.id = this.route.snapshot.paramMap.get('id');
this.getSubscription();
this.getConfig();
}
}
goBack() {
this.router.navigate(['/subscriptions']);
}
getSubscription() {
this.postsService.getSubscription(this.id).subscribe(res => {
this.subscription = res['subscription'];
this.files = res['files'];
if (this.search_mode) {
this.filterFiles(this.search_text);
} else {
this.filtered_files = this.files;
}
});
}
getConfig() {
this.postsService.loadNavItems().subscribe(res => {
const result = !this.postsService.debugMode ? res['config_file'] : res;
this.use_youtubedl_archive = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_use_youtubedl_archive'];
});
}
goToFile(name) {
localStorage.setItem('player_navigator', this.router.url);
this.router.navigate(['/player', {fileNames: name, type: 'subscription', subscriptionName: this.subscription.name,
subPlaylist: this.subscription.isPlaylist}]);
}
onSearchInputChanged(newvalue) {
if (newvalue.length > 0) {
this.search_mode = true;
this.filterFiles(newvalue);
} else {
this.search_mode = false;
}
}
private filterFiles(value: string) {
const filterValue = value.toLowerCase();
this.filtered_files = this.files.filter(option => option.id.toLowerCase().includes(filterValue));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
{
"YoutubeDLMaterial": {
"Host": {
"frontendurl": "http://localhost:4200",
"backendurl": "http://localhost:17442/"
},
"Host": {
"url": "http://localhost",
"port": "17442"
},
"Encryption": {
"use-encryption": false,
"cert-file-path": "/etc/letsencrypt/live/example.com/fullchain.pem",
"key-file-path": "/etc/letsencrypt/live/example.com/privkey.pem"
},
"Downloader": {
"path-base": "http://localhost:17442/",
"path-audio": "audio/",
"path-video": "video/"
"path-video": "video/",
"custom_args": ""
},
"Extra": {
"title_top": "Youtube Downloader",
@@ -29,6 +29,12 @@
"default_theme": "default",
"allow_theme_change": true
},
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

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

View File

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

Binary file not shown.