Compare commits

..

70 Commits

Author SHA1 Message Date
Isaac Abadi
7ee34d21eb Disables download cancelling for now 2021-09-16 15:28:25 -04:00
Isaac Abadi
66c184a2e6 Fixes issue with broken builds 2021-09-16 15:25:19 -04:00
Isaac Abadi
13f6f698b7 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into download-manager 2021-09-16 15:22:24 -04:00
Isaac Abadi
b08325c1e3 Added ability to filter for only audio and only video files in the home page 2021-09-15 10:47:46 -06:00
Isaac Abadi
070d3fed57 Improved error handling for downloads 2021-09-15 10:04:25 -06:00
Isaac Abadi
775a1766d8 Added max concurrent downloads setting
Fixed issue where navigating to a subscription video would make the player behave like a playlist for the whole sub
2021-09-15 09:44:31 -06:00
Isaac Abadi
dbefb66021 Fixed issue where errored downloads would result in an infinite loop of error messages in the home page
Added dialog to view error from an errored out download
2021-09-13 23:19:59 -06:00
Isaac Abadi
3241d6aaaf Added download manager to home page if autoplay is disabled
Fixed bug where the UI attempted to generate a preview URL for placeholder file cards

Fixed bug where file renaming was always attempted even when not necessary
2021-09-13 22:42:37 -06:00
Tzahi12345
d014c6facb Merge pull request #443 from KuroSetsuna29/fix-ldap-login
Fixed issue preventing LDAP to create new account
2021-09-12 12:57:51 -06:00
KuroSetsuna29
b25ab70732 Fixed issue preventing LDAP to create new account 2021-09-11 02:48:53 -04:00
Isaac Abadi
f9b8e78655 Fixed bug where auto builds would create a users file instead of a directory 2021-09-06 16:18:52 -06:00
Isaac Abadi
acad7cc057 Minor code cleanup 2021-09-06 16:15:52 -06:00
Isaac Abadi
c3d91e89a8 Get downloads now supports filtering by uids 2021-09-06 16:12:51 -06:00
Isaac Abadi
97c5102eb9 Limited video previews to video files only 2021-08-25 23:54:56 -06:00
Isaac Abadi
865185d277 Added ability to pause and resume all downloads
Removed backend dependency on queue library
2021-08-25 23:45:56 -06:00
Isaac Abadi
a36794fd4f Improved video preview behavior 2021-08-25 23:44:22 -06:00
Isaac Abadi
6639305771 Added video previews when hovering over a file card 2021-08-25 23:36:31 -06:00
Isaac Abadi
cca76dd248 Code cleanup 2021-08-24 22:05:02 -06:00
Isaac Abadi
d899f88164 Added button to edit a subscription from the subscriptions page 2021-08-24 21:34:10 -06:00
Isaac Abadi
09b3c752d9 Removed downlload delay setting for subscriptions
Subscription downloads already queued are now not requeued on the next check

Headers in download queue table are now sortable

Added button to clear all finished downloads in the downloads manager
2021-08-24 21:33:43 -06:00
Isaac Abadi
71bb91b6e6 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into download-manager 2021-08-23 20:22:22 -06:00
Isaac Abadi
f9b1414460 Logic to avoid duplicates for subscription files now uses the video URL instead of its path 2021-08-23 20:18:28 -06:00
Isaac Abadi
6eb1e2f898 Fixed issue where different path formatting would lead files to get duplicated in the DB 2021-08-22 23:06:16 -06:00
Isaac Abadi
30505d0e8b Cleaned up unused code in subscriptions 2021-08-22 22:50:16 -06:00
Isaac Abadi
48ab1836ca Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into download-manager 2021-08-22 22:34:19 -06:00
Isaac Abadi
20cedb6c29 Pagination and filtering of files is now server-side
Importing unregistered files does not block server start anymore
2021-08-22 22:31:01 -06:00
Isaac Abadi
9f5b6122fa Added additional protections to verify that the DB is initialized before downloader does
Began work on watching entire subscriptions as a playlist

Subscriptions now use the new download manager to download files
2021-08-21 21:54:40 -06:00
GlassedSilver
5321624604 Update README.md
Fixed Contributors link
2021-08-20 10:00:03 +02:00
Isaac Abadi
8828af4174 Fixed issue where config items that defaulted to false would not be created if they were missing 2021-08-19 23:22:37 -06:00
Isaac Abadi
2bb4860a36 Fixed issue where if multi user mode was not defined, subscriptions could not be retrieved 2021-08-19 23:09:00 -06:00
Isaac Abadi
ce3d540633 Forces file registration to avoid registering a file that already exists in an atomic fasion 2021-08-13 19:40:06 -06:00
Isaac Abadi
f7b152fcf6 Download manager is now per user
Replaced multi download mode with autoplay checkbox
2021-08-13 16:28:28 -06:00
Isaac Abadi
f892a4a305 Download manager is now thread safe 2021-08-10 23:57:26 -06:00
Isaac Abadi
fc55961822 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into download-manager 2021-08-10 21:33:31 -06:00
Isaac Abadi
ebfa49240c Added methods to modify download state
Added missing optionalJwt calls in several routes
2021-08-10 21:32:13 -06:00
Isaac Abadi
9e60d9fe3e Fixed issue where some some videos would send many requests to SponsorBlock when only one was needed 2021-08-09 00:23:09 -06:00
Isaac Abadi
ecef8842ae Converted downloads page to new downloads schema 2021-08-09 00:22:15 -06:00
Isaac Abadi
8cc653787f Cleaned up app.js backend code 2021-08-09 00:21:36 -06:00
Isaac Abadi
0360469c5a Download manager is now functional
Added UI support for new downloads schema

Implemented draft test for downloads

Cleaned up unused code snippets
2021-08-08 21:29:31 -06:00
Isaac Abadi
5a90be7703 Logger is now separated into its own module
Added eslint and fixed many logic errors based on its recommendations
2021-08-08 14:54:24 -06:00
Isaac Abadi
ff403d18d1 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into download-manager 2021-08-08 13:43:49 -06:00
Isaac Abadi
11284cb1b3 Fixed unnecessary (and mispelled) class for settings element 2021-08-08 06:00:15 -06:00
Isaac Abadi
8b1a1a56e3 Added SponsorBlock support for skipping ads when viewing supported videos
Updated default value for subscriptions check interval (new value of 86,400 only existed in the default.json)

Text inputs in settings menu are now larger
2021-08-08 05:56:47 -06:00
Tzahi12345
32370280ab Merge pull request #416 from BrianCArnold/master
Added change to make player work on iOS without being full screen.
2021-08-07 22:39:34 -06:00
Brian C. Arnold
240d6569fa Added change to make player work on iOS inline. 2021-08-06 15:50:11 -04:00
Isaac Abadi
2927a4564d Additional scaffolding for download manager
Added queue to npm backend dependencies
2021-08-05 18:57:54 -06:00
Isaac Abadi
5c94036625 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into download-manager 2021-08-02 19:59:54 -06:00
Isaac Abadi
7be90ccd94 Fixed bug where subscription videos would get duplicated 2021-08-02 18:50:44 -06:00
Isaac Abadi
01b6e22f83 Began scaffolding work for download manager 2021-08-02 18:41:30 -06:00
Isaac Abadi
b1385f451b Added option to rate limit downloads
Added option to force delay between videos in a subscription

Fixed issue where file handle was maintained on files deleted through unsubscribing
2021-08-01 22:19:15 -06:00
Tzahi12345
f40ac49082 Merge pull request #413 from Tzahi12345/cleaner-playlists-and-settings
Dedicated settings page and UI cleanup
2021-08-01 21:17:50 -06:00
Isaac Abadi
2756cfae17 Login component is now a lot prettier 2021-08-01 20:41:36 -06:00
Isaac Abadi
dac5919ffb Updated look of buttons and several home page elements 2021-08-01 20:41:13 -06:00
Isaac Abadi
34245bd339 Updated styling for settings page
Fixed issue where redirects to home occured when reloading the settings page

Fixed errors that occured when loading the settings page
2021-08-01 20:40:29 -06:00
Isaac Abadi
8d6ec819e6 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into cleaner-playlists-and-settings 2021-08-01 17:27:35 -06:00
Isaac Abadi
b03b4d173b Fixed issue where testing the connecting string would fail if local DB was being used
Fixed issue where blacklisting video in with archiving would not work

Cleaned up unused functions in app.js
2021-08-01 14:38:34 -06:00
Tzahi12345
c8f219d5b0 See previous commit, MongoDB is no longer on by default for all installs 2021-08-01 06:24:47 -06:00
Tzahi12345
ec3ab17507 MongoDB is no longer the default in config, this will just be set through the docker-compose.yml 2021-08-01 06:23:27 -06:00
Tzahi12345
5124e3b333 Update issue templates 2021-07-29 21:35:33 -06:00
Isaac Abadi
d09b244bc2 Fixed bug where unsubscribing from a channel would clear the entire files table
Fixed issue where yt-dlp did not work with subscriptions
2021-07-28 19:44:05 -06:00
Isaac Abadi
c0a385ce78 Default file output now applies to subscriptions 2021-07-27 22:36:32 -06:00
Isaac Abadi
258d5ff495 Test connection string now uses the currently typed in connection string rather than the last saved one 2021-07-26 20:31:35 -07:00
Isaac Abadi
fb5c13db27 Fixed issue where files could be added to playlists of the wrong type 2021-07-26 20:14:13 -07:00
Isaac Abadi
92413bd360 Added ability to add file to playlist using the context menu 2021-07-26 20:10:22 -07:00
Isaac Abadi
7174ef5f57 Fixed issue where config initialization did not occur early enough in lifecycle, causing db.js to throw an error if the config did not exist 2021-07-26 18:25:41 -07:00
Isaac Abadi
73b9cf7893 Settings is now a route instead of a dialog 2021-07-26 18:18:07 -07:00
Tzahi12345
7ff906fd35 Added issue templates 2021-07-22 23:04:29 -06:00
Isaac Abadi
6e084bd94a Fixed issue where subscriptions check interval would only update after restart 2021-07-22 20:56:42 -06:00
Tzahi12345
21b97911e8 Merge pull request #401 from Tzahi12345/python3-docker-test
yt-dlp python3 bugfix
2021-07-22 20:39:09 -06:00
Isaac Abadi
117255b0b7 Fixed bug where adding content to playlist wouldn't enable save button 2021-07-22 01:53:49 -06:00
63 changed files with 3162 additions and 1693 deletions

20
.eslintrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
}
}

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment**
- YoutubeDL-Material version
- Docker tag: <tag> (optional)
**Additional context**
Add any other context about the problem here. For example, a YouTube link.

View File

@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -38,7 +38,7 @@ jobs:
Copy-Item -Path ./backend/public -Recurse -Destination ./build/youtubedl-material Copy-Item -Path ./backend/public -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/subscriptions -Recurse -Destination ./build/youtubedl-material Copy-Item -Path ./backend/subscriptions -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/video -Recurse -Destination ./build/youtubedl-material Copy-Item -Path ./backend/video -Recurse -Destination ./build/youtubedl-material
New-Item -Path ./build/youtubedl-material -Name users New-Item -Path ./build/youtubedl-material -Name users -ItemType Directory
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
- name: upload build artifact - name: upload build artifact

View File

@@ -124,7 +124,7 @@ Official translators:
* German - UnlimitedCookies * German - UnlimitedCookies
* Chinese - TyRoyal * Chinese - TyRoyal
See also the list of [contributors](https://github.com/your/project/contributors) who participated in this project. See also the list of [contributors](https://github.com/Tzahi12345/YoutubeDL-Material/graphs/contributors) who participated in this project.
## License ## License

18
backend/.eslintrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"env": {
"node": true,
"es2021": true
},
"extends": [
"eslint:recommended"
],
"parser": "esprima",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [],
"rules": {
},
"root": true
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,14 +12,16 @@
"custom_args": "", "custom_args": "",
"safe_download_override": false, "safe_download_override": false,
"include_thumbnail": true, "include_thumbnail": true,
"include_metadata": true "include_metadata": true,
"max_concurrent_downloads": 5,
"download_rate_limit": ""
}, },
"Extra": { "Extra": {
"title_top": "YoutubeDL-Material", "title_top": "YoutubeDL-Material",
"file_manager_enabled": true, "file_manager_enabled": true,
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_multi_download_mode": true, "allow_autoplay": true,
"enable_downloads_manager": true, "enable_downloads_manager": true,
"allow_playlist_categorization": true "allow_playlist_categorization": true
}, },
@@ -30,7 +32,8 @@
"youtube_API_key": "", "youtube_API_key": "",
"use_twitch_API": false, "use_twitch_API": false,
"twitch_API_key": "", "twitch_API_key": "",
"twitch_auto_download_chat": false "twitch_auto_download_chat": false,
"use_sponsorblock_API": false
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -55,7 +58,7 @@
} }
}, },
"Database": { "Database": {
"use_local_db": false, "use_local_db": true,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib" "mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
}, },
"Advanced": { "Advanced": {

View File

@@ -1,7 +1,7 @@
const path = require('path');
const config_api = require('../config'); const config_api = require('../config');
const consts = require('../consts'); const consts = require('../consts');
const fs = require('fs-extra'); const logger = require('../logger');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
@@ -12,15 +12,13 @@ var JwtStrategy = require('passport-jwt').Strategy,
ExtractJwt = require('passport-jwt').ExtractJwt; ExtractJwt = require('passport-jwt').ExtractJwt;
// other required vars // other required vars
let logger = null;
let db_api = null; let db_api = null;
let SERVER_SECRET = null; let SERVER_SECRET = null;
let JWT_EXPIRATION = null; let JWT_EXPIRATION = null;
let opts = null; let opts = null;
let saltRounds = null; let saltRounds = null;
exports.initialize = function(db_api, input_logger) { exports.initialize = function(db_api) {
setLogger(input_logger)
setDB(db_api); setDB(db_api);
/************************* /*************************
@@ -53,10 +51,6 @@ exports.initialize = function(db_api, input_logger) {
})); }));
} }
function setLogger(input_logger) {
logger = input_logger;
}
function setDB(input_db_api) { function setDB(input_db_api) {
db_api = input_db_api; db_api = input_db_api;
} }
@@ -140,7 +134,7 @@ exports.registerUser = async function(req, res) {
exports.login = async (username, password) => { exports.login = async (username, password) => {
const user = await db_api.getRecord('users', {name: username}); const user = await db_api.getRecord('users', {name: username});
if (!user) { logger.error(`User ${username} not found`); false } if (!user) { logger.error(`User ${username} not found`); return false }
if (user.auth_method && user.auth_method !== 'internal') { return false } if (user.auth_method && user.auth_method !== 'internal') { return false }
return await bcrypt.compare(password, user.passhash) ? user : false; return await bcrypt.compare(password, user.passhash) ? user : false;
} }
@@ -291,17 +285,12 @@ exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false
return file; return file;
} }
exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames) {
users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).assign({fileNames: new_filenames});
return true;
}
exports.removePlaylist = async function(user_uid, playlistID) { exports.removePlaylist = async function(user_uid, playlistID) {
await db_api.removeRecord('playlist', {playlistID: playlistID}); await db_api.removeRecord('playlist', {playlistID: playlistID});
return true; return true;
} }
exports.getUserPlaylists = async function(user_uid, user_files = null) { exports.getUserPlaylists = async function(user_uid) {
return await db_api.getRecords('playlists', {user_uid: user_uid}); return await db_api.getRecords('playlists', {user_uid: user_uid});
} }

View File

@@ -1,17 +1,12 @@
const config_api = require('./config');
const utils = require('./utils'); const utils = require('./utils');
const logger = require('./logger');
var logger = null;
var db = null;
var users_db = null;
var db_api = null; var db_api = null;
function setDB(input_db, input_users_db, input_db_api) { db = input_db; users_db = input_users_db; db_api = input_db_api } function setDB(input_db_api) { db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db, input_users_db, input_logger, input_db_api) { function initialize(input_db_api) {
setDB(input_db, input_users_db, input_db_api); setDB(input_db_api);
setLogger(input_logger);
} }
/* /*
@@ -72,7 +67,7 @@ async function getCategoriesAsPlaylists(files = null) {
const categories_as_playlists = []; const categories_as_playlists = [];
const available_categories = await getCategories(); const available_categories = await getCategories();
if (available_categories && files) { if (available_categories && files) {
for (category of available_categories) { for (let category of available_categories) {
const files_that_match = utils.addUIDsToCategory(category, files); const files_that_match = utils.addUIDsToCategory(category, files);
if (files_that_match && files_that_match.length > 0) { if (files_that_match && files_that_match.length > 0) {
category['thumbnailURL'] = files_that_match[0].thumbnailURL; category['thumbnailURL'] = files_that_match[0].thumbnailURL;
@@ -125,21 +120,21 @@ function applyCategoryRules(file_json, rules, category_name) {
return rules_apply; return rules_apply;
} }
async function addTagToVideo(tag, video, user_uid) { // async function addTagToVideo(tag, video, user_uid) {
// TODO: Implement // // TODO: Implement
} // }
async function removeTagFromVideo(tag, video, user_uid) { // async function removeTagFromVideo(tag, video, user_uid) {
// TODO: Implement // // TODO: Implement
} // }
// adds tag to list of existing tags (used for tag suggestions) // // adds tag to list of existing tags (used for tag suggestions)
async function addTagToExistingTags(tag) { // async function addTagToExistingTags(tag) {
const existing_tags = db.get('tags').value(); // const existing_tags = db.get('tags').value();
if (!existing_tags.includes(tag)) { // if (!existing_tags.includes(tag)) {
db.get('tags').push(tag).write(); // db.get('tags').push(tag).write();
} // }
} // }
module.exports = { module.exports = {
initialize: initialize, initialize: initialize,

View File

@@ -1,3 +1,5 @@
const logger = require('./logger');
const fs = require('fs'); const fs = require('fs');
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS']; let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
@@ -5,11 +7,7 @@ const debugMode = process.env.YTDL_MODE === 'debug';
let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json'; let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json';
var logger = null; function initialize() {
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_logger) {
setLogger(input_logger);
ensureConfigFileExists(); ensureConfigFileExists();
ensureConfigItemsExist(); ensureConfigItemsExist();
} }
@@ -97,13 +95,13 @@ function getConfigItem(key) {
} }
let path = CONFIG_ITEMS[key]['path']; let path = CONFIG_ITEMS[key]['path'];
const val = Object.byString(config_json, path); const val = Object.byString(config_json, path);
if (val === undefined && Object.byString(DEFAULT_CONFIG, path)) { if (val === undefined && Object.byString(DEFAULT_CONFIG, path) !== undefined) {
logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`); logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`);
setConfigItem(key, Object.byString(DEFAULT_CONFIG, path)); setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
return Object.byString(DEFAULT_CONFIG, path); return Object.byString(DEFAULT_CONFIG, path);
} }
return Object.byString(config_json, path); return Object.byString(config_json, path);
}; }
function setConfigItem(key, value) { function setConfigItem(key, value) {
let success = false; let success = false;
@@ -175,7 +173,7 @@ module.exports = {
globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload
} }
DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
"YoutubeDLMaterial": { "YoutubeDLMaterial": {
"Host": { "Host": {
"url": "http://example.com", "url": "http://example.com",
@@ -189,14 +187,16 @@ DEFAULT_CONFIG = {
"custom_args": "", "custom_args": "",
"safe_download_override": false, "safe_download_override": false,
"include_thumbnail": true, "include_thumbnail": true,
"include_metadata": true "include_metadata": true,
"max_concurrent_downloads": 5,
"download_rate_limit": ""
}, },
"Extra": { "Extra": {
"title_top": "YoutubeDL-Material", "title_top": "YoutubeDL-Material",
"file_manager_enabled": true, "file_manager_enabled": true,
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_multi_download_mode": true, "allow_autoplay": true,
"enable_downloads_manager": true, "enable_downloads_manager": true,
"allow_playlist_categorization": true "allow_playlist_categorization": true
}, },
@@ -207,7 +207,8 @@ DEFAULT_CONFIG = {
"youtube_API_key": "", "youtube_API_key": "",
"use_twitch_API": false, "use_twitch_API": false,
"twitch_API_key": "", "twitch_API_key": "",
"twitch_auto_download_chat": false "twitch_auto_download_chat": false,
"use_sponsorblock_API": false
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -216,7 +217,7 @@ DEFAULT_CONFIG = {
"Subscriptions": { "Subscriptions": {
"allow_subscriptions": true, "allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/", "subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300", "subscriptions_check_interval": "86400",
"redownload_fresh_uploads": false "redownload_fresh_uploads": false
}, },
"Users": { "Users": {
@@ -232,7 +233,7 @@ DEFAULT_CONFIG = {
} }
}, },
"Database": { "Database": {
"use_local_db": false, "use_local_db": true,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib" "mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
}, },
"Advanced": { "Advanced": {

View File

@@ -1,4 +1,4 @@
let CONFIG_ITEMS = { exports.CONFIG_ITEMS = {
// Host // Host
'ytdl_url': { 'ytdl_url': {
'key': 'ytdl_url', 'key': 'ytdl_url',
@@ -42,6 +42,14 @@ let CONFIG_ITEMS = {
'key': 'ytdl_include_metadata', 'key': 'ytdl_include_metadata',
'path': 'YoutubeDLMaterial.Downloader.include_metadata' 'path': 'YoutubeDLMaterial.Downloader.include_metadata'
}, },
'ytdl_max_concurrent_downloads': {
'key': 'ytdl_max_concurrent_downloads',
'path': 'YoutubeDLMaterial.Downloader.max_concurrent_downloads'
},
'ytdl_download_rate_limit': {
'key': 'ytdl_download_rate_limit',
'path': 'YoutubeDLMaterial.Downloader.download_rate_limit'
},
// Extra // Extra
'ytdl_title_top': { 'ytdl_title_top': {
@@ -60,9 +68,9 @@ let CONFIG_ITEMS = {
'key': 'ytdl_download_only_mode', 'key': 'ytdl_download_only_mode',
'path': 'YoutubeDLMaterial.Extra.download_only_mode' 'path': 'YoutubeDLMaterial.Extra.download_only_mode'
}, },
'ytdl_allow_multi_download_mode': { 'ytdl_allow_autoplay': {
'key': 'ytdl_allow_multi_download_mode', 'key': 'ytdl_allow_autoplay',
'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode' 'path': 'YoutubeDLMaterial.Extra.allow_autoplay'
}, },
'ytdl_enable_downloads_manager': { 'ytdl_enable_downloads_manager': {
'key': 'ytdl_enable_downloads_manager', 'key': 'ytdl_enable_downloads_manager',
@@ -102,6 +110,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_twitch_auto_download_chat', 'key': 'ytdl_twitch_auto_download_chat',
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat' 'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
}, },
'ytdl_use_sponsorblock_api': {
'key': 'ytdl_use_sponsorblock_api',
'path': 'YoutubeDLMaterial.API.use_sponsorblock_API'
},
// Themes // Themes
'ytdl_default_theme': { 'ytdl_default_theme': {
@@ -126,10 +138,6 @@ let CONFIG_ITEMS = {
'key': 'ytdl_subscriptions_check_interval', 'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.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_redownload_fresh_uploads': { 'ytdl_subscriptions_redownload_fresh_uploads': {
'key': 'ytdl_subscriptions_redownload_fresh_uploads', 'key': 'ytdl_subscriptions_redownload_fresh_uploads',
'path': 'YoutubeDLMaterial.Subscriptions.redownload_fresh_uploads' 'path': 'YoutubeDLMaterial.Subscriptions.redownload_fresh_uploads'
@@ -198,7 +206,7 @@ let CONFIG_ITEMS = {
} }
}; };
AVAILABLE_PERMISSIONS = [ exports.AVAILABLE_PERMISSIONS = [
'filemanager', 'filemanager',
'settings', 'settings',
'subscriptions', 'subscriptions',
@@ -207,8 +215,6 @@ AVAILABLE_PERMISSIONS = [
'downloads_manager' 'downloads_manager'
]; ];
module.exports = { exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details'
CONFIG_ITEMS: CONFIG_ITEMS,
AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS, exports.CURRENT_VERSION = 'v4.2';
CURRENT_VERSION: 'v4.2'
}

View File

@@ -1,24 +1,31 @@
var fs = require('fs-extra') var fs = require('fs-extra')
var path = require('path') var path = require('path')
var utils = require('./utils')
const { uuid } = require('uuidv4');
const config_api = require('./config');
const { MongoClient } = require("mongodb"); const { MongoClient } = require("mongodb");
const { uuid } = require('uuidv4');
const config_api = require('./config');
var utils = require('./utils')
const logger = require('./logger');
const low = require('lowdb') const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync'); const FileSync = require('lowdb/adapters/FileSync');
const { BehaviorSubject } = require('rxjs');
const local_adapter = new FileSync('./appdata/local_db.json'); const local_adapter = new FileSync('./appdata/local_db.json');
const local_db = low(local_adapter); const local_db = low(local_adapter);
var logger = null; let database = null;
var db = null; exports.database_initialized = false;
var users_db = null; exports.database_initialized_bs = new BehaviorSubject(false);
var database = null;
const tables = { const tables = {
files: { files: {
name: 'files', name: 'files',
primary_key: 'uid' primary_key: 'uid',
text_search: {
title: 'text',
uploader: 'text',
uid: 'text'
}
}, },
playlists: { playlists: {
name: 'playlists', name: 'playlists',
@@ -43,6 +50,10 @@ const tables = {
name: 'roles', name: 'roles',
primary_key: 'key' primary_key: 'key'
}, },
download_queue: {
name: 'download_queue',
primary_key: 'uid'
},
test: { test: {
name: 'test' name: 'test'
} }
@@ -54,7 +65,7 @@ const local_db_defaults = {}
tables_list.forEach(table => {local_db_defaults[table] = []}); tables_list.forEach(table => {local_db_defaults[table] = []});
local_db.defaults(local_db_defaults).write(); local_db.defaults(local_db_defaults).write();
let using_local_db = config_api.getConfigItem('ytdl_use_local_db'); let using_local_db = null;
function setDB(input_db, input_users_db) { function setDB(input_db, input_users_db) {
db = input_db; users_db = input_users_db; db = input_db; users_db = input_users_db;
@@ -62,18 +73,16 @@ function setDB(input_db, input_users_db) {
exports.users_db = input_users_db exports.users_db = input_users_db
} }
function setLogger(input_logger) { exports.initialize = (input_db, input_users_db) => {
logger = input_logger;
}
exports.initialize = (input_db, input_users_db, input_logger) => {
setDB(input_db, input_users_db); setDB(input_db, input_users_db);
setLogger(input_logger);
// must be done here to prevent getConfigItem from being called before init
using_local_db = config_api.getConfigItem('ytdl_use_local_db');
} }
exports.connectToDB = async (retries = 5, no_fallback = false) => { exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {
if (using_local_db) return; if (using_local_db && !custom_connection_string) return;
const success = await exports._connectToDB(); const success = await exports._connectToDB(custom_connection_string);
if (success) return true; if (success) return true;
if (retries) { if (retries) {
@@ -105,8 +114,8 @@ exports.connectToDB = async (retries = 5, no_fallback = false) => {
return true; return true;
} }
exports._connectToDB = async () => { exports._connectToDB = async (custom_connection_string = null) => {
const uri = config_api.getConfigItem('ytdl_mongodb_connection_string'); // "mongodb://127.0.0.1:27017/?compressors=zlib&gssapiServiceName=mongodb"; const uri = !custom_connection_string ? config_api.getConfigItem('ytdl_mongodb_connection_string') : custom_connection_string; // "mongodb://127.0.0.1:27017/?compressors=zlib&gssapiServiceName=mongodb";
const client = new MongoClient(uri, { const client = new MongoClient(uri, {
useNewUrlParser: true, useNewUrlParser: true,
useUnifiedTopology: true, useUnifiedTopology: true,
@@ -115,6 +124,10 @@ exports._connectToDB = async () => {
try { try {
await client.connect(); await client.connect();
database = client.db('ytdl_material'); database = client.db('ytdl_material');
// avoid doing anything else if it's just a test
if (custom_connection_string) return true;
const existing_collections = (await database.listCollections({}, { nameOnly: true }).toArray()).map(collection => collection.name); const existing_collections = (await database.listCollections({}, { nameOnly: true }).toArray()).map(collection => collection.name);
const missing_tables = tables_list.filter(table => !(existing_collections.includes(table))); const missing_tables = tables_list.filter(table => !(existing_collections.includes(table)));
@@ -124,8 +137,13 @@ exports._connectToDB = async () => {
tables_list.forEach(async table => { tables_list.forEach(async table => {
const primary_key = tables[table]['primary_key']; const primary_key = tables[table]['primary_key'];
if (!primary_key) return; if (primary_key) {
await database.collection(table).createIndex({[primary_key]: 1}, { unique: true }); await database.collection(table).createIndex({[primary_key]: 1}, { unique: true });
}
const text_search = tables[table]['text_search'];
if (text_search) {
await database.collection(table).createIndex(text_search);
}
}); });
return true; return true;
} catch(err) { } catch(err) {
@@ -137,51 +155,17 @@ exports._connectToDB = async () => {
} }
} }
exports.registerFileDB = async (file_path, type, multiUserMode = null, sub = null, customPath = null, category = null, cropFileSettings = null, file_object = null) => { exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
let db_path = null; if (!file_object) file_object = generateFileObject(file_path, type);
const file_id = utils.removeFileExtension(file_path);
if (!file_object) file_object = generateFileObject(file_id, type, customPath || multiUserMode && multiUserMode.file_path, sub);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_id}`);
return false;
}
utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_path);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, customPath || multiUserMode && multiUserMode.file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
// modify duration
if (cropFileSettings) {
file_object['duration'] = (cropFileSettings.cropFileEnd || file_object.duration) - cropFileSettings.cropFileStart;
}
if (multiUserMode) file_object['user_uid'] = multiUserMode.user;
const file_obj = await registerFileDBManual(file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_id, type, multiUserMode && multiUserMode.file_path)
}
return file_obj;
}
exports.registerFileDB2 = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject2(file_path, type);
if (!file_object) { if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`); logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false; return false;
} }
utils.fixVideoMetadataPerms2(file_path, type); utils.fixVideoMetadataPerms(file_path, type);
// add thumbnail path // add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail2(file_path, type); file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
// if category exists, only include essential info // if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']}; if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
@@ -198,7 +182,7 @@ exports.registerFileDB2 = async (file_path, type, user_uid = null, category = nu
// remove metadata JSON if needed // remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) { if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile2(file_path, type) utils.deleteJSONFile(file_path, type)
} }
return file_obj; return file_obj;
@@ -216,39 +200,13 @@ async function registerFileDBManual(file_object) {
return file_object; return file_object;
} }
function generateFileObject(id, type, customPath = null, sub = null) { function generateFileObject(file_path, type) {
if (!customPath && sub) {
customPath = getAppendedBasePathSub(sub, config_api.getConfigItem('ytdl_subscriptions_base_path'));
}
var jsonobj = (type === 'audio') ? utils.getJSONMp3(id, customPath, true) : utils.getJSONMp4(id, customPath, true);
if (!jsonobj) {
return null;
}
const ext = (type === 'audio') ? '.mp3' : '.mp4'
const file_path = utils.getTrueFileName(jsonobj['_filename'], type); // path.join(type === 'audio' ? audioFolderPath : videoFolderPath, id + ext);
// console.
var stats = fs.statSync(path.join(__dirname, file_path));
var title = jsonobj.title;
var url = jsonobj.webpage_url;
var uploader = jsonobj.uploader;
var upload_date = jsonobj.upload_date;
upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : 'N/A';
var size = stats.size;
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = type === 'audio';
var description = jsonobj.description;
var file_obj = new utils.File(id, title, thumbnail, isaudio, duration, url, uploader, size, file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
function generateFileObject2(file_path, type) {
var jsonobj = utils.getJSON(file_path, type); var jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) { if (!jsonobj) {
return null; return null;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
return null;
} }
const ext = (type === 'audio') ? '.mp3' : '.mp4' const ext = (type === 'audio') ? '.mp3' : '.mp4'
const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type); const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type);
@@ -355,10 +313,11 @@ exports.importUnregisteredFiles = async () => {
const file = files[j]; const file = files[j];
// check if file exists in db, if not add it // check if file exists in db, if not add it
const file_is_registered = !!(await exports.getRecord('files', {id: file.id, sub_id: dir_to_check.sub_id})) const files_with_same_url = await exports.getRecords('files', {url: file.url, sub_id: dir_to_check.sub_id});
const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path)));
if (!file_is_registered) { if (!file_is_registered) {
// add additional info // add additional info
await exports.registerFileDB2(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null); await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
logger.verbose(`Added discovered file to the database: ${file.id}`); logger.verbose(`Added discovered file to the database: ${file.id}`);
} }
} }
@@ -366,24 +325,6 @@ exports.importUnregisteredFiles = async () => {
} }
exports.preimportUnregisteredSubscriptionFile = async (sub, appendedBasePath) => {
const preimported_file_paths = [];
const files = await utils.getDownloadedFilesByType(appendedBasePath, sub.type);
for (let i = 0; i < files.length; i++) {
const file = files[i];
// check if file exists in db, if not add it
const file_is_registered = await exports.getRecord('files', {id: file.id, sub_id: sub.id});
if (!file_is_registered) {
// add additional info
await exports.registerFileDB2(file['path'], sub.type, sub.user_uid, null, sub.id, null, file);
preimported_file_paths.push(file['path']);
logger.verbose(`Preemptively added subscription file to the database: ${file.id}`);
}
}
return preimported_file_paths;
}
exports.addMetadataPropertyToDB = async (property_key) => { exports.addMetadataPropertyToDB = async (property_key) => {
try { try {
const dirs_to_check = await exports.getFileDirectoriesAndDBs(); const dirs_to_check = await exports.getFileDirectoriesAndDBs();
@@ -408,24 +349,27 @@ exports.addMetadataPropertyToDB = async (property_key) => {
} }
} }
exports.createPlaylist = async (playlist_name, uids, type, thumbnail_url, user_uid = null) => { exports.createPlaylist = async (playlist_name, uids, type, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL'];
let new_playlist = { let new_playlist = {
name: playlist_name, name: playlist_name,
uids: uids, uids: uids,
id: uuid(), id: uuid(),
thumbnailURL: thumbnail_url, thumbnailURL: thumbnailToUse,
type: type, type: type,
registered: Date.now(), registered: Date.now(),
randomize_order: false randomize_order: false
}; };
const duration = await exports.calculatePlaylistDuration(new_playlist, user_uid);
new_playlist.duration = duration;
new_playlist.user_uid = user_uid ? user_uid : undefined; new_playlist.user_uid = user_uid ? user_uid : undefined;
await exports.insertRecordIntoTable('playlists', new_playlist); await exports.insertRecordIntoTable('playlists', new_playlist);
const duration = await exports.calculatePlaylistDuration(new_playlist);
await exports.updateRecord('playlists', {id: new_playlist.id}, {duration: duration});
return new_playlist; return new_playlist;
} }
@@ -460,10 +404,10 @@ exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = fal
return playlist; return playlist;
} }
exports.updatePlaylist = async (playlist, user_uid = null) => { exports.updatePlaylist = async (playlist) => {
let playlistID = playlist.id; let playlistID = playlist.id;
const duration = await exports.calculatePlaylistDuration(playlist, user_uid); const duration = await exports.calculatePlaylistDuration(playlist);
playlist.duration = duration; playlist.duration = duration;
return await exports.updateRecord('playlists', {id: playlistID}, playlist); return await exports.updateRecord('playlists', {id: playlistID}, playlist);
@@ -483,12 +427,12 @@ exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = nul
return success; return success;
} }
exports.calculatePlaylistDuration = async (playlist, uuid, playlist_file_objs = null) => { exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) => {
if (!playlist_file_objs) { if (!playlist_file_objs) {
playlist_file_objs = []; playlist_file_objs = [];
for (let i = 0; i < playlist['uids'].length; i++) { for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i]; const uid = playlist['uids'][i];
const file_obj = await exports.getVideo(uid, uuid); const file_obj = await exports.getVideo(uid);
if (file_obj) playlist_file_objs.push(file_obj); if (file_obj) playlist_file_objs.push(file_obj);
} }
} }
@@ -585,7 +529,7 @@ exports.getVideoUIDByID = async (file_id, uuid = null) => {
return file_obj ? file_obj['uid'] : null; return file_obj ? file_obj['uid'] : null;
} }
exports.getVideo = async (file_uid, uuid = null, sub_id = null) => { exports.getVideo = async (file_uid) => {
return await exports.getRecord('files', {uid: file_uid}); return await exports.getRecord('files', {uid: file_uid});
} }
@@ -610,7 +554,22 @@ exports.insertRecordIntoTable = async (table, doc, replaceFilter = null) => {
return true; return true;
} }
if (replaceFilter) await database.collection(table).deleteMany(replaceFilter); if (replaceFilter) {
const output = await database.collection(table).bulkWrite([
{
deleteMany: {
filter: replaceFilter
}
},
{
insertOne: {
document: doc
}
}
]);
logger.debug(`Inserted doc into ${table} with filter: ${JSON.stringify(replaceFilter)}`);
return !!(output['result']['ok']);
}
const output = await database.collection(table).insertOne(doc); const output = await database.collection(table).insertOne(doc);
logger.debug(`Inserted doc into ${table}`); logger.debug(`Inserted doc into ${table}`);
@@ -667,13 +626,28 @@ exports.getRecord = async (table, filter_obj) => {
return await database.collection(table).findOne(filter_obj); return await database.collection(table).findOne(filter_obj);
} }
exports.getRecords = async (table, filter_obj = null) => { exports.getRecords = async (table, filter_obj = null, return_count = false, sort = null, range = null) => {
// local db override // local db override
if (using_local_db) { if (using_local_db) {
return filter_obj ? applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').value() : local_db.get(table).value(); let cursor = filter_obj ? applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').value() : local_db.get(table).value();
if (sort) {
cursor = cursor.sort((a, b) => (a[sort['by']] > b[sort['by']] ? sort['order'] : sort['order']*-1));
}
if (range) {
cursor = cursor.slice(range[0], range[1]);
}
return !return_count ? cursor : cursor.length;
} }
return filter_obj ? await database.collection(table).find(filter_obj).toArray() : await database.collection(table).find().toArray(); const cursor = filter_obj ? database.collection(table).find(filter_obj) : database.collection(table).find();
if (sort) {
cursor.sort({[sort['by']]: sort['order']});
}
if (range) {
cursor.skip(range[0]).limit(range[1] - range[0]);
}
return !return_count ? await cursor.toArray() : await cursor.count();
} }
// Update // Update
@@ -771,26 +745,26 @@ exports.removeRecord = async (table, filter_obj) => {
return !!(output['result']['ok']); return !!(output['result']['ok']);
} }
exports.removeAllRecords = async (table = null) => { exports.removeAllRecords = async (table = null, filter_obj = null) => {
// local db override // local db override
const tables_to_remove = table ? [table] : tables_list; const tables_to_remove = table ? [table] : tables_list;
logger.debug(`Removing all records from: ${tables_to_remove} with filter: ${JSON.stringify(filter_obj)}`)
if (using_local_db) { if (using_local_db) {
logger.debug(`Removing all records from: ${tables_to_remove}`)
for (let i = 0; i < tables_to_remove.length; i++) { for (let i = 0; i < tables_to_remove.length; i++) {
const table_to_remove = tables_to_remove[i]; const table_to_remove = tables_to_remove[i];
local_db.assign({[table_to_remove]: []}).write(); if (filter_obj) applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
logger.debug(`Removed all records from ${table_to_remove}`); else local_db.assign({[table_to_remove]: []}).write();
logger.debug(`Successfully removed records from ${table_to_remove}`);
} }
return true; return true;
} }
let success = true; let success = true;
logger.debug(`Removing all records from: ${tables_to_remove}`)
for (let i = 0; i < tables_to_remove.length; i++) { for (let i = 0; i < tables_to_remove.length; i++) {
const table_to_remove = tables_to_remove[i]; const table_to_remove = tables_to_remove[i];
const output = await database.collection(table_to_remove).deleteMany({}); const output = await database.collection(table_to_remove).deleteMany(filter_obj ? filter_obj : {});
logger.debug(`Removed all records from ${table_to_remove}`); logger.debug(`Successfully removed records from ${table_to_remove}`);
success &= !!(output['result']['ok']); success &= !!(output['result']['ok']);
} }
return success; return success;
@@ -978,6 +952,8 @@ exports.transferDB = async (local_to_remote) => {
config_api.setConfigItem('ytdl_use_local_db', using_local_db); config_api.setConfigItem('ytdl_use_local_db', using_local_db);
logger.debug('Transfer finished!');
return success; return success;
} }
@@ -996,11 +972,29 @@ const applyFilterLocalDB = (db_path, filter_obj, operation) => {
const filter_prop_value = filter_obj[filter_prop]; const filter_prop_value = filter_obj[filter_prop];
if (filter_prop_value === undefined || filter_prop_value === null) { if (filter_prop_value === undefined || filter_prop_value === null) {
filtered &= record[filter_prop] === undefined || record[filter_prop] === null filtered &= record[filter_prop] === undefined || record[filter_prop] === null
} else {
if (typeof filter_prop_value === 'object') {
if (filter_prop_value['$regex']) {
filtered &= (record[filter_prop].search(new RegExp(filter_prop_value['$regex'], filter_prop_value['$options'])) !== -1);
}
} else { } else {
filtered &= record[filter_prop] === filter_prop_value; filtered &= record[filter_prop] === filter_prop_value;
} }
} }
}
return filtered; return filtered;
}); });
return return_val; return return_val;
} }
// archive helper functions
async function writeToBlacklist(type, line) {
const archivePath = path.join(__dirname, 'appdata', 'archives');
let blacklistPath = path.join(archivePath, (type === 'audio') ? 'blacklist_audio.txt' : 'blacklist_video.txt');
// adds newline to the beginning of the line
line.replace('\n', '');
line.replace('\r', '');
line = '\n' + line;
await fs.appendFile(blacklistPath, line);
}

606
backend/downloader.js Normal file
View File

@@ -0,0 +1,606 @@
const fs = require('fs-extra');
const { uuid } = require('uuidv4');
const path = require('path');
const mergeFiles = require('merge-files');
const NodeID3 = require('node-id3')
const glob = require('glob')
const Mutex = require('async-mutex').Mutex;
const youtubedl = require('youtube-dl');
const logger = require('./logger');
const config_api = require('./config');
const twitch_api = require('./twitch');
const categories_api = require('./categories');
const utils = require('./utils');
let db_api = null;
const mutex = new Mutex();
let should_check_downloads = true;
const archivePath = path.join(__dirname, 'appdata', 'archives');
function setDB(input_db_api) { db_api = input_db_api }
exports.initialize = (input_db_api) => {
setDB(input_db_api);
categories_api.initialize(db_api);
if (db_api.database_initialized) {
setupDownloads();
} else {
db_api.database_initialized_bs.subscribe(init => {
if (init) setupDownloads();
});
}
}
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null) => {
return await mutex.runExclusive(async () => {
const download = {
url: url,
type: type,
title: '',
user_uid: user_uid,
sub_id: sub_id,
sub_name: sub_name,
options: options,
uid: uuid(),
step_index: 0,
paused: false,
running: false,
finished_step: true,
error: null,
percent_complete: null,
finished: false,
timestamp_start: Date.now()
};
await db_api.insertRecordIntoTable('download_queue', download);
should_check_downloads = true;
return download;
});
}
exports.pauseDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
logger.warn(`Download ${download_uid} is already paused!`);
return false;
} else if (download['finished']) {
logger.info(`Download ${download_uid} could not be paused before completing.`);
return false;
}
return await db_api.updateRecord('download_queue', {uid: download_uid}, {paused: true, running: false});
}
exports.resumeDownload = async (download_uid) => {
return await mutex.runExclusive(async () => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (!download['paused']) {
logger.warn(`Download ${download_uid} is not paused!`);
return false;
}
const success = db_api.updateRecord('download_queue', {uid: download_uid}, {paused: false});
should_check_downloads = true;
return success;
})
}
exports.restartDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
await exports.clearDownload(download_uid);
const success = !!(await exports.createDownload(download['url'], download['type'], download['options'], download['user_uid']));
should_check_downloads = true;
return success;
}
exports.cancelDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['cancelled']) {
logger.warn(`Download ${download_uid} is already cancelled!`);
return false;
} else if (download['finished']) {
logger.info(`Download ${download_uid} could not be cancelled before completing.`);
return false;
}
return await db_api.updateRecord('download_queue', {uid: download_uid}, {cancelled: true, running: false});
}
exports.clearDownload = async (download_uid) => {
return await db_api.removeRecord('download_queue', {uid: download_uid});
}
async function handleDownloadError(download_uid, error_message) {
await db_api.updateRecord('download_queue', {uid: download_uid}, {error: error_message, finished: true, running: false});
}
async function setupDownloads() {
await fixDownloadState();
setInterval(checkDownloads, 1000);
}
async function fixDownloadState() {
const downloads = await db_api.getRecords('download_queue');
downloads.sort((download1, download2) => download1.timestamp_start - download2.timestamp_start);
const running_downloads = downloads.filter(download => !download['finished'] && !download['error']);
for (let i = 0; i < running_downloads.length; i++) {
const running_download = running_downloads[i];
const update_obj = {finished_step: true, paused: true, running: false};
if (running_download['step_index'] > 0) {
update_obj['step_index'] = running_download['step_index'] - 1;
}
await db_api.updateRecord('download_queue', {uid: running_download['uid']}, update_obj);
}
}
async function checkDownloads() {
if (!should_check_downloads) return;
const downloads = await db_api.getRecords('download_queue');
downloads.sort((download1, download2) => download1.timestamp_start - download2.timestamp_start);
await mutex.runExclusive(async () => {
// avoid checking downloads unnecessarily, but double check that should_check_downloads is still true
const running_downloads = downloads.filter(download => !download['paused'] && !download['finished']);
if (running_downloads.length === 0) {
should_check_downloads = false;
logger.verbose('Disabling checking downloads as none are available.');
}
return;
});
let running_downloads_count = downloads.filter(download => download['running']).length;
const waiting_downloads = downloads.filter(download => !download['paused'] && download['finished_step'] && !download['finished']);
for (let i = 0; i < waiting_downloads.length; i++) {
const waiting_download = waiting_downloads[i];
const max_concurrent_downloads = config_api.getConfigItem('ytdl_max_concurrent_downloads');
if (max_concurrent_downloads < 0 || running_downloads_count >= max_concurrent_downloads) break;
if (waiting_download['finished_step'] && !waiting_download['finished']) {
// move to next step
running_downloads_count++;
if (waiting_download['step_index'] === 0) {
collectInfo(waiting_download['uid']);
} else if (waiting_download['step_index'] === 1) {
downloadQueuedFile(waiting_download['uid']);
}
}
}
}
async function collectInfo(download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
return;
}
logger.verbose(`Collecting info for download ${download_uid}`);
await db_api.updateRecord('download_queue', {uid: download_uid}, {step_index: 1, finished_step: false, running: true});
const url = download['url'];
const type = download['type'];
const options = download['options'];
if (download['user_uid'] && !options.customFileFolderPath) {
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_path = path.join(usersFileFolder, download['user_uid'], type);
options.customFileFolderPath = user_path + path.sep;
}
let args = await generateArgs(url, type, options, download['user_uid']);
// get video info prior to download
let info = await getVideoInfoByURL(url, args, download_uid);
if (!info) {
// info failed, error presumably already recorded
return;
}
let category = null;
// check if it fits into a category. If so, then get info again using new args
if (!Array.isArray(info) || config_api.getConfigItem('ytdl_allow_playlist_categorization')) category = await categories_api.categorize(info);
// set custom output if the category has one and re-retrieve info so the download manager has the right file name
if (category && category['custom_output']) {
options.customOutput = category['custom_output'];
options.noRelativePath = true;
args = await generateArgs(url, type, options, download['user_uid']);
info = await getVideoInfoByURL(url, args, download_uid);
}
// setup info required to calculate download progress
const expected_file_size = utils.getExpectedFileSize(info);
const files_to_check_for_progress = [];
// store info in download for future use
if (Array.isArray(info)) {
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
} else {
files_to_check_for_progress.push(utils.removeFileExtension(info['_filename']));
}
const playlist_title = Array.isArray(info) ? info[0]['playlist_title'] || info[0]['playlist'] : null;
await db_api.updateRecord('download_queue', {uid: download_uid}, {args: args,
finished_step: true,
running: false,
options: options,
files_to_check_for_progress: files_to_check_for_progress,
expected_file_size: expected_file_size,
title: playlist_title ? playlist_title : info['title']
});
}
async function downloadQueuedFile(download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
return;
}
logger.verbose(`Downloading ${download_uid}`);
return new Promise(async resolve => {
const audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
await db_api.updateRecord('download_queue', {uid: download_uid}, {step_index: 2, finished_step: false, running: true});
const url = download['url'];
const type = download['type'];
const options = download['options'];
const args = download['args'];
const category = download['category'];
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; // TODO: fix
if (options.customFileFolderPath) {
fileFolderPath = options.customFileFolderPath;
}
fs.ensureDirSync(fileFolderPath);
const start_time = Date.now();
const download_checker = setInterval(() => checkDownloadPercent(download['uid']), 1000);
// download file
youtubedl.exec(url, args, {maxBuffer: Infinity}, async function(err, output) {
const file_objs = [];
let end_time = Date.now();
let difference = (end_time - start_time)/1000;
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
clearInterval(download_checker);
if (err) {
logger.error(err.stderr);
await handleDownloadError(download_uid, err.stderr);
resolve(false);
return;
} else if (output) {
if (output.length === 0 || output[0].length === 0) {
// ERROR!
logger.warn(`No output received for video download, check if it exists in your archive.`)
resolve(false);
return;
}
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;
}
// get filepath with no extension
const filepath_no_extension = utils.removeFileExtension(output_json['_filename']);
const ext = type === 'audio' ? '.mp3' : '.mp4';
var full_file_path = filepath_no_extension + ext;
var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length);
if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_use_twitch_api') && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']);
}
// renames file if necessary due to bug
if (!fs.existsSync(output_json['_filename']) && fs.existsSync(output_json['_filename'] + '.webm')) {
try {
fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']);
logger.info('Renamed ' + file_name + '.webm to ' + file_name);
} catch(e) {
logger.error(`Failed to rename file ${output_json['_filename']} to its appropriate extension.`);
}
}
if (type === 'audio') {
let tags = {
title: output_json['title'],
artist: output_json['artist'] ? output_json['artist'] : output_json['uploader']
}
let success = NodeID3.write(tags, utils.removeFileExtension(output_json['_filename']) + '.mp3');
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
}
if (options.cropFileSettings) {
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext);
}
// registers file in DB
const file_obj = await db_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
file_objs.push(file_obj);
}
if (options.merged_string !== null && options.merged_string !== undefined) {
let current_merged_archive = fs.readFileSync(path.join(fileFolderPath, `merged_${type}.txt`), 'utf8');
let diff = current_merged_archive.replace(options.merged_string, '');
const archive_path = download['user_uid'] ? path.join(fileFolderPath, 'archives', `archive_${type}.txt`) : path.join(archivePath, `archive_${type}.txt`);
fs.appendFileSync(archive_path, diff);
}
let container = null;
if (file_objs.length > 1) {
// create playlist
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
container = await db_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), type, download['user_uid']);
} else if (file_objs.length === 1) {
container = file_objs[0];
} else {
const error_message = 'Downloaded file failed to result in metadata object.';
logger.error(error_message);
await handleDownloadError(download_uid, error_message);
}
const file_uids = file_objs.map(file_obj => file_obj.uid);
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
resolve();
}
});
});
}
// helper functions
async function generateArgs(url, type, options, user_uid = null) {
const audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
const videopath = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
const globalArgs = config_api.getConfigItem('ytdl_custom_args');
const useCookies = config_api.getConfigItem('ytdl_use_cookies');
const is_audio = type === 'audio';
let fileFolderPath = is_audio ? audioFolderPath : videoFolderPath;
if (options.customFileFolderPath) fileFolderPath = options.customFileFolderPath;
const customArgs = options.customArgs;
let customOutput = options.customOutput;
const customQualityConfiguration = options.customQualityConfiguration;
// video-specific args
const selectedHeight = options.selectedHeight;
// audio-specific args
const maxBitrate = options.maxBitrate;
const youtubeUsername = options.youtubeUsername;
const youtubePassword = options.youtubePassword;
let downloadConfig = null;
let qualityPath = (is_audio && !options.skip_audio_args) ? ['-f', 'bestaudio'] : ['-f', 'bestvideo+bestaudio', '--merge-output-format', 'mp4'];
const is_youtube = url.includes('youtu');
if (!is_audio && !is_youtube) {
// tiktok videos fail when using the default format
qualityPath = null;
} else if (!is_audio && !is_youtube && (url.includes('reddit') || url.includes('pornhub'))) {
qualityPath = ['-f', 'bestvideo+bestaudio']
}
if (customArgs) {
downloadConfig = customArgs.split(',,');
} else {
if (customQualityConfiguration) {
qualityPath = ['-f', customQualityConfiguration];
} else if (selectedHeight && selectedHeight !== '' && !is_audio) {
qualityPath = ['-f', `'(mp4)[height=${selectedHeight}'`];
} else if (is_audio) {
qualityPath = ['--audio-quality', maxBitrate ? maxBitrate : '0']
}
if (customOutput) {
customOutput = options.noRelativePath ? customOutput : path.join(fileFolderPath, customOutput);
downloadConfig = ['-o', `${customOutput}.%(ext)s`, '--write-info-json', '--print-json'];
} else {
downloadConfig = ['-o', path.join(fileFolderPath, videopath + (is_audio ? '.%(ext)s' : '.mp4')), '--write-info-json', '--print-json'];
}
if (qualityPath) downloadConfig.push(...qualityPath);
if (is_audio && !options.skip_audio_args) {
downloadConfig.push('-x');
downloadConfig.push('--audio-format', 'mp3');
}
if (youtubeUsername && youtubePassword) {
downloadConfig.push('--username', youtubeUsername, '--password', youtubePassword);
}
if (useCookies) {
if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
}
}
const useDefaultDownloadingAgent = config_api.getConfigItem('ytdl_use_default_downloading_agent');
const customDownloadingAgent = config_api.getConfigItem('ytdl_custom_downloading_agent');
if (!useDefaultDownloadingAgent && customDownloadingAgent) {
downloadConfig.splice(0, 0, '--external-downloader', customDownloadingAgent);
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
let archive_folder = null;
if (options.customArchivePath) {
archive_folder = path.join(options.customArchivePath);
} else if (user_uid) {
archive_folder = path.join(fileFolderPath, 'archives');
} else {
archive_folder = path.join(archivePath);
}
const archive_path = path.join(archive_folder, `archive_${type}.txt`);
await fs.ensureDir(archive_folder);
await fs.ensureFile(archive_path);
let blacklist_path = path.join(archive_folder, `blacklist_${type}.txt`);
await fs.ensureFile(blacklist_path);
let merged_path = path.join(fileFolderPath, `merged_${type}.txt`);
await fs.ensureFile(merged_path);
// merges blacklist and regular archive
let inputPathList = [archive_path, blacklist_path];
await mergeFiles(inputPathList, merged_path);
options.merged_string = await fs.readFile(merged_path, "utf8");
downloadConfig.push('--download-archive', merged_path);
}
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
downloadConfig.push('--write-thumbnail');
}
if (globalArgs && globalArgs !== '') {
// adds global args
if (downloadConfig.indexOf('-o') !== -1 && globalArgs.split(',,').indexOf('-o') !== -1) {
// if global args has an output, replce the original output with that of global args
const original_output_index = downloadConfig.indexOf('-o');
downloadConfig.splice(original_output_index, 2);
}
downloadConfig = downloadConfig.concat(globalArgs.split(',,'));
}
if (options.additionalArgs && options.additionalArgs !== '') {
downloadConfig = downloadConfig.concat(options.additionalArgs.split(',,'));
}
const rate_limit = config_api.getConfigItem('ytdl_download_rate_limit');
if (rate_limit && downloadConfig.indexOf('-r') === -1 && downloadConfig.indexOf('--limit-rate') === -1) {
downloadConfig.push('-r', rate_limit);
}
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
if (default_downloader === 'yt-dlp') {
downloadConfig.push('--no-clean-infojson');
}
}
// filter out incompatible args
downloadConfig = filterArgs(downloadConfig, is_audio);
logger.verbose(`youtube-dl args being used: ${downloadConfig.join(',')}`);
return downloadConfig;
}
async function getVideoInfoByURL(url, args = [], download_uid = null) {
return new Promise(resolve => {
// remove bad args
const new_args = [...args];
const archiveArgIndex = new_args.indexOf('--download-archive');
if (archiveArgIndex !== -1) {
new_args.splice(archiveArgIndex, 2);
}
new_args.push('--dump-json');
youtubedl.exec(url, new_args, {maxBuffer: Infinity}, async (err, output) => {
if (output) {
let outputs = [];
try {
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;
}
outputs.push(output_json);
}
resolve(outputs.length === 1 ? outputs[0] : outputs);
} catch(e) {
const error = `Error while retrieving info on video with URL ${url} with the following message: output JSON could not be parsed. Output JSON: ${output}`;
logger.error(error);
if (download_uid) {
await handleDownloadError(download_uid, error);
}
resolve(null);
}
} else {
let error_message = `Error while retrieving info on video with URL ${url} with the following message: ${err}`;
if (err.stderr) error_message += `\n\n${err.stderr}`;
logger.error(error_message);
if (download_uid) {
await handleDownloadError(download_uid, error_message);
}
resolve(null);
}
});
});
}
function filterArgs(args, isAudio) {
const video_only_args = ['--add-metadata', '--embed-subs', '--xattrs'];
const audio_only_args = ['-x', '--extract-audio', '--embed-thumbnail'];
const args_to_remove = isAudio ? video_only_args : audio_only_args;
return args.filter(x => !args_to_remove.includes(x));
}
async function checkDownloadPercent(download_uid) {
/*
This is more of an art than a science, we're just selecting files that start with the file name,
thus capturing the parts being downloaded in files named like so: '<video title>.<format>.<ext>.part'.
Any file that starts with <video title> will be counted as part of the "bytes downloaded", which will
be divided by the "total expected bytes."
*/
const download = await db_api.getRecord('download_queue', {uid: download_uid});
const files_to_check_for_progress = download['files_to_check_for_progress'];
const resulting_file_size = download['expected_file_size'];
if (!resulting_file_size) return;
let sum_size = 0;
glob(`{${files_to_check_for_progress.join(',')}, }*`, async (err, files) => {
files.forEach(async file => {
try {
const file_stats = fs.statSync(file);
if (file_stats && file_stats.size) {
sum_size += file_stats.size;
}
} catch (e) {
}
});
const percent_complete = (sum_size/resulting_file_size * 100).toFixed(2);
await db_api.updateRecord('download_queue', {uid: download_uid}, {percent_complete: percent_complete});
});
}

23
backend/logger.js Normal file
View File

@@ -0,0 +1,23 @@
const winston = require('winston');
let debugMode = process.env.YTDL_MODE === 'debug';
const defaultFormat = winston.format.printf(({ level, message, label, timestamp }) => {
return `${timestamp} ${level.toUpperCase()}: ${message}`;
});
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(winston.format.timestamp(), defaultFormat),
defaultMeta: {},
transports: [
//
// - Write to all logs with level `info` and below to `combined.log`
// - Write all logs error (and below) to `error.log`.
//
new winston.transports.File({ filename: 'appdata/logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'appdata/logs/combined.log' }),
new winston.transports.Console({level: !debugMode ? 'info' : 'debug', name: 'console'})
]
});
module.exports = logger;

View File

@@ -287,6 +287,14 @@
"resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz",
"integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw=="
}, },
"async-mutex": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.1.tgz",
"integrity": "sha512-vRfQwcqBnJTLzVQo72Sf7KIUbcSUP5hNchx6udI1U6LuPQpfePgdjJzlCe76yFZ8pxlLjn9lwcl/Ya0TSOv0Tw==",
"requires": {
"tslib": "^2.1.0"
}
},
"asynckit": { "asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -3214,6 +3222,11 @@
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz",
"integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw=="
}, },
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"tunnel-agent": { "tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@@ -32,6 +32,7 @@
"dependencies": { "dependencies": {
"archiver": "^3.1.1", "archiver": "^3.1.1",
"async": "^3.1.0", "async": "^3.1.0",
"async-mutex": "^0.3.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"bcryptjs": "^2.4.0", "bcryptjs": "^2.4.0",
"compression": "^1.7.4", "compression": "^1.7.4",

View File

@@ -1,27 +1,21 @@
const FileSync = require('lowdb/adapters/FileSync') const fs = require('fs-extra');
const path = require('path');
const youtubedl = require('youtube-dl');
var fs = require('fs-extra');
const { uuid } = require('uuidv4');
var path = require('path');
var youtubedl = require('youtube-dl');
const config_api = require('./config'); const config_api = require('./config');
const twitch_api = require('./twitch'); const utils = require('./utils');
var utils = require('./utils'); const logger = require('./logger');
const debugMode = process.env.YTDL_MODE === 'debug'; const debugMode = process.env.YTDL_MODE === 'debug';
var logger = null;
var db = null;
var users_db = null;
let db_api = null; let db_api = null;
let downloader_api = null;
function setDB(input_db_api) { db_api = input_db_api } function setDB(input_db_api) { db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db_api, input_logger) { function initialize(input_db_api, input_downloader_api) {
setDB(input_db_api); setDB(input_db_api);
setLogger(input_logger); downloader_api = input_downloader_api;
} }
async function subscribe(sub, user_uid = null) { async function subscribe(sub, user_uid = null) {
@@ -46,13 +40,13 @@ async function subscribe(sub, user_uid = null) {
sub['user_uid'] = user_uid ? user_uid : undefined; sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', sub); await db_api.insertRecordIntoTable('subscriptions', sub);
let success = await getSubscriptionInfo(sub, user_uid); let success = await getSubscriptionInfo(sub);
if (success) { if (success) {
getVideosForSub(sub, user_uid); getVideosForSub(sub, user_uid);
} else { } else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.') logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
}; }
result_obj.success = success; result_obj.success = success;
result_obj.sub = sub; result_obj.sub = sub;
@@ -61,13 +55,7 @@ async function subscribe(sub, user_uid = null) {
} }
async function getSubscriptionInfo(sub, user_uid = null) { async function getSubscriptionInfo(sub) {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
// get videos // get videos
let downloadConfig = ['--dump-json', '--playlist-end', '1']; let downloadConfig = ['--dump-json', '--playlist-end', '1'];
let useCookies = config_api.getConfigItem('ytdl_use_cookies'); let useCookies = config_api.getConfigItem('ytdl_use_cookies');
@@ -114,22 +102,6 @@ async function getSubscriptionInfo(sub, user_uid = null) {
} }
} }
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useArchive && !sub.archive) {
// must create the archive
const archive_dir = path.join(__dirname, basePath, 'archives', sub.name);
const archive_path = path.join(archive_dir, 'archive.txt');
// creates archive directory and text file if it doesn't exist
fs.ensureDirSync(archive_dir);
fs.ensureFileSync(archive_path);
// updates subscription
sub.archive = archive_dir;
await db_api.updateRecord('subscriptions', {id: sub.id}, {archive: archive_dir});
}
// TODO: get even more info // TODO: get even more info
resolve(true); resolve(true);
@@ -146,9 +118,23 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let result_obj = { success: false, error: '' };
let id = sub.id; let id = sub.id;
const sub_files = await db_api.getRecords('files', {sub_id: id});
for (let i = 0; i < sub_files.length; i++) {
const sub_file = sub_files[i];
if (config_api.descriptors[sub_file['uid']]) {
try {
for (let i = 0; i < config_api.descriptors[sub_file['uid']].length; i++) {
config_api.descriptors[sub_file['uid']][i].destroy();
}
} catch(e) {
continue;
}
}
}
await db_api.removeRecord('subscriptions', {id: id}); await db_api.removeRecord('subscriptions', {id: id});
await db_api.removeAllRecords('files', {sub_id: id}); await db_api.removeAllRecords('files', {sub_id: id});
@@ -249,30 +235,15 @@ async function getVideosForSub(sub, user_uid = null) {
let appendedBasePath = getAppendedBasePath(sub, basePath); let appendedBasePath = getAppendedBasePath(sub, basePath);
fs.ensureDirSync(appendedBasePath); fs.ensureDirSync(appendedBasePath);
let multiUserMode = null;
if (user_uid) {
multiUserMode = {
user: user_uid,
file_path: appendedBasePath
}
}
const downloadConfig = await generateArgsForSubscription(sub, user_uid); const downloadConfig = await generateArgsForSubscription(sub, user_uid);
// get videos // get videos
logger.verbose('Subscription: getting videos for subscription ' + sub.name); logger.verbose(`Subscription: getting videos for subscription ${sub.name} with args: ${downloadConfig.join(',')}`);
return new Promise(async resolve => { return new Promise(async resolve => {
const preimported_file_paths = [];
const PREIMPORT_INTERVAL = 5000;
const preregister_check = setInterval(async () => {
if (sub.streamingOnly) return;
await db_api.preimportUnregisteredSubscriptionFile(sub, appendedBasePath);
}, PREIMPORT_INTERVAL);
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) { youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
// cleanup // cleanup
updateSubscriptionProperty(sub, {downloading: false}, user_uid); updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
logger.verbose('Subscription: finished check for ' + sub.name); logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) { if (err && !output) {
@@ -280,19 +251,21 @@ async function getVideosForSub(sub, user_uid = null) {
if (err.stderr.includes('This video is unavailable')) { if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.') logger.info('An error was encountered with at least one video, backup method will be used.')
try { try {
const outputs = err.stdout.split(/\r\n|\r|\n/); // TODO: reimplement
for (let i = 0; i < outputs.length; i++) {
const output = JSON.parse(outputs[i]); // const outputs = err.stdout.split(/\r\n|\r|\n/);
await handleOutputJSON(sub, output, i === 0, multiUserMode) // for (let i = 0; i < outputs.length; i++) {
if (err.stderr.includes(output['id']) && archive_path) { // const output = JSON.parse(outputs[i]);
// we found a video that errored! add it to the archive to prevent future errors // await handleOutputJSON(sub, output, i === 0, multiUserMode)
if (sub.archive) { // if (err.stderr.includes(output['id']) && archive_path) {
archive_dir = sub.archive; // // we found a video that errored! add it to the archive to prevent future errors
archive_path = path.join(archive_dir, 'archive.txt') // if (sub.archive) {
fs.appendFileSync(archive_path, output['id']); // archive_dir = sub.archive;
} // archive_path = path.join(archive_dir, 'archive.txt')
} // fs.appendFileSync(archive_path, output['id']);
} // }
// }
// }
} catch(e) { } catch(e) {
logger.error('Backup method failed. See error below:'); logger.error('Backup method failed. See error below:');
logger.error(e); logger.error(e);
@@ -305,21 +278,30 @@ async function getVideosForSub(sub, user_uid = null) {
resolve(true); resolve(true);
return; return;
} }
const output_jsons = [];
for (let i = 0; i < output.length; i++) { for (let i = 0; i < output.length; i++) {
let output_json = null; let output_json = null;
try { try {
output_json = JSON.parse(output[i]); output_json = JSON.parse(output[i]);
output_jsons.push(output_json);
} catch(e) { } catch(e) {
output_json = null; output_json = null;
} }
if (!output_json) { if (!output_json) {
continue; continue;
} }
const reset_videos = i === 0;
await handleOutputJSON(sub, output_json, multiUserMode, preimported_file_paths, reset_videos);
} }
const files_to_download = await getFilesToDownload(sub, output_jsons);
const base_download_options = generateOptionsForSubscriptionDownload(sub, user_uid);
for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[j];
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name);
}
resolve(files_to_download);
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) { if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
await setFreshUploads(sub, user_uid); await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(sub, user_uid); checkVideosForFreshUploads(sub, user_uid);
@@ -331,10 +313,28 @@ async function getVideosForSub(sub, user_uid = null) {
}, err => { }, err => {
logger.error(err); logger.error(err);
updateSubscriptionProperty(sub, {downloading: false}, user_uid); updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
}); });
} }
function generateOptionsForSubscriptionDownload(sub, user_uid) {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let default_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
const base_download_options = {
selectedHeight: sub.maxQuality && sub.maxQuality !== 'best' ? sub.maxQuality : null,
customFileFolderPath: getAppendedBasePath(sub, basePath),
customOutput: sub.custom_output ? `${sub.custom_output}` : `${default_output}`,
customArchivePath: path.join(__dirname, basePath, 'archives', sub.name)
}
return base_download_options;
}
async function generateArgsForSubscription(sub, user_uid, redownload = false, desired_path = null) { async function generateArgsForSubscription(sub, user_uid, redownload = false, desired_path = null) {
// get basePath // get basePath
let basePath = null; let basePath = null;
@@ -347,14 +347,16 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
let appendedBasePath = getAppendedBasePath(sub, basePath); let appendedBasePath = getAppendedBasePath(sub, basePath);
let fullOutput = `${appendedBasePath}/%(title)s.%(ext)s`; const file_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
let fullOutput = `${appendedBasePath}/${file_output}.%(ext)s`;
if (desired_path) { if (desired_path) {
fullOutput = `${desired_path}.%(ext)s`; fullOutput = `${desired_path}.%(ext)s`;
} else if (sub.custom_output) { } else if (sub.custom_output) {
fullOutput = `${appendedBasePath}/${sub.custom_output}.%(ext)s`; fullOutput = `${appendedBasePath}/${sub.custom_output}.%(ext)s`;
} }
let downloadConfig = ['-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json']; let downloadConfig = ['--dump-json', '-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
let qualityPath = null; let qualityPath = null;
if (sub.type && sub.type === 'audio') { if (sub.type && sub.type === 'audio') {
@@ -369,7 +371,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...qualityPath) downloadConfig.push(...qualityPath)
if (sub.custom_args) { if (sub.custom_args) {
customArgsArray = sub.custom_args.split(',,'); const customArgsArray = sub.custom_args.split(',,');
if (customArgsArray.indexOf('-f') !== -1) { if (customArgsArray.indexOf('-f') !== -1) {
// if custom args has a custom quality, replce the original quality with that of custom args // if custom args has a custom quality, replce the original quality with that of custom args
const original_output_index = downloadConfig.indexOf('-f'); const original_output_index = downloadConfig.indexOf('-f');
@@ -411,46 +413,37 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push('--write-thumbnail'); downloadConfig.push('--write-thumbnail');
} }
const rate_limit = config_api.getConfigItem('ytdl_download_rate_limit');
if (rate_limit && downloadConfig.indexOf('-r') === -1 && downloadConfig.indexOf('--limit-rate') === -1) {
downloadConfig.push('-r', rate_limit);
}
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
if (default_downloader === 'yt-dlp') {
downloadConfig.push('--no-clean-infojson');
}
return downloadConfig; return downloadConfig;
} }
async function handleOutputJSON(sub, output_json, multiUserMode = null, reset_videos = false) { async function getFilesToDownload(sub, output_jsons) {
// TODO: remove streaming only mode const files_to_download = [];
if (false && sub.streamingOnly) { for (let i = 0; i < output_jsons.length; i++) {
if (reset_videos) { const output_json = output_jsons[i];
sub_db.assign({videos: []}).write(); const file_missing = !(await db_api.getRecord('files', {sub_id: sub.id, url: output_json['webpage_url']})) && !(await db_api.getRecord('download_queue', {sub_id: sub.id, url: output_json['webpage_url'], error: null}));
if (file_missing) {
const file_with_path_exists = await db_api.getRecord('files', {sub_id: sub.id, path: output_json['_filename']});
if (file_with_path_exists) {
// or maybe just overwrite???
logger.info(`Skipping adding file ${output_json['_filename']} for subscription ${sub.name} as a file with that path already exists.`)
} }
files_to_download.push(output_json);
// remove unnecessary info
output_json.formats = null;
// add to db
sub_db.get('videos').push(output_json).write();
} else {
path_object = path.parse(output_json['_filename']);
const path_string = path.format(path_object);
const file_exists = await db_api.getRecord('files', {path: path_string, sub_id: sub.id});
if (file_exists) {
// TODO: fix issue where files of different paths due to custom path get downloaded multiple times
// file already exists in DB, return early to avoid reseting the download date
return;
}
await db_api.registerFileDB2(output_json['_filename'], sub.type, sub.user_uid, null, sub.id);
const url = output_json['webpage_url'];
if (sub.type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_use_twitch_api') && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
const file_name = path.basename(output_json['_filename']);
const id = file_name.substring(0, file_name.length-4);
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, id, sub.type, multiUserMode.user, sub);
} }
} }
return files_to_download;
} }
async function getSubscriptions(user_uid = null) { async function getSubscriptions(user_uid = null) {
return await db_api.getRecords('subscriptions', {user_uid: user_uid}); return await db_api.getRecords('subscriptions', {user_uid: user_uid});
} }
@@ -458,7 +451,7 @@ async function getSubscriptions(user_uid = null) {
async function getAllSubscriptions() { async function getAllSubscriptions() {
const all_subs = await db_api.getRecords('subscriptions'); const all_subs = await db_api.getRecords('subscriptions');
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
return all_subs.filter(sub => !!(sub.user_uid) === multiUserMode); return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
} }
async function getSubscription(subID) { async function getSubscription(subID) {
@@ -469,7 +462,7 @@ async function getSubscriptionByName(subName, user_uid = null) {
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid}); return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
} }
async function updateSubscription(sub, user_uid = null) { async function updateSubscription(sub) {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub); await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
return true; return true;
} }
@@ -480,7 +473,7 @@ async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
}); });
} }
async function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) { async function updateSubscriptionProperty(sub, assignment_obj) {
// TODO: combine with updateSubscription // TODO: combine with updateSubscription
await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj); await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj);
return true; return true;
@@ -535,7 +528,6 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
// helper functions // helper functions
function getAppendedBasePath(sub, base_path) { function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name); return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
} }
@@ -549,7 +541,6 @@ module.exports = {
unsubscribe : unsubscribe, unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile, deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub, getVideosForSub : getVideosForSub,
setLogger : setLogger,
initialize : initialize, initialize : initialize,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple
} }

View File

@@ -40,7 +40,7 @@ const subscriptions_api = require('../subscriptions');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
db_api.initialize(db, users_db, logger); db_api.initialize(db, users_db);
describe('Database', async function() { describe('Database', async function() {
@@ -288,3 +288,41 @@ describe('Multi User', async function() {
// }); // });
}); });
describe('Downloader', function() {
const downloader_api = require('../downloader');
downloader_api.initialize(db_api);
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
const options = {
ui_uid: uuid(),
user: 'admin'
}
beforeEach(async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('download_queue');
});
it('Get file info', async function() {
});
it('Download file', async function() {
this.timeout(300000);
const returned_download = await downloader_api.createDownload(url, 'video', options);
console.log(returned_download);
await utils.wait(20000);
});
it('Queue file', async function() {
this.timeout(300000);
const returned_download = await downloader_api.createDownload(url, 'video', options);
console.log(returned_download);
await utils.wait(20000);
});
it('Pause file', async function() {
});
});

View File

@@ -1,6 +1,9 @@
const fs = require('fs-extra') const fs = require('fs-extra')
const path = require('path') const path = require('path')
const ffmpeg = require('fluent-ffmpeg');
const config_api = require('./config'); const config_api = require('./config');
const logger = require('./logger');
const CONSTS = require('./consts')
const archiver = require('archiver'); const archiver = require('archiver');
const is_windows = process.platform === 'win32'; const is_windows = process.platform === 'win32';
@@ -141,24 +144,7 @@ function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms) return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
} }
function getDownloadedThumbnail(name, type, customPath = null) { function getDownloadedThumbnail(file_path) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path') : config_api.getConfigItem('ytdl_video_folder_path');
let jpgPath = path.join(customPath, name + '.jpg');
let webpPath = path.join(customPath, name + '.webp');
let pngPath = path.join(customPath, name + '.png');
if (fs.existsSync(jpgPath))
return jpgPath;
else if (fs.existsSync(webpPath))
return webpPath;
else if (fs.existsSync(pngPath))
return pngPath;
else
return null;
}
function getDownloadedThumbnail2(file_path, type) {
const file_path_no_extension = removeFileExtension(file_path); const file_path_no_extension = removeFileExtension(file_path);
let jpgPath = file_path_no_extension + '.jpg'; let jpgPath = file_path_no_extension + '.jpg';
@@ -181,10 +167,6 @@ function getExpectedFileSize(input_info_jsons) {
let expected_filesize = 0; let expected_filesize = 0;
info_jsons.forEach(info_json => { info_jsons.forEach(info_json => {
if (info_json['filesize']) {
expected_filesize += info_json['filesize'];
return;
}
const formats = info_json['format_id'].split('+'); const formats = info_json['format_id'].split('+');
let individual_expected_filesize = 0; let individual_expected_filesize = 0;
formats.forEach(format_id => { formats.forEach(format_id => {
@@ -200,29 +182,7 @@ function getExpectedFileSize(input_info_jsons) {
return expected_filesize; return expected_filesize;
} }
function fixVideoMetadataPerms(name, type, customPath = null) { function fixVideoMetadataPerms(file_path, type) {
if (is_windows) return;
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
: config_api.getConfigItem('ytdl_video_folder_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
const files_to_fix = [
// JSONs
path.join(customPath, name + '.info.json'),
path.join(customPath, name + ext + '.info.json'),
// Thumbnails
path.join(customPath, name + '.webp'),
path.join(customPath, name + '.jpg')
];
for (const file of files_to_fix) {
if (!fs.existsSync(file)) continue;
fs.chmodSync(file, 0o644);
}
}
function fixVideoMetadataPerms2(file_path, type) {
if (is_windows) return; if (is_windows) return;
const ext = type === 'audio' ? '.mp3' : '.mp4'; const ext = type === 'audio' ? '.mp3' : '.mp4';
@@ -244,19 +204,7 @@ function fixVideoMetadataPerms2(file_path, type) {
} }
} }
function deleteJSONFile(name, type, customPath = null) { function deleteJSONFile(file_path, type) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
: config_api.getConfigItem('ytdl_video_folder_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
let json_path = path.join(customPath, name + '.info.json');
let alternate_json_path = path.join(customPath, name + ext + '.info.json');
if (fs.existsSync(json_path)) fs.unlinkSync(json_path);
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
}
function deleteJSONFile2(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4'; const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path); const file_path_no_extension = removeFileExtension(file_path);
@@ -292,7 +240,6 @@ async function removeIDFromArchive(archive_path, id) {
const updatedData = dataArray.join('\n'); const updatedData = dataArray.join('\n');
await fs.writeFile(archive_path, updatedData); await fs.writeFile(archive_path, updatedData);
if (line) return line; if (line) return line;
if (err) throw err;
} }
function durationStringToNumber(dur_str) { function durationStringToNumber(dur_str) {
@@ -315,6 +262,11 @@ function addUIDsToCategory(category, files) {
return files_that_match; return files_that_match;
} }
function getCurrentDownloader() {
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
return details_json['downloader'];
}
async function recFindByExt(base,ext,files,result) async function recFindByExt(base,ext,files,result)
{ {
files = files || (await fs.readdir(base)) files = files || (await fs.readdir(base))
@@ -343,6 +295,53 @@ function removeFileExtension(filename) {
return filename_parts.join('.'); return filename_parts.join('.');
} }
function createEdgeNGrams(str) {
if (str && str.length > 3) {
const minGram = 3
const maxGram = str.length
return str.split(" ").reduce((ngrams, token) => {
if (token.length > minGram) {
for (let i = minGram; i <= maxGram && i <= token.length; ++i) {
ngrams = [...ngrams, token.substr(0, i)]
}
} else {
ngrams = [...ngrams, token]
}
return ngrams
}, []).join(" ")
}
return str
}
// ffmpeg helper functions
async function cropFile(file_path, start, end, ext) {
return new Promise(resolve => {
const temp_file_path = `${file_path}.cropped${ext}`;
let base_ffmpeg_call = ffmpeg(file_path);
if (start) {
base_ffmpeg_call = base_ffmpeg_call.seekOutput(start);
}
if (end) {
base_ffmpeg_call = base_ffmpeg_call.duration(end - start);
}
base_ffmpeg_call
.on('end', () => {
logger.verbose(`Cropping for '${file_path}' complete.`);
fs.unlinkSync(file_path);
fs.moveSync(temp_file_path, file_path);
resolve(true);
})
.on('error', (err) => {
logger.error(`Failed to crop ${file_path}.`);
logger.error(err);
resolve(false);
}).save(temp_file_path);
});
}
/** /**
* setTimeout, but its a promise. * setTimeout, but its a promise.
* @param {number} ms * @param {number} ms
@@ -378,20 +377,20 @@ module.exports = {
getJSON: getJSON, getJSON: getJSON,
getTrueFileName: getTrueFileName, getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail, getDownloadedThumbnail: getDownloadedThumbnail,
getDownloadedThumbnail2: getDownloadedThumbnail2,
getExpectedFileSize: getExpectedFileSize, getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms, fixVideoMetadataPerms: fixVideoMetadataPerms,
fixVideoMetadataPerms2: fixVideoMetadataPerms2,
deleteJSONFile: deleteJSONFile, deleteJSONFile: deleteJSONFile,
deleteJSONFile2: deleteJSONFile2, removeIDFromArchive: removeIDFromArchive,
removeIDFromArchive, removeIDFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType, getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile, createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber, durationStringToNumber: durationStringToNumber,
getMatchingCategoryFiles: getMatchingCategoryFiles, getMatchingCategoryFiles: getMatchingCategoryFiles,
addUIDsToCategory: addUIDsToCategory, addUIDsToCategory: addUIDsToCategory,
getCurrentDownloader: getCurrentDownloader,
recFindByExt: recFindByExt, recFindByExt: recFindByExt,
removeFileExtension: removeFileExtension, removeFileExtension: removeFileExtension,
cropFile: cropFile,
createEdgeNGrams: createEdgeNGrams,
wait: wait, wait: wait,
File: File File: File
} }

821
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
"@ngneat/content-loader": "^5.0.0", "@ngneat/content-loader": "^5.0.0",
"@videogular/ngx-videogular": "^2.1.0", "@videogular/ngx-videogular": "^2.1.0",
"core-js": "^2.4.1", "core-js": "^2.4.1",
"crypto-js": "^4.1.1",
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"fingerprintjs2": "^2.1.0", "fingerprintjs2": "^2.1.0",
@@ -57,8 +58,11 @@
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",
"@types/jasmine": "~3.6.0", "@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1", "@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"codelyzer": "^6.0.0", "codelyzer": "^6.0.0",
"electron": "^8.0.1", "electron": "^8.0.1",
"eslint": "^7.32.0",
"jasmine-core": "~3.6.0", "jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0", "jasmine-spec-reporter": "~5.0.0",
"karma": "~5.0.0", "karma": "~5.0.0",

View File

@@ -7,14 +7,16 @@ import { SubscriptionComponent } from './subscription/subscription/subscription.
import { PostsService } from './posts.services'; import { PostsService } from './posts.services';
import { LoginComponent } from './components/login/login.component'; import { LoginComponent } from './components/login/login.component';
import { DownloadsComponent } from './components/downloads/downloads.component'; import { DownloadsComponent } from './components/downloads/downloads.component';
import { SettingsComponent } from './settings/settings.component';
const routes: Routes = [ const routes: Routes = [
{ path: 'home', component: MainComponent, canActivate: [PostsService] }, { path: 'home', component: MainComponent, canActivate: [PostsService] },
{ path: 'player', component: PlayerComponent, canActivate: [PostsService]}, { path: 'player', component: PlayerComponent, canActivate: [PostsService]},
{ path: 'subscriptions', component: SubscriptionsComponent, canActivate: [PostsService] }, { path: 'subscriptions', component: SubscriptionsComponent, canActivate: [PostsService] },
{ path: 'subscription', component: SubscriptionComponent, canActivate: [PostsService] }, { path: 'subscription', component: SubscriptionComponent, canActivate: [PostsService] },
{ path: 'settings', component: SettingsComponent, canActivate: [PostsService] },
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent },
{ path: 'downloads', component: DownloadsComponent }, { path: 'downloads', component: DownloadsComponent, canActivate: [PostsService] },
{ path: '', redirectTo: '/home', pathMatch: 'full' } { path: '', redirectTo: '/home', pathMatch: 'full' }
]; ];

View File

@@ -23,10 +23,10 @@
<span i18n="Dark mode toggle label">Dark</span> <span i18n="Dark mode toggle label">Dark</span>
<mat-slide-toggle class="theme-slide-toggle" [checked]="postsService.theme.key === 'dark'"></mat-slide-toggle> <mat-slide-toggle class="theme-slide-toggle" [checked]="postsService.theme.key === 'dark'"></mat-slide-toggle>
</button> </button>
<button *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('settings')))" (click)="openSettingsDialog()" mat-menu-item> <!-- <button *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('settings')))" (click)="openSettingsDialog()" mat-menu-item>
<mat-icon>settings</mat-icon> <mat-icon>settings</mat-icon>
<span i18n="Settings menu label">Settings</span> <span i18n="Settings menu label">Settings</span>
</button> </button> -->
<button (click)="openAboutDialog()" mat-menu-item> <button (click)="openAboutDialog()" mat-menu-item>
<mat-icon>info</mat-icon> <mat-icon>info</mat-icon>
<span i18n="About menu label">About</span> <span i18n="About menu label">About</span>
@@ -42,10 +42,14 @@
<mat-nav-list> <mat-nav-list>
<a *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn)" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/home'><ng-container i18n="Navigation menu Home Page title">Home</ng-container></a> <a *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn)" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/home'><ng-container i18n="Navigation menu Home Page title">Home</ng-container></a>
<a *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode && !postsService.isLoggedIn" mat-list-item (click)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</ng-container></a> <a *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode && !postsService.isLoggedIn" mat-list-item (click)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</ng-container></a>
<a *ngIf="postsService.config && allowSubscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a> <a *ngIf="postsService.config && allowSubscriptions && postsService.hasPermission('subscriptions')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a>
<a *ngIf="postsService.config && enableDownloadsManager && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('downloads_manager')))" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a> <a *ngIf="postsService.config && enableDownloadsManager && postsService.hasPermission('downloads_manager')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a>
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))"> <ng-container *ngIf="postsService.config && postsService.hasPermission('settings')">
<mat-divider></mat-divider> <mat-divider></mat-divider>
<a mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/settings'><ng-container i18n="Settings menu label">Settings</ng-container></a>
</ng-container>
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && postsService.hasPermission('subscriptions')">
<mat-divider *ngIf="postsService.subscriptions.length > 0"></mat-divider>
<a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatar [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatar>{{subscription.name}}</a> <a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatar [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatar>{{subscription.name}}</a>
</ng-container> </ng-container>
</mat-nav-list> </mat-nav-list>

View File

@@ -1,9 +1,6 @@
import { Component, OnInit, ElementRef, ViewChild, HostBinding, AfterViewInit } from '@angular/core'; import { Component, OnInit, ElementRef, ViewChild, HostBinding, AfterViewInit } from '@angular/core';
import {MatDialogRef} from '@angular/material/dialog';
import {PostsService} from './posts.services'; import {PostsService} from './posts.services';
import {FileCardComponent} from './file-card/file-card.component';
import { Observable } from 'rxjs/Observable';
import {FormControl, Validators} from '@angular/forms';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSidenav } from '@angular/material/sidenav'; import { MatSidenav } from '@angular/material/sidenav';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
@@ -16,7 +13,6 @@ import 'rxjs/add/operator/filter'
import 'rxjs/add/operator/debounceTime' import 'rxjs/add/operator/debounceTime'
import 'rxjs/add/operator/do' import 'rxjs/add/operator/do'
import 'rxjs/add/operator/switch' import 'rxjs/add/operator/switch'
import { YoutubeSearchService, Result } from './youtube-search.service';
import { Router, NavigationStart, NavigationEnd } from '@angular/router'; import { Router, NavigationStart, NavigationEnd } from '@angular/router';
import { OverlayContainer } from '@angular/cdk/overlay'; import { OverlayContainer } from '@angular/cdk/overlay';
import { THEMES_CONFIG } from '../themes'; import { THEMES_CONFIG } from '../themes';
@@ -28,7 +24,11 @@ import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dial
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrls: ['./app.component.css'] styleUrls: ['./app.component.css'],
providers: [{
provide: MatDialogRef,
useValue: {}
}]
}) })
export class AppComponent implements OnInit, AfterViewInit { export class AppComponent implements OnInit, AfterViewInit {

View File

@@ -87,6 +87,7 @@ import { TwitchChatComponent } from './components/twitch-chat/twitch-chat.compon
import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.component'; import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.component';
import { H401Interceptor } from './http.interceptor'; import { H401Interceptor } from './http.interceptor';
import { ConcurrentStreamComponent } from './components/concurrent-stream/concurrent-stream.component'; import { ConcurrentStreamComponent } from './components/concurrent-stream/concurrent-stream.component';
import { SkipAdButtonComponent } from './components/skip-ad-button/skip-ad-button.component';
registerLocaleData(es, 'es'); registerLocaleData(es, 'es');
@@ -136,7 +137,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
EditCategoryDialogComponent, EditCategoryDialogComponent,
TwitchChatComponent, TwitchChatComponent,
SeeMoreComponent, SeeMoreComponent,
ConcurrentStreamComponent ConcurrentStreamComponent,
SkipAdButtonComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@@ -24,11 +24,18 @@ export class CustomPlaylistsComponent implements OnInit {
this.getAllPlaylists(); this.getAllPlaylists();
} }
}); });
this.postsService.playlists_changed.subscribe(changed => {
if (changed) {
this.getAllPlaylists();
}
});
} }
getAllPlaylists() { getAllPlaylists() {
this.playlists_received = false; this.playlists_received = false;
this.postsService.getAllFiles().subscribe(res => { // must call getAllFiles as we need to get category playlists as well
this.postsService.getPlaylists().subscribe(res => {
this.playlists = res['playlists']; this.playlists = res['playlists'];
this.playlists_received = true; this.playlists_received = true;
}); });

View File

@@ -1,27 +1,91 @@
<div style="padding: 20px;"> <div [hidden]="!(downloads && downloads.length > 0)">
<div *ngFor="let session_downloads of downloads"> <div style="overflow: hidden;" [ngClass]="uids ? 'rounded mat-elevation-z2' : 'mat-elevation-z8'">
<ng-container *ngIf="keys(session_downloads).length > 2"> <mat-table style="overflow: hidden" [ngClass]="uids ? 'rounded-top' : null" matSort [dataSource]="dataSource">
<mat-card style="padding-bottom: 30px; margin-bottom: 15px;">
<h4 style="text-align: center;"><ng-container i18n="Session ID">Session ID:</ng-container>&nbsp;{{session_downloads['session_id']}}
<span *ngIf="session_downloads['session_id'] === postsService.session_id">&nbsp;<ng-container i18n="Current session">(current)</ng-container></span>
</h4>
<div class="container">
<div class="row">
<div *ngFor="let download of session_downloads | keyvalue: sort_downloads; let i = index;" class="col-12 my-1">
<mat-card *ngIf="download.key !== 'session_id' && download.key !== '_id' && download.value" class="mat-elevation-z3">
<app-download-item [download]="download.value" [queueNumber]="i+1" (cancelDownload)="clearDownload(session_downloads['session_id'], download.value.uid)"></app-download-item>
</mat-card>
</div>
</div>
</div>
<div>
<button style="top: 15px;" (click)="clearDownloads(session_downloads['session_id'])" mat-stroked-button color="warn"><ng-container i18n="clear all downloads action button">Clear all downloads</ng-container></button>
</div>
</mat-card>
</ng-container>
</div>
<div *ngIf="downloads && !downloadsValid()"> <!-- Date Column -->
<h4 style="text-align: center;" i18n="No downloads label">No downloads available!</h4> <ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Date">Date</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element"> {{element.timestamp_start | date: 'short'}} </mat-cell>
</ng-container>
<!-- Title Column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<span class="one-line" [matTooltip]="element.title ? element.title : null">
{{element.title}}
</span>
</mat-cell>
</ng-container>
<!-- Subscription Column -->
<ng-container matColumnDef="subscription">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Subscription">Subscription</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<ng-container *ngIf="element.sub_name">
{{element.sub_name}}
</ng-container>
<ng-container *ngIf="!element.sub_name">
N/A
</ng-container>
</mat-cell>
</ng-container>
<!-- Stage Column -->
<ng-container matColumnDef="stage">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Stage">Stage</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element"> {{STEP_INDEX_TO_LABEL[element.step_index]}} </mat-cell>
</ng-container>
<!-- Progress Column -->
<ng-container matColumnDef="progress">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Progress">Progress</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<ng-container *ngIf="element.percent_complete">
{{+(element.percent_complete) > 100 ? '100' : element.percent_complete}}%
</ng-container>
<ng-container *ngIf="!element.percent_complete">
N/A
</ng-container>
</mat-cell>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<div>
<ng-container *ngIf="!element.finished">
<button (click)="pauseDownload(element.uid)" *ngIf="!element.paused || !element.finished_step" [disabled]="element.paused && !element.finished_step" mat-icon-button matTooltip="Pause" i18n-matTooltip="Pause"><mat-spinner [diameter]="28" *ngIf="element.paused && !element.finished_step" class="icon-button-spinner"></mat-spinner><mat-icon>pause</mat-icon></button>
<button (click)="resumeDownload(element.uid)" *ngIf="element.paused && element.finished_step" mat-icon-button matTooltip="Resume" i18n-matTooltip="Resume"><mat-icon>play_arrow</mat-icon></button>
<button *ngIf="false && !element.paused" (click)="cancelDownload(element.uid)" mat-icon-button matTooltip="Cancel" i18n-matTooltip="Cancel"><mat-icon>cancel</mat-icon></button>
</ng-container>
<ng-container *ngIf="element.finished">
<button *ngIf="!element.error" (click)="watchContent(element)" mat-icon-button matTooltip="Watch content" i18n-matTooltip="Watch content"><mat-icon>smart_display</mat-icon></button>
<button *ngIf="element.error" (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button>
<button (click)="restartDownload(element.uid)" mat-icon-button matTooltip="Restart" i18n-matTooltip="Restart"><mat-icon>restart_alt</mat-icon></button>
</ng-container>
<button *ngIf="element.finished || element.paused" (click)="clearDownload(element.uid)" mat-icon-button matTooltip="Clear" i18n-matTooltip="Clear"><mat-icon>delete</mat-icon></button>
</div>
</mat-cell>
</ng-container>
<mat-header-row [ngClass]="uids ? 'rounded-top' : null" *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
<mat-paginator [ngClass]="uids ? 'rounded-bottom' : null" [pageSizeOptions]="[5, 10, 20]"
showFirstLastButtons
aria-label="Select page of downloads">
</mat-paginator>
</div>
<div *ngIf="!uids" class="downloads-action-button-div">
<button [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
<button style="margin-left: 10px;" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
<button color="warn" style="margin-left: 10px;" mat-stroked-button (click)="clearFinishedDownloads()"><ng-container i18n="Clear finished downloads">Clear finished downloads</ng-container></button>
</div> </div>
</div> </div>
<div *ngIf="(!downloads || downloads.length === 0) && downloads_retrieved && !uids">
<h4 style="text-align: center; margin-top: 10px;" i18n="No downloads label">No downloads available!</h4>
</div>

View File

@@ -0,0 +1,32 @@
mat-header-cell, mat-cell {
justify-content: center;
}
.one-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon-button-spinner {
position: absolute;
top: 7px;
left: 6px;
}
.downloads-action-button-div {
margin-top: 10px;
margin-left: 5px;
}
.rounded-top {
border-radius: 16px 16px 0px 0px !important;
}
.rounded-bottom {
border-radius: 0px 0px 16px 16px !important;
}
.rounded {
border-radius: 16px 16px 16px 16px !important;
}

View File

@@ -1,7 +1,13 @@
import { Component, OnInit, ViewChildren, QueryList, ElementRef, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter } from '@angular/core';
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations'; import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator';
import { MatTableDataSource } from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
import { MatSort } from '@angular/material/sort';
import { Clipboard } from '@angular/cdk/clipboard';
@Component({ @Component({
selector: 'app-downloads', selector: 'app-downloads',
@@ -34,138 +40,222 @@ import { Router } from '@angular/router';
}) })
export class DownloadsComponent implements OnInit, OnDestroy { export class DownloadsComponent implements OnInit, OnDestroy {
@Input() uids = null;
downloads_check_interval = 1000; downloads_check_interval = 1000;
downloads = []; downloads = [];
finished_downloads = [];
interval_id = null; interval_id = null;
keys = Object.keys; keys = Object.keys;
valid_sessions_length = 0; valid_sessions_length = 0;
paused_download_exists = false;
running_download_exists = false;
STEP_INDEX_TO_LABEL = {
0: $localize`Creating download`,
1: $localize`Getting info`,
2: $localize`Downloading file`,
3: $localize`Complete`
}
displayedColumns: string[] = ['date', 'title', 'stage', 'subscription', 'progress', 'actions'];
dataSource = null; // new MatTableDataSource<Download>();
downloads_retrieved = false;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
sort_downloads = (a, b) => { sort_downloads = (a, b) => {
const result = b.value.timestamp_start - a.value.timestamp_start; const result = b.timestamp_start - a.timestamp_start;
return result; return result;
} }
constructor(public postsService: PostsService, private router: Router) { } constructor(public postsService: PostsService, private router: Router, private dialog: MatDialog, private clipboard: Clipboard) { }
ngOnInit(): void { ngOnInit(): void {
if (this.postsService.initialized) {
this.getCurrentDownloadsRecurring();
} else {
this.postsService.service_initialized.subscribe(init => {
if (init) {
this.getCurrentDownloadsRecurring();
}
});
}
}
getCurrentDownloadsRecurring(): void {
if (!this.postsService.config['Extra']['enable_downloads_manager']) {
this.router.navigate(['/home']);
return;
}
this.getCurrentDownloads(); this.getCurrentDownloads();
this.interval_id = setInterval(() => { this.interval_id = setInterval(() => {
this.getCurrentDownloads(); this.getCurrentDownloads();
}, this.downloads_check_interval); }, this.downloads_check_interval);
this.postsService.service_initialized.subscribe(init => {
if (init) {
if (!this.postsService.config['Extra']['enable_downloads_manager']) {
this.router.navigate(['/home']);
}
}
});
} }
ngOnDestroy() { ngOnDestroy(): void {
if (this.interval_id) { clearInterval(this.interval_id) } if (this.interval_id) { clearInterval(this.interval_id) }
} }
getCurrentDownloads() { getCurrentDownloads(): void {
this.postsService.getCurrentDownloads().subscribe(res => { this.postsService.getCurrentDownloads(this.uids).subscribe(res => {
if (res['downloads']) { this.downloads_retrieved = true;
this.assignNewValues(res['downloads']); if (res['downloads'] !== null
&& res['downloads'] !== undefined
&& JSON.stringify(this.downloads) !== JSON.stringify(res['downloads'])) {
this.downloads = this.combineDownloads(this.downloads, res['downloads']);
// this.downloads = res['downloads'];
this.downloads.sort(this.sort_downloads);
this.dataSource = new MatTableDataSource<Download>(this.downloads);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.paused_download_exists = this.downloads.find(download => download['paused'] && !download['error']);
this.running_download_exists = this.downloads.find(download => !download['paused'] && !download['finished']);
} else { } else {
// failed to get downloads // failed to get downloads
} }
}); });
} }
clearDownload(session_id, download_uid) { clearFinishedDownloads(): void {
this.postsService.clearDownloads(false, session_id, download_uid).subscribe(res => { const dialogRef = this.dialog.open(ConfirmDialogComponent, {
if (res['success']) { data: {
// this.downloads = res['downloads']; dialogTitle: $localize`Clear finished downloads`,
} else { dialogText: $localize`Would you like to clear your finished downloads?`,
submitText: $localize`Clear`,
warnSubmitColor: true
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.postsService.clearFinishedDownloads().subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to clear finished downloads!');
}
});
} }
}); });
} }
clearDownloads(session_id) { pauseDownload(download_uid: string): void {
this.postsService.clearDownloads(false, session_id).subscribe(res => { this.postsService.pauseDownload(download_uid).subscribe(res => {
if (res['success']) { if (!res['success']) {
this.downloads = res['downloads']; this.postsService.openSnackBar('Failed to pause download! See server logs for more info.');
} else {
} }
}); });
} }
clearAllDownloads() { pauseAllDownloads(): void {
this.postsService.clearDownloads(true).subscribe(res => { this.postsService.pauseAllDownloads().subscribe(res => {
if (res['success']) { if (!res['success']) {
this.downloads = res['downloads']; this.postsService.openSnackBar('Failed to pause all downloads! See server logs for more info.');
} else {
} }
}); });
} }
assignNewValues(new_downloads_by_session) { resumeDownload(download_uid: string): void {
const session_keys = Object.keys(new_downloads_by_session); this.postsService.resumeDownload(download_uid).subscribe(res => {
if (!res['success']) {
// remove missing session IDs this.postsService.openSnackBar('Failed to resume download! See server logs for more info.');
const current_session_ids = Object.keys(this.downloads); }
const missing_session_ids = current_session_ids.filter(session => session_keys.indexOf(session) === -1) });
for (const missing_session_id of missing_session_ids) {
delete this.downloads[missing_session_id];
} }
// loop through sessions resumeAllDownloads(): void {
for (let i = 0; i < session_keys.length; i++) { this.postsService.resumeAllDownloads().subscribe(res => {
const session_id = session_keys[i]; if (!res['success']) {
const session_downloads_by_id = new_downloads_by_session[session_id]; this.postsService.openSnackBar('Failed to resume all downloads! See server logs for more info.');
const session_download_ids = Object.keys(session_downloads_by_id);
if (this.downloads[session_id]) {
// remove missing download IDs
const current_download_ids = Object.keys(this.downloads[session_id]);
const missing_download_ids = current_download_ids.filter(download => session_download_ids.indexOf(download) === -1)
for (const missing_download_id of missing_download_ids) {
console.log('removing missing download id');
delete this.downloads[session_id][missing_download_id];
} }
});
} }
if (!this.downloads[session_id]) { restartDownload(download_uid: string): void {
this.downloads[session_id] = session_downloads_by_id; this.postsService.restartDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to restart download! See server logs for more info.');
}
});
}
cancelDownload(download_uid: string): void {
this.postsService.cancelDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to cancel download! See server logs for more info.');
}
});
}
clearDownload(download_uid: string): void {
this.postsService.clearDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to pause download! See server logs for more info.');
}
});
}
watchContent(download): void {
const container = download['container'];
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
const is_playlist = container['uids']; // hacky, TODO: fix
if (is_playlist) {
this.router.navigate(['/player', {playlist_id: container['id'], type: download['type']}]);
} else { } else {
for (let j = 0; j < session_download_ids.length; j++) { this.router.navigate(['/player', {type: download['type'], uid: container['uid']}]);
if (session_download_ids[j] === 'session_id' || session_download_ids[j] === '_id') continue;
const download_id = session_download_ids[j];
const download = new_downloads_by_session[session_id][download_id]
if (!this.downloads[session_id][download_id]) {
this.downloads[session_id][download_id] = download;
} else {
const download_to_update = this.downloads[session_id][download_id];
download_to_update['percent_complete'] = download['percent_complete'];
download_to_update['complete'] = download['complete'];
download_to_update['timestamp_end'] = download['timestamp_end'];
download_to_update['downloading'] = download['downloading'];
download_to_update['error'] = download['error'];
}
}
}
} }
} }
downloadsValid() { combineDownloads(downloads_old, downloads_new) {
let valid = false; // only keeps downloads that exist in the new set
for (let i = 0; i < this.downloads.length; i++) { downloads_old = downloads_old.filter(download_old => downloads_new.some(download_new => download_new.uid === download_old.uid));
const session_downloads = this.downloads[i];
if (!session_downloads) continue; // add downloads from the new set that the old one doesn't have
if (this.keys(session_downloads).length > 2) { const downloads_to_add = downloads_new.filter(download_new => !downloads_old.some(download_old => download_new.uid === download_old.uid));
valid = true; downloads_old.push(...downloads_to_add);
break; downloads_old.forEach(download_old => {
} const download_new = downloads_new.find(download_to_check => download_old.uid === download_to_check.uid);
} Object.keys(download_new).forEach(key => {
return valid; download_old[key] = download_new[key];
});
Object.keys(download_old).forEach(key => {
if (!download_new[key]) delete download_old[key];
});
});
return downloads_old;
} }
showError(download) {
const copyToClipboardEmitter = new EventEmitter<boolean>();
this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: $localize`Error for ${download['url']}:url:`,
dialogText: download['error'],
submitText: $localize`Copy to clipboard`,
cancelText: $localize`Close`,
closeOnSubmit: false,
onlyEmitOnDone: true,
doneEmitter: copyToClipboardEmitter
}
});
copyToClipboardEmitter.subscribe(done => {
if (done) {
this.postsService.openSnackBar($localize`Copied to clipboard!`);
this.clipboard.copy(download['error']);
}
});
}
}
export interface Download {
timestamp_start: number;
title: string;
step_index: number;
progress: string;
} }

View File

@@ -1,5 +1,5 @@
<mat-card class="login-card"> <mat-card class="login-card">
<mat-tab-group [(selectedIndex)]="selectedTabIndex"> <mat-tab-group style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
<mat-tab label="Login"> <mat-tab label="Login">
<div style="margin-top: 10px;"> <div style="margin-top: 10px;">
<mat-form-field> <mat-form-field>
@@ -11,9 +11,6 @@
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput placeholder="Password"> <input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput placeholder="Password">
</mat-form-field> </mat-form-field>
</div> </div>
<div style="margin-bottom: 10px; margin-top: 10px;">
<button [disabled]="loggingIn" color="primary" (click)="login()" mat-raised-button><ng-container i18n="Login">Login</ng-container></button>
</div>
</mat-tab> </mat-tab>
<mat-tab *ngIf="registrationEnabled" label="Register"> <mat-tab *ngIf="registrationEnabled" label="Register">
<div style="margin-top: 10px;"> <div style="margin-top: 10px;">
@@ -31,9 +28,14 @@
<input [(ngModel)]="registrationPasswordConfirmationInput" type="password" matInput placeholder="Confirm Password"> <input [(ngModel)]="registrationPasswordConfirmationInput" type="password" matInput placeholder="Confirm Password">
</mat-form-field> </mat-form-field>
</div> </div>
<div style="margin-bottom: 10px; margin-top: 10px;">
<button [disabled]="registering" color="primary" (click)="register()" mat-raised-button><ng-container i18n="Register">Register</ng-container></button>
</div>
</mat-tab> </mat-tab>
</mat-tab-group> </mat-tab-group>
<div *ngIf="selectedTabIndex === 0" class="login-button-div">
<button [disabled]="loggingIn" color="primary" (click)="login()" mat-raised-button><ng-container i18n="Login">Login</ng-container></button>
<mat-progress-bar *ngIf="loggingIn" class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
</div>
<div *ngIf="selectedTabIndex === 1" class="login-button-div">
<button [disabled]="registering" color="primary" (click)="register()" mat-raised-button><ng-container i18n="Register">Register</ng-container></button>
<mat-progress-bar *ngIf="registering" class="login-progress-bar" mode="indeterminate"></mat-progress-bar>
</div>
</mat-card> </mat-card>

View File

@@ -1,6 +1,33 @@
.login-card { .login-card {
max-width: 600px; max-width: 400px;
width: 80%; width: 80%;
margin: 0 auto; margin: 0 auto;
margin-top: 20px; margin-top: 20px;
padding-top: 8px;
}
.login-div {
height: calc(100% - 170px);
overflow-y: auto;
}
.login-button-div {
margin-bottom: 10px;
margin-top: 10px;
margin-left: -16px;
margin-right: -16px;
bottom: 0px;
width: 100%;
position: absolute;
}
.login-button-div > button {
width: 100%;
border-radius: 0px 0px 4px 4px !important;
}
.login-progress-bar {
position: absolute;
bottom: 0px;
border-radius: 0px 0px 4px 4px;
} }

View File

@@ -1,4 +1,4 @@
<div style="height: 275px;"> <div style="height: 100%;">
<div *ngIf="logs_loading" style="z-index: 999; position: absolute; top: 40%; left: 50%"> <div *ngIf="logs_loading" style="z-index: 999; position: absolute; top: 40%; left: 50%">
<mat-spinner [diameter]="32"></mat-spinner> <mat-spinner [diameter]="32"></mat-spinner>
</div> </div>
@@ -10,7 +10,7 @@
</cdk-virtual-scroll-viewport>--> </cdk-virtual-scroll-viewport>-->
<!-- Non-virtual mode (slow, bug-free) --> <!-- Non-virtual mode (slow, bug-free) -->
<div style="height: 274px; overflow-y: auto"> <div style="height: 100%; overflow-y: auto">
<div *ngFor="let log of logs; let i = index" class="example-item"> <div *ngFor="let log of logs; let i = index" class="example-item">
<span [ngStyle]="{'color':log.color}">{{log.text}}</span> <span [ngStyle]="{'color':log.color}">{{log.text}}</span>
</div> </div>

View File

@@ -1,7 +1,7 @@
<div *ngIf="dataSource; else loading"> <div *ngIf="dataSource; else loading">
<div style="padding: 15px"> <div style="padding: 15px">
<div class="row"> <div class="row">
<div class="table table-responsive px-5 pb-4 pt-2"> <div class="table table-responsive pb-4 pt-2">
<div class="example-header"> <div class="example-header">
<mat-form-field> <mat-form-field>
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Search" i18n-placeholder="search field description"> <input matInput (keyup)="applyFilter($event.target.value)" placeholder="Search" i18n-placeholder="search field description">

View File

@@ -1,5 +1,4 @@
.edit-role { .edit-role {
position: relative; position: relative;
top: -50px; top: -50px;
left: 35px;
} }

View File

@@ -28,13 +28,13 @@
</div> </div>
</div> </div>
<div> <div>
<div class="container"> <div class="container" style="margin-bottom: 16px">
<div class="row justify-content-center"> <div class="row justify-content-center">
<ng-container *ngIf="normal_files_received && paged_data"> <ng-container *ngIf="normal_files_received && paged_data">
<div *ngFor="let file of paged_data; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]"> <div *ngFor="let file of paged_data; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? '?jwt=' + this.postsService.token : ''"></app-unified-file-card> <app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [availablePlaylists]="playlists" (addFileToPlaylist)="addFileToPlaylist($event)" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? '?jwt=' + this.postsService.token : ''"></app-unified-file-card>
</div> </div>
<div *ngIf="filtered_files.length === 0"> <div *ngIf="paged_data.length === 0">
<ng-container i18n="No videos found">No videos found.</ng-container> <ng-container i18n="No videos found">No videos found.</ng-container>
</div> </div>
</ng-container> </ng-container>
@@ -46,8 +46,20 @@
</div> </div>
</div> </div>
<mat-paginator class="paginator" #paginator *ngIf="filtered_files && filtered_files.length > 0" (page)="pageChangeEvent($event)" [length]="filtered_files.length" <div>
<div style="position: absolute; margin-left: -8px; margin-top: 5px; scale: 0.8">
<mat-form-field>
<mat-label><ng-container i18n="File type">File type</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="fileTypeFilter" (selectionChange)="fileTypeFilterChanged($event.value)">
<mat-option value="both"><ng-container i18n="Both">Both</ng-container></mat-option>
<mat-option value="video_only"><ng-container i18n="Video only">Video only</ng-container></mat-option>
<mat-option value="audio_only"><ng-container i18n="Audio only">Audio only</ng-container></mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-paginator class="paginator" #paginator *ngIf="paged_data && paged_data.length > 0" (page)="pageChangeEvent($event)" [length]="file_count"
[pageSize]="pageSize" [pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]"> [pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">
</mat-paginator> </mat-paginator>
</div>
</div> </div>

View File

@@ -2,6 +2,8 @@ import { Component, OnInit, ViewChild } from '@angular/core';
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
@Component({ @Component({
selector: 'app-recent-videos', selector: 'app-recent-videos',
@@ -15,8 +17,8 @@ export class RecentVideosComponent implements OnInit {
normal_files_received = false; normal_files_received = false;
subscription_files_received = false; subscription_files_received = false;
files: any[] = null; file_count = 10;
filtered_files: any[] = null; searchChangedSubject: Subject<string> = new Subject<string>();
downloading_content = {'video': {}, 'audio': {}}; downloading_content = {'video': {}, 'audio': {}};
search_mode = false; search_mode = false;
search_text = ''; search_text = '';
@@ -50,6 +52,9 @@ export class RecentVideosComponent implements OnInit {
} }
}; };
filterProperty = this.filterProperties['upload_date']; filterProperty = this.filterProperties['upload_date'];
fileTypeFilter = 'both';
playlists = null;
pageSize = 10; pageSize = 10;
paged_data = null; paged_data = null;
@@ -68,83 +73,101 @@ export class RecentVideosComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
if (this.postsService.initialized) { if (this.postsService.initialized) {
this.getAllFiles(); this.getAllFiles();
this.getAllPlaylists();
} }
this.postsService.service_initialized.subscribe(init => { this.postsService.service_initialized.subscribe(init => {
if (init) { if (init) {
this.getAllFiles(); this.getAllFiles();
this.getAllPlaylists();
} }
}); });
this.postsService.files_changed.subscribe(changed => {
if (changed) {
this.getAllFiles();
}
});
// set filter property to cached this.postsService.playlists_changed.subscribe(changed => {
if (changed) {
this.getAllPlaylists();
}
});
// set filter property to cached value
const cached_filter_property = localStorage.getItem('filter_property'); const cached_filter_property = localStorage.getItem('filter_property');
if (cached_filter_property && this.filterProperties[cached_filter_property]) { if (cached_filter_property && this.filterProperties[cached_filter_property]) {
this.filterProperty = this.filterProperties[cached_filter_property]; this.filterProperty = this.filterProperties[cached_filter_property];
} }
// set file type filter to cached value
const cached_file_type_filter = localStorage.getItem('file_type_filter');
if (cached_file_type_filter) {
this.fileTypeFilter = cached_file_type_filter;
}
this.searchChangedSubject
.debounceTime(500)
.pipe(distinctUntilChanged()
).subscribe(model => {
if (model.length > 0) {
this.search_mode = true;
} else {
this.search_mode = false;
}
this.getAllFiles();
});
}
getAllPlaylists() {
this.postsService.getPlaylists().subscribe(res => {
this.playlists = res['playlists'];
});
} }
// search // search
onSearchInputChanged(newvalue) { onSearchInputChanged(newvalue) {
if (newvalue.length > 0) { this.normal_files_received = false;
this.search_mode = true; this.searchChangedSubject.next(newvalue);
this.filterFiles(newvalue);
} else {
this.search_mode = false;
this.filtered_files = this.files;
}
}
private filterFiles(value: string) {
const filterValue = value.toLowerCase();
this.filtered_files = this.files.filter(option => option.id.toLowerCase().includes(filterValue) || option.category?.name?.toLowerCase().includes(filterValue));
this.pageChangeEvent({pageSize: this.pageSize, pageIndex: this.paginator.pageIndex});
}
filterByProperty(prop) {
if (this.descendingMode) {
this.filtered_files = this.filtered_files.sort((a, b) => (a[prop] > b[prop] ? -1 : 1));
} else {
this.filtered_files = this.filtered_files.sort((a, b) => (a[prop] > b[prop] ? 1 : -1));
}
if (this.paginator) { this.pageChangeEvent({pageSize: this.pageSize, pageIndex: this.paginator.pageIndex}) };
} }
filterOptionChanged(value) { filterOptionChanged(value) {
this.filterByProperty(value['property']);
localStorage.setItem('filter_property', value['key']); localStorage.setItem('filter_property', value['key']);
this.getAllFiles();
}
fileTypeFilterChanged(value) {
localStorage.setItem('file_type_filter', value);
this.getAllFiles();
} }
toggleModeChange() { toggleModeChange() {
this.descendingMode = !this.descendingMode; this.descendingMode = !this.descendingMode;
this.filterByProperty(this.filterProperty['property']); this.getAllFiles();
} }
// get files // get files
getAllFiles() { getAllFiles(cache_mode = false) {
this.normal_files_received = false; this.normal_files_received = cache_mode;
this.postsService.getAllFiles().subscribe(res => { const current_file_index = (this.paginator?.pageIndex ? this.paginator.pageIndex : 0)*this.pageSize;
this.files = res['files']; const sort = {by: this.filterProperty['property'], order: this.descendingMode ? -1 : 1};
this.files.sort(this.sortFiles); const range = [current_file_index, current_file_index + this.pageSize];
for (let i = 0; i < this.files.length; i++) { this.postsService.getAllFiles(sort, range, this.search_mode ? this.search_text : null, this.fileTypeFilter).subscribe(res => {
const file = this.files[i]; this.file_count = res['file_count'];
this.paged_data = res['files'];
for (let i = 0; i < this.paged_data.length; i++) {
const file = this.paged_data[i];
file.duration = typeof file.duration !== 'string' ? file.duration : this.durationStringToNumber(file.duration); file.duration = typeof file.duration !== 'string' ? file.duration : this.durationStringToNumber(file.duration);
} }
if (this.search_mode) {
this.filterFiles(this.search_text);
} else {
this.filtered_files = this.files;
}
this.filterByProperty(this.filterProperty['property']);
// set cached file count for future use, note that we convert the amount of files to a string // set cached file count for future use, note that we convert the amount of files to a string
localStorage.setItem('cached_file_count', '' + this.files.length); localStorage.setItem('cached_file_count', '' + this.file_count);
this.normal_files_received = true; this.normal_files_received = true;
this.paged_data = this.filtered_files.slice(0, 10);
}); });
} }
@@ -173,7 +196,7 @@ export class RecentVideosComponent implements OnInit {
// normal subscriptions // normal subscriptions
!new_tab ? this.router.navigate(['/player', {uid: file.uid, !new_tab ? this.router.navigate(['/player', {uid: file.uid,
type: file.isAudio ? 'audio' : 'video', sub_id: sub.id}]) type: file.isAudio ? 'audio' : 'video', sub_id: sub.id}])
: window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'};sub_id=${sub.id}`); : window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'}`);
} }
} else { } else {
// normal files // normal files
@@ -280,12 +303,26 @@ export class RecentVideosComponent implements OnInit {
} }
removeFileCard(file_to_remove) { removeFileCard(file_to_remove) {
const index = this.files.map(e => e.uid).indexOf(file_to_remove.uid); const index = this.paged_data.map(e => e.uid).indexOf(file_to_remove.uid);
this.files.splice(index, 1); this.paged_data.splice(index, 1);
if (this.search_mode) { this.getAllFiles(true);
this.filterFiles(this.search_text);
} }
this.filterByProperty(this.filterProperty['property']);
addFileToPlaylist(info_obj) {
const file = info_obj['file'];
const playlist_id = info_obj['playlist_id'];
const playlist = this.playlists.find(potential_playlist => potential_playlist['id'] === playlist_id);
this.postsService.addFileToPlaylist(playlist_id, file['uid']).subscribe(res => {
if (res['success']) {
this.postsService.openSnackBar(`Successfully added ${file.title} to ${playlist.title}!`);
this.postsService.playlists_changed.next(true);
} else {
this.postsService.openSnackBar(`Failed to add ${file.title} to ${playlist.title}! Unknown error.`);
}
}, err => {
console.error(err);
this.postsService.openSnackBar(`Failed to add ${file.title} to ${playlist.title}! See browser console for error.`);
});
} }
// sorting and filtering // sorting and filtering
@@ -306,7 +343,8 @@ export class RecentVideosComponent implements OnInit {
} }
pageChangeEvent(event) { pageChangeEvent(event) {
const offset = ((event.pageIndex + 1) - 1) * event.pageSize; this.pageSize = event.pageSize;
this.paged_data = this.filtered_files.slice(offset).slice(0, event.pageSize); this.loading_files = Array(this.pageSize).fill(0);
this.getAllFiles();
} }
} }

View File

@@ -0,0 +1 @@
<button *ngIf="show_skip_ad_button" (click)="skipAdButtonClicked()" mat-flat-button><ng-container i18n="Skip ad button">Skip ad</ng-container></button>

View File

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

View File

@@ -0,0 +1,115 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { PostsService } from 'app/posts.services';
import CryptoJS from 'crypto-js';
@Component({
selector: 'app-skip-ad-button',
templateUrl: './skip-ad-button.component.html',
styleUrls: ['./skip-ad-button.component.scss']
})
export class SkipAdButtonComponent implements OnInit {
@Input() current_video = null;
@Input() playback_timestamp = null;
@Output() setPlaybackTimestamp = new EventEmitter<any>();
sponsor_block_cache = {};
show_skip_ad_button = false;
skip_ad_button_check_interval = null;
constructor(private postsService: PostsService) { }
ngOnInit(): void {
this.skip_ad_button_check_interval = setInterval(() => this.skipAdButtonCheck(), 500);
}
ngOnDestroy(): void {
clearInterval(this.skip_ad_button_check_interval);
}
checkSponsorBlock(video_to_check) {
if (!video_to_check) return;
// check cache, null means it has been checked and confirmed not to exist (limits API calls)
if (this.sponsor_block_cache[video_to_check.url] || this.sponsor_block_cache[video_to_check.url] === null) return;
// sponsor block needs first 4 chars from video ID hash
const video_id = this.getVideoIDFromURL(video_to_check.url);
const id_hash = this.getVideoIDHashFromURL(video_id);
if (!id_hash || id_hash.length < 4) return;
const truncated_id_hash = id_hash.substring(0, 4);
// we couldn't get the data from the cache, let's get it from sponsor block directly
this.postsService.getSponsorBlockDataForVideo(truncated_id_hash).subscribe(res => {
if (res && res['length'] && res['length'] === 0) {
return;
}
const found_data = res['find'](data => data['videoID'] === video_id);
if (found_data) {
this.sponsor_block_cache[video_to_check.url] = found_data;
} else {
this.sponsor_block_cache[video_to_check.url] = null;
}
}, err => {
// likely doesn't exist
this.sponsor_block_cache[video_to_check.url] = null;
});
}
getVideoIDHashFromURL(video_id) {
if (!video_id) return null;
return CryptoJS.SHA256(video_id).toString(CryptoJS.enc.Hex);;
}
getVideoIDFromURL(url) {
const regex_exp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
const match = url.match(regex_exp);
return (match && match[7].length==11) ? match[7] : null;
}
skipAdButtonCheck() {
const sponsor_block_data = this.sponsor_block_cache[this.current_video.url];
if (!sponsor_block_data && sponsor_block_data !== null) {
// we haven't yet tried to get the sponsor block data for the video
this.checkSponsorBlock(this.current_video);
} else if (!sponsor_block_data) {
this.show_skip_ad_button = false;
return;
}
if (this.getTimeToSkipTo()) {
this.show_skip_ad_button = true;
} else {
this.show_skip_ad_button = false;
}
}
getTimeToSkipTo() {
const sponsor_block_data = this.sponsor_block_cache[this.current_video.url];
if (!sponsor_block_data) return;
// check if we're in between an ad segment
const found_segment = sponsor_block_data['segments'].find(segment_data => this.playback_timestamp > segment_data.segment[0] && this.playback_timestamp < segment_data.segment[1] - 0.5);
if (found_segment) {
return found_segment['segment'][1];
}
return null;
}
skipAdButtonClicked() {
const time_to_skip_to = this.getTimeToSkipTo();
if (!time_to_skip_to) return;
this.setPlaybackTimestamp.emit(time_to_skip_to);
this.show_skip_ad_button = false;
}
}

View File

@@ -1,4 +1,4 @@
<div (mouseover)="elevated=true" (mouseout)="elevated=false" (contextmenu)="onRightClick($event)" style="position: relative; width: fit-content;"> <div (mouseenter)="onMouseOver()" (mouseleave)="onMouseOut()" (contextmenu)="onRightClick($event)" style="position: relative; width: fit-content;">
<div *ngIf="!loading" class="download-time"> <div *ngIf="!loading" class="download-time">
<mat-icon class="audio-video-icon">{{(file_obj.type === 'audio' || file_obj.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon> <mat-icon class="audio-video-icon">{{(file_obj.type === 'audio' || file_obj.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon>
&nbsp;&nbsp; &nbsp;&nbsp;
@@ -23,6 +23,12 @@
<ng-container *ngIf="!is_playlist && !loading"> <ng-container *ngIf="!is_playlist && !loading">
<button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button> <button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
<button (click)="navigateToSubscription()" mat-menu-item *ngIf="file_obj.sub_id"><mat-icon>{{file_obj.isAudio ? 'library_music' : 'video_library'}}</mat-icon>&nbsp;<ng-container i18n="Go to subscription menu item">Go to subscription</ng-container></button> <button (click)="navigateToSubscription()" mat-menu-item *ngIf="file_obj.sub_id"><mat-icon>{{file_obj.isAudio ? 'library_music' : 'video_library'}}</mat-icon>&nbsp;<ng-container i18n="Go to subscription menu item">Go to subscription</ng-container></button>
<button *ngIf="availablePlaylists" [matMenuTriggerFor]="addtoplaylist" mat-menu-item><mat-icon>playlist_add</mat-icon>&nbsp;<ng-container i18n="Add to playlist menu item">Add to playlist</ng-container></button>
<mat-menu #addtoplaylist="matMenu">
<ng-container *ngFor="let playlist of availablePlaylists">
<button *ngIf="(playlist.type === 'audio') === file_obj.isAudio" [disabled]="playlist.uids?.includes(file_obj.uid)" (click)="emitAddFileToPlaylist(playlist.id)" mat-menu-item>{{playlist.name}}</button>
</ng-container>
</mat-menu>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<button *ngIf="file_obj.sub_id" (click)="emitDeleteFile()" mat-menu-item> <button *ngIf="file_obj.sub_id" (click)="emitDeleteFile()" mat-menu-item>
<mat-icon>restore</mat-icon><ng-container i18n="Delete and redownload subscription video button">Delete and redownload</ng-container> <mat-icon>restore</mat-icon><ng-container i18n="Delete and redownload subscription video button">Delete and redownload</ng-container>
@@ -45,8 +51,10 @@
<mat-card [matTooltip]="null" (click)="navigateToFile($event)" matRipple class="file-mat-card" [ngClass]="{'small-mat-card': card_size === 'small', 'file-mat-card': card_size === 'medium', 'large-mat-card': card_size === 'large', 'mat-elevation-z4': !elevated, 'mat-elevation-z8': elevated}"> <mat-card [matTooltip]="null" (click)="navigateToFile($event)" matRipple class="file-mat-card" [ngClass]="{'small-mat-card': card_size === 'small', 'file-mat-card': card_size === 'medium', 'large-mat-card': card_size === 'large', 'mat-elevation-z4': !elevated, 'mat-elevation-z8': elevated}">
<div style="padding:5px"> <div style="padding:5px">
<div *ngIf="!loading && file_obj.thumbnailURL" class="img-div"> <div *ngIf="!loading && file_obj.thumbnailURL" class="img-div">
<div style="position: relative"> <div [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" style="position: relative">
<img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailPath ? thumbnailBlobURL : file_obj.thumbnailURL" alt="Thumbnail"> <img *ngIf="!hide_image || is_playlist || (file_obj.type === 'audio' || file_obj.isAudio)" [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailPath ? thumbnailBlobURL : file_obj.thumbnailURL" alt="Thumbnail">
<video *ngIf="elevated && !is_playlist && !(file_obj.type === 'audio' || file_obj.isAudio)" autoplay loop muted [muted]="true" [ngClass]="{'video-small': card_size === 'small', 'video': card_size === 'medium', 'video-large': card_size === 'large'}" [src]="streamURL">
</video>
<div class="duration-time"> <div class="duration-time">
{{file_length}} {{file_length}}
</div> </div>

View File

@@ -51,6 +51,30 @@
object-fit: cover; object-fit: cover;
} }
.video-large {
width: 300px;
height: 167.5px;
object-fit: cover;
position: absolute;
top: 0px;
}
.video {
width: 200px;
height: 112.5px;
object-fit: cover;
position: absolute;
top: 0px;
}
.video-small {
width: 150px;
height: 84.5px;
object-fit: cover;
position: absolute;
top: 0px;
}
.example-full-width-height { .example-full-width-height {
width: 100%; width: 100%;
height: 100% height: 100%

View File

@@ -35,6 +35,9 @@ export class UnifiedFileCardComponent implements OnInit {
// optional vars // optional vars
thumbnailBlobURL = null; thumbnailBlobURL = null;
streamURL = null;
hide_image = false;
// input/output // input/output
@Input() loading = true; @Input() loading = true;
@Input() theme = null; @Input() theme = null;
@@ -46,9 +49,11 @@ export class UnifiedFileCardComponent implements OnInit {
@Input() locale = null; @Input() locale = null;
@Input() baseStreamPath = null; @Input() baseStreamPath = null;
@Input() jwtString = null; @Input() jwtString = null;
@Input() availablePlaylists = null;
@Output() goToFile = new EventEmitter<any>(); @Output() goToFile = new EventEmitter<any>();
@Output() goToSubscription = new EventEmitter<any>(); @Output() goToSubscription = new EventEmitter<any>();
@Output() deleteFile = new EventEmitter<any>(); @Output() deleteFile = new EventEmitter<any>();
@Output() addFileToPlaylist = new EventEmitter<any>();
@Output() editPlaylist = new EventEmitter<any>(); @Output() editPlaylist = new EventEmitter<any>();
@@ -76,6 +81,8 @@ export class UnifiedFileCardComponent implements OnInit {
const bloburl = URL.createObjectURL(blob); const bloburl = URL.createObjectURL(blob);
this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl);*/ this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl);*/
} }
if (this.file_obj) this.streamURL = this.generateStreamURL();
} }
emitDeleteFile(blacklistMode = false) { emitDeleteFile(blacklistMode = false) {
@@ -86,6 +93,13 @@ export class UnifiedFileCardComponent implements OnInit {
}); });
} }
emitAddFileToPlaylist(playlist_id) {
this.addFileToPlaylist.emit({
file: this.file_obj,
playlist_id: playlist_id
});
}
navigateToFile(event) { navigateToFile(event) {
this.goToFile.emit({file: this.file_obj, event: event}); this.goToFile.emit({file: this.file_obj, event: event});
} }
@@ -119,6 +133,33 @@ export class UnifiedFileCardComponent implements OnInit {
this.contextMenu.openMenu(); this.contextMenu.openMenu();
} }
generateStreamURL() {
let baseLocation = 'stream/';
let fullLocation = this.baseStreamPath + baseLocation + `?test=test&uid=${this.file_obj['uid']}`;
if (this.jwtString) {
fullLocation += `&jwt=${this.jwtString}`;
}
fullLocation += '&t=,10';
return fullLocation;
}
onMouseOver() {
this.elevated = true;
setTimeout(() => {
if (this.elevated) {
this.hide_image = true;
}
}, 500);
}
onMouseOut() {
this.elevated = false;
this.hide_image = false;
}
} }
function fancyTimeFormat(time) { function fancyTimeFormat(time) {

View File

@@ -11,5 +11,8 @@
<mat-spinner [diameter]="25"></mat-spinner> <mat-spinner [diameter]="25"></mat-spinner>
</div> </div>
<span class="spacer"></span> <span class="spacer"></span>
<button style="float: right;" mat-stroked-button mat-dialog-close>Cancel</button> <button style="float: right;" mat-stroked-button mat-dialog-close>
<ng-container *ngIf="cancelText">{{cancelText}}</ng-container>
<ng-container *ngIf="!cancelText" i18n="Cancel">Cancel</ng-container>
</button>
</mat-dialog-actions> </mat-dialog-actions>

View File

@@ -11,18 +11,23 @@ export class ConfirmDialogComponent implements OnInit {
dialogTitle = 'Confirm'; dialogTitle = 'Confirm';
dialogText = 'Would you like to confirm?'; dialogText = 'Would you like to confirm?';
submitText = 'Yes' submitText = 'Yes'
cancelText = null;
submitClicked = false; submitClicked = false;
closeOnSubmit = true;
doneEmitter: EventEmitter<any> = null; doneEmitter: EventEmitter<boolean> = null;
onlyEmitOnDone = false; onlyEmitOnDone = false;
warnSubmitColor = false; warnSubmitColor = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) { constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle }; if (this.data.dialogTitle !== undefined) { this.dialogTitle = this.data.dialogTitle }
if (this.data.dialogText) { this.dialogText = this.data.dialogText }; if (this.data.dialogText !== undefined) { this.dialogText = this.data.dialogText }
if (this.data.submitText) { this.submitText = this.data.submitText }; if (this.data.submitText !== undefined) { this.submitText = this.data.submitText }
if (this.data.warnSubmitColor) { this.warnSubmitColor = this.data.warnSubmitColor }; if (this.data.cancelText !== undefined) { this.cancelText = this.data.cancelText }
if (this.data.warnSubmitColor !== undefined) { this.warnSubmitColor = this.data.warnSubmitColor }
if (this.data.warnSubmitColor !== undefined) { this.warnSubmitColor = this.data.warnSubmitColor }
if (this.data.closeOnSubmit !== undefined) { this.closeOnSubmit = this.data.closeOnSubmit }
// checks if emitter exists, if so don't autoclose as it should be handled by caller // checks if emitter exists, if so don't autoclose as it should be handled by caller
if (this.data.doneEmitter) { if (this.data.doneEmitter) {
@@ -34,9 +39,9 @@ export class ConfirmDialogComponent implements OnInit {
confirmClicked() { confirmClicked() {
if (this.onlyEmitOnDone) { if (this.onlyEmitOnDone) {
this.doneEmitter.emit(true); this.doneEmitter.emit(true);
this.submitClicked = true; if (this.closeOnSubmit) this.submitClicked = true;
} else { } else {
this.dialogRef.close(true); if (this.closeOnSubmit) this.dialogRef.close(true);
} }
} }

View File

@@ -57,6 +57,7 @@ export class ModifyPlaylistComponent implements OnInit {
this.playlist_updated = true; this.playlist_updated = true;
this.postsService.openSnackBar('Playlist updated successfully.'); this.postsService.openSnackBar('Playlist updated successfully.');
this.getPlaylist(); this.getPlaylist();
this.postsService.playlists_changed.next(true);
}); });
} }
@@ -77,6 +78,7 @@ export class ModifyPlaylistComponent implements OnInit {
addContent(file) { addContent(file) {
this.playlist_file_objs.push(file); this.playlist_file_objs.push(file);
this.playlist.uids.push(file.uid);
this.processFiles(); this.processFiles();
} }

View File

@@ -133,12 +133,16 @@ mat-form-field.mat-form-field {
top: -5px; top: -5px;
} }
.border-radius-both {
border-radius: 16px;
}
.no-border-radius-bottom { .no-border-radius-bottom {
border-radius: 4px 4px 0px 0px; border-radius: 16px 16px 0px 0px;
} }
.no-border-radius-top { .no-border-radius-top {
border-radius: 0px 0px 4px 4px; border-radius: 0px 0px 16px 16px;
} }
@media (max-width: 576px) { @media (max-width: 576px) {

View File

@@ -1,6 +1,6 @@
<br/> <br/>
<div class="big demo-basic"> <div class="big demo-basic">
<mat-card id="card" style="margin-right: 20px; margin-left: 20px;" [ngClass]="(allowAdvancedDownload) ? 'no-border-radius-bottom' : null"> <mat-card id="card" style="margin-right: 20px; margin-left: 20px;" [ngClass]="(allowAdvancedDownload) ? 'no-border-radius-bottom' : 'border-radius-both'">
<mat-card-content style="padding: 0px 8px 0px 8px;"> <mat-card-content style="padding: 0px 8px 0px 8px;">
<div style="position: relative; margin-right: 15px;"> <div style="position: relative; margin-right: 15px;">
<form class="example-form"> <form class="example-form">
@@ -65,9 +65,9 @@
Only Audio Only Audio
</ng-container> </ng-container>
</mat-checkbox> </mat-checkbox>
<mat-checkbox *ngIf="allowMultiDownloadMode" [disabled]="current_download" (change)="multiDownloadModeChanged($event)" [(ngModel)]="multiDownloadMode" style="float: right; margin-top: -12px"> <mat-checkbox *ngIf="allowAutoplay" (change)="autoplayChanged($event)" [(ngModel)]="autoplay" style="float: right; margin-top: -12px">
<ng-container i18n="Multi-download Mode checkbox"> <ng-container i18n="Autoplay checkbox">
Multi-download Mode Autoplay
</ng-container> </ng-container>
</mat-checkbox> </mat-checkbox>
@@ -169,20 +169,8 @@
</mat-expansion-panel> </mat-expansion-panel>
</form> </form>
</div> </div>
<div *ngIf="multiDownloadMode && downloads.length > 0 && !current_download" style="margin-top: 15px;" class="big demo-basic">
<mat-card id="card" style="margin-right: 20px; margin-left: 20px;">
<div class="container">
<div *ngFor="let download of downloads; let i = index;" class="row">
<ng-container *ngIf="current_download !== download && download['downloading']">
<app-download-item style="width: 100%" [download]="download" [queueNumber]="i+1" (cancelDownload)="cancelDownload($event)"></app-download-item>
<mat-divider style="position: relative" *ngIf="i !== downloads.length - 1"></mat-divider>
</ng-container>
</div>
</div>
</mat-card>
</div>
<br/> <br/>
<div class="centered big" id="bar_div" *ngIf="current_download && current_download.downloading; else nofile"> <div class="centered big" id="bar_div" *ngIf="current_download && autoplay">
<div class="margined"> <div class="margined">
<div [ngClass]="(+percentDownloaded > 99)?'make-room-for-spinner':'equal-sizes'" style="display: inline-block; width: 100%; padding-left: 20px" *ngIf="current_download.percent_complete && current_download.percent_complete > 1;else indeterminateprogress"> <div [ngClass]="(+percentDownloaded > 99)?'make-room-for-spinner':'equal-sizes'" style="display: inline-block; width: 100%; padding-left: 20px" *ngIf="current_download.percent_complete && current_download.percent_complete > 1;else indeterminateprogress">
<mat-progress-bar style="border-radius: 5px;" mode="determinate" value="{{percentDownloaded}}"></mat-progress-bar> <mat-progress-bar style="border-radius: 5px;" mode="determinate" value="{{percentDownloaded}}"></mat-progress-bar>
@@ -197,9 +185,10 @@
</div> </div>
<br/> <br/>
</div> </div>
<ng-template #nofile>
</ng-template> <div style="display: flex; justify-content: center;" *ngIf="downloads && downloads.length > 0 && !autoplay">
<app-downloads style="width: 80%; margin-bottom: 10px" [uids]="download_uids"></app-downloads>
</div>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled"> <ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">
<app-recent-videos #recentVideos></app-recent-videos> <app-recent-videos #recentVideos></app-recent-videos>

View File

@@ -46,7 +46,7 @@ export class MainComponent implements OnInit {
determinateProgress = false; determinateProgress = false;
downloadingfile = false; downloadingfile = false;
audioOnly: boolean; audioOnly: boolean;
multiDownloadMode = false; autoplay = false;
customArgsEnabled = false; customArgsEnabled = false;
customArgs = null; customArgs = null;
customOutputEnabled = false; customOutputEnabled = false;
@@ -68,7 +68,7 @@ export class MainComponent implements OnInit {
fileManagerEnabled = false; fileManagerEnabled = false;
allowQualitySelect = false; allowQualitySelect = false;
downloadOnlyMode = false; downloadOnlyMode = false;
allowMultiDownloadMode = false; allowAutoplay = false;
audioFolderPath; audioFolderPath;
videoFolderPath; videoFolderPath;
use_youtubedl_archive = false; use_youtubedl_archive = false;
@@ -95,6 +95,7 @@ export class MainComponent implements OnInit {
playlist_thumbnails = {}; playlist_thumbnails = {};
downloading_content = {'audio': {}, 'video': {}}; downloading_content = {'audio': {}, 'video': {}};
downloads: Download[] = []; downloads: Download[] = [];
download_uids: string[] = [];
current_download: Download = null; current_download: Download = null;
urlForm = new FormControl('', [Validators.required]); urlForm = new FormControl('', [Validators.required]);
@@ -230,9 +231,9 @@ export class MainComponent implements OnInit {
async loadConfig() { async loadConfig() {
// loading config // loading config
this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled'] this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled']
&& (!this.postsService.isLoggedIn || this.postsService.permissions.includes('filemanager')); && this.postsService.hasPermission('filemanager');
this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode']; this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode'];
this.allowMultiDownloadMode = this.postsService.config['Extra']['allow_multi_download_mode']; this.allowAutoplay = this.postsService.config['Extra']['allow_autoplay'];
this.audioFolderPath = this.postsService.config['Downloader']['path-audio']; this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
this.videoFolderPath = this.postsService.config['Downloader']['path-video']; this.videoFolderPath = this.postsService.config['Downloader']['path-video'];
this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive']; this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive'];
@@ -242,15 +243,10 @@ export class MainComponent implements OnInit {
this.youtubeAPIKey = this.youtubeSearchEnabled ? this.postsService.config['API']['youtube_API_key'] : null; this.youtubeAPIKey = this.youtubeSearchEnabled ? this.postsService.config['API']['youtube_API_key'] : null;
this.allowQualitySelect = this.postsService.config['Extra']['allow_quality_select']; this.allowQualitySelect = this.postsService.config['Extra']['allow_quality_select'];
this.allowAdvancedDownload = this.postsService.config['Advanced']['allow_advanced_download'] this.allowAdvancedDownload = this.postsService.config['Advanced']['allow_advanced_download']
&& (!this.postsService.isLoggedIn || this.postsService.permissions.includes('advanced_download')); && this.postsService.hasPermission('advanced_download');
this.useDefaultDownloadingAgent = this.postsService.config['Advanced']['use_default_downloading_agent']; this.useDefaultDownloadingAgent = this.postsService.config['Advanced']['use_default_downloading_agent'];
this.customDownloadingAgent = this.postsService.config['Advanced']['custom_downloading_agent']; this.customDownloadingAgent = this.postsService.config['Advanced']['custom_downloading_agent'];
if (this.youtubeSearchEnabled && this.youtubeAPIKey) {
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
this.attachToInput();
}
// set final cache items // set final cache items
localStorage.setItem('cached_filemanager_enabled', this.fileManagerEnabled.toString()); localStorage.setItem('cached_filemanager_enabled', this.fileManagerEnabled.toString());
@@ -274,9 +270,9 @@ export class MainComponent implements OnInit {
const customOutput = localStorage.getItem('customOutput'); const customOutput = localStorage.getItem('customOutput');
const youtubeUsername = localStorage.getItem('youtubeUsername'); const youtubeUsername = localStorage.getItem('youtubeUsername');
if (customArgs && customArgs !== 'null') { this.customArgs = customArgs }; if (customArgs && customArgs !== 'null') { this.customArgs = customArgs }
if (customOutput && customOutput !== 'null') { this.customOutput = customOutput }; if (customOutput && customOutput !== 'null') { this.customOutput = customOutput }
if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername }; if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername }
} }
// get downloads routine // get downloads routine
@@ -314,8 +310,8 @@ export class MainComponent implements OnInit {
this.audioOnly = localStorage.getItem('audioOnly') === 'true'; this.audioOnly = localStorage.getItem('audioOnly') === 'true';
} }
if (localStorage.getItem('multiDownloadMode') !== null) { if (localStorage.getItem('autoplay') !== null) {
this.multiDownloadMode = localStorage.getItem('multiDownloadMode') === 'true'; this.autoplay = localStorage.getItem('autoplay') === 'true';
} }
// check if params exist // check if params exist
@@ -330,6 +326,13 @@ export class MainComponent implements OnInit {
this.setCols(); this.setCols();
} }
ngAfterViewInit() {
if (this.youtubeSearchEnabled && this.youtubeAPIKey) {
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
this.attachToInput();
}
}
public setCols() { public setCols() {
if (window.innerWidth <= 350) { if (window.innerWidth <= 350) {
this.files_cols = 1; this.files_cols = 1;
@@ -343,7 +346,7 @@ export class MainComponent implements OnInit {
} }
public goToFile(container, isAudio, uid) { public goToFile(container, isAudio, uid) {
this.downloadHelper(container, isAudio ? 'audio' : 'video', false, false, null, true); this.downloadHelper(container, isAudio ? 'audio' : 'video', false, false, true);
} }
public goToPlaylist(playlistID, type) { public goToPlaylist(playlistID, type) {
@@ -374,10 +377,9 @@ export class MainComponent implements OnInit {
} }
// download helpers // download helpers
downloadHelper(container, type, is_playlist = false, force_view = false, navigate_mode = false) {
downloadHelper(container, type, is_playlist = false, force_view = false, new_download = null, navigate_mode = false) {
this.downloadingfile = false; this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) { if (!this.autoplay && !this.downloadOnlyMode && !navigate_mode) {
// do nothing // do nothing
this.reloadRecentVideos(); this.reloadRecentVideos();
} else { } else {
@@ -398,9 +400,6 @@ export class MainComponent implements OnInit {
} }
} }
} }
// remove download from current downloads
this.removeDownloadFromCurrentDownloads(new_download);
} }
// download click handler // download click handler
@@ -432,21 +431,8 @@ export class MainComponent implements OnInit {
} }
const type = this.audioOnly ? 'audio' : 'video'; const type = this.audioOnly ? 'audio' : 'video';
// create download object
const new_download: Download = {
uid: uuid(),
type: type,
percent_complete: 0,
url: this.url,
downloading: true,
is_playlist: this.url.includes('playlist'),
error: false
};
this.downloads.push(new_download);
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
this.downloadingfile = true;
let customQualityConfiguration = type === 'audio' ? this.getSelectedAudioFormat() : this.getSelectedVideoFormat(); const customQualityConfiguration = type === 'audio' ? this.getSelectedAudioFormat() : this.getSelectedVideoFormat();
let cropFileSettings = null; let cropFileSettings = null;
@@ -457,31 +443,21 @@ export class MainComponent implements OnInit {
} }
} }
this.downloadingfile = true;
this.postsService.downloadFile(this.url, type, (this.selectedQuality === '' ? null : this.selectedQuality), this.postsService.downloadFile(this.url, type, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid, cropFileSettings).subscribe(res => { customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, cropFileSettings).subscribe(res => {
// update download object this.current_download = res['download'];
new_download.downloading = false; this.downloads.push(res['download']);
new_download.percent_complete = 100; this.download_uids.push(res['download']['uid']);
const container = res['container'];
const is_playlist = res['file_uids'].length > 1;
this.current_download = null;
this.downloadHelper(container, type, is_playlist, false, new_download);
}, error => { // can't access server }, error => { // can't access server
this.downloadingfile = false; this.downloadingfile = false;
this.current_download = null; this.current_download = null;
new_download['downloading'] = false;
// removes download from list of downloads
const downloads_index = this.downloads.indexOf(new_download);
if (downloads_index !== -1) {
this.downloads.splice(downloads_index)
}
this.openSnackBar('Download failed!', 'OK.'); this.openSnackBar('Download failed!', 'OK.');
}); });
if (this.multiDownloadMode) { if (!this.autoplay) {
const download_queued_message = $localize`Download for ${this.url}:url: has been queued!`;
this.postsService.openSnackBar(download_queued_message);
this.url = ''; this.url = '';
this.downloadingfile = false; this.downloadingfile = false;
} }
@@ -640,7 +616,7 @@ export class MainComponent implements OnInit {
} }
if (!(this.cachedAvailableFormats[url] && this.cachedAvailableFormats[url]['formats'])) { if (!(this.cachedAvailableFormats[url] && this.cachedAvailableFormats[url]['formats'])) {
this.cachedAvailableFormats[url]['formats_loading'] = true; this.cachedAvailableFormats[url]['formats_loading'] = true;
this.postsService.getFileInfo([url], 'irrelevant', true).subscribe(res => { this.postsService.getFileFormats([url]).subscribe(res => {
this.cachedAvailableFormats[url]['formats_loading'] = false; this.cachedAvailableFormats[url]['formats_loading'] = false;
const infos = res['result']; const infos = res['result'];
if (!infos || !infos.formats) { if (!infos || !infos.formats) {
@@ -648,7 +624,6 @@ export class MainComponent implements OnInit {
return; return;
} }
this.cachedAvailableFormats[url]['formats'] = this.getAudioAndVideoFormats(infos.formats); this.cachedAvailableFormats[url]['formats'] = this.getAudioAndVideoFormats(infos.formats);
console.log(this.cachedAvailableFormats[url]['formats']);
}, err => { }, err => {
this.errorFormats(url); this.errorFormats(url);
}); });
@@ -773,8 +748,8 @@ export class MainComponent implements OnInit {
localStorage.setItem('audioOnly', new_val.checked.toString()); localStorage.setItem('audioOnly', new_val.checked.toString());
} }
multiDownloadModeChanged(new_val) { autoplayChanged(new_val) {
localStorage.setItem('multiDownloadMode', new_val.checked.toString()); localStorage.setItem('autoplay', new_val.checked.toString());
} }
customArgsEnabledChanged(new_val) { customArgsEnabledChanged(new_val) {
@@ -808,8 +783,6 @@ export class MainComponent implements OnInit {
const audio_formats: any = {}; const audio_formats: any = {};
const video_formats: any = {}; const video_formats: any = {};
console.log(formats);
for (let i = 0; i < formats.length; i++) { for (let i = 0; i < formats.length; i++) {
const format_obj = {type: null}; const format_obj = {type: null};
@@ -937,12 +910,20 @@ export class MainComponent implements OnInit {
if (!this.current_download) { if (!this.current_download) {
return; return;
} }
const ui_uid = this.current_download['ui_uid'] ? this.current_download['ui_uid'] : this.current_download['uid']; this.postsService.getCurrentDownload(this.current_download['uid']).subscribe(res => {
this.postsService.getCurrentDownload(this.postsService.session_id, ui_uid).subscribe(res => {
if (res['download']) { if (res['download']) {
if (ui_uid === res['download']['ui_uid']) {
this.current_download = res['download']; this.current_download = res['download'];
this.percentDownloaded = this.current_download.percent_complete; this.percentDownloaded = this.current_download.percent_complete;
if (this.current_download['finished'] && !this.current_download['error']) {
const container = this.current_download['container'];
const is_playlist = this.current_download['file_uids'].length > 1;
this.downloadHelper(container, this.current_download['type'], is_playlist, false);
this.current_download = null;
} else if (this.current_download['finished'] && this.current_download['error']) {
this.downloadingfile = false;
this.current_download = null;
this.openSnackBar('Download failed!', 'OK.');
} }
} else { } else {
// console.log('failed to get new download'); // console.log('failed to get new download');
@@ -951,8 +932,6 @@ export class MainComponent implements OnInit {
} }
reloadRecentVideos() { reloadRecentVideos() {
if (this.recentVideos) { this.postsService.files_changed.next(true);
this.recentVideos.getAllFiles();
}
} }
} }

View File

@@ -90,3 +90,9 @@
margin-right: 12px; margin-right: 12px;
top: 8px; top: 8px;
} }
.skip-ad-button {
position: absolute;
right: 20px;
bottom: 75px;
}

View File

@@ -4,8 +4,9 @@
<mat-drawer-container style="height: 100%" class="example-container" autosize> <mat-drawer-container style="height: 100%" class="example-container" autosize>
<div style="height: fit-content" [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-col' : 'video-col'"> <div style="height: fit-content" [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-col' : 'video-col'">
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(currentItem.type === 'audio/mp3') ? postsService.theme.drawer_color : 'black'"> <vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(currentItem.type === 'audio/mp3') ? postsService.theme.drawer_color : 'black'">
<video [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls> <video [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls playsinline>
</video> </video>
<app-skip-ad-button *ngIf="postsService['config']['API']['use_sponsorblock_API'] && api && playlist?.length > 0 && playlist[currentIndex]['type'] === 'video/mp4'" (setPlaybackTimestamp)="setPlaybackTimestamp($event)" [current_video]="playlist[currentIndex]" [playback_timestamp]="api.currentTime" [sponsor_block_cache]="sponsor_block_cache" class="skip-ad-button"></app-skip-ad-button>
</vg-player> </vg-player>
</div> </div>
<div style="height: fit-content; width: 100%; margin-top: 10px;"> <div style="height: fit-content; width: 100%; margin-top: 10px;">

View File

@@ -1,10 +1,9 @@
import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core'; import { Component, OnInit, HostListener, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
import { VgApiService } from '@videogular/ngx-videogular/core'; import { VgApiService } from '@videogular/ngx-videogular/core';
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component'; import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component';
import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component'; import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component';
@@ -15,6 +14,7 @@ export interface IMedia {
src: string; src: string;
type: string; type: string;
label: string; label: string;
url: string;
} }
@Component({ @Component({
@@ -133,7 +133,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
title: this.name, title: this.name,
label: this.name, label: this.name,
src: this.url, src: this.url,
type: 'video/mp4' type: 'video/mp4',
url: this.url
} }
this.playlist.push(imedia); this.playlist.push(imedia);
this.currentItem = this.playlist[0]; this.currentItem = this.playlist[0];
@@ -165,18 +166,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
const subscription = res['subscription']; const subscription = res['subscription'];
this.subscription = subscription; this.subscription = subscription;
this.type === this.subscription.type; this.type === this.subscription.type;
subscription.videos.forEach(video => { this.uids = this.subscription.videos.map(video => video['uid']);
if (video['uid'] === this.uid) {
this.db_file = video;
this.postsService.incrementViewCount(this.db_file['uid'], this.sub_id, this.uuid).subscribe(res => {}, err => {
console.error('Failed to increment view count');
console.error(err);
});
this.uids = [this.db_file['uid']];
this.show_player = true;
this.parseFileNames(); this.parseFileNames();
}
});
}, err => { }, err => {
this.openSnackBar(`Failed to find subscription ${this.sub_id}`, 'Dismiss'); this.openSnackBar(`Failed to find subscription ${this.sub_id}`, 'Dismiss');
}); });
@@ -202,9 +193,14 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
parseFileNames() { parseFileNames() {
this.playlist = []; this.playlist = [];
for (let i = 0; i < this.uids.length; i++) { for (let i = 0; i < this.uids.length; i++) {
const uid = this.uids[i]; let file_obj = null;
if (this.playlist_id) {
const file_obj = this.playlist_id ? this.db_playlist['file_objs'][i] : this.db_file; file_obj = this.db_playlist['file_objs'][i];
} else if (this.sub_id) {
file_obj = this.subscription['videos'][i];
} else {
file_obj = this.db_file;
}
const mime_type = file_obj.isAudio ? 'audio/mp3' : 'video/mp4' const mime_type = file_obj.isAudio ? 'audio/mp3' : 'video/mp4'
@@ -229,7 +225,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
title: file_obj['title'], title: file_obj['title'],
src: fullLocation, src: fullLocation,
type: mime_type, type: mime_type,
label: file_obj['title'] label: file_obj['title'],
url: file_obj['url']
} }
this.playlist.push(mediaObject); this.playlist.push(mediaObject);
} }
@@ -289,13 +286,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.currentItem = item; this.currentItem = item;
} }
getFileInfos() {
const fileNames = this.getFileNames();
this.postsService.getFileInfo(fileNames, this.type, false).subscribe(res => {
});
}
getFileNames() { getFileNames() {
const fileNames = []; const fileNames = [];
for (let i = 0; i < this.playlist.length; i++) { for (let i = 0; i < this.playlist.length; i++) {
@@ -350,22 +340,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
return JSON.stringify(this.playlist) !== this.original_playlist; return JSON.stringify(this.playlist) !== this.original_playlist;
} }
updatePlaylist() {
const fileNames = this.getFileNames();
this.playlist_updating = true;
this.postsService.updatePlaylistFiles(this.playlist_id, fileNames, this.type).subscribe(res => {
this.playlist_updating = false;
if (res['success']) {
const fileNamesEncoded = fileNames.join('|nvr|');
this.router.navigate(['/player', {fileNames: fileNamesEncoded, type: this.type, id: this.playlist_id}]);
this.openSnackBar('Successfully updated playlist.', '');
this.original_playlist = JSON.stringify(this.playlist);
} else {
this.openSnackBar('ERROR: Failed to update playlist.', '');
}
})
}
openShareDialog() { openShareDialog() {
const dialogRef = this.dialog.open(ShareMediaDialogComponent, { const dialogRef = this.dialog.open(ShareMediaDialogComponent, {
data: { data: {

View File

@@ -48,6 +48,9 @@ export class PostsService implements CanActivate {
settings_changed = new BehaviorSubject<boolean>(false); settings_changed = new BehaviorSubject<boolean>(false);
open_create_default_admin_dialog = new BehaviorSubject<boolean>(false); open_create_default_admin_dialog = new BehaviorSubject<boolean>(false);
files_changed = new BehaviorSubject<boolean>(false);
playlists_changed = new BehaviorSubject<boolean>(false);
// app status // app status
initialized = false; initialized = false;
@@ -171,7 +174,7 @@ export class PostsService implements CanActivate {
} }
// tslint:disable-next-line: max-line-length // tslint:disable-next-line: max-line-length
downloadFile(url: string, type: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null, cropFileSettings = null) { downloadFile(url: string, type: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, cropFileSettings = null) {
return this.http.post(this.path + 'downloadFile', {url: url, return this.http.post(this.path + 'downloadFile', {url: url,
selectedHeight: selectedQuality, selectedHeight: selectedQuality,
customQualityConfiguration: customQualityConfiguration, customQualityConfiguration: customQualityConfiguration,
@@ -179,7 +182,6 @@ export class PostsService implements CanActivate {
customOutput: customOutput, customOutput: customOutput,
youtubeUsername: youtubeUsername, youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword, youtubePassword: youtubePassword,
ui_uid: ui_uid,
type: type, type: type,
cropFileSettings: cropFileSettings}, this.httpOptions); cropFileSettings: cropFileSettings}, this.httpOptions);
} }
@@ -192,8 +194,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'transferDB', {local_to_remote: local_to_remote}, this.httpOptions); return this.http.post(this.path + 'transferDB', {local_to_remote: local_to_remote}, this.httpOptions);
} }
testConnectionString() { testConnectionString(connection_string) {
return this.http.post(this.path + 'testConnectionString', {}, this.httpOptions); return this.http.post(this.path + 'testConnectionString', {connection_string: connection_string}, this.httpOptions);
} }
killAllDownloads() { killAllDownloads() {
@@ -236,8 +238,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'getFile', {uid: uid, type: type, uuid: uuid}, this.httpOptions); return this.http.post(this.path + 'getFile', {uid: uid, type: type, uuid: uuid}, this.httpOptions);
} }
getAllFiles() { getAllFiles(sort, range, text_search, file_type_filter) {
return this.http.post(this.path + 'getAllFiles', {}, this.httpOptions); return this.http.post(this.path + 'getAllFiles', {sort: sort, range: range, text_search: text_search, file_type_filter: file_type_filter}, this.httpOptions);
} }
getFullTwitchChat(id, type, uuid = null, sub = null) { getFullTwitchChat(id, type, uuid = null, sub = null) {
@@ -293,8 +295,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', params: this.httpOptions.params}); return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', params: this.httpOptions.params});
} }
getFileInfo(fileNames, type, urlMode) { getFileFormats(url) {
return this.http.post(this.path + 'getVideoInfos', {fileNames: fileNames, type: type, urlMode: urlMode}, this.httpOptions); return this.http.post(this.path + 'getFileFormats', {url: url}, this.httpOptions);
} }
getLogs(lines = 50) { getLogs(lines = 50) {
@@ -334,18 +336,22 @@ export class PostsService implements CanActivate {
include_file_metadata: include_file_metadata}, this.httpOptions); include_file_metadata: include_file_metadata}, this.httpOptions);
} }
getPlaylists() {
return this.http.post(this.path + 'getPlaylists', {}, this.httpOptions);
}
updatePlaylist(playlist) { updatePlaylist(playlist) {
return this.http.post(this.path + 'updatePlaylist', {playlist: playlist}, this.httpOptions); return this.http.post(this.path + 'updatePlaylist', {playlist: playlist}, this.httpOptions);
} }
updatePlaylistFiles(playlistID, fileNames, type) { addFileToPlaylist(playlist_id, file_uid) {
return this.http.post(this.path + 'updatePlaylistFiles', {playlistID: playlistID, return this.http.post(this.path + 'addFileToPlaylist', {playlist_id: playlist_id,
fileNames: fileNames, file_uid: file_uid},
type: type}, this.httpOptions); this.httpOptions);
} }
removePlaylist(playlistID, type) { removePlaylist(playlist_id, type) {
return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}, this.httpOptions); return this.http.post(this.path + 'deletePlaylist', {playlist_id: playlist_id, type: type}, this.httpOptions);
} }
// categories // categories
@@ -407,24 +413,46 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'getSubscriptions', {}, this.httpOptions); return this.http.post(this.path + 'getSubscriptions', {}, this.httpOptions);
} }
// current downloads getCurrentDownloads(uids = null) {
getCurrentDownloads() { return this.http.post(this.path + 'downloads', {uids: uids}, this.httpOptions);
return this.http.get(this.path + 'downloads', this.httpOptions);
} }
// current download getCurrentDownload(download_uid) {
getCurrentDownload(session_id, download_id) { return this.http.post(this.path + 'download', {download_uid: download_uid}, this.httpOptions);
return this.http.post(this.path + 'download', {download_id: download_id, session_id: session_id}, this.httpOptions);
} }
// clear downloads. download_id is optional, if it exists only 1 download will be cleared pauseDownload(download_uid) {
clearDownloads(delete_all = false, session_id = null, download_id = null) { return this.http.post(this.path + 'pauseDownload', {download_uid: download_uid}, this.httpOptions);
return this.http.post(this.path + 'clearDownloads', {delete_all: delete_all, }
download_id: download_id,
session_id: session_id ? session_id : this.session_id}, this.httpOptions); pauseAllDownloads() {
return this.http.post(this.path + 'pauseAllDownloads', {}, this.httpOptions);
}
resumeDownload(download_uid) {
return this.http.post(this.path + 'resumeDownload', {download_uid: download_uid}, this.httpOptions);
}
resumeAllDownloads() {
return this.http.post(this.path + 'resumeAllDownloads', {}, this.httpOptions);
}
restartDownload(download_uid) {
return this.http.post(this.path + 'restartDownload', {download_uid: download_uid}, this.httpOptions);
}
cancelDownload(download_uid) {
return this.http.post(this.path + 'cancelDownload', {download_uid: download_uid}, this.httpOptions);
}
clearDownload(download_uid) {
return this.http.post(this.path + 'clearDownload', {download_uid: download_uid}, this.httpOptions);
}
clearFinishedDownloads() {
return this.http.post(this.path + 'clearFinishedDownloads', {}, this.httpOptions);
} }
// updates the server to the latest version
updateServer(tag) { updateServer(tag) {
return this.http.post(this.path + 'updateServer', {tag: tag}, this.httpOptions); return this.http.post(this.path + 'updateServer', {tag: tag}, this.httpOptions);
} }
@@ -498,6 +526,12 @@ export class PostsService implements CanActivate {
this.resetHttpParams(); this.resetHttpParams();
} }
hasPermission(permission) {
// assume not logged in users never have permission
if (this.config.Advanced.multi_user_mode && !this.isLoggedIn) return false;
return this.config.Advanced.multi_user_mode ? this.permissions.includes(permission) : true;
}
// user methods // user methods
register(username, password) { register(username, password) {
const call = this.http.post(this.path + 'auth/register', {userid: username, const call = this.http.post(this.path + 'auth/register', {userid: username,
@@ -595,6 +629,11 @@ export class PostsService implements CanActivate {
this.httpOptions); this.httpOptions);
} }
getSponsorBlockDataForVideo(id_hash) {
const sponsor_block_api_path = 'https://sponsor.ajay.app/api/';
return this.http.get(sponsor_block_api_path + `skipSegments/${id_hash}`);
}
public openSnackBar(message: string, action: string = '') { public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, { this.snackBar.open(message, action, {
duration: 2000, duration: 2000,

View File

@@ -1,13 +1,12 @@
<h4 i18n="Settings title" mat-dialog-title>Settings</h4> <h4 class="settings-title" i18n="Settings title">Settings</h4>
<!-- <ng-container i18n="Allow subscriptions setting"></ng-container> --> <!-- <ng-container i18n="Allow subscriptions setting"></ng-container> -->
<mat-dialog-content>
<!-- Language <!-- Language
<div style="margin-bottom: 10px;"> <div style="margin-bottom: 10px;">
</div> --> </div> -->
<mat-tab-group> <mat-tab-group style="height: 76vh" mat-align-tabs="center">
<!-- Server --> <!-- Server -->
<mat-tab label="Main" i18n-label="Main settings label"> <mat-tab label="Main" i18n-label="Main settings label">
<ng-template matTabContent style="padding: 15px;"> <ng-template matTabContent style="padding: 15px;">
@@ -59,7 +58,7 @@
<mat-hint><ng-container i18n="Check interval setting input hint">Unit is seconds, only include numbers.</ng-container></mat-hint> <mat-hint><ng-container i18n="Check interval setting input hint">Unit is seconds, only include numbers.</ng-container></mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-2 mb-3"> <div class="col-12 mt-4 mb-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['Subscriptions']['redownload_fresh_uploads']" matTooltip="Sometimes new videos are downloaded before being fully processed. This setting will mean new videos will be checked for a higher quality version the following day." i18n-matTooltip="Redownload fresh uploads tooltip"><ng-container i18n="Redownload fresh uploads">Redownload fresh uploads</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Subscriptions']['redownload_fresh_uploads']" matTooltip="Sometimes new videos are downloaded before being fully processed. This setting will mean new videos will be checked for a higher quality version the following day." i18n-matTooltip="Redownload fresh uploads tooltip"><ng-container i18n="Redownload fresh uploads">Redownload fresh uploads</ng-container></mat-checkbox>
</div> </div>
</div> </div>
@@ -111,14 +110,14 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-5"> <div class="col-12 mt-3">
<mat-form-field class="text-field" color="accent"> <mat-form-field class="text-field" color="accent">
<input matInput [(ngModel)]="new_config['Downloader']['path-video']" placeholder="Video folder path" i18n-placeholder="Video folder path input placeholder" required> <input matInput [(ngModel)]="new_config['Downloader']['path-video']" placeholder="Video folder path" i18n-placeholder="Video folder path input placeholder" required>
<mat-hint><ng-container i18n="Video path setting input hint">Path for video downloads. It is relative to YTDL-Material's root folder.</ng-container></mat-hint> <mat-hint><ng-container i18n="Video path setting input hint">Path for video downloads. It is relative to YTDL-Material's root folder.</ng-container></mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-4"> <div class="col-12 mt-3">
<mat-form-field class="text-field" color="accent"> <mat-form-field class="text-field" color="accent">
<input matInput [(ngModel)]="new_config['Downloader']['default_file_output']" matInput placeholder="Default file output" i18n-placeholder="Default file output placeholder"> <input matInput [(ngModel)]="new_config['Downloader']['default_file_output']" matInput placeholder="Default file output" i18n-placeholder="Default file output placeholder">
<mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template"> <mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
@@ -128,7 +127,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-4 mb-5"> <div class="col-12 mt-3 mb-4">
<mat-form-field class="text-field" style="margin-right: 12px;" color="accent"> <mat-form-field class="text-field" style="margin-right: 12px;" color="accent">
<textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Global custom args" i18n-placeholder="Custom args input placeholder"></textarea> <textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Global custom args" i18n-placeholder="Custom args input placeholder"></textarea>
<mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint> <mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint>
@@ -142,7 +141,7 @@
<div class="row"> <div class="row">
<div class="col-12 mt-3"> <div class="col-12 mt-3">
<h6 i18n="Categories">Categories</h6> <h6 i18n="Categories">Categories</h6>
<div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)"> <div *ngIf="postsService.categories && postsService.categories.length > 0" kDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div class="category-box" *ngFor="let category of postsService.categories" cdkDrag> <div class="category-box" *ngFor="let category of postsService.categories" cdkDrag>
<div class="category-custom-placeholder" *cdkDragPlaceholder></div> <div class="category-custom-placeholder" *cdkDragPlaceholder></div>
{{category['name']}} {{category['name']}}
@@ -170,11 +169,32 @@
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_thumbnail']"><ng-container i18n="Include thumbnail setting">Include thumbnail</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_thumbnail']"><ng-container i18n="Include thumbnail setting">Include thumbnail</ng-container></mat-checkbox>
</div> </div>
<div class="col-12 mt-2"> <div class="col-12 mt-2 mb-2">
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_metadata']"><ng-container i18n="Include metadata setting">Include metadata</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_metadata']"><ng-container i18n="Include metadata setting">Include metadata</ng-container></mat-checkbox>
</div> </div>
</div>
<div class="col-12 mt-2"> </div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3 mb-4">
<mat-form-field class="text-field" color="accent">
<input type="number" [(ngModel)]="new_config['Downloader']['max_concurrent_downloads']" matInput placeholder="Max concurrent downloads" i18n-placeholder="Max concurrent downloads">
<mat-hint><ng-container i18n="Max concurrent downloads input hint">Limits the amount of downloads that can be simultaneously downloaded. Use -1 for no limit.</ng-container></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-2 mb-4">
<mat-form-field class="text-field" color="accent">
<input [(ngModel)]="new_config['Downloader']['download_rate_limit']" matInput placeholder="Download rate limit" i18n-placeholder="Download rate limit input placeholder">
<mat-hint><ng-container i18n="Download rate limit input hint">Rate limits your downloads to the specified amount. Ex: 200K</ng-container></mat-hint>
</mat-form-field>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<button (click)="killAllDownloads()" mat-stroked-button color="warn"><ng-container i18n="Kill all downloads button">Kill all downloads</ng-container></button> <button (click)="killAllDownloads()" mat-stroked-button color="warn"><ng-container i18n="Kill all downloads button">Kill all downloads</ng-container></button>
</div> </div>
</div> </div>
@@ -205,7 +225,7 @@
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['download_only_mode']"><ng-container i18n="Download only mode setting">Download only mode</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['download_only_mode']"><ng-container i18n="Download only mode setting">Download only mode</ng-container></mat-checkbox>
</div> </div>
<div class="col-12"> <div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_multi_download_mode']"><ng-container i18n="Allow multi-download mode setting">Allow multi-download mode</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_autoplay']"><ng-container i18n="Allow autoplay setting">Allow autoplay</ng-container></mat-checkbox>
</div> </div>
</div> </div>
</div> </div>
@@ -246,12 +266,15 @@
<div *ngIf="new_config['API']['use_twitch_API']" class="col-12 mt-1"> <div *ngIf="new_config['API']['use_twitch_API']" class="col-12 mt-1">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['twitch_auto_download_chat']"><ng-container i18n="Auto download Twitch Chat setting">Auto-download Twitch Chat</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['API']['twitch_auto_download_chat']"><ng-container i18n="Auto download Twitch Chat setting">Auto-download Twitch Chat</ng-container></mat-checkbox>
</div> </div>
<div class="col-12 mb-5"> <div class="col-12">
<mat-form-field class="text-field" color="accent"> <mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_API_key']" matInput placeholder="Twitch API Key" i18n-placeholder="Twitch API Key setting placeholder" required> <input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_API_key']" matInput placeholder="Twitch API Key" i18n-placeholder="Twitch API Key setting placeholder" required>
<mat-hint><ng-container i18n="Twitch API Key setting hint AKA preamble">Also known as a Client ID.</ng-container>&nbsp;<a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint> <mat-hint><ng-container i18n="Twitch API Key setting hint AKA preamble">Also known as a Client ID.</ng-container>&nbsp;<a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="col-12 mt-4 mb-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_sponsorblock_API']" matTooltip="Enables a button to skip ads when viewing supported videos." i18n-matTooltip="SponsorBlock API tooltip"><ng-container i18n="Use SponsorBlock API setting">Use SponsorBlock API</ng-container></mat-checkbox>
</div>
</div> </div>
</div> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
@@ -301,7 +324,7 @@
</mat-form-field> </mat-form-field>
<div class="test-connection-div"> <div class="test-connection-div">
<button (click)="testConnectionString()" [disabled]="testing_connection_string" mat-flat-button color="accent"><ng-container i18n="Test connection string button">Test connection string</ng-container></button> <button (click)="testConnectionString(new_config['Database']['mongodb_connection_string'])" [disabled]="testing_connection_string" mat-flat-button color="accent"><ng-container i18n="Test connection string button">Test connection string</ng-container></button>
</div> </div>
<div class="transfer-db-div"> <div class="transfer-db-div">
@@ -391,7 +414,7 @@
<app-updater></app-updater> <app-updater></app-updater>
</div> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
<div *ngIf="new_config" class="container"> <div *ngIf="new_config" class="container-fluid">
<div class="row"> <div class="row">
<div class="col-12 mt-4"> <div class="col-12 mt-4">
<button (click)="restartServer()" mat-stroked-button color="warn"><ng-container i18n="Restart server button">Restart server</ng-container></button> <button (click)="restartServer()" mat-stroked-button color="warn"><ng-container i18n="Restart server button">Restart server</ng-container></button>
@@ -401,8 +424,7 @@
</ng-template> </ng-template>
</mat-tab> </mat-tab>
<mat-tab *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode" label="Users" i18n-label="Users settings label"> <mat-tab *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode" label="Users" i18n-label="Users settings label">
<div *ngIf="new_config" style="margin-top: 24px; margin-bottom: -25px;">
<div style="margin-left: 48px; margin-top: 24px; margin-bottom: -25px;">
<div> <div>
<mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox> <mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox>
</div> </div>
@@ -446,25 +468,23 @@
</div> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
</div> </div>
<app-modify-users></app-modify-users> <app-modify-users *ngIf="new_config"></app-modify-users>
</mat-tab> </mat-tab>
<mat-tab *ngIf="postsService.config" label="Logs" i18n-label="Logs settings label"> <mat-tab *ngIf="postsService.config" label="Logs" i18n-label="Logs settings label">
<ng-template matTabContent> <ng-template matTabContent>
<div style="margin-left: 48px; margin-top: 24px; height: 340px"> <div style="margin-top: 15px; height: 84%;">
<app-logs-viewer></app-logs-viewer> <app-logs-viewer></app-logs-viewer>
</div> </div>
</ng-template> </ng-template>
</mat-tab> </mat-tab>
</mat-tab-group> </mat-tab-group>
</mat-dialog-content>
<mat-dialog-actions> <div class="action-buttons">
<div style="margin-bottom: 10px;"> <button style="margin-left: 10px; height: 37.3px" color="accent" (click)="saveSettings()" [disabled]="settingsSame()" mat-raised-button><mat-icon>done</mat-icon>&nbsp;&nbsp;
<button color="accent" (click)="saveSettings()" [disabled]="settingsSame()" mat-raised-button><mat-icon>done</mat-icon>&nbsp;&nbsp;
<ng-container i18n="Settings save button">Save</ng-container> <ng-container i18n="Settings save button">Save</ng-container>
</button> </button>
<button mat-flat-button [mat-dialog-close]="false"><mat-icon>cancel</mat-icon>&nbsp;&nbsp; <button style="margin-left: 10px;" mat-flat-button (click)="cancelSettings()" [disabled]="settingsSame()"><mat-icon>cancel</mat-icon>&nbsp;&nbsp;
<span i18n="Settings cancel and close button">{settingsAreTheSame + "", select, true {Close} false {Cancel} other {otha}}</span> <span i18n="Settings cancel button">Cancel</span>
</button> </button>
</div> </div>
</mat-dialog-actions>

View File

@@ -2,6 +2,15 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.settings-title {
text-align: center;
margin-top: 15px;
}
::ng-deep .mat-tab-body {
margin-left: 15px;
}
.ext-divider { .ext-divider {
margin-bottom: 14px; margin-bottom: 14px;
} }
@@ -23,7 +32,8 @@
} }
.text-field { .text-field {
min-width: 30%; width: 95%;
max-width: 500px;
} }
.checkbox-button { .checkbox-button {
@@ -91,3 +101,8 @@
.transfer-db-div { .transfer-db-div {
margin-bottom: 10px; margin-bottom: 10px;
} }
.action-buttons {
position: absolute;
bottom: 15px;
}

View File

@@ -51,8 +51,17 @@ export class SettingsComponent implements OnInit {
private dialog: MatDialog) { } private dialog: MatDialog) { }
ngOnInit() { ngOnInit() {
if (this.postsService.initialized) {
this.getConfig(); this.getConfig();
this.getDBInfo(); this.getDBInfo();
} else {
this.postsService.service_initialized.subscribe(init => {
if (init) {
this.getConfig();
this.getDBInfo();
}
});
}
this.generated_bookmarklet_code = this.sanitizer.bypassSecurityTrustUrl(this.generateBookmarkletCode()); this.generated_bookmarklet_code = this.sanitizer.bypassSecurityTrustUrl(this.generateBookmarkletCode());
@@ -85,6 +94,10 @@ export class SettingsComponent implements OnInit {
}) })
} }
cancelSettings() {
this.new_config = JSON.parse(JSON.stringify(this.initial_config));
}
dropCategory(event: CdkDragDrop<string[]>) { dropCategory(event: CdkDragDrop<string[]>) {
moveItemInArray(this.postsService.categories, event.previousIndex, event.currentIndex); moveItemInArray(this.postsService.categories, event.previousIndex, event.currentIndex);
this.postsService.updateCategories(this.postsService.categories).subscribe(res => { this.postsService.updateCategories(this.postsService.categories).subscribe(res => {
@@ -307,9 +320,9 @@ export class SettingsComponent implements OnInit {
}); });
} }
testConnectionString() { testConnectionString(connection_string) {
this.testing_connection_string = true; this.testing_connection_string = true;
this.postsService.testConnectionString().subscribe(res => { this.postsService.testConnectionString(connection_string).subscribe(res => {
this.testing_connection_string = false; this.testing_connection_string = false;
if (res['success']) { if (res['success']) {
this.postsService.openSnackBar('Connection successful!'); this.postsService.openSnackBar('Connection successful!');

View File

@@ -44,5 +44,6 @@
</div> </div>
</div> </div>
<button class="edit-button" color="primary" (click)="editSubscription()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">edit</mat-icon></button> <button class="edit-button" color="primary" (click)="editSubscription()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">edit</mat-icon></button>
<button class="watch-button" color="primary" (click)="watchSubscription()" mat-fab><mat-icon class="save-icon">video_library</mat-icon></button>
<button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button> <button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
</div> </div>

View File

@@ -68,3 +68,9 @@
bottom: 1px; bottom: 1px;
position: relative; position: relative;
} }
.watch-button {
left: 90px;
position: fixed;
bottom: 25px;
}

View File

@@ -109,8 +109,7 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
if (this.subscription.streamingOnly) { if (this.subscription.streamingOnly) {
this.router.navigate(['/player', {uid: uid, url: url}]); this.router.navigate(['/player', {uid: uid, url: url}]);
} else { } else {
this.router.navigate(['/player', {uid: uid, this.router.navigate(['/player', {uid: uid}]);
sub_id: this.subscription.id}]);
} }
} }
@@ -171,4 +170,8 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
}); });
} }
watchSubscription() {
this.router.navigate(['/player', {sub_id: this.subscription.id}])
}
} }

View File

@@ -14,6 +14,9 @@
<ng-container i18n="Subscription playlist not available text">Name not available. Channel retrieval in progress.</ng-container> <ng-container i18n="Subscription playlist not available text">Name not available. Channel retrieval in progress.</ng-container>
</div> </div>
</a> </a>
<button mat-icon-button (click)="editSubscription(sub)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button (click)="showSubInfo(sub)"> <button mat-icon-button (click)="showSubInfo(sub)">
<mat-icon>info</mat-icon> <mat-icon>info</mat-icon>
</button> </button>

View File

@@ -5,6 +5,7 @@ import { SubscribeDialogComponent } from 'app/dialogs/subscribe-dialog/subscribe
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { SubscriptionInfoDialogComponent } from 'app/dialogs/subscription-info-dialog/subscription-info-dialog.component'; import { SubscriptionInfoDialogComponent } from 'app/dialogs/subscription-info-dialog/subscription-info-dialog.component';
import { EditSubscriptionDialogComponent } from 'app/dialogs/edit-subscription-dialog/edit-subscription-dialog.component';
@Component({ @Component({
selector: 'app-subscriptions', selector: 'app-subscriptions',
@@ -32,8 +33,8 @@ export class SubscriptionsComponent implements OnInit {
}); });
} }
getSubscriptions() { getSubscriptions(show_loading = true) {
this.subscriptions_loading = true; if (show_loading) this.subscriptions_loading = true;
this.subscriptions = null; this.subscriptions = null;
this.postsService.getAllSubscriptions().subscribe(res => { this.postsService.getAllSubscriptions().subscribe(res => {
this.channel_subscriptions = []; this.channel_subscriptions = [];
@@ -102,6 +103,17 @@ export class SubscriptionsComponent implements OnInit {
}) })
} }
editSubscription(sub) {
const dialogRef = this.dialog.open(EditSubscriptionDialogComponent, {
data: {
sub: this.postsService.getSubscriptionByID(sub.id)
}
});
dialogRef.afterClosed().subscribe(() => {
this.getSubscriptions(false);
});
}
// snackbar helper // snackbar helper
public openSnackBar(message: string, action = '') { public openSnackBar(message: string, action = '') {
this.snackBar.open(message, action, { this.snackBar.open(message, action, {

View File

@@ -42,6 +42,10 @@ $dark-theme: mat-dark-theme($dark-primary, $dark-accent, $dark-warn);
@include angular-material-theme($dark-theme); @include angular-material-theme($dark-theme);
} }
.mat-stroked-button, .mat-raised-button, .mat-flat-button {
border-radius: 24px !important
}
// Light theme // Light theme
$light-primary: mat-palette($mat-grey, 200, 500, 300); $light-primary: mat-palette($mat-grey, 200, 500, 300);
$light-accent: mat-palette($mat-brown, 200); $light-accent: mat-palette($mat-brown, 200);
@@ -50,7 +54,7 @@ $light-warn: mat-palette($mat-deep-orange, 200);
$light-theme: mat-light-theme($light-primary, $light-accent, $light-warn); $light-theme: mat-light-theme($light-primary, $light-accent, $light-warn);
.light-theme { .light-theme {
@include angular-material-theme($light-theme) @include angular-material-theme($light-theme);
} }
.no-outline { .no-outline {