Compare commits

..

85 Commits

Author SHA1 Message Date
Isaac Abadi
84187b9474 Fixed issue where selecting video quality would
Main component cleanup

Removed deprecated file card component
2021-09-28 20:14:57 -06:00
Isaac Abadi
dbeeb32d48 Updated Dockerfile and entrypoint to use pm2 instead of forever 2021-09-27 18:11:38 -06:00
Isaac Abadi
46087f622e Switched forever.js to pm2
Updated winston
2021-09-26 02:40:05 -06:00
Isaac Abadi
5dd48035fb Improved archive management for subscription downloads
Downloads that fail due to existing in the archive now appears as an error in the manager

Fixed issue where redownloading sub videos wouldn't occur if it was not cleared from the download manager
2021-09-25 22:33:22 -06:00
Isaac Abadi
db53a12635 Added Korean translations and updated source translations file 2021-09-22 21:29:15 -06:00
Tzahi12345
8cd21bf433 Merge pull request #322 from Tzahi12345/dependabot/npm_and_yarn/electron-9.4.0
Bump electron from 8.2.0 to 9.4.0
2021-09-22 20:24:53 -06:00
Tzahi12345
c33acfb3de Merge pull request #447 from Tzahi12345/dependabot/npm_and_yarn/backend/axios-0.21.2
Bump axios from 0.21.1 to 0.21.2 in /backend
2021-09-22 20:24:43 -06:00
Isaac Abadi
562eaa1b9b Added support for generate NFO files for Kodi
Minor UI updates to settings
2021-09-22 19:27:25 -06:00
Isaac Abadi
ec7f04552f Fixed mangled Subject initialization in main component 2021-09-21 23:56:04 -06:00
Isaac Abadi
75fc09ed99 Improved arg simulation -- now uses same method as the actual download
Added checkbox for advanced custom args to either replace all args or append
2021-09-21 23:51:07 -06:00
Isaac Abadi
8aa354ac24 Fixed issue where navigating to a sub's video would play all videos from the subscription 2021-09-21 20:05:09 -06:00
Isaac Abadi
58a0dc4afe Version and commit info is now generated during autobuilds and can be viewed in the about dialog
Prepared removal of JSON translations from repo to move towards XLIFF-only
2021-09-21 20:05:09 -06:00
Glassed Silver
0e37d83740 Merge pull request #455 from GlassedSilver/Readme-Update
Readme update
2021-09-20 00:29:23 +02:00
Isaac Abadi
27faff054e Recent videos component now remembers sort order between page reloads 2021-09-19 14:56:32 -04:00
Isaac Abadi
a71d9f5c7e Added tests for arg generation and laid some plumbing for better arg simulation in the UI 2021-09-19 14:44:02 -04:00
Isaac Abadi
759637c1cf Fixed issue where per-subscription custom args were not being applied 2021-09-19 14:29:12 -04:00
Isaac Abadi
33f23c3ca9 Fixed issue where youtube-dl autoupdates broke if checkExistsWithTimeout failed the first time 2021-09-19 14:24:18 -04:00
GlassedSilver
176c99f813 Reference host-specific instructions 2021-09-18 17:41:25 +02:00
GlassedSilver
f7e0b3e86b [DRAFT!] Bump alpine: pinned '3.12' → 'latest' 2021-09-18 17:22:11 +02:00
GlassedSilver
bd2443b1e9 docker-compose.yml: Use YoutubeDL-Material nightly 2021-09-18 16:59:49 +02:00
Isaac Abadi
f689609941 Fixed missing rxjs import 2021-09-17 01:15:08 -04:00
dependabot[bot]
1e9eec1b55 Bump electron from 8.2.0 to 9.4.0
Bumps [electron](https://github.com/electron/electron) from 8.2.0 to 9.4.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/master/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v8.2.0...v9.4.0)

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-16 19:36:01 +00:00
dependabot[bot]
677af3712b Bump axios from 0.21.1 to 0.21.2 in /backend
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

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

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

Headers in download queue table are now sortable

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

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

Implemented draft test for downloads

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

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

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

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

Cleaned up unused functions in app.js
2021-08-01 14:38:34 -06:00
Tzahi12345
c8f219d5b0 See previous commit, MongoDB is no longer on by default for all installs 2021-08-01 06:24:47 -06:00
Tzahi12345
ec3ab17507 MongoDB is no longer the default in config, this will just be set through the docker-compose.yml 2021-08-01 06:23:27 -06:00
Tzahi12345
5124e3b333 Update issue templates 2021-07-29 21:35:33 -06:00
Isaac Abadi
d09b244bc2 Fixed bug where unsubscribing from a channel would clear the entire files table
Fixed issue where yt-dlp did not work with subscriptions
2021-07-28 19:44:05 -06:00
Isaac Abadi
73b9cf7893 Settings is now a route instead of a dialog 2021-07-26 18:18:07 -07:00
79 changed files with 7086 additions and 4192 deletions

20
.eslintrc.json Normal file
View File

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

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.
Add any other context about the problem here. For example, a YouTube link.

View File

@@ -25,6 +25,23 @@ jobs:
cd backend
npm install
sudo npm install -g @angular/cli
- name: prepare localization
run: |
sudo npm install -g xliff-to-json
xliff-to-json ./src/assets/i18n
- name: Set hash
id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@1.1.2
with:
name: "version.json"
json: '{"type": "autobuild", "tag": "N/A", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: build
run: ng build --prod
- name: prepare artifact upload
@@ -38,7 +55,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
New-Item -Path ./build/youtubedl-material -Name users -ItemType Directory
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
- name: upload build artifact

View File

@@ -13,6 +13,23 @@ jobs:
steps:
- name: checkout code
uses: actions/checkout@v2
- name: prepare localization
run: |
sudo npm install -g xliff-to-json
xliff-to-json ./src/assets/i18n
- name: Set hash
id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@1.1.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "latest", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: setup platform emulator
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build

View File

@@ -10,6 +10,23 @@ jobs:
steps:
- name: checkout code
uses: actions/checkout@v2
- name: prepare localization
run: |
sudo npm install -g xliff-to-json
xliff-to-json ./src/assets/i18n
- name: Set hash
id: vars
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@1.1.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: setup platform emulator
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build

View File

@@ -1,4 +1,4 @@
FROM alpine:3.12 as frontend
FROM alpine:latest as frontend
RUN apk add --no-cache \
npm
@@ -15,14 +15,13 @@ RUN ng build --prod
#--------------#
FROM alpine:3.12
FROM alpine:latest
ENV UID=1000 \
GID=1000 \
USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV FOREVER_ROOT=/app/.forever
RUN addgroup -S $USER -g $GID && adduser -D -S $USER -G $USER -u $UID
@@ -37,7 +36,7 @@ RUN apk add --no-cache \
WORKDIR /app
COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ]
RUN npm install forever -g
RUN npm install pm2 -g
RUN npm install && chown -R $UID:$GID ./
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
@@ -45,4 +44,4 @@ COPY --chown=$UID:$GID [ "/backend/", "/app/" ]
EXPOSE 17442
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "forever", "app.js" ]
CMD [ "pm2-runtime", "pm2.config.js" ]

View File

@@ -77,6 +77,10 @@ Alternatively, you can port forward the port specified in the config (defaults t
## Docker
### Host-specific instructions
If you're on a Synology NAS, unRAID or any other possible special case you can check if there's known issues or instructions both in the issue tracker and in the [Wiki!](https://github.com/Tzahi12345/YoutubeDL-Material/wiki#environment-specific-guideshelp)
### Setup
If you are looking to setup YoutubeDL-Material with Docker, this section is for you. And you're in luck! Docker setup is quite simple.
@@ -124,7 +128,7 @@ Official translators:
* German - UnlimitedCookies
* Chinese - TyRoyal
See also the list of [contributors](https://github.com/your/project/contributors) who participated in this project.
See also the list of [contributors](https://github.com/Tzahi12345/YoutubeDL-Material/graphs/contributors) who participated in this project.
## License

18
backend/.eslintrc.json Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,14 +12,16 @@
"custom_args": "",
"safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true
"include_metadata": true,
"max_concurrent_downloads": 5,
"download_rate_limit": ""
},
"Extra": {
"title_top": "YoutubeDL-Material",
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false,
"allow_multi_download_mode": true,
"allow_autoplay": true,
"enable_downloads_manager": true,
"allow_playlist_categorization": true
},
@@ -30,7 +32,9 @@
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false
},
"Themes": {
"default_theme": "default",
@@ -55,7 +59,7 @@
}
},
"Database": {
"use_local_db": false,
"use_local_db": true,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": {
@@ -65,8 +69,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 fs = require('fs-extra');
const logger = require('../logger');
const jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4');
const bcrypt = require('bcryptjs');
@@ -12,15 +12,13 @@ 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, input_logger) {
setLogger(input_logger)
exports.initialize = function(db_api) {
setDB(db_api);
/*************************
@@ -53,10 +51,6 @@ exports.initialize = function(db_api, input_logger) {
}));
}
function setLogger(input_logger) {
logger = input_logger;
}
function setDB(input_db_api) {
db_api = input_db_api;
}
@@ -140,7 +134,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`); false }
if (!user) { logger.error(`User ${username} not found`); return false }
if (user.auth_method && user.auth_method !== 'internal') { return false }
return await bcrypt.compare(password, user.passhash) ? user : false;
}
@@ -291,17 +285,12 @@ exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false
return file;
}
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, user_files = null) {
exports.getUserPlaylists = async function(user_uid) {
return await db_api.getRecords('playlists', {user_uid: user_uid});
}

View File

@@ -1,17 +1,12 @@
const config_api = require('./config');
const utils = require('./utils');
const logger = require('./logger');
var logger = null;
var db = null;
var users_db = null;
var db_api = null;
function setDB(input_db, input_users_db, input_db_api) { db = input_db; users_db = input_users_db; db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; }
function setDB(input_db_api) { db_api = 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);
function initialize(input_db_api) {
setDB(input_db_api);
}
/*
@@ -72,7 +67,7 @@ async function getCategoriesAsPlaylists(files = null) {
const categories_as_playlists = [];
const available_categories = await getCategories();
if (available_categories && files) {
for (category of available_categories) {
for (let 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;
@@ -125,21 +120,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,3 +1,5 @@
const logger = require('./logger');
const fs = require('fs');
let CONFIG_ITEMS = require('./consts.js')['CONFIG_ITEMS'];
@@ -5,11 +7,7 @@ const debugMode = process.env.YTDL_MODE === 'debug';
let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.json';
var logger = null;
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_logger) {
setLogger(input_logger);
function initialize() {
ensureConfigFileExists();
ensureConfigItemsExist();
}
@@ -97,13 +95,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)) {
if (val === undefined && Object.byString(DEFAULT_CONFIG, path) !== undefined) {
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;
@@ -175,7 +173,7 @@ module.exports = {
globalArgsRequiresSafeDownload: globalArgsRequiresSafeDownload
}
DEFAULT_CONFIG = {
const DEFAULT_CONFIG = {
"YoutubeDLMaterial": {
"Host": {
"url": "http://example.com",
@@ -189,14 +187,16 @@ DEFAULT_CONFIG = {
"custom_args": "",
"safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true
"include_metadata": true,
"max_concurrent_downloads": 5,
"download_rate_limit": ""
},
"Extra": {
"title_top": "YoutubeDL-Material",
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false,
"allow_multi_download_mode": true,
"allow_autoplay": true,
"enable_downloads_manager": true,
"allow_playlist_categorization": true
},
@@ -207,7 +207,9 @@ DEFAULT_CONFIG = {
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false
},
"Themes": {
"default_theme": "default",
@@ -216,7 +218,7 @@ DEFAULT_CONFIG = {
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300",
"subscriptions_check_interval": "86400",
"redownload_fresh_uploads": false
},
"Users": {
@@ -232,7 +234,7 @@ DEFAULT_CONFIG = {
}
},
"Database": {
"use_local_db": false,
"use_local_db": true,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": {

View File

@@ -1,4 +1,4 @@
let CONFIG_ITEMS = {
exports.CONFIG_ITEMS = {
// Host
'ytdl_url': {
'key': 'ytdl_url',
@@ -42,6 +42,14 @@ let 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': {
@@ -60,9 +68,9 @@ let CONFIG_ITEMS = {
'key': 'ytdl_download_only_mode',
'path': 'YoutubeDLMaterial.Extra.download_only_mode'
},
'ytdl_allow_multi_download_mode': {
'key': 'ytdl_allow_multi_download_mode',
'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode'
'ytdl_allow_autoplay': {
'key': 'ytdl_allow_autoplay',
'path': 'YoutubeDLMaterial.Extra.allow_autoplay'
},
'ytdl_enable_downloads_manager': {
'key': 'ytdl_enable_downloads_manager',
@@ -102,6 +110,15 @@ let 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'
},
'ytdl_generate_nfo_files': {
'key': 'ytdl_generate_nfo_files',
'path': 'YoutubeDLMaterial.API.generate_NFO_files'
},
// Themes
'ytdl_default_theme': {
@@ -126,10 +143,6 @@ let 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'
@@ -198,7 +211,7 @@ let CONFIG_ITEMS = {
}
};
AVAILABLE_PERMISSIONS = [
exports.AVAILABLE_PERMISSIONS = [
'filemanager',
'settings',
'subscriptions',
@@ -207,8 +220,6 @@ AVAILABLE_PERMISSIONS = [
'downloads_manager'
];
module.exports = {
CONFIG_ITEMS: CONFIG_ITEMS,
AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS,
CURRENT_VERSION: 'v4.2'
}
exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details'
exports.CURRENT_VERSION = 'v4.2';

View File

@@ -1,24 +1,31 @@
var fs = require('fs-extra')
var path = require('path')
var utils = require('./utils')
const { uuid } = require('uuidv4');
const config_api = require('./config');
const { MongoClient } = require("mongodb");
const { uuid } = require('uuidv4');
const config_api = require('./config');
var utils = require('./utils')
const logger = require('./logger');
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync');
const { BehaviorSubject } = require('rxjs');
const local_adapter = new FileSync('./appdata/local_db.json');
const local_db = low(local_adapter);
var logger = null;
var db = null;
var users_db = null;
var database = null;
let database = null;
exports.database_initialized = false;
exports.database_initialized_bs = new BehaviorSubject(false);
const tables = {
files: {
name: 'files',
primary_key: 'uid'
primary_key: 'uid',
text_search: {
title: 'text',
uploader: 'text',
uid: 'text'
}
},
playlists: {
name: 'playlists',
@@ -43,6 +50,10 @@ const tables = {
name: 'roles',
primary_key: 'key'
},
download_queue: {
name: 'download_queue',
primary_key: 'uid'
},
test: {
name: 'test'
}
@@ -62,20 +73,15 @@ function setDB(input_db, input_users_db) {
exports.users_db = input_users_db
}
function setLogger(input_logger) {
logger = input_logger;
}
exports.initialize = (input_db, input_users_db, input_logger) => {
exports.initialize = (input_db, input_users_db) => {
setDB(input_db, input_users_db);
setLogger(input_logger);
// must be done here to prevent getConfigItem from being called before init
using_local_db = config_api.getConfigItem('ytdl_use_local_db');
}
exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {
if (using_local_db) return;
if (using_local_db && !custom_connection_string) return;
const success = await exports._connectToDB(custom_connection_string);
if (success) return true;
@@ -131,8 +137,13 @@ exports._connectToDB = async (custom_connection_string = null) => {
tables_list.forEach(async table => {
const primary_key = tables[table]['primary_key'];
if (!primary_key) return;
await database.collection(table).createIndex({[primary_key]: 1}, { unique: true });
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);
}
});
return true;
} catch(err) {
@@ -144,51 +155,17 @@ exports._connectToDB = async (custom_connection_string = null) => {
}
}
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);
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);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false;
}
utils.fixVideoMetadataPerms2(file_path, type);
utils.fixVideoMetadataPerms(file_path, type);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail2(file_path, type);
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
@@ -205,7 +182,7 @@ exports.registerFileDB2 = async (file_path, type, user_uid = null, category = nu
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile2(file_path, type)
utils.deleteJSONFile(file_path, type)
}
return file_obj;
@@ -223,39 +200,13 @@ async function registerFileDBManual(file_object) {
return file_object;
}
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;
}
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) {
function generateFileObject(file_path, type) {
var jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) {
return null;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
return null;
}
const ext = (type === 'audio') ? '.mp3' : '.mp4'
const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type);
@@ -266,8 +217,7 @@ function generateFileObject2(file_path, type) {
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 upload_date = utils.formatDateString(jsonobj.upload_date);
var size = stats.size;
@@ -362,10 +312,11 @@ exports.importUnregisteredFiles = async () => {
const file = files[j];
// check if file exists in db, if not add it
const file_is_registered = !!(await exports.getRecord('files', {id: file.id, sub_id: dir_to_check.sub_id}))
const files_with_same_url = await exports.getRecords('files', {url: file.url, sub_id: dir_to_check.sub_id});
const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path)));
if (!file_is_registered) {
// add additional info
await exports.registerFileDB2(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
logger.verbose(`Added discovered file to the database: ${file.id}`);
}
}
@@ -373,24 +324,6 @@ exports.importUnregisteredFiles = async () => {
}
exports.preimportUnregisteredSubscriptionFile = async (sub, appendedBasePath) => {
const preimported_file_paths = [];
const files = await utils.getDownloadedFilesByType(appendedBasePath, sub.type);
for (let i = 0; i < files.length; i++) {
const file = files[i];
// check if file exists in db, if not add it
const file_is_registered = await exports.getRecord('files', {id: file.id, sub_id: sub.id});
if (!file_is_registered) {
// add additional info
await exports.registerFileDB2(file['path'], sub.type, sub.user_uid, null, sub.id, null, file);
preimported_file_paths.push(file['path']);
logger.verbose(`Preemptively added subscription file to the database: ${file.id}`);
}
}
return preimported_file_paths;
}
exports.addMetadataPropertyToDB = async (property_key) => {
try {
const dirs_to_check = await exports.getFileDirectoriesAndDBs();
@@ -519,8 +452,8 @@ exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(jsonPath);
altJSONPath = path.join(altJSONPath);
jsonPath = path.join(__dirname, jsonPath);
altJSONPath = path.join(__dirname, altJSONPath);
let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath);
@@ -620,7 +553,22 @@ exports.insertRecordIntoTable = async (table, doc, replaceFilter = null) => {
return true;
}
if (replaceFilter) await database.collection(table).deleteMany(replaceFilter);
if (replaceFilter) {
const output = await database.collection(table).bulkWrite([
{
deleteMany: {
filter: replaceFilter
}
},
{
insertOne: {
document: doc
}
}
]);
logger.debug(`Inserted doc into ${table} with filter: ${JSON.stringify(replaceFilter)}`);
return !!(output['result']['ok']);
}
const output = await database.collection(table).insertOne(doc);
logger.debug(`Inserted doc into ${table}`);
@@ -677,13 +625,28 @@ exports.getRecord = async (table, filter_obj) => {
return await database.collection(table).findOne(filter_obj);
}
exports.getRecords = async (table, filter_obj = null) => {
exports.getRecords = async (table, filter_obj = null, return_count = false, sort = null, range = null) => {
// local db override
if (using_local_db) {
return filter_obj ? applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').value() : local_db.get(table).value();
let cursor = filter_obj ? applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').value() : local_db.get(table).value();
if (sort) {
cursor = cursor.sort((a, b) => (a[sort['by']] > b[sort['by']] ? sort['order'] : sort['order']*-1));
}
if (range) {
cursor = cursor.slice(range[0], range[1]);
}
return !return_count ? cursor : cursor.length;
}
return filter_obj ? await database.collection(table).find(filter_obj).toArray() : await database.collection(table).find().toArray();
const cursor = filter_obj ? database.collection(table).find(filter_obj) : database.collection(table).find();
if (sort) {
cursor.sort({[sort['by']]: sort['order']});
}
if (range) {
cursor.skip(range[0]).limit(range[1] - range[0]);
}
return !return_count ? await cursor.toArray() : await cursor.count();
}
// Update
@@ -781,26 +744,26 @@ exports.removeRecord = async (table, filter_obj) => {
return !!(output['result']['ok']);
}
exports.removeAllRecords = async (table = null) => {
exports.removeAllRecords = async (table = null, filter_obj = 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];
local_db.assign({[table_to_remove]: []}).write();
logger.debug(`Removed all records from ${table_to_remove}`);
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}`);
}
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({});
logger.debug(`Removed all records from ${table_to_remove}`);
const output = await database.collection(table_to_remove).deleteMany(filter_obj ? filter_obj : {});
logger.debug(`Successfully removed records from ${table_to_remove}`);
success &= !!(output['result']['ok']);
}
return success;
@@ -988,6 +951,8 @@ exports.transferDB = async (local_to_remote) => {
config_api.setConfigItem('ytdl_use_local_db', using_local_db);
logger.debug('Transfer finished!');
return success;
}
@@ -1007,10 +972,28 @@ 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 {
filtered &= record[filter_prop] === filter_prop_value;
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;
}
}
}
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);
}

632
backend/downloader.js Normal file
View File

@@ -0,0 +1,632 @@
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 { create } = require('xmlbuilder2');
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 exports.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 exports.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!
const error_message = `No output received for video download, check if it exists in your archive.`;
await handleDownloadError(download_uid, error_message);
logger.warn(error_message);
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 (config_api.getConfigItem('ytdl_generate_nfo_files')) {
exports.generateNFOFile(output_json, `${filepath_no_extension}.nfo`);
}
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) {
const archive_folder = getArchiveFolder(fileFolderPath, options, download['user_uid']);
const current_merged_archive = fs.readFileSync(path.join(archive_folder, `merged_${type}.txt`), 'utf8');
const diff = current_merged_archive.replace(options.merged_string, '');
const archive_path = path.join(archive_folder, `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
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
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, '--merge-output-format', 'mp4'];
} 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) {
const archive_folder = getArchiveFolder(fileFolderPath, options, user_uid);
const archive_path = path.join(archive_folder, `archive_${type}.txt`);
await fs.ensureDir(archive_folder);
await fs.ensureFile(archive_path);
const blacklist_path = path.join(archive_folder, `blacklist_${type}.txt`);
await fs.ensureFile(blacklist_path);
const merged_path = path.join(archive_folder, `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);
if (!simulated) 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});
});
}
exports.generateNFOFile = (info, output_path) => {
const nfo_obj = {
episodedetails: {
title: info['fulltitle'],
episode: info['playlist_index'] ? info['playlist_index'] : undefined,
premiered: utils.formatDateString(info['upload_date']),
plot: `${info['uploader_url']}\n${info['description']}\n${info['playlist_title'] ? info['playlist_title'] : ''}`,
director: info['artist'] ? info['artist'] : info['uploader']
}
};
const doc = create(nfo_obj);
const xml = doc.end({ prettyPrint: true });
fs.writeFileSync(output_path, xml);
}
function getArchiveFolder(fileFolderPath, options, user_uid) {
if (options.customArchivePath) {
return path.join(options.customArchivePath);
} else if (user_uid) {
return path.join(fileFolderPath, 'archives');
} else {
return path.join(archivePath);
}
}

View File

@@ -0,0 +1,8 @@
module.exports = {
apps : [{
name : "YoutubeDL-Material",
script : "./app.js",
watch : "placeholder",
watch_delay: 5000
}]
}

View File

@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
CMD="forever app.js"
CMD="pm2-runtime pm2.config.js"
# if the first arg starts with "-" pass it to program
if [ "${1#-}" != "$1" ]; then

23
backend/logger.js Normal file
View File

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

1788
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,11 @@
"name": "backend",
"version": "1.0.0",
"description": "backend for YoutubeDL-Material",
"main": "main.js",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon app.js",
"debug": "set YTDL_MODE=debug && node app.js",
"electron": "electron main.js",
"pack": "electron-builder --dir",
"dist": "electron-builder"
"debug": "set YTDL_MODE=debug && node app.js"
},
"nodemonConfig": {
"ignore": [
@@ -22,13 +19,6 @@
"restart_general.json"
]
},
"build": {
"appId": "youtubedl.material",
"mac": {
"category": "public.app-category.utilities"
},
"files": ["!audio/*", "!video/*", "!users/*", "!subscriptions/*", "!appdata/*"]
},
"repository": {
"type": "git",
"url": ""
@@ -42,7 +32,8 @@
"dependencies": {
"archiver": "^3.1.1",
"async": "^3.1.0",
"axios": "^0.21.1",
"async-mutex": "^0.3.1",
"axios": "^0.21.2",
"bcryptjs": "^2.4.0",
"compression": "^1.7.4",
"config": "^3.2.3",
@@ -70,14 +61,12 @@
"progress": "^2.0.3",
"ps-node": "^0.1.6",
"read-last-lines": "^1.7.2",
"rxjs": "^7.3.0",
"shortid": "^2.2.15",
"unzipper": "^0.10.10",
"uuidv4": "^6.0.6",
"winston": "^3.2.1",
"winston": "^3.3.3",
"xmlbuilder2": "^3.0.2",
"youtube-dl": "^3.0.2"
},
"devDependencies": {
"electron": "^13.1.7",
"electron-builder": "^22.11.7"
}
}

7
backend/pm2.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
apps : [{
name : "YoutubeDL-Material",
script : "./app.js",
watch : "placeholder"
}]
}

View File

@@ -1,27 +1,21 @@
const FileSync = require('lowdb/adapters/FileSync')
const fs = require('fs-extra');
const path = require('path');
const youtubedl = require('youtube-dl');
var fs = require('fs-extra');
const { uuid } = require('uuidv4');
var path = require('path');
var youtubedl = require('youtube-dl');
const config_api = require('./config');
const twitch_api = require('./twitch');
var utils = require('./utils');
const utils = require('./utils');
const logger = require('./logger');
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_logger) {
function initialize(input_db_api, input_downloader_api) {
setDB(input_db_api);
setLogger(input_logger);
downloader_api = input_downloader_api;
}
async function subscribe(sub, user_uid = null) {
@@ -46,13 +40,13 @@ async function subscribe(sub, user_uid = null) {
sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', sub);
let success = await getSubscriptionInfo(sub, user_uid);
let success = await getSubscriptionInfo(sub);
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;
@@ -61,18 +55,12 @@ async function subscribe(sub, user_uid = null) {
}
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');
async function getSubscriptionInfo(sub) {
// 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('appdata', 'cookies.txt'))) {
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.');
@@ -114,22 +102,6 @@ async function getSubscriptionInfo(sub, user_uid = null) {
}
}
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useArchive && !sub.archive) {
// must create the archive
const archive_dir = path.join(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);
@@ -146,9 +118,23 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
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});
@@ -185,10 +171,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(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');
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');
const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([
fs.pathExists(jsonPath),
@@ -249,30 +235,15 @@ 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);
logger.verbose(`Subscription: getting videos for subscription ${sub.name} with args: ${downloadConfig.join(',')}`);
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) {
@@ -280,19 +251,21 @@ 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 {
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']);
}
}
}
// 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']);
// }
// }
// }
} catch(e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
@@ -305,21 +278,30 @@ 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);
@@ -331,10 +313,29 @@ 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),
additionalArgs: sub.custom_args
}
return base_download_options;
}
async function generateArgsForSubscription(sub, user_uid, redownload = false, desired_path = null) {
// get basePath
let basePath = null;
@@ -356,7 +357,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
fullOutput = `${appendedBasePath}/${sub.custom_output}.%(ext)s`;
}
let downloadConfig = ['-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
let downloadConfig = ['--dump-json', '-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
let qualityPath = null;
if (sub.type && sub.type === 'audio') {
@@ -371,7 +372,7 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...qualityPath)
if (sub.custom_args) {
customArgsArray = sub.custom_args.split(',,');
const 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 +403,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('appdata', 'cookies.txt'))) {
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.');
@@ -413,46 +414,37 @@ 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 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);
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, finished: false}));
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);
}
}
return files_to_download;
}
async function getSubscriptions(user_uid = null) {
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
}
@@ -460,7 +452,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) {
@@ -471,7 +463,7 @@ async function getSubscriptionByName(subName, user_uid = null) {
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
}
async function updateSubscription(sub, user_uid = null) {
async function updateSubscription(sub) {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
return true;
}
@@ -482,7 +474,7 @@ async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
});
}
async function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) {
async function updateSubscriptionProperty(sub, assignment_obj) {
// TODO: combine with updateSubscription
await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj);
return true;
@@ -537,7 +529,6 @@ 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);
}
@@ -551,7 +542,7 @@ module.exports = {
unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub,
setLogger : setLogger,
initialize : initialize,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple,
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload
}

File diff suppressed because one or more lines are too long

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, logger);
db_api.initialize(db, users_db);
describe('Database', async function() {
@@ -286,5 +286,67 @@ 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 sub_id = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
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() {
});
it('Generate args', async function() {
const args = await downloader_api.generateArgs(url, 'video', options);
console.log(args);
});
it('Generate args - subscription', async function() {
subscriptions_api.initialize(db_api, logger);
const sub = await subscriptions_api.getSubscription(sub_id);
const sub_options = subscriptions_api.generateOptionsForSubscriptionDownload(sub, 'admin');
const args = await downloader_api.generateArgs(url, 'video', sub_options, 'admin');
console.log(args);
});
it('Generate kodi NFO file', async function() {
const nfo_file_path = './test/sample.nfo';
if (fs.existsSync(nfo_file_path)) {
fs.unlinkSync(nfo_file_path);
}
const sample_json = fs.readJSONSync('./test/sample.info.json');
downloader_api.generateNFOFile(sample_json, nfo_file_path);
assert(fs.existsSync(nfo_file_path), true);
});
});

View File

@@ -1,6 +1,9 @@
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';
@@ -42,8 +45,7 @@ async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
files.push(jsonobj);
continue;
}
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)}` : null;
var upload_date = formatDateString(jsonobj.upload_date);
var isaudio = type === 'audio';
var file_obj = new File(id, jsonobj.title, jsonobj.thumbnail, isaudio, jsonobj.duration, jsonobj.webpage_url, jsonobj.uploader,
@@ -141,24 +143,7 @@ function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
}
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) {
function getDownloadedThumbnail(file_path) {
const file_path_no_extension = removeFileExtension(file_path);
let jpgPath = file_path_no_extension + '.jpg';
@@ -181,10 +166,6 @@ 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 => {
@@ -200,29 +181,7 @@ function getExpectedFileSize(input_info_jsons) {
return expected_filesize;
}
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) {
function fixVideoMetadataPerms(file_path, type) {
if (is_windows) return;
const ext = type === 'audio' ? '.mp3' : '.mp4';
@@ -244,19 +203,7 @@ function fixVideoMetadataPerms2(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) {
function deleteJSONFile(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path);
@@ -292,7 +239,6 @@ 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) {
@@ -315,6 +261,11 @@ 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))
@@ -343,6 +294,57 @@ function removeFileExtension(filename) {
return filename_parts.join('.');
}
function formatDateString(date_string) {
return date_string ? `${date_string.substring(0, 4)}-${date_string.substring(4, 6)}-${date_string.substring(6, 8)}` : 'N/A';
}
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
@@ -378,20 +380,21 @@ module.exports = {
getJSON: getJSON,
getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail,
getDownloadedThumbnail2: getDownloadedThumbnail2,
getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms,
fixVideoMetadataPerms2: fixVideoMetadataPerms2,
deleteJSONFile: deleteJSONFile,
deleteJSONFile2: deleteJSONFile2,
removeIDFromArchive, removeIDFromArchive,
removeIDFromArchive: removeIDFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber,
getMatchingCategoryFiles: getMatchingCategoryFiles,
addUIDsToCategory: addUIDsToCategory,
getCurrentDownloader: getCurrentDownloader,
recFindByExt: recFindByExt,
removeFileExtension: removeFileExtension,
formatDateString: formatDateString,
cropFile: cropFile,
createEdgeNGrams: createEdgeNGrams,
wait: wait,
File: File
}

View File

@@ -15,7 +15,7 @@ services:
- ./users:/app/users
ports:
- "8998:17442"
image: tzahi12345/youtubedl-material:latest
image: tzahi12345/youtubedl-material:nightly
ytdl-mongo-db:
image: mongo
ports:

View File

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

986
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@
"@ngneat/content-loader": "^5.0.0",
"@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",
@@ -57,8 +58,11 @@
"@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",
"electron": "^9.4.0",
"eslint": "^7.32.0",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~5.0.0",

View File

@@ -7,14 +7,16 @@ import { SubscriptionComponent } from './subscription/subscription/subscription.
import { PostsService } from './posts.services';
import { 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 },
{ path: 'downloads', component: DownloadsComponent, canActivate: [PostsService] },
{ 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,10 +42,14 @@
<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.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')))">
<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')">
<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,9 +1,6 @@
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';
@@ -16,7 +13,6 @@ 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';
@@ -28,7 +24,11 @@ import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dial
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
styleUrls: ['./app.component.css'],
providers: [{
provide: MatDialogRef,
useValue: {}
}]
})
export class AppComponent implements OnInit, AfterViewInit {
@@ -118,6 +118,10 @@ export class AppComponent implements OnInit, AfterViewInit {
}
this.postsService.reloadCategories();
this.postsService.getVersionInfo().subscribe(res => {
this.postsService.version_info = res['version_info'];
});
}
// theme stuff

View File

@@ -34,7 +34,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { PostsService } from 'app/posts.services';
import { FileCardComponent } from './file-card/file-card.component';
import { RouterModule } from '@angular/router';
import { AppRoutingModule } from './app-routing.module';
import { MainComponent } from './main/main.component';
@@ -87,6 +86,7 @@ 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');
@@ -97,7 +97,6 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
@NgModule({
declarations: [
AppComponent,
FileCardComponent,
MainComponent,
PlayerComponent,
InputDialogComponent,
@@ -136,7 +135,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
EditCategoryDialogComponent,
TwitchChatComponent,
SeeMoreComponent,
ConcurrentStreamComponent
ConcurrentStreamComponent,
SkipAdButtonComponent
],
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.getAllFiles().subscribe(res => {
this.postsService.getPlaylists().subscribe(res => {
this.playlists = res['playlists'];
this.playlists_received = true;
});

View File

@@ -1,27 +1,91 @@
<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>
<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 [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>
<div *ngIf="downloads && !downloadsValid()">
<h4 style="text-align: center;" i18n="No downloads label">No downloads available!</h4>
</div>
<!-- Subscription Column -->
<ng-container matColumnDef="subscription">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Subscription">Subscription</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<ng-container *ngIf="element.sub_name">
{{element.sub_name}}
</ng-container>
<ng-container *ngIf="!element.sub_name">
N/A
</ng-container>
</mat-cell>
</ng-container>
<!-- Stage Column -->
<ng-container matColumnDef="stage">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Stage">Stage</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element"> {{STEP_INDEX_TO_LABEL[element.step_index]}} </mat-cell>
</ng-container>
<!-- Progress Column -->
<ng-container matColumnDef="progress">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Progress">Progress</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<ng-container *ngIf="element.percent_complete">
{{+(element.percent_complete) > 100 ? '100' : element.percent_complete}}%
</ng-container>
<ng-container *ngIf="!element.percent_complete">
N/A
</ng-container>
</mat-cell>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<div>
<ng-container *ngIf="!element.finished">
<button (click)="pauseDownload(element.uid)" *ngIf="!element.paused || !element.finished_step" [disabled]="element.paused && !element.finished_step" mat-icon-button matTooltip="Pause" i18n-matTooltip="Pause"><mat-spinner [diameter]="28" *ngIf="element.paused && !element.finished_step" class="icon-button-spinner"></mat-spinner><mat-icon>pause</mat-icon></button>
<button (click)="resumeDownload(element.uid)" *ngIf="element.paused && element.finished_step" mat-icon-button matTooltip="Resume" i18n-matTooltip="Resume"><mat-icon>play_arrow</mat-icon></button>
<button *ngIf="false && !element.paused" (click)="cancelDownload(element.uid)" mat-icon-button matTooltip="Cancel" i18n-matTooltip="Cancel"><mat-icon>cancel</mat-icon></button>
</ng-container>
<ng-container *ngIf="element.finished">
<button *ngIf="!element.error" (click)="watchContent(element)" mat-icon-button matTooltip="Watch content" i18n-matTooltip="Watch content"><mat-icon>smart_display</mat-icon></button>
<button *ngIf="element.error" (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button>
<button (click)="restartDownload(element.uid)" mat-icon-button matTooltip="Restart" i18n-matTooltip="Restart"><mat-icon>restart_alt</mat-icon></button>
</ng-container>
<button *ngIf="element.finished || element.paused" (click)="clearDownload(element.uid)" mat-icon-button matTooltip="Clear" i18n-matTooltip="Clear"><mat-icon>delete</mat-icon></button>
</div>
</mat-cell>
</ng-container>
<mat-header-row [ngClass]="uids ? 'rounded-top' : null" *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
<mat-paginator [ngClass]="uids ? 'rounded-bottom' : null" [pageSizeOptions]="[5, 10, 20]"
showFirstLastButtons
aria-label="Select page of downloads">
</mat-paginator>
</div>
<div *ngIf="!uids" class="downloads-action-button-div">
<button [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
<button style="margin-left: 10px;" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
<button color="warn" style="margin-left: 10px;" mat-stroked-button (click)="clearFinishedDownloads()"><ng-container i18n="Clear finished downloads">Clear finished downloads</ng-container></button>
</div>
</div>
<div *ngIf="(!downloads || downloads.length === 0) && downloads_retrieved && !uids">
<h4 style="text-align: center; margin-top: 10px;" i18n="No downloads label">No downloads available!</h4>
</div>

View File

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

View File

@@ -1,7 +1,13 @@
import { Component, OnInit, ViewChildren, QueryList, ElementRef, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { 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',
@@ -34,138 +40,222 @@ import { Router } from '@angular/router';
})
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.value.timestamp_start - a.value.timestamp_start;
const result = b.timestamp_start - a.timestamp_start;
return result;
}
constructor(public postsService: PostsService, private router: Router) { }
constructor(public postsService: PostsService, private router: Router, private dialog: MatDialog, private clipboard: Clipboard) { }
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() {
ngOnDestroy(): void {
if (this.interval_id) { clearInterval(this.interval_id) }
}
getCurrentDownloads() {
this.postsService.getCurrentDownloads().subscribe(res => {
if (res['downloads']) {
this.assignNewValues(res['downloads']);
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']);
} else {
// failed to get downloads
}
});
}
clearDownload(session_id, download_uid) {
this.postsService.clearDownloads(false, session_id, download_uid).subscribe(res => {
if (res['success']) {
// this.downloads = res['downloads'];
} else {
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
}
});
}
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'];
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.postsService.clearFinishedDownloads().subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to clear finished downloads!');
}
}
});
}
});
}
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']}]);
}
}
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;
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;
}
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 [(selectedIndex)]="selectedTabIndex">
<mat-tab-group style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
<mat-tab label="Login">
<div style="margin-top: 10px;">
<mat-form-field>
@@ -11,9 +11,6 @@
<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;">
@@ -31,9 +28,14 @@
<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,6 +1,33 @@
.login-card {
max-width: 600px;
max-width: 400px;
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: 275px;">
<div style="height: 100%;">
<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: 274px; overflow-y: auto">
<div style="height: 100%; 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 px-5 pb-4 pt-2">
<div class="table table-responsive 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,5 +1,4 @@
.edit-role {
position: relative;
top: -50px;
left: 35px;
}

View File

@@ -28,13 +28,13 @@
</div>
</div>
<div>
<div class="container">
<div class="container" style="margin-bottom: 16px">
<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="filtered_files.length === 0">
<div *ngIf="paged_data.length === 0">
<ng-container i18n="No videos found">No videos found.</ng-container>
</div>
</ng-container>
@@ -46,8 +46,20 @@
</div>
</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>
<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>
</div>

View File

@@ -2,6 +2,8 @@ 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',
@@ -15,8 +17,8 @@ export class RecentVideosComponent implements OnInit {
normal_files_received = false;
subscription_files_received = false;
files: any[] = null;
filtered_files: any[] = null;
file_count = 10;
searchChangedSubject: Subject<string> = new Subject<string>();
downloading_content = {'video': {}, 'audio': {}};
search_mode = false;
search_text = '';
@@ -50,6 +52,7 @@ export class RecentVideosComponent implements OnInit {
}
};
filterProperty = this.filterProperties['upload_date'];
fileTypeFilter = 'both';
playlists = null;
@@ -92,11 +95,35 @@ export class RecentVideosComponent implements OnInit {
}
});
// set filter property to cached
// set filter property to cached value
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;
}
const sort_order = localStorage.getItem('recent_videos_sort_order');
if (sort_order) {
this.descendingMode = sort_order === 'descending';
}
this.searchChangedSubject
.debounceTime(500)
.pipe(distinctUntilChanged()
).subscribe(model => {
if (model.length > 0) {
this.search_mode = true;
} else {
this.search_mode = false;
}
this.getAllFiles();
});
}
getAllPlaylists() {
@@ -108,64 +135,46 @@ export class RecentVideosComponent implements OnInit {
// search
onSearchInputChanged(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}) };
this.normal_files_received = false;
this.searchChangedSubject.next(newvalue);
}
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.filterByProperty(this.filterProperty['property']);
localStorage.setItem('recent_videos_sort_order', this.descendingMode ? 'descending' : 'ascending');
this.getAllFiles();
}
// get files
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];
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];
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.files.length);
localStorage.setItem('cached_file_count', '' + this.file_count);
this.normal_files_received = true;
this.paged_data = this.filtered_files.slice(0, 10);
});
}
@@ -193,8 +202,8 @@ export class RecentVideosComponent implements OnInit {
} else {
// 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'};sub_id=${sub.id}`);
type: file.isAudio ? 'audio' : 'video'}])
: window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'}`);
}
} else {
// normal files
@@ -301,12 +310,9 @@ export class RecentVideosComponent implements OnInit {
}
removeFileCard(file_to_remove) {
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']);
const index = this.paged_data.map(e => e.uid).indexOf(file_to_remove.uid);
this.paged_data.splice(index, 1);
this.getAllFiles(true);
}
addFileToPlaylist(info_obj) {
@@ -344,7 +350,8 @@ export class RecentVideosComponent implements OnInit {
}
pageChangeEvent(event) {
const offset = ((event.pageIndex + 1) - 1) * event.pageSize;
this.paged_data = this.filtered_files.slice(offset).slice(0, event.pageSize);
this.pageSize = event.pageSize;
this.loading_files = Array(this.pageSize).fill(0);
this.getAllFiles();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,30 @@
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,6 +35,9 @@ export class UnifiedFileCardComponent implements OnInit {
// optional vars
thumbnailBlobURL = null;
streamURL = null;
hide_image = false;
// input/output
@Input() loading = true;
@Input() theme = null;
@@ -72,12 +75,14 @@ export class UnifiedFileCardComponent implements OnInit {
}
if (this.file_obj && this.file_obj.thumbnailPath) {
this.thumbnailBlobURL = `${this.baseStreamPath}thumbnail/${this.file_obj.uid}${this.jwtString}`;
this.thumbnailBlobURL = `${this.baseStreamPath}thumbnail/${encodeURIComponent(this.file_obj.thumbnailPath)}${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) {
@@ -128,6 +133,33 @@ 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

@@ -21,6 +21,17 @@
<mat-icon *ngIf="!checking_for_updates" class="version-checked-icon">done</mat-icon>&nbsp;&nbsp;<ng-container *ngIf="!checking_for_updates && latestGithubRelease['tag_name'] !== current_version_tag"><a [href]="latestUpdateLink" target="_blank"><ng-container i18n="View latest update">Update available</ng-container> - {{latestGithubRelease['tag_name']}}</a>. <ng-container i18n="Update through settings menu hint">You can update from the settings menu.</ng-container></ng-container>
<span *ngIf="!checking_for_updates && latestGithubRelease['tag_name'] === current_version_tag">You are up to date.</span>
</p>
<p>
<ng-container i18n="Installation type">Installation type:</ng-container>&nbsp;{{postsService.version_info.type}}
<br>
<ng-container *ngIf="postsService.version_info.type === 'docker'">
<ng-container i18n="Docker tag">Docker tag:</ng-container>&nbsp;{{postsService.version_info.tag}}
<br>
</ng-container>
<ng-container i18n="Commit hash">Commit hash:</ng-container>&nbsp;{{postsService.version_info.commit}}
<br>
<ng-container i18n="Build date">Build date:</ng-container>&nbsp;{{postsService.version_info.date}}
</p>
<p>
<ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container>&nbsp;<a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a>&nbsp;<ng-container i18n="About bug suffix">to create an issue!</ng-container>
</p>

View File

@@ -19,7 +19,7 @@ export class AboutDialogComponent implements OnInit {
sidepanel_mode = this.postsService.sidepanel_mode;
card_size = this.postsService.card_size;
constructor(private postsService: PostsService) { }
constructor(public postsService: PostsService) { }
ngOnInit(): void {
this.getLatestGithubRelease();

View File

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

View File

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

View File

@@ -1,68 +0,0 @@
.example-card {
width: 150px;
height: 125px;
padding: 0px;
}
.deleteButton {
top:-5px;
right:-5px;
position:absolute;
}
/* Coerce the <span> icon container away from display:inline */
.mat-icon-button .mat-button-wrapper {
display: flex;
justify-content: center;
}
.image {
width: 100%;
}
.example-full-width-height {
width: 100%;
height: 100%
}
.centered {
margin: 0 auto;
top: 50%;
left: 50%;
}
.img-div {
height: 60px;
padding: 0px;
margin: 8px 0px 0px -5px;
width: calc(100% + 5px + 5px);
overflow: hidden;
border-radius: 0px 0px 4px 4px;
}
.max-two-lines {
display: -webkit-box;
display: -moz-box;
max-height: 2.4em;
line-height: 1.2em;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-link {
width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
@media (max-width: 576px){
.example-card {
width: 125px !important;
}
}

View File

@@ -1,29 +0,0 @@
<mat-card class="example-card mat-elevation-z6">
<div style="padding:5px">
<div style="height: 52px;">
<div>
<b><a class="file-link" href="javascript:void(0)" (click)="!playlist ? mainComponent.goToFile(name, isAudio, uid) : mainComponent.goToPlaylist(name, type)">{{title}}</a></b>
</div>
<span class="max-two-lines"><ng-container i18n="File or playlist ID">ID:</ng-container>&nbsp;{{name}}</span>
<div *ngIf="playlist"><ng-container i18n="Playlist video count">Count:</ng-container>&nbsp;{{count}}</div>
</div>
<div *ngIf="!image_errored && thumbnailURL" class="img-div">
<img class="image" (error) ="onImgError($event)" [id]="type" [lazyLoad]="thumbnailURL" [customObservable]="scrollAndLoad" (onLoad)="imageLoaded($event)" alt="Thumbnail">
<span *ngIf="!image_loaded">
</span>
</div>
</div>
<button [matMenuTriggerFor]="playlist_menu" *ngIf="playlist" class="deleteButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #playlist_menu="matMenu">
<button (click)="editPlaylistDialog()" mat-menu-item><mat-icon>edit</mat-icon><ng-container i18n="Playlist edit button">Edit</ng-container></button>
<button (click)="deleteFile()" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete playlist">Delete</ng-container></button>
</mat-menu>
<button [matMenuTriggerFor]="action_menu" *ngIf="!playlist" class="deleteButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #action_menu="matMenu">
<button (click)="openVideoInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
<button (click)="deleteFile()" mat-menu-item><mat-icon>delete</mat-icon><ng-container i18n="Delete video button">Delete</ng-container></button>
<button *ngIf="use_youtubedl_archive" (click)="deleteFile(true)" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete and blacklist video button">Delete and blacklist</ng-container></button>
</mat-menu>
</mat-card>

View File

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

View File

@@ -1,120 +0,0 @@
import { Component, OnInit, Input, Output } from '@angular/core';
import {PostsService} from '../posts.services';
import { MatSnackBar } from '@angular/material/snack-bar';
import {EventEmitter} from '@angular/core';
import { MainComponent } from 'app/main/main.component';
import { Subject, Observable } from 'rxjs';
import 'rxjs/add/observable/merge';
import { MatDialog } from '@angular/material/dialog';
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component';
import { ModifyPlaylistComponent } from '../dialogs/modify-playlist/modify-playlist.component';
@Component({
selector: 'app-file-card',
templateUrl: './file-card.component.html',
styleUrls: ['./file-card.component.css']
})
export class FileCardComponent implements OnInit {
@Input() file: any;
@Input() title: string;
@Input() length: string;
@Input() name: string;
@Input() uid: string;
@Input() thumbnailURL: string;
@Input() isAudio = true;
@Output() removeFile: EventEmitter<string> = new EventEmitter<string>();
@Input() playlist = null;
@Input() count = null;
@Input() use_youtubedl_archive = false;
type;
image_loaded = false;
image_errored = false;
scrollSubject;
scrollAndLoad;
constructor(private postsService: PostsService, public snackBar: MatSnackBar, public mainComponent: MainComponent,
private dialog: MatDialog) {
this.scrollSubject = new Subject();
this.scrollAndLoad = Observable.merge(
Observable.fromEvent(window, 'scroll'),
this.scrollSubject
);
}
ngOnInit() {
this.type = this.isAudio ? 'audio' : 'video';
if (this.file && this.file.url && this.file.url.includes('youtu')) {
const string_id = (this.playlist ? '?list=' : '?v=')
const index_offset = (this.playlist ? 6 : 3);
const end_index = this.file.url.indexOf(string_id) + index_offset;
this.name = this.file.url.substring(end_index, this.file.url.length);
}
}
deleteFile(blacklistMode = false) {
if (!this.playlist) {
this.postsService.deleteFile(this.uid, blacklistMode).subscribe(result => {
if (result) {
this.openSnackBar('Delete success!', 'OK.');
this.removeFile.emit(this.name);
} else {
this.openSnackBar('Delete failed!', 'OK.');
}
}, err => {
this.openSnackBar('Delete failed!', 'OK.');
});
} else {
this.removeFile.emit(this.name);
}
}
openVideoInfoDialog() {
const dialogRef = this.dialog.open(VideoInfoDialogComponent, {
data: {
file: this.file,
},
minWidth: '50vw'
});
}
editPlaylistDialog() {
const dialogRef = this.dialog.open(ModifyPlaylistComponent, {
data: {
playlist_id: this.playlist.id,
width: '65vw'
}
});
dialogRef.afterClosed().subscribe(res => {
// updates playlist in file manager if it changed
if (dialogRef.componentInstance.playlist_updated) {
this.playlist = dialogRef.componentInstance.original_playlist;
this.title = this.playlist.name;
this.count = this.playlist.fileNames.length;
}
});
}
onImgError(event) {
this.image_errored = true;
}
onHoverResponse() {
this.scrollSubject.next();
}
imageLoaded(loaded) {
this.image_loaded = true;
}
public openSnackBar(message: string, action: string) {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

View File

@@ -129,16 +129,22 @@ mat-form-field.mat-form-field {
}
.edit-button {
margin-left: 10px;
margin-left: 5px;
margin-top: -6px;
margin-bottom: -5px;
top: -5px;
}
.border-radius-both {
border-radius: 16px;
}
.no-border-radius-bottom {
border-radius: 4px 4px 0px 0px;
border-radius: 16px 16px 0px 0px;
}
.no-border-radius-top {
border-radius: 0px 0px 4px 4px;
border-radius: 0px 0px 16px 16px;
}
@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' : null">
<mat-card id="card" style="margin-right: 20px; margin-left: 20px;" [ngClass]="(allowAdvancedDownload) ? 'no-border-radius-bottom' : 'border-radius-both'">
<mat-card-content style="padding: 0px 8px 0px 8px;">
<div style="position: relative; margin-right: 15px;">
<form class="example-form">
@@ -19,7 +19,7 @@
Quality
</ng-container>
</mat-label>
<mat-select [ngModelOptions]="{standalone: true}" [(ngModel)]="selectedQuality">
<mat-select [ngModelOptions]="{standalone: true}" [(ngModel)]="selectedQuality" (ngModelChange)="argsChanged($event)">
<mat-option [value]="''">
Max
</mat-option>
@@ -65,9 +65,9 @@
Only Audio
</ng-container>
</mat-checkbox>
<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
<mat-checkbox *ngIf="allowAutoplay" (change)="autoplayChanged($event)" [(ngModel)]="autoplay" style="float: right; margin-top: -12px">
<ng-container i18n="Autoplay checkbox">
Autoplay
</ng-container>
</mat-checkbox>
@@ -111,8 +111,13 @@
</ng-container>
</mat-checkbox>
<button class="edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
<mat-checkbox color="accent" [disabled]="!customArgsEnabled || current_download" (change)="replaceArgsChanged($event)" [(ngModel)]="replaceArgs" style="z-index: 999; margin-left: 10px" [ngModelOptions]="{standalone: true}">
<ng-container i18n="Replace args">
Replace args
</ng-container>
</mat-checkbox>
<mat-form-field color="accent" style="margin-bottom: 42px;" class="advanced-input">
<input [(ngModel)]="customArgs" [ngModelOptions]="{standalone: true}" [disabled]="!customArgsEnabled" matInput placeholder="Custom args" i18n-placeholder="Custom args placeholder">
<input [(ngModel)]="customArgs" [ngModelOptions]="{standalone: true}" [disabled]="!customArgsEnabled" matInput (ngModelChange)="argsChanged()" placeholder="Custom args" i18n-placeholder="Custom args placeholder">
<mat-hint>
<ng-container i18n="Custom Args input hint">
No need to include URL, just everything after. Args are delimited using two commas like so: ,,
@@ -127,7 +132,7 @@
</ng-container>
</mat-checkbox>
<mat-form-field style="margin-bottom: 42px;" color="accent" class="advanced-input">
<input [(ngModel)]="customOutput" [ngModelOptions]="{standalone: true}" [disabled]="!customOutputEnabled" matInput placeholder="Custom output" i18n-placeholder="Custom output placeholder">
<input [(ngModel)]="customOutput" [ngModelOptions]="{standalone: true}" [disabled]="!customOutputEnabled" matInput (ngModelChange)="argsChanged()" placeholder="Custom output" i18n-placeholder="Custom output placeholder">
<mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
<ng-container i18n="Youtube-dl output template documentation link">Documentation</ng-container></a>.
<ng-container i18n="Custom Output input hint">Path is relative to the config download path. Don't include extension.</ng-container>
@@ -140,13 +145,13 @@
Use authentication
</ng-container>
</mat-checkbox>
<mat-form-field color="accent" class="advanced-input">
<input [(ngModel)]="youtubeUsername" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Username" i18n-placeholder="YT Username placeholder">
<mat-form-field *ngIf="youtubeAuthEnabled" color="accent" class="advanced-input">
<input [(ngModel)]="youtubeUsername" [ngModelOptions]="{standalone: true}" matInput (ngModelChange)="argsChanged()" placeholder="Username" i18n-placeholder="YT Username placeholder">
</mat-form-field>
</div>
<div *ngIf="!youtubeAuthDisabledOverride" class="col-12 col-sm-6 mt-3">
<mat-form-field style="margin-top: 31px;" color="accent" class="advanced-input">
<input [(ngModel)]="youtubePassword" type="password" [ngModelOptions]="{standalone: true}" [disabled]="!youtubeAuthEnabled" matInput placeholder="Password" i18n-placeholder="YT Password placeholder">
<mat-form-field *ngIf="youtubeAuthEnabled" style="margin-top: 31px;" color="accent" class="advanced-input">
<input [(ngModel)]="youtubePassword" type="password" [ngModelOptions]="{standalone: true}" matInput (ngModelChange)="argsChanged()" placeholder="Password" i18n-placeholder="YT Password placeholder">
</mat-form-field>
</div>
<div class="col-12 col-sm-6 mt-3">
@@ -155,13 +160,13 @@
Crop file
</ng-container>
</mat-checkbox>
<mat-form-field color="accent" class="advanced-input">
<input [(ngModel)]="cropFileStart" type="number" [ngModelOptions]="{standalone: true}" [disabled]="!cropFile" matInput placeholder="Crop from (seconds)" i18n-placeholder="Crop from placeholder">
<mat-form-field *ngIf="cropFile" color="accent" class="advanced-input">
<input [(ngModel)]="cropFileStart" type="number" [ngModelOptions]="{standalone: true}" matInput placeholder="Crop from (seconds)" i18n-placeholder="Crop from placeholder">
</mat-form-field>
</div>
<div class="col-12 col-sm-6 mt-3">
<mat-form-field style="margin-top: 31px;" color="accent" class="advanced-input">
<input [(ngModel)]="cropFileEnd" type="number" [ngModelOptions]="{standalone: true}" [disabled]="!cropFile" matInput placeholder="Crop to (seconds)" i18n-placeholder="Crop to placeholder">
<mat-form-field *ngIf="cropFile" style="margin-top: 31px;" color="accent" class="advanced-input">
<input [(ngModel)]="cropFileEnd" type="number" [ngModelOptions]="{standalone: true}" matInput placeholder="Crop to (seconds)" i18n-placeholder="Crop to placeholder">
</mat-form-field>
</div>
</div>
@@ -169,20 +174,8 @@
</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 && current_download.downloading; else nofile">
<div class="centered big" id="bar_div" *ngIf="current_download && autoplay">
<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>
@@ -197,9 +190,10 @@
</div>
<br/>
</div>
<ng-template #nofile>
</ng-template>
<div style="display: flex; justify-content: center;" *ngIf="downloads && downloads.length > 0 && !autoplay">
<app-downloads style="width: 80%; margin-bottom: 10px" [uids]="download_uids"></app-downloads>
</div>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">
<app-recent-videos #recentVideos></app-recent-videos>

View File

@@ -1,7 +1,6 @@
import { Component, OnInit, ElementRef, ViewChild, ViewChildren, QueryList } from '@angular/core';
import {PostsService} from '../posts.services';
import {FileCardComponent} from '../file-card/file-card.component';
import { Observable } from 'rxjs';
import { Observable, Subject } from 'rxjs';
import {FormControl, Validators} from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -9,7 +8,6 @@ import { saveAs } from 'file-saver';
import { YoutubeSearchService, Result } from '../youtube-search.service';
import { Router, ActivatedRoute } from '@angular/router';
import { Platform } from '@angular/cdk/platform';
import { v4 as uuid } from 'uuid';
import { ArgModifierDialogComponent } from 'app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component';
import { RecentVideosComponent } from 'app/components/recent-videos/recent-videos.component';
@@ -46,10 +44,11 @@ export class MainComponent implements OnInit {
determinateProgress = false;
downloadingfile = false;
audioOnly: boolean;
multiDownloadMode = false;
autoplay = false;
customArgsEnabled = false;
customArgs = null;
customOutputEnabled = false;
replaceArgs = false;
customOutput = null;
youtubeAuthEnabled = false;
youtubeUsername = null;
@@ -68,7 +67,7 @@ export class MainComponent implements OnInit {
fileManagerEnabled = false;
allowQualitySelect = false;
downloadOnlyMode = false;
allowMultiDownloadMode = false;
allowAutoplay = false;
audioFolderPath;
videoFolderPath;
use_youtubedl_archive = false;
@@ -90,11 +89,11 @@ export class MainComponent implements OnInit {
mp3s: any[] = [];
mp4s: any[] = [];
files_cols = null;
playlists = {'audio': [], 'video': []};
playlist_thumbnails = {};
downloading_content = {'audio': {}, 'video': {}};
downloads: Download[] = [];
download_uids: string[] = [];
current_download: Download = null;
urlForm = new FormControl('', [Validators.required]);
@@ -196,8 +195,6 @@ export class MainComponent implements OnInit {
@ViewChild('urlinput', { read: ElementRef }) urlInput: ElementRef;
@ViewChild('recentVideos') recentVideos: RecentVideosComponent;
@ViewChildren('audiofilecard') audioFileCards: QueryList<FileCardComponent>;
@ViewChildren('videofilecard') videoFileCards: QueryList<FileCardComponent>;
last_valid_url = '';
last_url_check = 0;
@@ -211,6 +208,7 @@ export class MainComponent implements OnInit {
error: false
};
argsChangedSubject: Subject<boolean> = new Subject<boolean>();
simulatedOutput = '';
constructor(public postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
@@ -218,21 +216,19 @@ export class MainComponent implements OnInit {
this.audioOnly = false;
}
async configLoad() {
async configLoad(): Promise<void> {
await this.loadConfig();
if (this.autoStartDownload) {
this.downloadClicked();
}
setInterval(() => this.getSimulatedOutput(), 1000);
}
async loadConfig() {
async loadConfig(): Promise<boolean> {
// loading config
this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled']
&& (!this.postsService.isLoggedIn || this.postsService.permissions.includes('filemanager'));
&& this.postsService.hasPermission('filemanager');
this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode'];
this.allowMultiDownloadMode = this.postsService.config['Extra']['allow_multi_download_mode'];
this.allowAutoplay = this.postsService.config['Extra']['allow_autoplay'];
this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
this.videoFolderPath = this.postsService.config['Downloader']['path-video'];
this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive'];
@@ -242,15 +238,10 @@ 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.isLoggedIn || this.postsService.permissions.includes('advanced_download'));
&& this.postsService.hasPermission('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());
@@ -265,6 +256,10 @@ export class MainComponent implements OnInit {
this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true';
}
if (localStorage.getItem('replaceArgs') !== null) {
this.replaceArgs = localStorage.getItem('replaceArgs') === 'true';
}
if (localStorage.getItem('youtubeAuthEnabled') !== null) {
this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true';
}
@@ -274,9 +269,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
@@ -290,7 +285,7 @@ export class MainComponent implements OnInit {
}
// app initialization.
ngOnInit() {
ngOnInit(): void {
if (this.postsService.initialized) {
this.configLoad();
} else {
@@ -314,8 +309,8 @@ export class MainComponent implements OnInit {
this.audioOnly = localStorage.getItem('audioOnly') === 'true';
}
if (localStorage.getItem('multiDownloadMode') !== null) {
this.multiDownloadMode = localStorage.getItem('multiDownloadMode') === 'true';
if (localStorage.getItem('autoplay') !== null) {
this.autoplay = localStorage.getItem('autoplay') === 'true';
}
// check if params exist
@@ -327,57 +322,24 @@ export class MainComponent implements OnInit {
this.autoStartDownload = true;
}
this.setCols();
this.argsChangedSubject
.debounceTime(500)
.subscribe((should_simulate) => {
if (should_simulate) this.getSimulatedOutput();
});
}
public setCols() {
if (window.innerWidth <= 350) {
this.files_cols = 1;
} else if (window.innerWidth <= 500) {
this.files_cols = 2;
} else if (window.innerWidth <= 750) {
this.files_cols = 3
} else {
this.files_cols = 4;
ngAfterViewInit(): void {
if (this.youtubeSearchEnabled && this.youtubeAPIKey) {
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
this.attachToInput();
}
}
public goToFile(container, isAudio, uid) {
this.downloadHelper(container, isAudio ? 'audio' : 'video', false, false, null, true);
}
public goToPlaylist(playlistID, type) {
const playlist = this.getPlaylistObjectByID(playlistID, type);
if (playlist) {
if (this.downloadOnlyMode) {
this.downloading_content[type][playlistID] = true;
this.downloadPlaylist(playlist);
} else {
localStorage.setItem('player_navigator', this.router.url);
const fileNames = playlist.fileNames;
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID, uid: playlistID}]);
}
} else {
// playlist not found
console.error(`Playlist with ID ${playlistID} not found!`);
}
}
getPlaylistObjectByID(playlistID, type) {
for (let i = 0; i < this.playlists[type].length; i++) {
const playlist = this.playlists[type][i];
if (playlist.id === playlistID) {
return playlist;
}
}
return null;
}
// download helpers
downloadHelper(container, type, is_playlist = false, force_view = false, new_download = null, navigate_mode = false) {
downloadHelper(container, type: string, is_playlist = false, force_view = false, navigate_mode = false): void {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
if (!this.autoplay && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
} else {
@@ -398,13 +360,10 @@ export class MainComponent implements OnInit {
}
}
}
// remove download from current downloads
this.removeDownloadFromCurrentDownloads(new_download);
}
// download click handler
downloadClicked() {
downloadClicked(): void {
if (!this.ValidURL(this.url)) {
this.urlError = true;
return;
@@ -413,7 +372,8 @@ export class MainComponent implements OnInit {
this.urlError = false;
// get common args
const customArgs = (this.customArgsEnabled ? this.customArgs : null);
const customArgs = (this.customArgsEnabled && this.replaceArgs ? this.customArgs : null);
const additionalArgs = (this.customArgsEnabled && !this.replaceArgs ? this.customArgs : null);
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
const youtubeUsername = (this.youtubeAuthEnabled && this.youtubeUsername ? this.youtubeUsername : null);
const youtubePassword = (this.youtubeAuthEnabled && this.youtubePassword ? this.youtubePassword : null);
@@ -432,21 +392,8 @@ 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;
let customQualityConfiguration = type === 'audio' ? this.getSelectedAudioFormat() : this.getSelectedVideoFormat();
const customQualityConfiguration = type === 'audio' ? this.getSelectedAudioFormat() : this.getSelectedVideoFormat();
let cropFileSettings = null;
@@ -457,38 +404,31 @@ export class MainComponent implements OnInit {
}
}
this.postsService.downloadFile(this.url, type, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid, cropFileSettings).subscribe(res => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
const selected_quality = this.selectedQuality;
this.selectedQuality = '';
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 = true;
this.postsService.downloadFile(this.url, type, (selected_quality === '' ? null : selected_quality),
customQualityConfiguration, customArgs, additionalArgs, customOutput, youtubeUsername, youtubePassword, cropFileSettings).subscribe(res => {
this.current_download = res['download'];
this.downloads.push(res['download']);
this.download_uids.push(res['download']['uid']);
}, () => { // 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.');
this.postsService.openSnackBar('Download failed!', 'OK.');
});
if (this.multiDownloadMode) {
if (!this.autoplay) {
const download_queued_message = $localize`Download for ${this.url}:url: has been queued!`;
this.postsService.openSnackBar(download_queued_message);
this.url = '';
this.downloadingfile = false;
}
}
// download canceled handler
cancelDownload(download_to_cancel = null) {
cancelDownload(download_to_cancel = null): void {
// if one is provided, cancel that one. otherwise, remove the current one
if (download_to_cancel) {
this.removeDownloadFromCurrentDownloads(download_to_cancel)
@@ -499,33 +439,32 @@ export class MainComponent implements OnInit {
this.current_download = null;
}
getSelectedAudioFormat() {
getSelectedAudioFormat(): string {
if (this.selectedQuality === '') { return null; }
const cachedFormatsExists = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormatsExists) {
const audio_formats = this.cachedAvailableFormats[this.url]['formats']['audio'];
return this.selectedQuality['format_id'];
} else {
return null;
}
}
getSelectedVideoFormat() {
getSelectedVideoFormat(): string {
if (this.selectedQuality === '') { return null; }
const cachedFormats = this.cachedAvailableFormats[this.url] && this.cachedAvailableFormats[this.url]['formats'];
if (cachedFormats) {
const video_formats = cachedFormats['video'];
if (this.selectedQuality) {
let selected_video_format = this.selectedQuality['format_id'];
// add in audio format if necessary
if (!this.selectedQuality['acodec'] && cachedFormats['best_audio_format']) selected_video_format += `+${cachedFormats['best_audio_format']}`;
const audio_missing = !this.selectedQuality['acodec'] || this.selectedQuality['acodec'] === 'none';
if (audio_missing && cachedFormats['best_audio_format']) selected_video_format += `+${cachedFormats['best_audio_format']}`;
return selected_video_format;
}
}
return null;
}
getDownloadByUID(uid) {
getDownloadByUID(uid: string) {
const index = this.downloads.findIndex(download => download.uid === uid);
if (index !== -1) {
return this.downloads[index];
@@ -534,7 +473,7 @@ export class MainComponent implements OnInit {
}
}
removeDownloadFromCurrentDownloads(download_to_remove) {
removeDownloadFromCurrentDownloads(download_to_remove): boolean {
if (this.current_download === download_to_remove) {
this.current_download = null;
}
@@ -547,7 +486,7 @@ export class MainComponent implements OnInit {
}
}
downloadFileFromServer(file, type) {
downloadFileFromServer(file, type: string): void {
const ext = type === 'audio' ? 'mp3' : 'mp4'
this.downloading_content[type][file.id] = true;
this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
@@ -557,13 +496,12 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(file.uid).subscribe(delRes => {
});
this.postsService.deleteFile(file.uid).subscribe(() => {});
}
});
}
downloadPlaylist(playlist) {
downloadPlaylist(playlist): void {
this.postsService.downloadPlaylistFromServer(playlist.id).subscribe(res => {
if (playlist.id) { this.downloading_content[playlist.type][playlist.id] = false };
const blob: Blob = res;
@@ -572,25 +510,25 @@ export class MainComponent implements OnInit {
}
clearInput() {
clearInput(): void {
this.url = '';
this.results_showing = false;
}
onInputBlur() {
onInputBlur(): void {
this.results_showing = false;
}
visitURL(url) {
visitURL(url: string): void {
window.open(url);
}
useURL(url) {
useURL(url: string): void {
this.results_showing = false;
this.url = url;
}
inputChanged(new_val) {
inputChanged(new_val: string): void {
if (new_val === '' || !new_val) {
this.results_showing = false;
} else {
@@ -601,7 +539,7 @@ export class MainComponent implements OnInit {
}
// checks if url is a valid URL
ValidURL(str) {
ValidURL(str: string): boolean {
// tslint:disable-next-line: max-line-length
const strRegex = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/;
const re = new RegExp(strRegex);
@@ -617,20 +555,14 @@ export class MainComponent implements OnInit {
if (str !== this.last_valid_url && this.allowQualitySelect) {
// get info
this.getURLInfo(str);
this.argsChanged();
}
this.last_valid_url = str;
}
return valid;
}
// snackbar helper
public openSnackBar(message: string, action: string) {
this.snackBar.open(message, action, {
duration: 2000,
});
}
getURLInfo(url) {
getURLInfo(url: string): void {
// if url is a youtube playlist, skip getting url info
if (url.includes('playlist')) {
return;
@@ -640,7 +572,7 @@ export class MainComponent implements OnInit {
}
if (!(this.cachedAvailableFormats[url] && this.cachedAvailableFormats[url]['formats'])) {
this.cachedAvailableFormats[url]['formats_loading'] = true;
this.postsService.getFileInfo([url], 'irrelevant', true).subscribe(res => {
this.postsService.getFileFormats([url]).subscribe(res => {
this.cachedAvailableFormats[url]['formats_loading'] = false;
const infos = res['result'];
if (!infos || !infos.formats) {
@@ -648,94 +580,54 @@ export class MainComponent implements OnInit {
return;
}
this.cachedAvailableFormats[url]['formats'] = this.getAudioAndVideoFormats(infos.formats);
console.log(this.cachedAvailableFormats[url]['formats']);
}, err => {
}, () => {
this.errorFormats(url);
});
}
}
getSimulatedOutput() {
const customArgsExists = this.customArgsEnabled && this.customArgs;
const globalArgsExists = this.globalCustomArgs && this.globalCustomArgs !== '';
getSimulatedOutput(): void {
// this function should be very similar to downloadClicked()
const customArgs = (this.customArgsEnabled && this.replaceArgs ? this.customArgs : null);
const additionalArgs = (this.customArgsEnabled && !this.replaceArgs ? this.customArgs : null);
const customOutput = (this.customOutputEnabled ? this.customOutput : null);
const youtubeUsername = (this.youtubeAuthEnabled && this.youtubeUsername ? this.youtubeUsername : null);
const youtubePassword = (this.youtubeAuthEnabled && this.youtubePassword ? this.youtubePassword : null);
let full_string_array: string[] = [];
const base_string_array = ['youtube-dl', this.url];
const type = this.audioOnly ? 'audio' : 'video';
if (customArgsExists) {
this.simulatedOutput = base_string_array.join(' ') + ' ' + this.customArgs.split(',,').join(' ');
return this.simulatedOutput;
}
const customQualityConfiguration = type === 'audio' ? this.getSelectedAudioFormat() : this.getSelectedVideoFormat();
full_string_array.push(...base_string_array);
let cropFileSettings = null;
const base_path = this.audioOnly ? this.audioFolderPath : this.videoFolderPath;
const ext = this.audioOnly ? '.mp3' : '.mp4';
// gets output
let output_string_array = ['-o', base_path + '%(title)s' + ext];
if (this.customOutputEnabled && this.customOutput) {
output_string_array = ['-o', base_path + this.customOutput + ext];
}
// before pushing output, should check if using an external downloader
if (!this.useDefaultDownloadingAgent && this.customDownloadingAgent === 'aria2c') {
full_string_array.push('--external-downloader', 'aria2c');
}
// pushes output
full_string_array.push(...output_string_array);
// logic splits into audio and video modes
if (this.audioOnly) {
// adds base audio string
const format_array = [];
const audio_format = this.getSelectedAudioFormat();
if (audio_format) {
format_array.push('-f', audio_format);
} else if (this.selectedQuality) {
format_array.push('--audio-quality', this.selectedQuality['format_id']);
if (this.cropFile) {
cropFileSettings = {
cropFileStart: this.cropFileStart,
cropFileEnd: this.cropFileEnd
}
// pushes formats
full_string_array.splice(2, 0, ...format_array);
const additional_params = ['-x', '--audio-format', 'mp3', '--write-info-json', '--print-json'];
full_string_array.push(...additional_params);
} else {
// adds base video string
let format_array = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4'];
const video_format = this.getSelectedVideoFormat();
if (video_format) {
format_array = ['-f', video_format];
} else if (this.selectedQuality) {
format_array = [`bestvideo[height=${this.selectedQuality['format_id']}]+bestaudio/best[height=${this.selectedQuality}]`];
}
// pushes formats
full_string_array.splice(2, 0, ...format_array);
const additional_params = ['--write-info-json', '--print-json'];
full_string_array.push(...additional_params);
}
if (this.use_youtubedl_archive) {
full_string_array.push('--download-archive', 'archive.txt');
}
if (globalArgsExists) {
full_string_array = full_string_array.concat(this.globalCustomArgs.split(',,'));
}
this.simulatedOutput = full_string_array.join(' ');
return this.simulatedOutput;
this.postsService.generateArgs(this.url, type, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, additionalArgs, customOutput, youtubeUsername, youtubePassword, cropFileSettings).subscribe(res => {
const simulated_args = res['args'];
if (simulated_args) {
// hide password if needed
const passwordIndex = simulated_args.indexOf('--password');
console.log(passwordIndex);
if (passwordIndex !== -1 && passwordIndex !== simulated_args.length - 1) {
simulated_args[passwordIndex + 1] = simulated_args[passwordIndex + 1].replace(/./g, '*');
}
this.simulatedOutput = `youtube-dl ${this.url} ${simulated_args.join(' ')}`;
}
});
}
errorFormats(url) {
errorFormats(url: string): void {
this.cachedAvailableFormats[url]['formats_loading'] = false;
console.error('Could not load formats for url ' + url);
}
attachToInput() {
attachToInput(): void {
Observable.fromEvent(this.urlInput.nativeElement, 'keyup')
.map((e: any) => e.target.value) // extract the value of input
.filter((text: string) => text.length > 1) // filter out if empty
@@ -764,52 +656,44 @@ export class MainComponent implements OnInit {
);
}
onResize(event) {
this.setCols();
argsChanged(): void {
this.argsChangedSubject.next(true);
}
videoModeChanged(new_val) {
videoModeChanged(new_val): void {
this.selectedQuality = '';
localStorage.setItem('audioOnly', new_val.checked.toString());
this.argsChanged();
}
multiDownloadModeChanged(new_val) {
localStorage.setItem('multiDownloadMode', new_val.checked.toString());
autoplayChanged(new_val): void {
localStorage.setItem('autoplay', new_val.checked.toString());
}
customArgsEnabledChanged(new_val) {
customArgsEnabledChanged(new_val): void {
localStorage.setItem('customArgsEnabled', new_val.checked.toString());
if (new_val.checked === true && this.customOutputEnabled) {
this.customOutputEnabled = false;
localStorage.setItem('customOutputEnabled', 'false');
this.youtubeAuthEnabled = false;
localStorage.setItem('youtubeAuthEnabled', 'false');
}
this.argsChanged();
}
customOutputEnabledChanged(new_val) {
replaceArgsChanged(new_val): void {
localStorage.setItem('replaceArgs', new_val.checked.toString());
this.argsChanged();
}
customOutputEnabledChanged(new_val): void {
localStorage.setItem('customOutputEnabled', new_val.checked.toString());
if (new_val.checked === true && this.customArgsEnabled) {
this.customArgsEnabled = false;
localStorage.setItem('customArgsEnabled', 'false');
}
this.argsChanged();
}
youtubeAuthEnabledChanged(new_val) {
youtubeAuthEnabledChanged(new_val): void {
localStorage.setItem('youtubeAuthEnabled', new_val.checked.toString());
if (new_val.checked === true && this.customArgsEnabled) {
this.customArgsEnabled = false;
localStorage.setItem('customArgsEnabled', 'false');
}
this.argsChanged();
}
getAudioAndVideoFormats(formats) {
getAudioAndVideoFormats(formats): void {
const audio_formats: any = {};
const video_formats: any = {};
console.log(formats);
for (let i = 0; i < formats.length; i++) {
const format_obj = {type: null};
@@ -865,7 +749,7 @@ export class MainComponent implements OnInit {
return parsed_formats;
}
getBestAudioFormatForMp4(audio_formats) {
getBestAudioFormatForMp4(audio_formats): void {
let best_audio_format_for_mp4 = null;
let best_audio_format_bitrate = 0;
const available_audio_format_keys = Object.keys(audio_formats);
@@ -881,46 +765,8 @@ export class MainComponent implements OnInit {
return best_audio_format_for_mp4;
}
accordionEntered(type) {
if (type === 'audio') {
audioFilesMouseHovering = true;
this.audioFileCards.forEach(filecard => {
filecard.onHoverResponse();
});
} else if (type === 'video') {
videoFilesMouseHovering = true;
this.videoFileCards.forEach(filecard => {
filecard.onHoverResponse();
});
}
}
accordionLeft(type) {
if (type === 'audio') {
audioFilesMouseHovering = false;
} else if (type === 'video') {
videoFilesMouseHovering = false;
}
}
accordionOpened(type) {
if (type === 'audio') {
audioFilesOpened = true;
} else if (type === 'video') {
videoFilesOpened = true;
}
}
accordionClosed(type) {
if (type === 'audio') {
audioFilesOpened = false;
} else if (type === 'video') {
videoFilesOpened = false;
}
}
// modify custom args
openArgsModifierDialog() {
openArgsModifierDialog(): void {
const dialogRef = this.dialog.open(ArgModifierDialogComponent, {
data: {
initial_args: this.customArgs
@@ -933,16 +779,24 @@ export class MainComponent implements OnInit {
});
}
getCurrentDownload() {
getCurrentDownload(): void {
if (!this.current_download) {
return;
}
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 => {
this.postsService.getCurrentDownload(this.current_download['uid']).subscribe(res => {
if (res['download']) {
if (ui_uid === res['download']['ui_uid']) {
this.current_download = res['download'];
this.percentDownloaded = this.current_download.percent_complete;
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.postsService.openSnackBar('Download failed!', 'OK.');
}
} else {
// console.log('failed to get new download');
@@ -950,7 +804,7 @@ export class MainComponent implements OnInit {
});
}
reloadRecentVideos() {
reloadRecentVideos(): void {
this.postsService.files_changed.next(true);
}
}

View File

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

View File

@@ -4,8 +4,9 @@
<mat-drawer-container style="height: 100%" class="example-container" autosize>
<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>
<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>
<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,10 +1,9 @@
import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
import { Component, OnInit, HostListener, OnDestroy, AfterViewInit, ViewChild, ChangeDetectorRef } from '@angular/core';
import { VgApiService } from '@videogular/ngx-videogular/core';
import { 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';
@@ -15,6 +14,7 @@ export interface IMedia {
src: string;
type: string;
label: string;
url: string;
}
@Component({
@@ -133,7 +133,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
title: this.name,
label: this.name,
src: this.url,
type: 'video/mp4'
type: 'video/mp4',
url: this.url
}
this.playlist.push(imedia);
this.currentItem = this.playlist[0];
@@ -165,18 +166,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
const subscription = res['subscription'];
this.subscription = subscription;
this.type === this.subscription.type;
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();
}
});
this.uids = this.subscription.videos.map(video => video['uid']);
this.parseFileNames();
}, err => {
this.openSnackBar(`Failed to find subscription ${this.sub_id}`, 'Dismiss');
});
@@ -202,9 +193,14 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
parseFileNames() {
this.playlist = [];
for (let i = 0; i < this.uids.length; i++) {
const uid = this.uids[i];
const file_obj = this.playlist_id ? this.db_playlist['file_objs'][i] : this.db_file;
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 mime_type = file_obj.isAudio ? 'audio/mp3' : 'video/mp4'
@@ -229,7 +225,8 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
title: file_obj['title'],
src: fullLocation,
type: mime_type,
label: file_obj['title']
label: file_obj['title'],
url: file_obj['url']
}
this.playlist.push(mediaObject);
}
@@ -289,13 +286,6 @@ 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++) {
@@ -350,22 +340,6 @@ 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

@@ -60,6 +60,7 @@ export class PostsService implements CanActivate {
categories = null;
sidenav = null;
locale = isoLangs['en'];
version_info = null;
constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document,
public snackBar: MatSnackBar, private titleService: Title) {
@@ -174,15 +175,28 @@ 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, ui_uid = null, cropFileSettings = null) {
downloadFile(url: string, type: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, additionalArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, cropFileSettings = null) {
return this.http.post(this.path + 'downloadFile', {url: url,
selectedHeight: selectedQuality,
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
additionalArgs: additionalArgs,
customOutput: customOutput,
youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword,
type: type,
cropFileSettings: cropFileSettings}, this.httpOptions);
}
generateArgs(url: string, type: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, additionalArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, cropFileSettings = null) {
return this.http.post(this.path + 'generateArgs', {url: url,
selectedHeight: selectedQuality,
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
additionalArgs: additionalArgs,
customOutput: customOutput,
youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword,
ui_uid: ui_uid,
type: type,
cropFileSettings: cropFileSettings}, this.httpOptions);
}
@@ -239,8 +253,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'getFile', {uid: uid, type: type, uuid: uuid}, this.httpOptions);
}
getAllFiles() {
return this.http.post(this.path + 'getAllFiles', {}, 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);
}
getFullTwitchChat(id, type, uuid = null, sub = null) {
@@ -296,8 +310,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', params: this.httpOptions.params});
}
getFileInfo(fileNames, type, urlMode) {
return this.http.post(this.path + 'getVideoInfos', {fileNames: fileNames, type: type, urlMode: urlMode}, this.httpOptions);
getFileFormats(url) {
return this.http.post(this.path + 'getFileFormats', {url: url}, this.httpOptions);
}
getLogs(lines = 50) {
@@ -345,12 +359,6 @@ 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},
@@ -420,24 +428,50 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'getSubscriptions', {}, this.httpOptions);
}
// current downloads
getCurrentDownloads() {
return this.http.get(this.path + 'downloads', this.httpOptions);
getCurrentDownloads(uids = null) {
return this.http.post(this.path + 'downloads', {uids: uids}, 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);
getCurrentDownload(download_uid) {
return this.http.post(this.path + 'download', {download_uid: download_uid}, 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);
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);
}
getVersionInfo() {
return this.http.get(this.path + 'versionInfo', this.httpOptions);
}
// updates the server to the latest version
updateServer(tag) {
return this.http.post(this.path + 'updateServer', {tag: tag}, this.httpOptions);
}
@@ -511,6 +545,12 @@ 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,
@@ -608,6 +648,11 @@ 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,13 +1,5 @@
<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>
<h4 class="settings-title" i18n="Settings title">Settings</h4>
<mat-tab-group style="height: 76vh" mat-align-tabs="center" [selectedIndex]="tabIndex" (selectedTabChange)="tabChanged($event)">
<!-- Server -->
<mat-tab label="Main" i18n-label="Main settings label">
<ng-template matTabContent style="padding: 15px;">
@@ -59,7 +51,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-2 mb-3">
<div class="col-12 mt-4 mb-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['Subscriptions']['redownload_fresh_uploads']" matTooltip="Sometimes new videos are downloaded before being fully processed. This setting will mean new videos will be checked for a higher quality version the following day." i18n-matTooltip="Redownload fresh uploads tooltip"><ng-container i18n="Redownload fresh uploads">Redownload fresh uploads</ng-container></mat-checkbox>
</div>
</div>
@@ -111,14 +103,14 @@
</mat-form-field>
</div>
<div class="col-12 mt-5">
<div class="col-12 mt-3">
<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-4">
<div class="col-12 mt-3">
<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">
@@ -128,7 +120,7 @@
</mat-form-field>
</div>
<div class="col-12 mt-4 mb-5">
<div class="col-12 mt-3 mb-4">
<mat-form-field class="text-field" style="margin-right: 12px;" color="accent">
<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>
@@ -142,7 +134,7 @@
<div class="row">
<div class="col-12 mt-3">
<h6 i18n="Categories">Categories</h6>
<div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div *ngIf="postsService.categories && postsService.categories.length > 0" kDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div class="category-box" *ngFor="let category of postsService.categories" cdkDrag>
<div class="category-custom-placeholder" *cdkDragPlaceholder></div>
{{category['name']}}
@@ -170,11 +162,32 @@
<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">
<div class="col-12 mt-2 mb-2">
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_metadata']"><ng-container i18n="Include metadata setting">Include metadata</ng-container></mat-checkbox>
</div>
<div class="col-12 mt-2">
</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">
<button (click)="killAllDownloads()" mat-stroked-button color="warn"><ng-container i18n="Kill all downloads button">Kill all downloads</ng-container></button>
</div>
</div>
@@ -205,7 +218,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_multi_download_mode']"><ng-container i18n="Allow multi-download mode setting">Allow multi-download mode</ng-container></mat-checkbox>
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_autoplay']"><ng-container i18n="Allow autoplay setting">Allow autoplay</ng-container></mat-checkbox>
</div>
</div>
</div>
@@ -246,12 +259,18 @@
<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 mb-5">
<div class="col-12">
<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">
<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 class="col-12 mt-2 mb-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['generate_NFO_files']" matTooltip="Generates NFO files with every download, primarily used by Kodi." i18n-matTooltip="Generate NFO files tooltip"><ng-container i18n="Generate NFO files setting">Generate NFO files</ng-container></mat-checkbox>
</div>
</div>
</div>
<mat-divider></mat-divider>
@@ -391,7 +410,7 @@
<app-updater></app-updater>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container">
<div *ngIf="new_config" class="container-fluid">
<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>
@@ -400,71 +419,75 @@
</div>
</ng-template>
</mat-tab>
<mat-tab *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode" label="Users" i18n-label="Users settings label">
<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>
<mat-tab [disabled]="!postsService.config?.Advanced.multi_user_mode">
<ng-template mat-tab-label>
<div [matTooltip]="!postsService.config?.Advanced.multi_user_mode ? usersTabDisabledTooltip : null">
<ng-container i18n="Users settings label">Users</ng-container>
</div>
<mat-divider></mat-divider>
<mat-form-field style="margin-top: 15px;">
<mat-select [(ngModel)]="new_config['Users']['auth_method']" placeholder="Auth method" i18n-placeholder="Auth method select">
<mat-option value="internal">
<ng-container i18n="Internal auth method">Internal</ng-container>
</mat-option>
<mat-option value="ldap">
<ng-container i18n="LDAP auth method">LDAP</ng-container>
</mat-option>
</mat-select>
</mat-form-field>
<div *ngIf="new_config['Users']['auth_method'] === 'ldap'">
</ng-template>
<ng-container *ngIf="postsService.config?.Advanced.multi_user_mode">
<div *ngIf="new_config" style="margin-top: 24px; margin-bottom: -25px;">
<div>
<mat-form-field>
<input matInput i18n-placeholder="LDAP URL" placeholder="LDAP URL" [(ngModel)]="new_config['Users']['ldap_config']['url']">
</mat-form-field>
<mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Bind DN" placeholder="Bind DN" [(ngModel)]="new_config['Users']['ldap_config']['bindDN']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Bind Credentials" placeholder="Bind Credentials" [(ngModel)]="new_config['Users']['ldap_config']['bindCredentials']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Search Base" placeholder="Search Base" [(ngModel)]="new_config['Users']['ldap_config']['searchBase']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Search Filter" placeholder="Search Filter" [(ngModel)]="new_config['Users']['ldap_config']['searchFilter']">
</mat-form-field>
<mat-divider></mat-divider>
<mat-form-field style="margin-top: 15px;">
<mat-select [(ngModel)]="new_config['Users']['auth_method']" placeholder="Auth method" i18n-placeholder="Auth method select">
<mat-option value="internal">
<ng-container i18n="Internal auth method">Internal</ng-container>
</mat-option>
<mat-option value="ldap">
<ng-container i18n="LDAP auth method">LDAP</ng-container>
</mat-option>
</mat-select>
</mat-form-field>
<div *ngIf="new_config['Users']['auth_method'] === 'ldap'">
<div>
<mat-form-field>
<input matInput i18n-placeholder="LDAP URL" placeholder="LDAP URL" [(ngModel)]="new_config['Users']['ldap_config']['url']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Bind DN" placeholder="Bind DN" [(ngModel)]="new_config['Users']['ldap_config']['bindDN']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Bind Credentials" placeholder="Bind Credentials" [(ngModel)]="new_config['Users']['ldap_config']['bindCredentials']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Search Base" placeholder="Search Base" [(ngModel)]="new_config['Users']['ldap_config']['searchBase']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Search Filter" placeholder="Search Filter" [(ngModel)]="new_config['Users']['ldap_config']['searchFilter']">
</mat-form-field>
</div>
</div>
<mat-divider></mat-divider>
</div>
<mat-divider></mat-divider>
</div>
<app-modify-users></app-modify-users>
<app-modify-users *ngIf="new_config"></app-modify-users>
</ng-container>
</mat-tab>
<mat-tab *ngIf="postsService.config" label="Logs" i18n-label="Logs settings label">
<ng-template matTabContent>
<div style="margin-left: 48px; margin-top: 24px; height: 340px">
<div style="margin-top: 15px; height: 84%;">
<app-logs-viewer></app-logs-viewer>
</div>
</ng-template>
</mat-tab>
</mat-tab-group>
</mat-dialog-content>
<mat-dialog-actions>
<div style="margin-bottom: 10px;">
<button color="accent" (click)="saveSettings()" [disabled]="settingsSame()" mat-raised-button><mat-icon>done</mat-icon>&nbsp;&nbsp;
<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>
<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>

View File

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

View File

@@ -12,6 +12,7 @@ import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialo
import { moveItemInArray, CdkDragDrop } from '@angular/cdk/drag-drop';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { EditCategoryDialogComponent } from 'app/dialogs/edit-category-dialog/edit-category-dialog.component';
import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'app-settings',
@@ -20,7 +21,7 @@ import { EditCategoryDialogComponent } from 'app/dialogs/edit-category-dialog/ed
})
export class SettingsComponent implements OnInit {
all_locales = isoLangs;
supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'pt', 'it', 'ca', 'cs', 'nb', 'ru', 'zh', 'id', 'en-GB'];
supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'pt', 'it', 'ca', 'cs', 'nb', 'ru', 'zh', 'ko', 'id', 'en-GB'];
initialLocale = localStorage.getItem('locale');
initial_config = null;
@@ -38,25 +39,48 @@ export class SettingsComponent implements OnInit {
latestGithubRelease = null;
CURRENT_VERSION = CURRENT_VERSION
get settingsAreTheSame() {
tabs = ['main', 'downloader', 'extra', 'database', 'advanced', 'users', 'logs'];
tabIndex = 0;
INDEX_TO_TAB = Object.assign({}, this.tabs);
TAB_TO_INDEX = {};
usersTabDisabledTooltip = $localize`You must enable multi-user mode to access this tab.`;
get settingsAreTheSame(): boolean {
this._settingsSame = this.settingsSame()
return this._settingsSame;
}
set settingsAreTheSame(val) {
set settingsAreTheSame(val: boolean) {
this._settingsSame = val;
}
constructor(public postsService: PostsService, private snackBar: MatSnackBar, private sanitizer: DomSanitizer,
private dialog: MatDialog) { }
private dialog: MatDialog, private router: Router, private route: ActivatedRoute) {
// invert index to tab
Object.keys(this.INDEX_TO_TAB).forEach(key => { this.TAB_TO_INDEX[this.INDEX_TO_TAB[key]] = key; });
}
ngOnInit() {
this.getConfig();
this.getDBInfo();
if (this.postsService.initialized) {
this.getConfig();
this.getDBInfo();
} else {
this.postsService.service_initialized.subscribe(init => {
if (init) {
this.getConfig();
this.getDBInfo();
}
});
}
this.generated_bookmarklet_code = this.sanitizer.bypassSecurityTrustUrl(this.generateBookmarkletCode());
this.getLatestGithubRelease();
const tab = this.route.snapshot.paramMap.get('tab');
this.tabIndex = tab && this.TAB_TO_INDEX[tab] ? this.TAB_TO_INDEX[tab] : 0;
}
getConfig() {
@@ -85,6 +109,15 @@ export class SettingsComponent implements OnInit {
})
}
cancelSettings() {
this.new_config = JSON.parse(JSON.stringify(this.initial_config));
}
tabChanged(event) {
const index = event['index'];
this.router.navigate(['/settings', {tab: this.INDEX_TO_TAB[index]}]);
}
dropCategory(event: CdkDragDrop<string[]>) {
moveItemInArray(this.postsService.categories, event.previousIndex, event.currentIndex);
this.postsService.updateCategories(this.postsService.categories).subscribe(res => {

View File

@@ -44,5 +44,6 @@
</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,4 +67,10 @@
.save-icon {
bottom: 1px;
position: relative;
}
.watch-button {
left: 90px;
position: fixed;
bottom: 25px;
}

View File

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

View File

@@ -14,6 +14,9 @@
<ng-container i18n="Subscription playlist not available text">Name not available. Channel retrieval in progress.</ng-container>
</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,6 +5,7 @@ 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',
@@ -32,8 +33,8 @@ export class SubscriptionsComponent implements OnInit {
});
}
getSubscriptions() {
this.subscriptions_loading = true;
getSubscriptions(show_loading = true) {
if (show_loading) this.subscriptions_loading = true;
this.subscriptions = null;
this.postsService.getAllSubscriptions().subscribe(res => {
this.channel_subscriptions = [];
@@ -102,6 +103,17 @@ export class SubscriptionsComponent implements OnInit {
})
}
editSubscription(sub) {
const dialogRef = this.dialog.open(EditSubscriptionDialogComponent, {
data: {
sub: this.postsService.getSubscriptionByID(sub.id)
}
});
dialogRef.afterClosed().subscribe(() => {
this.getSubscriptions(false);
});
}
// snackbar helper
public openSnackBar(message: string, action = '') {
this.snackBar.open(message, action, {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
{
"004b222ff9ef9dd4771b777950ca1d0e4cd4348a": "대하여",
"994363f08f9fbfa3b3994ff7b35c6904fdff18d8": "프로필",
"adb4562d2dbd3584370e44496969d58c511ecb63": "다크",
"121cc5391cd2a5115bc2b3160379ee5b36cd7716": "설정",
"92eee6be6de0b11c924e3ab27db30257159c0a7c": "홈",
"6765b4c916060f6bc42d9bb69e80377dbcb5e4e9": "로그인",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "구독",
"822fab38216f64e8166d368b59fe756ca39d301b": "다운로드",
"4a9889d36910edc8323d7bab60858ab3da6d91df": "오디오만",
"6a21ba5fb0ac804a525bf9ab168038c3ee88e661": "다운로드",
"a38ae1082fec79ba1f379978337385a539a28e73": "품질",
"4be966a9dcfbc9b54dfcc604b831c0289f847fa4": "URL 이용",
"d3f02f845e62cebd75fde451ab8479d2a8ad784d": "보기",
"96a01fafe135afc58b0f8071a4ab00234495ce18": "복수 다운로드 모드",
"6a3777f913cf3f288664f0632b9f24794fdcc24e": "취소",
"322ed150e02666fe2259c5b4614eac7066f4ffa0": "고급",
"4e4c721129466be9c3862294dc40241b64045998": "사용자 지정 인수 이용",
"ad2f8ac8b7de7945b80c8e424484da94e597125f": "사용자 지정 인수",
"a6911c2157f1b775284bbe9654ce5eb30cf45d7f": "URL을 포함할 필요가 없습니다. 이후의 모든 항목만 포함하면 됩니다. 인수는 다음과 같은 두 개의 쉼표를 사용하여 구분됩니다. : ,,",
"3a92a3443c65a52f37ca7efb8f453b35dbefbf29": "사용자 지정 출력 사용",
"d9c02face477f2f9cdaae318ccee5f89856851fb": "사용자 지정 출력",
"fcfd4675b4c90f08d18d3abede9a9a4dff4cfdc7": "문서",
"19d1ae64d94d28a29b2c57ae8671aace906b5401": "경로는 설정된 다운로드 경로에 상대적입니다. 확장자는 포함하지 마세요.",
"4e1291cb1d579e7b7a1b802e6a8fd16ef7a557fa": "파일 자르기",
"44d007f6f8a2b19f12d85f9e49647b4ac02d7cbe": "자르기 시작지점 (초)",
"661206c3ab91fa81e9d8b40afb29f1866b78432f": "자르기 마무리지점 (초)",
"b7ffe7c6586d6f3f18a9246806a7c7d5538ab43e": "시뮬레이션된 명령:",
"8fad10737d3e3735a6699a4d89cbf6c20f6bb55f": "인증 사용",
"08c74dc9762957593b91f6eb5d65efdfc975bf48": "아이디",
"c32ef07f8803a223a83ed17024b38e8d82292407": "비밀번호",
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "재생목록 만들기",
"cff1428d10d59d14e45edec3c735a27b5482db59": "제목",
"f61c6867295f3b53d23557021f2f4e0aa1d0b8fc": "종류",
"f0baeb8b69d120073b6d60d34785889b0c3232c8": "오디오",
"2d1ea268a6a9f483dbc2cbfe19bf4256a57a6af4": "동영상",
"f47e2d56dd8a145b2e9599da9730c049d52962a2": "오디오 파일",
"a52dae09be10ca3a65da918533ced3d3f4992238": "동영상",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "재생목록이나 채널 구독",
"801b98c6f02fe3b32f6afa3ee854c99ed83474e6": "URL",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "재생목록이나 채널 URL",
"08f5d0ef937ae17feb1b04aff15ad88911e87baf": "사용자 지정 이름",
"ea30873bd3f0d5e4fb2378eec3f0a1db77634a28": "모든 업로드 된 파일 다운로드",
"d641b8fa5ac5e85114c733b1f7de6976bd091f70": "최고 화질",
"c76a955642714b8949ff3e4b4990864a2e2cac95": "오디오 전용 모드",
"408ca4911457e84a348cecf214f02c69289aa8f1": "스트리밍 전용 모드",
"f432e1a8d6adb12e612127978ce2e0ced933959c": "이것들은 일반적인 인수 뒤에 추가됩니다.",
"98b6ec9ec138186d663e64770267b67334353d63": "사용자 지정 파일 출력",
"d7b35c384aecd25a516200d6921836374613dfe7": "취소",
"d0336848b0c375a1c25ba369b3481ee383217a4f": "구독",
"28a678e9cabf86e44c32594c43fa0e890135c20f": "마지막으로 업로드된 동영상 다운로드",
"e78c0d60ac39787f62c9159646fe0b3c1ed55a1d": "종류:",
"c52db455cca9109ee47e1a612c3f4117c09eb71b": "URL:",
"ca3dbbc7f3e011bffe32a10a3ea45cc84f30ecf1": "아이디:",
"f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8": "닫기",
"8efc77bf327659c0fec1f518cf48a98cdcd9dddf": "아카이브 내보내기",
"3042bd3ad8dffcfeca5fd1ae6159fd1047434e95": "구독 취소",
"303e45ffae995c9817e510e38cb969e6bb3adcbf": "(일시정지)",
"a44d86aa1e6c20ced07aca3a7c081d8db9ded1c6": "아카이브:",
"616e206cb4f25bd5885fc35925365e43cf5fb929": "제목:",
"c6eb45d085384903e53ab001a3513d1de6a1dbac": "업로더:",
"109c6f4a5e46efb933612ededfaf52a13178b7e0": "파일 크기:",
"bd630d8669b16e5f264ec4649d9b469fe03e5ff4": "경로:",
"a67e7d843cef735c79d5ef1c8ba4af3e758912bb": "업로드 날짜:",
"0cc1dec590ecd74bef71a865fb364779bc42a749": "카테고리:",
"d9e83ac17026e70ef6e9c0f3240a3b2450367f40": "Youtube-dl 인수 수정",
"7fc1946abe2b40f60059c6cd19975d677095fd19": "시뮬레이션된 새 인수",
"0b71824ae71972f236039bed43f8d2323e8fd570": "인수 추가",
"c8b0e59eb491f2ac7505f0fbab747062e6b32b23": "카테고리로 찾기",
"9eeb91caef5a50256dd87e1c4b7b3e8216479377": "인수 값 이용",
"7de2451ed3fb8d8b847979bd3f0c740b970f167b": "인수 추가",
"b2623aee44b70c9a4ba1fce16c8a593b0a4c7974": "수정",
"25d8ad5eba2ec24e68295a27d6a4bb9b49e3dacd": "인수 값",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "업데이터",
"b7ff2e2b909c53abe088fe60b9f4b6ac7757247f": "사용자 등록",
"024886ca34a6f309e3e51c2ed849320592c3faaa": "아이디",
"cfc2f436ec2beffb042e7511a73c89c372e86a6c": "등록",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "새 쿠키 업로드",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "참고: 새로운 쿠키를 추가하면 이전 쿠키를 덮어씁니다. 또한 쿠키는 사용자 개인이 아닌 전체에 적용됩니다.",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "드래그 앤 드롭",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "재생목록 수정",
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "저장",
"cba36d610ddba59b6dd6fbec77199eabf0ff2de3": "재생할 때 재생목록 섞기",
"5caadefa4143cf6766a621b0f54f91f373a1f164": "콘텐츠 추가",
"33026f57ea65cd9c8a5d917a08083f71a718933a": "기본 순서",
"29376982b1205d9d6ea3d289e8e2f8e1ac2839b1": "순서 거꾸로",
"d02888c485d3aeab6de628508f4a00312a722894": "내 동영상",
"7e892ba15f2c6c17e83510e273b3e10fc32ea016": "검색",
"73423607944a694ce6f9e55cfee329681bb4d9f9": "동영상 없음.",
"3697f8583ea42868aa269489ad366103d94aece7": "수정중",
"07db550ae114d9faad3a0cbb68bcc16ab6cd31fc": "일시정지됨",
"c3b0b86523f1d10e84a71f9b188d54913a11af3b": "카테고리 수정중",
"2489eefea00931942b91f4a1ae109514b591e2e1": "규칙",
"e4eeb9106dbcbc91ca1ac3fb4068915998a70f37": "새로운 규칙 추가",
"792dc6a57f28a1066db283f2e736484f066005fd": "트위치 채팅 다운로드",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "수정",
"826b25211922a1b46436589233cb6f1a163d89b7": "삭제",
"321e4419a943044e674beb55b8039f42a9761ca5": "정보",
"e684046d73bcee88e82f7ff01e2852789a05fc32": "동영상 수:",
"34504b488c24c27e68089be549f0eeae6ebaf30b": "삭제하고 블랙리스트 추가",
"dad95154dcef3509b8cc705046061fd24994bbb7": "조회수",
"4d8a18b04a1f785ecd8021ac824e0dfd5881dbfc": "성공적으로 다운로드 완료",
"348cc5d553b18e862eb1c1770e5636f6b05ba130": "에러 발생",
"4f8b2bb476981727ab34ed40fde1218361f92c45": "세부사항",
"e9aff8e6df2e2bf6299ea27bb2894c70bc48bd4d": "에러 발생:",
"77b0c73840665945b25bd128709aa64c8f017e1c": "다운로드 시작:",
"08ff9375ec078065bcdd7637b7ea65fce2979266": "다운로드 끝:",
"ad127117f9471612f47d01eae09709da444a36a4": "파일 경로(들):",
"e2319dec5b4ccfb6ed9f55ccabd63650a8fdf547": "구독중",
"807cf11e6ac1cde912496f764c176bdfdd6b7e19": "채널",
"47546e45bbb476baaaad38244db444c427ddc502": "재생목록",
"29b89f751593e1b347eef103891b7a1ff36ec03f": "이름이 유효하지 않음. 채널 검색중.",
"4636cd4a1379c50d471e98786098c4d39e1e82ad": "구독중인 채널이 없습니다.",
"2e0a410652cb07d069f576b61eab32586a18320d": "이름이 유효하지 않음. 플레이리스트 검색중.",
"587b57ced54965d8874c3fd0e9dfedb987e5df04": "구독중인 재생목록이 없습니다.",
"82421c3e46a0453a70c42900eab51d58d79e6599": "메인",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "다운로더",
"d5f69691f9f05711633128b5a3db696783266b58": "추가",
"fb324ec7da611c6283caa6fc6257c39a56d6aaf7": "데이터베이스",
"bc2e854e111ecf2bd7db170da5e3c2ed08181d88": "고급",
"4d13a9cd5ed3dcee0eab22cb25198d43886942be": "사용자",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "로그",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {닫다} false {취소} other {기타}}",
"54c512cca1923ab72faf1a0bd98d3d172469629a": "포트를 제외한 이 앱에 접속할 URL.",
"cb2741a46e3560f6bc6dfd99d385e86b08b26d72": "포트",
"22e8f1d0423a3b784fe40fab187b92c06541b577": "포트 설정. 기본 포트는 17442 입니다.",
"d4477669a560750d2064051a510ef4d7679e2f3e": "복수 사용자 모드",
"2eb03565fcdce7a7a67abc277a936a32fcf51557": "사용자 기본 경로",
"a64505c41150663968e277ec9b3ddaa5f4838798": "사용자와 그들의 동영상 다운로드를 위한 기본 경로.",
"4e3120311801c4acd18de7146add2ee4a4417773": "구독 허용",
"4bee2a4bef2d26d37c9b353c278e24e5cd309ce3": "구독 기본 경로",
"bc9892814ee2d119ae94378c905ea440a249b84a": "구독된 채널과 재생목록에서 나온 영상들을 위한 기본 경로. 경로는 YTDL-Material 루트 폴더 경로에 상대적입니다.",
"5bef4b25ba680da7fff06b86a91b1fc7e6a926e3": "확인 간격",
"0f56a7449b77630c114615395bbda4cab398efd8": "단위는 초이며, 숫자만 넣으세요.",
"13759b09a7f4074ceee8fa2f968f9815fdf63295": "가끔 새 동영상이 최고 화질 처리 전에 다운로드 될 때가 있습니다. 이 설정은 새 동영상이 더 높은 화질의 버전이 있는지 다음 날짜에 확인됨을 의미합니다.",
"3d1a47dc18b7bd8b5d9e1eb44b235ed9c4a2b513": "높은 화질 재다운로드",
"27a56aad79d8b61269ed303f11664cc78bcc2522": "테마",
"ff7cee38a2259526c519f878e71b964f41db4348": "기본",
"7a6bacee4c31cb5c0ac2d24274fb4610d8858602": "테마 변경 허용",
"fe46ccaae902ce974e2441abe752399288298619": "언어",
"ab2756805742e84ad0cc0468f4be2d8aa9f855a5": "오디오 폴더 경로",
"c2c89cdf45d46ea64d2ed2f9ac15dfa4d77e26ca": "오디오 전용 다운로드 경로. 경로는 YTDL-Material 루트 폴더 경로에 상대적입니다.",
"46826331da1949bd6fb74624447057099c9d20cd": "동영상 폴더 경로",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "동영상 다운로드 경로. 경로는 YTDL-Material 루트 폴더 경로에 상대적입니다.",
"cfe829634b1144bc44b6d38cf5584ea65db9804f": "기본 파일 출력",
"1148fd45287ff09955b938756bc302042bcb29c7": "경로는 위의 다운로드 경로에 상대적입니다. 확장자는 포함하지 마세요.",
"ef418d4ece7c844f3a5e431da1aa59bedd88da7b": "전반적으로 적용될 사용자 지정 인수",
"6b995e7130b4d667eaab6c5f61b362ace486d26d": "홈페이지에서의 다운로드에 대해 전반적으로 적용될 사용자 지정 인수. 인수는 다음과 같은 두 개의 쉼표를 사용하여 구분됩니다. : ,,",
"04201f9d27abd7d6f58a4328ab98063ce1072006": "카테고리",
"1f6d3986a970af27f16f8a95ce0dc3033cc90a83": "이 설정을 사용하면, 하나의 동영상이 카테고리와 일치할 경우, 전체 재생목록에 해당 카테고리가 표시됩니다.",
"5da94ccb2301f586af26916e921bdad6d673ab58": "재생목록 카테고리화 허용",
"78e49b7339b4fa7184dd21bcaae107ce9b7076f6": "Youtube-dl 아카이브 사용",
"ffc19f32b1cba0daefc0e5668f89346db1db83ad": "썸네일 포함",
"384de8f8f112c9e6092eb2698706d391553f3e8d": "메타데이터 포함",
"fb35145bfb84521e21b6385363d59221f436a573": "모든 다운로드 종료",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "상위 제목",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "파일 매니저 설정됨",
"a5a1be0a5df07de9eec57f5d2a86ed0204b2e75a": "다운로드 매니저 설정됨",
"c33bd5392b39dbed36b8e5a1145163a15d45835f": "화질 선택 허용",
"bda5508e24e0d77debb28bcd9194d8fefb1cfb92": "다운로드 전용 모드",
"09d31c803a7252658694e1e3176b97f5655a3fe3": "복수 다운로드 모드 허용",
"1c4dbce56d96b8974aac24a02f7ab2ee81415014": "오픈 API 허용",
"23bd81dcc30b74d06279a26d7a42e8901c1b124e": "오픈 API 키",
"41016a73d8ad85e6cb26dffa0a8fab9fe8f60d8e": "문서 보기",
"00a94f58d9eb2e3aa561440eabea616d0c937fa2": "이것은 예전 API키를 지울 것입니다!",
"1b258b258b4cc475ceb2871305b61756b0134f4a": "생성",
"d5d7c61349f3b0859336066e6d453fc35d334fe5": "유튜브 API 사용",
"ce10d31febb3d9d60c160750570310f303a22c22": "유튜브 API 키",
"8602e313cdfa7c4cc475ccbe86459fce3c3fd986": "키를 만드는 것은 쉽습니다!",
"d162f9fcd6a7187b391e004f072ab3da8377c47d": "트위치 API 사용",
"8ae23bc4302a479f687f4b20a84c276182e2519c": "트위치 API 키",
"84ffcebac2709ca0785f4a1d5ba274433b5beabc": "클라이언트 ID라고도 알려져 있음.",
"5fb1e0083c9b2a40ac8ae7dcb2618311c291b8b9": "트위치 채팅 자동 다운로드",
"9b3cedfa83c6d7acb3210953289d1be4aab115c7": "이곳을 누르세요",
"7f09776373995003161235c0c8d02b7f91dbc4df": "공식 YoutubeDL-Material 크롬 확장 프로그램을 수동으로 다운로드 하기 위해.",
"5b5296423906ab3371fdb2b5a5aaa83acaa2ee52": "반드시 확장 프로그램을 수동으로 실행하고 확장 프로그램 설정을 수정하여 프론트엔드 URL을 설정해야 합니다.",
"9a2ec6da48771128384887525bdcac992632c863": "파이어폭스 확장 프로그램 페이지에서 바로 공식 YoutubeDL-Material 파이어폭스 확장 프로그램을 설치하기 위해.",
"eb81be6b49e195e5307811d1d08a19259d411f37": "자세한 설정 지침.",
"cb17ff8fe3961cf90f44bee97c88a3f3347a7e55": "프론트엔드 URL을 설정하기 위해 확장 프로그램 설정을 변경하는 것 외에는 필요한 것이 많지 않습니다.",
"61b81b11aad0b9d970ece2fce18405f07eac69c2": "아래 링크를 북마크에 끌어다 놓으시면 됩니다! 이제 그냥 다운로드하고자 하는 유튜브 비디오 페이지에서 북마크를 클릭하면 됩니다.",
"c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "'오디오 전용' 북마크 생성",
"47955e2cc6986625528b4352034858180d675281": "데이터베이스 위치:",
"9f8de81d44ec2a9a58b97e589b9e3154b3966c60": "테이블당 레코드",
"3913164a51898aac444bf6c7150e46ad5a8a18ad": "몽고DB 연결 문자열",
"5473e36f5102e2ae22ce4c6620cacc40cc98da95": "예시:",
"d54142de169844b014ae913a4056c31495f4a305": "연결 문자열 테스트",
"98e94c9bdac1ca8beb29d73b2e6f7a9e5e035aec": "DB 전환",
"b1c08387975e6feada407c9b5f5f564261b8192b": "데이터베이스 정보를 검색할 수 없습니다. 자세한 내용은 서버 로그를 확인하세요.",
"ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "다운로더 선택",
"5fab47f146b0a4b809dcebf3db9da94df6299ea1": "기본 다운로드 에이전트 사용",
"c776eb4992b6c98f58cd89b20c1ea8ac37888521": "다운로드 에이전트 선택",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "로그 레벨",
"db6c192032f4cab809aad35215f0aa4765761897": "로그인 만료",
"dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "고급 다운로드 허용",
"431e5f3a0dde88768d1074baedd65266412b3f02": "쿠키 사용",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "쿠키 설정",
"635285fa5624d50a408feb7eb564c0db0d3f1ce1": "서버 재시작",
"37224420db54d4bc7696f157b779a7225f03ca9d": "사용자 등록 허용",
"fa548cee6ea11c160a416cac3e6bdec0363883dc": "인증 방법",
"4f56ced9d6b85aeb1d4346433361d47ea72dac1a": "내부",
"e3d7c5f019e79a3235a28ba24df24f11712c7627": "LDAP",
"1db9789b93069861019bd0ccaa5d4706b00afc61": "LDAP URL",
"f50fa6c09c8944aed504f6325f2913ee6c7a296a": "Bind DN",
"080cc6abcba236390fc22e79792d0d3443a3bd2a": "Bind Credentials",
"cfa67d14d84fe0e9fadf251dc51ffc181173b662": "기본 검색",
"e01d54ecc1a0fcf9525a3c100ed8b83d94e61c23": "검색 필터",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "YoutubeDL-Material에 대하여",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "은(는) 구글의 Material 디자인 요건에 따라 만들어진 오픈소스 유튜브 다운로더 입니다. 당신은 당신이 좋아하는 동영상을 동영상이나 오디오 파일로 원활하게 받을 수 있으며, 심지어 당신이 좋아하는 채널이나 재생목록을 구독해 그들의 새로운 동영상을 지속적으로 업데이트 할 수도 있습니다.",
"bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "은(는) 광범위한 API, 도커 지원, 현지화 (번역) 지원을 포함한 몇몇 엄청난 기능이 포함되어 있습니다! 아래 깃허브 아이콘을 클릭해 모든 지원되는 기능을 확인해보세요.",
"a45e3b05f0529dc5246d70ef62304c94426d4c81": "설치된 버전:",
"b33536f59b94ec935a16bd6869d836895dc5300c": "버그를 찾았거나 제안하실 사항이 있으신가요?",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "이슈를 생성하기 위해!",
"e22f3a5351944f3a1a10cfc7da6f65dfbe0037fe": "업데이트 확인중...",
"a16e92385b4fd9677bb830a4b796b8b79c113290": "업데이트 가능",
"189b28aaa19b3c51c6111ad039c4fd5e2a22e370": "설정 메뉴에서 업데이트를 할 수 있습니다.",
"1372e61c5bd06100844bd43b98b016aabc468f62": "선택된 버전:",
"1f6d14a780a37a97899dc611881e6bc971268285": "공유 허용",
"6580b6a950d952df847cb3d8e7176720a740adc8": "타임스탬프 사용",
"4f2ed9e71a7c981db3e50ae2fedb28aff2ec4e6c": "초",
"3a6e5a6aa78ca864f6542410c5dafb6334538106": "클립보드에 복사",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "재생목록 공유",
"94e2674467c7a08a291f9bd97ce694d4e47ffd62": "파일 공유",
"a1ad8b1be9be43b5183bd2c3186d4e19496f2a0b": "세션 아이디:",
"b6c453e0e61faea184bbaf5c5b0a1e164f4de2a2": "모든 다운로드된 항목 지우기",
"eb98135e35af26a9a326ee69bd8ff104d36dd8ec": "(현재)",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "다운로드된 항목 없음!",
"42ff677ec14f111e88bd6cdd30145378e994d1bf": "프로필",
"bb694b49d408265c91c62799c2b3a7e3151c824d": "로그아웃",
"ac9d09de42edca1296371e4d801349c9096ac8de": "UID:",
"a5ed099ffc9e96f6970df843289ade8a7d20ab9f": "생성됨:",
"fa96f2137af0a24e6d6d54c598c0af7d5d5ad344": "로그인하지 않았습니다.",
"a1dbca87b9f36d2b06a5cbcffb5814c4ae9b798a": "관리자 계정 생성",
"2d2adf3ca26a676bca2269295b7455a26fd26980": "기본 관리자 계정이 감지되지 않았습니다. 이것은 'admin'이라는 ID를 가진 관리자 계정을 만들고, 비밀번호를 설정할 것입니다.",
"70a67e04629f6d412db0a12d51820b480788d795": "생성",
"4d92a0395dd66778a931460118626c5794a3fc7a": "사용자 추가",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "역할 수정",
"746f64ddd9001ac456327cd9a3d5152203a4b93c": "ID",
"52c1447c1ec9570a2a3025c7e566557b8d19ed92": "역할",
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": "액션",
"2bd201aea09e43fbfd3cd15ec0499b6755302329": "사용자 관리",
"95b95a9c79e4fd9ed41f6855e37b3b06af25bcab": "사용자 삭제",
"632e8b20c98e8eec4059a605a4b011bb476137af": "사용자 수정",
"29c97c8e76763bb15b6d515648fa5bd1eb0f7510": "사용자 UID:",
"e70e209561583f360b1e9cefd2cbb1fe434b6229": "새 비밀번호",
"6498fa1b8f563988f769654a75411bb8060134b9": "새 비밀번호 설정",
"544e09cdc99a8978f48521d45f62db0da6dcf742": "기본 역할 사용",
"4f20f2d5a6882190892e58b85f6ccbedfa737952": "네",
"3d3ae7deebc5949b0c1c78b9847886a94321d9fd": "아니오",
"57c6c05d8ebf4ef1180c2705033c044f655bb2c4": "역할 관리",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "줄:",
"8a0bda4c47f10b2423ff183acefbf70d4ab52ea2": "로그 지우기",
"24dc3ecf7ec2c2144910c4f3d38343828be03a4c": "자동으로 생성됨",
"ccf5ea825526ac490974336cb5c24352886abc07": "파일 열기",
"5656a06f17c24b2d7eae9c221567b209743829a9": "새 탭에서 파일 열기",
"a0720c36ee1057e5c54a86591b722485c62d7b1a": "구독중으로 가기",
"94e01842dcee90531caa52e4147f70679bac87fe": "삭제하고 재다운로드",
"2031adb51e07a41844e8ba7704b054e98345c9c1": "영원히 삭제",
"ddc31f2885b1b33a7651963254b0c197f2a64086": "더 보기.",
"56a2a773fbd5a6b9ac2e6b89d29d70a2ed0f3227": "간략히 보기.",
"2054791b822475aeaea95c0119113de3200f5e1c": "길이:"
}

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,10 @@ $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);
@@ -50,7 +54,7 @@ $light-warn: mat-palette($mat-deep-orange, 200);
$light-theme: mat-light-theme($light-primary, $light-accent, $light-warn);
.light-theme {
@include angular-material-theme($light-theme)
@include angular-material-theme($light-theme);
}
.no-outline {