Compare commits

..

3 Commits

Author SHA1 Message Date
Isaac Abadi
c789ba9553 Server autocloses on crash
Thumbnails are now retrieved using file UID
2021-07-31 15:51:16 -06:00
Isaac Abadi
b8e1117ff6 Removed all __dirname references in backend to allow for electron to boot 2021-07-28 21:14:32 -06:00
Isaac Abadi
b64a001ae1 Electron almost boots, but errors presumably due to a filesystem issue (missing folder?) 2021-07-28 19:17:08 -06:00
62 changed files with 3298 additions and 3017 deletions

View File

@@ -1,20 +0,0 @@
{
"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": {
}
}

View File

@@ -28,4 +28,4 @@ If applicable, add screenshots to help explain your problem.
- Docker tag: <tag> (optional)
**Additional context**
Add any other context about the problem here. For example, a YouTube link.
Add any other context about the problem here.

View File

@@ -38,7 +38,7 @@ jobs:
Copy-Item -Path ./backend/public -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
New-Item -Path ./build/youtubedl-material -Name users -ItemType Directory
New-Item -Path ./build/youtubedl-material -Name users
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
- name: upload build artifact

View File

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

View File

@@ -1,18 +0,0 @@
{
"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,16 +12,14 @@
"custom_args": "",
"safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true,
"max_concurrent_downloads": 5,
"download_rate_limit": ""
"include_metadata": true
},
"Extra": {
"title_top": "YoutubeDL-Material",
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false,
"allow_autoplay": true,
"allow_multi_download_mode": true,
"enable_downloads_manager": true,
"allow_playlist_categorization": true
},
@@ -32,8 +30,7 @@
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false
"twitch_auto_download_chat": false
},
"Themes": {
"default_theme": "default",
@@ -58,7 +55,7 @@
}
},
"Database": {
"use_local_db": true,
"use_local_db": false,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": {
@@ -68,8 +65,8 @@
"multi_user_mode": false,
"allow_advanced_download": false,
"use_cookies": false,
"jwt_expiration": 86400,
"jwt_expiration": 86400,
"logger_level": "info"
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +1,24 @@
var fs = require('fs-extra')
var path = require('path')
const { MongoClient } = require("mongodb");
const { uuid } = require('uuidv4');
const config_api = require('./config');
var utils = require('./utils')
const logger = require('./logger');
const { uuid } = require('uuidv4');
const config_api = require('./config');
const { MongoClient } = require("mongodb");
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync');
const { BehaviorSubject } = require('rxjs');
const local_adapter = new FileSync('./appdata/local_db.json');
const local_db = low(local_adapter);
let database = null;
exports.database_initialized = false;
exports.database_initialized_bs = new BehaviorSubject(false);
var logger = null;
var db = null;
var users_db = null;
var database = null;
const tables = {
files: {
name: 'files',
primary_key: 'uid',
text_search: {
title: 'text',
uploader: 'text',
uid: 'text'
}
primary_key: 'uid'
},
playlists: {
name: 'playlists',
@@ -50,10 +43,6 @@ const tables = {
name: 'roles',
primary_key: 'key'
},
download_queue: {
name: 'download_queue',
primary_key: 'uid'
},
test: {
name: 'test'
}
@@ -73,15 +62,20 @@ function setDB(input_db, input_users_db) {
exports.users_db = input_users_db
}
exports.initialize = (input_db, input_users_db) => {
function setLogger(input_logger) {
logger = input_logger;
}
exports.initialize = (input_db, input_users_db, input_logger) => {
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, custom_connection_string = null) => {
if (using_local_db && !custom_connection_string) return;
if (using_local_db) return;
const success = await exports._connectToDB(custom_connection_string);
if (success) return true;
@@ -137,13 +131,8 @@ exports._connectToDB = async (custom_connection_string = null) => {
tables_list.forEach(async table => {
const primary_key = tables[table]['primary_key'];
if (primary_key) {
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);
}
if (!primary_key) return;
await database.collection(table).createIndex({[primary_key]: 1}, { unique: true });
});
return true;
} catch(err) {
@@ -155,17 +144,51 @@ exports._connectToDB = async (custom_connection_string = null) => {
}
}
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject(file_path, type);
exports.registerFileDB = async (file_path, type, multiUserMode = null, sub = null, customPath = null, category = null, cropFileSettings = null, file_object = null) => {
let db_path = null;
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) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false;
}
utils.fixVideoMetadataPerms(file_path, type);
utils.fixVideoMetadataPerms2(file_path, type);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
file_object['thumbnailPath'] = utils.getDownloadedThumbnail2(file_path, type);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
@@ -182,7 +205,7 @@ exports.registerFileDB = async (file_path, type, user_uid = null, category = nul
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_path, type)
utils.deleteJSONFile2(file_path, type)
}
return file_obj;
@@ -200,12 +223,38 @@ async function registerFileDBManual(file_object) {
return file_object;
}
function generateFileObject(file_path, type) {
var jsonobj = utils.getJSON(file_path, type);
function generateFileObject(id, type, customPath = null, sub = null) {
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;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
}
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(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);
if (!jsonobj) {
return null;
}
const ext = (type === 'audio') ? '.mp3' : '.mp4'
@@ -313,11 +362,10 @@ exports.importUnregisteredFiles = async () => {
const file = files[j];
// check if file exists in db, if not add it
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)));
const file_is_registered = !!(await exports.getRecord('files', {id: file.id, sub_id: dir_to_check.sub_id}))
if (!file_is_registered) {
// add additional info
await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
await exports.registerFileDB2(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}`);
}
}
@@ -325,6 +373,24 @@ 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) => {
try {
const dirs_to_check = await exports.getFileDirectoriesAndDBs();
@@ -453,8 +519,8 @@ exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(__dirname, jsonPath);
altJSONPath = path.join(__dirname, altJSONPath);
jsonPath = path.join(jsonPath);
altJSONPath = path.join(altJSONPath);
let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath);
@@ -554,22 +620,7 @@ exports.insertRecordIntoTable = async (table, doc, replaceFilter = null) => {
return true;
}
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']);
}
if (replaceFilter) await database.collection(table).deleteMany(replaceFilter);
const output = await database.collection(table).insertOne(doc);
logger.debug(`Inserted doc into ${table}`);
@@ -626,28 +677,13 @@ exports.getRecord = async (table, filter_obj) => {
return await database.collection(table).findOne(filter_obj);
}
exports.getRecords = async (table, filter_obj = null, return_count = false, sort = null, range = null) => {
exports.getRecords = async (table, filter_obj = null) => {
// local db override
if (using_local_db) {
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 ? applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').value() : local_db.get(table).value();
}
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();
return filter_obj ? await database.collection(table).find(filter_obj).toArray() : await database.collection(table).find().toArray();
}
// Update
@@ -745,26 +781,26 @@ exports.removeRecord = async (table, filter_obj) => {
return !!(output['result']['ok']);
}
exports.removeAllRecords = async (table = null, filter_obj = null) => {
exports.removeAllRecords = async (table = null) => {
// local db override
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) {
logger.debug(`Removing all records from: ${tables_to_remove}`)
for (let i = 0; i < tables_to_remove.length; i++) {
const table_to_remove = tables_to_remove[i];
if (filter_obj) applyFilterLocalDB(local_db.get(table), filter_obj, 'remove').write();
else local_db.assign({[table_to_remove]: []}).write();
logger.debug(`Successfully removed records from ${table_to_remove}`);
local_db.assign({[table_to_remove]: []}).write();
logger.debug(`Removed all records from ${table_to_remove}`);
}
return true;
}
let success = true;
logger.debug(`Removing all records from: ${tables_to_remove}`)
for (let i = 0; i < tables_to_remove.length; i++) {
const table_to_remove = tables_to_remove[i];
const output = await database.collection(table_to_remove).deleteMany(filter_obj ? filter_obj : {});
logger.debug(`Successfully removed records from ${table_to_remove}`);
const output = await database.collection(table_to_remove).deleteMany({});
logger.debug(`Removed all records from ${table_to_remove}`);
success &= !!(output['result']['ok']);
}
return success;
@@ -952,8 +988,6 @@ exports.transferDB = async (local_to_remote) => {
config_api.setConfigItem('ytdl_use_local_db', using_local_db);
logger.debug('Transfer finished!');
return success;
}
@@ -973,28 +1007,10 @@ const applyFilterLocalDB = (db_path, filter_obj, operation) => {
if (filter_prop_value === undefined || filter_prop_value === 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 {
filtered &= record[filter_prop] === filter_prop_value;
}
filtered &= record[filter_prop] === filter_prop_value;
}
}
return filtered;
});
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);
}
}

View File

@@ -1,606 +0,0 @@
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});
});
}

View File

@@ -1,23 +0,0 @@
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

@@ -1,6 +1,7 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
const url = require('url');
const server = require('./app');
let win;
@@ -8,13 +9,7 @@ function createWindow() {
win = new BrowserWindow({ width: 800, height: 600 });
// load the dist folder from Angular
win.loadURL(
url.format({
pathname: path.join(__dirname, `/dist/index.html`),
protocol: 'file:',
slashes: true
})
);
win.loadURL('http://localhost:17442') //ADD THIS
// The following is optional and will open the DevTools:
// win.webContents.openDevTools()

1587
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,14 @@
"name": "backend",
"version": "1.0.0",
"description": "backend for YoutubeDL-Material",
"main": "index.js",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon app.js",
"debug": "set YTDL_MODE=debug && node app.js"
"debug": "set YTDL_MODE=debug && node app.js",
"electron": "electron main.js",
"pack": "electron-builder --dir",
"dist": "electron-builder"
},
"nodemonConfig": {
"ignore": [
@@ -19,6 +22,13 @@
"restart_general.json"
]
},
"build": {
"appId": "youtubedl.material",
"mac": {
"category": "public.app-category.utilities"
},
"files": ["!audio/*", "!video/*", "!users/*", "!subscriptions/*", "!appdata/*"]
},
"repository": {
"type": "git",
"url": ""
@@ -32,7 +42,6 @@
"dependencies": {
"archiver": "^3.1.1",
"async": "^3.1.0",
"async-mutex": "^0.3.1",
"axios": "^0.21.1",
"bcryptjs": "^2.4.0",
"compression": "^1.7.4",
@@ -66,5 +75,9 @@
"uuidv4": "^6.0.6",
"winston": "^3.2.1",
"youtube-dl": "^3.0.2"
},
"devDependencies": {
"electron": "^13.1.7",
"electron-builder": "^22.11.7"
}
}

View File

@@ -1,21 +1,27 @@
const fs = require('fs-extra');
const path = require('path');
const youtubedl = require('youtube-dl');
const FileSync = require('lowdb/adapters/FileSync')
var fs = require('fs-extra');
const { uuid } = require('uuidv4');
var path = require('path');
var youtubedl = require('youtube-dl');
const config_api = require('./config');
const utils = require('./utils');
const logger = require('./logger');
const twitch_api = require('./twitch');
var utils = require('./utils');
const debugMode = process.env.YTDL_MODE === 'debug';
var logger = null;
var db = null;
var users_db = null;
let db_api = null;
let downloader_api = null;
function setDB(input_db_api) { db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db_api, input_downloader_api) {
function initialize(input_db_api, input_logger) {
setDB(input_db_api);
downloader_api = input_downloader_api;
setLogger(input_logger);
}
async function subscribe(sub, user_uid = null) {
@@ -40,13 +46,13 @@ async function subscribe(sub, user_uid = null) {
sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', sub);
let success = await getSubscriptionInfo(sub);
let success = await getSubscriptionInfo(sub, user_uid);
if (success) {
getVideosForSub(sub, user_uid);
} else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
}
};
result_obj.success = success;
result_obj.sub = sub;
@@ -55,12 +61,18 @@ async function subscribe(sub, user_uid = null) {
}
async function getSubscriptionInfo(sub) {
async function getSubscriptionInfo(sub, user_uid = null) {
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
let downloadConfig = ['--dump-json', '--playlist-end', '1'];
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) {
if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) {
if (await fs.pathExists(path.join('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.');
@@ -102,6 +114,22 @@ async function getSubscriptionInfo(sub) {
}
}
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useArchive && !sub.archive) {
// must create the archive
const archive_dir = path.join(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
resolve(true);
@@ -118,23 +146,9 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let result_obj = { success: false, error: '' };
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.removeAllRecords('files', {sub_id: id});
@@ -171,10 +185,10 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
let filePath = appendedBasePath;
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
var jsonPath = path.join(__dirname,filePath,name+'.info.json');
var videoFilePath = path.join(__dirname,filePath,name+ext);
var imageFilePath = path.join(__dirname,filePath,name+'.jpg');
var altImageFilePath = path.join(__dirname,filePath,name+'.webp');
var jsonPath = path.join(filePath,name+'.info.json');
var videoFilePath = path.join(filePath,name+ext);
var imageFilePath = path.join(filePath,name+'.jpg');
var altImageFilePath = path.join(filePath,name+'.webp');
const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([
fs.pathExists(jsonPath),
@@ -235,15 +249,30 @@ async function getVideosForSub(sub, user_uid = null) {
let appendedBasePath = getAppendedBasePath(sub, basePath);
fs.ensureDirSync(appendedBasePath);
let multiUserMode = null;
if (user_uid) {
multiUserMode = {
user: user_uid,
file_path: appendedBasePath
}
}
const downloadConfig = await generateArgsForSubscription(sub, user_uid);
// get videos
logger.verbose(`Subscription: getting videos for subscription ${sub.name} with args: ${downloadConfig.join(',')}`);
logger.verbose('Subscription: getting videos for subscription ' + sub.name);
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) {
// cleanup
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) {
@@ -251,21 +280,19 @@ async function getVideosForSub(sub, user_uid = null) {
if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
// TODO: reimplement
// const outputs = err.stdout.split(/\r\n|\r|\n/);
// for (let i = 0; i < outputs.length; i++) {
// const output = JSON.parse(outputs[i]);
// await handleOutputJSON(sub, output, i === 0, multiUserMode)
// if (err.stderr.includes(output['id']) && archive_path) {
// // we found a video that errored! add it to the archive to prevent future errors
// if (sub.archive) {
// archive_dir = sub.archive;
// archive_path = path.join(archive_dir, 'archive.txt')
// fs.appendFileSync(archive_path, output['id']);
// }
// }
// }
const outputs = err.stdout.split(/\r\n|\r|\n/);
for (let i = 0; i < outputs.length; i++) {
const output = JSON.parse(outputs[i]);
await handleOutputJSON(sub, output, i === 0, multiUserMode)
if (err.stderr.includes(output['id']) && archive_path) {
// we found a video that errored! add it to the archive to prevent future errors
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
fs.appendFileSync(archive_path, output['id']);
}
}
}
} catch(e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
@@ -278,30 +305,21 @@ async function getVideosForSub(sub, user_uid = null) {
resolve(true);
return;
}
const output_jsons = [];
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
output_jsons.push(output_json);
} catch(e) {
output_json = null;
}
if (!output_json) {
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')) {
await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(sub, user_uid);
@@ -313,28 +331,10 @@ async function getVideosForSub(sub, user_uid = null) {
}, err => {
logger.error(err);
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) {
// get basePath
let basePath = null;
@@ -356,7 +356,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
fullOutput = `${appendedBasePath}/${sub.custom_output}.%(ext)s`;
}
let downloadConfig = ['--dump-json', '-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
let downloadConfig = ['-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
let qualityPath = null;
if (sub.type && sub.type === 'audio') {
@@ -371,7 +371,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...qualityPath)
if (sub.custom_args) {
const customArgsArray = sub.custom_args.split(',,');
customArgsArray = sub.custom_args.split(',,');
if (customArgsArray.indexOf('-f') !== -1) {
// if custom args has a custom quality, replce the original quality with that of custom args
const original_output_index = downloadConfig.indexOf('-f');
@@ -402,7 +402,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) {
if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) {
if (await fs.pathExists(path.join('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.');
@@ -413,37 +413,46 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
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;
}
async function getFilesToDownload(sub, output_jsons) {
const files_to_download = [];
for (let i = 0; i < output_jsons.length; i++) {
const output_json = output_jsons[i];
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);
async function handleOutputJSON(sub, output_json, multiUserMode = null, reset_videos = false) {
// TODO: remove streaming only mode
if (false && sub.streamingOnly) {
if (reset_videos) {
sub_db.assign({videos: []}).write();
}
// 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) {
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
}
@@ -451,7 +460,7 @@ async function getSubscriptions(user_uid = null) {
async function getAllSubscriptions() {
const all_subs = await db_api.getRecords('subscriptions');
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) {
@@ -462,7 +471,7 @@ async function getSubscriptionByName(subName, user_uid = null) {
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
}
async function updateSubscription(sub) {
async function updateSubscription(sub, user_uid = null) {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
return true;
}
@@ -473,7 +482,7 @@ async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
});
}
async function updateSubscriptionProperty(sub, assignment_obj) {
async function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) {
// TODO: combine with updateSubscription
await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj);
return true;
@@ -528,6 +537,7 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
// helper functions
function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
}
@@ -541,6 +551,7 @@ module.exports = {
unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub,
setLogger : setLogger,
initialize : initialize,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple
}

View File

@@ -40,7 +40,7 @@ const subscriptions_api = require('../subscriptions');
const fs = require('fs-extra');
const { uuid } = require('uuidv4');
db_api.initialize(db, users_db);
db_api.initialize(db, users_db, logger);
describe('Database', async function() {
@@ -286,43 +286,5 @@ describe('Multi User', async function() {
// assert(video_obj);
// });
// });
});
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,9 +1,6 @@
const fs = require('fs-extra')
const path = require('path')
const ffmpeg = require('fluent-ffmpeg');
const config_api = require('./config');
const logger = require('./logger');
const CONSTS = require('./consts')
const archiver = require('archiver');
const is_windows = process.platform === 'win32';
@@ -144,7 +141,24 @@ function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
}
function getDownloadedThumbnail(file_path) {
function getDownloadedThumbnail(name, type, customPath = null) {
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);
let jpgPath = file_path_no_extension + '.jpg';
@@ -167,6 +181,10 @@ function getExpectedFileSize(input_info_jsons) {
let expected_filesize = 0;
info_jsons.forEach(info_json => {
if (info_json['filesize']) {
expected_filesize += info_json['filesize'];
return;
}
const formats = info_json['format_id'].split('+');
let individual_expected_filesize = 0;
formats.forEach(format_id => {
@@ -182,7 +200,29 @@ function getExpectedFileSize(input_info_jsons) {
return expected_filesize;
}
function fixVideoMetadataPerms(file_path, type) {
function fixVideoMetadataPerms(name, type, customPath = null) {
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;
const ext = type === 'audio' ? '.mp3' : '.mp4';
@@ -204,7 +244,19 @@ function fixVideoMetadataPerms(file_path, type) {
}
}
function deleteJSONFile(file_path, type) {
function deleteJSONFile(name, type, customPath = null) {
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 file_path_no_extension = removeFileExtension(file_path);
@@ -240,6 +292,7 @@ async function removeIDFromArchive(archive_path, id) {
const updatedData = dataArray.join('\n');
await fs.writeFile(archive_path, updatedData);
if (line) return line;
if (err) throw err;
}
function durationStringToNumber(dur_str) {
@@ -262,11 +315,6 @@ function addUIDsToCategory(category, files) {
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)
{
files = files || (await fs.readdir(base))
@@ -295,53 +343,6 @@ function removeFileExtension(filename) {
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.
* @param {number} ms
@@ -377,20 +378,20 @@ module.exports = {
getJSON: getJSON,
getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail,
getDownloadedThumbnail2: getDownloadedThumbnail2,
getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms,
fixVideoMetadataPerms2: fixVideoMetadataPerms2,
deleteJSONFile: deleteJSONFile,
removeIDFromArchive: removeIDFromArchive,
deleteJSONFile2: deleteJSONFile2,
removeIDFromArchive, removeIDFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber,
getMatchingCategoryFiles: getMatchingCategoryFiles,
addUIDsToCategory: addUIDsToCategory,
getCurrentDownloader: getCurrentDownloader,
recFindByExt: recFindByExt,
removeFileExtension: removeFileExtension,
cropFile: cropFile,
createEdgeNGrams: createEdgeNGrams,
wait: wait,
File: File
}

821
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -23,10 +23,10 @@
<span i18n="Dark mode toggle label">Dark</span>
<mat-slide-toggle class="theme-slide-toggle" [checked]="postsService.theme.key === 'dark'"></mat-slide-toggle>
</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>
<span i18n="Settings menu label">Settings</span>
</button> -->
</button>
<button (click)="openAboutDialog()" mat-menu-item>
<mat-icon>info</mat-icon>
<span i18n="About menu label">About</span>
@@ -42,14 +42,10 @@
<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)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</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.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 && postsService.hasPermission('settings')">
<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 && 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>
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))">
<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>
</ng-container>
</mat-nav-list>

View File

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

View File

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

View File

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

View File

@@ -1,91 +1,27 @@
<div [hidden]="!(downloads && downloads.length > 0)">
<div style="overflow: hidden;" [ngClass]="uids ? 'rounded mat-elevation-z2' : 'mat-elevation-z8'">
<mat-table style="overflow: hidden" [ngClass]="uids ? 'rounded-top' : null" matSort [dataSource]="dataSource">
<!-- Date Column -->
<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 style="padding: 20px;">
<div *ngFor="let session_downloads of downloads">
<ng-container *ngIf="keys(session_downloads).length > 2">
<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>
</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>
<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 || downloads.length === 0) && downloads_retrieved && !uids">
<h4 style="text-align: center; margin-top: 10px;" i18n="No downloads label">No downloads available!</h4>
<div *ngIf="downloads && !downloadsValid()">
<h4 style="text-align: center;" i18n="No downloads label">No downloads available!</h4>
</div>
</div>

View File

@@ -1,32 +0,0 @@
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,13 +1,7 @@
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter } from '@angular/core';
import { Component, OnInit, ViewChildren, QueryList, ElementRef, OnDestroy } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations';
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({
selector: 'app-downloads',
@@ -40,222 +34,138 @@ import { Clipboard } from '@angular/cdk/clipboard';
})
export class DownloadsComponent implements OnInit, OnDestroy {
@Input() uids = null;
downloads_check_interval = 1000;
downloads = [];
finished_downloads = [];
interval_id = null;
keys = Object.keys;
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) => {
const result = b.timestamp_start - a.timestamp_start;
const result = b.value.timestamp_start - a.value.timestamp_start;
return result;
}
constructor(public postsService: PostsService, private router: Router, private dialog: MatDialog, private clipboard: Clipboard) { }
constructor(public postsService: PostsService, private router: Router) { }
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.interval_id = setInterval(() => {
this.getCurrentDownloads();
}, 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(): void {
ngOnDestroy() {
if (this.interval_id) { clearInterval(this.interval_id) }
}
getCurrentDownloads(): void {
this.postsService.getCurrentDownloads(this.uids).subscribe(res => {
this.downloads_retrieved = true;
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']);
getCurrentDownloads() {
this.postsService.getCurrentDownloads().subscribe(res => {
if (res['downloads']) {
this.assignNewValues(res['downloads']);
} else {
// failed to get downloads
}
});
}
clearFinishedDownloads(): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: $localize`Clear finished downloads`,
dialogText: $localize`Would you like to clear your finished downloads?`,
submitText: $localize`Clear`,
warnSubmitColor: true
clearDownload(session_id, download_uid) {
this.postsService.clearDownloads(false, session_id, download_uid).subscribe(res => {
if (res['success']) {
// this.downloads = res['downloads'];
} else {
}
});
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) {
this.postsService.clearDownloads(false, session_id).subscribe(res => {
if (res['success']) {
this.downloads = res['downloads'];
} else {
}
});
}
clearAllDownloads() {
this.postsService.clearDownloads(true).subscribe(res => {
if (res['success']) {
this.downloads = res['downloads'];
} else {
}
});
}
assignNewValues(new_downloads_by_session) {
const session_keys = Object.keys(new_downloads_by_session);
// remove missing session IDs
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
for (let i = 0; i < session_keys.length; i++) {
const session_id = session_keys[i];
const session_downloads_by_id = new_downloads_by_session[session_id];
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]) {
this.downloads[session_id] = session_downloads_by_id;
} else {
for (let j = 0; j < session_download_ids.length; j++) {
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'];
}
});
}
}
});
}
pauseDownload(download_uid: string): void {
this.postsService.pauseDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to pause download! See server logs for more info.');
}
});
}
pauseAllDownloads(): void {
this.postsService.pauseAllDownloads().subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to pause all downloads! See server logs for more info.');
}
});
}
resumeDownload(download_uid: string): void {
this.postsService.resumeDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to resume download! See server logs for more info.');
}
});
}
resumeAllDownloads(): void {
this.postsService.resumeAllDownloads().subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to resume all downloads! See server logs for more info.');
}
});
}
restartDownload(download_uid: string): void {
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 {
this.router.navigate(['/player', {type: download['type'], uid: container['uid']}]);
}
}
combineDownloads(downloads_old, downloads_new) {
// only keeps downloads that exist in the new set
downloads_old = downloads_old.filter(download_old => downloads_new.some(download_new => download_new.uid === download_old.uid));
// add downloads from the new set that the old one doesn't have
const downloads_to_add = downloads_new.filter(download_new => !downloads_old.some(download_old => download_new.uid === download_old.uid));
downloads_old.push(...downloads_to_add);
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 => {
download_old[key] = download_new[key];
});
Object.keys(download_old).forEach(key => {
if (!download_new[key]) delete download_old[key];
});
});
return downloads_old;
downloadsValid() {
let valid = false;
for (let i = 0; i < this.downloads.length; i++) {
const session_downloads = this.downloads[i];
if (!session_downloads) continue;
if (this.keys(session_downloads).length > 2) {
valid = true;
break;
}
}
return valid;
}
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-tab-group style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
<mat-tab-group [(selectedIndex)]="selectedTabIndex">
<mat-tab label="Login">
<div style="margin-top: 10px;">
<mat-form-field>
@@ -11,6 +11,9 @@
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput placeholder="Password">
</mat-form-field>
</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 *ngIf="registrationEnabled" label="Register">
<div style="margin-top: 10px;">
@@ -28,14 +31,9 @@
<input [(ngModel)]="registrationPasswordConfirmationInput" type="password" matInput placeholder="Confirm Password">
</mat-form-field>
</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-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>

View File

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

View File

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

View File

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

View File

@@ -28,13 +28,13 @@
</div>
</div>
<div>
<div class="container" style="margin-bottom: 16px">
<div class="container">
<div class="row justify-content-center">
<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' : '' ]">
<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 *ngIf="paged_data.length === 0">
<div *ngIf="filtered_files.length === 0">
<ng-container i18n="No videos found">No videos found.</ng-container>
</div>
</ng-container>
@@ -46,20 +46,8 @@
</div>
</div>
<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"
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">
</mat-paginator>
</div>
<mat-paginator class="paginator" #paginator *ngIf="filtered_files && filtered_files.length > 0" (page)="pageChangeEvent($event)" [length]="filtered_files.length"
[pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">
</mat-paginator>
</div>

View File

@@ -2,8 +2,6 @@ import { Component, OnInit, ViewChild } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator';
import { Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
@Component({
selector: 'app-recent-videos',
@@ -17,8 +15,8 @@ export class RecentVideosComponent implements OnInit {
normal_files_received = false;
subscription_files_received = false;
file_count = 10;
searchChangedSubject: Subject<string> = new Subject<string>();
files: any[] = null;
filtered_files: any[] = null;
downloading_content = {'video': {}, 'audio': {}};
search_mode = false;
search_text = '';
@@ -52,7 +50,6 @@ export class RecentVideosComponent implements OnInit {
}
};
filterProperty = this.filterProperties['upload_date'];
fileTypeFilter = 'both';
playlists = null;
@@ -95,29 +92,11 @@ export class RecentVideosComponent implements OnInit {
}
});
// set filter property to cached value
// set filter property to cached
const cached_filter_property = localStorage.getItem('filter_property');
if (cached_filter_property && 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() {
@@ -129,45 +108,64 @@ export class RecentVideosComponent implements OnInit {
// search
onSearchInputChanged(newvalue) {
this.normal_files_received = false;
this.searchChangedSubject.next(newvalue);
if (newvalue.length > 0) {
this.search_mode = true;
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) {
this.filterByProperty(value['property']);
localStorage.setItem('filter_property', value['key']);
this.getAllFiles();
}
fileTypeFilterChanged(value) {
localStorage.setItem('file_type_filter', value);
this.getAllFiles();
}
toggleModeChange() {
this.descendingMode = !this.descendingMode;
this.getAllFiles();
this.filterByProperty(this.filterProperty['property']);
}
// get files
getAllFiles(cache_mode = false) {
this.normal_files_received = cache_mode;
const current_file_index = (this.paginator?.pageIndex ? this.paginator.pageIndex : 0)*this.pageSize;
const sort = {by: this.filterProperty['property'], order: this.descendingMode ? -1 : 1};
const range = [current_file_index, current_file_index + this.pageSize];
this.postsService.getAllFiles(sort, range, this.search_mode ? this.search_text : null, this.fileTypeFilter).subscribe(res => {
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];
getAllFiles() {
this.normal_files_received = false;
this.postsService.getAllFiles().subscribe(res => {
this.files = res['files'];
this.files.sort(this.sortFiles);
for (let i = 0; i < this.files.length; i++) {
const file = this.files[i];
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
localStorage.setItem('cached_file_count', '' + this.file_count);
localStorage.setItem('cached_file_count', '' + this.files.length);
this.normal_files_received = true;
this.paged_data = this.filtered_files.slice(0, 10);
});
}
@@ -196,7 +194,7 @@ export class RecentVideosComponent implements OnInit {
// normal subscriptions
!new_tab ? this.router.navigate(['/player', {uid: file.uid,
type: file.isAudio ? 'audio' : 'video', sub_id: sub.id}])
: window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'}`);
: window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'};sub_id=${sub.id}`);
}
} else {
// normal files
@@ -303,9 +301,12 @@ export class RecentVideosComponent implements OnInit {
}
removeFileCard(file_to_remove) {
const index = this.paged_data.map(e => e.uid).indexOf(file_to_remove.uid);
this.paged_data.splice(index, 1);
this.getAllFiles(true);
const index = this.files.map(e => e.uid).indexOf(file_to_remove.uid);
this.files.splice(index, 1);
if (this.search_mode) {
this.filterFiles(this.search_text);
}
this.filterByProperty(this.filterProperty['property']);
}
addFileToPlaylist(info_obj) {
@@ -343,8 +344,7 @@ export class RecentVideosComponent implements OnInit {
}
pageChangeEvent(event) {
this.pageSize = event.pageSize;
this.loading_files = Array(this.pageSize).fill(0);
this.getAllFiles();
const offset = ((event.pageIndex + 1) - 1) * event.pageSize;
this.paged_data = this.filtered_files.slice(offset).slice(0, event.pageSize);
}
}

View File

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

View File

@@ -1,25 +0,0 @@
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

@@ -1,115 +0,0 @@
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 (mouseenter)="onMouseOver()" (mouseleave)="onMouseOut()" (contextmenu)="onRightClick($event)" style="position: relative; width: fit-content;">
<div (mouseover)="elevated=true" (mouseout)="elevated=false" (contextmenu)="onRightClick($event)" style="position: relative; width: fit-content;">
<div *ngIf="!loading" class="download-time">
<mat-icon class="audio-video-icon">{{(file_obj.type === 'audio' || file_obj.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon>
&nbsp;&nbsp;
@@ -51,10 +51,8 @@
<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 *ngIf="!loading && file_obj.thumbnailURL" class="img-div">
<div [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" style="position: relative">
<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 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">
<div class="duration-time">
{{file_length}}
</div>

View File

@@ -51,30 +51,6 @@
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 {
width: 100%;
height: 100%

View File

@@ -35,9 +35,6 @@ export class UnifiedFileCardComponent implements OnInit {
// optional vars
thumbnailBlobURL = null;
streamURL = null;
hide_image = false;
// input/output
@Input() loading = true;
@Input() theme = null;
@@ -75,14 +72,12 @@ export class UnifiedFileCardComponent implements OnInit {
}
if (this.file_obj && this.file_obj.thumbnailPath) {
this.thumbnailBlobURL = `${this.baseStreamPath}thumbnail/${encodeURIComponent(this.file_obj.thumbnailPath)}${this.jwtString}`;
this.thumbnailBlobURL = `${this.baseStreamPath}thumbnail/${this.file_obj.uid}${this.jwtString}`;
/*const mime = getMimeByFilename(this.file_obj.thumbnailPath);
const blob = new Blob([new Uint8Array(this.file_obj.thumbnailBlob.data)], {type: mime});
const bloburl = URL.createObjectURL(blob);
this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl);*/
}
if (this.file_obj) this.streamURL = this.generateStreamURL();
}
emitDeleteFile(blacklistMode = false) {
@@ -133,33 +128,6 @@ export class UnifiedFileCardComponent implements OnInit {
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) {

View File

@@ -11,8 +11,5 @@
<mat-spinner [diameter]="25"></mat-spinner>
</div>
<span class="spacer"></span>
<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>
<button style="float: right;" mat-stroked-button mat-dialog-close>Cancel</button>
</mat-dialog-actions>

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,10 +89,4 @@
display: inline-block;
margin-right: 12px;
top: 8px;
}
.skip-ad-button {
position: absolute;
right: 20px;
bottom: 75px;
}

View File

@@ -4,9 +4,8 @@
<mat-drawer-container style="height: 100%" class="example-container" autosize>
<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'">
<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 [ngClass]="(currentItem.type === 'audio/mp3') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
</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>
</div>
<div style="height: fit-content; width: 100%; margin-top: 10px;">

View File

@@ -1,9 +1,10 @@
import { Component, OnInit, HostListener, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
import { VgApiService } from '@videogular/ngx-videogular/core';
import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
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 { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component';
import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component';
@@ -14,7 +15,6 @@ export interface IMedia {
src: string;
type: string;
label: string;
url: string;
}
@Component({
@@ -133,8 +133,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
title: this.name,
label: this.name,
src: this.url,
type: 'video/mp4',
url: this.url
type: 'video/mp4'
}
this.playlist.push(imedia);
this.currentItem = this.playlist[0];
@@ -166,8 +165,18 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
const subscription = res['subscription'];
this.subscription = subscription;
this.type === this.subscription.type;
this.uids = this.subscription.videos.map(video => video['uid']);
this.parseFileNames();
subscription.videos.forEach(video => {
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();
}
});
}, err => {
this.openSnackBar(`Failed to find subscription ${this.sub_id}`, 'Dismiss');
});
@@ -193,14 +202,9 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
parseFileNames() {
this.playlist = [];
for (let i = 0; i < this.uids.length; i++) {
let file_obj = null;
if (this.playlist_id) {
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 uid = this.uids[i];
const file_obj = this.playlist_id ? this.db_playlist['file_objs'][i] : this.db_file;
const mime_type = file_obj.isAudio ? 'audio/mp3' : 'video/mp4'
@@ -225,8 +229,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
title: file_obj['title'],
src: fullLocation,
type: mime_type,
label: file_obj['title'],
url: file_obj['url']
label: file_obj['title']
}
this.playlist.push(mediaObject);
}
@@ -286,6 +289,13 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.currentItem = item;
}
getFileInfos() {
const fileNames = this.getFileNames();
this.postsService.getFileInfo(fileNames, this.type, false).subscribe(res => {
});
}
getFileNames() {
const fileNames = [];
for (let i = 0; i < this.playlist.length; i++) {
@@ -340,6 +350,22 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
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() {
const dialogRef = this.dialog.open(ShareMediaDialogComponent, {
data: {

View File

@@ -174,7 +174,7 @@ export class PostsService implements CanActivate {
}
// 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, cropFileSettings = null) {
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) {
return this.http.post(this.path + 'downloadFile', {url: url,
selectedHeight: selectedQuality,
customQualityConfiguration: customQualityConfiguration,
@@ -182,6 +182,7 @@ export class PostsService implements CanActivate {
customOutput: customOutput,
youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword,
ui_uid: ui_uid,
type: type,
cropFileSettings: cropFileSettings}, this.httpOptions);
}
@@ -238,8 +239,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'getFile', {uid: uid, type: type, uuid: uuid}, this.httpOptions);
}
getAllFiles(sort, range, text_search, file_type_filter) {
return this.http.post(this.path + 'getAllFiles', {sort: sort, range: range, text_search: text_search, file_type_filter: file_type_filter}, this.httpOptions);
getAllFiles() {
return this.http.post(this.path + 'getAllFiles', {}, this.httpOptions);
}
getFullTwitchChat(id, type, uuid = null, sub = null) {
@@ -295,8 +296,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', params: this.httpOptions.params});
}
getFileFormats(url) {
return this.http.post(this.path + 'getFileFormats', {url: url}, this.httpOptions);
getFileInfo(fileNames, type, urlMode) {
return this.http.post(this.path + 'getVideoInfos', {fileNames: fileNames, type: type, urlMode: urlMode}, this.httpOptions);
}
getLogs(lines = 50) {
@@ -344,6 +345,12 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'updatePlaylist', {playlist: playlist}, this.httpOptions);
}
updatePlaylistFiles(playlist_id, fileNames, type) {
return this.http.post(this.path + 'updatePlaylistFiles', {playlist_id: playlist_id,
fileNames: fileNames,
type: type}, this.httpOptions);
}
addFileToPlaylist(playlist_id, file_uid) {
return this.http.post(this.path + 'addFileToPlaylist', {playlist_id: playlist_id,
file_uid: file_uid},
@@ -413,46 +420,24 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'getSubscriptions', {}, this.httpOptions);
}
getCurrentDownloads(uids = null) {
return this.http.post(this.path + 'downloads', {uids: uids}, this.httpOptions);
// current downloads
getCurrentDownloads() {
return this.http.get(this.path + 'downloads', this.httpOptions);
}
getCurrentDownload(download_uid) {
return this.http.post(this.path + 'download', {download_uid: download_uid}, this.httpOptions);
// current download
getCurrentDownload(session_id, download_id) {
return this.http.post(this.path + 'download', {download_id: download_id, session_id: session_id}, this.httpOptions);
}
pauseDownload(download_uid) {
return this.http.post(this.path + 'pauseDownload', {download_uid: download_uid}, 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);
// clear downloads. download_id is optional, if it exists only 1 download will be cleared
clearDownloads(delete_all = false, session_id = null, download_id = null) {
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);
}
// updates the server to the latest version
updateServer(tag) {
return this.http.post(this.path + 'updateServer', {tag: tag}, this.httpOptions);
}
@@ -526,12 +511,6 @@ export class PostsService implements CanActivate {
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
register(username, password) {
const call = this.http.post(this.path + 'auth/register', {userid: username,
@@ -629,11 +608,6 @@ export class PostsService implements CanActivate {
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 = '') {
this.snackBar.open(message, action, {
duration: 2000,

View File

@@ -1,12 +1,13 @@
<h4 class="settings-title" i18n="Settings title">Settings</h4>
<h4 i18n="Settings title" mat-dialog-title>Settings</h4>
<!-- <ng-container i18n="Allow subscriptions setting"></ng-container> -->
<mat-dialog-content>
<!-- Language
<div style="margin-bottom: 10px;">
</div> -->
<mat-tab-group style="height: 76vh" mat-align-tabs="center">
<mat-tab-group>
<!-- Server -->
<mat-tab label="Main" i18n-label="Main settings label">
<ng-template matTabContent style="padding: 15px;">
@@ -58,7 +59,7 @@
<mat-hint><ng-container i18n="Check interval setting input hint">Unit is seconds, only include numbers.</ng-container></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-4 mb-3">
<div class="col-12 mt-2 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>
</div>
</div>
@@ -110,14 +111,14 @@
</mat-form-field>
</div>
<div class="col-12 mt-3">
<div class="col-12 mt-5">
<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>
<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>
</div>
<div class="col-12 mt-3">
<div class="col-12 mt-4">
<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">
<mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
@@ -127,7 +128,7 @@
</mat-form-field>
</div>
<div class="col-12 mt-3 mb-4">
<div class="col-12 mt-4 mb-5">
<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>
<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>
@@ -141,7 +142,7 @@
<div class="row">
<div class="col-12 mt-3">
<h6 i18n="Categories">Categories</h6>
<div *ngIf="postsService.categories && postsService.categories.length > 0" kDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div class="category-box" *ngFor="let category of postsService.categories" cdkDrag>
<div class="category-custom-placeholder" *cdkDragPlaceholder></div>
{{category['name']}}
@@ -169,32 +170,11 @@
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_thumbnail']"><ng-container i18n="Include thumbnail setting">Include thumbnail</ng-container></mat-checkbox>
</div>
<div class="col-12 mt-2 mb-2">
<div class="col-12 mt-2">
<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>
<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">
<div class="col-12 mt-2">
<button (click)="killAllDownloads()" mat-stroked-button color="warn"><ng-container i18n="Kill all downloads button">Kill all downloads</ng-container></button>
</div>
</div>
@@ -225,7 +205,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>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_autoplay']"><ng-container i18n="Allow autoplay setting">Allow autoplay</ng-container></mat-checkbox>
<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>
</div>
</div>
</div>
@@ -266,15 +246,12 @@
<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>
</div>
<div class="col-12">
<div class="col-12 mb-5">
<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>
<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>
</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>
<mat-divider></mat-divider>
@@ -414,7 +391,7 @@
<app-updater></app-updater>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div *ngIf="new_config" class="container">
<div class="row">
<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>
@@ -424,7 +401,8 @@
</ng-template>
</mat-tab>
<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>
<mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox>
</div>
@@ -468,23 +446,25 @@
</div>
<mat-divider></mat-divider>
</div>
<app-modify-users *ngIf="new_config"></app-modify-users>
<app-modify-users></app-modify-users>
</mat-tab>
<mat-tab *ngIf="postsService.config" label="Logs" i18n-label="Logs settings label">
<ng-template matTabContent>
<div style="margin-top: 15px; height: 84%;">
<div style="margin-left: 48px; margin-top: 24px; height: 340px">
<app-logs-viewer></app-logs-viewer>
</div>
</ng-template>
</mat-tab>
</mat-tab-group>
</mat-dialog-content>
<div class="action-buttons">
<button style="margin-left: 10px; height: 37.3px" 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>
</button>
<button style="margin-left: 10px;" mat-flat-button (click)="cancelSettings()" [disabled]="settingsSame()"><mat-icon>cancel</mat-icon>&nbsp;&nbsp;
<span i18n="Settings cancel button">Cancel</span>
</button>
</div>
<mat-dialog-actions>
<div style="margin-bottom: 10px;">
<button color="accent" (click)="saveSettings()" [disabled]="settingsSame()" mat-raised-button><mat-icon>done</mat-icon>&nbsp;&nbsp;
<ng-container i18n="Settings save button">Save</ng-container>
</button>
<button mat-flat-button [mat-dialog-close]="false"><mat-icon>cancel</mat-icon>&nbsp;&nbsp;
<span i18n="Settings cancel and close button">{settingsAreTheSame + "", select, true {Close} false {Cancel} other {otha}}</span>
</button>
</div>
</mat-dialog-actions>

View File

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

View File

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

View File

@@ -44,6 +44,5 @@
</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="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>
</div>

View File

@@ -67,10 +67,4 @@
.save-icon {
bottom: 1px;
position: relative;
}
.watch-button {
left: 90px;
position: fixed;
bottom: 25px;
}

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import { SubscribeDialogComponent } from 'app/dialogs/subscribe-dialog/subscribe
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
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({
selector: 'app-subscriptions',
@@ -33,8 +32,8 @@ export class SubscriptionsComponent implements OnInit {
});
}
getSubscriptions(show_loading = true) {
if (show_loading) this.subscriptions_loading = true;
getSubscriptions() {
this.subscriptions_loading = true;
this.subscriptions = null;
this.postsService.getAllSubscriptions().subscribe(res => {
this.channel_subscriptions = [];
@@ -103,17 +102,6 @@ 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
public openSnackBar(message: string, action = '') {
this.snackBar.open(message, action, {

View File

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