Compare commits

..

120 Commits

Author SHA1 Message Date
Tzahi12345
cc5fd7e997 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into downloading-improvements 2020-05-27 21:42:19 -04:00
Isaac Grynsztein
80f214fdde Fixed bug that caused non-YT videos to be downloading using the best format 2020-05-27 00:12:17 -04:00
Isaac Grynsztein
57a9434b3c File cards now include the video's real ID (for YT videos). Otherwise the file name will be used as a fallback 2020-05-25 16:51:49 -04:00
Isaac Grynsztein
cec0ed78ec Fixed bug that prevented user registration from occuring, and added new "Login" option to the hamburger menu when appropriate
Related: made it so non-logged in users (in multi user mode) don't have the option to go "home" or "subscriptions" or "downloads". It would error regardless, but it looks cleaner now
2020-05-25 16:35:02 -04:00
Isaac Grynsztein
c8a8046056 Fixed bug where if a subscription name was missing, the wrong folder was used
Fixed bug that caused high CPU usage when the subscriptions check took longer than the interval in the settings (thus they piled on)
2020-05-25 15:35:49 -04:00
Tzahi12345
b7b77995b9 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into downloading-improvements 2020-05-20 10:34:31 -04:00
Tzahi12345
73cd142b77 Cookies now work in subscriptions, too 2020-05-19 21:52:45 -04:00
Tzahi12345
b071fb9e2e Merge pull request #94 from Tzahi12345/cookies-helper
Cookies upload dialog
2020-05-19 21:43:39 -04:00
Tzahi12345
f485da06b5 Implemented cookies upload dialog and the ability to "enable cookies" to hopefully circumvent 429 errors 2020-05-19 21:37:19 -04:00
Tzahi12345
59098d4693 Minor update to support alternate youtube links 2020-05-19 19:31:41 -04:00
Tzahi12345
4cf92b8f3d Fixed bugged override 2020-05-19 18:15:07 -04:00
Tzahi12345
e07acfd4b3 Added support for a popular adult website 2020-05-18 20:45:36 -04:00
Tzahi12345
05d962328b Fixed bug that prevented non-youtube videos from downloading 2020-05-18 19:32:00 -04:00
Tzahi12345
0816cb7046 Fixed bug that preventing reddit videos from downloading 2020-05-18 18:44:39 -04:00
Tzahi12345
c6553d99c6 Hotfix for bug that prevented large twitch videos from downloading 2020-05-10 05:20:53 -04:00
Tzahi12345
8bf3680b6f Fixed bug that prevented soundcloud audio files from downloading correctly 2020-05-10 04:56:21 -04:00
Tzahi12345
d7eb3f35ac Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into downloading-improvements 2020-05-09 04:43:56 -04:00
Tzahi12345
3487813cb5 Updated frontend files 2020-05-09 03:41:27 -04:00
Tzahi12345
0ea826487d minor improvements to downloading process, mainly scaffolding for future improvements 2020-05-09 03:37:50 -04:00
Tzahi12345
ecc2737a05 Pressing enter on URL input now triggers the download 2020-05-09 03:34:59 -04:00
Tzahi12345
39e737024f Pressing enter on url input now triggers download 2020-05-09 03:03:37 -04:00
Tzahi12345
550013a2e7 Config file is now created when missing and set with default values 2020-05-08 14:28:19 -04:00
Tzahi12345
b6f8551cfa Updated docker-compose.yml to support multi-user mode 2020-05-06 02:49:18 -04:00
Tzahi12345
98e94d3c38 Updated frontend binaries 2020-05-06 02:46:44 -04:00
Tzahi12345
8c94255f61 Updated version number to 4.0 2020-05-06 02:43:46 -04:00
Tzahi12345
409fd0fe20 Updated translations and frontend binaries 2020-05-06 02:39:20 -04:00
Tzahi12345
d4ad1f9fce Added additional sentence to custom args hint and fixed issue where empty args could be added through the args modifier 2020-05-06 02:38:39 -04:00
Tzahi12345
cc47823b0c Updated gitignore and re-added package-lock.json
Translations updated
2020-05-05 20:12:15 -04:00
Tzahi12345
747735dffe Arg modifier chip list now supports auto complete and arg description as the chip tooltip
Fixed bug that caused custom args to reset after exiting arg modifier without hitting cancel
2020-05-05 20:11:23 -04:00
Tzahi12345
76b63329ca Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2020-05-05 19:32:08 -04:00
Tzahi12345
f094d18e03 Changed /api/changeUser endpoint to /api/updateUser 2020-05-05 19:31:47 -04:00
Tzahi12345
c1b0b583e4 Updated API to reflect changes for 4.0 2020-05-05 19:29:04 -04:00
Tzahi12345
a1c9c97616 Updated security schema in repository api docs 2020-05-05 16:47:28 -04:00
Tzahi12345
0504167734 Arg modifier improvements: args are now shown as removable chips which can be directly typed as well (w/o using the adder) 2020-05-04 15:39:33 -04:00
Tzahi12345
49081db8cb Config items are now checked on start. Missing ones will be autofilled with the default values automatically on startup 2020-05-04 05:18:06 -04:00
Tzahi12345
98e33ac399 Updated frontend files 2020-05-04 04:38:21 -04:00
Tzahi12345
a3424f973e Output on global args will now override specific output 2020-05-03 21:14:09 -04:00
Tzahi12345
8e5db3e9d1 Custom args and global custom args now use double comma as a delimiter. This should allow file names with spaces when using custom args (global and not) 2020-05-03 21:05:56 -04:00
Tzahi12345
1861011fb0 Cleaned up code and added missing translation units 2020-05-03 19:35:38 -04:00
Tzahi12345
74e47b7d04 Fixed bug that prevented audio files from being played after username change
Downloads with custom args or custom quality config now use the old downloader to ensure stability and prevent arg conflict
2020-05-03 19:34:01 -04:00
Tzahi12345
68f791eea3 Downloads now show new spinner after download completes, indicating processing has begun 2020-05-03 18:56:07 -04:00
Tzahi12345
f73ec2dd94 Fixed bug that caused users with large amounts of data to have extremely large tokens
Subfolders are now ensured to exist with the normal downloading method

Initialization now happens after token retrieval to avoid failed requests

Fixed bug that caused login to be called twice, introducing a possible race condition
2020-05-03 18:55:42 -04:00
Tzahi12345
26ad195597 Adds ability to set umask through an environment variable. Does not work on Windows, and it's untested on Linux 2020-05-03 13:40:54 -04:00
Tzahi12345
fb23d7c41e Audio downloads now work with progress bar, but it requires file conversion at the end. It ends up being around the same speed as the regular method 2020-05-03 03:24:25 -04:00
Tzahi12345
4e6d68d9e6 Updated video playing/sharing logic to support sharing of playlists in multi user mode and when multi user mode is disabled
Fixed bug that caused normal archive to be used in multi-user mode

Updated login logic when username is not found or user file is missing

Fixed bug that prevented playlist sharing from working

Added ability to use timestamps when sharing videos
2020-05-02 20:36:30 -04:00
Tzahi12345
8bc99fb557 Fixed bug that prevented registration from occuring 2020-05-02 17:25:35 -04:00
Tzahi12345
8277c95c4e Updated youtube-dl binary for windows 2020-05-02 17:10:08 -04:00
Tzahi12345
e5db376914 All config values are now reloaded on config set
Added 4 new settings: user files folder, enable registration, enable downloads manager, and logging level selection
2020-05-02 17:09:46 -04:00
Tzahi12345
661b96cfe5 Fixed bug that prevented default config items to be set 2020-05-02 15:06:43 -04:00
Tzahi12345
2eef1b062c Updated frontend binaries 2020-05-01 16:26:14 -04:00
Tzahi12345
d376ee4409 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2020-05-01 16:18:47 -04:00
Tzahi12345
7661b1e79e Fixed bug that prevented admin creation prompt from occuring in the settings 2020-05-01 16:18:39 -04:00
Tzahi12345
0401769968 Merge pull request #69 from SuperSandro2000/patch-1
Update badge links
2020-05-01 14:54:26 -04:00
Tzahi12345
c901efebc4 Merge pull request #68 from SuperSandro2000/docker
Docker improvements
2020-05-01 14:49:32 -04:00
Sandro
4e550da4f6 Update badge links 2020-05-01 20:41:01 +02:00
Sandro Jäckel
ae76e9db8d Install dependencies first, remove duplicated workdir 2020-05-01 19:45:19 +02:00
Sandro Jäckel
d763f88ceb Remove comments 2020-05-01 19:40:35 +02:00
Sandro Jäckel
a8b188cd22 Don't create cache with apk, use cdn network 2020-05-01 19:40:02 +02:00
Sandro Jäckel
1034aa1980 Don't copy Docker related files into image 2020-05-01 19:39:34 +02:00
Tzahi12345
da26d88ba9 Updated frontend binaries 2020-05-01 03:55:17 -04:00
Tzahi12345
93e117a7aa Adds fingerprintjs2 as frontend dependency 2020-05-01 03:52:31 -04:00
Tzahi12345
ae9c52a14d Merge pull request #67 from Tzahi12345/multi-user-mode
Adds multi-user mode
2020-05-01 03:48:50 -04:00
Tzahi12345
b685b955df Added roles and permissions system, as well as the ability to modify users and their roles
Downloads manager now uses device fingerprint as identifier rather than a randomly generated sessionID
2020-05-01 03:34:35 -04:00
Tzahi12345
e7b841c056 Added UI flow for creating default admin account. Dialog will show up after enabling or in the login menu if the admin account isn't present 2020-04-30 16:31:36 -04:00
Tzahi12345
e5f9694da0 Fixed bug where downloading individual files failed for channel subscriptions 2020-04-30 13:29:47 -04:00
Tzahi12345
81b0ef4a72 Refactored initialization process to better facilitate auth if necessary
Date in user profile dialog now shows date
2020-04-30 13:28:58 -04:00
Tzahi12345
31f581c642 Subscriptions now support multi-user-mode
Fixed bug where playlist subscription downloads would fail due to a mislabeled parameter

Components that are routes now make sure auth is finished before sending requests to the backend
2020-04-30 04:54:41 -04:00
Tzahi12345
d2af233a1f Fixed bug that when multi-download mode was enabled, videos could not be navigated to 2020-04-29 23:09:00 -04:00
Adam Verga
0fb00bac12 Initialization on auth component happens in a separate function, users_db primarily sits in app.js
Fixed bug where current download would set to null, but maincomponent still tried to parse it
2020-04-29 20:46:29 -04:00
Adam Verga
6980828853 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into multi-user-mode 2020-04-29 20:16:42 -04:00
Adam Verga
a48e122763 Settings are now more centralized in the frontend 2020-04-29 20:15:15 -04:00
Tzahi12345
2d66d653f6 Updated frontend files to reflect commits since v3.6 2020-04-29 00:52:45 -04:00
Tzahi12345
03ea04f8d8 Fixed missing double ampersand 2020-04-29 00:40:14 -04:00
Tzahi12345
8fbb1c9bbd Fixed repo for atomicparsley on docker/alpine 2020-04-29 00:35:51 -04:00
Tzahi12345
c67d6ea89a Added atomicparsley as a dependency to Docker, and listed it as an optional dependency for normal installs 2020-04-29 00:27:39 -04:00
Tzahi12345
a701d0fe83 Fixes bug (hopefully) that causes stale data to be saved to the db due to multiple adapters instances being used. Now the db adapter gets passed as a parameter 2020-04-29 00:12:50 -04:00
Tzahi12345
ff51a49d1b Removed unused import 2020-04-29 00:04:04 -04:00
Tzahi12345
46b595db45 Update README.md
Updated API info
2020-04-27 04:55:46 -04:00
Isaac Grynsztein
4b2b278439 Sharing and video downloads on shared videos now work for multi-user mode 2020-04-27 04:31:39 -04:00
Isaac Grynsztein
1ac6683f33 Custom quality configurations now use the old downloading method to avoid errors
postsservice now does jwt auth after checking if multi user mode is enabled

Minor update to user profile UI

Added setting for enabling and disabling multi user mode
2020-04-26 21:37:08 -04:00
Isaac Grynsztein
e790c9fadf File descriptors are now stored in the config_api until they find a better home
File deletion now works in multi-user mode. Sharing and subscriptions are the last holdouts for porting over to multi-user-mode

Fixed bug with archive mode that defaulted to storing the ID in the video archive all the time (rather than audio if it's an mp3)
2020-04-26 18:33:23 -04:00
Isaac Grynsztein
c18bf568c7 Fixed bug that prevented video download or archive download from occuring 2020-04-26 17:41:54 -04:00
Isaac Grynsztein
fa1b291f97 Added video downloading functionality to multi user mode, as well as playlist management and saving of videos locally. Still missing video deletions and subscriptions
Simplified code for downloading videos to client (locally)
2020-04-26 17:40:28 -04:00
Isaac Grynsztein
cb6451ef96 Added new settings: multi user mode and users base path 2020-04-26 17:37:49 -04:00
Isaac Grynsztein
912a419bd4 Getting current download refactored to work and display less errors
Player component now sends jwt token if logged in
2020-04-26 17:34:38 -04:00
Isaac Grynsztein
a7c810136b Added basic user profile component 2020-04-26 17:33:29 -04:00
Isaac Grynsztein
e6ea2238f8 Fixed bug where HTTP headers were sent when params should have been sent instead
sessionID now gets sent after logging in
2020-04-26 17:32:50 -04:00
Isaac Grynsztein
98f1d003c3 Fixed bug that prevented migrations from succeeding
Added scaffolding required for jwt authentication for certain routes

Added logger to auth_api

Added necessary routing rules for multi-user mode

Registration is now possible
2020-04-24 21:03:00 -04:00
Isaac Grynsztein
c3cc28540f Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into multi-user-mode 2020-04-23 14:56:14 -04:00
Tzahi12345
68ed66072e Merge pull request #57 from Tzahi12345/current-downloads-refactor
Downloads system refactor
2020-04-22 23:15:26 -04:00
Isaac Grynsztein
eca06a7fb1 Downloads on the home page now show the progress bar 2020-04-22 21:42:21 -04:00
Isaac Grynsztein
b583305940 Downloads in the download manager now get updated smoothly, preventing the DOM from updating on object reassign 2020-04-21 20:10:53 -04:00
Isaac Grynsztein
f361b8a974 Furrther simplified download process and fixed a couple bugs
Audio files will not show download progress as enabling this feature causes it to be really slow

Fixed bug where downloading the same video twice produced duplicate files in the file manager
2020-04-21 18:56:52 -04:00
Isaac Grynsztein
1565c328d5 If a video is a playlist, it will download the normal way 2020-04-21 16:19:19 -04:00
Isaac Grynsztein
a6534f66a6 migrated audio file downloads to new system. still untested with playlists
video/audio player now doesnt show share button when uid isn't present, user will be notified of this through a snackbar as well
2020-04-21 03:16:39 -04:00
Isaac Grynsztein
a78ccefc83 Updated package.json 2020-04-20 18:40:21 -04:00
Isaac Grynsztein
6fe7d20498 downloads refactor half done - videos are now implement, but audo files are now
Added downloads manager in the UI where downloads can be viewed/cleared
2020-04-20 18:39:55 -04:00
Isaac Grynsztein
d887380fd1 Added new methods to facilitate server-side download management 2020-04-18 01:31:32 -04:00
Isaac Grynsztein
1f3572a630 jwt auth scaffolding
logging in now works

UI login component created
2020-04-16 22:35:34 -04:00
Isaac Grynsztein
da8571fb1a Added additional info when requests are rejected due to no auth
Added two additional auth methods: registering and logging in. They have minimal functionality right now

Added auth module which will handle all auth-related requests
2020-04-16 16:33:32 -04:00
Isaac Grynsztein
4617362270 New default youtube-dl.exe binary
Updated public dir in backend
2020-04-15 18:52:18 -04:00
Isaac Grynsztein
bdb5072014 API key is now passed as a query param 2020-04-15 18:46:13 -04:00
Isaac Grynsztein
e5baf094c9 chmodsync will not run if app is running on windows 2020-04-15 03:40:26 -04:00
Isaac Grynsztein
264b3606d6 Modified automatic permissions for json files 2020-04-15 03:36:56 -04:00
Isaac Grynsztein
2408184cc7 new video json files created now get read perms across the board 2020-04-15 03:19:28 -04:00
Isaac Grynsztein
e4851253dd Docker now ignores executable files 2020-04-15 02:15:15 -04:00
Isaac Grynsztein
87696f71f8 Added subscription folders to repo
Added .dockerignore to ignore node_modules

Removed unnecessary whitespace from docker-compose.yml
2020-04-15 02:10:12 -04:00
Isaac Grynsztein
d6fe2a5720 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2020-04-15 02:02:27 -04:00
Isaac Grynsztein
90c2d3f70b Moved docker files to backend 2020-04-15 02:02:14 -04:00
Isaac Grynsztein
0d54cb9872 Updated player view to prevent video from being too large 2020-04-15 02:02:03 -04:00
Isaac Grynsztein
a8d6298cfd Adds preliminary support for tiktok and periscope
Added alternate json path for mp4s in case it's not found in the main location
2020-04-15 02:01:25 -04:00
Tzahi12345
65b31633d9 Update README.md
Changed start command to use npm
2020-04-14 15:41:18 -04:00
Isaac Grynsztein
3db3077dec Fixed misspelling in docs 2020-04-13 16:01:37 -04:00
Tzahi12345
61633e817b Updated API docs 2020-04-13 15:59:46 -04:00
Tzahi12345
6cc93ba4f9 Update README.md
Added badges to README, additional links to reverse proxy info, and updated the API section.
2020-04-13 02:16:35 -04:00
Isaac Grynsztein
9b8b92b8df Added latest release to repo 2020-04-12 23:54:16 -04:00
Isaac Grynsztein
d9f6b8c64c Simplified archive creation for subscriptions to reduce risk of error
If no subscriptions have ever been made, "No channel/playlist subscriptions" text will now show
2020-04-12 21:00:47 -04:00
Isaac Grynsztein
10b59191f6 Updated public directory 2020-04-12 20:47:29 -04:00
Isaac Grynsztein
a89787698b Fixed version in package.json 2020-04-12 20:42:47 -04:00
Isaac Grynsztein
3d3ab5180f fixed bug that prevented non-api routes from loading without an auth header 2020-04-10 21:03:37 -04:00
106 changed files with 25825 additions and 1511 deletions

4
.gitignore vendored
View File

@@ -54,8 +54,6 @@ backend/subscriptions/channels/*
backend/subscriptions/playlists/*
backend/subscriptions/archives/*
src/assets/default.json
package-lock.json
backend/package-lock.json
backend/appdata/db.json
backend/appdata/archives/archive_audio.txt
backend/appdata/archives/archive_video.txt
@@ -63,3 +61,5 @@ backend/appdata/archives/blacklist_audio.txt
backend/appdata/archives/blacklist_video.txt
backend/appdata/logs/combined.log
backend/appdata/logs/error.log
backend/appdata/users.json
backend/users/*

View File

@@ -1,21 +0,0 @@
FROM alpine:3.11
RUN apk add --update npm python ffmpeg
# Change directory so that our commands run inside this new directory
WORKDIR /app
# Copy dependency definitions
COPY ./ /app/
# Change directory to backend
WORKDIR /app
# Install dependencies on backend
RUN npm install
# Expose the port the app runs in
EXPOSE 17442
# Run the specified command within the container.
CMD [ "node", "app.js" ]

View File

@@ -1,7 +1,8 @@
openapi: 3.0.0
info:
title: Version 1
title: YoutubeDL-Material API Docs - Official
version: '1.0'
description: "Welcome to the official docs for YoutubeDL-Material.\n\n\nYou can check out all the available endpoints. Remember to authenticate with your API key using the \"apiKey\" query parameter with your requests.\n\n\nTo do so, simply add this to the end of your API call:\n\n\n`?apiKey=API_KEY`\n\n\nReplce API_KEY with the API key in the settings menu. If one doesn't exist, click generate to create one.\n\n### Multi-user mode\n\nWhen using multi-user mode, you will need to supply a JWT token to authenticate requests through that user. This lets the server know which user to run the task for, like downloading a video for a specific user. \n\nTo do this, you must use the `/api/auth/login` endpoint to login using a user's username and password. This will result in an object containing a `token`. Supply this along with your API key like so:\n\n`?apiKey=API_KEY&jwt=JWT_TOKEN`\n\nNotice the `&` between the `API_KEY` and `jwt`."
servers:
- url: 'http://localhost:17442'
paths:
@@ -10,7 +11,11 @@ paths:
tags:
- downloader
summary: Download audio file
description: Downloads an audio file with the given URL. Will include global args if they exist.
description: |-
Downloads an audio file with the given URL. Will include global args if they exist.
HTTP requests will return once the audio file download completes. In the future, it will (by default) return once the download starts, and a separate API call will be used for checking the download status.
operationId: post-tomp3
requestBody:
content:
@@ -24,12 +29,18 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200'
security:
- Auth query parameter: []
/api/tomp4:
post:
tags:
- downloader
summary: Download video file
description: Downloads a video file with the given URL. Will include global args if they exist.
description: |-
Downloads a video file with the given URL. Will include global args if they exist.
HTTP requests will return once the video file download completes. In the future, it will (by default) return once the download starts, and a separate API call will be used for checking the download status.
operationId: post-tomp4
requestBody:
content:
@@ -43,6 +54,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_1'
security:
- Auth query parameter: []
/api/getMp3s:
get:
tags:
@@ -57,6 +70,9 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_2'
requestBody: {}
security:
- Auth query parameter: []
/api/getMp4s:
get:
tags:
@@ -71,6 +87,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_3'
security:
- Auth query parameter: []
/api/getFile:
post:
tags:
@@ -90,6 +108,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_4'
security:
- Auth query parameter: []
/api/enableSharing:
post:
tags:
@@ -110,6 +130,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_5'
security:
- Auth query parameter: []
/api/disableSharing:
post:
tags:
@@ -130,6 +152,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_5'
security:
- Auth query parameter: []
/api/subscribe:
post:
tags:
@@ -149,6 +173,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_6'
security:
- Auth query parameter: []
/api/unsubscribe:
post:
tags:
@@ -168,6 +194,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_7'
security:
- Auth query parameter: []
/api/deleteSubscriptionFile:
post:
tags:
@@ -189,6 +217,8 @@ paths:
$ref: '#/components/schemas/inline_response_200_8'
'500':
description: Internal Server Error
security:
- Auth query parameter: []
/api/getSubscription:
post:
tags:
@@ -208,6 +238,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_9'
security:
- Auth query parameter: []
/api/downloadVideosForSubscription:
post:
tags:
@@ -227,6 +259,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_10'
security:
- Auth query parameter: []
/api/getAllSubscriptions:
post:
tags:
@@ -245,6 +279,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_11'
security:
- Auth query parameter: []
/api/createPlaylist:
post:
tags:
@@ -264,6 +300,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_12'
security:
- Auth query parameter: []
/api/getPlaylist:
post:
tags:
@@ -283,6 +321,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_13'
security:
- Auth query parameter: []
/api/updatePlaylist:
post:
tags:
@@ -302,6 +342,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_5'
security:
- Auth query parameter: []
/api/deletePlaylist:
post:
tags:
@@ -321,6 +363,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_5'
security:
- Auth query parameter: []
/api/deleteMp4:
post:
tags:
@@ -341,6 +385,8 @@ paths:
schema:
type: boolean
description: Whether the operation succeeded
security:
- Auth query parameter: []
/api/downloadFile:
post:
tags:
@@ -359,6 +405,8 @@ paths:
responses:
'200':
description: 'The file itself is in the response, as well as an options object.'
security:
- Auth query parameter: []
/api/deleteFile:
post:
tags:
@@ -373,6 +421,8 @@ paths:
responses:
'200':
description: OK
security:
- Auth query parameter: []
/api/downloadArchive:
post:
tags:
@@ -388,6 +438,8 @@ paths:
responses:
'200':
description: The archive text file is sent as a response
security:
- Auth query parameter: []
/api/updaterStatus:
get:
tags:
@@ -402,6 +454,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_14'
security:
- Auth query parameter: []
/api/updateServer:
post:
tags:
@@ -421,6 +475,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_5'
security:
- Auth query parameter: []
/api/isPinSet:
post:
tags:
@@ -435,6 +491,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_15'
security:
- Auth query parameter: []
/api/checkPin:
post:
tags:
@@ -454,6 +512,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_5'
security:
- Auth query parameter: []
/api/setPin:
post:
tags:
@@ -472,6 +532,9 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_5'
security:
- Auth query parameter: []
description: ''
/api/generateNewAPIKey:
post:
tags:
@@ -486,6 +549,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_16'
security:
- Auth query parameter: []
/api/deleteMp3:
post:
tags:
@@ -506,6 +571,8 @@ paths:
schema:
type: boolean
description: Whether the operation succeeded
security:
- Auth query parameter: []
/api/config:
get:
tags: []
@@ -519,6 +586,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_17'
security:
- Auth query parameter: []
/api/setConfig:
post:
summary: Set config
@@ -536,6 +605,365 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_5'
security:
- Auth query parameter: []
/api/downloads:
get:
summary: Get info for all downloads
tags:
- downloader
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
downloads:
type: object
properties:
uid:
type: string
downloading:
type: boolean
complete:
type: boolean
url:
type: string
type:
type: string
percent_complete:
type: string
is_playlist:
type: boolean
timestamp_start:
type: number
timestamp_end:
type: number
fileNames:
type: array
items:
type: string
operationId: get-api-downloads
description: Retrieves all downloads recorded by the server and their status.
security:
- Auth query parameter: []
/api/download:
post:
summary: Get info for one download
operationId: post-api-download
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
download:
type: object
properties:
uid:
type: string
downloading:
type: boolean
complete:
type: boolean
url:
type: string
type:
type: string
percent_complete:
type: string
is_playlist:
type: boolean
timestamp_start:
type: number
timestamp_end:
type: number
fileNames:
type: array
items:
type: string
requestBody:
content:
application/json:
schema:
type: object
properties:
session_id:
type: string
download_id:
type: string
required:
- session_id
- download_id
description: ''
description: "Gets a single download using its download_id and session_id. session_id is the device fingerprint. If none was provided at the time of download, then set session_id is 'undeclared'."
security:
- Auth query parameter: []
tags:
- downloader
/api/auth/login:
post:
summary: Login
operationId: post-api-auth-login
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/user'
token:
type: string
permissions:
type: array
items:
type: string
available_permissions:
type: array
items:
type: string
description: Use this method to log into a user using their username and password and receive a jwt auth token so you can send per-user requests.
requestBody:
content:
application/json:
schema:
type: object
properties:
userid:
type: string
description: 'This is the username, not the user uid'
password:
type: string
required:
- userid
- password
security:
- Auth query parameter: []
tags:
- multi-user mode
/api/auth/register:
post:
summary: Register
operationId: post-api-auth-register
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/user'
description: Use this endpoint to register a user. It will only work if registration is enabled.
requestBody:
content:
application/json:
schema:
type: object
properties:
userid:
type: string
username:
type: string
password:
type: string
required:
- userid
- username
- password
security:
- Auth query parameter: []
tags:
- multi-user mode
/api/updateUser:
post:
summary: Update user
operationId: post-api-updateUser
responses:
'200':
description: OK
requestBody:
content:
application/json:
schema:
type: object
properties:
change_object:
type: object
properties:
name:
type: string
role:
type: string
required:
- change_object
description: Updates certain properties for a user. Only two are possible right now.
security:
- Auth query parameter: []
tags:
- multi-user mode
/api/deleteUser:
post:
summary: Delete user
operationId: post-api-deleteUser
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
description: Deletes a user by its uid.
requestBody:
content:
application/json:
schema:
type: object
properties:
uid:
type: string
required:
- uid
security:
- Auth query parameter: []
tags:
- multi-user mode
/api/getRoles:
post:
summary: Get available roles
operationId: post-api-getRoles
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
roles:
type: object
properties:
admin:
type: object
properties:
permissions:
type: array
items:
type: string
user:
type: object
properties:
permissions:
type: array
items:
type: string
description: Gets the available roles and their permissions
security:
- Auth query parameter: []
tags:
- multi-user mode
/api/changeUserPermissions:
post:
summary: Change user permissions
operationId: post-api-changeUserPermissions
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
description: "Changes the permissions for a user. Available values for each permission are: `default`, `yes`, and `no`. `default` will use the user's role's default permission."
security:
- Auth query parameter: []
requestBody:
content:
application/json:
schema:
type: object
properties:
user_uid:
type: string
permission:
type: string
new_value:
type: string
required:
- user_uid
- permission
- new_value
tags:
- multi-user mode
/api/changeRolePermissions:
post:
summary: Change role permissions
operationId: post-api-changeRolePermissions
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
requestBody:
content:
application/json:
schema:
type: object
properties:
role:
type: string
permission:
type: string
new_value:
type: string
required:
- role
- permission
- new_value
description: 'Changes the permissions for a role. Available values for each permission are: `yes`, and `no`.'
security:
- Auth query parameter: []
tags:
- multi-user mode
/api/getUsers:
post:
summary: Get all users
operationId: post-api-getUsers
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/user'
description: 'Gets all users, returns a list of the user objects including their user permissions, videos, playlists, subscriptions, etc.'
security:
- Auth query parameter: []
tags:
- multi-user mode
components:
schemas:
body:
@@ -1205,4 +1633,68 @@ components:
type: string
type:
type: string
securitySchemes: {}
user:
title: user
type: object
properties:
uid:
type: string
name:
type: string
passhash:
type: string
files:
type: object
properties:
audio:
type: array
items:
$ref: '#/components/schemas/file'
video:
type: array
items:
$ref: '#/components/schemas/file'
playlists:
type: object
properties:
audio:
type: array
items:
$ref: '#/components/schemas/file'
video:
type: array
items:
$ref: '#/components/schemas/file'
subscriptions:
type: array
items:
$ref: '#/components/schemas/inline_response_200_9_subscription'
created:
type: number
role:
type: string
permissions:
type: array
items:
type: string
permission_overrides:
type: array
items:
type: string
file:
title: file
type: object
properties:
id:
type: string
securitySchemes:
Auth query parameter:
name: apiKey
type: apiKey
in: query
description: Use your public API key generated from the settings menu
JWT token parameter:
name: jwt
type: apiKey
in: query
description: 'User-specific authentication token, only used in multi-user mode. Retrieve this token by using the login endpoint'

View File

@@ -1,4 +1,10 @@
# YoutubeDL-Material
[![](https://img.shields.io/docker/pulls/tzahi12345/youtubedl-material.svg)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![](https://img.shields.io/docker/image-size/tzahi12345/youtubedl-material?sort=date)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![](https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg)](https://heroku.com/deploy?template=https://github.com/Tzahi12345/YoutubeDL-Material)
[![](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues)
[![](https://img.shields.io/github/license/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/LICENSE.md)
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 9](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
@@ -30,17 +36,20 @@ Make sure you have these dependencies installed on your system: nodejs and youtu
sudo apt-get install nodejs youtube-dl
```
Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
### Installing
1. First, download the [latest release](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest)!
2. Drag the `youtubedl-material` directory to an easily accessible directory. Navigate to the `appdata` folder and edit the `default.json` file. If you're using SSL encryption, look at the `encrypted.json` file for a template.
NOTE: If you are intending to use a reverse proxy, this next step is not necessary
NOTE: If you are intending to use a [reverse proxy](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/Reverse-Proxy-Setup), this next step is not necessary
3. Port forward the port listed in `default.json`, which defaults to `17442`.
4. Once the configuration is done, run `npm install` to install all the backend dependencies. Once that is finished, type `nodejs app.js`. This will run the backend server, which serves the frontend as well. On your browser, navigate to to the server (url with the specified port). Try putting in a youtube link to see if it works. If it does, viola! YoutubeDL-Material is now up and running.
4. Once the configuration is done, run `npm install` to install all the backend dependencies. Once that is finished, type `npm start`. This will run the backend server, which serves the frontend as well. On your browser, navigate to to the server (url with the specified port). Try putting in a youtube link to see if it works. If it does, viola! YoutubeDL-Material is now up and running.
If you experience problems, know that it's usually caused by a configuration problem. The first thing you should do is check the console. To get there, right click anywhere on the page and click "Inspect element." Then on the menu that pops up, click console. Look at the error there, and try to investigate.
@@ -80,9 +89,11 @@ To deploy, simply clone the repository, and go into the `youtubedl-material` dir
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/appdata`, go back into the `youtubedl-material` directory, and type `ng build --prod`. This will build the app, and put the output files in the `youtubedl-material/dist` folder. Drag those files into the `public` directory in the `backend` folder.
The frontend is now complete. The backend is much easier. Just go into the `backend` folder, and type `nodejs app.js`.
The frontend is now complete. The backend is much easier. Just go into the `backend` folder, and type `npm start`.
Finally, port forward the port specified in the config (defaults to `17442`) and point it to the server's IP address. Make sure the port is also allowed through the server's firewall.
Finally, if you want your instance to be available from outside your network, you can set up a [reverse proxy](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/Reverse-Proxy-Setup).
Alternatively, you can port forward the port specified in the config (defaults to `17442`) and point it to the server's IP address. Make sure the port is also allowed through the server's firewall.
## Docker
@@ -97,7 +108,11 @@ If you are looking to setup YoutubeDL-Material with Docker, this section is for
## API
View how to use the backend API on the [API Wiki page](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/API).
[API Docs](https://stoplight.io/p/docs/gh/tzahi12345/youtubedl-material?group=master&utm_campaign=publish_dialog&utm_source=studio)
To get started, go to the settings menu and enable the public API from the *Extra* tab. You can generate an API key if one is missing.
Once you have enabled the API and have the key, you can start sending requests by adding the query param `apiKey=API_KEY`. Replace `API_KEY` with your actual API key, and you should be good to go! Nearly all of the backend should be at your disposal. View available endpoints in the link above.
## Contributing

4
backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
*.exe
docker-compose.yml
Dockerfile

18
backend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM alpine:3.11
RUN \
apk add --no-cache npm python ffmpeg && \
apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing/ \
atomicparsley
WORKDIR /app
COPY package.json /app/
RUN npm install
COPY ./ /app/
EXPOSE 17442
CMD [ "node", "app.js" ]

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,8 @@
"allow_quality_select": true,
"download_only_mode": false,
"allow_multi_download_mode": true,
"settings_pin_required": false
"settings_pin_required": false,
"enable_downloads_manager": true
},
"API": {
"use_API_key": false,
@@ -39,10 +40,17 @@
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Users": {
"base_path": "users/",
"allow_registration": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"allow_advanced_download": false
"multi_user_mode": false,
"allow_advanced_download": false,
"use_cookies": false,
"logger_level": "info"
}
}
}

View File

@@ -21,7 +21,8 @@
"allow_quality_select": true,
"download_only_mode": false,
"allow_multi_download_mode": true,
"settings_pin_required": false
"settings_pin_required": false,
"enable_downloads_manager": true
},
"API": {
"use_API_key": false,
@@ -39,10 +40,17 @@
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Users": {
"base_path": "users/",
"allow_registration": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"allow_advanced_download": false
"multi_user_mode": false,
"allow_advanced_download": false,
"use_cookies": false,
"logger_level": "info"
}
}
}

View File

@@ -0,0 +1,540 @@
const path = require('path');
const config_api = require('../config');
const consts = require('../consts');
var subscriptions_api = require('../subscriptions')
const fs = require('fs-extra');
var jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4');
var bcrypt = require('bcrypt');
var LocalStrategy = require('passport-local').Strategy;
var JwtStrategy = require('passport-jwt').Strategy,
ExtractJwt = require('passport-jwt').ExtractJwt;
// other required vars
let logger = null;
var users_db = null;
let SERVER_SECRET = null;
let JWT_EXPIRATION = null;
let opts = null;
let saltRounds = null;
exports.initialize = function(input_users_db, input_logger) {
setLogger(input_logger)
setDB(input_users_db);
/*************************
* Authentication module
************************/
saltRounds = 10;
JWT_EXPIRATION = (60 * 60); // one hour
SERVER_SECRET = null;
if (users_db.get('jwt_secret').value()) {
SERVER_SECRET = users_db.get('jwt_secret').value();
} else {
SERVER_SECRET = uuid();
users_db.set('jwt_secret', SERVER_SECRET).write();
}
opts = {}
opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt');
opts.secretOrKey = SERVER_SECRET;
/*opts.issuer = 'example.com';
opts.audience = 'example.com';*/
exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) {
const user = users_db.get('users').find({uid: jwt_payload.user}).value();
if (user) {
return done(null, user);
} else {
return done(null, false);
// or you could create a new account
}
}));
}
function setLogger(input_logger) {
logger = input_logger;
}
function setDB(input_users_db) {
users_db = input_users_db;
}
exports.passport = require('passport');
exports.passport.serializeUser(function(user, done) {
done(null, user);
});
exports.passport.deserializeUser(function(user, done) {
done(null, user);
});
/***************************************
* Register user with hashed password
**************************************/
exports.registerUser = function(req, res) {
var userid = req.body.userid;
var username = req.body.username;
var plaintextPassword = req.body.password;
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
res.sendStatus(409);
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
return;
}
bcrypt.hash(plaintextPassword, saltRounds)
.then(function(hash) {
let new_user = {
name: username,
uid: userid,
passhash: hash,
files: {
audio: [],
video: []
},
playlists: {
audio: [],
video: []
},
subscriptions: [],
created: Date.now(),
role: userid === 'admin' ? 'admin' : 'user',
permissions: [],
permission_overrides: []
};
// check if user exists
if (users_db.get('users').find({uid: userid}).value()) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
res.status(409).send('UID is already taken!');
} else if (users_db.get('users').find({name: username}).value()) {
// user name is taken!
logger.error('Registration failed: User name is already taken!');
res.status(409).send('User name is already taken!');
} else {
// add to db
users_db.get('users').push(new_user).write();
logger.verbose(`New user created: ${new_user.name}`);
res.send({
user: new_user
});
}
})
.then(function(result) {
})
.catch(function(err) {
logger.error(err);
if( err.code == 'ER_DUP_ENTRY' ) {
res.status(409).send('UserId already taken');
} else {
res.sendStatus(409);
}
});
}
/***************************************
* Login methods
**************************************/
/*************************************************
* This gets called when passport.authenticate()
* gets called.
*
* This checks that the credentials are valid.
* If so, passes the user info to the next middleware.
************************************************/
exports.passport.use(new LocalStrategy({
usernameField: 'userid',
passwordField: 'password'},
function(username, password, done) {
const user = users_db.get('users').find({name: username}).value();
if (!user) { logger.error(`User ${username} not found`); return done(null, false); }
if (user) {
return done(null, bcrypt.compareSync(password, user.passhash) ? user : false);
}
}
));
/*passport.use(new BasicStrategy(
function(userid, plainTextPassword, done) {
const user = users_db.get('users').find({name: userid}).value();
if (user) {
var hashedPwd = user.passhash;
return bcrypt.compare(plainTextPassword, hashedPwd);
} else {
return false;
}
}
));
*/
/*************************************************************
* This is a wrapper for auth.passport.authenticate().
* We use this to change WWW-Authenticate header so
* the browser doesn't pop-up challenge dialog box by default.
* Browser's will pop-up up dialog when status is 401 and
* "WWW-Authenticate:Basic..."
*************************************************************/
/*
exports.authenticateViaPassport = function(req, res, next) {
exports.passport.authenticate('basic',{session:false},
function(err, user, info) {
if(!user){
res.set('WWW-Authenticate', 'x'+info); // change to xBasic
res.status(401).send('Invalid Authentication');
} else {
req.user = user;
next();
}
}
)(req, res, next);
};
*/
/**********************************
* Generating/Signing a JWT token
* And attaches the user info into
* the payload to be sent on every
* request.
*********************************/
exports.generateJWT = function(req, res, next) {
var payload = {
exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION
, user: req.user.uid
};
req.token = jwt.sign(payload, SERVER_SECRET);
next();
}
exports.returnAuthResponse = function(req, res) {
res.status(200).json({
user: req.user,
token: req.token,
permissions: exports.userPermissions(req.user.uid),
available_permissions: consts['AVAILABLE_PERMISSIONS']
});
}
/***************************************
* Authorization: middleware that checks the
* JWT token for validity before allowing
* the user to access anything.
*
* It also passes the user object to the next
* middleware through res.locals
**************************************/
exports.ensureAuthenticatedElseError = function(req, res, next) {
var token = getToken(req.query);
if( token ) {
try {
var payload = jwt.verify(token, SERVER_SECRET);
// console.log('payload: ' + JSON.stringify(payload));
// check if user still exists in database if you'd like
res.locals.user = payload.user;
next();
} catch(err) {
res.status(401).send('Invalid Authentication');
}
} else {
res.status(401).send('Missing Authorization header');
}
}
// change password
exports.changeUserPassword = async function(user_uid, new_pass) {
return new Promise(resolve => {
bcrypt.hash(new_pass, saltRounds)
.then(function(hash) {
users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write();
resolve(true);
}).catch(err => {
resolve(false);
});
});
}
// change user permissions
exports.changeUserPermissions = function(user_uid, permission, new_value) {
try {
const user_db_obj = users_db.get('users').find({uid: user_uid});
user_db_obj.get('permissions').pull(permission).write();
user_db_obj.get('permission_overrides').pull(permission).write();
if (new_value === 'yes') {
user_db_obj.get('permissions').push(permission).write();
user_db_obj.get('permission_overrides').push(permission).write();
} else if (new_value === 'no') {
user_db_obj.get('permission_overrides').push(permission).write();
}
return true;
} catch (err) {
logger.error(err);
return false;
}
}
// change role permissions
exports.changeRolePermissions = function(role, permission, new_value) {
try {
const role_db_obj = users_db.get('roles').get(role);
role_db_obj.get('permissions').pull(permission).write();
if (new_value === 'yes') {
role_db_obj.get('permissions').push(permission).write();
}
return true;
} catch (err) {
logger.error(err);
return false;
}
}
exports.adminExists = function() {
return !!users_db.get('users').find({uid: 'admin'}).value();
}
// video stuff
exports.getUserVideos = function(user_uid, type) {
const user = users_db.get('users').find({uid: user_uid}).value();
return user['files'][type];
}
exports.getUserVideo = function(user_uid, file_uid, type, requireSharing = false) {
if (!type) {
file = users_db.get('users').find({uid: user_uid}).get(`files.audio`).find({uid: file_uid}).value();
if (!file) {
file = users_db.get('users').find({uid: user_uid}).get(`files.video`).find({uid: file_uid}).value();
if (file) type = 'video';
} else {
type = 'audio';
}
}
if (!file && type) file = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value();
// prevent unauthorized users from accessing the file info
if (requireSharing && !file['sharingEnabled']) file = null;
return file;
}
exports.addPlaylist = function(user_uid, new_playlist, type) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).push(new_playlist).write();
return true;
}
exports.updatePlaylist = function(user_uid, playlistID, new_filenames, type) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).assign({fileNames: new_filenames});
return true;
}
exports.removePlaylist = function(user_uid, playlistID, type) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).remove({id: playlistID}).write();
return true;
}
exports.getUserPlaylists = function(user_uid, type) {
const user = users_db.get('users').find({uid: user_uid}).value();
return user['playlists'][type];
}
exports.getUserPlaylist = function(user_uid, playlistID, type, requireSharing = false) {
let playlist = null;
if (!type) {
playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.audio`).find({id: playlistID}).value();
if (!playlist) {
playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.video`).find({id: playlistID}).value();
if (playlist) type = 'video';
} else {
type = 'audio';
}
}
if (!playlist) playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).value();
// prevent unauthorized users from accessing the file info
if (requireSharing && !playlist['sharingEnabled']) playlist = null;
return playlist;
}
exports.registerUserFile = function(user_uid, file_object, type) {
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
.remove({
path: file_object['path']
}).write();
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
.push(file_object)
.write();
}
exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = false) {
let success = false;
const file_obj = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value();
if (file_obj) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
// close descriptors
if (config_api.descriptors[file_obj.id]) {
try {
for (let i = 0; i < config_api.descriptors[file_obj.id].length; i++) {
config_api.descriptors[file_obj.id][i].destroy();
}
} catch(e) {
}
}
const full_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext);
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
.remove({
uid: file_uid
}).write();
if (fs.existsSync(full_path)) {
// remove json and file
const json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + '.info.json');
const alternate_json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext + '.info.json');
let youtube_id = null;
if (fs.existsSync(json_path)) {
youtube_id = fs.readJSONSync(json_path).id;
fs.unlinkSync(json_path);
} else if (fs.existsSync(alternate_json_path)) {
youtube_id = fs.readJSONSync(alternate_json_path).id;
fs.unlinkSync(alternate_json_path);
}
fs.unlinkSync(full_path);
// do archive stuff
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_path = path.join(usersFileFolder, user_uid, 'archives', `archive_${type}.txt`);
// use subscriptions API to remove video from the archive file, and write it to the blacklist
if (fs.existsSync(archive_path)) {
const line = youtube_id ? subscriptions_api.removeIDFromArchive(archive_path, youtube_id) : null;
if (blacklistMode && line) {
let blacklistPath = path.join(usersFileFolder, user_uid, 'archives', `blacklist_${type}.txt`);
// adds newline to the beginning of the line
line = '\n' + line;
fs.appendFileSync(blacklistPath, line);
}
} else {
logger.info('Could not find archive file for audio files. Creating...');
fs.closeSync(fs.openSync(archive_path, 'w'));
}
}
}
success = true;
} else {
success = false;
logger.warn(`User file ${file_uid} does not exist!`);
}
return success;
}
exports.changeSharingMode = function(user_uid, file_uid, type, is_playlist, enabled) {
let success = false;
const user_db_obj = users_db.get('users').find({uid: user_uid});
if (user_db_obj.value()) {
const file_db_obj = is_playlist ? user_db_obj.get(`playlists.${type}`).find({id: file_uid}) : user_db_obj.get(`files.${type}`).find({uid: file_uid});
if (file_db_obj.value()) {
success = true;
file_db_obj.assign({sharingEnabled: enabled}).write();
}
}
return success;
}
exports.userHasPermission = function(user_uid, permission) {
const user_obj = users_db.get('users').find({uid: user_uid}).value();
const role = user_obj['role'];
if (!role) {
// role doesn't exist
logger.error('Invalid role ' + role);
return false;
}
const role_permissions = (users_db.get('roles').value())['permissions'];
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
// check if user has a negative/positive override
if (user_has_explicit_permission && permission_in_overrides) {
// positive override
return true;
} else if (!user_has_explicit_permission && permission_in_overrides) {
// negative override
return false;
}
// no overrides, let's check if the role has the permission
if (role_permissions.includes(permission)) {
return true;
} else {
logger.verbose(`User ${user_uid} failed to get permission ${permission}`);
return false;
}
}
exports.userPermissions = function(user_uid) {
let user_permissions = [];
const user_obj = users_db.get('users').find({uid: user_uid}).value();
const role = user_obj['role'];
if (!role) {
// role doesn't exist
logger.error('Invalid role ' + role);
return null;
}
const role_permissions = users_db.get('roles').get(role).get('permissions').value()
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
let permission = consts['AVAILABLE_PERMISSIONS'][i];
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
// check if user has a negative/positive override
if (user_has_explicit_permission && permission_in_overrides) {
// positive override
user_permissions.push(permission);
} else if (!user_has_explicit_permission && permission_in_overrides) {
// negative override
continue;
}
// no overrides, let's check if the role has the permission
if (role_permissions.includes(permission)) {
user_permissions.push(permission);
} else {
continue;
}
}
return user_permissions;
}
function getToken(queryParams) {
if (queryParams && queryParams.jwt) {
var parted = queryParams.jwt.split(' ');
if (parted.length === 2) {
return parted[1];
} else {
return null;
}
} else {
return null;
}
};

View File

@@ -8,6 +8,27 @@ let configPath = debugMode ? '../src/assets/default.json' : 'appdata/default.jso
var logger = null;
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_logger) {
setLogger(input_logger);
ensureConfigFileExists();
ensureConfigItemsExist();
}
function ensureConfigItemsExist() {
const config_keys = Object.keys(CONFIG_ITEMS);
for (let i = 0; i < config_keys.length; i++) {
const config_key = config_keys[i];
getConfigItem(config_key);
}
}
function ensureConfigFileExists() {
if (!fs.existsSync(configPath)) {
logger.info('Cannot find config file. Creating one with default values...');
fs.writeFileSync(configPath, JSON.stringify(DEFAULT_CONFIG, null, 2));
}
}
// https://stackoverflow.com/questions/6491463/accessing-nested-javascript-objects-with-string-key
Object.byString = function(o, s) {
s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
@@ -49,8 +70,8 @@ function configExistsCheck() {
* Gets config file and returns as a json
*/
function getConfigFile() {
let raw_data = fs.readFileSync(configPath);
try {
let raw_data = fs.readFileSync(configPath);
let parsed_data = JSON.parse(raw_data);
return parsed_data;
} catch(e) {
@@ -77,7 +98,7 @@ function getConfigItem(key) {
let path = CONFIG_ITEMS[key]['path'];
const val = Object.byString(config_json, path);
if (val === undefined && Object.byString(DEFAULT_CONFIG, path)) {
logger.warning(`Cannot find config with key '${key}'. Creating one with the default value...`);
logger.warn(`Cannot find config with key '${key}'. Creating one with the default value...`);
setConfigItem(key, Object.byString(DEFAULT_CONFIG, path));
return Object.byString(DEFAULT_CONFIG, path);
}
@@ -88,10 +109,18 @@ function setConfigItem(key, value) {
let success = false;
let config_json = getConfigFile();
let path = CONFIG_ITEMS[key]['path'];
let parent_path = getParentPath(path);
let element_name = getElementNameInConfig(path);
let parent_path = getParentPath(path);
let parent_object = Object.byString(config_json, parent_path);
if (!parent_object) {
let parent_parent_path = getParentPath(parent_path);
let parent_parent_object = Object.byString(config_json, parent_parent_path);
let parent_path_arr = parent_path.split('.');
let parent_parent_single_key = parent_path_arr[parent_path_arr.length-1];
parent_parent_object[parent_parent_single_key] = {};
parent_object = Object.byString(config_json, parent_path);
}
if (value === 'false' || value === 'true') {
parent_object[element_name] = (value === 'true');
} else {
@@ -134,15 +163,16 @@ module.exports = {
setConfigFile: setConfigFile,
configExistsCheck: configExistsCheck,
CONFIG_ITEMS: CONFIG_ITEMS,
setLogger: setLogger
initialize: initialize,
descriptors: {}
}
DEFAULT_CONFIG = {
"YoutubeDLMaterial": {
"Host": {
"url": "http://example.com",
"port": "17442"
},
"Host": {
"url": "http://example.com",
"port": "17442"
},
"Encryption": {
"use-encryption": false,
"cert-file-path": "/etc/letsencrypt/live/example.com/fullchain.pem",
@@ -155,12 +185,13 @@ DEFAULT_CONFIG = {
"custom_args": ""
},
"Extra": {
"title_top": "Youtube Downloader",
"title_top": "YoutubeDL-Material",
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false,
"allow_multi_download_mode": true,
"settings_pin_required": false
"settings_pin_required": false,
"enable_downloads_manager": true
},
"API": {
"use_API_key": false,
@@ -178,10 +209,17 @@ DEFAULT_CONFIG = {
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Users": {
"base_path": "users/",
"allow_registration": true
},
"Advanced": {
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"allow_advanced_download": false
"multi_user_mode": false,
"allow_advanced_download": false,
"use_cookies": false,
"logger_level": "info"
}
}
}

View File

@@ -66,6 +66,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_settings_pin_required',
'path': 'YoutubeDLMaterial.Extra.settings_pin_required'
},
'ytdl_enable_downloads_manager': {
'key': 'ytdl_enable_downloads_manager',
'path': 'YoutubeDLMaterial.Extra.enable_downloads_manager'
},
// API
'ytdl_use_api_key': {
@@ -117,6 +121,16 @@ let CONFIG_ITEMS = {
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_use_youtubedl_archive'
},
// Users
'ytdl_users_base_path': {
'key': 'ytdl_users_base_path',
'path': 'YoutubeDLMaterial.Users.base_path'
},
'ytdl_allow_registration': {
'key': 'ytdl_allow_registration',
'path': 'YoutubeDLMaterial.Users.allow_registration'
},
// Advanced
'ytdl_use_default_downloading_agent': {
'key': 'ytdl_use_default_downloading_agent',
@@ -126,13 +140,35 @@ let CONFIG_ITEMS = {
'key': 'ytdl_custom_downloading_agent',
'path': 'YoutubeDLMaterial.Advanced.custom_downloading_agent'
},
'ytdl_multi_user_mode': {
'key': 'ytdl_multi_user_mode',
'path': 'YoutubeDLMaterial.Advanced.multi_user_mode'
},
'ytdl_allow_advanced_download': {
'key': 'ytdl_allow_advanced_download',
'path': 'YoutubeDLMaterial.Advanced.allow_advanced_download'
},
'ytdl_use_cookies': {
'key': 'ytdl_use_cookies',
'path': 'YoutubeDLMaterial.Advanced.use_cookies'
},
'ytdl_logger_level': {
'key': 'ytdl_logger_level',
'path': 'YoutubeDLMaterial.Advanced.logger_level'
}
};
AVAILABLE_PERMISSIONS = [
'filemanager',
'settings',
'subscriptions',
'sharing',
'advanced_download',
'downloads_manager'
];
module.exports = {
CONFIG_ITEMS: CONFIG_ITEMS,
CURRENT_VERSION: 'v3.6'
AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS,
CURRENT_VERSION: 'v4.0'
}

View File

@@ -1,4 +1,3 @@
version: "2"
services:
ytdl_material:
@@ -10,6 +9,7 @@ services:
- ./audio:/app/audio
- ./video:/app/video
- ./subscriptions:/app/subscriptions
- ./users:/app/users
ports:
- "8998:17442"
image: tzahi12345/youtubedl-material:latest

3094
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -30,17 +30,25 @@
"dependencies": {
"archiver": "^3.1.1",
"async": "^3.1.0",
"bcrypt": "^4.0.1",
"compression": "^1.7.4",
"config": "^3.2.3",
"exe": "^1.0.2",
"express": "^4.17.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^9.0.0",
"jsonwebtoken": "^8.5.1",
"lowdb": "^1.0.0",
"md5": "^2.2.1",
"merge-files": "^0.1.2",
"multer": "^1.4.2",
"node-fetch": "^2.6.0",
"node-id3": "^0.1.14",
"nodemon": "^2.0.2",
"passport": "^0.4.1",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"progress": "^2.0.3",
"shortid": "^2.2.15",
"unzipper": "^0.10.10",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -63,9 +63,6 @@ MIT
@angular/forms
MIT
@angular/http
MIT
@angular/localize
MIT
@@ -103,9 +100,6 @@ MIT
@angular/router
MIT
@locl/core
AGPL-3.0-or-later
core-js
MIT
Copyright (c) 2014-2020 Denis Pushkarev
@@ -176,6 +170,26 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
fingerprintjs2
MIT
Fingerprintjs2 Modern & flexible browser fingerprint library v2
https://github.com/Valve/fingerprintjs2
Copyright (c) 2018 Jonas Haag (jonas@lophus.org)
Copyright (c) 2015 Valentin Vasilyev (valentin.vasilyev@outlook.com)
Licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) license.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL VALENTIN VASILYEV BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
ng-lazyload-image
MIT
The MIT License (MIT)
@@ -201,30 +215,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
ngx-content-loading
ngx-file-drop
MIT
MIT License
Copyright (c) 2017 Giancarlo Buomprisco
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
ngx-videogular
MIT
@@ -753,11 +745,35 @@ SOFTWARE.
web-animations-js
Apache-2.0
webpack
MIT
Copyright JS Foundation and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
zone.js
MIT
The MIT License
Copyright (c) 2010-2019 Google LLC. http://angular.io/license
Copyright (c) 2010-2020 Google LLC. http://angular.io/license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,4 +1,6 @@
{
"ccc7e92cbdd35e901acf9ad80941abee07bd8f60": "No es necesario incluir URL, solo todo después ",
"f41145afc02fd47ef0576ac79acd2c47ebbf4901": "Argumentos personalizados globales para descargas en la página de inicio.",
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "Crea una lista de reproducción",
"cff1428d10d59d14e45edec3c735a27b5482db59": "Nombre",
"f47e2d56dd8a145b2e9599da9730c049d52962a2": "Archivos de sonido",
@@ -14,9 +16,9 @@
"b2623aee44b70c9a4ba1fce16c8a593b0a4c7974": "Modificar",
"038ebcb2a89155d90c24fa1c17bfe83dbadc3c20": "Descargador de Youtube",
"6d2ec8898344c8955542b0542c942038ef28bb80": "Por favor entre una URL válida",
"a38ae1082fec79ba1f379978337385a539a28e73": "Calidad:",
"a38ae1082fec79ba1f379978337385a539a28e73": " Calidad ",
"4be966a9dcfbc9b54dfcc604b831c0289f847fa4": "Usa URL",
"d3f02f845e62cebd75fde451ab8479d2a8ad784d": "Ver:",
"d3f02f845e62cebd75fde451ab8479d2a8ad784d": " Ver ",
"4a9889d36910edc8323d7bab60858ab3da6d91df": "Solo audio",
"96a01fafe135afc58b0f8071a4ab00234495ce18": "Descarga múltiple",
"6a21ba5fb0ac804a525bf9ab168038c3ee88e661": "Descarga",
@@ -25,7 +27,7 @@
"b7ffe7c6586d6f3f18a9246806a7c7d5538ab43e": "Commando simulado:",
"4e4c721129466be9c3862294dc40241b64045998": "Usar argumentos personalizados",
"ad2f8ac8b7de7945b80c8e424484da94e597125f": "Argumentos personalizados",
"ccc7e92cbdd35e901acf9ad80941abee07bd8f60": "No es necesario incluir URL, solo todo después ",
"a6911c2157f1b775284bbe9654ce5eb30cf45d7f": "No es necesario incluir URL, solo todo después. Los argumentos se delimitan usando dos comas así: ,,",
"3a92a3443c65a52f37ca7efb8f453b35dbefbf29": "Usar salida personalizada",
"d9c02face477f2f9cdaae318ccee5f89856851fb": "Salida personalizada",
"fcfd4675b4c90f08d18d3abede9a9a4dff4cfdc7": "Documentación",
@@ -53,48 +55,52 @@
"826b25211922a1b46436589233cb6f1a163d89b7": "Eliminar",
"34504b488c24c27e68089be549f0eeae6ebaf30b": "Eliminar y pones en la lista negra",
"121cc5391cd2a5115bc2b3160379ee5b36cd7716": "Configuraciones",
"fe22ca53e651df951dac25b67c17894b0980f767": "Host",
"801b98c6f02fe3b32f6afa3ee854c99ed83474e6": "URL",
"54c512cca1923ab72faf1a0bd98d3d172469629a": "URL desde la que se accederá a esta aplicación, sin el puerto.",
"cb2741a46e3560f6bc6dfd99d385e86b08b26d72": "Puerto",
"22e8f1d0423a3b784fe40fab187b92c06541b577": "Puerto deseado. El valor predeterminado es 17442.",
"948758e1412bb2ecdb98e3a4f1cbb6d7458456f2": "Cifrado",
"d4477669a560750d2064051a510ef4d7679e2f3e": "Modo multiusuario",
"2eb03565fcdce7a7a67abc277a936a32fcf51557": "Ruta base de usuarios",
"a64505c41150663968e277ec9b3ddaa5f4838798": "Ruta base para los usuarios y sus videos descargados.",
"cbe16a57be414e84b6a68309d08fad894df797d6": "Usa cifrado",
"0c1875a79b7ecc792cc1bebca3e063e40b5764f9": "Ruta del archivo de certificado",
"736551b93461d2de64b118cf4043eee1d1c2cb2c": "Ruta de archivo de clave",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "Descargador",
"ab2756805742e84ad0cc0468f4be2d8aa9f855a5": "Ruta de la carpeta de audio",
"c2c89cdf45d46ea64d2ed2f9ac15dfa4d77e26ca": "Ruta para descargas de solo audio. Es relativo a la carpeta raíz de YTDL-Material.",
"46826331da1949bd6fb74624447057099c9d20cd": "Ruta de la carpeta de video",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "Ruta de descarga de videos. Es relativo a la carpeta raíz de YTDL-Material.",
"f41145afc02fd47ef0576ac79acd2c47ebbf4901": "Argumentos personalizados globales para descargas en la página de inicio.",
"78e49b7339b4fa7184dd21bcaae107ce9b7076f6": "Usa el archivo de youtube-dl",
"d5f69691f9f05711633128b5a3db696783266b58": "Extra",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "Título superior",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "Administrador de archivos habilitado",
"c33bd5392b39dbed36b8e5a1145163a15d45835f": "Permitir selección de calidad",
"bda5508e24e0d77debb28bcd9194d8fefb1cfb92": "Modo de solo descarga",
"09d31c803a7252658694e1e3176b97f5655a3fe3": "Permitir el modo de descarga múltiple",
"d8b47221b5af9e9e4cd5cb434d76fc0c91611409": "Requiere pin para la configuración",
"f5ec7b2cdf87d41154f4fcbc86e856314409dcb9": "Establecer nuevo pin",
"e351b40b3869a5c7d19c3d4918cb1ac7aaab95c4": "API",
"d5d7c61349f3b0859336066e6d453fc35d334fe5": "Utilizar la API de YouTube",
"ce10d31febb3d9d60c160750570310f303a22c22": "Clave API de YouTube",
"8602e313cdfa7c4cc475ccbe86459fce3c3fd986": "¡Generar una clave es fácil!",
"60c855c323706a04ccd2ff22d634bde9b6233bbf": "Temas",
"ff7cee38a2259526c519f878e71b964f41db4348": "Defecto",
"adb4562d2dbd3584370e44496969d58c511ecb63": "Oscura",
"7a6bacee4c31cb5c0ac2d24274fb4610d8858602": "Permitir cambio de tema",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "Suscripciones",
"4e3120311801c4acd18de7146add2ee4a4417773": "Permitir suscripciones",
"4bee2a4bef2d26d37c9b353c278e24e5cd309ce3": "Ruta base de suscripciones",
"bc9892814ee2d119ae94378c905ea440a249b84a": "Ruta base para videos de sus canales y listas de reproducción suscritos. Es relativo a la carpeta raíz de YTDL-Material.",
"5bef4b25ba680da7fff06b86a91b1fc7e6a926e3": "Intervalo de comprobación",
"0f56a7449b77630c114615395bbda4cab398efd8": "La unidad es segundos, solo incluye números.",
"78e49b7339b4fa7184dd21bcaae107ce9b7076f6": "Usa el archivo de youtube-dl",
"fa9fe4255231dd1cc6b29d3d254a25cb7c764f0f": "Con la función de archivo de youtube-dl,",
"09006404cccc24b7a8f8d1ce0b39f2761ab841d8": "los videos descargados de sus suscripciones se graban en un archivo de texto en el subdirectorio del archivo de suscripciones.",
"29ed79a98fc01e7f9537777598e31dbde3aa7981": "Esto permite eliminar videos de sus suscripciones de forma permanente sin darse de baja y le permite grabar los videos que descargó en caso de pérdida de datos.",
"0feab442129ba239106e55cf029069d3d4adeadc": "Extensiones",
"27a56aad79d8b61269ed303f11664cc78bcc2522": "Tema",
"ff7cee38a2259526c519f878e71b964f41db4348": "Defecto",
"adb4562d2dbd3584370e44496969d58c511ecb63": "Oscura",
"7a6bacee4c31cb5c0ac2d24274fb4610d8858602": "Permitir cambio de tema",
"fe46ccaae902ce974e2441abe752399288298619": "Idioma",
"82421c3e46a0453a70c42900eab51d58d79e6599": "Principal",
"ab2756805742e84ad0cc0468f4be2d8aa9f855a5": "Ruta de la carpeta de audio",
"c2c89cdf45d46ea64d2ed2f9ac15dfa4d77e26ca": "Ruta para descargas de solo audio. Es relativo a la carpeta raíz de YTDL-Material.",
"46826331da1949bd6fb74624447057099c9d20cd": "Ruta de la carpeta de video",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "Ruta de descarga de videos. Es relativo a la carpeta raíz de YTDL-Material.",
"6b995e7130b4d667eaab6c5f61b362ace486d26d": "Argumentos personalizados globales para descargas en la página de inicio. Los argumentos se delimitan usando dos comas así: ,,",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "Descargador",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "Título superior",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "Administrador de archivos habilitado",
"a5a1be0a5df07de9eec57f5d2a86ed0204b2e75a": "Administrador de descargas habilitado",
"c33bd5392b39dbed36b8e5a1145163a15d45835f": "Permitir selección de calidad",
"bda5508e24e0d77debb28bcd9194d8fefb1cfb92": "Modo de solo descarga",
"09d31c803a7252658694e1e3176b97f5655a3fe3": "Permitir el modo de descarga múltiple",
"d8b47221b5af9e9e4cd5cb434d76fc0c91611409": "Requiere pin para la configuración",
"f5ec7b2cdf87d41154f4fcbc86e856314409dcb9": "Establecer nuevo pin",
"1c4dbce56d96b8974aac24a02f7ab2ee81415014": "Habilitar API pública",
"23bd81dcc30b74d06279a26d7a42e8901c1b124e": "Clave API pública",
"41016a73d8ad85e6cb26dffa0a8fab9fe8f60d8e": "Ver documentación",
"1b258b258b4cc475ceb2871305b61756b0134f4a": "Generar",
"d5d7c61349f3b0859336066e6d453fc35d334fe5": "Utilizar la API de YouTube",
"ce10d31febb3d9d60c160750570310f303a22c22": "Clave API de YouTube",
"8602e313cdfa7c4cc475ccbe86459fce3c3fd986": "¡Generar una clave es fácil!",
"9b3cedfa83c6d7acb3210953289d1be4aab115c7": "¡Haga clic aquí",
"7f09776373995003161235c0c8d02b7f91dbc4df": "para descargar la extensión Chrome oficial de YoutubeDL-Material manualmente.",
"5b5296423906ab3371fdb2b5a5aaa83acaa2ee52": "Debe cargar manualmente la extensión y modificar la configuración de la extensión para establecer la URL de la interfaz.",
@@ -102,9 +108,14 @@
"eb81be6b49e195e5307811d1d08a19259d411f37": "Instrucciones detalladas de configuración.",
"cb17ff8fe3961cf90f44bee97c88a3f3347a7e55": "No se requiere mucho más que cambiar la configuración de la extensión para establecer la URL de la interfaz.",
"61b81b11aad0b9d970ece2fce18405f07eac69c2": "Arrastra el enlace de abajo a tus marcadores, ¡y listo! Simplemente navegue hasta el video de YouTube que desea descargar y haga clic en el marcador.",
"bc2e854e111ecf2bd7db170da5e3c2ed08181d88": "Avanzado",
"c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "Generar bookmarklet solo de audio",
"d5f69691f9f05711633128b5a3db696783266b58": "Extra",
"5fab47f146b0a4b809dcebf3db9da94df6299ea1": "Usar agente de descarga predeterminado",
"ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "Seleccione un descargador",
"dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "Permitir descarga avanzada",
"bc2e854e111ecf2bd7db170da5e3c2ed08181d88": "Avanzado",
"37224420db54d4bc7696f157b779a7225f03ca9d": "Permitir registro de usuario",
"4d13a9cd5ed3dcee0eab22cb25198d43886942be": "Usuarios",
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "Salvar",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {Cerrar} false {Cancelar} other {Otro} }",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "Sobre YoutubeDL-Material",
@@ -113,17 +124,44 @@
"a45e3b05f0529dc5246d70ef62304c94426d4c81": "Versión instalada:",
"e22f3a5351944f3a1a10cfc7da6f65dfbe0037fe": "Comprobando actualizaciones...",
"a16e92385b4fd9677bb830a4b796b8b79c113290": "Actualización disponible",
"189b28aaa19b3c51c6111ad039c4fd5e2a22e370": "Puede actualizar desde el menú de configuración.",
"b33536f59b94ec935a16bd6869d836895dc5300c": "¿Encontró un error o tiene una sugerencia?",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "para crear una cuestión!",
"42ff677ec14f111e88bd6cdd30145378e994d1bf": "Tu perfil",
"ac9d09de42edca1296371e4d801349c9096ac8de": "UID:",
"a5ed099ffc9e96f6970df843289ade8a7d20ab9f": "Creado:",
"fa96f2137af0a24e6d6d54c598c0af7d5d5ad344": "Usted no se ha identificado.",
"6765b4c916060f6bc42d9bb69e80377dbcb5e4e9": "Identificarse",
"bb694b49d408265c91c62799c2b3a7e3151c824d": "Salir",
"a1dbca87b9f36d2b06a5cbcffb5814c4ae9b798a": "Crear cuenta de administrador",
"2d2adf3ca26a676bca2269295b7455a26fd26980": "No se detectó una cuenta de administrador predeterminada. Esto creará y establecerá la contraseña para una cuenta de administrador con el nombre de usuario como 'admin'.",
"70a67e04629f6d412db0a12d51820b480788d795": "Crear",
"994363f08f9fbfa3b3994ff7b35c6904fdff18d8": "Perfil",
"004b222ff9ef9dd4771b777950ca1d0e4cd4348a": "Sobre",
"92eee6be6de0b11c924e3ab27db30257159c0a7c": "Inicio",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "Suscripciones",
"822fab38216f64e8166d368b59fe756ca39d301b": "Descargas",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "Compartir lista de reproducción",
"15da89490e04496ca9ea1e1b3d44fb5efd4a75d9": "Compartir vídeo",
"1d540dcd271b316545d070f9d182c372d923aadd": "Compartir audio",
"1f6d14a780a37a97899dc611881e6bc971268285": "Habilitar compartir",
"6580b6a950d952df847cb3d8e7176720a740adc8": "Usar marca de tiempo",
"4f2ed9e71a7c981db3e50ae2fedb28aff2ec4e6c": "Segundos",
"3a6e5a6aa78ca864f6542410c5dafb6334538106": "Copiar al Portapapeles",
"5b3075e8dc3f3921ec316b0bd83b6d14a06c1a4f": "Guardar cambios",
"4f8b2bb476981727ab34ed40fde1218361f92c45": "Detalles",
"383000ab16bf415d5a1d61d7eb7b5959c72a9515": "Se ha producido un error:",
"77b0c73840665945b25bd128709aa64c8f017e1c": "Inicio de descarga:",
"08ff9375ec078065bcdd7637b7ea65fce2979266": "Fin de descarga:",
"ad127117f9471612f47d01eae09709da444a36a4": "Ruta(s) del archivo:",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "Suscríbase a la lista de reproducción o al canal",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "La lista de reproducción o la URL del canal",
"08f5d0ef937ae17feb1b04aff15ad88911e87baf": "Nombre personalizado",
"f3f62aa84d59f3a8b900cc9a7eec3ef279a7b4e7": "Esto es opcional",
"ea30873bd3f0d5e4fb2378eec3f0a1db77634a28": "Descargar todas las cargas",
"28a678e9cabf86e44c32594c43fa0e890135c20f": "Descargar videos subidos en el último",
"408ca4911457e84a348cecf214f02c69289aa8f1": "Modo de solo transmisión",
"d0336848b0c375a1c25ba369b3481ee383217a4f": "Subscribe",
"e78c0d60ac39787f62c9159646fe0b3c1ed55a1d": "Tipo:",
"a44d86aa1e6c20ced07aca3a7c081d8db9ded1c6": "Archivo:",
"8efc77bf327659c0fec1f518cf48a98cdcd9dddf": "Exportar el archivo",
@@ -137,5 +175,26 @@
"7e892ba15f2c6c17e83510e273b3e10fc32ea016": "Buscar",
"2054791b822475aeaea95c0119113de3200f5e1c": "Duración:",
"94e01842dcee90531caa52e4147f70679bac87fe": "Eliminar y volver a descargar",
"2031adb51e07a41844e8ba7704b054e98345c9c1": "Borrar para siempre"
"2031adb51e07a41844e8ba7704b054e98345c9c1": "Borrar para siempre",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "Updater",
"1372e61c5bd06100844bd43b98b016aabc468f62": "Seleccione una versión:",
"cfc2f436ec2beffb042e7511a73c89c372e86a6c": "Registrarse",
"a1ad8b1be9be43b5183bd2c3186d4e19496f2a0b": "ID de sesión:",
"eb98135e35af26a9a326ee69bd8ff104d36dd8ec": "(actual)",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "¡No hay descargas disponibles!",
"b7ff2e2b909c53abe088fe60b9f4b6ac7757247f": "Registrar un usuario",
"024886ca34a6f309e3e51c2ed849320592c3faaa": "Nombre de usuario",
"2bd201aea09e43fbfd3cd15ec0499b6755302329": "Administrar usuario",
"29c97c8e76763bb15b6d515648fa5bd1eb0f7510": "UID de usuario:",
"e70e209561583f360b1e9cefd2cbb1fe434b6229": "Nueva contraseña",
"6498fa1b8f563988f769654a75411bb8060134b9": "Establecer nueva contraseña",
"40da072004086c9ec00d125165da91eaade7f541": "Uso por defecto",
"4f20f2d5a6882190892e58b85f6ccbedfa737952": "Si",
"3d3ae7deebc5949b0c1c78b9847886a94321d9fd": "No",
"57c6c05d8ebf4ef1180c2705033c044f655bb2c4": "Gestionar rol",
"746f64ddd9001ac456327cd9a3d5152203a4b93c": " Nombre de usuario ",
"52c1447c1ec9570a2a3025c7e566557b8d19ed92": " Rol ",
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": " Acciones ",
"4d92a0395dd66778a931460118626c5794a3fc7a": "Agregar Usuarios",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "Editar Rol"
}

View File

@@ -14,5 +14,5 @@
<link rel="stylesheet" href="styles.5112d6db78cf21541598.css"></head>
<body>
<app-root></app-root>
<script src="runtime-es2015.3e431b3a15319f824273.js" type="module"></script><script src="runtime-es5.3e431b3a15319f824273.js" nomodule defer></script><script src="polyfills-es5.613e2d10a30e7a079e64.js" nomodule defer></script><script src="polyfills-es2015.7bc3b3aedaaa5e653ec6.js" type="module"></script><script src="main-es2015.51368ad793059fc3e28c.js" type="module"></script><script src="main-es5.51368ad793059fc3e28c.js" nomodule defer></script></body>
<script src="runtime-es2015.06b6262a0d981fd4885e.js" type="module"></script><script src="runtime-es5.06b6262a0d981fd4885e.js" nomodule defer></script><script src="polyfills-es5.7f923c8f5afda210edd3.js" nomodule defer></script><script src="polyfills-es2015.5b408f108bcea938a7e2.js" type="module"></script><script src="main-es2015.0cbc545a4a3bee376826.js" type="module"></script><script src="main-es5.0cbc545a4a3bee376826.js" nomodule defer></script></body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
!function(e){function r(r){for(var n,i,a=r[0],c=r[1],l=r[2],p=0,s=[];p<a.length;p++)i=a[p],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&s.push(o[i][0]),o[i]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);s.length;)s.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++)0!==o[t[a]]&&(n=!1);n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={0:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,a=document.createElement("script");a.charset="utf-8",a.timeout=120,i.nc&&a.setAttribute("nonce",i.nc),a.src=function(e){return i.p+""+({}[e]||e)+"-es2015."+{1:"cc1ef452b2945b55327a"}[e]+".js"}(e);var c=new Error;u=function(r){a.onerror=a.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:a})}),12e4);a.onerror=a.onload=u,document.head.appendChild(a)}return Promise.all(r)},i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,(function(r){return e[r]}).bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="",i.oe=function(e){throw console.error(e),e};var a=window.webpackJsonp=window.webpackJsonp||[],c=a.push.bind(a);a.push=r,a=a.slice();for(var l=0;l<a.length;l++)r(a[l]);var f=c;t()}([]);

View File

@@ -1 +0,0 @@
!function(e){function r(r){for(var n,a,i=r[0],c=r[1],l=r[2],p=0,s=[];p<i.length;p++)a=i[p],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);s.length;)s.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++)0!==o[t[i]]&&(n=!1);n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={0:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,i=document.createElement("script");i.charset="utf-8",i.timeout=120,a.nc&&i.setAttribute("nonce",a.nc),i.src=function(e){return a.p+""+({}[e]||e)+"-es2015."+{1:"8a2b309101ca7894f706"}[e]+".js"}(e);var c=new Error;u=function(r){i.onerror=i.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:i})}),12e4);i.onerror=i.onload=u,document.head.appendChild(i)}return Promise.all(r)},a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,(function(r){return e[r]}).bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="",a.oe=function(e){throw console.error(e),e};var i=window.webpackJsonp=window.webpackJsonp||[],c=i.push.bind(i);i.push=r,i=i.slice();for(var l=0;l<i.length;l++)r(i[l]);var f=c;t()}([]);

View File

@@ -0,0 +1 @@
!function(e){function r(r){for(var n,i,a=r[0],c=r[1],l=r[2],p=0,s=[];p<a.length;p++)i=a[p],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&s.push(o[i][0]),o[i]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);s.length;)s.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++)0!==o[t[a]]&&(n=!1);n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={0:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,a=document.createElement("script");a.charset="utf-8",a.timeout=120,i.nc&&a.setAttribute("nonce",i.nc),a.src=function(e){return i.p+""+({}[e]||e)+"-es5."+{1:"cc1ef452b2945b55327a"}[e]+".js"}(e);var c=new Error;u=function(r){a.onerror=a.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:a})}),12e4);a.onerror=a.onload=u,document.head.appendChild(a)}return Promise.all(r)},i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,(function(r){return e[r]}).bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="",i.oe=function(e){throw console.error(e),e};var a=window.webpackJsonp=window.webpackJsonp||[],c=a.push.bind(a);a.push=r,a=a.slice();for(var l=0;l<a.length;l++)r(a[l]);var f=c;t()}([]);

View File

@@ -1 +0,0 @@
!function(e){function r(r){for(var n,a,i=r[0],c=r[1],l=r[2],p=0,s=[];p<i.length;p++)a=i[p],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);s.length;)s.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++)0!==o[t[i]]&&(n=!1);n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={0:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,i=document.createElement("script");i.charset="utf-8",i.timeout=120,a.nc&&i.setAttribute("nonce",a.nc),i.src=function(e){return a.p+""+({}[e]||e)+"-es5."+{1:"8a2b309101ca7894f706"}[e]+".js"}(e);var c=new Error;u=function(r){i.onerror=i.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:i})}),12e4);i.onerror=i.onload=u,document.head.appendChild(i)}return Promise.all(r)},a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,(function(r){return e[r]}).bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="",a.oe=function(e){throw console.error(e),e};var i=window.webpackJsonp=window.webpackJsonp||[],c=i.push.bind(i);i.push=r,i=i.slice();for(var l=0;l<i.length;l++)r(i[l]);var f=c;t()}([]);

View File

@@ -1,22 +1,26 @@
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
var fs = require('fs');
var fs = require('fs-extra');
const { uuid } = require('uuidv4');
var path = require('path');
var youtubedl = require('youtube-dl');
const config_api = require('./config');
const adapter = new FileSync('./appdata/db.json');
const db = low(adapter)
const debugMode = process.env.YTDL_MODE === 'debug';
var logger = null;
var db = null;
var users_db = null;
function setDB(input_db, input_users_db) { db = input_db; users_db = input_users_db }
function setLogger(input_logger) { logger = input_logger; }
async function subscribe(sub) {
function initialize(input_db, input_users_db, input_logger) {
setDB(input_db, input_users_db);
setLogger(input_logger);
}
async function subscribe(sub, user_uid = null) {
const result_obj = {
success: false,
error: ''
@@ -25,7 +29,14 @@ async function subscribe(sub) {
// sub should just have url and name. here we will get isPlaylist and path
sub.isPlaylist = sub.url.includes('playlist');
if (db.get('subscriptions').find({url: sub.url}).value()) {
let url_exists = false;
if (user_uid)
url_exists = !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({url: sub.url}).value()
else
url_exists = !!db.get('subscriptions').find({url: sub.url}).value();
if (url_exists) {
logger.info('Sub already exists');
result_obj.error = 'Subcription with URL ' + sub.url + ' already exists!';
resolve(result_obj);
@@ -33,19 +44,27 @@ async function subscribe(sub) {
}
// add sub to db
db.get('subscriptions').push(sub).write();
if (user_uid)
users_db.get('users').find({uid: user_uid}).get('subscriptions').push(sub).write();
else
db.get('subscriptions').push(sub).write();
let success = await getSubscriptionInfo(sub);
let success = await getSubscriptionInfo(sub, user_uid);
result_obj.success = success;
result_obj.sub = sub;
getVideosForSub(sub);
getVideosForSub(sub, user_uid);
resolve(result_obj);
});
}
async function getSubscriptionInfo(sub) {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
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');
return new Promise(resolve => {
// get videos
let downloadConfig = ['--dump-json', '--playlist-end', '1']
@@ -71,31 +90,33 @@ async function getSubscriptionInfo(sub) {
if (!output_json) {
continue;
}
if (!sub.name) {
sub.name = sub.isPlaylist ? output_json.playlist_title : output_json.uploader;
// if it's now valid, update
if (sub.name) {
db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
if (user_uid)
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
else
db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
}
}
if (!sub.archive) {
const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive');
if (useArchive && !sub.archive) {
// must create the archive
const archive_dir = basePath + 'archives/' + sub.name;
const archive_dir = path.join(__dirname, basePath, 'archives', sub.name);
const archive_path = path.join(archive_dir, 'archive.txt');
// creates archive directory and text file if it doesn't exist
if (!fs.existsSync(archive_dir)) {
fs.mkdirSync(archive_dir);
fs.closeSync(fs.openSync(archive_path, 'w'));
} else if (!fs.existsSync(archive_path)) {
fs.closeSync(fs.openSync(archive_path, 'w'));
}
fs.ensureDirSync(archive_dir);
fs.ensureFileSync(archive_path);
// updates subscription
sub.archive = archive_dir;
db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
if (user_uid)
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
else
db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
}
// TODO: get even more info
@@ -108,13 +129,20 @@ async function getSubscriptionInfo(sub) {
});
}
async function unsubscribe(sub, deleteMode) {
async function unsubscribe(sub, deleteMode, user_uid = null) {
return new Promise(async resolve => {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
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 result_obj = { success: false, error: '' };
let id = sub.id;
db.get('subscriptions').remove({id: id}).write();
if (user_uid)
users_db.get('users').find({uid: user_uid}).get('subscriptions').remove({id: id}).write();
else
db.get('subscriptions').remove({id: id}).write();
const appendedBasePath = getAppendedBasePath(sub, basePath);
if (deleteMode && fs.existsSync(appendedBasePath)) {
@@ -132,8 +160,12 @@ async function unsubscribe(sub, deleteMode) {
}
async function deleteSubscriptionFile(sub, file, deleteForever) {
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
async function deleteSubscriptionFile(sub, file, deleteForever, 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');
const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const name = file;
@@ -181,24 +213,37 @@ async function deleteSubscriptionFile(sub, file, deleteForever) {
});
}
async function getVideosForSub(sub) {
async function getVideosForSub(sub, user_uid = null) {
return new Promise(resolve => {
if (!subExists(sub.id)) {
if (!subExists(sub.id, user_uid)) {
resolve(false);
return;
}
const sub_db = db.get('subscriptions').find({id: sub.id});
const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
// get sub_db
let sub_db = null;
if (user_uid)
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
else
sub_db = db.get('subscriptions').find({id: sub.id});
// get basePath
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');
const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive');
let appendedBasePath = null
if (sub.name) {
appendedBasePath = getAppendedBasePath(sub, basePath);
} else {
appendedBasePath = basePath + (sub.isPlaylist ? 'playlists/%(playlist_title)s' : 'channels/%(uploader)s');
appendedBasePath = path.join(basePath, (sub.isPlaylist ? 'playlists/%(playlist_title)s' : 'channels/%(uploader)s'));
}
let downloadConfig = ['-o', appendedBasePath + '/%(title)s.mp4', '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4', '-ciw', '--write-annotations', '--write-thumbnail', '--write-info-json', '--print-json'];
let downloadConfig = ['-o', appendedBasePath + '/%(title)s.mp4', '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4', '-ciw', '--write-info-json', '--print-json'];
let archive_dir = null;
let archive_path = null;
@@ -220,11 +265,38 @@ async function getVideosForSub(sub) {
downloadConfig.push('--dateafter', sub.timerange);
}
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) {
if (fs.existsSync(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.');
}
}
// get videos
logger.verbose('Subscribe: getting videos for subscription ' + sub.name);
logger.verbose('Subscription: getting videos for subscription ' + sub.name);
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
if (err) {
logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) {
logger.error(err.stderr);
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]);
handleOutputJSON(sub, sub_db, output, i === 0)
if (err.stderr.includes(output['id']) && archive_path) {
// we found a video that errored! add it to the archive to prevent future errors
fs.appendFileSync(archive_path, output['id']);
}
}
} catch(e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
}
}
resolve(false);
} else if (output) {
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
@@ -242,17 +314,8 @@ async function getVideosForSub(sub) {
continue;
}
if (sub.streamingOnly) {
if (i === 0) {
sub_db.assign({videos: []}).write();
}
// remove unnecessary info
output_json.formats = null;
// add to db
sub_db.get('videos').push(output_json).write();
}
const reset_videos = i === 0;
handleOutputJSON(sub, sub_db, output_json, reset_videos);
// TODO: Potentially store downloaded files in db?
@@ -263,23 +326,46 @@ async function getVideosForSub(sub) {
});
}
function getAllSubscriptions() {
const subscriptions = db.get('subscriptions').value();
return subscriptions;
function handleOutputJSON(sub, sub_db, output_json, reset_videos = false) {
if (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();
}
}
function getSubscription(subID) {
return db.get('subscriptions').find({id: subID}).value();
function getAllSubscriptions(user_uid = null) {
if (user_uid)
return users_db.get('users').find({uid: user_uid}).get('subscriptions').value();
else
return db.get('subscriptions').value();
}
function subExists(subID) {
return !!db.get('subscriptions').find({id: subID}).value();
function getSubscription(subID, user_uid = null) {
if (user_uid)
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value();
else
return db.get('subscriptions').find({id: subID}).value();
}
function subExists(subID, user_uid = null) {
if (user_uid)
return !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value();
else
return !!db.get('subscriptions').find({id: subID}).value();
}
// helper functions
function getAppendedBasePath(sub, base_path) {
return base_path + (sub.isPlaylist ? 'playlists/' : 'channels/') + sub.name;
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
}
// https://stackoverflow.com/a/32197381/8088021
@@ -332,5 +418,6 @@ module.exports = {
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub,
removeIDFromArchive : removeIDFromArchive,
setLogger : setLogger
setLogger : setLogger,
initialize : initialize
}

View File

@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

View File

@@ -0,0 +1,4 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore

Binary file not shown.

16245
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "youtube-dl-material",
"version": "3.6",
"version": "4.0.0",
"license": "MIT",
"scripts": {
"ng": "ng",
@@ -33,7 +33,9 @@
"core-js": "^2.4.1",
"file-saver": "^2.0.2",
"filesize": "^6.1.0",
"fingerprintjs2": "^2.1.0",
"ng-lazyload-image": "^7.0.1",
"ngx-file-drop": "^9.0.1",
"ngx-videogular": "^9.0.1",
"rxjs": "^6.5.3",
"rxjs-compat": "^6.0.0-rc.0",

Binary file not shown.

View File

@@ -4,12 +4,18 @@ import { MainComponent } from './main/main.component';
import { PlayerComponent } from './player/player.component';
import { SubscriptionsComponent } from './subscriptions/subscriptions.component';
import { SubscriptionComponent } from './subscription/subscription/subscription.component';
import { PostsService } from './posts.services';
import { LoginComponent } from './components/login/login.component';
import { DownloadsComponent } from './components/downloads/downloads.component';
const routes: Routes = [
{ path: 'home', component: MainComponent },
{ path: 'player', component: PlayerComponent},
{ path: 'subscriptions', component: SubscriptionsComponent },
{ path: 'subscription', component: SubscriptionComponent },
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ 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: 'login', component: LoginComponent },
{ path: 'downloads', component: DownloadsComponent },
{ path: '', redirectTo: '/home', pathMatch: 'full' }
];
@NgModule({

View File

@@ -3,7 +3,7 @@
<mat-toolbar color="primary" class="sticky-toolbar top-toolbar">
<div class="flex-row" width="100%" height="100%">
<div class="flex-column" style="text-align: left; margin-top: 1px;">
<button #hamburgerMenu style="outline: none" *ngIf="router.url.split(';')[0] !== '/player' && allowSubscriptions" mat-icon-button aria-label="Toggle side navigation" (click)="toggleSidenav()"><mat-icon>menu</mat-icon></button>
<button #hamburgerMenu style="outline: none" *ngIf="router.url.split(';')[0] !== '/player'" mat-icon-button aria-label="Toggle side navigation" (click)="toggleSidenav()"><mat-icon>menu</mat-icon></button>
<button (click)="goBack()" *ngIf="router.url.split(';')[0] === '/player'" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
</div>
<div class="flex-column" style="text-align: center; margin-top: 5px;">
@@ -14,12 +14,16 @@
<div class="flex-column" style="text-align: right; align-items: flex-end;">
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #menuSettings="matMenu">
<button (click)="openProfileDialog()" *ngIf="postsService.isLoggedIn" mat-menu-item>
<mat-icon>person</mat-icon>
<span i18n="Profile menu label">Profile</span>
</button>
<button (click)="themeMenuItemClicked($event)" *ngIf="allowThemeChange" mat-menu-item>
<mat-icon>{{(postsService.theme.key === 'default') ? 'brightness_5' : 'brightness_2'}}</mat-icon>
<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 (click)="openSettingsDialog()" mat-menu-item>
<button *ngIf="!postsService.isLoggedIn || postsService.permissions.includes('settings')" (click)="openSettingsDialog()" mat-menu-item>
<mat-icon>settings</mat-icon>
<span i18n="Settings menu label">Settings</span>
</button>
@@ -36,8 +40,10 @@
<mat-sidenav-container style="height: 100%">
<mat-sidenav #sidenav>
<mat-nav-list>
<a mat-list-item (click)="sidenav.close()" routerLink='/home'><ng-container i18n="Navigation menu Home Page title">Home</ng-container></a>
<a mat-list-item (click)="sidenav.close()" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a>
<a *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn)" mat-list-item (click)="sidenav.close()" 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)="sidenav.close()" 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)="sidenav.close()" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content [style.background]="postsService.theme ? postsService.theme.background_color : null">

View File

@@ -23,6 +23,8 @@ import { THEMES_CONFIG } from '../themes';
import { SettingsComponent } from './settings/settings.component';
import { CheckOrSetPinDialogComponent } from './dialogs/check-or-set-pin-dialog/check-or-set-pin-dialog.component';
import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.component';
import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component';
import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component';
@Component({
selector: 'app-root',
@@ -39,6 +41,7 @@ export class AppComponent implements OnInit {
defaultTheme = null;
allowThemeChange = null;
allowSubscriptions = false;
enableDownloadsManager = false;
// defaults to true to prevent attack
settingsPinRequired = true;
@@ -61,8 +64,7 @@ export class AppComponent implements OnInit {
}
});
this.loadConfig();
this.postsService.settings_changed.subscribe(changed => {
this.postsService.config_reloaded.subscribe(changed => {
if (changed) {
this.loadConfig();
}
@@ -76,22 +78,18 @@ export class AppComponent implements OnInit {
loadConfig() {
// loading config
this.postsService.loadNavItems().subscribe(res => { // loads settings
const result = !this.postsService.debugMode ? res['config_file'] : res;
this.topBarTitle = result['YoutubeDLMaterial']['Extra']['title_top'];
this.settingsPinRequired = result['YoutubeDLMaterial']['Extra']['settings_pin_required'];
const themingExists = result['YoutubeDLMaterial']['Themes'];
this.defaultTheme = themingExists ? result['YoutubeDLMaterial']['Themes']['default_theme'] : 'default';
this.allowThemeChange = themingExists ? result['YoutubeDLMaterial']['Themes']['allow_theme_change'] : true;
this.allowSubscriptions = result['YoutubeDLMaterial']['Subscriptions']['allow_subscriptions'];
this.topBarTitle = this.postsService.config['Extra']['title_top'];
this.settingsPinRequired = this.postsService.config['Extra']['settings_pin_required'];
const themingExists = this.postsService.config['Themes'];
this.defaultTheme = themingExists ? this.postsService.config['Themes']['default_theme'] : 'default';
this.allowThemeChange = themingExists ? this.postsService.config['Themes']['allow_theme_change'] : true;
this.allowSubscriptions = this.postsService.config['Subscriptions']['allow_subscriptions'];
this.enableDownloadsManager = this.postsService.config['Extra']['enable_downloads_manager'];
// sets theme to config default if it doesn't exist
if (!localStorage.getItem('theme')) {
this.setTheme(themingExists ? this.defaultTheme : 'default');
}
}, error => {
console.log(error);
});
// sets theme to config default if it doesn't exist
if (!localStorage.getItem('theme')) {
this.setTheme(themingExists ? this.defaultTheme : 'default');
}
}
// theme stuff
@@ -153,6 +151,18 @@ onSetTheme(theme, old_theme) {
} else {
//
}
this.postsService.open_create_default_admin_dialog.subscribe(open => {
if (open) {
const dialogRef = this.dialog.open(SetDefaultAdminDialogComponent);
dialogRef.afterClosed().subscribe(success => {
if (success) {
if (this.router.url !== '/login') { this.router.navigate(['/login']); }
} else {
console.error('Failed to create default admin account. See logs for details.');
}
});
}
});
}
@@ -196,5 +206,11 @@ onSetTheme(theme, old_theme) {
});
}
openProfileDialog() {
const dialogRef = this.dialog.open(UserProfileDialogComponent, {
width: '60vw'
});
}
}

View File

@@ -1,5 +1,5 @@
import { BrowserModule } from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NgModule, LOCALE_ID } from '@angular/core';
import { registerLocaleData, CommonModule } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
@@ -25,18 +25,21 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTabsModule } from '@angular/material/tabs';
import {DragDropModule} from '@angular/cdk/drag-drop';
import {ClipboardModule} from '@angular/cdk/clipboard';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { PostsService } from 'app/posts.services';
import { FileCardComponent } from './file-card/file-card.component';
import {RouterModule} from '@angular/router';
import { RouterModule } from '@angular/router';
import { AppRoutingModule } from './app-routing.module';
import { MainComponent } from './main/main.component';
import { PlayerComponent } from './player/player.component';
import {VgCoreModule, VgControlsModule, VgOverlayPlayModule, VgBufferingModule} from 'ngx-videogular';
import { VgCoreModule, VgControlsModule, VgOverlayPlayModule, VgBufferingModule } from 'ngx-videogular';
import { InputDialogComponent } from './input-dialog/input-dialog.component';
import { LazyLoadImageModule, IsVisibleProps } from 'ng-lazyload-image';
import { audioFilesMouseHovering, videoFilesMouseHovering, audioFilesOpened, videoFilesOpened } from './main/main.component';
@@ -49,6 +52,8 @@ import { SubscriptionFileCardComponent } from './subscription/subscription-file-
import { SubscriptionInfoDialogComponent } from './dialogs/subscription-info-dialog/subscription-info-dialog.component';
import { SettingsComponent } from './settings/settings.component';
import { CheckOrSetPinDialogComponent } from './dialogs/check-or-set-pin-dialog/check-or-set-pin-dialog.component';
import { MatChipsModule } from '@angular/material/chips';
import { NgxFileDropModule } from 'ngx-file-drop';
import es from '@angular/common/locales/es';
import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.component';
@@ -57,6 +62,16 @@ import { ArgModifierDialogComponent, HighlightPipe } from './dialogs/arg-modifie
import { UpdaterComponent } from './updater/updater.component';
import { UpdateProgressDialogComponent } from './dialogs/update-progress-dialog/update-progress-dialog.component';
import { ShareMediaDialogComponent } from './dialogs/share-media-dialog/share-media-dialog.component';
import { LoginComponent } from './components/login/login.component';
import { DownloadsComponent } from './components/downloads/downloads.component';
import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component';
import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component';
import { ModifyUsersComponent } from './components/modify-users/modify-users.component';
import { AddUserDialogComponent } from './dialogs/add-user-dialog/add-user-dialog.component';
import { ManageUserComponent } from './components/manage-user/manage-user.component';
import { ManageRoleComponent } from './components/manage-role/manage-role.component';
import { CookiesUploaderDialogComponent } from './dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component';
registerLocaleData(es, 'es');
export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps<any>) {
@@ -85,7 +100,16 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
HighlightPipe,
UpdaterComponent,
UpdateProgressDialogComponent,
ShareMediaDialogComponent
ShareMediaDialogComponent,
LoginComponent,
DownloadsComponent,
UserProfileDialogComponent,
SetDefaultAdminDialogComponent,
ModifyUsersComponent,
AddUserDialogComponent,
ManageUserComponent,
ManageRoleComponent,
CookiesUploaderDialogComponent
],
imports: [
CommonModule,
@@ -119,8 +143,13 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
MatAutocompleteModule,
MatTabsModule,
MatTooltipModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
MatChipsModule,
DragDropModule,
ClipboardModule,
NgxFileDropModule,
VgCoreModule,
VgControlsModule,
VgOverlayPlayModule,

View File

@@ -0,0 +1,24 @@
<div style="padding: 20px;">
<div *ngFor="let session_downloads of downloads | keyvalue">
<ng-container *ngIf="keys(session_downloads.value).length > 0">
<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.key}}
<span *ngIf="session_downloads.key === 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.value | keyvalue: sort_downloads; let i = index;" class="col-12 my-1">
<mat-card *ngIf="download.value" class="mat-elevation-z3">
<app-download-item [download]="download.value" [queueNumber]="i+1" (cancelDownload)="clearDownload(session_downloads.key, download.value.uid)"></app-download-item>
</mat-card>
</div>
</div>
</div>
</mat-card>
</ng-container>
</div>
<div *ngIf="downloads && !downloadsValid()">
<h4 style="text-align: center;" i18n="No downloads label">No downloads available!</h4>
</div>
</div>

View File

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

View File

@@ -0,0 +1,150 @@
import { Component, OnInit, ViewChildren, QueryList, ElementRef, OnDestroy } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations';
import { Router } from '@angular/router';
@Component({
selector: 'app-downloads',
templateUrl: './downloads.component.html',
styleUrls: ['./downloads.component.scss'],
animations: [
// nice stagger effect when showing existing elements
trigger('list', [
transition(':enter', [
// child animation selector + stagger
query('@items',
stagger(100, animateChild()), { optional: true }
)
]),
]),
trigger('items', [
// cubic-bezier for a tiny bouncing feel
transition(':enter', [
style({ transform: 'scale(0.5)', opacity: 0 }),
animate('500ms cubic-bezier(.8,-0.6,0.2,1.5)',
style({ transform: 'scale(1)', opacity: 1 }))
]),
transition(':leave', [
style({ transform: 'scale(1)', opacity: 1, height: '*' }),
animate('1s cubic-bezier(.8,-0.6,0.2,1.5)',
style({ transform: 'scale(0.5)', opacity: 0, height: '0px', margin: '0px' }))
]),
])
],
})
export class DownloadsComponent implements OnInit, OnDestroy {
downloads_check_interval = 1000;
downloads = {};
interval_id = null;
keys = Object.keys;
valid_sessions_length = 0;
sort_downloads = (a, b) => {
const result = a.value.timestamp_start < b.value.timestamp_start;
return result;
}
constructor(public postsService: PostsService, private router: Router) { }
ngOnInit(): void {
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() {
if (this.interval_id) { clearInterval(this.interval_id) }
}
getCurrentDownloads() {
this.postsService.getCurrentDownloads().subscribe(res => {
if (res['downloads']) {
this.assignNewValues(res['downloads']);
} 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 {
}
});
}
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);
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]) {
this.downloads[session_id] = session_downloads_by_id;
} else {
for (let j = 0; j < session_download_ids.length; j++) {
const download_id = session_download_ids[j];
const download = new_downloads_by_session[session_id][download_id]
if (!this.downloads[session_id][download_id]) {
this.downloads[session_id][download_id] = download;
} else {
const download_to_update = this.downloads[session_id][download_id];
download_to_update['percent_complete'] = download['percent_complete'];
download_to_update['complete'] = download['complete'];
download_to_update['timestamp_end'] = download['timestamp_end'];
download_to_update['downloading'] = download['downloading'];
download_to_update['error'] = download['error'];
}
}
}
}
}
downloadsValid() {
let valid = false;
const keys = this.keys(this.downloads);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const value = this.downloads[key];
if (this.keys(value).length > 0) {
valid = true;
break;
}
}
return valid;
}
}

View File

@@ -0,0 +1,39 @@
<mat-card class="login-card">
<mat-tab-group [(selectedIndex)]="selectedTabIndex">
<mat-tab label="Login">
<div style="margin-top: 10px;">
<mat-form-field>
<input [(ngModel)]="loginUsernameInput" matInput placeholder="User name">
</mat-form-field>
</div>
<div>
<mat-form-field>
<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;">
<mat-form-field>
<input [(ngModel)]="registrationUsernameInput" matInput placeholder="User name">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input [(ngModel)]="registrationPasswordInput" type="password" matInput placeholder="Password">
</mat-form-field>
</div>
<div>
<mat-form-field>
<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>
</mat-card>

View File

@@ -0,0 +1,6 @@
.login-card {
max-width: 600px;
width: 80%;
margin: 0 auto;
margin-top: 20px;
}

View File

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

View File

@@ -0,0 +1,105 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent implements OnInit {
selectedTabIndex = 0;
// login
loginUsernameInput = '';
loginPasswordInput = '';
loggingIn = false;
// registration
registrationEnabled = false;
registrationUsernameInput = '';
registrationPasswordInput = '';
registrationPasswordConfirmationInput = '';
registering = false;
constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router) { }
ngOnInit(): void {
if (this.postsService.isLoggedIn) {
this.router.navigate(['/home']);
}
this.postsService.service_initialized.subscribe(init => {
if (init) {
if (!this.postsService.config['Advanced']['multi_user_mode']) {
this.router.navigate(['/home']);
}
this.registrationEnabled = this.postsService.config['Users'] && this.postsService.config['Users']['allow_registration'];
}
});
}
login() {
if (this.loginPasswordInput === '') {
return;
}
this.loggingIn = true;
this.postsService.login(this.loginUsernameInput, this.loginPasswordInput).subscribe(res => {
this.loggingIn = false;
if (res['token']) {
this.postsService.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']);
}
}, err => {
this.loggingIn = false;
});
}
register() {
if (!this.registrationUsernameInput || this.registrationUsernameInput === '') {
this.openSnackBar('User name is required!');
return;
}
if (!this.registrationPasswordInput || this.registrationPasswordInput === '') {
this.openSnackBar('Password is required!');
return;
}
if (!this.registrationPasswordConfirmationInput || this.registrationPasswordConfirmationInput === '') {
this.openSnackBar('Password confirmation is required!');
return;
}
if (this.registrationPasswordInput !== this.registrationPasswordConfirmationInput) {
this.openSnackBar('Password confirmation is incorrect!');
return;
}
this.registering = true;
this.postsService.register(this.registrationUsernameInput, this.registrationPasswordInput).subscribe(res => {
this.registering = false;
if (res && res['user']) {
this.openSnackBar(`User ${res['user']['name']} successfully registered.`);
this.loginUsernameInput = res['user']['name'];
this.selectedTabIndex = 0;
} else {
}
}, err => {
this.registering = false;
if (err && err.error && typeof err.error === 'string') {
this.openSnackBar(err.error);
} else {
console.log(err);
}
});
}
public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

View File

@@ -0,0 +1,19 @@
<h4 *ngIf="role" mat-dialog-title><ng-container i18n="Manage role dialog title">Manage role</ng-container>&nbsp;-&nbsp;{{role.name}}</h4>
<mat-dialog-content *ngIf="role">
<mat-list>
<mat-list-item role="listitem" *ngFor="let permission of available_permissions">
<h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3>
<span matLine>
<mat-radio-group [disabled]="permission === 'settings' && role.name === 'admin'" (change)="changeRolePermissions($event, permission, permissions[permission])" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give role permission for ' + permission">
<mat-radio-button value="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button>
<mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
</mat-radio-group>
</span>
</mat-list-item>
</mat-list>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
</mat-dialog-actions>

View File

@@ -0,0 +1,4 @@
.mat-radio-button {
margin-right: 10px;
margin-top: 5px;
}

View File

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

View File

@@ -0,0 +1,61 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-manage-role',
templateUrl: './manage-role.component.html',
styleUrls: ['./manage-role.component.scss']
})
export class ManageRoleComponent implements OnInit {
role = null;
available_permissions = null;
permissions = null;
permissionToLabel = {
'filemanager': 'File manager',
'settings': 'Settings access',
'subscriptions': 'Subscriptions',
'sharing': 'Share files',
'advanced_download': 'Use advanced download mode',
'downloads_manager': 'Use downloads manager'
}
constructor(public postsService: PostsService, private dialogRef: MatDialogRef<ManageRoleComponent>,
@Inject(MAT_DIALOG_DATA) public data: any) {
if (this.data) {
this.role = this.data.role;
this.available_permissions = this.postsService.available_permissions;
this.parsePermissions();
}
}
ngOnInit(): void {
}
parsePermissions() {
this.permissions = {};
for (let i = 0; i < this.available_permissions.length; i++) {
const permission = this.available_permissions[i];
if (this.role.permissions.includes(permission)) {
this.permissions[permission] = 'yes';
} else {
this.permissions[permission] = 'no';
}
}
}
changeRolePermissions(change, permission) {
this.postsService.setRolePermission(this.role.name, permission, change.value).subscribe(res => {
if (res['success']) {
} else {
this.permissions[permission] = this.permissions[permission] === 'yes' ? 'no' : 'yes';
}
}, err => {
this.permissions[permission] = this.permissions[permission] === 'yes' ? 'no' : 'yes';
});
}
}

View File

@@ -0,0 +1,31 @@
<h4 *ngIf="user" mat-dialog-title><ng-container i18n="Manage user dialog title">Manage user</ng-container>&nbsp;-&nbsp;{{user.name}}</h4>
<mat-dialog-content *ngIf="user">
<p><ng-container i18n="User UID">User UID:</ng-container>&nbsp;{{user.uid}}</p>
<div>
<mat-form-field style="margin-right: 15px;">
<input matInput [(ngModel)]="newPasswordInput" type="password" placeholder="New password" i18n-placeholder="New password placeholder">
</mat-form-field>
<button mat-raised-button color="accent" (click)="setNewPassword()" [disabled]="newPasswordInput.length === 0"><ng-container i18n="Set new password">Set new password</ng-container></button>
</div>
<div>
<mat-list>
<mat-list-item role="listitem" *ngFor="let permission of available_permissions">
<h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3>
<span matLine>
<mat-radio-group [disabled]="permission === 'settings' && postsService.user.uid === user.uid" (change)="changeUserPermissions($event, permission)" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give user permission for ' + permission">
<mat-radio-button value="default"><ng-container i18n="Use default">Use default</ng-container></mat-radio-button>
<mat-radio-button value="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button>
<mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
</mat-radio-group>
</span>
</mat-list-item>
</mat-list>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close><ng-container i18n="Close">Close</ng-container></button>
</mat-dialog-actions>

View File

@@ -0,0 +1,4 @@
.mat-radio-button {
margin-right: 10px;
margin-top: 5px;
}

View File

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

View File

@@ -0,0 +1,69 @@
import { Component, OnInit, Inject } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({
selector: 'app-manage-user',
templateUrl: './manage-user.component.html',
styleUrls: ['./manage-user.component.scss']
})
export class ManageUserComponent implements OnInit {
user = null;
newPasswordInput = '';
available_permissions = null;
permissions = null;
permissionToLabel = {
'filemanager': 'File manager',
'settings': 'Settings access',
'subscriptions': 'Subscriptions',
'sharing': 'Share files',
'advanced_download': 'Use advanced download mode',
'downloads_manager': 'Use downloads manager'
}
settingNewPassword = false;
constructor(public postsService: PostsService, @Inject(MAT_DIALOG_DATA) public data: any) {
if (this.data) {
this.user = this.data.user;
this.available_permissions = this.postsService.available_permissions;
this.parsePermissions();
}
}
ngOnInit(): void {
}
parsePermissions() {
this.permissions = {};
for (let i = 0; i < this.available_permissions.length; i++) {
const permission = this.available_permissions[i];
if (this.user.permission_overrides.includes(permission)) {
if (this.user.permissions.includes(permission)) {
this.permissions[permission] = 'yes';
} else {
this.permissions[permission] = 'no';
}
} else {
this.permissions[permission] = 'default';
}
}
}
changeUserPermissions(change, permission) {
this.postsService.setUserPermission(this.user.uid, permission, change.value).subscribe(res => {
// console.log(res);
});
}
setNewPassword() {
this.settingNewPassword = true;
this.postsService.changeUserPassword(this.user.uid, this.newPasswordInput).subscribe(res => {
this.newPasswordInput = '';
this.settingNewPassword = false;
});
}
}

View File

@@ -0,0 +1,107 @@
<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="example-header">
<mat-form-field>
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Search">
</mat-form-field>
</div>
<div class="example-container mat-elevation-z8">
<mat-table #table [dataSource]="dataSource" matSort>
<!-- Name Column -->
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef mat-sort-header><ng-container i18n="Username users table header"> User name </ng-container></mat-header-cell>
<mat-cell *matCellDef="let row">
<span *ngIf="editObject && editObject.uid === row.uid; else noteditingname">
<span style="width: 80%;">
<mat-form-field>
<input matInput [(ngModel)]="constructedObject['name']" type="text" style="font-size: 12px">
</mat-form-field>
</span>
</span>
<ng-template #noteditingname>
{{row.name}}
</ng-template>
</mat-cell>
</ng-container>
<!-- Email Column -->
<ng-container matColumnDef="role">
<mat-header-cell *matHeaderCellDef mat-sort-header><ng-container i18n="Role users table header"> Role </ng-container></mat-header-cell>
<mat-cell *matCellDef="let row">
<span *ngIf="editObject && editObject.uid === row.uid; else noteditingemail">
<span style="width: 80%;">
<mat-form-field>
<mat-select [(ngModel)]="constructedObject['role']">
<mat-option value="admin">Admin</mat-option>
<mat-option value="user">User</mat-option>
</mat-select>
</mat-form-field>
</span>
</span>
<ng-template #noteditingemail>
{{row.role}}
</ng-template>
</mat-cell>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef mat-sort-header><ng-container i18n="Actions users table header"> Actions </ng-container></mat-header-cell>
<mat-cell *matCellDef="let row">
<span *ngIf="editObject && editObject.uid === row.uid; else notediting">
<button mat-icon-button color="primary" (click)="finishEditing(row.uid)" matTooltip="Finish editing user">
<mat-icon>done</mat-icon>
</button>
<button mat-icon-button (click)="disableEditMode()" matTooltip="Cancel editing user">
<mat-icon>cancel</mat-icon>
</button>
</span>
<ng-template #notediting>
<button mat-icon-button (click)="enableEditMode(row.uid)" matTooltip="Edit user">
<mat-icon>edit</mat-icon>
</button>
</ng-template>
<button (click)="manageUser(row.uid)" mat-icon-button [disabled]="editObject && editObject.uid === row.uid" matTooltip="Manage user">
<mat-icon>settings</mat-icon>
</button>
<button mat-icon-button [disabled]="editObject && editObject.uid === row.uid || row.uid === postsService.user.uid" (click)="removeUser(row.uid)" matTooltip="Delete user">
<mat-icon>delete</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;">
</mat-row>
</mat-table>
<mat-paginator #paginator [length]="length"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions">
</mat-paginator>
<button color="primary" [disabled]="!this.users" mat-raised-button (click)="openAddUserDialog()" style="float: left; top: -45px; left: 15px">
<ng-container i18n="Add users button">Add Users</ng-container>
</button>
</div>
</div>
</div>
<button color="primary" [matMenuTriggerFor]="edit_roles_menu" class="edit-role" mat-raised-button><ng-container i18n="Edit role">Edit Role</ng-container></button>
<mat-menu #edit_roles_menu="matMenu">
<button (click)="openModifyRole(role)" mat-menu-item *ngFor="let role of roles">{{role.name}}</button>
</mat-menu>
</div>
</div>
<div style="position: absolute" class="centered">
<ng-template #loading>
<mat-spinner></mat-spinner>
</ng-template>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,219 @@
import { Component, OnInit, Input, ViewChild, AfterViewInit } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { PostsService } from 'app/posts.services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { AddUserDialogComponent } from 'app/dialogs/add-user-dialog/add-user-dialog.component';
import { ManageUserComponent } from '../manage-user/manage-user.component';
import { ManageRoleComponent } from '../manage-role/manage-role.component';
@Component({
selector: 'app-modify-users',
templateUrl: './modify-users.component.html',
styleUrls: ['./modify-users.component.scss']
})
export class ModifyUsersComponent implements OnInit, AfterViewInit {
displayedColumns = ['name', 'role', 'actions'];
dataSource = new MatTableDataSource();
deleteDialogContentSubstring = 'Are you sure you want delete user ';
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
// MatPaginator Inputs
length = 100;
@Input() pageSize = 5;
pageSizeOptions: number[] = [5, 10, 25, 100];
// MatPaginator Output
pageEvent: PageEvent;
users: any;
editObject = null;
constructedObject = {};
roles = null;
constructor(public postsService: PostsService, public snackBar: MatSnackBar, public dialog: MatDialog,
private dialogRef: MatDialogRef<ModifyUsersComponent>) { }
ngOnInit() {
this.getArray();
this.getRoles();
}
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
/**
* Set the paginator and sort after the view init since this component will
* be able to query its view for the initialized paginator and sort.
*/
afterGetData() {
this.dataSource.sort = this.sort;
}
setPageSizeOptions(setPageSizeOptionsInput: string) {
this.pageSizeOptions = setPageSizeOptionsInput.split(',').map(str => +str);
}
applyFilter(filterValue: string) {
filterValue = filterValue.trim(); // Remove whitespace
filterValue = filterValue.toLowerCase(); // Datasource defaults to lowercase matches
this.dataSource.filter = filterValue;
}
private getArray() {
this.postsService.getUsers().subscribe(res => {
this.users = res['users'];
this.createAndSortData();
this.afterGetData();
});
}
getRoles() {
this.postsService.getRoles().subscribe(res => {
this.roles = [];
const roles = res['roles'];
const role_names = Object.keys(roles);
for (let i = 0; i < role_names.length; i++) {
const role_name = role_names[i];
this.roles.push({
name: role_name,
permissions: roles[role_name]['permissions']
});
}
});
}
openAddUserDialog() {
const dialogRef = this.dialog.open(AddUserDialogComponent);
dialogRef.afterClosed().subscribe(user => {
if (user && !user.error) {
this.openSnackBar('Successfully added user ' + user.name);
this.getArray();
} else if (user && user.error) {
this.openSnackBar('Failed to add user');
}
});
}
finishEditing(user_uid) {
let has_finished = false;
if (this.constructedObject && this.constructedObject['name'] && this.constructedObject['role']) {
if (!isEmptyOrSpaces(this.constructedObject['name']) && !isEmptyOrSpaces(this.constructedObject['role'])) {
has_finished = true;
const index_of_object = this.indexOfUser(user_uid);
this.users[index_of_object] = this.constructedObject;
this.constructedObject = {};
this.editObject = null;
this.setUser(this.users[index_of_object]);
this.createAndSortData();
}
}
}
enableEditMode(user_uid) {
if (this.uidInUserList(user_uid) && this.indexOfUser(user_uid) > -1) {
const users_index = this.indexOfUser(user_uid);
this.editObject = this.users[users_index];
this.constructedObject['name'] = this.users[users_index].name;
this.constructedObject['uid'] = this.users[users_index].uid;
this.constructedObject['role'] = this.users[users_index].role;
}
}
disableEditMode() {
this.editObject = null;
}
// checks if user is in users array by name
uidInUserList(user_uid) {
for (let i = 0; i < this.users.length; i++) {
if (this.users[i].uid === user_uid) {
return true;
}
}
return false;
}
// gets index of user in users array by name
indexOfUser(user_uid) {
for (let i = 0; i < this.users.length; i++) {
if (this.users[i].uid === user_uid) {
return i;
}
}
return -1;
}
setUser(change_obj) {
this.postsService.changeUser(change_obj).subscribe(res => {
this.getArray();
});
}
manageUser(user_uid) {
const index_of_object = this.indexOfUser(user_uid);
const user_obj = this.users[index_of_object];
this.dialog.open(ManageUserComponent, {
data: {
user: user_obj
},
width: '65vw'
});
}
removeUser(user_uid) {
this.postsService.deleteUser(user_uid).subscribe(res => {
this.getArray();
}, err => {
this.getArray();
});
}
createAndSortData() {
// Sorts the data by last finished
this.users.sort((a, b) => b.name > a.name);
const filteredData = [];
for (let i = 0; i < this.users.length; i++) {
filteredData.push(JSON.parse(JSON.stringify(this.users[i])));
}
// Assign the data to the data source for the table to render
this.dataSource.data = filteredData;
}
openModifyRole(role) {
const dialogRef = this.dialog.open(ManageRoleComponent, {
data: {
role: role
}
});
dialogRef.afterClosed().subscribe(success => {
this.getRoles();
});
}
closeDialog() {
this.dialogRef.close();
}
public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}
function isEmptyOrSpaces(str){
return str === null || str.match(/^ *$/) !== null;
}

View File

@@ -1 +1 @@
export const CURRENT_VERSION = 'v3.6';
export const CURRENT_VERSION = 'v4.0';

View File

@@ -0,0 +1,19 @@
<h4 mat-dialog-title i18n="Register user dialog title">Register a user</h4>
<mat-dialog-content>
<div>
<mat-form-field>
<input matInput placeholder="User name" i18n-placeholder="User name placeholder" [(ngModel)]="usernameInput">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput placeholder="Password" i18n-placeholder="Password placeholder" [(ngModel)]="passwordInput" type="password">
</mat-form-field>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button color="accent" (click)="createUser()" mat-raised-button><ng-container i18n="Register user button">Register</ng-container></button>
<button mat-dialog-close mat-button><ng-container i18n="Close button">Close</ng-container></button>
</mat-dialog-actions>

View File

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

View File

@@ -0,0 +1,32 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-add-user-dialog',
templateUrl: './add-user-dialog.component.html',
styleUrls: ['./add-user-dialog.component.scss']
})
export class AddUserDialogComponent implements OnInit {
usernameInput = '';
passwordInput = '';
constructor(private postsService: PostsService, public dialogRef: MatDialogRef<AddUserDialogComponent>) { }
ngOnInit(): void {
}
createUser() {
this.postsService.register(this.usernameInput, this.passwordInput).subscribe(res => {
if (res['user']) {
this.dialogRef.close(res['user']);
} else {
this.dialogRef.close({error: 'Unknown error'});
}
}, err => {
this.dialogRef.close({error: err});
});
}
}

View File

@@ -6,9 +6,27 @@
<div class="col-12">
<mat-card class="mat-elevation-z6">
<h6 i18n="Simulated args title">Simulated new args</h6>
<mat-chip-list class="example-chip" #chipList aria-label="Args array" cdkDropList cdkDropListDisabled
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="drop($event)">
<mat-chip [matTooltip]="argsByKey[arg] ? argsByKey[arg]['description'] : null" *ngFor="let arg of args_array; let i = index;" [selectable]="selectable" [removable]="removable" (removed)="remove(i)" cdkDrag>
{{arg}}
<mat-icon matChipRemove *ngIf="removable">cancel</mat-icon>
</mat-chip>
</mat-chip-list>
<mat-form-field style="width: 100%" color="accent">
<textarea [disabled]="true" matInput>{{modified_args}}</textarea>
<input #chipper style="width: 100%;" [formControl]="chipCtrl" matInput [matAutocomplete]="autochip" [matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[matChipInputAddOnBlur]="addOnBlur"
(matChipInputTokenEnd)="add($event)">
</mat-form-field>
<mat-autocomplete #autochip="matAutocomplete">
<mat-option *ngFor="let arg of filteredChipOptions | async" [value]="arg.key">
<span [innerHTML]="arg.key | highlight : chipCtrl.value"></span>
<button style="float: right" [matTooltip]="arg.description" mat-icon-button><mat-icon>info</mat-icon></button>
</mat-option>
</mat-autocomplete>
</mat-card>
</div>
<div class="col-12">
@@ -63,6 +81,6 @@
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close><ng-container i18n="Arg modifier cancel button">Cancel</ng-container></button>
<button mat-button [mat-dialog-close]="null"><ng-container i18n="Arg modifier cancel button">Cancel</ng-container></button>
<button mat-button color="accent" [mat-dialog-close]="modified_args"><ng-container i18n="Arg modifier modify button">Modify</ng-container></button>
</mat-dialog-actions>

View File

@@ -1,10 +1,13 @@
import { Component, OnInit, Inject, Pipe, PipeTransform, NgModule } from '@angular/core';
import { Component, OnInit, Inject, Pipe, PipeTransform, ViewChild, AfterViewInit } from '@angular/core';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialog } from '@angular/material/dialog';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { FormControl } from '@angular/forms';
import { args, args_info } from './youtubedl_args';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators/map';
import { startWith } from 'rxjs/operators/startWith';
import { MatAutocompleteTrigger } from '@angular/material/autocomplete';
@Pipe({ name: 'highlight' })
export class HighlightPipe implements PipeTransform {
@@ -26,17 +29,31 @@ export class HighlightPipe implements PipeTransform {
providers: [HighlightPipe],
styleUrls: ['./arg-modifier-dialog.component.scss'],
})
export class ArgModifierDialogComponent implements OnInit {
export class ArgModifierDialogComponent implements OnInit, AfterViewInit {
myGroup = new FormControl();
firstArg = '';
secondArg = '';
secondArgEnabled = false;
modified_args = '';
stateCtrl = new FormControl();
chipCtrl = new FormControl();
availableArgs = null;
argsByCategory = null;
argsByKey = null;
argsInfo = null;
filteredOptions: Observable<any>;
filteredChipOptions: Observable<any>;
// chip list
chipInput = '';
visible = true;
selectable = true;
removable = true;
addOnBlur = false;
args_array = null;
readonly separatorKeysCodes: number[] = [ENTER, COMMA];
@ViewChild( 'chipper', {read: MatAutocompleteTrigger}) autoTrigger: MatAutocompleteTrigger;
static forRoot() {
return {
@@ -51,6 +68,7 @@ export class ArgModifierDialogComponent implements OnInit {
ngOnInit(): void {
if (this.data) {
this.modified_args = this.data.initial_args;
this.generateArgsArray();
}
this.getAllPossibleArgs();
@@ -61,6 +79,21 @@ export class ArgModifierDialogComponent implements OnInit {
startWith(''),
map(val => this.filter(val))
);
this.filteredChipOptions = this.chipCtrl.valueChanges
.pipe(
startWith(''),
map(val => this.filter(val))
);
}
ngAfterViewInit() {
this.autoTrigger.panelClosingActions.subscribe( x => {
if (this.autoTrigger.activeOption) {
console.log(this.autoTrigger.activeOption.value)
this.chipCtrl.setValue(this.autoTrigger.activeOption.value)
}
} )
}
// autocomplete filter
@@ -72,12 +105,16 @@ export class ArgModifierDialogComponent implements OnInit {
}
addArg() {
if (!this.modified_args) {
this.modified_args = '';
}
// adds space
if (this.modified_args !== '') {
this.modified_args += ' ';
this.modified_args += ',,';
}
this.modified_args += this.stateCtrl.value + ' ' + (this.secondArgEnabled ? this.secondArg : '');
this.modified_args += this.stateCtrl.value + (this.secondArgEnabled ? ',,' + this.secondArg : '');
this.generateArgsArray();
}
canAddArg() {
@@ -104,6 +141,11 @@ export class ArgModifierDialogComponent implements OnInit {
// converts array of arrays to one array
const singular_arg_array = [].concat.apply([], arg_arrays);
const args_by_key = singular_arg_array.reduce((acc, curr) => {
acc[curr.key] = curr;
return acc;
}, {});
this.argsByKey = args_by_key;
this.availableArgs = singular_arg_array;
this.argsByCategory = all_args;
@@ -114,4 +156,40 @@ export class ArgModifierDialogComponent implements OnInit {
this.stateCtrl.setValue(arg_key);
}
// chip list functions
add(event) {
const input = event.input;
const arg = event.value;
if (!arg || arg.trim().length === 0) {
return;
}
this.args_array.push(arg);
if (this.modified_args.length > 0) {
this.modified_args += ',,'
}
this.modified_args += arg;
if (input) { input.value = ''; }
}
remove(arg_index) {
this.args_array.splice(arg_index, 1);
this.modified_args = this.args_array.join(',,');
}
generateArgsArray() {
if (this.modified_args.trim().length === 0) {
this.args_array = [];
return;
}
this.args_array = this.modified_args.split(',,');
}
drop(event: CdkDragDrop<any>) {
moveItemInArray(this.args_array, event.previousIndex, event.currentIndex);
this.modified_args = this.args_array.join(',,');
}
}

View File

@@ -0,0 +1,40 @@
<h4 mat-dialog-title i18n="Cookies uploader dialog title">Upload new cookies</h4>
<mat-dialog-content>
<div>
<div class="center">
<ngx-file-drop [multiple]="false" accept=".txt" dropZoneLabel="Drop files here" (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)">
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div>
<div>
<ng-container i18n="Drag and Drop">Drag and Drop</ng-container>
</div>
<div style="margin-top: 6px;">
<button mat-stroked-button (click)="openFileSelector()">Browse Files</button>
</div>
</div>
</ng-template>
</ngx-file-drop>
<div style="margin-top: 15px;">
<p style="font-size: 14px;" i18n="Cookies upload warning">NOTE: Uploading new cookies will overrride your previous cookies. Also note that cookies are instance-wide, not per-user.</p>
</div>
<div style="margin-top: 10px;">
<table class="table">
<tbody class="upload-name-style">
<tr *ngFor="let item of files; let i=index">
<td style="vertical-align: middle;">
<strong>{{ item.relativePath }}</strong>
</td>
<td>
<button [disabled]="uploading || uploaded" (click)="uploadFile()" style="float: right" matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon><mat-spinner *ngIf="uploading" class="spinner" [diameter]="38"></mat-spinner></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions><button style="margin-bottom: 5px;" mat-dialog-close mat-stroked-button><ng-container i18n="Close">Close</ng-container></button></mat-dialog-actions>

View File

@@ -0,0 +1,5 @@
.spinner {
bottom: 1px;
left: 0.5px;
position: absolute;
}

View File

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

View File

@@ -0,0 +1,57 @@
import { Component, OnInit } from '@angular/core';
import { NgxFileDropEntry, FileSystemFileEntry, FileSystemDirectoryEntry } from 'ngx-file-drop';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-cookies-uploader-dialog',
templateUrl: './cookies-uploader-dialog.component.html',
styleUrls: ['./cookies-uploader-dialog.component.scss']
})
export class CookiesUploaderDialogComponent implements OnInit {
public files: NgxFileDropEntry[] = [];
uploading = false;
uploaded = false;
constructor(private postsService: PostsService) { }
ngOnInit(): void {
}
public dropped(files: NgxFileDropEntry[]) {
this.files = files;
this.uploading = false;
this.uploaded = false;
}
uploadFile() {
this.uploading = true;
for (const droppedFile of this.files) {
// Is it a file?
if (droppedFile.fileEntry.isFile) {
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
fileEntry.file((file: File) => {
// You could upload it like this:
const formData = new FormData()
formData.append('cookies', file, droppedFile.relativePath);
this.postsService.uploadCookiesFile(formData).subscribe(res => {
this.uploading = false;
if (res['success']) {
this.uploaded = true;
this.postsService.openSnackBar('Cookies successfully uploaded!');
}
}, err => {
this.uploading = false;
});
});
}
}
}
public fileOver(event) {
}
public fileLeave(event) {
}
}

View File

@@ -0,0 +1,19 @@
<h4 mat-dialog-title><ng-container i18n="Create admin account dialog title">Create admin account</ng-container></h4>
<mat-dialog-content>
<div>
<p i18n="No default admin detected explanation">No default admin account detected. This will create and set the password for an admin account with the user name as 'admin'.</p>
</div>
<div style="position: relative">
<div>
<mat-form-field color="accent">
<input type="password" (keyup.enter)="create()" matInput [(ngModel)]="input" placeholder="Password" i18n-placeholder="Password">
</mat-form-field>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button [disabled]="input.length === 0" color="accent" style="margin-bottom: 12px;" (click)="create()" mat-raised-button><ng-container i18n="Create">Create</ng-container></button>
<div class="spinner-div"><mat-spinner [diameter]="25" *ngIf="creating"></mat-spinner></div>
</mat-dialog-actions>

View File

@@ -0,0 +1,5 @@
.spinner-div {
position: relative;
left: 10px;
bottom: 5px;
}

View File

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

View File

@@ -0,0 +1,33 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-set-default-admin-dialog',
templateUrl: './set-default-admin-dialog.component.html',
styleUrls: ['./set-default-admin-dialog.component.scss']
})
export class SetDefaultAdminDialogComponent implements OnInit {
creating = false;
input = '';
constructor(private postsService: PostsService, public dialogRef: MatDialogRef<SetDefaultAdminDialogComponent>) { }
ngOnInit(): void {
}
create() {
this.creating = true;
this.postsService.createAdminAccount(this.input).subscribe(res => {
this.creating = false;
if (res['success']) {
this.dialogRef.close(true);
} else {
this.dialogRef.close(false);
}
}, err => {
console.log(err);
this.dialogRef.close(false);
});
}
}

View File

@@ -9,6 +9,12 @@
<div>
<mat-checkbox [checked]="sharing_enabled" (change)="sharingChanged($event)"><ng-container i18n="Enable sharing checkbox">Enable sharing</ng-container></mat-checkbox>
</div>
<div>
<mat-checkbox style="margin-right: 15px;" (change)="useTimestampChanged()" [(ngModel)]="timestamp_enabled"><ng-container i18n="Use timestamp">Use timestamp</ng-container></mat-checkbox>
<mat-form-field>
<input matInput type="number" [(ngModel)]="current_timestamp" [disabled]="!timestamp_enabled" (change)="timestampInputChanged($event)" placeholder="Seconds" i18n-placeholder="Seconds">
</mat-form-field>
</div>
<div>
<mat-form-field style="width: 100%">
<input matInput [disabled]="!sharing_enabled" [readonly]="true" [value]="share_url">

View File

@@ -13,9 +13,13 @@ export class ShareMediaDialogComponent implements OnInit {
type = null;
uid = null;
uuid = null;
share_url = null;
default_share_url = null;
sharing_enabled = null;
is_playlist = null;
current_timestamp = null
timestamp_enabled = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any, public router: Router, private snackBar: MatSnackBar,
private postsService: PostsService) { }
@@ -24,14 +28,37 @@ export class ShareMediaDialogComponent implements OnInit {
if (this.data) {
this.type = this.data.type;
this.uid = this.data.uid;
this.uuid = this.data.uuid;
this.sharing_enabled = this.data.sharing_enabled;
this.is_playlist = this.data.is_playlist;
this.current_timestamp = (this.data.current_timestamp / 1000).toFixed(2);
const arg = (this.is_playlist ? ';id=' : ';uid=');
this.share_url = window.location.href.split(';')[0] + arg + this.uid;
this.default_share_url = window.location.href.split(';')[0] + arg + this.uid;
if (this.uuid) {
this.default_share_url += ';uuid=' + this.uuid;
}
this.share_url = this.default_share_url;
}
}
timestampInputChanged(change) {
if (!this.timestamp_enabled) {
this.share_url = this.default_share_url;
return;
}
const new_val = change.target.value;
if (new_val > 0) {
this.share_url = this.default_share_url + ';timestamp=' + new_val;
} else {
this.share_url = this.default_share_url;
}
}
useTimestampChanged() {
this.timestampInputChanged({target: {value: this.current_timestamp}})
}
copiedToClipboard() {
this.openSnackBar('Copied to clipboard!');
}

View File

@@ -31,7 +31,7 @@
</div>
<div class="col-12">
<div>
<mat-checkbox [(ngModel)]="streamingOnlyMode">Streaming-only mode</mat-checkbox>
<mat-checkbox [(ngModel)]="streamingOnlyMode"><ng-container i18n="Streaming-only mode">Streaming-only mode</ng-container></mat-checkbox>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
<h4 mat-dialog-title i18n="User profile dialog title">Your Profile</h4>
<mat-dialog-content>
<div *ngIf="postsService.isLoggedIn && postsService.user">
<div>
<strong><ng-container i18n="Name">Name:</ng-container></strong>&nbsp;{{postsService.user.name}}
</div>
<div>
<strong><ng-container i18n="UID">UID:</ng-container></strong>&nbsp;{{postsService.user.uid}}
</div>
<div>
<strong><ng-container i18n="Created">Created:</ng-container></strong>&nbsp;{{postsService.user.created ? (postsService.user.created | date) : 'N/A'}}
</div>
<div style="margin-top: 20px;">
</div>
</div>
<div *ngIf="!postsService.isLoggedIn || !postsService.user">
<h5><mat-icon>warn</mat-icon><ng-container i18n="Not logged in notification">You are not logged in.</ng-container></h5>
<button (click)="loginClicked()" mat-raised-button color="primary"><ng-container i18n="Login">Login</ng-container></button>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<div style="width: 100%">
<div style="position: relative">
<button mat-stroked-button mat-dialog-close color="primary"><ng-container i18n="Close">Close</ng-container></button>
<button style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
</div>
</div>
</mat-dialog-actions>

View File

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

View File

@@ -0,0 +1,28 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-user-profile-dialog',
templateUrl: './user-profile-dialog.component.html',
styleUrls: ['./user-profile-dialog.component.scss']
})
export class UserProfileDialogComponent implements OnInit {
constructor(public postsService: PostsService, private router: Router, public dialogRef: MatDialogRef<UserProfileDialogComponent>) { }
ngOnInit(): void {
}
loginClicked() {
this.router.navigate(['/login']);
this.dialogRef.close();
}
logoutClicked() {
this.postsService.logout();
this.dialogRef.close();
}
}

View File

@@ -1,16 +1,41 @@
<div>
<mat-grid-list [rowHeight]="50" [cols]="24">
<mat-grid-tile [colspan]="2">
<h5 style="display: inline-block; margin-right: 5px; position: relative; top: 5px;">{{queueNumber}}.</h5>
</mat-grid-tile>
<mat-grid-tile [colspan]="6">
<div style="display: inline-block; text-align: center;"><ng-container i18n="Download ID">ID:</ng-container>&nbsp;{{url_id}}</div>
<mat-grid-tile [colspan]="7">
<div style="display: inline-block; text-align: center; width: 100%;"><span class="shorten"><ng-container i18n="Download ID">ID:</ng-container>&nbsp;{{url_id ? url_id : download.uid}}</span></div>
</mat-grid-tile>
<mat-grid-tile [colspan]="13">
<mat-progress-bar style="width: 80%" [value]="download.percent_complete" [mode]="(download.percent_complete === 0) ? 'indeterminate' : 'determinate'"></mat-progress-bar>
<mat-progress-bar [value]="(download.complete || download.error) ? 100 : download.percent_complete" [mode]="(!download.complete && download.percent_complete === 0 && !download.error) ? 'indeterminate' : 'determinate'"></mat-progress-bar>
<mat-icon *ngIf="download.complete" style="margin-left: 25px; cursor: default" matTooltip="The download is complete" matTooltip-i18n>done</mat-icon>
<mat-icon *ngIf="download.error" style="margin-left: 25px; cursor: default" matTooltip="An error has occurred" matTooltip-i18n>error</mat-icon>
</mat-grid-tile>
<mat-grid-tile [colspan]="3">
<button (click)="cancelTheDownload()" mat-icon-button color="warn"><mat-icon fontSet="material-icons-outlined">cancel</mat-icon></button>
<mat-grid-tile [colspan]="4">
<button style="margin-bottom: 2px;" (click)="cancelTheDownload()" mat-icon-button color="warn"><mat-icon fontSet="material-icons-outlined">cancel</mat-icon></button>
</mat-grid-tile>
</mat-grid-list>
<mat-expansion-panel *ngIf="download.timestamp_start" class="ignore-margin">
<mat-expansion-panel-header>
<div>
<ng-container i18n="Details">Details</ng-container>
</div>
<div style="width: 100%">
<div style="float: right">
<mat-panel-description>{{download.timestamp_start | date:'medium'}}</mat-panel-description>
</div>
</div>
</mat-expansion-panel-header>
<div *ngIf="download.error">
<strong><ng-container i18n="Error label">An error has occurred:</ng-container></strong>
<br/>
{{download.error}}
</div>
<div *ngIf="download.timestamp_start">
<strong><ng-container i18n="Download start label">Download start:</ng-container></strong>&nbsp;{{download.timestamp_start | date:'medium'}}
</div>
<div *ngIf="download.timestamp_end">
<strong><ng-container i18n="Download end label">Download end:</ng-container></strong>&nbsp;{{download.timestamp_end | date:'medium'}}
</div>
<div *ngIf="download.fileNames">
<strong><ng-container i18n="File path(s) label">File path(s):</ng-container></strong>&nbsp;{{download.fileNames.join(', ')}}
</div>
</mat-expansion-panel>
</div>

View File

@@ -0,0 +1,16 @@
.shorten {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
}
.mat-expansion-panel:not([class*='mat-elevation-z']) {
box-shadow: none;
}
.ignore-margin {
margin-left: -15px;
margin-right: -15px;
margin-bottom: -15px;
}

View File

@@ -13,9 +13,13 @@ export class DownloadItemComponent implements OnInit {
uid: null,
type: 'audio',
percent_complete: 0,
complete: false,
url: 'http://youtube.com/watch?v=17848rufj',
downloading: true,
is_playlist: false
timestamp_start: null,
timestamp_end: null,
is_playlist: false,
error: false
};
@Output() cancelDownload = new EventEmitter<Download>();
@@ -26,7 +30,7 @@ export class DownloadItemComponent implements OnInit {
constructor() { }
ngOnInit() {
if (this.download && this.download.url && this.download.url.includes('youtube')) {
if (this.download && this.download.url && this.download.url.includes('youtu')) {
const string_id = (this.download.is_playlist ? '?list=' : '?v=')
const index_offset = (this.download.is_playlist ? 6 : 3);
const end_index = this.download.url.indexOf(string_id) + index_offset;

View File

@@ -44,6 +44,13 @@ export class FileCardComponent implements OnInit {
ngOnInit() {
this.type = this.isAudio ? 'audio' : 'video';
if (this.file && this.file.url && this.file.url.includes('youtu')) {
const string_id = (this.isPlaylist ? '?list=' : '?v=')
const index_offset = (this.isPlaylist ? 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) {

View File

@@ -67,8 +67,8 @@ mat-form-field.mat-form-field {
.input-clear-button {
position: absolute;
right: -10px;
top: 5px;
right: 5px;
top: 22px;
}
.spinner-div {

View File

@@ -11,12 +11,12 @@
<div class="row">
<div [ngClass]="allowQualitySelect ? 'col-sm-9' : null" class="col-12">
<mat-form-field color="accent" class="example-full-width">
<input style="padding-right: 25px;" matInput (ngModelChange)="inputChanged($event)" [(ngModel)]="url" [placeholder]="'URL' + (youtubeSearchEnabled ? ' or search' : '')" type="url" name="url" [formControl]="urlForm" required #urlinput>
<input style="padding-right: 25px;" matInput (keyup.enter)="downloadClicked()" (ngModelChange)="inputChanged($event)" [(ngModel)]="url" [placeholder]="'URL' + (youtubeSearchEnabled ? ' or search' : '')" type="url" name="url" [formControl]="urlForm" #urlinput>
<mat-error *ngIf="urlError || urlForm.invalid">
<ng-container i18n="Enter valid URL error">Please enter a valid URL!</ng-container>
</mat-error>
<button class="input-clear-button" mat-icon-button (click)="clearInput()"><mat-icon>clear</mat-icon></button>
</mat-form-field>
<button type="button" class="input-clear-button" mat-icon-button (click)="clearInput()"><mat-icon>clear</mat-icon></button>
</div>
<div *ngIf="allowQualitySelect" class="col-7 col-sm-3">
<mat-form-field color="accent" style="display: inline-block; width: inherit; min-width: 120px;">
@@ -27,7 +27,7 @@
</mat-label>
<mat-select [ngModelOptions]="{standalone: true}" [(ngModel)]="selectedQuality">
<ng-container *ngFor="let option of qualityOptions[(audioOnly) ? 'audio' : 'video']">
<mat-option *ngIf="option.value === '' || url && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats'] && cachedAvailableFormats[url]['formats'][(audioOnly) ? 'audio' : 'video'][option.value]" [value]="option.value">
<mat-option *ngIf="option.value === '' || url && cachedAvailableFormats[url] && cachedAvailableFormats[url]['formats'] && cachedAvailableFormats[url]['formats'][(audioOnly) ? 'audio' : 'video'][option.note]" [value]="option.note">
{{option.label}}
</mat-option>
</ng-container>
@@ -116,7 +116,7 @@
<input [(ngModel)]="customArgs" [ngModelOptions]="{standalone: true}" [disabled]="!customArgsEnabled" matInput 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.
No need to include URL, just everything after. Args are delimited using two commas like so: ,,
</ng-container>
</mat-hint>
</mat-form-field>
@@ -170,11 +170,11 @@
<br/>
<div class="centered big" id="bar_div" *ngIf="current_download && current_download.downloading; else nofile">
<div class="margined">
<div [ngClass]="(determinateProgress && percentDownloaded === 100)?'make-room-for-spinner':'equal-sizes'" style="display: inline-block; width: 100%; padding-left: 20px" *ngIf="determinateProgress;else indeterminateprogress">
<mat-progress-bar mode="determinate" value="{{percentDownloaded}}"></mat-progress-bar>
<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 > 15;else indeterminateprogress">
<mat-progress-bar style="border-radius: 5px;" mode="determinate" value="{{percentDownloaded}}"></mat-progress-bar>
<br/>
</div>
<div *ngIf="determinateProgress && percentDownloaded === 100" class="spinner">
<div *ngIf="+percentDownloaded > 99" class="spinner">
<mat-spinner [diameter]="25"></mat-spinner>
</div>
<ng-template #indeterminateprogress>
@@ -186,7 +186,7 @@
<ng-template #nofile>
</ng-template>
<div style="margin: 20px" *ngIf="fileManagerEnabled">
<div style="margin: 20px" *ngIf="fileManagerEnabled && (!postsService.isLoggedIn || postsService.permissions.includes('filemanager'))">
<mat-accordion>
<mat-expansion-panel (opened)="accordionOpened('audio')" (closed)="accordionClosed('audio')" (mouseleave)="accordionLeft('audio')" (mouseenter)="accordionEntered('audio')" class="big">
<mat-expansion-panel-header>

View File

@@ -3,7 +3,6 @@ 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 { MatSnackBar } from '@angular/material/snack-bar';
import { saveAs } from 'file-saver';
@@ -34,7 +33,11 @@ export interface Download {
percent_complete: number;
downloading: boolean;
is_playlist: boolean;
error: boolean | string;
fileNames?: string[];
complete?: boolean;
timestamp_start?: number;
timestamp_end?: number;
}
@Component({
@@ -105,47 +108,56 @@ export class MainComponent implements OnInit {
{
'resolution': null,
'value': '',
'label': 'Max'
'label': 'Max',
'note': ''
},
{
'resolution': '3840x2160',
'value': '2160',
'label': '2160p (4K)'
'label': '2160p (4K)',
'note': '2160p'
},
{
'resolution': '2560x1440',
'value': '1440',
'label': '1440p'
'label': '1440p',
'note': '1440p'
},
{
'resolution': '1920x1080',
'value': '1080',
'label': '1080p'
'label': '1080p',
'note': '1080p'
},
{
'resolution': '1280x720',
'value': '720',
'label': '720p'
'label': '720p',
'note': '720p'
},
{
'resolution': '720x480',
'value': '480',
'label': '480p'
'label': '480p',
'note': '480p'
},
{
'resolution': '480x360',
'value': '360',
'label': '360p'
'label': '360p',
'note': '360p'
},
{
'resolution': '360x240',
'value': '240',
'label': '240p'
'label': '240p',
'note': '240p'
},
{
'resolution': '256x144',
'value': '144',
'label': '144p'
'label': '144p',
'note': '144p'
}
],
'audio': [
@@ -207,12 +219,13 @@ export class MainComponent implements OnInit {
percent_complete: 0,
url: 'http://youtube.com/watch?v=17848rufj',
downloading: true,
is_playlist: false
is_playlist: false,
error: false
};
simulatedOutput = '';
constructor(private postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
constructor(public postsService: PostsService, private youtubeSearch: YoutubeSearchService, public snackBar: MatSnackBar,
private router: Router, public dialog: MatDialog, private platform: Platform, private route: ActivatedRoute) {
this.audioOnly = false;
}
@@ -228,73 +241,81 @@ export class MainComponent implements OnInit {
async loadConfig() {
// loading config
this.postsService.loadNavItems().subscribe(res => { // loads settings
const result = !this.postsService.debugMode ? res['config_file'] : res;
this.fileManagerEnabled = result['YoutubeDLMaterial']['Extra']['file_manager_enabled'];
this.downloadOnlyMode = result['YoutubeDLMaterial']['Extra']['download_only_mode'];
this.allowMultiDownloadMode = result['YoutubeDLMaterial']['Extra']['allow_multi_download_mode'];
this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio'];
this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video'];
this.use_youtubedl_archive = result['YoutubeDLMaterial']['Downloader']['use_youtubedl_archive'];
this.globalCustomArgs = result['YoutubeDLMaterial']['Downloader']['custom_args'];
this.youtubeSearchEnabled = result['YoutubeDLMaterial']['API'] && result['YoutubeDLMaterial']['API']['use_youtube_API'] &&
result['YoutubeDLMaterial']['API']['youtube_API_key'];
this.youtubeAPIKey = this.youtubeSearchEnabled ? result['YoutubeDLMaterial']['API']['youtube_API_key'] : null;
this.allowQualitySelect = result['YoutubeDLMaterial']['Extra']['allow_quality_select'];
this.allowAdvancedDownload = result['YoutubeDLMaterial']['Advanced']['allow_advanced_download'];
this.useDefaultDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['use_default_downloading_agent'];
this.customDownloadingAgent = result['YoutubeDLMaterial']['Advanced']['custom_downloading_agent'];
this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled'];
this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode'];
this.allowMultiDownloadMode = this.postsService.config['Extra']['allow_multi_download_mode'];
this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
this.videoFolderPath = this.postsService.config['Downloader']['path-video'];
this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive'];
this.globalCustomArgs = this.postsService.config['Downloader']['custom_args'];
this.youtubeSearchEnabled = this.postsService.config['API'] && this.postsService.config['API']['use_youtube_API'] &&
this.postsService.config['API']['youtube_API_key'];
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.useDefaultDownloadingAgent = this.postsService.config['Advanced']['use_default_downloading_agent'];
this.customDownloadingAgent = this.postsService.config['Advanced']['custom_downloading_agent'];
if (this.fileManagerEnabled) {
this.getMp3s();
this.getMp4s();
if (this.fileManagerEnabled) {
this.getMp3s();
this.getMp4s();
}
if (this.youtubeSearchEnabled && this.youtubeAPIKey) {
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
this.attachToInput();
}
// set final cache items
if (this.allowAdvancedDownload) {
if (localStorage.getItem('customArgsEnabled') !== null) {
this.customArgsEnabled = localStorage.getItem('customArgsEnabled') === 'true';
}
if (this.youtubeSearchEnabled && this.youtubeAPIKey) {
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
this.attachToInput();
if (localStorage.getItem('customOutputEnabled') !== null) {
this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true';
}
// set final cache items
if (this.allowAdvancedDownload) {
if (localStorage.getItem('customArgsEnabled') !== null) {
this.customArgsEnabled = localStorage.getItem('customArgsEnabled') === 'true';
}
if (localStorage.getItem('customOutputEnabled') !== null) {
this.customOutputEnabled = localStorage.getItem('customOutputEnabled') === 'true';
}
if (localStorage.getItem('youtubeAuthEnabled') !== null) {
this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true';
}
// set advanced inputs
const customArgs = localStorage.getItem('customArgs');
const customOutput = localStorage.getItem('customOutput');
const youtubeUsername = localStorage.getItem('youtubeUsername');
if (customArgs && customArgs !== 'null') { this.customArgs = customArgs };
if (customOutput && customOutput !== 'null') { this.customOutput = customOutput };
if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername };
if (localStorage.getItem('youtubeAuthEnabled') !== null) {
this.youtubeAuthEnabled = localStorage.getItem('youtubeAuthEnabled') === 'true';
}
return true;
// set advanced inputs
const customArgs = localStorage.getItem('customArgs');
const customOutput = localStorage.getItem('customOutput');
const youtubeUsername = localStorage.getItem('youtubeUsername');
}, error => {
console.log(error);
if (customArgs && customArgs !== 'null') { this.customArgs = customArgs };
if (customOutput && customOutput !== 'null') { this.customOutput = customOutput };
if (youtubeUsername && youtubeUsername !== 'null') { this.youtubeUsername = youtubeUsername };
}
return false;
});
// get downloads routine
setInterval(() => {
if (this.current_download) {
this.getCurrentDownload();
}
}, 500);
return true;
}
// app initialization.
ngOnInit() {
this.configLoad();
if (this.postsService.initialized) {
this.configLoad();
} else {
this.postsService.service_initialized.subscribe(init => {
if (init) {
this.configLoad();
}
});
}
this.postsService.settings_changed.subscribe(changed => {
this.postsService.config_reloaded.subscribe(changed => {
if (changed) {
this.loadConfig();
}
@@ -392,9 +413,9 @@ export class MainComponent implements OnInit {
public goToFile(name, isAudio, uid) {
if (isAudio) {
this.downloadHelperMp3(name, uid, false, false);
this.downloadHelperMp3(name, uid, false, false, null, true);
} else {
this.downloadHelperMp4(name, uid, false, false);
this.downloadHelperMp4(name, uid, false, false, null, true);
}
}
@@ -463,10 +484,9 @@ export class MainComponent implements OnInit {
// download helpers
downloadHelperMp3(name, uid, is_playlist = false, forceView = false, new_download = null) {
downloadHelperMp3(name, uid, is_playlist = false, forceView = false, new_download = null, navigate_mode = false) {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode) {
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
} else {
// if download only mode, just download the file. no redirect
@@ -482,7 +502,7 @@ export class MainComponent implements OnInit {
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'audio'}]);
} else {
this.router.navigate(['/player', {fileNames: name, type: 'audio', uid: uid}]);
this.router.navigate(['/player', {type: 'audio', uid: uid}]);
}
}
}
@@ -501,9 +521,9 @@ export class MainComponent implements OnInit {
}
}
downloadHelperMp4(name, uid, is_playlist = false, forceView = false, new_download = null) {
downloadHelperMp4(name, uid, is_playlist = false, forceView = false, new_download = null, navigate_mode = false) {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode) {
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
} else {
// if download only mode, just download the file. no redirect
@@ -519,7 +539,7 @@ export class MainComponent implements OnInit {
if (is_playlist) {
this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'video'}]);
} else {
this.router.navigate(['/player', {fileNames: name, type: 'video', uid: uid}]);
this.router.navigate(['/player', {type: 'video', uid: uid}]);
}
}
}
@@ -571,7 +591,8 @@ export class MainComponent implements OnInit {
percent_complete: 0,
url: this.url,
downloading: true,
is_playlist: this.url.includes('playlist')
is_playlist: this.url.includes('playlist'),
error: false
};
this.downloads.push(new_download);
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
@@ -583,7 +604,7 @@ export class MainComponent implements OnInit {
}
this.postsService.makeMP3(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword).subscribe(posts => {
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid).subscribe(posts => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
@@ -591,6 +612,8 @@ export class MainComponent implements OnInit {
const is_playlist = !!(posts['file_names']);
this.path = is_playlist ? posts['file_names'] : posts['audiopathEncoded'];
this.current_download = null;
if (this.path !== '-1') {
this.downloadHelperMp3(this.path, posts['uid'], is_playlist, false, new_download);
}
@@ -613,7 +636,8 @@ export class MainComponent implements OnInit {
percent_complete: 0,
url: this.url,
downloading: true,
is_playlist: this.url.includes('playlist')
is_playlist: this.url.includes('playlist'),
error: false
};
this.downloads.push(new_download);
if (!this.current_download && !this.multiDownloadMode) { this.current_download = new_download };
@@ -622,7 +646,7 @@ export class MainComponent implements OnInit {
const customQualityConfiguration = this.getSelectedVideoFormat();
this.postsService.makeMP4(this.url, (this.selectedQuality === '' ? null : this.selectedQuality),
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword).subscribe(posts => {
customQualityConfiguration, customArgs, customOutput, youtubeUsername, youtubePassword, new_download.uid).subscribe(posts => {
// update download object
new_download.downloading = false;
new_download.percent_complete = 100;
@@ -630,6 +654,8 @@ export class MainComponent implements OnInit {
const is_playlist = !!(posts['file_names']);
this.path = is_playlist ? posts['file_names'] : posts['videopathEncoded'];
this.current_download = null;
if (this.path !== '-1') {
this.downloadHelperMp4(this.path, posts['uid'], is_playlist, false, new_download);
}
@@ -847,7 +873,7 @@ export class MainComponent implements OnInit {
const base_string_array = ['youtube-dl', this.url];
if (customArgsExists) {
this.simulatedOutput = base_string_array.join(' ') + ' ' + this.customArgs;
this.simulatedOutput = base_string_array.join(' ') + ' ' + this.customArgs.split(',,').join(' ');
return this.simulatedOutput;
}
@@ -907,7 +933,7 @@ export class MainComponent implements OnInit {
}
if (globalArgsExists) {
full_string_array = full_string_array.concat(this.globalCustomArgs.split(' '));
full_string_array = full_string_array.concat(this.globalCustomArgs.split(',,'));
}
this.simulatedOutput = full_string_array.join(' ');
@@ -1015,14 +1041,16 @@ export class MainComponent implements OnInit {
} else if (format_obj.type === 'video') {
// check if video format is mp4
const key = format.height.toString();
if (format.ext === 'mp4') {
if (true) {
format_obj['height'] = format.height;
format_obj['acodec'] = format.acodec;
format_obj['format_id'] = format.format_id;
format_obj['ext'] = format.ext;
format_obj['note'] = format.format_note;
// no acodec means no overwrite
if (!(video_formats[key]) || format_obj['acodec'] !== 'none') {
video_formats[key] = format_obj;
video_formats[format_obj['note']] = format_obj;
}
}
}
@@ -1114,8 +1142,25 @@ export class MainComponent implements OnInit {
}
});
dialogRef.afterClosed().subscribe(new_args => {
if (new_args) {
this.customArgs = new_args;
if (new_args !== null && new_args !== undefined) {
this.customArgs = new_args;
}
});
}
getCurrentDownload() {
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 => {
if (res['download']) {
if (ui_uid === res['download']['ui_uid']) {
this.current_download = res['download'];
this.percentDownloaded = this.current_download.percent_complete;
}
} else {
// console.log('failed to get new download');
}
});
}

View File

@@ -14,7 +14,7 @@
}
.video-styles {
width: 80%;
width: 100%;
}
::ng-deep .mat-button-toggle-label-content {
@@ -64,6 +64,7 @@
.video-col {
padding-right: 0px;
padding-left: 0.01px;
height: 100%;
}
.save-icon {

View File

@@ -1,6 +1,6 @@
<div *ngIf="playlist.length > 0 && show_player">
<div [ngClass]="(type === 'audio') ? null : 'container-video'" class="container">
<div style="max-width: 100%; margin-left: 0px;" class="row">
<div style="max-width: 100%; margin-left: 0px; height: 70vh" class="row">
<div [ngClass]="(type === 'audio') ? 'my-2 px-1' : 'video-col'" class="col">
<vg-player (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'">
<video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
@@ -26,10 +26,10 @@
<div *ngIf="playlist.length > 1">
<button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!id" color="accent" class="favorite-button" color="primary" (click)="namePlaylistDialog()" mat-fab><mat-icon class="save-icon">favorite</mat-icon></button>
<button *ngIf="!is_shared && id" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
<button *ngIf="!is_shared && id && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
</div>
<div *ngIf="playlist.length === 1">
<button class="save-button" color="primary" (click)="downloadFile()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!is_shared && type !== 'subscription'" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
<button *ngIf="!is_shared && uid && uid !== 'false' && type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
</div>
</div>

View File

@@ -39,6 +39,8 @@ export class PlayerComponent implements OnInit {
uid = null; // used for non-subscription files (audio, video, playlist)
subscriptionName = null;
subPlaylist = null;
uuid = null; // used for sharing in multi-user mode, uuid is the user that downloaded the video
timestamp = null;
is_shared = false;
@@ -50,6 +52,8 @@ export class PlayerComponent implements OnInit {
videoFolderPath = null;
subscriptionFolderPath = null;
sharingEnabled = null;
// url-mode params
url = null;
name = null;
@@ -73,58 +77,71 @@ export class PlayerComponent implements OnInit {
this.subPlaylist = this.route.snapshot.paramMap.get('subPlaylist');
this.url = this.route.snapshot.paramMap.get('url');
this.name = this.route.snapshot.paramMap.get('name');
this.uuid = this.route.snapshot.paramMap.get('uuid');
this.timestamp = this.route.snapshot.paramMap.get('timestamp');
// loading config
this.postsService.loadNavItems().subscribe(res => { // loads settings
const result = !this.postsService.debugMode ? res['config_file'] : res;
this.baseStreamPath = this.postsService.path;
this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio'];
this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video'];
this.subscriptionFolderPath = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_base_path'];
this.fileNames = this.route.snapshot.paramMap.get('fileNames') ? this.route.snapshot.paramMap.get('fileNames').split('|nvr|') : null;
if (!this.fileNames) {
this.is_shared = true;
}
if (this.uid && !this.id) {
this.getFile();
} else if (this.id) {
this.getPlaylistFiles();
}
if (this.url) {
// if a url is given, just stream the URL
this.playlist = [];
const imedia: IMedia = {
title: this.name,
label: this.name,
src: this.url,
type: 'video/mp4'
if (this.postsService.initialized) {
this.processConfig();
} else {
this.postsService.service_initialized.subscribe(init => { // loads settings
if (init) {
this.processConfig();
}
this.playlist.push(imedia);
this.currentItem = this.playlist[0];
this.currentIndex = 0;
this.show_player = true;
} else if (this.type === 'subscription' || this.fileNames) {
this.show_player = true;
this.parseFileNames();
}
});
// this.getFileInfos();
});
}
}
constructor(private postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router,
constructor(public postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router,
public snackBar: MatSnackBar) {
}
processConfig() {
this.baseStreamPath = this.postsService.path;
this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
this.videoFolderPath = this.postsService.config['Downloader']['path-video'];
this.subscriptionFolderPath = this.postsService.config['Subscriptions']['subscriptions_base_path'];
this.fileNames = this.route.snapshot.paramMap.get('fileNames') ? this.route.snapshot.paramMap.get('fileNames').split('|nvr|') : null;
if (!this.fileNames && !this.type) {
this.is_shared = true;
}
if (this.uid && !this.id) {
this.getFile();
} else if (this.id) {
this.getPlaylistFiles();
}
if (this.url) {
// if a url is given, just stream the URL
this.playlist = [];
const imedia: IMedia = {
title: this.name,
label: this.name,
src: this.url,
type: 'video/mp4'
}
this.playlist.push(imedia);
this.currentItem = this.playlist[0];
this.currentIndex = 0;
this.show_player = true;
} else if (this.type === 'subscription' || this.fileNames) {
this.show_player = true;
this.parseFileNames();
}
}
getFile() {
const already_has_filenames = !!this.fileNames;
this.postsService.getFile(this.uid, null).subscribe(res => {
this.postsService.getFile(this.uid, null, this.uuid).subscribe(res => {
this.db_file = res['file'];
if (!this.db_file) {
this.openSnackBar('Failed to get file information from the server.', 'Dismiss');
return;
}
this.sharingEnabled = this.db_file.sharingEnabled;
if (!this.fileNames) {
// means it's a shared video
if (!this.id) {
@@ -134,7 +151,7 @@ export class PlayerComponent implements OnInit {
if (!already_has_filenames) { this.parseFileNames(); }
}
}
if (this.db_file['sharingEnabled']) {
if (this.db_file['sharingEnabled'] || !this.uuid) {
this.show_player = true;
} else if (!already_has_filenames) {
this.openSnackBar('Error: Sharing has been disabled for this video!', 'Dismiss');
@@ -143,12 +160,18 @@ export class PlayerComponent implements OnInit {
}
getPlaylistFiles() {
this.postsService.getPlaylist(this.id, null).subscribe(res => {
this.db_playlist = res['playlist'];
this.fileNames = this.db_playlist['fileNames'];
this.type = res['type'];
this.show_player = true;
this.parseFileNames();
this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => {
if (res['playlist']) {
this.db_playlist = res['playlist'];
this.fileNames = this.db_playlist['fileNames'];
this.type = res['type'];
this.show_player = true;
this.parseFileNames();
} else {
this.openSnackBar('Failed to load playlist!', '');
}
}, err => {
this.openSnackBar('Failed to load playlist!', '');
});
}
@@ -179,6 +202,18 @@ export class PlayerComponent implements OnInit {
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist;
}
// adds user token if in multi-user-mode
const uuid_str = this.uuid ? `&uuid=${this.uuid}` : '';
const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`;
const type_str = (this.id || !this.db_file) ? '' : `&type=${this.db_file.type}`
const id_str = this.id ? `&id=${this.id}` : '';
if (this.postsService.isLoggedIn) {
fullLocation += (this.subscriptionName ? '&' : '?') + `jwt=${this.postsService.token}`;
if (this.is_shared) { fullLocation += `${uuid_str}${uid_str}${type_str}${id_str}`; }
} else if (this.is_shared) {
fullLocation += (this.subscriptionName ? '&' : '?') + `test=test${uuid_str}${uid_str}${type_str}${id_str}`;
}
// if it has a slash (meaning it's in a directory), only get the file name for the label
let label = null;
const decodedName = decodeURIComponent(fileName);
@@ -205,6 +240,10 @@ export class PlayerComponent implements OnInit {
this.api.getDefaultMedia().subscriptions.loadedMetadata.subscribe(this.playVideo.bind(this));
this.api.getDefaultMedia().subscriptions.ended.subscribe(this.nextVideo.bind(this));
if (this.timestamp) {
this.api.seekTime(+this.timestamp);
}
}
nextVideo() {
@@ -269,7 +308,8 @@ export class PlayerComponent implements OnInit {
const ext = (this.type === 'audio') ? '.mp3' : '.mp4';
const filename = this.playlist[0].title;
this.downloading = true;
this.postsService.downloadFileFromServer(filename, this.type, null, null, this.subscriptionName, this.subPlaylist).subscribe(res => {
this.postsService.downloadFileFromServer(filename, this.type, null, null, this.subscriptionName, this.subPlaylist,
this.is_shared ? this.db_file['uid'] : null, this.uuid).subscribe(res => {
this.downloading = false;
const blob: Blob = res;
saveAs(blob, filename + ext);
@@ -356,7 +396,9 @@ export class PlayerComponent implements OnInit {
uid: this.id ? this.id : this.uid,
type: this.type,
sharing_enabled: this.id ? this.db_playlist.sharingEnabled : this.db_file.sharingEnabled,
is_playlist: !!this.id
is_playlist: !!this.id,
uuid: this.postsService.isLoggedIn ? this.postsService.user.uid : null,
current_timestamp: this.api.time.current
},
width: '60vw'
});

View File

@@ -1,16 +1,19 @@
import {Injectable, isDevMode, Inject} from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';
import { THEMES_CONFIG } from '../themes';
import { Router } from '@angular/router';
import { Router, CanActivate } from '@angular/router';
import { DOCUMENT } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
import { v4 as uuid } from 'uuid';
import { MatSnackBar } from '@angular/material/snack-bar';
import * as Fingerprint2 from 'fingerprintjs2';
@Injectable()
export class PostsService {
export class PostsService implements CanActivate {
path = '';
audioFolder = '';
videoFolder = '';
@@ -21,10 +24,31 @@ export class PostsService {
theme;
settings_changed = new BehaviorSubject<boolean>(false);
auth_token = '4241b401-7236-493e-92b5-b72696b9d853';
session_id = null;
httpOptions = null;
http_params: string = null;
unauthorized = false;
debugMode = false;
constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document) {
// must be reset after logout
isLoggedIn = false;
token = null;
user = null;
permissions = null;
available_permissions = null;
reload_config = new BehaviorSubject<boolean>(false);
config_reloaded = new BehaviorSubject<boolean>(false);
service_initialized = new BehaviorSubject<boolean>(false);
initialized = false;
open_create_default_admin_dialog = new BehaviorSubject<boolean>(false);
config = null;
constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document,
public snackBar: MatSnackBar) {
console.log('PostsService Initialized...');
// this.startPath = window.location.href + '/api/';
// this.startPathSSL = window.location.href + '/api/';
@@ -35,11 +59,50 @@ export class PostsService {
this.path = 'http://localhost:17442/api/';
}
this.http_params = `apiKey=${this.auth_token}`
this.httpOptions = {
headers: new HttpHeaders({
'Authorization': '4241b401-7236-493e-92b5-b72696b9d853'
}),
params: new HttpParams({
fromString: this.http_params
})
};
Fingerprint2.get(components => {
// set identity as user id doesn't necessarily exist
this.session_id = Fingerprint2.x64hash128(components.map(function (pair) { return pair.value; }).join(), 31);
this.httpOptions.params = this.httpOptions.params.set('sessionID', this.session_id);
});
// get config
this.loadNavItems().subscribe(res => {
const result = !this.debugMode ? res['config_file'] : res;
if (result) {
this.config = result['YoutubeDLMaterial'];
if (this.config['Advanced']['multi_user_mode']) {
// login stuff
if (localStorage.getItem('jwt_token')) {
this.token = localStorage.getItem('jwt_token');
this.httpOptions.params = this.httpOptions.params.set('jwt', this.token);
this.jwtAuth();
} else {
this.sendToLogin();
}
} else {
this.setInitialized();
}
}
});
this.reload_config.subscribe(yes_reload => {
if (yes_reload) { this.reloadConfig(); }
});
}
canActivate(route, state): Promise<boolean> {
return new Promise(resolve => {
resolve(true);
})
console.log(route);
throw new Error('Method not implemented.');
}
setTheme(theme) {
@@ -54,6 +117,16 @@ export class PostsService {
return this.http.get(url + 'geturl');
}
reloadConfig() {
this.loadNavItems().subscribe(res => {
const result = !this.debugMode ? res['config_file'] : res;
if (result) {
this.config = result['YoutubeDLMaterial'];
this.config_reloaded.next(true);
}
});
}
getVideoFolder() {
return this.http.get(this.startPath + 'videofolder');
}
@@ -63,25 +136,27 @@ export class PostsService {
}
// tslint:disable-next-line: max-line-length
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null) {
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null) {
return this.http.post(this.path + 'tomp3', {url: url,
maxBitrate: selectedQuality,
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
customOutput: customOutput,
youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword}, this.httpOptions);
youtubePassword: youtubePassword,
ui_uid: ui_uid}, this.httpOptions);
}
// tslint:disable-next-line: max-line-length
makeMP4(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null) {
makeMP4(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null) {
return this.http.post(this.path + 'tomp4', {url: url,
selectedHeight: selectedQuality,
customQualityConfiguration: customQualityConfiguration,
customArgs: customArgs,
customOutput: customOutput,
youtubeUsername: youtubeUsername,
youtubePassword: youtubePassword}, this.httpOptions);
youtubePassword: youtubePassword,
ui_uid: ui_uid}, this.httpOptions);
}
getFileStatusMp3(name: string) {
@@ -124,24 +199,31 @@ export class PostsService {
return this.http.get(this.path + 'getMp4s', this.httpOptions);
}
getFile(uid, type) {
return this.http.post(this.path + 'getFile', {uid: uid, type: type}, this.httpOptions);
getFile(uid, type, uuid = null) {
return this.http.post(this.path + 'getFile', {uid: uid, type: type, uuid: uuid}, this.httpOptions);
}
downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null) {
downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null,
uid = null, uuid = null) {
return this.http.post(this.path + 'downloadFile', {fileNames: fileName,
type: type,
zip_mode: Array.isArray(fileName),
outputName: outputName,
fullPathProvided: fullPathProvided,
subscriptionName: subscriptionName,
subPlaylist: subPlaylist
subPlaylist: subPlaylist,
uuid: uuid,
uid: uid
},
{responseType: 'blob', headers: this.httpOptions.headers});
{responseType: 'blob', params: this.httpOptions.params});
}
uploadCookiesFile(fileFormData) {
return this.http.post(this.path + 'uploadCookies', fileFormData, this.httpOptions);
}
downloadArchive(sub) {
return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', headers: this.httpOptions.headers});
return this.http.post(this.path + 'downloadArchive', {sub: sub}, {responseType: 'blob', params: this.httpOptions.params});
}
getFileInfo(fileNames, type, urlMode) {
@@ -179,9 +261,9 @@ export class PostsService {
thumbnailURL: thumbnailURL}, this.httpOptions);
}
getPlaylist(playlistID, type) {
getPlaylist(playlistID, type, uuid = null) {
return this.http.post(this.path + 'getPlaylist', {playlistID: playlistID,
type: type}, this.httpOptions);
type: type, uuid: uuid}, this.httpOptions);
}
updatePlaylist(playlistID, fileNames, type) {
@@ -215,6 +297,23 @@ export class PostsService {
return this.http.post(this.path + 'getAllSubscriptions', {}, this.httpOptions);
}
// current downloads
getCurrentDownloads() {
return this.http.get(this.path + 'downloads', 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);
}
// clear downloads. download_id is optional, if it exists only 1 download will be cleared
clearDownloads(delete_all = false, session_id = null, download_id = null) {
return this.http.post(this.path + 'clearDownloads', {delete_all: delete_all,
download_id: download_id,
session_id: session_id ? session_id : this.session_id}, this.httpOptions);
}
// updates the server to the latest version
updateServer(tag) {
return this.http.post(this.path + 'updateServer', {tag: tag}, this.httpOptions);
@@ -233,4 +332,154 @@ export class PostsService {
return this.http.get('https://api.github.com/repos/tzahi12345/youtubedl-material/releases');
}
afterLogin(user, token, permissions, available_permissions) {
this.isLoggedIn = true;
this.user = user;
this.permissions = permissions;
this.available_permissions = available_permissions;
this.token = token;
localStorage.setItem('jwt_token', this.token);
this.httpOptions.params = this.httpOptions.params.set('jwt', this.token);
this.setInitialized();
// needed to re-initialize parts of app after login
this.config_reloaded.next(true);
if (this.router.url === '/login') {
this.router.navigate(['/home']);
}
}
// user methods
login(username, password) {
const call = this.http.post(this.path + 'auth/login', {userid: username, password: password}, this.httpOptions);
return call;
}
// user methods
jwtAuth() {
const call = this.http.post(this.path + 'auth/jwtAuth', {}, this.httpOptions);
call.subscribe(res => {
if (res['token']) {
this.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']);
}
}, err => {
if (err.status === 401) {
this.sendToLogin();
}
console.log(err);
});
return call;
}
logout() {
this.user = null;
this.permissions = null;
this.isLoggedIn = false;
localStorage.setItem('jwt_token', null);
if (this.router.url !== '/login') {
this.router.navigate(['/login']);
}
// resets http params
this.http_params = `apiKey=${this.auth_token}&sessionID=${this.session_id}`
this.httpOptions = {
params: new HttpParams({
fromString: this.http_params
}),
};
}
// user methods
register(username, password) {
const call = this.http.post(this.path + 'auth/register', {userid: username,
username: username,
password: password}, this.httpOptions);
/*call.subscribe(res => {
console.log(res['user']);
if (res['user']) {
// this.afterRegistration(res['user']);
}
});*/
return call;
}
sendToLogin() {
this.checkAdminCreationStatus();
if (this.router.url === '/login') {
return;
}
this.router.navigate(['/login']);
// send login notification
this.openSnackBar('You must log in to access this page!');
}
setInitialized() {
this.service_initialized.next(true);
this.initialized = true;
this.config_reloaded.next(true);
}
adminExists() {
return this.http.post(this.path + 'auth/adminExists', {}, this.httpOptions);
}
createAdminAccount(password) {
return this.http.post(this.path + 'auth/register', {userid: 'admin',
username: 'admin',
password: password}, this.httpOptions);
}
checkAdminCreationStatus(skip_check = false) {
if (!skip_check && !this.config['Advanced']['multi_user_mode']) {
return;
}
this.adminExists().subscribe(res => {
if (!res['exists']) {
// must create admin account
this.open_create_default_admin_dialog.next(true);
}
});
}
changeUser(change_obj) {
return this.http.post(this.path + 'updateUser', {change_object: change_obj}, this.httpOptions);
}
deleteUser(uid) {
return this.http.post(this.path + 'deleteUser', {uid: uid}, this.httpOptions);
}
changeUserPassword(user_uid, new_password) {
return this.http.post(this.path + 'auth/changePassword', {user_uid: user_uid, new_password: new_password}, this.httpOptions);
}
getUsers() {
return this.http.post(this.path + 'getUsers', {}, this.httpOptions);
}
getRoles() {
return this.http.post(this.path + 'getRoles', {}, this.httpOptions);
}
setUserPermission(user_uid, permission, new_value) {
return this.http.post(this.path + 'changeUserPermissions', {user_uid: user_uid, permission: permission, new_value: new_value},
this.httpOptions);
}
setRolePermission(role_name, permission, new_value) {
return this.http.post(this.path + 'changeRolePermissions', {role: role_name, permission: permission, new_value: new_value},
this.httpOptions);
}
public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

View File

@@ -14,13 +14,13 @@
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [(ngModel)]="new_config['Host']['url']" matInput placeholder="URL" i18n-placeholder="URL input placeholder" required>
<mat-hint><ng-container i18n="URL setting input hint">URL this app will be accessed from, without the port.</ng-container></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mb-4">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [(ngModel)]="new_config['Host']['port']" matInput placeholder="Port" i18n-placeholder="Port input placeholder" required>
<mat-hint><ng-container i18n="Port setting input hint">The desired port. Default is 17442.</ng-container></mat-hint>
</mat-form-field>
@@ -28,6 +28,20 @@
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['Advanced']['multi_user_mode']"><ng-container i18n="Multi user mode setting">Multi-user mode</ng-container></mat-checkbox>
</div>
<div class="col-12 mt-3 mb-4">
<mat-form-field class="text-field">
<input [disabled]="!new_config['Advanced']['multi_user_mode']" [(ngModel)]="new_config['Users']['base_path']" matInput placeholder="Users base path" i18n-placeholder="Users base path placeholder" required>
<mat-hint><ng-container i18n="Users base path hint">Base path for users and their downloaded videos.</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">
@@ -35,13 +49,13 @@
</div>
<div class="col-12">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['Encryption']['use-encryption']" [(ngModel)]="new_config['Encryption']['cert-file-path']" matInput placeholder="Cert file path" i18n-placeholder="Cert file path input placeholder">
</mat-form-field>
</div>
<div class="col-12">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['Encryption']['use-encryption']" [(ngModel)]="new_config['Encryption']['key-file-path']" matInput placeholder="Key file path" i18n-placeholder="Key file path input placeholder">
</mat-form-field>
</div>
@@ -54,13 +68,13 @@
<mat-checkbox color="accent" [(ngModel)]="new_config['Subscriptions']['allow_subscriptions']"><ng-container i18n="Allow subscriptions setting">Allow subscriptions</ng-container></mat-checkbox>
</div>
<div class="col-12">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['Subscriptions']['allow_subscriptions']" [(ngModel)]="new_config['Subscriptions']['subscriptions_base_path']" matInput placeholder="Subscriptions base path" i18n-placeholder="Subscriptions base path input setting placeholder">
<mat-hint><ng-container i18n="Subscriptions base path setting input hint">Base path for videos from your subscribed channels and playlists. It is relative to YTDL-Material's root folder.</ng-container></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-5">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['Subscriptions']['allow_subscriptions']" [(ngModel)]="new_config['Subscriptions']['subscriptions_check_interval']" matInput placeholder="Check interval" i18n-placeholder="Check interval input setting placeholder">
<mat-hint><ng-container i18n="Check interval setting input hint">Unit is seconds, only include numbers.</ng-container></mat-hint>
</mat-form-field>
@@ -93,7 +107,7 @@
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-form-field>
<mat-form-field color="accent">
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
<mat-option *ngFor="let locale of supported_locales" [value]="locale">
@@ -113,23 +127,23 @@
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input matInput [(ngModel)]="new_config['Downloader']['path-audio']" placeholder="Audio folder path" i18n-placeholder="Audio folder path input placeholder" required>
<mat-hint><ng-container i18n="Aduio path setting input hint">Path for audio only downloads. It is relative to YTDL-Material's root folder.</ng-container></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-5">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input matInput [(ngModel)]="new_config['Downloader']['path-video']" placeholder="Video folder path" i18n-placeholder="Video folder path input placeholder" required>
<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-5">
<mat-form-field style="margin-right: 12px;" color="accent">
<mat-form-field class="text-field" style="margin-right: 12px;" color="accent">
<textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="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.</ng-container></mat-hint>
<mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint>
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
</mat-form-field>
</div>
@@ -148,7 +162,7 @@
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [(ngModel)]="new_config['Extra']['title_top']" matInput placeholder="Top title" i18n-placeholder="Top title input placeholder" required>
<mat-hint></mat-hint>
</mat-form-field>
@@ -156,6 +170,9 @@
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['file_manager_enabled']"><ng-container i18n="File manager enabled setting">File manager enabled</ng-container></mat-checkbox>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['enable_downloads_manager']"><ng-container i18n="Downloads manager enabled setting">Downloads manager enabled</ng-container></mat-checkbox>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_quality_select']"><ng-container i18n="Allow quality seelct setting">Allow quality select</ng-container></mat-checkbox>
</div>
@@ -163,10 +180,10 @@
<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-downloade mode setting">Allow multi-download mode</ng-container></mat-checkbox>
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['allow_multi_download_mode']"><ng-container i18n="Allow multi-download mode setting">Allow multi-download mode</ng-container></mat-checkbox>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['settings_pin_required']"><ng-container i18n="Require pin for settings setting">Require pin for settings</ng-container></mat-checkbox>
<mat-checkbox [disabled]="new_config['Advanced']['multi_user_mode']" color="accent" [(ngModel)]="new_config['Extra']['settings_pin_required']"><ng-container i18n="Require pin for settings setting">Require pin for settings</ng-container></mat-checkbox>
<button style="margin-left: 15px; margin-bottom: 10px;" mat-stroked-button (click)="setNewPin()" [disabled]="!new_config['Extra']['settings_pin_required']"><ng-container i18n="Set new pin button">Set New Pin</ng-container></button>
</div>
</div>
@@ -179,7 +196,7 @@
</div>
<div class="col-12 mb-3">
<div class="enable-api-key-div">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_API_key']" [(ngModel)]="new_config['API']['API_key']" matInput placeholder="Public API Key" i18n-placeholder="Public API Key setting placeholder" required>
<mat-hint><a target="_blank" href="https://stoplight.io/p/docs/gh/tzahi12345/youtubedl-material"><ng-container i18n="View API docs setting hint">View documentation</ng-container></a></mat-hint>
</mat-form-field>
@@ -197,7 +214,7 @@
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_youtube_API']"><ng-container i18n="Use YouTube API setting">Use YouTube API</ng-container></mat-checkbox>
</div>
<div class="col-12 mb-3">
<mat-form-field color="accent">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_youtube_API']" [(ngModel)]="new_config['API']['youtube_API_key']" matInput placeholder="Youtube API Key" i18n-placeholder="Youtube API Key setting placeholder" required>
<mat-hint><a target="_blank" href="https://developers.google.com/youtube/v3/getting-started"><ng-container i18n="Youtube API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
</mat-form-field>
@@ -252,17 +269,44 @@
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mb-3">
<div class="col-12 mt-2 mb-1">
<mat-form-field>
<mat-label><ng-container i18n="Logger level select label">Select a downloader</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['logger_level']">
<mat-option value="debug">Debug</mat-option>
<mat-option value="verbose">Verbose</mat-option>
<mat-option value="info">Info</mat-option>
<mat-option value="warn">Warn</mat-option>
<mat-option value="error">Error</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="col-12">
<mat-checkbox color="accent" [(ngModel)]="new_config['Advanced']['allow_advanced_download']"><ng-container i18n="Allow advanced downloading setting">Allow advanced download</ng-container></mat-checkbox>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-4">
<mat-checkbox color="accent" [(ngModel)]="new_config['Advanced']['use_cookies']"><ng-container i18n="Use cookies setting">Use Cookies</ng-container></mat-checkbox>
<button class="checkbox-button" mat-stroked-button (click)="openCookiesUploaderDialog()"><ng-container i18n="Set cookies button">Set Cookies</ng-container></button>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid mt-1">
<app-updater></app-updater>
</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;">
<mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox>
</div>
<app-modify-users></app-modify-users>
</mat-tab>
</mat-tab-group>
</mat-dialog-content>

View File

@@ -20,4 +20,14 @@
.api-key-div {
display: inline-block;
}
.text-field {
min-width: 30%;
}
.checkbox-button {
margin-left: 15px;
margin-bottom: 12px;
bottom: 4px;
}

View File

@@ -8,6 +8,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ArgModifierDialogComponent } from 'app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component';
import { CURRENT_VERSION } from 'app/consts';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { CookiesUploaderDialogComponent } from 'app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component';
@Component({
selector: 'app-settings',
@@ -39,7 +40,7 @@ export class SettingsComponent implements OnInit {
this._settingsSame = val;
}
constructor(private postsService: PostsService, private snackBar: MatSnackBar, private sanitizer: DomSanitizer,
constructor(public postsService: PostsService, private snackBar: MatSnackBar, private sanitizer: DomSanitizer,
private dialog: MatDialog) { }
ngOnInit() {
@@ -51,14 +52,8 @@ export class SettingsComponent implements OnInit {
}
getConfig() {
this.loading_config = true;
this.postsService.loadNavItems().subscribe(res => {
this.loading_config = false;
// successfully loaded config
this.initial_config = !this.postsService.debugMode ? res['config_file']['YoutubeDLMaterial'] : res['YoutubeDLMaterial'];
this.new_config = JSON.parse(JSON.stringify(this.initial_config));
});
this.initial_config = this.postsService.config;
this.new_config = JSON.parse(JSON.stringify(this.initial_config));
}
settingsSame() {
@@ -69,9 +64,13 @@ export class SettingsComponent implements OnInit {
const settingsToSave = {'YoutubeDLMaterial': this.new_config};
this.postsService.setConfig(settingsToSave).subscribe(res => {
if (res['success']) {
if (!this.initial_config['Advanced']['multi_user_mode'] && this.new_config['Advanced']['multi_user_mode']) {
// multi user mode was enabled, let's check if default admin account exists
this.postsService.checkAdminCreationStatus(true);
}
// sets new config as old config
this.postsService.settings_changed.next(true);
this.initial_config = JSON.parse(JSON.stringify(this.new_config));
this.postsService.reload_config.next(true);
}
}, err => {
console.error('Failed to save config!');
@@ -146,9 +145,9 @@ export class SettingsComponent implements OnInit {
}
});
dialogRef.afterClosed().subscribe(new_args => {
if (new_args) {
if (new_args !== null && new_args !== undefined) {
this.new_config['Downloader']['custom_args'] = new_args;
}
}
});
}
@@ -158,6 +157,12 @@ export class SettingsComponent implements OnInit {
});
}
openCookiesUploaderDialog() {
this.dialog.open(CookiesUploaderDialogComponent, {
width: '65vw'
});
}
// snackbar helper
public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, {

Some files were not shown because too many files have changed in this diff Show More