Compare commits

...

264 Commits

Author SHA1 Message Date
Isaac Abadi
c9c0247480 Tasks actions styling update 2023-05-21 00:12:58 -06:00
Isaac Abadi
2fcf5364d8 Rebuild database task now asks for confirmation before rebuilding
Fixed api types build errors
2023-05-21 00:11:48 -06:00
Isaac Abadi
a38fb0e2e0 Updated api models 2023-05-20 23:49:27 -06:00
Isaac Abadi
e6050969ec Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into rebuild-database-task 2023-05-20 23:44:49 -06:00
Isaac Abadi
958300c281 Added typing to task key 2023-05-20 23:44:14 -06:00
Tzahi12345
84c2b2769b Merge pull request #890 from martadinata666/js-generate
docker : reduce build time and size
2023-05-21 00:35:28 -04:00
Isaac Abadi
e145c9c992 Updated fetch-twitchdownloader.sh to get the latest release for the specific arch 2023-05-20 19:32:00 -06:00
Isaac Abadi
078408236c Code cleanup
Default user password after rebuild is now 'password'
2023-05-20 18:27:08 -06:00
Dedy Martadinata S
2adbc0a02c Update fetch-twitchdownloader.sh 2023-05-20 11:35:12 +07:00
Dedy Martadinata S
fe95f04c18 Update Dockerfile 2023-05-20 11:30:24 +07:00
Dedy Martadinata S
9b3816afce Update Dockerfile 2023-05-20 11:25:16 +07:00
Dedy Martadinata S
07874d9241 Revert 142d708ee3
It become fail to set anything
2023-05-20 11:13:09 +07:00
Isaac Abadi
03122b4c81 Subscription metadata is now backed up
Rebuild database now used subscription metadata backup
2023-05-18 23:32:12 -04:00
Isaac Abadi
3deb1e8459 updated subscriptions.js export syntax 2023-05-18 22:23:48 -04:00
Dedy Martadinata S
9fa1aab1e5 Update Dockerfile, Update CI PR, use scripts to download twitchdownloader 2023-05-12 14:46:27 +07:00
Glassed Silver
80b41af620 Merge pull request #910 from Tzahi12345/twitch-chat-fix
Twitch chat downloader fix for Docker
2023-05-12 07:01:12 +02:00
Tzahi12345
ab5d8dc5ca Merge pull request #911 from Tzahi12345/registration-fixes
Registration fixes
2023-05-12 00:03:05 -04:00
Tzahi12345
4b55c39f39 permissions code simplified 2023-05-11 23:14:40 -04:00
Tzahi12345
3ca296f195 Fixed compilation issue 2023-05-11 23:14:25 -04:00
Tzahi12345
d4fa640f0f Added tasks_manager to possible user/role permission in openapi 2023-05-11 02:47:06 -04:00
Tzahi12345
427eecf214 Fixed issue where after resetting the DB, admin would have to be registered twice 2023-05-11 02:44:05 -04:00
Tzahi12345
4f54e408a5 Removed erroneous error after registering admin 2023-05-11 02:43:37 -04:00
Tzahi12345
9e481bbd5f Fixed issue where twitch chat downloader could not be found in docker 2023-05-11 02:27:35 -04:00
Glassed Silver
78b29a76b8 Merge pull request #864 from nardis556/master
Update entrypoint.sh
2023-05-11 03:19:50 +02:00
Glassed Silver
0342d18f76 Merge pull request #906 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-05-11 03:17:40 +02:00
Glassed Silver
70754c580c Merge pull request #908 from Tzahi12345/ffmpeg-force-v5
Force ffmpeg 5.1.1
2023-05-11 03:16:08 +02:00
Tzahi12345
e58b0b8638 Force ffmpeg 5.1.1 2023-05-10 19:04:48 -04:00
YMisterXY
df8f8070ca Translated using Weblate (Polish)
Currently translated at 80.5% (385 of 478 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/pl/
2023-05-10 13:49:19 +02:00
gallegonovato
0b8ca31594 Translated using Weblate (Spanish)
Currently translated at 100.0% (478 of 478 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/es/
2023-05-10 13:49:19 +02:00
Tzahi12345
658a76dc1c Added missing admin tasks_manager role 2023-05-08 19:23:07 -04:00
Glassed Silver
f363ec5db6 Merge pull request #901 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-05-08 22:12:01 +02:00
gallegonovato
f36d675abf Translated using Weblate (Spanish)
Currently translated at 100.0% (480 of 480 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/es/
2023-05-08 17:52:23 +02:00
Tzahi12345
35fcf44e1a Fixed rebuild db log messages
Rebuild db now imports unregistered files
2023-05-08 02:08:01 -04:00
Tzahi12345
bad6080730 Adds task to rebuild database 2023-05-08 02:00:43 -04:00
Tzahi12345
2a7b62272e Merge remote-tracking branch 'origin/testing-improvements' 2023-05-07 23:36:35 -04:00
Tzahi12345
be74377a08 Merge pull request #899 from Tzahi12345/remove-armv7
Remove armv7 support in Docker
2023-05-07 02:13:55 -04:00
Isaac Abadi
808c7e2112 Temporarily remove armv7 support
Revert "Added python3.8-dev/build-essential to dockerfile"

This reverts commit d90434c240.

Revert "Adds token to GH actions for GetTwitchDownloader"

This reverts commit a4ca1abb7c.
2023-05-07 01:23:58 -04:00
Isaac Abadi
d6f39d37b5 Added PR multiarch
Added python3.8-dev/build-essential to dockerfile

Adds token to GH actions for GetTwitchDownloader
2023-05-07 01:23:44 -04:00
Isaac Abadi
0c46b044da Improved tests for multi-user mode 2023-05-06 23:29:20 -04:00
Tzahi12345
e573f34cea Merge pull request #893 from Tzahi12345/readme-update
README update
2023-05-05 00:05:03 -04:00
Tzahi12345
52e32d4f0f Changed tcd to Twitch Downloader 2023-05-04 23:44:12 -04:00
Tzahi12345
adb5f2256e Translations source file update 2023-05-04 22:33:48 -04:00
Tzahi12345
59bf6ff86d Merge pull request #888 from Tzahi12345/dependency-updates
Dependency updates
2023-05-04 22:28:50 -04:00
Tzahi12345
5ce2e2a35d Updated name of arm64 TwitchDownloaderCLI asset 2023-05-04 22:19:00 -04:00
Tzahi12345
68fbde8907 Merge pull request #800 from Bastians-Bits/master
Development Documentation
2023-05-03 19:32:07 -04:00
Tzahi12345
62bccb3349 Updated DEVELOPMENT.md to reflect dev config file 2023-05-03 17:01:17 -04:00
Tzahi12345
90d9ac025a Added missing config item from default.json 2023-05-03 16:38:48 -04:00
Tzahi12345
07903131f9 GetTwitchDownloader.py now supports LinuxArm-x64 (not implemeted yet), waiting for this: https://github.com/lay295/TwitchDownloader/pull/703 2023-05-03 14:20:54 -04:00
Tzahi12345
ec3bb3e738 Updated dockerfile to move TwitchDownloaderCLI to /usr/local/bin
Fixed issue that prevented TwitchDownloader from working in windows
2023-05-03 14:04:09 -04:00
Tzahi12345
18fcf4eb61 Added missing copy in dockerfile 2023-05-03 02:01:28 -04:00
Tzahi12345
19f35d6af4 Updated content-loader and ngx-file-drop to improve build times 2023-05-03 01:33:51 -04:00
Tzahi12345
3a918b7059 Updated github actions dependencies (2) 2023-05-03 01:08:52 -04:00
Tzahi12345
7e7da6c0bc Updated github actions dependencies 2023-05-03 01:07:14 -04:00
Tzahi12345
f9f7204deb Updated several dependencies 2023-05-03 01:03:49 -04:00
Tzahi12345
8827d9f3de Added some pruning to shrink docker image size 2023-05-02 23:13:47 -04:00
Tzahi12345
42bc255d6c Removed fingerprintjs2 and sessionID param 2023-05-02 23:04:41 -04:00
Tzahi12345
2df3b9cbfd Removed electron (for now) 2023-05-02 23:02:33 -04:00
Tzahi12345
b859d08d86 Added raspberry pi specific documentation to docker-compose 2023-05-02 22:51:52 -04:00
Tzahi12345
e7325b2dc2 Merge pull request #887 from Tzahi12345/gh-actions-fix
GH actions fix
2023-05-02 19:19:06 -04:00
Tzahi12345
21463762ce Removed rimraf install from package.json 2023-05-02 19:10:40 -04:00
Tzahi12345
b06f6a81bb Force rmraf install 2023-05-01 22:34:25 -04:00
Tzahi12345
82c8146032 Updated nodejs version for backend 2023-05-01 19:56:37 -04:00
Tzahi12345
6f13eab550 Force rimraf to use locally installed version https://stackoverflow.com/questions/49092120/sh-1-rimraf-not-found-whenever-i-run-npm-run-build-within-vagrant-installed-o 2023-05-01 19:52:35 -04:00
Tzahi12345
9d2d70b194 Upgraded node version to v16 2023-05-01 19:43:04 -04:00
Tzahi12345
4e04ceae16 Fixed function call in db.js 2023-05-01 19:31:54 -04:00
Tzahi12345
5eec5ac082 Merge pull request #885 from Tzahi12345/slack-notifications
Added support for slack notifications
2023-05-01 17:21:00 -04:00
Tzahi12345
5253ce8793 Merge pull request #886 from Tzahi12345/archive-improvements
Archive improvements
2023-05-01 17:20:50 -04:00
Tzahi12345
33a99d9c8d Added files_api (migrated functions from db_api that are file related)
Archive dialog can now always be opened
2023-04-29 19:41:34 -04:00
Tzahi12345
0e5c78db0d Modified archive logic to align with #366 2023-04-29 19:34:08 -04:00
Tzahi12345
9a08fc6140 Added support for slack notifications 2023-04-29 15:46:58 -04:00
Glassed Silver
e7b9dfd312 Merge pull request #873 from Tzahi12345/pre-4.3.1-bug-fixes
Pre 4.3.1 bug fixes
2023-04-28 10:26:11 +02:00
Glassed Silver
1e2922559c Merge pull request #868 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-04-26 15:30:41 +02:00
Tzahi12345
cfbee6d6f1 Sub name duplicated bug fix 2023-04-25 23:59:13 -04:00
Tzahi12345
c75d58efd5 Fixed issue where duplicate sub names were possible (#801) 2023-04-25 23:36:15 -04:00
Tzahi12345
efbf395368 Fixed settings tab url labels 2023-04-25 22:14:52 -04:00
Tzahi12345
dab9fc83ba Added support for discord webhooks
Improved download error notifications
2023-04-25 22:14:35 -04:00
Tzahi12345
e086bbc301 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into pre-4.3.1-bug-fixes 2023-04-25 21:57:24 -04:00
Tzahi12345
0b3a21b383 Added missing config file settings 2023-04-24 21:17:33 -04:00
Tzahi12345
f973426bd2 Hotfix for error that prevents downloads from occurring 2023-04-24 21:11:10 -04:00
Tzahi12345
a4c78e3a3d Minor verbose message update 2023-04-24 21:10:51 -04:00
Tzahi12345
50d3bc183b Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into pre-4.3.1-bug-fixes 2023-04-24 20:14:55 -04:00
Tzahi12345
5a379a6a2b Updated package-lock.json (#877) 2023-04-24 20:03:11 -04:00
Tzahi12345
71692f6b13 Updated dependencies 2023-04-24 19:55:53 -04:00
Tzahi12345
1746b08d4c Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into pre-4.3.1-bug-fixes 2023-04-24 19:32:19 -04:00
Tzahi12345
3bc0ec8bb5 Added category tests
Fixed syntax errors in tests.js
2023-04-24 19:30:25 -04:00
Tzahi12345
2df4dc1bfc Updated file not found error message in /stream 2023-04-24 19:29:35 -04:00
Tzahi12345
0e190fca2a Ghost file card count are now per-sub 2023-04-24 19:28:52 -04:00
Tzahi12345
5aea0b7a3d Archive text file is now temporarily added to a sub dir when checking a sub for speed purposes 2023-04-24 19:28:11 -04:00
Tzahi12345
d76aaf83f6 Merge pull request #707 from Tzahi12345/categories-fix
Categories matching bug fix
2023-04-24 10:35:08 -04:00
Tzahi12345
a996b9f0d2 ng-deeps now only apply to current component
https://stackoverflow.com/questions/46786986/how-and-where-to-use-ng-deep
2023-04-23 22:31:11 -04:00
Tzahi12345
d3b88412c6 Fixed thumbnails for auto-generated playlists 2023-04-23 22:27:09 -04:00
Tzahi12345
6cee892e18 Added label for category field in video-info-dialog 2023-04-23 22:20:50 -04:00
Tzahi12345
e2438a236b Adjusted category UI styling to Angular 15 updates 2023-04-23 22:14:20 -04:00
Ettore Atalan
7a4ae052ed Translated using Weblate (German)
Currently translated at 95.0% (456 of 480 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/de/
2023-04-23 23:48:35 +02:00
Ettore Atalan
b65a7b3dd4 Translated using Weblate (German)
Currently translated at 91.8% (441 of 480 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/de/
2023-04-23 23:48:35 +02:00
Tzahi12345
955c401f0b Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into categories-fix 2023-04-22 17:47:16 -04:00
Tzahi12345
f0a34df7c6 Fixed syntax error in utils 2023-04-21 16:48:24 -04:00
Tzahi12345
e2c68713ba Fixed bug where added args would get injected improperly if they had a value (#745) 2023-04-21 16:34:47 -04:00
Tzahi12345
24cabc1f02 Fixed issue where videos would be downloaded in the collectInfo stage (#774) 2023-04-21 16:12:41 -04:00
Tzahi12345
1edcfca6c3 Fixed bug where notifications wouldn't be set as read if using local db 2023-04-21 15:57:59 -04:00
Tzahi12345
e7fa25cf38 Fixed tasks UI bug 2023-04-21 15:34:14 -04:00
Tzahi12345
527b1f1cb9 Merge pull request #872 from Tzahi12345/twitch-downloader-fix
Twitch chat download fixes
2023-04-21 00:16:06 -04:00
Tzahi12345
24d8072eb5 Fixed minor syntax error in Dockerfile 2023-04-20 21:47:20 -04:00
Tzahi12345
c81bf980ca Separated image for TwitchDownloader download in Dockerfile 2023-04-20 21:41:36 -04:00
Tzahi12345
a91381720f Added link in error message to get TiwtchDownloaderCLI 2023-04-20 21:16:06 -04:00
Tzahi12345
edd4a0928c Fixed twitch downloading by using TiwtchDownloader's CLI
Removed unecessary settings

Created dedicated folder for docker utils
2023-04-20 21:11:48 -04:00
Tzahi12345
770916492e Fixed authentication error in notifications (2) 2023-04-17 23:56:52 -04:00
Tzahi12345
6400b807c2 Fixed auth error in notifications 2023-04-17 23:54:34 -04:00
Tzahi12345
3a7e2d9d0f Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2023-04-16 21:09:35 -04:00
Tzahi12345
ca5381fe0f Updated tasks DB-related code to not insert properties that prevent local_db from being imported
Added DB functionality to remove properties from records

DB records in local DB can now be updated if nested
2023-04-16 21:08:18 -04:00
nardis556
26988bd607 Update entrypoint.sh 2023-04-16 15:03:12 -05:00
Glassed Silver
bd8d91ebe5 Merge pull request #862 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-04-16 14:05:13 +02:00
Kawaxte
27f05dbae3 Translated using Weblate (Estonian)
Currently translated at 31.8% (153 of 480 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/et/
2023-04-16 13:51:39 +02:00
Glassed Silver
c7bf1d0e27 Merge pull request #786 from beauharrison/docker-custom-user-startup
Fixed long docker startup time by optimizing chown use
2023-04-16 10:30:24 +02:00
Glassed Silver
57be0a032e Merge pull request #858 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-04-16 07:50:35 +02:00
Glassed Silver
6fe4b22efc Merge pull request #860 from Tzahi12345/docker-fix
Docker fixes
2023-04-16 07:48:50 +02:00
Tzahi12345
ed492e54c9 Fixed pycryptodomex build error
Changed tcd package to fix broken twitch downloads (#859)
2023-04-15 16:45:32 -04:00
Kawaxte
af2d583924 Added translation using Weblate (Estonian) 2023-04-15 12:14:14 +02:00
yangyangdaji
c61d51be76 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (480 of 480 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/zh_Hans/
2023-04-14 16:49:59 +02:00
Tzahi12345
f3a7d198dc Downgraded mongo image to mongo:4 for compatibility purposes 2023-04-12 19:05:26 -04:00
Tzahi12345
3c03cd96d9 Added link to notifications docs in settings menu 2023-04-12 00:45:13 -04:00
Tzahi12345
43848792fa Updated translations source file 2023-04-12 00:27:54 -04:00
Tzahi12345
fb27264d33 Added support for custom webhook URLs for notifications 2023-04-11 23:45:30 -04:00
Tzahi12345
7593a23c2e Updated README 2023-04-07 21:12:22 -04:00
Tzahi12345
aedde4b4fc Removed unused dependency 2023-04-07 21:04:32 -04:00
Tzahi12345
cd2a727e23 Added some documentation to downloader.js 2023-04-07 21:01:08 -04:00
Tzahi12345
30c7a96540 Dockerfile now installs pycryptodomex (#819) 2023-04-07 20:24:57 -04:00
Tzahi12345
5197a5f1cc Minor refactor of utils.js 2023-04-07 20:23:19 -04:00
Tzahi12345
12e69afa84 Merge pull request #708 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-04-02 21:14:09 -04:00
Tzahi12345
e720edf9f0 Added hint that restart is required after changing the downloader
Updated translation source file
2023-04-01 19:26:25 -04:00
Tzahi12345
3544a2316d Added warning to using non yt-dlp downloaders 2023-04-01 19:14:07 -04:00
Tzahi12345
4b2e5fb636 Incremented version to v4.3.1 2023-04-01 19:08:03 -04:00
Tzahi12345
929e01e5eb Merge pull request #809 from Tzahi12345/notifications-update
v4.3.1 update
2023-04-01 19:02:35 -04:00
Tzahi12345
1f2c5a0238 Fixed an issue where subs would only display 10 of their videos in the subscription component (#851)
Fixed an issue where a sub would get stuck in the downloading state

Fixed UI bug in the subscriptions component
2023-04-01 18:59:23 -04:00
Tzahi12345
9f833d32a2 Fixed an issue where JWT_EXPIRATION was sometimes a string causing a crash (#813) 2023-04-01 18:31:45 -04:00
Tzahi12345
763ce5d28b Added ability to download archives from the archive viewer 2023-04-01 03:14:17 -04:00
Tzahi12345
0e15fd7193 Removed deprecated safe_download_override setting 2023-04-01 00:17:13 -04:00
Tzahi12345
a9d7f275ba Added filesize approximation tooltips to quality select 2023-04-01 00:15:17 -04:00
Tzahi12345
b911552c31 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into notifications-update 2023-03-30 02:29:48 -04:00
Tzahi12345
da17d903e1 Merge pull request #812 from mholmqvist/mholmqvist-patch-1
Mholmqvist patch 1
2023-03-28 12:12:17 -04:00
Isaac Abadi
a4bbc7df3b Updated translations source file 2023-03-27 19:37:44 -04:00
Isaac Abadi
0bdac15ef1 Downloads restarted from home page now persist after restarting rather than disappearing 2023-03-27 19:15:14 -04:00
Isaac Abadi
07a0ea6d18 Download notifications now include original URL
Fixed minor notification crash
2023-03-27 19:14:15 -04:00
Isaac Abadi
9c4f903811 Filter text in archive viewer now resets when changing other filters 2023-03-27 18:32:05 -04:00
Isaac Abadi
c1fd8047ea Improved archive viewer
Added archive importing
2023-03-27 01:55:54 -04:00
Isaac Abadi
77a858effa Reverted changes to generated openapi types 2023-03-27 00:59:28 -04:00
Tzahi12345
62ad4226d9 Archive improvements
Began UI for viewing/modifying archives
2023-03-26 01:01:36 -04:00
Tzahi12345
a2b5484b75 Updated OpenAPI version 2023-03-26 00:52:55 -04:00
Frederik Tranberg
c869c84553 Translated using Weblate (Danish)
Currently translated at 3.3% (13 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/da/
2023-03-22 09:39:44 +01:00
Frederik Tranberg
32b2a02f79 Added translation using Weblate (Danish) 2023-03-21 09:00:08 +01:00
Tzahi12345
cb5651d437 Added telegram notification support 2023-03-20 20:23:50 -04:00
Thunderstrike116
105140e674 Translated using Weblate (Greek)
Currently translated at 2.0% (8 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/el/
2023-03-16 20:38:14 +01:00
Thunderstrike116
475efc4d9e Added translation using Weblate (Greek) 2023-03-15 19:49:26 +01:00
yangyangdaji
c8a3551402 Translated using Weblate (Turkish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/tr/
2023-03-04 06:37:16 +01:00
yangyangdaji
c526457ee0 Translated using Weblate (Japanese)
Currently translated at 61.8% (240 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ja/
2023-03-04 06:37:16 +01:00
yangyangdaji
859861fae8 Translated using Weblate (Swedish)
Currently translated at 30.1% (117 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/sv/
2023-03-04 06:37:16 +01:00
yangyangdaji
c63744fb3a Translated using Weblate (Telugu)
Currently translated at 70.1% (272 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/te/
2023-03-04 06:37:15 +01:00
yangyangdaji
bbc5b6d222 Translated using Weblate (Macedonian)
Currently translated at 78.0% (303 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/mk/
2023-03-04 06:37:15 +01:00
yangyangdaji
95c0a4977c Translated using Weblate (Korean)
Currently translated at 70.3% (273 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ko/
2023-03-04 06:37:14 +01:00
yangyangdaji
40eefc2ea3 Translated using Weblate (Portuguese)
Currently translated at 70.3% (273 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/pt/
2023-03-04 06:37:14 +01:00
yangyangdaji
8fb0b17441 Translated using Weblate (Dutch)
Currently translated at 99.7% (387 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/nl/
2023-03-04 06:37:14 +01:00
yangyangdaji
191f3b3781 Translated using Weblate (Russian)
Currently translated at 76.5% (297 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ru/
2023-03-04 06:37:13 +01:00
yangyangdaji
95342d6d97 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/zh_Hans/
2023-03-04 06:37:13 +01:00
yangyangdaji
5c70e71710 Translated using Weblate (German)
Currently translated at 99.7% (387 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/de/
2023-03-04 06:37:12 +01:00
Oğuz Ersen
2d0137db43 Translated using Weblate (Turkish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/tr/
2023-02-27 20:38:51 +01:00
Allan Nordhøy
01b307ddb2 Translated using Weblate (Norwegian Bokmål)
Currently translated at 46.1% (179 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/nb_NO/
2023-02-09 14:40:17 +01:00
gallegonovato
9e0d91992d Translated using Weblate (Spanish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/es/
2023-02-09 14:40:16 +01:00
Allan Nordhøy
4e6b895af3 Translated using Weblate (English)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/en/
2023-02-09 14:40:16 +01:00
maboroshin
bdaf336712 Translated using Weblate (Japanese)
Currently translated at 62.1% (241 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ja/
2023-02-05 20:40:06 +01:00
Tzahi12345
0f7c495595 Added download error type information to notifications and downloads (currently unused) 2023-01-29 15:55:05 -05:00
Tzahi12345
6010d991fb Enabled strict template mode in Angular
Code cleanup
2023-01-29 15:51:50 -05:00
Tzahi12345
e82066b2cd Unsubscribing now deletes entries from the archive
Code cleanup
2023-01-24 21:48:42 -05:00
Tzahi12345
970e3834be UI updates to subscriptions
Improved translation coverage
2023-01-24 21:45:19 -05:00
Tzahi12345
840e12db71 Implemented basic db-based archive functionality, converted old archive functionality to new system 2023-01-23 23:17:58 -05:00
Tzahi12345
54208ce6ce Added preliminary backend support for custom archives 2023-01-17 23:35:53 -05:00
Tzahi12345
c724a8019a Improved DB tests, now both local and remote DB can be tested easily 2023-01-16 02:42:36 -05:00
Tzahi12345
f20a31ed0f Removed need for authentication to count views 2023-01-15 00:27:22 -05:00
Tzahi12345
6c8b7d0052 Improved UX in share dialog and video info dialog 2023-01-13 22:52:52 -05:00
Tzahi12345
cebf8c3d36 Improved edit button in video info dialog 2023-01-13 22:24:36 -05:00
Tzahi12345
fe06076eba File deletion is now unified between sub and non-sub files 2023-01-13 22:24:16 -05:00
ssantos
9539e78295 Translated using Weblate (Portuguese)
Currently translated at 70.1% (272 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/pt/
2023-01-12 16:52:02 +01:00
Tzahi12345
8bc14a8be8 Missing options are now auto-added to tasks
Added onlyNumber directive for number-only inputs
2023-01-10 00:09:15 -05:00
Tzahi12345
67e13cb23b Minor style edits in subscribe dialog 2023-01-07 02:04:44 -05:00
Tzahi12345
ba438eca02 Info icon now appears for playlist files in the player component
Added missing data types
2023-01-07 02:04:20 -05:00
mholmqvist
8da050e5b3 Merge pull request #1 from mholmqvist/mholmqvist-patch-2
make ffmpeg-fetch.sh script executable
2023-01-06 21:03:15 +01:00
mholmqvist
01e65a9c25 make ffmpeg-fetch.sh script executable
When starting from git clone its' needed to set +x on ffmpeg-fetch.sh. Else the build fails.
2023-01-06 21:01:11 +01:00
mholmqvist
cfb28f3d43 Update ffmpeg-fetch.sh
Extended timeout on curl for ffmpeg download.
2023-01-06 19:22:02 +01:00
Tzahi12345
121f5586a6 Added ability to generate RSS feed URLs from the UI
Moved property sorting into its own component
2023-01-05 02:38:44 -05:00
Tzahi12345
2a3017972a Added ability to generate RSS feeds from downloads 2023-01-03 23:59:16 -05:00
Tzahi12345
46ffd02b08 Fixed issue where unsubbed subscriptions would continue downloading its videos (#689) 2023-01-03 21:33:16 -05:00
Tzahi12345
8c63a78884 Added timezone information to tasks so that recurring tasks will use the timezone from the user 2023-01-03 21:21:14 -05:00
Tzahi12345
c382758833 Updated node version during CI build to v14 2023-01-03 02:52:54 -05:00
Tzahi12345
9dda608a50 Fixed issue with adding thumbnails to notifications 2023-01-03 02:19:11 -05:00
Tzahi12345
d53b1ec742 Fixed UI bug in about dialog 2023-01-03 02:18:55 -05:00
Tzahi12345
c10b062832 Converted allow_autoplay to force_autoplay as per #695 2023-01-03 02:18:14 -05:00
Tzahi12345
61973510f7 Quality preferences can now be selected for non-YT videos and YT playlists 2023-01-03 02:17:29 -05:00
Tzahi12345
0161f544aa Fixed issue where sometimes only the first video from a playlist would be registered 2023-01-03 01:50:56 -05:00
Nathan-Moignard
1797772395 Translated using Weblate (French)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/fr/
2023-01-03 00:57:26 +01:00
Tzahi12345
7d1c5ff5d8 Fixed UI console error 2023-01-02 02:13:45 -05:00
Tzahi12345
f0c9a6122f Added nfty and gotify notifications support 2023-01-02 02:01:19 -05:00
Tzahi12345
6d881dc812 Minor tests cleanup and workspace improvements 2023-01-01 23:54:46 -05:00
Tzahi12345
46756a575c Added task settings
Added support for task errors

Added support for lt, gt etc. db comparisons

Added task to delete old files
2023-01-01 21:27:07 -05:00
Tzahi12345
3edd4ec5a6 Updated vscode tasks 2023-01-01 12:13:48 -05:00
Tzahi12345
0cf9f2de7a Fixed issue where role/user permissions could not be changed 2023-01-01 12:12:32 -05:00
Tzahi12345
964760a6a8 Fixed potential UI bug where notifications menu would be too small 2022-12-31 15:35:45 -05:00
Isaac Abadi
4f26e9ac3a Added filters for notifications
Added notifications for tasks
2022-12-31 03:38:03 -05:00
Isaac Abadi
bfcc6a0697 Dependencies update 2022-12-29 14:13:05 -06:00
Isaac Abadi
1d10d36304 Misc style improvements and code cleanup 2022-12-29 13:58:04 -06:00
Isaac Abadi
cc2be46ad8 Notifications style improvements 2022-12-29 13:56:45 -06:00
Isaac Abadi
992947fba5 Unused code cleanup 2022-12-28 21:49:56 -06:00
Isaac Abadi
2860b45198 Updated style of top toolbar
- removed title
- added logo which navigates users home
- removed blue color
2022-12-28 21:49:40 -06:00
Isaac Abadi
665bcc04a7 Added ability to favorite a file
Moved file filter options above the list of files, and added option to filter for favorites
2022-12-28 21:48:24 -06:00
Isaac Abadi
c45e0f04be Style improvements and UI bug fixes 2022-12-25 19:26:05 -06:00
John Willemsen
2a19e60c85 Translated using Weblate (Dutch)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/nl/
2022-12-15 13:48:01 +01:00
bastiansbits
575f7eed4e Added a new read me (DEVELOPMENT.md) as starting point for new develope
Added a new VSC launch configuration to start the backend in the debugger
Update the build instruction in README.md (Issue #728)
2022-12-07 14:43:43 +01:00
lk.KEVIN
3ba1b05e84 Translated using Weblate (Spanish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/es/
2022-12-04 20:47:19 +01:00
lk.KEVIN
52b435b8ae Translated using Weblate (Spanish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/es/
2022-11-28 11:47:58 +01:00
Isaac Abadi
20e7ec7c84 Fixed issue where simulated output would only appear after a change is made to args 2022-11-27 12:11:25 -05:00
Isaac Abadi
ac808fcabe Added timestamp to notifications 2022-11-27 12:11:04 -05:00
Isaac Abadi
0efbd11d29 Converted input placeholders to mat-label
Various style improvements

Updated translations
2022-11-27 12:10:45 -05:00
Oğuz Ersen
b78bb83ec9 Translated using Weblate (Turkish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/tr/
2022-11-27 08:48:33 +01:00
Isaac Abadi
5a6e17edb6 Fixed bug where sometimes the config wouldn't be retrieved by the time canActivate would be executed 2022-11-26 17:29:21 -05:00
Isaac Abadi
b11a4e006c Updated to material design v15 2022-11-26 17:28:10 -05:00
Oğuz Ersen
c6ede725e1 Added translation using Weblate (Turkish) 2022-11-26 06:51:43 +01:00
Isaac Abadi
3795a6564b Minor notification style improvement 2022-11-26 00:51:23 -05:00
Isaac Abadi
f44be29181 Added support for download error notifications
Style improvements
2022-11-25 18:20:08 -05:00
Isaac Abadi
b51f45c704 Completed notification functionality
Minor code cleanup
2022-11-25 17:47:30 -05:00
Isaac Abadi
4583e3e5d4 Fixed compilation error 2022-11-25 16:09:48 -05:00
Isaac Abadi
6d5a108cb6 Updated angular to v15 2022-11-24 15:58:31 -05:00
Isaac Abadi
790db77832 Updated ngx-avatars 2022-11-24 15:46:19 -05:00
Isaac Abadi
b1c213f9be Updated angular material to v14 2022-11-24 15:34:17 -05:00
Isaac Abadi
49ecaee58c Angular updated to v14 2022-11-24 15:27:27 -05:00
Isaac Abadi
5e08ca004a Added notifications - (WIP, boilerplate) 2022-11-24 14:54:08 -05:00
Beau Harrison
142d708ee3 Fixed long docker startup time by optimizing chown use 2022-11-17 08:16:24 +10:00
Tanat
477d2f6672 Translated using Weblate (Korean)
Currently translated at 70.1% (272 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ko/
2022-11-14 15:47:57 +01:00
Maite Guix
5cf6e1817f Translated using Weblate (Catalan)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ca/
2022-10-24 07:50:45 +02:00
yangyangdaji
1d6be1442c Translated using Weblate (French)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/fr/
2022-10-21 11:03:42 +02:00
yangyangdaji
8c938b635c Translated using Weblate (Polish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/pl/
2022-10-02 12:18:19 +02:00
yangyangdaji
b56eea3b76 Translated using Weblate (Indonesian)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/id/
2022-10-02 12:18:18 +02:00
yangyangdaji
2aa5d3e91e Translated using Weblate (French)
Currently translated at 99.7% (387 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/fr/
2022-10-02 12:18:18 +02:00
yangyangdaji
89a16ef555 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/zh_Hans/
2022-10-02 12:18:18 +02:00
Kachelkaiser
f818ed744b Translated using Weblate (German)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/de/
2022-09-23 10:23:36 +02:00
Isaac Abadi
2e52ec22e0 Default sort for videos is now download date 2022-09-15 21:38:19 -04:00
Isaac Abadi
efdd0dd228 Categories fix for yt-dlp 2022-09-15 21:38:00 -04:00
atilluF
48248c7ddf Translated using Weblate (Italian)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/it/
2022-09-03 13:22:38 +02:00
Xyx S
49e2458747 Translated using Weblate (Japanese)
Currently translated at 29.6% (115 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ja/
2022-09-03 02:25:55 +02:00
Xyx S
1f973efe60 Translated using Weblate (English)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/en/
2022-09-03 02:25:55 +02:00
YMisterXY
3847f3e0d3 Translated using Weblate (Polish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/pl/
2022-08-31 21:24:32 +02:00
atilluF
26d3875293 Translated using Weblate (Italian)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/it/
2022-08-31 21:24:31 +02:00
YMisterXY
55a4e2e1f2 Translated using Weblate (Polish)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/pl/
2022-08-30 16:55:08 +02:00
YMisterXY
f26016d4ec Added translation using Weblate (Polish) 2022-08-29 19:53:05 +02:00
KoalaUniverse
cd7adcecdd Translated using Weblate (Dutch)
Currently translated at 89.4% (347 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/nl/
2022-08-26 18:21:21 +02:00
Hugel
09847f74ae Translated using Weblate (Japanese)
Currently translated at 16.4% (64 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ja/
2022-08-25 05:21:39 +02:00
Hugel
8ea78f38ed Translated using Weblate (Japanese)
Currently translated at 15.9% (62 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ja/
2022-08-24 04:24:02 +02:00
yangyangdaji
0675ef21c7 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/zh_Hans/
2022-08-21 12:22:10 +02:00
Maite Guix
dfe554d880 Translated using Weblate (Catalan)
Currently translated at 89.4% (347 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ca/
2022-08-13 20:22:44 +02:00
Maxime Leroy
6f1a40d329 Translated using Weblate (French)
Currently translated at 99.7% (387 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/fr/
2022-07-22 21:14:47 +02:00
yangyangdaji
9c7416b2eb Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/zh_Hans/
2022-07-18 07:19:52 +02:00
yangyangdaji
54d8d7844a Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/zh_Hans/
2022-07-16 08:15:48 +02:00
はらたく
1533bc951b Translated using Weblate (Japanese)
Currently translated at 15.2% (59 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/ja/
2022-07-14 02:15:26 +02:00
Azhar Pusparadhian
31f8827e61 Translated using Weblate (Indonesian)
Currently translated at 100.0% (388 of 388 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/id/
2022-07-14 02:15:26 +02:00
はらたく
5f87356544 Added translation using Weblate (Japanese) 2022-07-12 11:18:03 +02:00
Isaac Abadi
415c97cb09 Fixed issue where categories were not being properly applied to matching files (#701) 2022-07-07 01:07:22 -04:00
Isaac Abadi
1c6b7815fe Login tabs now fill the entire component
Expanded widths of inputs on login page to 100%
2022-07-06 23:31:41 -04:00
292 changed files with 42287 additions and 7554 deletions

View File

@@ -15,9 +15,9 @@ jobs:
- name: checkout code - name: checkout code
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: setup node - name: setup node
uses: actions/setup-node@v2 uses: actions/setup-node@v3
with: with:
node-version: '12' node-version: '16'
cache: 'npm' cache: 'npm'
- name: install dependencies - name: install dependencies
run: | run: |
@@ -33,7 +33,7 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y-%m-%d')" run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json - name: create-json
id: create-json id: create-json
uses: jsdaniell/create-json@1.1.2 uses: jsdaniell/create-json@v1.2.2
with: with:
name: "version.json" name: "version.json"
json: '{"type": "autobuild", "tag": "N/A", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}' json: '{"type": "autobuild", "tag": "N/A", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
@@ -55,7 +55,7 @@ jobs:
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
- name: upload build artifact - name: upload build artifact
uses: actions/upload-artifact@v1 uses: actions/upload-artifact@v3
with: with:
name: youtubedl-material name: youtubedl-material
path: build path: build

View File

@@ -18,10 +18,21 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y-%m-%d')" run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json - name: create-json
id: create-json id: create-json
uses: jsdaniell/create-json@1.1.2 uses: jsdaniell/create-json@v1.2.2
with: with:
name: "version.json" name: "version.json"
json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}' json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/' dir: 'backend/'
- name: Build docker images - name: setup platform emulator
run: docker build . -t tzahi12345/youtubedl-material:nightly-pr uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1
- name: build & push images
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
#platforms: linux/amd64
push: false
tags: tzahi12345/youtubedl-material:nightly-pr

View File

@@ -27,7 +27,7 @@ jobs:
- name: create-json - name: create-json
id: create-json id: create-json
uses: jsdaniell/create-json@1.1.2 uses: jsdaniell/create-json@v1.2.2
with: with:
name: "version.json" name: "version.json"
json: '{"type": "docker", "tag": "latest", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}' json: '{"type": "docker", "tag": "latest", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
@@ -60,10 +60,10 @@ jobs:
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build - name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -80,7 +80,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8 platforms: linux/amd64,linux/arm64/v8
push: true push: true
tags: ${{ steps.docker-meta.outputs.tags }} tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }} labels: ${{ steps.docker-meta.outputs.labels }}

View File

@@ -34,7 +34,7 @@ jobs:
- name: create-json - name: create-json
id: create-json id: create-json
uses: jsdaniell/create-json@1.1.2 uses: jsdaniell/create-json@v1.2.2
with: with:
name: "version.json" name: "version.json"
json: '{"type": "docker", "tag": "${{secrets.DOCKERHUB_MASTER_TAG}}", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}' json: '{"type": "docker", "tag": "${{secrets.DOCKERHUB_MASTER_TAG}}", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
@@ -44,7 +44,7 @@ jobs:
uses: docker/setup-qemu-action@v1 uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build - name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v2
- name: Generate Docker image metadata - name: Generate Docker image metadata
id: docker-meta id: docker-meta
@@ -63,7 +63,7 @@ jobs:
type=sha,prefix=sha-,format=short type=sha,prefix=sha-,format=short
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v1 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -80,7 +80,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8 platforms: linux/amd64,linux/arm64/v8
push: true push: true
tags: ${{ steps.docker-meta.outputs.tags }} tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }} labels: ${{ steps.docker-meta.outputs.labels }}

11
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"recommendations": [
"angular.ng-template",
"dbaeumer.vscode-eslint",
"waderyan.gitblame",
"42crunch.vscode-openapi",
"redhat.vscode-yaml",
"christian-kohler.npm-intellisense",
"hbenl.vscode-mocha-test-adapter"
]
}

14
.vscode/launch.json vendored
View File

@@ -4,6 +4,20 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "Dev: Debug Backend",
"request": "launch",
"runtimeArgs": [
"run-script",
"debug"
],
"runtimeExecutable": "npm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node",
"cwd": "${workspaceFolder}/backend"
},
{ {
"type": "node", "type": "node",
"request": "attach", "request": "attach",

8
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"mochaExplorer.files": "backend/test/**/*.js",
"mochaExplorer.cwd": "backend",
"mochaExplorer.globImplementation": "vscode",
"mochaExplorer.env": {
"YTDL_MODE": "debug"
}
}

45
.vscode/tasks.json vendored
View File

@@ -1,25 +1,60 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"windows": {
"options": {
"shell": {
"executable": "cmd.exe",
"args": [
"/d", "/c"
]
}
}
},
"tasks": [ "tasks": [
{ {
"type": "npm", "type": "npm",
"script": "start", "script": "start",
"problemMatcher": [], "problemMatcher": [],
"label": "Dev: start frontend", "label": "Dev: start frontend",
"detail": "ng serve" "detail": "ng serve",
"presentation": {
"echo": true,
"reveal": "always",
"focus": true,
"panel": "shared",
"showReuseMessage": true,
"clear": false
}
}, },
{ {
"label": "Dev: start backend", "label": "Dev: start backend",
"type": "shell", "type": "shell",
"command": "set YTDL_MODE=debug && node app.js", "command": "node app.js",
"options": { "options": {
"cwd": "./backend" "cwd": "./backend",
"env": {
"YTDL_MODE": "debug"
}
}, },
"presentation": { "presentation": {
"echo": true,
"reveal": "always", "reveal": "always",
"panel": "new" "focus": true,
"panel": "shared",
"showReuseMessage": true,
"clear": false
}, },
"problemMatcher": [] "problemMatcher": [],
"dependsOn": ["Dev: post-build"]
},
{
"label": "Dev: post-build",
"type": "shell",
"command": "node src/postbuild.mjs"
},
{
"label": "Dev: run all",
"dependsOn": ["Dev: start backend", "Dev: start frontend"]
} }
] ]
} }

38
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,38 @@
<h1>Development</h1>
- [First time...](#first-time)
- [Setup](#setup)
- [Startup](#startup)
- [Debugging the backend (VSC)](#debugging-the-backend-vsc)
- [Deploy changes](#deploy-changes)
- [Frontend](#frontend)
- [Backend](#backend)
# First time...
## Setup
Checkout the repository and navigate to the `youtubedl-material` directory.
```bash
vim ./src/assets/default.json # Edit settings for your local environment. This config file is just the dev config file, if YTDL_MODE is not set to "debug", then ./backend/appdata/default.json will be used
npm -g install pm2 # Install pm2
npm install # Install dependencies for the frontend
cd ./backend
npm install # Install dependencies for the backend
cd ..
npm run build # Build the frontend
```
This step have to be done only once.
## Startup
Navigate to the `youtubedl-material/backend` directory and run `npm start`.
# Debugging the backend (VSC)
Open the `youtubedl-material` directory in Visual Studio Code and run the launch configuration `Dev: Debug Backend`.
# Deploy changes
## Frontend
Navigate to the `youtubedl-material` directory and run `npm run build`. Restart the backend.
## Backend
Simply restart the backend.

View File

@@ -1,14 +1,17 @@
# Fetching our ffmpeg # Fetching our utils
FROM ubuntu:22.04 AS ffmpeg FROM ubuntu:22.04 AS utils
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
# Use script due local build compability # Use script due local build compability
COPY ffmpeg-fetch.sh . COPY docker-utils/*.sh .
RUN chmod +x *.sh
RUN sh ./ffmpeg-fetch.sh RUN sh ./ffmpeg-fetch.sh
RUN sh ./fetch-twitchdownloader.sh
# Create our Ubuntu 22.04 with node 16.14.2 (that specific version is required as per: https://stackoverflow.com/a/72855258/8088021) # Create our Ubuntu 22.04 with node 16.14.2 (that specific version is required as per: https://stackoverflow.com/a/72855258/8088021)
# Go to 20.04 # Go to 20.04
FROM ubuntu:20.04 AS base FROM ubuntu:22.04 AS base
ARG TARGETPLATFORM
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ENV UID=1000 ENV UID=1000
ENV GID=1000 ENV GID=1000
@@ -16,19 +19,30 @@ ENV USER=youtube
ENV NO_UPDATE_NOTIFIER=true ENV NO_UPDATE_NOTIFIER=true
ENV PM2_HOME=/app/pm2 ENV PM2_HOME=/app/pm2
ENV ALLOW_CONFIG_MUTATIONS=true ENV ALLOW_CONFIG_MUTATIONS=true
# Directy fetch specific version
## https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_amd64.deb
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \ RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
apt update && \ apt update && \
apt install -y --no-install-recommends curl ca-certificates tzdata && \ apt install -y --no-install-recommends curl ca-certificates tzdata libicu70 && \
curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && \
apt install -y --no-install-recommends nodejs && \
npm -g install npm n && \
n 16.14.2 && \
apt clean && \ apt clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
RUN case ${TARGETPLATFORM} in \
"linux/amd64") NODE_ARCH=amd64 ;; \
"linux/arm") NODE_ARCH=armhf ;; \
"linux/arm/v7") NODE_ARCH=armhf ;; \
"linux/arm64") NODE_ARCH=arm64 ;; \
esac \
&& curl -L https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_$NODE_ARCH.deb -o ./nodejs.deb && \
apt update && \
apt install -y ./nodejs.deb && \
apt clean && \
rm -rf /var/lib/apt/lists/* &&\
rm nodejs.deb;
# Build frontend # Build frontend
FROM base as frontend ARG BUILDPLATFORM
FROM --platform=${BUILDPLATFORM} node:16 as frontend
RUN npm install -g @angular/cli RUN npm install -g @angular/cli
WORKDIR /build WORKDIR /build
COPY [ "package.json", "package-lock.json", "angular.json", "tsconfig.json", "/build/" ] COPY [ "package.json", "package-lock.json", "angular.json", "tsconfig.json", "/build/" ]
@@ -36,6 +50,8 @@ COPY [ "src/", "/build/src/" ]
RUN npm install && \ RUN npm install && \
npm run build && \ npm run build && \
ls -al /build/backend/public ls -al /build/backend/public
RUN npm uninstall -g @angular/cli
RUN rm -rf node_modules
# Install backend deps # Install backend deps
@@ -46,21 +62,35 @@ RUN npm config set strict-ssl false && \
npm install --prod && \ npm install --prod && \
ls -al ls -al
#FROM base as python
# armv7 need build from source
#WORKDIR /app
#COPY docker-utils/GetTwitchDownloader.py .
#RUN apt update && \
# apt install -y --no-install-recommends python3-minimal python-is-python3 python3-pip python3-dev build-essential libffi-dev && \
# apt clean && \
# rm -rf /var/lib/apt/lists/*
#RUN pip install PyGithub requests
#RUN python GetTwitchDownloader.py
# Final image # Final image
FROM base FROM base
RUN npm install -g pm2 && \ RUN npm install -g pm2 && \
apt update && \ apt update && \
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 python3-pip atomicparsley && \ apt install -y --no-install-recommends gosu python3-minimal python-is-python3 python3-pip atomicparsley build-essential && \
pip install pycryptodomex && \
apt remove -y --purge build-essential && \
apt autoremove -y --purge && \
apt clean && \ apt clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
RUN pip install tcd
WORKDIR /app WORKDIR /app
# User 1000 already exist from base image # User 1000 already exist from base image
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ] COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ]
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffprobe", "/usr/local/bin/ffprobe" ] COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/ffprobe", "/usr/local/bin/ffprobe" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/TwitchDownloaderCLI", "/usr/local/bin/TwitchDownloaderCLI"]
COPY --chown=$UID:$GID --from=backend ["/app/","/app/"] COPY --chown=$UID:$GID --from=backend ["/app/","/app/"]
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ] COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
#COPY --chown=$UID:$GID --from=python ["/app/TwitchDownloaderCLI","/usr/local/bin/TwitchDownloaderCLI"]
RUN chmod +x /app/fix-scripts/*.sh RUN chmod +x /app/fix-scripts/*.sh
# Add some persistence data # Add some persistence data
#VOLUME ["/app/appdata"] #VOLUME ["/app/appdata"]

View File

@@ -111,6 +111,37 @@ paths:
$ref: '#/components/schemas/GetAllFilesResponse' $ref: '#/components/schemas/GetAllFilesResponse'
security: security:
- Auth query parameter: [] - Auth query parameter: []
/api/rss:
get:
tags:
- files
summary: Generates an RSS feed
description: Generates an RSS feed for downloaded files
operationId: get-rss
parameters:
- in: query
name: params
schema:
allOf:
- $ref: '#/components/schemas/GetAllFilesRequest'
- type: object
properties:
uuid:
type: string
description: user uid
default: null
style: form
explode: true
responses:
'200':
description: OK
content:
text/plain:
schema:
type: string
description: RSS feed
security:
- Auth query parameter: []
/api/getFile: /api/getFile:
post: post:
tags: tags:
@@ -547,6 +578,69 @@ paths:
description: If the archive dir is not found, 404 is sent as a response description: If the archive dir is not found, 404 is sent as a response
security: security:
- Auth query parameter: [] - Auth query parameter: []
/api/deleteArchiveItems:
post:
tags:
- archive
summary: Delete item from archive
description: 'Deletes an item from the archive'
operationId: post-api-deleteArchiveItems
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DeleteArchiveItemsRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/importArchive:
post:
tags:
- archive
summary: Imports archive
description: 'Imports an existing archive.txt file'
operationId: post-api-importArchive
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ImportArchiveRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/uploadCookies:
post:
tags:
- downloader
summary: Upload cookies
description: 'Uploads cookies file to be used during downloading'
operationId: post-api-uploadCookies
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/UploadCookiesRequest'
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/updaterStatus: /api/updaterStatus:
get: get:
tags: tags:
@@ -811,7 +905,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/SuccessObject' $ref: '#/components/schemas/RestartDownloadResponse'
requestBody: requestBody:
content: content:
application/json: application/json:
@@ -1589,6 +1683,10 @@ components:
type: string type: string
description: Height of the video, if known description: Height of the video, if known
example: '1080' example: '1080'
maxHeight:
type: string
description: Max height that should be used, useful for playlists. selectedHeight will override this.
example: '1080'
maxBitrate: maxBitrate:
type: string type: string
description: Specify ffmpeg/avconv audio quality description: Specify ffmpeg/avconv audio quality
@@ -1597,6 +1695,9 @@ components:
$ref: '#/components/schemas/FileType' $ref: '#/components/schemas/FileType'
cropFileSettings: cropFileSettings:
$ref: '#/components/schemas/CropFileSettings' $ref: '#/components/schemas/CropFileSettings'
ignoreArchive:
type: boolean
description: If using youtube-dl archive, download will ignore it
DownloadResponse: DownloadResponse:
type: object type: object
properties: properties:
@@ -1621,6 +1722,13 @@ components:
properties: properties:
download: download:
$ref: '#/components/schemas/Download' $ref: '#/components/schemas/Download'
RestartDownloadResponse:
allOf:
- $ref: '#/components/schemas/SuccessObject'
- type: object
properties:
new_download_uid:
type: string
GetAllDownloadsRequest: GetAllDownloadsRequest:
type: object type: object
properties: properties:
@@ -1650,14 +1758,14 @@ components:
type: object type: object
properties: properties:
task_key: task_key:
type: string $ref: '#/components/schemas/TaskType'
required: required:
- task_key - task_key
UpdateTaskScheduleRequest: UpdateTaskScheduleRequest:
type: object type: object
properties: properties:
task_key: task_key:
type: string $ref: '#/components/schemas/TaskType'
new_schedule: new_schedule:
$ref: '#/components/schemas/Schedule' $ref: '#/components/schemas/Schedule'
required: required:
@@ -1667,12 +1775,22 @@ components:
type: object type: object
properties: properties:
task_key: task_key:
type: string $ref: '#/components/schemas/TaskType'
new_data: new_data:
type: object type: object
required: required:
- task_key - task_key
- new_data - new_data
UpdateTaskOptionsRequest:
type: object
properties:
task_key:
$ref: '#/components/schemas/TaskType'
new_options:
type: object
required:
- task_key
- new_options
GetTaskResponse: GetTaskResponse:
type: object type: object
properties: properties:
@@ -1741,29 +1859,39 @@ components:
description: Two elements allowed, start index and end index description: Two elements allowed, start index and end index
minItems: 2 minItems: 2
maxItems: 2 maxItems: 2
default: null
text_search: text_search:
type: string type: string
description: Filter files by title description: Filter files by title
default: null
file_type_filter: file_type_filter:
$ref: '#/components/schemas/FileTypeFilter' $ref: '#/components/schemas/FileTypeFilter'
favorite_filter:
type: boolean
description: If set to true, only gets favorites
default: false
sub_id: sub_id:
type: string type: string
description: Include if you want to filter by subscription description: Include if you want to filter by subscription
default: null
Sort: Sort:
type: object type: object
properties: properties:
by: by:
type: string type: string
description: Property to sort by description: Property to sort by
default: registered
order: order:
type: number type: number
description: 1 for ascending, -1 for descending description: 1 for ascending, -1 for descending
default: -1
FileTypeFilter: FileTypeFilter:
type: string type: string
enum: enum:
- audio_only - audio_only
- video_only - video_only
- both - both
default: both
GetAllFilesResponse: GetAllFilesResponse:
required: required:
- files - files
@@ -1881,16 +2009,11 @@ components:
description: Number of files removed description: Number of files removed
DeleteSubscriptionFileRequest: DeleteSubscriptionFileRequest:
required: required:
- file - file_uid
- sub
type: object type: object
properties: properties:
file:
type: string
file_uid: file_uid:
type: string type: string
sub:
$ref: '#/components/schemas/SubscriptionRequestData'
deleteForever: deleteForever:
type: boolean type: boolean
description: 'If true, does not remove id from archive. Only valid if youtube-dl archive is enabled in settings.' description: 'If true, does not remove id from archive. Only valid if youtube-dl archive is enabled in settings.'
@@ -2030,17 +2153,83 @@ components:
type: type:
$ref: '#/components/schemas/FileType' $ref: '#/components/schemas/FileType'
DownloadArchiveRequest: DownloadArchiveRequest:
required:
- sub
type: object type: object
properties: properties:
sub: type:
required: $ref: '#/components/schemas/FileType'
- archive_dir sub_id:
type: object type: string
properties: Archive:
archive_dir: required:
type: string - extractor
- id
- type
- title
- timestamp
- uid
type: object
properties:
extractor:
type: string
id:
type: string
type:
$ref: '#/components/schemas/FileType'
title:
type: string
user_uid:
type: string
sub_id:
type: string
timestamp:
type: number
uid:
type: string
DeleteArchiveItemsRequest:
type: object
required:
- archives
properties:
archives:
type: array
items:
$ref: '#/components/schemas/Archive'
ImportArchiveRequest:
type: object
required:
- archive
- type
properties:
archive:
type: string
type:
$ref: '#/components/schemas/FileType'
sub_id:
type: string
GetArchivesRequest:
type: object
properties:
type:
$ref: '#/components/schemas/FileType'
sub_id:
type: string
GetArchivesResponse:
type: object
required:
- archives
properties:
archives:
type: array
items:
$ref: '#/components/schemas/Archive'
UploadCookiesRequest:
type: object
required:
- cookies
properties:
cookies:
type: string
format: binary
UpdaterStatus: UpdaterStatus:
required: required:
- details - details
@@ -2061,8 +2250,6 @@ components:
tag: tag:
type: string type: string
DBInfoResponse: DBInfoResponse:
required:
- db_info
type: object type: object
properties: properties:
using_local_db: using_local_db:
@@ -2084,6 +2271,8 @@ components:
$ref: '#/components/schemas/TableInfo' $ref: '#/components/schemas/TableInfo'
download_queue: download_queue:
$ref: '#/components/schemas/TableInfo' $ref: '#/components/schemas/TableInfo'
archives:
$ref: '#/components/schemas/TableInfo'
TransferDBResponse: TransferDBResponse:
required: required:
- success - success
@@ -2383,6 +2572,7 @@ components:
- upload_date - upload_date
- uploader - uploader
- url - url
- favorite
type: object type: object
properties: properties:
id: id:
@@ -2412,6 +2602,8 @@ components:
type: string type: string
uid: uid:
type: string type: string
user_uid:
type: string
sharingEnabled: sharingEnabled:
type: boolean type: boolean
category: category:
@@ -2430,6 +2622,8 @@ components:
abr: abr:
type: number type: number
description: In Kbps description: In Kbps
favorite:
type: boolean
Playlist: Playlist:
required: required:
- uids - uids
@@ -2461,6 +2655,8 @@ components:
type: string type: string
auto: auto:
type: boolean type: boolean
sharingEnabled:
type: boolean
Download: Download:
required: required:
- url - url
@@ -2505,6 +2701,10 @@ components:
type: string type: string
description: Error text, set if download fails. description: Error text, set if download fails.
nullable: true nullable: true
error_type:
type: string
description: Error type, may or may not be set in case of an error
nullable: true
user_uid: user_uid:
type: string type: string
sub_id: sub_id:
@@ -2526,7 +2726,7 @@ components:
type: object type: object
properties: properties:
key: key:
type: string $ref: '#/components/schemas/TaskType'
title: title:
type: string type: string
last_ran: last_ran:
@@ -2542,7 +2742,20 @@ components:
error: error:
type: string type: string
schedule: schedule:
$ref: '#/components/schemas/Schedule'
options:
type: object type: object
TaskType:
type: string
enum:
- backup_local_db
- missing_files_check
- missing_db_records
- duplicate_files_check
- youtubedl_update_check
- delete_old_files
- import_legacy_archives
- rebuild_database
Schedule: Schedule:
required: required:
- type - type
@@ -2567,6 +2780,8 @@ components:
type: number type: number
timestamp: timestamp:
type: number type: number
tz:
type: string
DBBackup: DBBackup:
required: required:
- name - name
@@ -2673,6 +2888,7 @@ components:
- sharing - sharing
- advanced_download - advanced_download
- downloads_manager - downloads_manager
- tasks_manager
YesNo: YesNo:
type: string type: string
enum: enum:
@@ -2755,6 +2971,44 @@ components:
type: string type: string
date: date:
type: string type: string
Notification:
required:
- uid
- type
- text
- read
- timestamp
type: object
properties:
type:
$ref: '#/components/schemas/NotificationType'
uid:
type: string
user_uid:
type: string
action:
type: array
items:
$ref: '#/components/schemas/NotificationAction'
read:
type: boolean
data:
type: object
timestamp:
type: number
NotificationAction:
type: string
enum:
- play
- retry_download
- view_download_error
- view_tasks
NotificationType:
type: string
enum:
- download_complete
- download_error
- task_finished
BaseChangePermissionsRequest: BaseChangePermissionsRequest:
required: required:
- permission - permission
@@ -2886,6 +3140,29 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/UserPermission' $ref: '#/components/schemas/UserPermission'
DeleteNotificationRequest:
required:
- uid
type: object
properties:
uid:
type: string
SetNotificationsToReadRequest:
required:
- uids
type: object
properties:
uids:
type: array
items:
type: string
GetNotificationsResponse:
type: object
properties:
notifications:
type: array
items:
$ref: '#/components/schemas/Notification'
securitySchemes: securitySchemes:
Auth query parameter: Auth query parameter:
name: apiKey name: apiKey

View File

@@ -6,7 +6,7 @@
[![GitHub issues badge](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues) [![GitHub issues badge](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues)
[![License badge](https://img.shields.io/github/license/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/LICENSE.md) [![License badge](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 13](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend. YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 15](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
Now with [Docker](#Docker) support! Now with [Docker](#Docker) support!
@@ -14,7 +14,7 @@ Now with [Docker](#Docker) support!
## Getting Started ## Getting Started
Check out the prerequisites, and go to the installation section. Easy as pie! Check out the prerequisites, and go to the [installation](#Installing) section. Easy as pie!
Here's an image of what it'll look like once you're done: Here's an image of what it'll look like once you're done:
@@ -28,13 +28,28 @@ Dark mode:
NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker) section for a setup guide. NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker) section for a setup guide.
Debian/Ubuntu: Required dependencies:
* Node.js 16
* Python
Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
* [Twitch Downloader CLI](https://github.com/lay295/TwitchDownloader) (for downloading Twitch VOD chats)
<details>
<summary>Debian/Ubuntu</summary>
```bash ```bash
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install nodejs youtube-dl ffmpeg unzip python npm sudo apt-get install nodejs youtube-dl ffmpeg unzip python npm
``` ```
CentOS 7: </details>
<details>
<summary>CentOS 7</summary>
```bash ```bash
sudo yum install epel-release sudo yum install epel-release
@@ -42,16 +57,16 @@ sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfu
sudo yum install centos-release-scl-rh sudo yum install centos-release-scl-rh
sudo yum install rh-nodejs12 sudo yum install rh-nodejs12
scl enable rh-nodejs12 bash scl enable rh-nodejs12 bash
curl -fsSL https://rpm.nodesource.com/setup_16.x | sudo bash -
sudo yum install nodejs youtube-dl ffmpeg ffmpeg-devel sudo yum install nodejs youtube-dl ffmpeg ffmpeg-devel
``` ```
Optional dependencies: </details>
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
* [tcd](https://github.com/PetterKraabol/Twitch-Chat-Downloader) (for downloading Twitch VOD chats)
### Installing ### Installing
If you are using Docker, skip to the [Docker](#Docker) section. Otherwise, continue:
1. First, download the [latest release](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest)! 1. First, download the [latest release](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest)!
2. Drag the `youtubedl-material` directory to an easily accessible directory. Navigate to the `appdata` folder and edit the `default.json` file. 2. Drag the `youtubedl-material` directory to an easily accessible directory. Navigate to the `appdata` folder and edit the `default.json` file.
@@ -70,7 +85,9 @@ If you'd like to install YoutubeDL-Material, go to the Installation section. If
To deploy, simply clone the repository, and go into the `youtubedl-material` directory. Type `npm install` and all the dependencies will install. Then type `cd backend` and again type `npm install` to install the dependencies for the backend. To deploy, simply clone the repository, and go into the `youtubedl-material` directory. Type `npm install` and all the dependencies will install. Then type `cd backend` and again type `npm install` to install the dependencies for the backend.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/appdata`, go back into the `youtubedl-material` directory, and type `npm build`. This will build the app, and put the output files in the `youtubedl-material/backend/public` folder. 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 `npm run build`. This will build the app, and put the output files in the `youtubedl-material/backend/public` folder.
Lastly, type `npm -g install pm2` to install pm2 globally.
The frontend is now complete. The backend is much easier. Just go into the `backend` folder, and type `npm start`. The frontend is now complete. The backend is much easier. Just go into the `backend` folder, and type `npm start`.

View File

@@ -182,7 +182,6 @@
} }
} }
}, },
"defaultProject": "youtube-dl-material",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
"prefix": "app", "prefix": "app",
@@ -191,5 +190,8 @@
"@schematics/angular:directive": { "@schematics/angular:directive": {
"prefix": "app" "prefix": "app"
} }
},
"cli": {
"analytics": false
} }
} }

View File

@@ -2,7 +2,6 @@ const { uuid } = require('uuidv4');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { promisify } = require('util'); const { promisify } = require('util');
const auth_api = require('./authentication/auth'); const auth_api = require('./authentication/auth');
const winston = require('winston');
const path = require('path'); const path = require('path');
const compression = require('compression'); const compression = require('compression');
const multer = require('multer'); const multer = require('multer');
@@ -18,6 +17,8 @@ const URL = require('url').URL;
const CONSTS = require('./consts') const CONSTS = require('./consts')
const read_last_lines = require('read-last-lines'); const read_last_lines = require('read-last-lines');
const ps = require('ps-node'); const ps = require('ps-node');
const Feed = require('feed').Feed;
const session = require('express-session');
// needed if bin/details somehow gets deleted // needed if bin/details somehow gets deleted
if (!fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2000.06.06","path":"node_modules\\youtube-dl\\bin\\youtube-dl.exe","exec":"youtube-dl.exe","downloader":"youtube-dl"}) if (!fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2000.06.06","path":"node_modules\\youtube-dl\\bin\\youtube-dl.exe","exec":"youtube-dl.exe","downloader":"youtube-dl"})
@@ -32,6 +33,8 @@ const subscriptions_api = require('./subscriptions');
const categories_api = require('./categories'); const categories_api = require('./categories');
const twitch_api = require('./twitch'); const twitch_api = require('./twitch');
const youtubedl_api = require('./youtube-dl'); const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
const files_api = require('./files');
var app = express(); var app = express();
@@ -69,7 +72,8 @@ db.defaults(
downloads: {}, downloads: {},
subscriptions: [], subscriptions: [],
files_to_db_migration_complete: false, files_to_db_migration_complete: false,
tasks_manager_role_migration_complete: false tasks_manager_role_migration_complete: false,
archives_migration_complete: false
}).write(); }).write();
users_db.defaults( users_db.defaults(
@@ -159,6 +163,8 @@ app.use(bodyParser.json());
// use passport // use passport
app.use(auth_api.passport.initialize()); app.use(auth_api.passport.initialize());
app.use(session({ secret: uuid(), resave: true, saveUninitialized: true }))
app.use(auth_api.passport.session());
// actual functions // actual functions
@@ -169,10 +175,10 @@ async function checkMigrations() {
if (!simplified_db_migration_complete) { if (!simplified_db_migration_complete) {
logger.info('Beginning migration: 4.1->4.2+') logger.info('Beginning migration: 4.1->4.2+')
let success = await simplifyDBFileStructure(); let success = await simplifyDBFileStructure();
success = success && await db_api.addMetadataPropertyToDB('view_count'); success = success && await files_api.addMetadataPropertyToDB('view_count');
success = success && await db_api.addMetadataPropertyToDB('description'); success = success && await files_api.addMetadataPropertyToDB('description');
success = success && await db_api.addMetadataPropertyToDB('height'); success = success && await files_api.addMetadataPropertyToDB('height');
success = success && await db_api.addMetadataPropertyToDB('abr'); success = success && await files_api.addMetadataPropertyToDB('abr');
// sets migration to complete // sets migration to complete
db.set('simplified_db_migration_complete', true).write(); db.set('simplified_db_migration_complete', true).write();
if (success) { logger.info('4.1->4.2+ migration complete!'); } if (success) { logger.info('4.1->4.2+ migration complete!'); }
@@ -199,6 +205,15 @@ async function checkMigrations() {
db.set('tasks_manager_role_migration_complete', true).write(); db.set('tasks_manager_role_migration_complete', true).write();
} }
const archives_migration_complete = db.get('archives_migration_complete').value();
if (!archives_migration_complete) {
logger.info('Checking if archives have been migrated...');
const imported_archives = await archive_api.importArchives();
if (imported_archives) logger.info('Archives migration complete!');
else logger.error('Failed to migrate archives!');
db.set('archives_migration_complete', true).write();
}
return true; return true;
} }
@@ -510,9 +525,6 @@ async function loadConfig() {
db_api.database_initialized = true; db_api.database_initialized = true;
db_api.database_initialized_bs.next(true); db_api.database_initialized_bs.next(true);
// creates archive path if missing
await fs.ensureDir(utils.getArchiveFolder());
// check migrations // check migrations
await checkMigrations(); await checkMigrations();
@@ -523,6 +535,7 @@ async function loadConfig() {
if (allowSubscriptions) { if (allowSubscriptions) {
// set downloading to false // set downloading to false
let subscriptions = await subscriptions_api.getAllSubscriptions(); let subscriptions = await subscriptions_api.getAllSubscriptions();
subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub));
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false}); subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false});
// runs initially, then runs every ${subscriptionCheckInterval} seconds // runs initially, then runs every ${subscriptionCheckInterval} seconds
const watchSubscriptionsInterval = function() { const watchSubscriptionsInterval = function() {
@@ -558,14 +571,7 @@ function loadConfigValues() {
url_domain = new URL(url); url_domain = new URL(url);
let logger_level = config_api.getConfigItem('ytdl_logger_level'); let logger_level = config_api.getConfigItem('ytdl_logger_level');
const possible_levels = ['error', 'warn', 'info', 'verbose', 'debug']; utils.updateLoggerLevel(logger_level);
if (!possible_levels.includes(logger_level)) {
logger.error(`${logger_level} is not a valid logger level! Choose one of the following: ${possible_levels.join(', ')}.`)
logger_level = 'info';
}
logger.level = logger_level;
winston.loggers.get('console').level = logger_level;
logger.transports[2].level = logger_level;
} }
function calculateSubcriptionRetrievalDelay(subscriptions_amount) { function calculateSubcriptionRetrievalDelay(subscriptions_amount) {
@@ -700,7 +706,7 @@ app.use(function(req, res, next) {
next(); next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) { } else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next(); next();
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/')) { } else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss')) {
next(); next();
} else { } else {
logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`); logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`);
@@ -721,7 +727,7 @@ const optionalJwt = async function (req, res, next) {
const uuid = using_body ? req.body.uuid : req.query.uuid; const uuid = using_body ? req.body.uuid : req.query.uuid;
const uid = using_body ? req.body.uid : req.query.uid; const uid = using_body ? req.body.uid : req.query.uid;
const playlist_id = using_body ? req.body.playlist_id : req.query.playlist_id; const playlist_id = using_body ? req.body.playlist_id : req.query.playlist_id;
const file = !playlist_id ? auth_api.getUserVideo(uuid, uid, true) : await db_api.getPlaylist(playlist_id, uuid, true); const file = !playlist_id ? auth_api.getUserVideo(uuid, uid, true) : await files_api.getPlaylist(playlist_id, uuid, true);
if (file) { if (file) {
req.can_watch = true; req.can_watch = true;
return next(); return next();
@@ -773,7 +779,7 @@ app.post('/api/restartServer', optionalJwt, (req, res) => {
app.get('/api/getDBInfo', optionalJwt, async (req, res) => { app.get('/api/getDBInfo', optionalJwt, async (req, res) => {
const db_info = await db_api.getDBStats(); const db_info = await db_api.getDBStats();
res.send({db_info: db_info}); res.send(db_info);
}); });
app.post('/api/transferDB', optionalJwt, async (req, res) => { app.post('/api/transferDB', optionalJwt, async (req, res) => {
@@ -813,11 +819,13 @@ app.post('/api/downloadFile', optionalJwt, async function(req, res) {
additionalArgs: req.body.additionalArgs, additionalArgs: req.body.additionalArgs,
customOutput: req.body.customOutput, customOutput: req.body.customOutput,
selectedHeight: req.body.selectedHeight, selectedHeight: req.body.selectedHeight,
maxHeight: req.body.maxHeight,
customQualityConfiguration: req.body.customQualityConfiguration, customQualityConfiguration: req.body.customQualityConfiguration,
youtubeUsername: req.body.youtubeUsername, youtubeUsername: req.body.youtubeUsername,
youtubePassword: req.body.youtubePassword, youtubePassword: req.body.youtubePassword,
ui_uid: req.body.ui_uid, ui_uid: req.body.ui_uid,
cropFileSettings: req.body.cropFileSettings cropFileSettings: req.body.cropFileSettings,
ignoreArchive: req.body.ignoreArchive
}; };
const download = await downloader_api.createDownload(url, type, options, user_uid); const download = await downloader_api.createDownload(url, type, options, user_uid);
@@ -843,6 +851,7 @@ app.post('/api/generateArgs', optionalJwt, async function(req, res) {
additionalArgs: req.body.additionalArgs, additionalArgs: req.body.additionalArgs,
customOutput: req.body.customOutput, customOutput: req.body.customOutput,
selectedHeight: req.body.selectedHeight, selectedHeight: req.body.selectedHeight,
maxHeight: req.body.maxHeight,
customQualityConfiguration: req.body.customQualityConfiguration, customQualityConfiguration: req.body.customQualityConfiguration,
youtubeUsername: req.body.youtubeUsername, youtubeUsername: req.body.youtubeUsername,
youtubePassword: req.body.youtubePassword, youtubePassword: req.body.youtubePassword,
@@ -921,35 +930,15 @@ app.post('/api/getFile', optionalJwt, async function (req, res) {
app.post('/api/getAllFiles', optionalJwt, async function (req, res) { app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
// these are returned // these are returned
let files = null;
const sort = req.body.sort; const sort = req.body.sort;
const range = req.body.range; const range = req.body.range;
const text_search = req.body.text_search; const text_search = req.body.text_search;
const file_type_filter = req.body.file_type_filter; const file_type_filter = req.body.file_type_filter;
const favorite_filter = req.body.favorite_filter;
const sub_id = req.body.sub_id; const sub_id = req.body.sub_id;
const uuid = req.isAuthenticated() ? req.user.uid : null; const uuid = req.isAuthenticated() ? req.user.uid : null;
const filter_obj = {user_uid: uuid}; const {files, file_count} = await files_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
const regex = true;
if (text_search) {
if (regex) {
filter_obj['title'] = {$regex: `.*${text_search}.*`, $options: 'i'};
} else {
filter_obj['$text'] = { $search: utils.createEdgeNGrams(text_search) };
}
}
if (sub_id) {
filter_obj['sub_id'] = sub_id;
}
if (file_type_filter === 'audio_only') filter_obj['isAudio'] = true;
else if (file_type_filter === 'video_only') filter_obj['isAudio'] = false;
files = await db_api.getRecords('files', filter_obj, false, sort, range, text_search);
const file_count = await db_api.getRecords('files', filter_obj, true);
files = JSON.parse(JSON.stringify(files));
res.send({ res.send({
files: files, files: files,
@@ -1092,9 +1081,6 @@ app.post('/api/disableSharing', optionalJwt, async function(req, res) {
await db_api.updateRecord('files', {uid: uid}, {sharingEnabled: false}) await db_api.updateRecord('files', {uid: uid}, {sharingEnabled: false})
} else if (is_playlist) { } else if (is_playlist) {
await db_api.updateRecord(`playlists`, {id: uid}, {sharingEnabled: false}); await db_api.updateRecord(`playlists`, {id: uid}, {sharingEnabled: false});
} else if (type === 'subscription') {
// TODO: Implement. Main blocker right now is subscription videos are not stored in the DB, they are searched for every
// time they are requested from the subscription directory.
} else { } else {
// error // error
success = false; success = false;
@@ -1109,7 +1095,7 @@ app.post('/api/disableSharing', optionalJwt, async function(req, res) {
}); });
}); });
app.post('/api/incrementViewCount', optionalJwt, async (req, res) => { app.post('/api/incrementViewCount', async (req, res) => {
let file_uid = req.body.file_uid; let file_uid = req.body.file_uid;
let sub_id = req.body.sub_id; let sub_id = req.body.sub_id;
let uuid = req.body.uuid; let uuid = req.body.uuid;
@@ -1118,7 +1104,7 @@ app.post('/api/incrementViewCount', optionalJwt, async (req, res) => {
uuid = req.user.uid; uuid = req.user.uid;
} }
const file_obj = await db_api.getVideo(file_uid, uuid, sub_id); const file_obj = await files_api.getVideo(file_uid, uuid, sub_id);
const current_view_count = file_obj && file_obj['local_view_count'] ? file_obj['local_view_count'] : 0; const current_view_count = file_obj && file_obj['local_view_count'] ? file_obj['local_view_count'] : 0;
const new_view_count = current_view_count + 1; const new_view_count = current_view_count + 1;
@@ -1244,12 +1230,9 @@ app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => { app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
let deleteForever = req.body.deleteForever; let deleteForever = req.body.deleteForever;
let file = req.body.file;
let file_uid = req.body.file_uid; let file_uid = req.body.file_uid;
let sub = req.body.sub;
let user_uid = req.isAuthenticated() ? req.user.uid : null;
let success = await subscriptions_api.deleteSubscriptionFile(sub, file, deleteForever, file_uid, user_uid); let success = await files_api.deleteFile(file_uid, deleteForever);
if (success) { if (success) {
res.send({ res.send({
@@ -1337,7 +1320,7 @@ app.post('/api/createPlaylist', optionalJwt, async (req, res) => {
let playlistName = req.body.playlistName; let playlistName = req.body.playlistName;
let uids = req.body.uids; let uids = req.body.uids;
const new_playlist = await db_api.createPlaylist(playlistName, uids, req.isAuthenticated() ? req.user.uid : null); const new_playlist = await files_api.createPlaylist(playlistName, uids, req.isAuthenticated() ? req.user.uid : null);
res.send({ res.send({
new_playlist: new_playlist, new_playlist: new_playlist,
@@ -1350,13 +1333,13 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => {
let uuid = req.body.uuid ? req.body.uuid : (req.user && req.user.uid ? req.user.uid : null); let uuid = req.body.uuid ? req.body.uuid : (req.user && req.user.uid ? req.user.uid : null);
let include_file_metadata = req.body.include_file_metadata; let include_file_metadata = req.body.include_file_metadata;
const playlist = await db_api.getPlaylist(playlist_id, uuid); const playlist = await files_api.getPlaylist(playlist_id, uuid);
const file_objs = []; const file_objs = [];
if (playlist && include_file_metadata) { if (playlist && include_file_metadata) {
for (let i = 0; i < playlist['uids'].length; i++) { for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i]; const uid = playlist['uids'][i];
const file_obj = await db_api.getVideo(uid, uuid); const file_obj = await files_api.getVideo(uid, uuid);
if (file_obj) file_objs.push(file_obj); if (file_obj) file_objs.push(file_obj);
// TODO: remove file from playlist if could not be found // TODO: remove file from playlist if could not be found
} }
@@ -1394,7 +1377,7 @@ app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => {
playlist.uids.push(file_uid); playlist.uids.push(file_uid);
let success = await db_api.updatePlaylist(playlist); let success = await files_api.updatePlaylist(playlist);
res.send({ res.send({
success: success success: success
}); });
@@ -1402,7 +1385,7 @@ app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => {
app.post('/api/updatePlaylist', optionalJwt, async (req, res) => { app.post('/api/updatePlaylist', optionalJwt, async (req, res) => {
let playlist = req.body.playlist; let playlist = req.body.playlist;
let success = await db_api.updatePlaylist(playlist, req.user && req.user.uid); let success = await files_api.updatePlaylist(playlist, req.user && req.user.uid);
res.send({ res.send({
success: success success: success
}); });
@@ -1430,10 +1413,9 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
app.post('/api/deleteFile', optionalJwt, async (req, res) => { app.post('/api/deleteFile', optionalJwt, async (req, res) => {
const uid = req.body.uid; const uid = req.body.uid;
const blacklistMode = req.body.blacklistMode; const blacklistMode = req.body.blacklistMode;
const uuid = req.isAuthenticated() ? req.user.uid : null;
let wasDeleted = false; let wasDeleted = false;
wasDeleted = await db_api.deleteFile(uid, uuid, blacklistMode); wasDeleted = await files_api.deleteFile(uid, blacklistMode);
res.send(wasDeleted); res.send(wasDeleted);
}); });
@@ -1465,7 +1447,7 @@ app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
let wasDeleted = false; let wasDeleted = false;
wasDeleted = await db_api.deleteFile(files[i].uid, uuid, blacklistMode); wasDeleted = await files_api.deleteFile(files[i].uid, blacklistMode);
if (wasDeleted) { if (wasDeleted) {
delete_count++; delete_count++;
} }
@@ -1491,10 +1473,10 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
if (playlist_id) { if (playlist_id) {
zip_file_generated = true; zip_file_generated = true;
const playlist_files_to_download = []; const playlist_files_to_download = [];
const playlist = await db_api.getPlaylist(playlist_id, uuid); const playlist = await files_api.getPlaylist(playlist_id, uuid);
for (let i = 0; i < playlist['uids'].length; i++) { for (let i = 0; i < playlist['uids'].length; i++) {
const playlist_file_uid = playlist['uids'][i]; const playlist_file_uid = playlist['uids'][i];
const file_obj = await db_api.getVideo(playlist_file_uid, uuid); const file_obj = await files_api.getVideo(playlist_file_uid, uuid);
playlist_files_to_download.push(file_obj); playlist_files_to_download.push(file_obj);
} }
@@ -1508,7 +1490,7 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
// generate zip // generate zip
file_path_to_download = await utils.createContainerZipFile(sub['name'], sub_files_to_download); file_path_to_download = await utils.createContainerZipFile(sub['name'], sub_files_to_download);
} else { } else {
const file_obj = await db_api.getVideo(uid, uuid, sub_id) const file_obj = await files_api.getVideo(uid, uuid, sub_id)
file_path_to_download = file_obj.path; file_path_to_download = file_obj.path;
} }
if (!path.isAbsolute(file_path_to_download)) file_path_to_download = path.join(__dirname, file_path_to_download); if (!path.isAbsolute(file_path_to_download)) file_path_to_download = path.join(__dirname, file_path_to_download);
@@ -1526,20 +1508,69 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
}); });
}); });
app.post('/api/getArchives', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const sub_id = req.body.sub_id;
const filter_obj = {user_uid: uuid, sub_id: sub_id};
const type = req.body.type;
// we do this for file types because if type is null, that means get files of all types
if (type) filter_obj['type'] = type;
const archives = await db_api.getRecords('archives', filter_obj);
res.send({
archives: archives
});
});
app.post('/api/downloadArchive', optionalJwt, async (req, res) => { app.post('/api/downloadArchive', optionalJwt, async (req, res) => {
let sub = req.body.sub; const uuid = req.isAuthenticated() ? req.user.uid : null;
let archive_dir = sub.archive; const sub_id = req.body.sub_id;
const type = req.body.type;
let full_archive_path = path.join(archive_dir, 'archive.txt'); const archive_text = await archive_api.generateArchive(type, uuid, sub_id);
if (await fs.pathExists(full_archive_path)) { if (archive_text !== null && archive_text !== undefined) {
res.sendFile(full_archive_path); res.setHeader('Content-type', "application/octet-stream");
res.setHeader('Content-disposition', 'attachment; filename=archive.txt');
res.send(archive_text);
} else { } else {
res.sendStatus(404); res.sendStatus(400);
} }
}); });
app.post('/api/importArchive', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const archive = req.body.archive;
const sub_id = req.body.sub_id;
const type = req.body.type;
const archive_text = Buffer.from(archive.split(',')[1], 'base64').toString();
const imported_count = await archive_api.importArchiveFile(archive_text, type, uuid, sub_id);
res.send({
success: !!imported_count,
imported_count: imported_count
});
});
app.post('/api/deleteArchiveItems', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const archives = req.body.archives;
let success = true;
for (const archive of archives) {
success &= await archive_api.removeFromArchive(archive['extractor'], archive['id'], archive['type'], uuid, archive['sub_id']);
}
res.send({
success: success
});
});
var upload_multer = multer({ dest: __dirname + '/appdata/' }); var upload_multer = multer({ dest: __dirname + '/appdata/' });
app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => { app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => {
const new_path = path.join(__dirname, 'appdata', 'cookies.txt'); const new_path = path.join(__dirname, 'appdata', 'cookies.txt');
@@ -1606,12 +1637,12 @@ app.get('/api/stream', optionalJwt, async (req, res) => {
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (!multiUserMode || req.isAuthenticated() || req.can_watch) { if (!multiUserMode || req.isAuthenticated() || req.can_watch) {
file_obj = await db_api.getVideo(uid, uuid, sub_id); file_obj = await files_api.getVideo(uid, uuid, sub_id);
if (file_obj) file_path = file_obj['path']; if (file_obj) file_path = file_obj['path'];
else file_path = null; else file_path = null;
} }
if (!fs.existsSync(file_path)) { if (!fs.existsSync(file_path)) {
logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj.id}`); logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj && file_obj.id}`);
} }
const stat = fs.statSync(file_path); const stat = fs.statSync(file_path);
const fileSize = stat.size; const fileSize = stat.size;
@@ -1731,8 +1762,8 @@ app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => {
app.post('/api/restartDownload', optionalJwt, async (req, res) => { app.post('/api/restartDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid; const download_uid = req.body.download_uid;
const success = await downloader_api.restartDownload(download_uid); const new_download = await downloader_api.restartDownload(download_uid);
res.send({success: success}); res.send({success: !!new_download, new_download_uid: new_download ? new_download['uid'] : null});
}); });
app.post('/api/cancelDownload', optionalJwt, async (req, res) => { app.post('/api/cancelDownload', optionalJwt, async (req, res) => {
@@ -1809,6 +1840,15 @@ app.post('/api/updateTaskData', optionalJwt, async (req, res) => {
res.send({success: success}); res.send({success: success});
}); });
app.post('/api/updateTaskOptions', optionalJwt, async (req, res) => {
const task_key = req.body.task_key;
const new_options = req.body.new_options;
const success = await db_api.updateRecord('tasks', {key: task_key}, {options: new_options});
res.send({success: success});
});
app.post('/api/getDBBackups', optionalJwt, async (req, res) => { app.post('/api/getDBBackups', optionalJwt, async (req, res) => {
const backup_dir = path.join('appdata', 'db_backup'); const backup_dir = path.join('appdata', 'db_backup');
fs.ensureDirSync(backup_dir); fs.ensureDirSync(backup_dir);
@@ -1889,9 +1929,34 @@ app.post('/api/clearAllLogs', optionalJwt, async function(req, res) {
// user authentication // user authentication
app.post('/api/auth/register' app.post('/api/auth/register', optionalJwt, async (req, res) => {
, optionalJwt const userid = req.body.userid;
, auth_api.registerUser); const username = req.body.username;
const plaintextPassword = req.body.password;
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
res.sendStatus(409);
return;
}
if (plaintextPassword === "") {
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
res.sendStatus(409);
return;
}
const new_user = await auth_api.registerUser(userid, username, plaintextPassword);
if (!new_user) {
res.sendStatus(409);
return;
}
res.send({
user: new_user
});
});
app.post('/api/auth/login' app.post('/api/auth/login'
, auth_api.passport.authenticate(['local', 'ldapauth'], {}) , auth_api.passport.authenticate(['local', 'ldapauth'], {})
, auth_api.generateJWT , auth_api.generateJWT
@@ -1943,18 +2008,7 @@ app.post('/api/updateUser', optionalJwt, async (req, res) => {
app.post('/api/deleteUser', optionalJwt, async (req, res) => { app.post('/api/deleteUser', optionalJwt, async (req, res) => {
let uid = req.body.uid; let uid = req.body.uid;
try { try {
let success = false; const success = await auth_api.deleteUser(uid);
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
}
res.send({success: success}); res.send({success: success});
} catch (err) { } catch (err) {
logger.error(err); logger.error(err);
@@ -1992,6 +2046,93 @@ app.post('/api/changeRolePermissions', optionalJwt, async (req, res) => {
res.send({success: success}); res.send({success: success});
}); });
// notifications
app.post('/api/getNotifications', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const notifications = await db_api.getRecords('notifications', {user_uid: uuid});
res.send({notifications: notifications});
});
// set notifications to read
app.post('/api/setNotificationsToRead', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const success = await db_api.updateRecords('notifications', {user_uid: uuid}, {read: true});
res.send({success: success});
});
app.post('/api/deleteNotification', optionalJwt, async (req, res) => {
const uid = req.isAuthenticated() ? req.user.uid : null;
const success = await db_api.removeRecord('notifications', {uid: uid});
res.send({success: success});
});
app.post('/api/deleteAllNotifications', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const success = await db_api.removeAllRecords('notifications', {user_uid: uuid});
res.send({success: success});
});
// rss feed
app.get('/api/rss', async function (req, res) {
if (!config_api.getConfigItem('ytdl_enable_rss_feed')) {
logger.error('RSS feed is disabled! It must be enabled in the settings before it can be generated.');
res.sendStatus(403);
return;
}
// these are returned
const sort = req.query.sort ? JSON.parse(decodeURIComponent(req.query.sort)) : {by: 'registered', order: -1};
const range = req.query.range ? req.query.range.map(range_num => parseInt(range_num)) : null;
const text_search = req.query.text_search ? decodeURIComponent(req.query.text_search) : null;
const file_type_filter = req.query.file_type_filter;
const favorite_filter = req.query.favorite_filter === 'true';
const sub_id = req.query.sub_id ? decodeURIComponent(req.query.sub_id) : null;
const uuid = req.query.uuid ? decodeURIComponent(req.query.uuid) : null;
const {files} = await files_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
const feed = new Feed({
title: 'Downloads',
description: 'YoutubeDL-Material downloads',
id: utils.getBaseURL(),
link: utils.getBaseURL(),
image: 'https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/src/assets/images/logo_128px.png',
favicon: 'https://raw.githubusercontent.com/Tzahi12345/YoutubeDL-Material/master/src/favicon.ico',
generator: 'YoutubeDL-Material'
});
files.forEach(file => {
feed.addItem({
title: file.title,
link: `${utils.getBaseURL()}/#/player;uid=${file.uid}`,
description: file.description,
author: [
{
name: file.uploader,
link: file.url
}
],
contributor: [],
date: file.timestamp,
// https://stackoverflow.com/a/45415677/8088021
image: file.thumbnailURL.replace('&', '&amp;')
});
});
res.send(feed.rss2());
});
// web server
app.use(function(req, res, next) { app.use(function(req, res, next) {
//if the request is not html then move along //if the request is not html then move along
var accept = req.accepts('html', 'json', 'xml'); var accept = req.accepts('html', 'json', 'xml');

View File

@@ -23,7 +23,12 @@
"download_only_mode": false, "download_only_mode": false,
"allow_autoplay": true, "allow_autoplay": true,
"enable_downloads_manager": true, "enable_downloads_manager": true,
"allow_playlist_categorization": true "allow_playlist_categorization": true,
"force_autoplay": false,
"enable_notifications": true,
"enable_all_notifications": true,
"allowed_notification_types": [],
"enable_rss_feed": false
}, },
"API": { "API": {
"use_API_key": false, "use_API_key": false,
@@ -35,7 +40,18 @@
"twitch_client_secret": "", "twitch_client_secret": "",
"twitch_auto_download_chat": false, "twitch_auto_download_chat": false,
"use_sponsorblock_API": false, "use_sponsorblock_API": false,
"generate_NFO_files": false "generate_NFO_files": false,
"use_ntfy_API": false,
"ntfy_topic_URL": "",
"use_gotify_API": false,
"gotify_server_URL": "",
"gotify_app_token": "",
"use_telegram_API": false,
"telegram_bot_token": "",
"telegram_chat_id": "",
"webhook_URL": "",
"discord_webhook_URL": "",
"slack_webhook_URL": ""
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",

91
backend/archive.js Normal file
View File

@@ -0,0 +1,91 @@
const path = require('path');
const fs = require('fs-extra');
const { uuid } = require('uuidv4');
const db_api = require('./db');
exports.generateArchive = async (type = null, user_uid = null, sub_id = null) => {
const filter = {user_uid: user_uid, sub_id: sub_id};
if (type) filter['type'] = type;
const archive_items = await db_api.getRecords('archives', filter);
const archive_item_lines = archive_items.map(archive_item => `${archive_item['extractor']} ${archive_item['id']}`);
return archive_item_lines.join('\n');
}
exports.addToArchive = async (extractor, id, type, title, user_uid = null, sub_id = null) => {
const archive_item = createArchiveItem(extractor, id, type, title, user_uid, sub_id);
const success = await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id, type: type});
return success;
}
exports.removeFromArchive = async (extractor, id, type, user_uid = null, sub_id = null) => {
const success = await db_api.removeAllRecords('archives', {extractor: extractor, id: id, type: type, user_uid: user_uid, sub_id: sub_id});
return success;
}
exports.existsInArchive = async (extractor, id, type, user_uid, sub_id) => {
const archive_item = await db_api.getRecord('archives', {extractor: extractor, id: id, type: type, user_uid: user_uid, sub_id: sub_id});
return !!archive_item;
}
exports.importArchiveFile = async (archive_text, type, user_uid = null, sub_id = null) => {
let archive_import_count = 0;
const lines = archive_text.split('\n');
for (let line of lines) {
const archive_line_parts = line.trim().split(' ');
// should just be the extractor and the video ID
if (archive_line_parts.length !== 2) {
continue;
}
const extractor = archive_line_parts[0];
const id = archive_line_parts[1];
if (!extractor || !id) continue;
// we can't do a bulk write because we need to avoid duplicate archive items existing in db
const archive_item = createArchiveItem(extractor, id, type, null, user_uid, sub_id);
await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id, type: type, sub_id: sub_id, user_uid: user_uid});
archive_import_count++;
}
return archive_import_count;
}
exports.importArchives = async () => {
const imported_archives = [];
const dirs_to_check = await db_api.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
if (!dir_to_check['archive_path']) continue;
const files_to_import = [
path.join(dir_to_check['archive_path'], `archive_${dir_to_check['type']}.txt`),
path.join(dir_to_check['archive_path'], `blacklist_${dir_to_check['type']}.txt`)
]
for (const file_to_import of files_to_import) {
const file_exists = await fs.pathExists(file_to_import);
if (!file_exists) continue;
const archive_text = await fs.readFile(file_to_import, 'utf8');
await exports.importArchiveFile(archive_text, dir_to_check.type, dir_to_check.user_uid, dir_to_check.sub_id);
imported_archives.push(file_to_import);
}
}
return imported_archives;
}
const createArchiveItem = (extractor, id, type, title = null, user_uid = null, sub_id = null) => {
return {
extractor: extractor,
id: id,
type: type,
title: title,
user_uid: user_uid ? user_uid : null,
sub_id: sub_id ? sub_id : null,
timestamp: Date.now() / 1000,
uid: uuid()
}
}

View File

@@ -1,11 +1,13 @@
const config_api = require('../config'); const config_api = require('../config');
const consts = require('../consts'); const CONSTS = require('../consts');
const logger = require('../logger'); const logger = require('../logger');
const db_api = require('../db'); const db_api = require('../db');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const fs = require('fs-extra');
const path = require('path');
var LocalStrategy = require('passport-local').Strategy; var LocalStrategy = require('passport-local').Strategy;
var LdapStrategy = require('passport-ldapauth'); var LdapStrategy = require('passport-ldapauth');
@@ -16,7 +18,7 @@ var JwtStrategy = require('passport-jwt').Strategy,
let SERVER_SECRET = null; let SERVER_SECRET = null;
let JWT_EXPIRATION = null; let JWT_EXPIRATION = null;
let opts = null; let opts = null;
let saltRounds = null; let saltRounds = 10;
exports.initialize = function () { exports.initialize = function () {
/************************* /*************************
@@ -31,9 +33,14 @@ exports.initialize = function () {
}); });
} }
saltRounds = 10; // Sometimes this value is not properly typed: https://github.com/Tzahi12345/YoutubeDL-Material/issues/813
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration'); JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
if (!(+JWT_EXPIRATION)) {
logger.warn(`JWT expiration value improperly set to ${JWT_EXPIRATION}, auto setting to 1 day.`);
JWT_EXPIRATION = 86400;
} else {
JWT_EXPIRATION = +JWT_EXPIRATION;
}
SERVER_SECRET = null; SERVER_SECRET = null;
if (db_api.users_db.get('jwt_secret').value()) { if (db_api.users_db.get('jwt_secret').value()) {
@@ -61,14 +68,7 @@ exports.initialize = function () {
const setupRoles = async () => { const setupRoles = async () => {
const required_roles = { const required_roles = {
admin: { admin: {
permissions: [ permissions: CONSTS.AVAILABLE_PERMISSIONS
'filemanager',
'settings',
'subscriptions',
'sharing',
'advanced_download',
'downloads_manager'
]
}, },
user: { user: {
permissions: [ permissions: [
@@ -106,55 +106,41 @@ exports.passport.deserializeUser(function(user, done) {
/*************************************** /***************************************
* Register user with hashed password * Register user with hashed password
**************************************/ **************************************/
exports.registerUser = async 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'))) { exports.registerUser = async (userid, username, plaintextPassword) => {
res.sendStatus(409); const hash = await bcrypt.hash(plaintextPassword, saltRounds);
logger.error(`Registration failed for user ${userid}. Registration is disabled.`); const new_user = generateUserObject(userid, username, hash);
return; // check if user exists
if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
return null;
} else if (await db_api.getRecord('users', {name: username})) {
// user name is taken!
logger.error('Registration failed: User name is already taken!');
return null;
} else {
// add to db
await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`);
return new_user;
} }
}
if (plaintextPassword === "") { exports.deleteUser = async (uid) => {
res.sendStatus(400); let success = false;
logger.error(`Registration failed for user ${userid}. A password must be provided.`); let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
return; const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
} }
return success;
bcrypt.hash(plaintextPassword, saltRounds)
.then(async function(hash) {
let new_user = generateUserObject(userid, username, hash);
// check if user exists
if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
res.status(409).send('UID is already taken!');
} else if (await db_api.getRecord('users', {name: username})) {
// 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
await db_api.insertRecordIntoTable('users', new_user);
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);
}
});
} }
/*************************************** /***************************************
@@ -235,7 +221,7 @@ exports.returnAuthResponse = async function(req, res) {
user: req.user, user: req.user,
token: req.token, token: req.token,
permissions: await exports.userPermissions(req.user.uid), permissions: await exports.userPermissions(req.user.uid),
available_permissions: consts['AVAILABLE_PERMISSIONS'] available_permissions: CONSTS.AVAILABLE_PERMISSIONS
}); });
} }
@@ -319,7 +305,7 @@ exports.getUserVideos = async function(user_uid, type) {
} }
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) { exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
let file = await db_api.getRecord('files', {file_uid: file_uid}); let file = await db_api.getRecord('files', {uid: file_uid});
// prevent unauthorized users from accessing the file info // prevent unauthorized users from accessing the file info
if (file && !file['sharingEnabled'] && requireSharing) file = null; if (file && !file['sharingEnabled'] && requireSharing) file = null;
@@ -406,8 +392,8 @@ exports.userPermissions = async function(user_uid) {
const role_obj = await db_api.getRecord('roles', {key: role}); const role_obj = await db_api.getRecord('roles', {key: role});
const role_permissions = role_obj['permissions']; const role_permissions = role_obj['permissions'];
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) { for (let i = 0; i < CONSTS.AVAILABLE_PERMISSIONS.length; i++) {
let permission = consts['AVAILABLE_PERMISSIONS'][i]; let permission = CONSTS.AVAILABLE_PERMISSIONS[i];
const user_has_explicit_permission = user_obj['permissions'].includes(permission); const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission); const permission_in_overrides = user_obj['permission_overrides'].includes(permission);

View File

@@ -185,7 +185,6 @@ const DEFAULT_CONFIG = {
"default_file_output": "", "default_file_output": "",
"use_youtubedl_archive": false, "use_youtubedl_archive": false,
"custom_args": "", "custom_args": "",
"safe_download_override": false,
"include_thumbnail": true, "include_thumbnail": true,
"include_metadata": true, "include_metadata": true,
"max_concurrent_downloads": 5, "max_concurrent_downloads": 5,
@@ -196,21 +195,33 @@ const DEFAULT_CONFIG = {
"file_manager_enabled": true, "file_manager_enabled": true,
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_autoplay": true, "force_autoplay": false,
"enable_downloads_manager": true, "enable_downloads_manager": true,
"allow_playlist_categorization": true "allow_playlist_categorization": true,
"enable_notifications": true,
"enable_all_notifications": true,
"allowed_notification_types": [],
"enable_rss_feed": false,
}, },
"API": { "API": {
"use_API_key": false, "use_API_key": false,
"API_key": "", "API_key": "",
"use_youtube_API": false, "use_youtube_API": false,
"youtube_API_key": "", "youtube_API_key": "",
"use_twitch_API": false,
"twitch_client_ID": "",
"twitch_client_secret": "",
"twitch_auto_download_chat": false, "twitch_auto_download_chat": false,
"use_sponsorblock_API": false, "use_sponsorblock_API": false,
"generate_NFO_files": false "generate_NFO_files": false,
"use_ntfy_API": false,
"ntfy_topic_URL": "",
"use_gotify_API": false,
"gotify_server_URL": "",
"gotify_app_token": "",
"use_telegram_API": false,
"telegram_bot_token": "",
"telegram_chat_id": "",
"webhook_URL": "",
"discord_webhook_URL": "",
"slack_webhook_URL": "",
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",

View File

@@ -30,10 +30,6 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_custom_args', 'key': 'ytdl_custom_args',
'path': 'YoutubeDLMaterial.Downloader.custom_args' 'path': 'YoutubeDLMaterial.Downloader.custom_args'
}, },
'ytdl_safe_download_override': {
'key': 'ytdl_safe_download_override',
'path': 'YoutubeDLMaterial.Downloader.safe_download_override'
},
'ytdl_include_thumbnail': { 'ytdl_include_thumbnail': {
'key': 'ytdl_include_thumbnail', 'key': 'ytdl_include_thumbnail',
'path': 'YoutubeDLMaterial.Downloader.include_thumbnail' 'path': 'YoutubeDLMaterial.Downloader.include_thumbnail'
@@ -68,9 +64,9 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_download_only_mode', 'key': 'ytdl_download_only_mode',
'path': 'YoutubeDLMaterial.Extra.download_only_mode' 'path': 'YoutubeDLMaterial.Extra.download_only_mode'
}, },
'ytdl_allow_autoplay': { 'ytdl_force_autoplay': {
'key': 'ytdl_allow_autoplay', 'key': 'ytdl_force_autoplay',
'path': 'YoutubeDLMaterial.Extra.allow_autoplay' 'path': 'YoutubeDLMaterial.Extra.force_autoplay'
}, },
'ytdl_enable_downloads_manager': { 'ytdl_enable_downloads_manager': {
'key': 'ytdl_enable_downloads_manager', 'key': 'ytdl_enable_downloads_manager',
@@ -80,6 +76,22 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_allow_playlist_categorization', 'key': 'ytdl_allow_playlist_categorization',
'path': 'YoutubeDLMaterial.Extra.allow_playlist_categorization' 'path': 'YoutubeDLMaterial.Extra.allow_playlist_categorization'
}, },
'ytdl_enable_notifications': {
'key': 'ytdl_enable_notifications',
'path': 'YoutubeDLMaterial.Extra.enable_notifications'
},
'ytdl_enable_all_notifications': {
'key': 'ytdl_enable_all_notifications',
'path': 'YoutubeDLMaterial.Extra.enable_all_notifications'
},
'ytdl_allowed_notification_types': {
'key': 'ytdl_allowed_notification_types',
'path': 'YoutubeDLMaterial.Extra.allowed_notification_types'
},
'ytdl_enable_rss_feed': {
'key': 'ytdl_enable_rss_feed',
'path': 'YoutubeDLMaterial.Extra.enable_rss_feed'
},
// API // API
'ytdl_use_api_key': { 'ytdl_use_api_key': {
@@ -98,18 +110,6 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_youtube_api_key', 'key': 'ytdl_youtube_api_key',
'path': 'YoutubeDLMaterial.API.youtube_API_key' 'path': 'YoutubeDLMaterial.API.youtube_API_key'
}, },
'ytdl_use_twitch_api': {
'key': 'ytdl_use_twitch_api',
'path': 'YoutubeDLMaterial.API.use_twitch_API'
},
'ytdl_twitch_client_id': {
'key': 'ytdl_twitch_client_id',
'path': 'YoutubeDLMaterial.API.twitch_client_ID'
},
'ytdl_twitch_client_secret': {
'key': 'ytdl_twitch_client_secret',
'path': 'YoutubeDLMaterial.API.twitch_client_secret'
},
'ytdl_twitch_auto_download_chat': { 'ytdl_twitch_auto_download_chat': {
'key': 'ytdl_twitch_auto_download_chat', 'key': 'ytdl_twitch_auto_download_chat',
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat' 'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
@@ -122,6 +122,50 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_generate_nfo_files', 'key': 'ytdl_generate_nfo_files',
'path': 'YoutubeDLMaterial.API.generate_NFO_files' 'path': 'YoutubeDLMaterial.API.generate_NFO_files'
}, },
'ytdl_use_ntfy_API': {
'key': 'ytdl_use_ntfy_API',
'path': 'YoutubeDLMaterial.API.use_ntfy_API'
},
'ytdl_ntfy_topic_url': {
'key': 'ytdl_ntfy_topic_url',
'path': 'YoutubeDLMaterial.API.ntfy_topic_URL'
},
'ytdl_use_gotify_API': {
'key': 'ytdl_use_gotify_API',
'path': 'YoutubeDLMaterial.API.use_gotify_API'
},
'ytdl_gotify_server_url': {
'key': 'ytdl_gotify_server_url',
'path': 'YoutubeDLMaterial.API.gotify_server_URL'
},
'ytdl_gotify_app_token': {
'key': 'ytdl_gotify_app_token',
'path': 'YoutubeDLMaterial.API.gotify_app_token'
},
'ytdl_use_telegram_API': {
'key': 'ytdl_use_telegram_API',
'path': 'YoutubeDLMaterial.API.use_telegram_API'
},
'ytdl_telegram_bot_token': {
'key': 'ytdl_telegram_bot_token',
'path': 'YoutubeDLMaterial.API.telegram_bot_token'
},
'ytdl_telegram_chat_id': {
'key': 'ytdl_telegram_chat_id',
'path': 'YoutubeDLMaterial.API.telegram_chat_id'
},
'ytdl_webhook_url': {
'key': 'ytdl_webhook_url',
'path': 'YoutubeDLMaterial.API.webhook_URL'
},
'ytdl_discord_webhook_url': {
'key': 'ytdl_discord_webhook_url',
'path': 'YoutubeDLMaterial.API.discord_webhook_URL'
},
'ytdl_slack_webhook_url': {
'key': 'ytdl_slack_webhook_url',
'path': 'YoutubeDLMaterial.API.slack_webhook_URL'
},
// Themes // Themes
@@ -303,7 +347,11 @@ const YTDL_ARGS_WITH_VALUES = [
'--convert-subs' '--convert-subs'
]; ];
exports.SUBSCRIPTION_BACKUP_PATH = 'subscription_backup.json'
// we're using a Set here for performance // we're using a Set here for performance
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES); exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
exports.CURRENT_VERSION = 'v4.3'; exports.ICON_URL = 'https://i.imgur.com/IKOlr0N.png';
exports.CURRENT_VERSION = 'v4.3.1';

View File

@@ -1,10 +1,11 @@
var fs = require('fs-extra') const fs = require('fs-extra')
var path = require('path') const path = require('path')
const { MongoClient } = require("mongodb"); const { MongoClient } = require("mongodb");
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
const _ = require('lodash');
const config_api = require('./config'); const config_api = require('./config');
var utils = require('./utils') const utils = require('./utils')
const logger = require('./logger'); const logger = require('./logger');
const low = require('lowdb') const low = require('lowdb')
@@ -58,6 +59,13 @@ const tables = {
name: 'tasks', name: 'tasks',
primary_key: 'key' primary_key: 'key'
}, },
notifications: {
name: 'notifications',
primary_key: 'uid'
},
archives: {
name: 'archives'
},
test: { test: {
name: 'test' name: 'test'
} }
@@ -148,6 +156,7 @@ exports._connectToDB = async (custom_connection_string = null) => {
await database.collection(table).createIndex(text_search); await database.collection(table).createIndex(text_search);
} }
}); });
using_local_db = false; // needs to happen for tests (in normal operation using_local_db is guaranteed false)
return true; return true;
} catch(err) { } catch(err) {
logger.error(err); logger.error(err);
@@ -158,82 +167,9 @@ exports._connectToDB = async (custom_connection_string = null) => {
} }
} }
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => { exports.setVideoProperty = async (file_uid, assignment_obj) => {
if (!file_object) file_object = generateFileObject(file_path, type); // TODO: check if video exists, throw error if not
if (!file_object) { await exports.updateRecord('files', {uid: file_uid}, assignment_obj);
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false;
}
utils.fixVideoMetadataPerms(file_path, type);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
// modify duration
if (cropFileSettings) {
file_object['duration'] = (cropFileSettings.cropFileEnd || file_object.duration) - cropFileSettings.cropFileStart;
}
if (user_uid) file_object['user_uid'] = user_uid;
if (sub_id) file_object['sub_id'] = sub_id;
const file_obj = await registerFileDBManual(file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_path, type)
}
return file_obj;
}
async function registerFileDBManual(file_object) {
// add additional info
file_object['uid'] = uuid();
file_object['registered'] = Date.now();
path_object = path.parse(file_object['path']);
file_object['path'] = path.format(path_object);
await exports.insertRecordIntoTable('files', file_object, {path: file_object['path']})
return file_object;
}
function generateFileObject(file_path, type) {
var jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) {
return null;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
return null;
}
const ext = (type === 'audio') ? '.mp3' : '.mp4'
const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type);
// console.
var stats = fs.statSync(true_file_path);
const file_id = utils.removeFileExtension(path.basename(file_path));
var title = jsonobj.title;
var url = jsonobj.webpage_url;
var uploader = jsonobj.uploader;
var upload_date = utils.formatDateString(jsonobj.upload_date);
var size = stats.size;
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = type === 'audio';
var description = jsonobj.description;
var file_obj = new utils.File(file_id, title, thumbnail, isaudio, duration, url, uploader, size, true_file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
function getAppendedBasePathSub(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
} }
exports.getFileDirectoriesAndDBs = async () => { exports.getFileDirectoriesAndDBs = async () => {
@@ -252,13 +188,16 @@ exports.getFileDirectoriesAndDBs = async () => {
dirs_to_check.push({ dirs_to_check.push({
basePath: path.join(usersFileFolder, user.uid, 'audio'), basePath: path.join(usersFileFolder, user.uid, 'audio'),
user_uid: user.uid, user_uid: user.uid,
type: 'audio' type: 'audio',
archive_path: utils.getArchiveFolder('audio', user.uid)
}); });
// add user's video dir to check list // add user's video dir to check list
dirs_to_check.push({ dirs_to_check.push({
basePath: path.join(usersFileFolder, user.uid, 'video'), basePath: path.join(usersFileFolder, user.uid, 'video'),
type: 'video' user_uid: user.uid,
type: 'video',
archive_path: utils.getArchiveFolder('video', user.uid)
}); });
} }
} else { } else {
@@ -268,13 +207,15 @@ exports.getFileDirectoriesAndDBs = async () => {
// add audio dir to check list // add audio dir to check list
dirs_to_check.push({ dirs_to_check.push({
basePath: audioFolderPath, basePath: audioFolderPath,
type: 'audio' type: 'audio',
archive_path: utils.getArchiveFolder('audio')
}); });
// add video dir to check list // add video dir to check list
dirs_to_check.push({ dirs_to_check.push({
basePath: videoFolderPath, basePath: videoFolderPath,
type: 'video' type: 'video',
archive_path: utils.getArchiveFolder('video')
}); });
} }
@@ -295,254 +236,14 @@ exports.getFileDirectoriesAndDBs = async () => {
: path.join(subscriptions_base_path, subscription_to_check.isPlaylist ? 'playlists/' : 'channels/', subscription_to_check.name), : path.join(subscriptions_base_path, subscription_to_check.isPlaylist ? 'playlists/' : 'channels/', subscription_to_check.name),
user_uid: subscription_to_check.user_uid, user_uid: subscription_to_check.user_uid,
type: subscription_to_check.type, type: subscription_to_check.type,
sub_id: subscription_to_check['id'] sub_id: subscription_to_check['id'],
archive_path: utils.getArchiveFolder(subscription_to_check.type, subscription_to_check.user_uid, subscription_to_check)
}); });
} }
return dirs_to_check; return dirs_to_check;
} }
exports.importUnregisteredFiles = async () => {
const imported_files = [];
const dirs_to_check = await exports.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type);
for (let j = 0; j < files.length; j++) {
const file = files[j];
// check if file exists in db, if not add it
const files_with_same_url = await exports.getRecords('files', {url: file.url, sub_id: dir_to_check.sub_id});
const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path)));
if (!file_is_registered) {
// add additional info
const file_obj = await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
if (file_obj) {
imported_files.push(file_obj['uid']);
logger.verbose(`Added discovered file to the database: ${file.id}`);
} else {
logger.error(`Failed to import ${file['path']} automatically.`);
}
}
}
}
return imported_files;
}
exports.addMetadataPropertyToDB = async (property_key) => {
try {
const dirs_to_check = await exports.getFileDirectoriesAndDBs();
const update_obj = {};
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type, true);
for (let j = 0; j < files.length; j++) {
const file = files[j];
if (file[property_key]) {
update_obj[file.uid] = {[property_key]: file[property_key]};
}
}
}
return await exports.bulkUpdateRecords('files', 'uid', update_obj);
} catch(err) {
logger.error(err);
return false;
}
}
exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL'];
let new_playlist = {
name: playlist_name,
uids: uids,
id: uuid(),
thumbnailURL: thumbnailToUse,
registered: Date.now(),
randomize_order: false
};
new_playlist.user_uid = user_uid ? user_uid : undefined;
await exports.insertRecordIntoTable('playlists', new_playlist);
const duration = await exports.calculatePlaylistDuration(new_playlist);
await exports.updateRecord('playlists', {id: new_playlist.id}, {duration: duration});
return new_playlist;
}
exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = false) => {
let playlist = await exports.getRecord('playlists', {id: playlist_id});
if (!playlist) {
playlist = await exports.getRecord('categories', {uid: playlist_id});
if (playlist) {
const uids = (await exports.getRecords('files', {'category.uid': playlist_id})).map(file => file.uid);
playlist['uids'] = uids;
playlist['auto'] = true;
}
}
// converts playlists to new UID-based schema
if (playlist && playlist['fileNames'] && !playlist['uids']) {
playlist['uids'] = [];
logger.verbose(`Converting playlist ${playlist['name']} to new UID-based schema.`);
for (let i = 0; i < playlist['fileNames'].length; i++) {
const fileName = playlist['fileNames'][i];
const uid = await exports.getVideoUIDByID(fileName, user_uid);
if (uid) playlist['uids'].push(uid);
else logger.warn(`Failed to convert file with name ${fileName} to its UID while converting playlist ${playlist['name']} to the new UID-based schema. The original file is likely missing/deleted and it will be skipped.`);
}
exports.updatePlaylist(playlist, user_uid);
}
// prevent unauthorized users from accessing the file info
if (require_sharing && !playlist['sharingEnabled']) return null;
return playlist;
}
exports.updatePlaylist = async (playlist) => {
let playlistID = playlist.id;
const duration = await exports.calculatePlaylistDuration(playlist);
playlist.duration = duration;
return await exports.updateRecord('playlists', {id: playlistID}, playlist);
}
exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = null) => {
let success = await exports.updateRecord('playlists', {id: playlist_id}, assignment_obj);
if (!success) {
success = await exports.updateRecord('categories', {uid: playlist_id}, assignment_obj);
}
if (!success) {
logger.error(`Could not find playlist or category with ID ${playlist_id}`);
}
return success;
}
exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) => {
if (!playlist_file_objs) {
playlist_file_objs = [];
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await exports.getVideo(uid);
if (file_obj) playlist_file_objs.push(file_obj);
}
}
return playlist_file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
}
exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
const file_obj = await exports.getVideo(uid, uuid);
const type = file_obj.isAudio ? 'audio' : 'video';
const folderPath = path.dirname(file_obj.path);
const ext = type === 'audio' ? 'mp3' : 'mp4';
const name = file_obj.id;
const filePathNoExtension = utils.removeFileExtension(file_obj.path);
var jsonPath = `${file_obj.path}.info.json`;
var altJSONPath = `${filePathNoExtension}.info.json`;
var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(__dirname, jsonPath);
altJSONPath = path.join(__dirname, altJSONPath);
let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath);
if (!jsonExists) {
if (await fs.pathExists(altJSONPath)) {
jsonExists = true;
jsonPath = altJSONPath;
}
}
if (!thumbnailExists) {
if (await fs.pathExists(altThumbnailPath)) {
thumbnailExists = true;
thumbnailPath = altThumbnailPath;
}
}
let fileExists = await fs.pathExists(file_obj.path);
if (config_api.descriptors[uid]) {
try {
for (let i = 0; i < config_api.descriptors[uid].length; i++) {
config_api.descriptors[uid][i].destroy();
}
} catch(e) {
}
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_path = utils.getArchiveFolder(type, uuid);
// get ID from JSON
var jsonobj = await (type === 'audio' ? utils.getJSONMp3(name, folderPath) : utils.getJSONMp4(name, folderPath));
let id = null;
if (jsonobj) id = jsonobj.id;
// Remove file ID from the archive file, and write it to the blacklist (if enabled)
await utils.deleteFileFromArchive(uid, type, archive_path, id, blacklistMode);
}
if (jsonExists) await fs.unlink(jsonPath);
if (thumbnailExists) await fs.unlink(thumbnailPath);
await exports.removeRecord('files', {uid: uid});
if (fileExists) {
await fs.unlink(file_obj.path);
if (await fs.pathExists(jsonPath) || await fs.pathExists(file_obj.path)) {
return false;
} else {
return true;
}
} else {
// TODO: tell user that the file didn't exist
return true;
}
}
// Video ID is basically just the file name without the base path and file extension - this method helps us get away from that
exports.getVideoUIDByID = async (file_id, uuid = null) => {
const file_obj = await exports.getRecord('files', {id: file_id});
return file_obj ? file_obj['uid'] : null;
}
exports.getVideo = async (file_uid) => {
return await exports.getRecord('files', {uid: file_uid});
}
exports.getFiles = async (uuid = null) => {
return await exports.getRecords('files', {user_uid: uuid});
}
exports.setVideoProperty = async (file_uid, assignment_obj) => {
// TODO: check if video exists, throw error if not
await exports.updateRecord('files', {uid: file_uid}, assignment_obj);
}
// Basic DB functions // Basic DB functions
// Create // Create
@@ -550,7 +251,7 @@ exports.setVideoProperty = async (file_uid, assignment_obj) => {
exports.insertRecordIntoTable = async (table, doc, replaceFilter = null) => { exports.insertRecordIntoTable = async (table, doc, replaceFilter = null) => {
// local db override // local db override
if (using_local_db) { if (using_local_db) {
if (replaceFilter) local_db.get(table).remove(replaceFilter).write(); if (replaceFilter) local_db.get(table).remove((doc) => _.isMatch(doc, replaceFilter)).write();
local_db.get(table).push(doc).write(); local_db.get(table).push(doc).write();
return true; return true;
} }
@@ -653,9 +354,15 @@ exports.getRecords = async (table, filter_obj = null, return_count = false, sort
// Update // Update
exports.updateRecord = async (table, filter_obj, update_obj) => { exports.updateRecord = async (table, filter_obj, update_obj, nested_mode = false) => {
// local db override // local db override
if (using_local_db) { if (using_local_db) {
if (nested_mode) {
// if object is nested we need to handle it differently
update_obj = utils.convertFlatObjectToNestedObject(update_obj);
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').merge(update_obj).write();
return true;
}
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').assign(update_obj).write(); exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').assign(update_obj).write();
return true; return true;
} }
@@ -669,7 +376,14 @@ exports.updateRecord = async (table, filter_obj, update_obj) => {
exports.updateRecords = async (table, filter_obj, update_obj) => { exports.updateRecords = async (table, filter_obj, update_obj) => {
// local db override // local db override
if (using_local_db) { if (using_local_db) {
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').assign(update_obj).write(); exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').each((record) => {
const props_to_update = Object.keys(update_obj);
for (let i = 0; i < props_to_update.length; i++) {
const prop_to_update = props_to_update[i];
const prop_value = update_obj[prop_to_update];
record[prop_to_update] = prop_value;
}
}).write();
return true; return true;
} }
@@ -677,7 +391,19 @@ exports.updateRecords = async (table, filter_obj, update_obj) => {
return !!(output['result']['ok']); return !!(output['result']['ok']);
} }
exports.bulkUpdateRecords = async (table, key_label, update_obj) => { exports.removePropertyFromRecord = async (table, filter_obj, remove_obj) => {
// local db override
if (using_local_db) {
const props_to_remove = Object.keys(remove_obj);
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'find').unset(props_to_remove).write();
return true;
}
const output = await database.collection(table).updateOne(filter_obj, {$unset: remove_obj});
return !!(output['result']['ok']);
}
exports.bulkUpdateRecordsByKey = async (table, key_label, update_obj) => {
// local db override // local db override
if (using_local_db) { if (using_local_db) {
local_db.get(table).each((record) => { local_db.get(table).each((record) => {
@@ -1091,6 +817,14 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
filtered &= (record[filter_prop].search(new RegExp(filter_prop_value['$regex'], filter_prop_value['$options'])) !== -1); filtered &= (record[filter_prop].search(new RegExp(filter_prop_value['$regex'], filter_prop_value['$options'])) !== -1);
} else if ('$ne' in filter_prop_value) { } else if ('$ne' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] !== filter_prop_value['$ne']; filtered &= filter_prop in record && record[filter_prop] !== filter_prop_value['$ne'];
} else if ('$lt' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] < filter_prop_value['$lt'];
} else if ('$gt' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] > filter_prop_value['$gt'];
} else if ('$lte' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] <= filter_prop_value['$lt'];
} else if ('$gte' in filter_prop_value) {
filtered &= filter_prop in record && record[filter_prop] >= filter_prop_value['$gt'];
} }
} else { } else {
// handle case of nested property check // handle case of nested property check
@@ -1105,3 +839,8 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
}); });
return return_val; return return_val;
} }
// should only be used for tests
exports.setLocalDBMode = (mode) => {
using_local_db = mode;
}

View File

@@ -1,7 +1,6 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
const path = require('path'); const path = require('path');
const mergeFiles = require('merge-files');
const NodeID3 = require('node-id3') const NodeID3 = require('node-id3')
const Mutex = require('async-mutex').Mutex; const Mutex = require('async-mutex').Mutex;
@@ -14,6 +13,9 @@ const { create } = require('xmlbuilder2');
const categories_api = require('./categories'); const categories_api = require('./categories');
const utils = require('./utils'); const utils = require('./utils');
const db_api = require('./db'); const db_api = require('./db');
const files_api = require('./files');
const notifications_api = require('./notifications');
const archive_api = require('./archive');
const mutex = new Mutex(); const mutex = new Mutex();
let should_check_downloads = true; let should_check_downloads = true;
@@ -26,6 +28,25 @@ if (db_api.database_initialized) {
}); });
} }
/*
This file handles all the downloading functionality.
To download a file, we go through 4 steps. Here they are with their respective index & function:
0: Create the download
- createDownload()
1: Get info for the download (we need this step for categories and archive functionality)
- collectInfo()
2: Download the file
- downloadQueuedFile()
3: Complete
- N/A
We use checkDownloads() to move downloads through the steps and call their respective functions.
*/
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null) => { exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null) => {
return await mutex.runExclusive(async () => { return await mutex.runExclusive(async () => {
const download = { const download = {
@@ -84,10 +105,10 @@ exports.resumeDownload = async (download_uid) => {
exports.restartDownload = async (download_uid) => { exports.restartDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid}); const download = await db_api.getRecord('download_queue', {uid: download_uid});
await exports.clearDownload(download_uid); await exports.clearDownload(download_uid);
const success = !!(await exports.createDownload(download['url'], download['type'], download['options'], download['user_uid'])); const new_download = await exports.createDownload(download['url'], download['type'], download['options'], download['user_uid']);
should_check_downloads = true; should_check_downloads = true;
return success; return new_download;
} }
exports.cancelDownload = async (download_uid) => { exports.cancelDownload = async (download_uid) => {
@@ -106,9 +127,10 @@ exports.clearDownload = async (download_uid) => {
return await db_api.removeRecord('download_queue', {uid: download_uid}); return await db_api.removeRecord('download_queue', {uid: download_uid});
} }
async function handleDownloadError(download_uid, error_message) { async function handleDownloadError(download, error_message, error_type = null) {
if (!download_uid) return; if (!download || !download['uid']) return;
await db_api.updateRecord('download_queue', {uid: download_uid}, {error: error_message, finished: true, running: false}); notifications_api.sendDownloadErrorNotification(download, download['user_uid'], error_message, error_type);
await db_api.updateRecord('download_queue', {uid: download['uid']}, {error: error_message, finished: true, running: false, error_type: error_type});
} }
async function setupDownloads() { async function setupDownloads() {
@@ -154,6 +176,13 @@ async function checkDownloads() {
if (max_concurrent_downloads < 0 || running_downloads_count >= max_concurrent_downloads) break; if (max_concurrent_downloads < 0 || running_downloads_count >= max_concurrent_downloads) break;
if (waiting_download['finished_step'] && !waiting_download['finished']) { if (waiting_download['finished_step'] && !waiting_download['finished']) {
if (waiting_download['sub_id']) {
const sub_missing = !(await db_api.getRecord('subscriptions', {id: waiting_download['sub_id']}));
if (sub_missing) {
handleDownloadError(waiting_download, `Download failed as subscription with id '${waiting_download['sub_id']}' is missing!`, 'sub_id_missing');
continue;
}
}
// move to next step // move to next step
running_downloads_count++; running_downloads_count++;
if (waiting_download['step_index'] === 0) { if (waiting_download['step_index'] === 0) {
@@ -193,6 +222,21 @@ async function collectInfo(download_uid) {
return; return;
} }
// in subscriptions we don't care if archive mode is enabled, but we already removed archived videos from subs by this point
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive && !options.ignoreArchive) {
const exists_in_archive = await archive_api.existsInArchive(info['extractor'], info['id'], type, download['user_uid'], download['sub_id']);
if (exists_in_archive) {
const error = `File '${info['title']}' already exists in archive! Disable the archive or override to continue downloading.`;
logger.warn(error);
if (download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
await handleDownloadError(download, error, 'exists_in_archive');
return;
}
}
}
let category = null; let category = null;
// check if it fits into a category. If so, then get info again using new args // check if it fits into a category. If so, then get info again using new args
@@ -203,11 +247,10 @@ async function collectInfo(download_uid) {
options.customOutput = category['custom_output']; options.customOutput = category['custom_output'];
options.noRelativePath = true; options.noRelativePath = true;
args = await exports.generateArgs(url, type, options, download['user_uid']); args = await exports.generateArgs(url, type, options, download['user_uid']);
args = utils.filterArgs(args, ['--no-simulate']);
info = await exports.getVideoInfoByURL(url, args, download_uid); info = await exports.getVideoInfoByURL(url, args, download_uid);
} }
download['category'] = category; const stripped_category = category ? {name: category['name'], uid: category['uid']} : null;
// setup info required to calculate download progress // setup info required to calculate download progress
@@ -230,6 +273,7 @@ async function collectInfo(download_uid) {
files_to_check_for_progress: files_to_check_for_progress, files_to_check_for_progress: files_to_check_for_progress,
expected_file_size: expected_file_size, expected_file_size: expected_file_size,
title: playlist_title ? playlist_title : info['title'], title: playlist_title ? playlist_title : info['title'],
category: stripped_category,
prefetched_info: null prefetched_info: null
}); });
} }
@@ -272,14 +316,14 @@ async function downloadQueuedFile(download_uid) {
clearInterval(download_checker); clearInterval(download_checker);
if (err) { if (err) {
logger.error(err.stderr); logger.error(err.stderr);
await handleDownloadError(download_uid, err.stderr); await handleDownloadError(download, err.stderr, 'unknown_error');
resolve(false); resolve(false);
return; return;
} else if (output) { } else if (output) {
if (output.length === 0 || output[0].length === 0) { if (output.length === 0 || output[0].length === 0) {
// ERROR! // ERROR!
const error_message = `No output received for video download, check if it exists in your archive.`; const error_message = `No output received for video download, check if it exists in your archive.`;
await handleDownloadError(download_uid, error_message); await handleDownloadError(download, error_message, 'no_output');
logger.warn(error_message); logger.warn(error_message);
resolve(false); resolve(false);
return; return;
@@ -288,7 +332,10 @@ async function downloadQueuedFile(download_uid) {
for (let i = 0; i < output.length; i++) { for (let i = 0; i < output.length; i++) {
let output_json = null; let output_json = null;
try { try {
output_json = JSON.parse(output[i]); // we have to do this because sometimes there will be leading characters before the actual json
const start_idx = output[i].indexOf('{"');
const clean_output = output[i].slice(start_idx, output[i].length);
output_json = JSON.parse(clean_output);
} catch(e) { } catch(e) {
output_json = null; output_json = null;
} }
@@ -305,7 +352,7 @@ async function downloadQueuedFile(download_uid) {
var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length); var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length);
if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1 if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_use_twitch_api') && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) { && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
let vodId = url.split('twitch.tv/videos/')[1]; let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0]; vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']); twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']);
@@ -339,31 +386,27 @@ async function downloadQueuedFile(download_uid) {
} }
// registers file in DB // registers file in DB
const file_obj = await db_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings); const file_obj = await files_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
file_objs.push(file_obj); file_objs.push(file_obj);
} }
if (options.merged_string !== null && options.merged_string !== undefined) {
const archive_folder = getArchiveFolder(fileFolderPath, options, download['user_uid']);
const current_merged_archive = fs.readFileSync(path.join(archive_folder, `merged_${type}.txt`), 'utf8');
const diff = current_merged_archive.replace(options.merged_string, '');
const archive_path = path.join(archive_folder, `archive_${type}.txt`);
fs.appendFileSync(archive_path, diff);
}
let container = null; let container = null;
if (file_objs.length > 1) { if (file_objs.length > 1) {
// create playlist // create playlist
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', '); const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
container = await db_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), download['user_uid']); container = await files_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), download['user_uid']);
} else if (file_objs.length === 1) { } else if (file_objs.length === 1) {
container = file_objs[0]; container = file_objs[0];
} else { } else {
const error_message = 'Downloaded file failed to result in metadata object.'; const error_message = 'Downloaded file failed to result in metadata object.';
logger.error(error_message); logger.error(error_message);
await handleDownloadError(download_uid, error_message); await handleDownloadError(download, error_message, 'no_metadata');
} }
const file_uids = file_objs.map(file_obj => file_obj.uid); const file_uids = file_objs.map(file_obj => file_obj.uid);
@@ -379,6 +422,10 @@ async function downloadQueuedFile(download_uid) {
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => { exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader'); const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
if (!simulated && (default_downloader === 'youtube-dl' || default_downloader === 'youtube-dlc')) {
logger.warn('It is recommended you use yt-dlp! To prevent failed downloads, change the downloader in your settings menu to yt-dlp and restart your instance.')
}
const audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path'); const audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path'); const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path'); const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
@@ -426,7 +473,8 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
if (customQualityConfiguration) { if (customQualityConfiguration) {
qualityPath = ['-f', customQualityConfiguration, '--merge-output-format', 'mp4']; qualityPath = ['-f', customQualityConfiguration, '--merge-output-format', 'mp4'];
} else if (heightParam && heightParam !== '' && !is_audio) { } else if (heightParam && heightParam !== '' && !is_audio) {
qualityPath = ['-f', `'(mp4)[height${maxHeight ? '<' : ''}=${heightParam}]`]; const heightFilter = (maxHeight && default_downloader === 'yt-dlp') ? ['-S', `res:${heightParam}`] : ['-f', `best[height${maxHeight ? '<' : ''}=${heightParam}]+bestaudio`]
qualityPath = [...heightFilter, '--merge-output-format', 'mp4'];
} else if (is_audio) { } else if (is_audio) {
qualityPath = ['--audio-quality', maxBitrate ? maxBitrate : '0'] qualityPath = ['--audio-quality', maxBitrate ? maxBitrate : '0']
} }
@@ -463,28 +511,6 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
downloadConfig.splice(0, 0, '--external-downloader', customDownloadingAgent); downloadConfig.splice(0, 0, '--external-downloader', customDownloadingAgent);
} }
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_folder = getArchiveFolder(fileFolderPath, options, user_uid);
const archive_path = path.join(archive_folder, `archive_${type}.txt`);
await fs.ensureDir(archive_folder);
await fs.ensureFile(archive_path);
const blacklist_path = path.join(archive_folder, `blacklist_${type}.txt`);
await fs.ensureFile(blacklist_path);
const merged_path = path.join(archive_folder, `merged_${type}.txt`);
await fs.ensureFile(merged_path);
// merges blacklist and regular archive
let inputPathList = [archive_path, blacklist_path];
await mergeFiles(inputPathList, merged_path);
options.merged_string = await fs.readFile(merged_path, "utf8");
downloadConfig.push('--download-archive', merged_path);
}
if (config_api.getConfigItem('ytdl_include_thumbnail')) { if (config_api.getConfigItem('ytdl_include_thumbnail')) {
downloadConfig.push('--write-thumbnail'); downloadConfig.push('--write-thumbnail');
} }
@@ -527,7 +553,8 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => { exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
return new Promise(resolve => { return new Promise(resolve => {
// remove bad args // remove bad args
const new_args = [...args]; const temp_args = utils.filterArgs(args, ['--no-simulate']);
const new_args = [...temp_args];
const archiveArgIndex = new_args.indexOf('--download-archive'); const archiveArgIndex = new_args.indexOf('--download-archive');
if (archiveArgIndex !== -1) { if (archiveArgIndex !== -1) {
@@ -559,7 +586,8 @@ exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
const error = `Error while retrieving info on video with URL ${url} with the following message: output JSON could not be parsed. Output JSON: ${output}`; const error = `Error while retrieving info on video with URL ${url} with the following message: output JSON could not be parsed. Output JSON: ${output}`;
logger.error(error); logger.error(error);
if (download_uid) { if (download_uid) {
await handleDownloadError(download_uid, error); const download = await db_api.getRecord('download_queue', {uid: download_uid});
await handleDownloadError(download, error, 'parse_failed');
} }
resolve(null); resolve(null);
} }
@@ -568,7 +596,8 @@ exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
if (err.stderr) error_message += `\n\n${err.stderr}`; if (err.stderr) error_message += `\n\n${err.stderr}`;
logger.error(error_message); logger.error(error_message);
if (download_uid) { if (download_uid) {
await handleDownloadError(download_uid, error_message); const download = await db_api.getRecord('download_queue', {uid: download_uid});
await handleDownloadError(download, error_message, 'info_retrieve_failed');
} }
resolve(null); resolve(null);
} }
@@ -634,13 +663,3 @@ exports.generateNFOFile = (info, output_path) => {
const xml = doc.end({ prettyPrint: true }); const xml = doc.end({ prettyPrint: true });
fs.writeFileSync(output_path, xml); fs.writeFileSync(output_path, xml);
} }
function getArchiveFolder(fileFolderPath, options, user_uid) {
if (options.customArchivePath) {
return path.join(options.customArchivePath);
} else if (user_uid) {
return path.join(fileFolderPath, 'archives');
} else {
return path.join('appdata', 'archives');
}
}

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
CMD="npm start" CMD="npm start && pm2 start"
# if the first arg starts with "-" pass it to program # if the first arg starts with "-" pass it to program
if [ "${1#-}" != "$1" ]; then if [ "${1#-}" != "$1" ]; then

350
backend/files.js Normal file
View File

@@ -0,0 +1,350 @@
const fs = require('fs-extra')
const path = require('path')
const { uuid } = require('uuidv4');
const config_api = require('./config');
const db_api = require('./db');
const archive_api = require('./archive');
const utils = require('./utils')
const logger = require('./logger');
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject(file_path, type);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false;
}
utils.fixVideoMetadataPerms(file_path, type);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
// modify duration
if (cropFileSettings) {
file_object['duration'] = (cropFileSettings.cropFileEnd || file_object.duration) - cropFileSettings.cropFileStart;
}
if (user_uid) file_object['user_uid'] = user_uid;
if (sub_id) file_object['sub_id'] = sub_id;
const file_obj = await registerFileDBManual(file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_path, type)
}
return file_obj;
}
async function registerFileDBManual(file_object) {
// add additional info
file_object['uid'] = uuid();
file_object['registered'] = Date.now();
const path_object = path.parse(file_object['path']);
file_object['path'] = path.format(path_object);
await db_api.insertRecordIntoTable('files', file_object, {path: file_object['path']})
return file_object;
}
function generateFileObject(file_path, type) {
const jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) {
return null;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
return null;
}
const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type);
// console.
const stats = fs.statSync(true_file_path);
const file_id = utils.removeFileExtension(path.basename(file_path));
const title = jsonobj.title;
const url = jsonobj.webpage_url;
const uploader = jsonobj.uploader;
const upload_date = utils.formatDateString(jsonobj.upload_date);
const size = stats.size;
const thumbnail = jsonobj.thumbnail;
const duration = jsonobj.duration;
const isaudio = type === 'audio';
const description = jsonobj.description;
const file_obj = new utils.File(file_id, title, thumbnail, isaudio, duration, url, uploader, size, true_file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
exports.importUnregisteredFiles = async () => {
const imported_files = [];
const dirs_to_check = await db_api.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type);
for (let j = 0; j < files.length; j++) {
const file = files[j];
// check if file exists in db, if not add it
const files_with_same_url = await db_api.getRecords('files', {url: file.url, sub_id: dir_to_check.sub_id});
const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path)));
if (!file_is_registered) {
// add additional info
const file_obj = await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
if (file_obj) {
imported_files.push(file_obj['uid']);
logger.verbose(`Added discovered file to the database: ${file.id}`);
} else {
logger.error(`Failed to import ${file['path']} automatically.`);
}
}
}
}
return imported_files;
}
exports.addMetadataPropertyToDB = async (property_key) => {
try {
const dirs_to_check = await db_api.getFileDirectoriesAndDBs();
const update_obj = {};
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type, true);
for (let j = 0; j < files.length; j++) {
const file = files[j];
if (file[property_key]) {
update_obj[file.uid] = {[property_key]: file[property_key]};
}
}
}
return await db_api.bulkUpdateRecordsByKey('files', 'uid', update_obj);
} catch(err) {
logger.error(err);
return false;
}
}
exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL'];
let new_playlist = {
name: playlist_name,
uids: uids,
id: uuid(),
thumbnailURL: thumbnailToUse,
registered: Date.now(),
randomize_order: false
};
new_playlist.user_uid = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('playlists', new_playlist);
const duration = await exports.calculatePlaylistDuration(new_playlist);
await db_api.updateRecord('playlists', {id: new_playlist.id}, {duration: duration});
return new_playlist;
}
exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = false) => {
let playlist = await db_api.getRecord('playlists', {id: playlist_id});
if (!playlist) {
playlist = await db_api.getRecord('categories', {uid: playlist_id});
if (playlist) {
const uids = (await db_api.getRecords('files', {'category.uid': playlist_id})).map(file => file.uid);
playlist['uids'] = uids;
playlist['auto'] = true;
}
}
// converts playlists to new UID-based schema
if (playlist && playlist['fileNames'] && !playlist['uids']) {
playlist['uids'] = [];
logger.verbose(`Converting playlist ${playlist['name']} to new UID-based schema.`);
for (let i = 0; i < playlist['fileNames'].length; i++) {
const fileName = playlist['fileNames'][i];
const uid = await exports.getVideoUIDByID(fileName, user_uid);
if (uid) playlist['uids'].push(uid);
else logger.warn(`Failed to convert file with name ${fileName} to its UID while converting playlist ${playlist['name']} to the new UID-based schema. The original file is likely missing/deleted and it will be skipped.`);
}
exports.updatePlaylist(playlist, user_uid);
}
// prevent unauthorized users from accessing the file info
if (require_sharing && !playlist['sharingEnabled']) return null;
return playlist;
}
exports.updatePlaylist = async (playlist) => {
let playlistID = playlist.id;
const duration = await exports.calculatePlaylistDuration(playlist);
playlist.duration = duration;
return await db_api.updateRecord('playlists', {id: playlistID}, playlist);
}
exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = null) => {
let success = await db_api.updateRecord('playlists', {id: playlist_id}, assignment_obj);
if (!success) {
success = await db_api.updateRecord('categories', {uid: playlist_id}, assignment_obj);
}
if (!success) {
logger.error(`Could not find playlist or category with ID ${playlist_id}`);
}
return success;
}
exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) => {
if (!playlist_file_objs) {
playlist_file_objs = [];
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await exports.getVideo(uid);
if (file_obj) playlist_file_objs.push(file_obj);
}
}
return playlist_file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
}
exports.deleteFile = async (uid, blacklistMode = false) => {
const file_obj = await exports.getVideo(uid);
const type = file_obj.isAudio ? 'audio' : 'video';
const folderPath = path.dirname(file_obj.path);
const name = file_obj.id;
const filePathNoExtension = utils.removeFileExtension(file_obj.path);
var jsonPath = `${file_obj.path}.info.json`;
var altJSONPath = `${filePathNoExtension}.info.json`;
var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(__dirname, jsonPath);
altJSONPath = path.join(__dirname, altJSONPath);
let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath);
if (!jsonExists) {
if (await fs.pathExists(altJSONPath)) {
jsonExists = true;
jsonPath = altJSONPath;
}
}
if (!thumbnailExists) {
if (await fs.pathExists(altThumbnailPath)) {
thumbnailExists = true;
thumbnailPath = altThumbnailPath;
}
}
let fileExists = await fs.pathExists(file_obj.path);
if (config_api.descriptors[uid]) {
try {
for (let i = 0; i < config_api.descriptors[uid].length; i++) {
config_api.descriptors[uid][i].destroy();
}
} catch(e) {
}
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive || file_obj.sub_id) {
// get id/extractor from JSON
const info_json = await (type === 'audio' ? utils.getJSONMp3(name, folderPath) : utils.getJSONMp4(name, folderPath));
let retrievedID = null;
let retrievedExtractor = null;
if (info_json) {
retrievedID = info_json['id'];
retrievedExtractor = info_json['extractor'];
}
// Remove file ID from the archive file, and write it to the blacklist (if enabled)
if (!blacklistMode) {
await archive_api.removeFromArchive(retrievedExtractor, retrievedID, type, file_obj.user_uid, file_obj.sub_id)
} else {
const exists_in_archive = await archive_api.existsInArchive(retrievedExtractor, retrievedID, type, file_obj.user_uid, file_obj.sub_id);
if (!exists_in_archive) {
await archive_api.addToArchive(retrievedExtractor, retrievedID, type, file_obj.title, file_obj.user_uid, file_obj.sub_id);
}
}
}
if (jsonExists) await fs.unlink(jsonPath);
if (thumbnailExists) await fs.unlink(thumbnailPath);
await db_api.removeRecord('files', {uid: uid});
if (fileExists) {
await fs.unlink(file_obj.path);
if (await fs.pathExists(jsonPath) || await fs.pathExists(file_obj.path)) {
return false;
} else {
return true;
}
} else {
// TODO: tell user that the file didn't exist
return true;
}
}
// Video ID is basically just the file name without the base path and file extension - this method helps us get away from that
exports.getVideoUIDByID = async (file_id, uuid = null) => {
const file_obj = await db_api.getRecord('files', {id: file_id});
return file_obj ? file_obj['uid'] : null;
}
exports.getVideo = async (file_uid) => {
return await db_api.getRecord('files', {uid: file_uid});
}
exports.getAllFiles = async (sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid) => {
const filter_obj = {user_uid: uuid};
const regex = true;
if (text_search) {
if (regex) {
filter_obj['title'] = {$regex: `.*${text_search}.*`, $options: 'i'};
} else {
filter_obj['$text'] = { $search: utils.createEdgeNGrams(text_search) };
}
}
if (favorite_filter) {
filter_obj['favorite'] = true;
}
if (sub_id) {
filter_obj['sub_id'] = sub_id;
}
if (file_type_filter === 'audio_only') filter_obj['isAudio'] = true;
else if (file_type_filter === 'video_only') filter_obj['isAudio'] = false;
const files = JSON.parse(JSON.stringify(await db_api.getRecords('files', filter_obj, false, sort, range, text_search)));
const file_count = await db_api.getRecords('files', filter_obj, true);
return {files, file_count};
}

249
backend/notifications.js Normal file
View File

@@ -0,0 +1,249 @@
const db_api = require('./db');
const config_api = require('./config');
const logger = require('./logger');
const utils = require('./utils');
const consts = require('./consts');
const { uuid } = require('uuidv4');
const fetch = require('node-fetch');
const { gotify } = require("gotify");
const TelegramBot = require('node-telegram-bot-api');
const REST = require('@discordjs/rest').REST;
const API = require('@discordjs/core').API;
const EmbedBuilder = require('@discordjs/builders').EmbedBuilder;
const NOTIFICATION_TYPE_TO_TITLE = {
task_finished: 'Task finished',
download_complete: 'Download complete',
download_error: 'Download error'
}
const NOTIFICATION_TYPE_TO_BODY = {
task_finished: (notification) => notification['data']['task_title'],
download_complete: (notification) => {return `${notification['data']['file_title']}\nOriginal URL: ${notification['data']['original_url']}`},
download_error: (notification) => {return `Error: ${notification['data']['download_error_message']}\nError code: ${notification['data']['download_error_type']}\n\nOriginal URL: ${notification['data']['download_url']}`}
}
const NOTIFICATION_TYPE_TO_URL = {
task_finished: () => {return `${utils.getBaseURL()}/#/tasks`},
download_complete: (notification) => {return `${utils.getBaseURL()}/#/player;uid=${notification['data']['file_uid']}`},
download_error: () => {return `${utils.getBaseURL()}/#/downloads`},
}
const NOTIFICATION_TYPE_TO_THUMBNAIL = {
task_finished: () => null,
download_complete: (notification) => notification['data']['file_thumbnail'],
download_error: () => null
}
exports.sendNotification = async (notification) => {
// info necessary if we are using 3rd party APIs
const type = notification['type'];
const data = {
title: NOTIFICATION_TYPE_TO_TITLE[type],
body: NOTIFICATION_TYPE_TO_BODY[type](notification),
type: type,
url: NOTIFICATION_TYPE_TO_URL[type](notification),
thumbnail: NOTIFICATION_TYPE_TO_THUMBNAIL[type](notification)
}
if (config_api.getConfigItem('ytdl_use_ntfy_API') && config_api.getConfigItem('ytdl_ntfy_topic_url')) {
sendNtfyNotification(data);
}
if (config_api.getConfigItem('ytdl_use_gotify_API') && config_api.getConfigItem('ytdl_gotify_server_url') && config_api.getConfigItem('ytdl_gotify_app_token')) {
sendGotifyNotification(data);
}
if (config_api.getConfigItem('ytdl_use_telegram_API') && config_api.getConfigItem('ytdl_telegram_bot_token') && config_api.getConfigItem('ytdl_telegram_chat_id')) {
sendTelegramNotification(data);
}
if (config_api.getConfigItem('ytdl_webhook_url')) {
sendGenericNotification(data);
}
if (config_api.getConfigItem('ytdl_discord_webhook_url')) {
sendDiscordNotification(data);
}
if (config_api.getConfigItem('ytdl_slack_webhook_url')) {
sendSlackNotification(data);
}
await db_api.insertRecordIntoTable('notifications', notification);
return notification;
}
exports.sendTaskNotification = async (task_obj, confirmed) => {
if (!notificationEnabled('task_finished')) return;
// workaround for tasks which are user_uid agnostic
const user_uid = config_api.getConfigItem('ytdl_multi_user_mode') ? 'admin' : null;
await db_api.removeAllRecords('notifications', {"data.task_key": task_obj.key});
const data = {task_key: task_obj.key, task_title: task_obj.title, confirmed: confirmed};
const notification = exports.createNotification('task_finished', ['view_tasks'], data, user_uid);
return await exports.sendNotification(notification);
}
exports.sendDownloadNotification = async (file, user_uid) => {
if (!notificationEnabled('download_complete')) return;
const data = {file_uid: file.uid, file_title: file.title, file_thumbnail: file.thumbnailURL, original_url: file.url};
const notification = exports.createNotification('download_complete', ['play'], data, user_uid);
return await exports.sendNotification(notification);
}
exports.sendDownloadErrorNotification = async (download, user_uid, error_message, error_type = null) => {
if (!notificationEnabled('download_error')) return;
const data = {download_uid: download.uid, download_url: download.url, download_error_message: error_message, download_error_type: error_type};
const notification = exports.createNotification('download_error', ['view_download_error', 'retry_download'], data, user_uid);
return await exports.sendNotification(notification);
}
exports.createNotification = (type, actions, data, user_uid) => {
const notification = {
type: type,
actions: actions,
data: data,
user_uid: user_uid,
uid: uuid(),
read: false,
timestamp: Date.now()/1000
}
return notification;
}
function notificationEnabled(type) {
return config_api.getConfigItem('ytdl_enable_notifications') && (config_api.getConfigItem('ytdl_enable_all_notifications') || config_api.getConfigItem('ytdl_allowed_notification_types').includes(type));
}
function sendNtfyNotification({body, title, type, url, thumbnail}) {
logger.verbose('Sending notification to ntfy');
fetch(config_api.getConfigItem('ytdl_ntfy_topic_url'), {
method: 'POST',
body: body,
headers: {
'Title': title,
'Tags': type,
'Click': url,
'Attach': thumbnail
}
});
}
async function sendGotifyNotification({body, title, type, url, thumbnail}) {
logger.verbose('Sending notification to gotify');
await gotify({
server: config_api.getConfigItem('ytdl_gotify_server_url'),
app: config_api.getConfigItem('ytdl_gotify_app_token'),
title: title,
message: body,
tag: type,
priority: 5, // Keeping default from docs, may want to change this,
extras: {
"client::notification": {
click: { url: url },
bigImageUrl: thumbnail
}
}
});
}
async function sendTelegramNotification({body, title, type, url, thumbnail}) {
logger.verbose('Sending notification to Telegram');
const bot_token = config_api.getConfigItem('ytdl_telegram_bot_token');
const chat_id = config_api.getConfigItem('ytdl_telegram_chat_id');
const bot = new TelegramBot(bot_token);
if (thumbnail) await bot.sendPhoto(chat_id, thumbnail);
bot.sendMessage(chat_id, `<b>${title}</b>\n\n${body}\n<a href="${url}">${url}</a>`, {parse_mode: 'HTML'});
}
async function sendDiscordNotification({body, title, type, url, thumbnail}) {
const discord_webhook_url = config_api.getConfigItem('ytdl_discord_webhook_url');
const url_split = discord_webhook_url.split('webhooks/');
const [webhook_id, webhook_token] = url_split[1].split('/');
const rest = new REST({ version: '10' });
const api = new API(rest);
const embed = new EmbedBuilder()
.setTitle(title)
.setColor(0x00FFFF)
.setURL(url)
.setDescription(`ID: ${type}`);
if (thumbnail) embed.setThumbnail(thumbnail);
if (type === 'download_error') embed.setColor(0xFC2003);
const result = await api.webhooks.execute(webhook_id, webhook_token, {
content: body,
username: 'YoutubeDL-Material',
avatar_url: consts.ICON_URL,
embeds: [embed],
});
return result;
}
function sendSlackNotification({body, title, type, url, thumbnail}) {
const slack_webhook_url = config_api.getConfigItem('ytdl_slack_webhook_url');
logger.verbose(`Sending slack notification to ${slack_webhook_url}`);
const data = {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${title}*`
}
},
{
type: "section",
text: {
type: "plain_text",
text: body
}
}
]
}
// add thumbnail if exists
if (thumbnail) {
data['blocks'].push({
type: "image",
image_url: thumbnail,
alt_text: "notification_thumbnail"
});
}
data['blocks'].push(
{
type: "section",
text: {
type: "mrkdwn",
text: `<${url}|${url}>`
}
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `*ID:* ${type}`
}
]
}
);
fetch(slack_webhook_url, {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data),
});
}
function sendGenericNotification(data) {
const webhook_url = config_api.getConfigItem('ytdl_webhook_url');
logger.verbose(`Sending generic notification to ${webhook_url}`);
fetch(webhook_url, {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data),
});
}

2557
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,32 +17,43 @@
"bugs": { "bugs": {
"url": "" "url": ""
}, },
"engines": {
"node": "^16",
"npm": "6.14.4"
},
"homepage": "", "homepage": "",
"dependencies": { "dependencies": {
"@discordjs/builders": "^1.6.1",
"@discordjs/core": "^0.5.2",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"async": "^3.2.3", "async": "^3.2.3",
"async-mutex": "^0.3.1", "async-mutex": "^0.4.0",
"axios": "^0.21.2", "axios": "^0.21.2",
"bcryptjs": "^2.4.0", "bcryptjs": "^2.4.0",
"command-exists": "^1.2.9",
"compression": "^1.7.4", "compression": "^1.7.4",
"config": "^3.2.3", "config": "^3.2.3",
"express": "^4.17.3", "express": "^4.18.2",
"express-session": "^1.17.3",
"feed": "^4.2.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^9.0.0", "fs-extra": "^9.0.0",
"jsonwebtoken": "^8.5.1", "gotify": "^1.1.0",
"jsonwebtoken": "^9.0.0",
"lodash": "^4.17.21",
"lowdb": "^1.0.0", "lowdb": "^1.0.0",
"md5": "^2.2.1", "md5": "^2.2.1",
"merge-files": "^0.1.2",
"mocha": "^9.2.2", "mocha": "^9.2.2",
"moment": "^2.29.2", "moment": "^2.29.4",
"mongodb": "^3.6.9", "mongodb": "^3.6.9",
"multer": "1.4.5-lts.1", "multer": "1.4.5-lts.1",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.7",
"node-id3": "^0.1.14", "node-id3": "^0.2.6",
"node-schedule": "^2.1.0", "node-schedule": "^2.1.0",
"passport": "^0.4.1", "node-telegram-bot-api": "^0.61.0",
"passport": "^0.6.0",
"passport-http": "^0.3.0", "passport-http": "^0.3.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.1",
"passport-ldapauth": "^3.0.1", "passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"progress": "^2.0.3", "progress": "^2.0.3",
@@ -51,7 +62,7 @@
"rxjs": "^7.3.0", "rxjs": "^7.3.0",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"unzipper": "^0.10.10", "unzipper": "^0.10.10",
"uuidv4": "^6.0.6", "uuidv4": "^6.2.13",
"winston": "^3.7.2", "winston": "^3.7.2",
"xmlbuilder2": "^3.0.2", "xmlbuilder2": "^3.0.2",
"youtube-dl": "^3.0.2" "youtube-dl": "^3.0.2"

View File

@@ -3,22 +3,24 @@ const path = require('path');
const youtubedl = require('youtube-dl'); const youtubedl = require('youtube-dl');
const config_api = require('./config'); const config_api = require('./config');
const archive_api = require('./archive');
const utils = require('./utils'); const utils = require('./utils');
const logger = require('./logger'); const logger = require('./logger');
const CONSTS = require('./consts');
const debugMode = process.env.YTDL_MODE === 'debug'; const debugMode = process.env.YTDL_MODE === 'debug';
const db_api = require('./db'); const db_api = require('./db');
const downloader_api = require('./downloader'); const downloader_api = require('./downloader');
async function subscribe(sub, user_uid = null) { exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
const result_obj = { const result_obj = {
success: false, success: false,
error: '' error: ''
}; };
return new Promise(async resolve => { return new Promise(async resolve => {
// sub should just have url and name. here we will get isPlaylist and path // sub should just have url and name. here we will get isPlaylist and path
sub.isPlaylist = sub.url.includes('playlist'); sub.isPlaylist = sub.isPlaylist || sub.url.includes('playlist');
sub.videos = []; sub.videos = [];
let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid})); let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
@@ -33,10 +35,11 @@ async function subscribe(sub, user_uid = null) {
sub['user_uid'] = user_uid ? user_uid : undefined; sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', sub); await db_api.insertRecordIntoTable('subscriptions', sub);
let success = await getSubscriptionInfo(sub); let success = skip_get_info ? true : await getSubscriptionInfo(sub);
exports.writeSubscriptionMetadata(sub);
if (success) { if (success) {
getVideosForSub(sub, user_uid); if (!sub.paused) exports.getVideosForSub(sub, user_uid);
} else { } else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.') logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
} }
@@ -91,7 +94,10 @@ async function getSubscriptionInfo(sub) {
} }
// if it's now valid, update // if it's now valid, update
if (sub.name) { if (sub.name) {
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub.name}); let sub_name = sub.name;
const sub_name_exists = await db_api.getRecord('subscriptions', {name: sub.name, isPlaylist: sub.isPlaylist, user_uid: sub.user_uid});
if (sub_name_exists) sub_name += ` - ${sub.id}`;
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub_name});
} }
} }
@@ -105,7 +111,7 @@ async function getSubscriptionInfo(sub) {
}); });
} }
async function unsubscribe(sub, deleteMode, user_uid = null) { exports.unsubscribe = async (sub, deleteMode, user_uid = null) => {
let basePath = null; let basePath = null;
if (user_uid) if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -138,28 +144,25 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
const appendedBasePath = getAppendedBasePath(sub, basePath); const appendedBasePath = getAppendedBasePath(sub, basePath);
if (deleteMode && (await fs.pathExists(appendedBasePath))) { if (deleteMode && (await fs.pathExists(appendedBasePath))) {
if (sub.archive && (await fs.pathExists(sub.archive))) {
const archive_file_path = path.join(sub.archive, 'archive.txt');
// deletes archive if it exists
// TODO: Keep entries in blacklist_video.txt by moving them to a global blacklist
if (await fs.pathExists(archive_file_path)) {
await fs.unlink(archive_file_path);
}
await fs.rmdir(sub.archive);
}
await fs.remove(appendedBasePath); await fs.remove(appendedBasePath);
} }
await db_api.removeAllRecords('archives', {sub_id: sub.id});
} }
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) { exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = null, user_uid = null) => {
if (typeof sub === 'string') {
// TODO: fix bad workaround where sub is a sub_id
sub = await db_api.getRecord('subscriptions', {sub_id: sub});
}
// TODO: combine this with deletefile // TODO: combine this with deletefile
let basePath = null; let basePath = null;
basePath = user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions') basePath = user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions')
: config_api.getConfigItem('ytdl_subscriptions_base_path'); : config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
const appendedBasePath = getAppendedBasePath(sub, basePath); const appendedBasePath = getAppendedBasePath(sub, basePath);
const name = file; const name = file;
let retrievedID = null; let retrievedID = null;
let retrievedExtractor = null;
await db_api.removeRecord('files', {uid: file_uid}); await db_api.removeRecord('files', {uid: file_uid});
@@ -178,7 +181,9 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
]); ]);
if (jsonExists) { if (jsonExists) {
retrievedID = fs.readJSONSync(jsonPath)['id']; const info_json = fs.readJSONSync(jsonPath);
retrievedID = info_json['id'];
retrievedExtractor = info_json['extractor'];
await fs.unlink(jsonPath); await fs.unlink(jsonPath);
} }
@@ -196,11 +201,14 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
return false; return false;
} else { } else {
// check if the user wants the video to be redownloaded (deleteForever === false) // check if the user wants the video to be redownloaded (deleteForever === false)
if (useArchive && retrievedID) { if (deleteForever) {
const archive_path = utils.getArchiveFolder(sub.type, user_uid, sub); // ensure video is in the archives
const exists_in_archive = await archive_api.existsInArchive(retrievedExtractor, retrievedID, sub.type, user_uid, sub.id);
// Remove file ID from the archive file, and write it to the blacklist (if enabled) if (!exists_in_archive) {
await utils.deleteFileFromArchive(file_uid, sub.type, archive_path, retrievedID, deleteForever); await archive_api.addToArchive(retrievedExtractor, retrievedID, sub.type, file.title, user_uid, sub.id);
}
} else {
await archive_api.removeFromArchive(retrievedExtractor, retrievedID, sub.type, user_uid, sub.id);
} }
return true; return true;
} }
@@ -210,8 +218,8 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
} }
} }
async function getVideosForSub(sub, user_uid = null) { exports.getVideosForSub = async (sub, user_uid = null) => {
const latest_sub_obj = await getSubscription(sub.id); const latest_sub_obj = await exports.getSubscription(sub.id);
if (!latest_sub_obj || latest_sub_obj['downloading']) { if (!latest_sub_obj || latest_sub_obj['downloading']) {
return false; return false;
} }
@@ -231,13 +239,20 @@ async function getVideosForSub(sub, user_uid = null) {
const downloadConfig = await generateArgsForSubscription(sub, user_uid); const downloadConfig = await generateArgsForSubscription(sub, user_uid);
// get videos // get videos
logger.verbose(`Subscription: getting videos for subscription ${sub.name} with args: ${downloadConfig.join(',')}`); logger.verbose(`Subscription: getting list of videos to download for ${sub.name} with args: ${downloadConfig.join(',')}`);
return new Promise(async resolve => { return new Promise(async resolve => {
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) { youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
// cleanup // cleanup
updateSubscriptionProperty(sub, {downloading: false}, user_uid); updateSubscriptionProperty(sub, {downloading: false}, user_uid);
// remove temporary archive file if it exists
const archive_path = path.join(appendedBasePath, 'archive.txt');
const archive_exists = await fs.pathExists(archive_path);
if (archive_exists) {
await fs.unlink(archive_path);
}
logger.verbose('Subscription: finished check for ' + sub.name); logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) { if (err && !output) {
logger.error(err.stderr ? err.stderr : err.message); logger.error(err.stderr ? err.stderr : err.message);
@@ -292,7 +307,7 @@ async function handleOutputJSON(output, sub, user_uid) {
} }
const files_to_download = await getFilesToDownload(sub, output_jsons); const files_to_download = await getFilesToDownload(sub, output_jsons);
const base_download_options = generateOptionsForSubscriptionDownload(sub, user_uid); const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid);
for (let j = 0; j < files_to_download.length; j++) { for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[j]; const file_to_download = files_to_download[j];
@@ -303,7 +318,7 @@ async function handleOutputJSON(output, sub, user_uid) {
return files_to_download; return files_to_download;
} }
function generateOptionsForSubscriptionDownload(sub, user_uid) { exports.generateOptionsForSubscriptionDownload = (sub, user_uid) => {
let basePath = null; let basePath = null;
if (user_uid) if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -331,8 +346,6 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
else else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
let appendedBasePath = getAppendedBasePath(sub, basePath); let appendedBasePath = getAppendedBasePath(sub, basePath);
const file_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s'; const file_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
@@ -358,6 +371,13 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...qualityPath) downloadConfig.push(...qualityPath)
// skip videos that are in the archive. otherwise sub download can be permanently slow (vs. just the first time)
const archive_text = await archive_api.generateArchive(sub.type, sub.user_uid, sub.id);
logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_text.split('\n').length - 1} entries.`)
const archive_path = path.join(appendedBasePath, 'archive.txt');
await fs.writeFile(archive_path, archive_text);
downloadConfig.push('--download-archive', archive_path);
if (sub.custom_args) { if (sub.custom_args) {
const customArgsArray = sub.custom_args.split(',,'); const customArgsArray = sub.custom_args.split(',,');
if (customArgsArray.indexOf('-f') !== -1) { if (customArgsArray.indexOf('-f') !== -1) {
@@ -368,21 +388,6 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...customArgsArray); downloadConfig.push(...customArgsArray);
} }
let archive_dir = null;
let archive_path = null;
if (useArchive && !redownload) {
if (sub.archive) {
archive_dir = sub.archive;
if (sub.type && sub.type === 'audio') {
archive_path = path.join(archive_dir, 'merged_audio.txt');
} else {
archive_path = path.join(archive_dir, 'merged_video.txt');
}
}
downloadConfig.push('--download-archive', archive_path);
}
if (sub.timerange && !redownload) { if (sub.timerange && !redownload) {
downloadConfig.push('--dateafter', sub.timerange); downloadConfig.push('--dateafter', sub.timerange);
} }
@@ -425,7 +430,11 @@ async function getFilesToDownload(sub, output_jsons) {
if (file_with_path_exists) { if (file_with_path_exists) {
// or maybe just overwrite??? // or maybe just overwrite???
logger.info(`Skipping adding file ${output_json['_filename']} for subscription ${sub.name} as a file with that path already exists.`) logger.info(`Skipping adding file ${output_json['_filename']} for subscription ${sub.name} as a file with that path already exists.`)
continue;
} }
const exists_in_archive = await archive_api.existsInArchive(output_json['extractor'], output_json['id'], sub.type, sub.user_uid, sub.id);
if (exists_in_archive) continue;
files_to_download.push(output_json); files_to_download.push(output_json);
} }
} }
@@ -433,30 +442,36 @@ async function getFilesToDownload(sub, output_jsons) {
} }
async function getSubscriptions(user_uid = null) { exports.getSubscriptions = async (user_uid = null) => {
return await db_api.getRecords('subscriptions', {user_uid: user_uid}); return await db_api.getRecords('subscriptions', {user_uid: user_uid});
} }
async function getAllSubscriptions() { exports.getAllSubscriptions = async () => {
const all_subs = await db_api.getRecords('subscriptions'); const all_subs = await db_api.getRecords('subscriptions');
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode); return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
} }
async function getSubscription(subID) { exports.getSubscription = async (subID) => {
return await db_api.getRecord('subscriptions', {id: subID}); // stringify and parse because we may override the 'downloading' property
const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID})));
// now with the download_queue, we may need to override 'downloading'
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: subID}, true);
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
return sub;
} }
async function getSubscriptionByName(subName, user_uid = null) { exports.getSubscriptionByName = async (subName, user_uid = null) => {
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid}); return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
} }
async function updateSubscription(sub) { exports.updateSubscription = async (sub) => {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub); await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
exports.writeSubscriptionMetadata(sub);
return true; return true;
} }
async function updateSubscriptionPropertyMultiple(subs, assignment_obj) { exports.updateSubscriptionPropertyMultiple = async (subs, assignment_obj) => {
subs.forEach(async sub => { subs.forEach(async sub => {
await updateSubscriptionProperty(sub, assignment_obj); await updateSubscriptionProperty(sub, assignment_obj);
}); });
@@ -468,6 +483,14 @@ async function updateSubscriptionProperty(sub, assignment_obj) {
return true; return true;
} }
exports.writeSubscriptionMetadata = (sub) => {
let basePath = sub.user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), sub.user_uid, 'subscriptions')
: config_api.getConfigItem('ytdl_subscriptions_base_path');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
fs.writeJSONSync(metadata_path, sub);
}
async function setFreshUploads(sub) { async function setFreshUploads(sub) {
const sub_files = await db_api.getRecords('files', {sub_id: sub.id}); const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
if (!sub_files) return; if (!sub_files) return;
@@ -522,17 +545,3 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
function getAppendedBasePath(sub, base_path) { function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name); return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
} }
module.exports = {
getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getSubscriptions : getSubscriptions,
getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription,
subscribe : subscribe,
unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple,
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload
}

View File

@@ -1,9 +1,19 @@
const db_api = require('./db'); const db_api = require('./db');
const notifications_api = require('./notifications');
const youtubedl_api = require('./youtube-dl'); const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
const files_api = require('./files');
const subscriptions_api = require('./subscriptions');
const config_api = require('./config');
const auth_api = require('./authentication/auth');
const utils = require('./utils');
const logger = require('./logger');
const CONSTS = require('./consts');
const fs = require('fs-extra'); const fs = require('fs-extra');
const logger = require('./logger'); const path = require('path');
const scheduler = require('node-schedule'); const scheduler = require('node-schedule');
const { uuid } = require('uuidv4');
const TASKS = { const TASKS = {
backup_local_db: { backup_local_db: {
@@ -18,7 +28,7 @@ const TASKS = {
job: null job: null
}, },
missing_db_records: { missing_db_records: {
run: db_api.importUnregisteredFiles, run: files_api.importUnregisteredFiles,
title: 'Import missing DB records', title: 'Import missing DB records',
job: null job: null
}, },
@@ -33,6 +43,33 @@ const TASKS = {
confirm: youtubedl_api.updateYoutubeDL, confirm: youtubedl_api.updateYoutubeDL,
title: 'Update youtube-dl', title: 'Update youtube-dl',
job: null job: null
},
delete_old_files: {
run: checkForAutoDeleteFiles,
confirm: autoDeleteFiles,
title: 'Delete old files',
job: null
},
import_legacy_archives: {
run: archive_api.importArchives,
title: 'Import legacy archives',
job: null
},
rebuild_database: {
run: rebuildDB,
title: 'Rebuild database',
job: null
}
}
const defaultOptions = {
all: {
auto_confirm: false
},
delete_old_files: {
blacklist_files: false,
blacklist_subscription_files: false,
threshold_days: ''
} }
} }
@@ -45,7 +82,7 @@ function scheduleJob(task_key, schedule) {
const dayOfWeek = schedule['data']['dayOfWeek'] != null ? schedule['data']['dayOfWeek'] : null; const dayOfWeek = schedule['data']['dayOfWeek'] != null ? schedule['data']['dayOfWeek'] : null;
const hour = schedule['data']['hour'] != null ? schedule['data']['hour'] : null; const hour = schedule['data']['hour'] != null ? schedule['data']['hour'] : null;
const minute = schedule['data']['minute'] != null ? schedule['data']['minute'] : null; const minute = schedule['data']['minute'] != null ? schedule['data']['minute'] : null;
converted_schedule = new scheduler.RecurrenceRule(null, null, null, dayOfWeek, hour, minute); converted_schedule = new scheduler.RecurrenceRule(null, null, null, dayOfWeek, hour, minute, undefined, schedule['data']['tz'] ? schedule['data']['tz'] : undefined);
} else { } else {
logger.error(`Failed to schedule job '${task_key}' as the type '${schedule['type']}' is invalid.`) logger.error(`Failed to schedule job '${task_key}' as the type '${schedule['type']}' is invalid.`)
return null; return null;
@@ -77,9 +114,10 @@ exports.setupTasks = async () => {
const tasks_keys = Object.keys(TASKS); const tasks_keys = Object.keys(TASKS);
for (let i = 0; i < tasks_keys.length; i++) { for (let i = 0; i < tasks_keys.length; i++) {
const task_key = tasks_keys[i]; const task_key = tasks_keys[i];
const mergedDefaultOptions = Object.assign({}, defaultOptions['all'], defaultOptions[task_key] || {});
const task_in_db = await db_api.getRecord('tasks', {key: task_key}); const task_in_db = await db_api.getRecord('tasks', {key: task_key});
if (!task_in_db) { if (!task_in_db) {
// insert task metadata into table if missing // insert task metadata into table if missing, eventually move title to UI
await db_api.insertRecordIntoTable('tasks', { await db_api.insertRecordIntoTable('tasks', {
key: task_key, key: task_key,
title: TASKS[task_key]['title'], title: TASKS[task_key]['title'],
@@ -90,9 +128,19 @@ exports.setupTasks = async () => {
data: null, data: null,
error: null, error: null,
schedule: null, schedule: null,
options: {} options: Object.assign({}, defaultOptions['all'], defaultOptions[task_key] || {})
}); });
} else { } else {
// verify all options exist in task
for (const key of Object.keys(mergedDefaultOptions)) {
const option_key = `options.${key}`;
// Remove any potential mangled option keys (#861)
await db_api.removePropertyFromRecord('tasks', {key: task_key}, {[option_key]: true});
if (!(task_in_db.options && task_in_db.options.hasOwnProperty(key))) {
await db_api.updateRecord('tasks', {key: task_key}, {[option_key]: mergedDefaultOptions[key]}, true);
}
}
// reset task if necessary // reset task if necessary
await db_api.updateRecord('tasks', {key: task_key}, {running: false, confirming: false}); await db_api.updateRecord('tasks', {key: task_key}, {running: false, confirming: false});
@@ -123,15 +171,23 @@ exports.executeTask = async (task_key) => {
exports.executeRun = async (task_key) => { exports.executeRun = async (task_key) => {
logger.verbose(`Running task ${task_key}`); logger.verbose(`Running task ${task_key}`);
await db_api.updateRecord('tasks', {key: task_key}, {error: null})
// don't set running to true when backup up DB as it will be stick "running" if restored // don't set running to true when backup up DB as it will be stick "running" if restored
if (task_key !== 'backup_local_db') await db_api.updateRecord('tasks', {key: task_key}, {running: true}); if (task_key !== 'backup_local_db') await db_api.updateRecord('tasks', {key: task_key}, {running: true});
const data = await TASKS[task_key].run(); const data = await TASKS[task_key].run();
await db_api.updateRecord('tasks', {key: task_key}, {data: TASKS[task_key]['confirm'] ? data : null, last_ran: Date.now()/1000, running: false}); await db_api.updateRecord('tasks', {key: task_key}, {data: TASKS[task_key]['confirm'] ? data : null, last_ran: Date.now()/1000, running: false});
logger.verbose(`Finished running task ${task_key}`); logger.verbose(`Finished running task ${task_key}`);
const task_obj = await db_api.getRecord('tasks', {key: task_key});
await notifications_api.sendTaskNotification(task_obj, false);
if (task_obj['options'] && task_obj['options']['auto_confirm']) {
exports.executeConfirm(task_key);
}
} }
exports.executeConfirm = async (task_key) => { exports.executeConfirm = async (task_key) => {
logger.verbose(`Confirming task ${task_key}`); logger.verbose(`Confirming task ${task_key}`);
await db_api.updateRecord('tasks', {key: task_key}, {error: null})
if (!TASKS[task_key]['confirm']) { if (!TASKS[task_key]['confirm']) {
return null; return null;
} }
@@ -141,6 +197,7 @@ exports.executeConfirm = async (task_key) => {
await TASKS[task_key].confirm(data); await TASKS[task_key].confirm(data);
await db_api.updateRecord('tasks', {key: task_key}, {confirming: false, last_confirmed: Date.now()/1000, data: null}); await db_api.updateRecord('tasks', {key: task_key}, {confirming: false, last_confirmed: Date.now()/1000, data: null});
logger.verbose(`Finished confirming task ${task_key}`); logger.verbose(`Finished confirming task ${task_key}`);
await notifications_api.sendTaskNotification(task_obj, false);
} }
exports.updateTaskSchedule = async (task_key, schedule) => { exports.updateTaskSchedule = async (task_key, schedule) => {
@@ -193,4 +250,95 @@ async function removeDuplicates(data) {
} }
} }
// auto delete files
async function checkForAutoDeleteFiles() {
const task_obj = await db_api.getRecord('tasks', {key: 'delete_old_files'});
if (!task_obj['options'] || !task_obj['options']['threshold_days']) {
const error_message = 'Failed to do delete check because no limit was set!';
logger.error(error_message);
await db_api.updateRecord('tasks', {key: 'delete_old_files'}, {error: error_message})
return null;
}
const delete_older_than_timestamp = Date.now() - task_obj['options']['threshold_days']*86400*1000;
const files = (await db_api.getRecords('files', {registered: {$lt: delete_older_than_timestamp}}))
const files_to_remove = files.map(file => {return {uid: file.uid, sub_id: file.sub_id}});
return {files_to_remove: files_to_remove};
}
async function autoDeleteFiles(data) {
const task_obj = await db_api.getRecord('tasks', {key: 'delete_old_files'});
if (data['files_to_remove']) {
logger.info(`Removing ${data['files_to_remove'].length} old files!`);
for (let i = 0; i < data['files_to_remove'].length; i++) {
const file_to_remove = data['files_to_remove'][i];
await files_api.deleteFile(file_to_remove['uid'], task_obj['options']['blacklist_files'] || (file_to_remove['sub_id'] && file_to_remove['blacklist_subscription_files']));
}
}
}
async function rebuildDB() {
await db_api.backupDB();
let subs_to_add = await guessSubscriptions(false);
subs_to_add = subs_to_add.concat(await guessSubscriptions(true));
const users_to_add = await guessUsers();
for (const user_to_add of users_to_add) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_exists = await db_api.getRecord('users', {uid: user_to_add});
if (!user_exists) {
await auth_api.registerUser(user_to_add, user_to_add, 'password');
logger.info(`Regenerated user ${user_to_add}`);
}
const user_channel_subs = await guessSubscriptions(false, path.join(usersFileFolder, user_to_add), user_to_add);
const user_playlist_subs = await guessSubscriptions(true, path.join(usersFileFolder, user_to_add), user_to_add);
subs_to_add = subs_to_add.concat(user_channel_subs, user_playlist_subs);
}
for (const sub_to_add of subs_to_add) {
const sub_exists = !!(await subscriptions_api.getSubscriptionByName(sub_to_add['name'], sub_to_add['user_uid']));
// TODO: we shouldn't be creating this here
const new_sub = Object.assign({}, sub_to_add, {paused: true});
if (!sub_exists) {
await subscriptions_api.subscribe(new_sub, sub_to_add['user_uid'], true);
logger.info(`Regenerated subscription ${sub_to_add['name']}`);
}
}
logger.info(`Importing unregistered files`);
await files_api.importUnregisteredFiles();
}
const guessUsers = async () => {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const userPaths = await utils.getDirectoriesInDirectory(usersFileFolder);
return userPaths.map(userPath => path.basename(userPath));
}
const guessSubscriptions = async (isPlaylist, basePath = null) => {
const guessed_subs = [];
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
const subsSubPath = basePath ? path.join(basePath, 'subscriptions') : subscriptionsFileFolder;
const subsPath = path.join(subsSubPath, isPlaylist ? 'playlists' : 'channels');
const subs = await utils.getDirectoriesInDirectory(subsPath);
for (const subPath of subs) {
const sub_backup_path = path.join(subPath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
if (!fs.existsSync(sub_backup_path)) continue;
try {
const sub_backup = fs.readJSONSync(sub_backup_path)
delete sub_backup['_id'];
guessed_subs.push(sub_backup);
} catch(err) {
logger.warn(`Failed to reimport subscription in path ${subPath}`)
logger.warn(err);
}
}
return guessed_subs;
}
exports.TASKS = TASKS; exports.TASKS = TASKS;

View File

@@ -1,9 +1,9 @@
/* eslint-disable no-undef */
const assert = require('assert'); const assert = require('assert');
const low = require('lowdb') const low = require('lowdb')
const winston = require('winston'); const winston = require('winston');
const path = require('path'); const path = require('path');
process.chdir('./backend')
const FileSync = require('lowdb/adapters/FileSync'); const FileSync = require('lowdb/adapters/FileSync');
@@ -38,6 +38,9 @@ var auth_api = require('../authentication/auth');
var db_api = require('../db'); var db_api = require('../db');
const utils = require('../utils'); const utils = require('../utils');
const subscriptions_api = require('../subscriptions'); const subscriptions_api = require('../subscriptions');
const archive_api = require('../archive');
const categories_api = require('../categories');
const files_api = require('../files');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
const NodeID3 = require('node-id3'); const NodeID3 = require('node-id3');
@@ -66,12 +69,12 @@ const sample_video_json = {
describe('Database', async function() { describe('Database', async function() {
describe('Import', async function() { describe('Import', async function() {
it('Migrate', async function() { // it('Migrate', async function() {
await db_api.connectToDB(); // await db_api.connectToDB();
await db_api.removeAllRecords(); // await db_api.removeAllRecords();
const success = await db_api.importJSONToDB(db.value(), users_db.value()); // const success = await db_api.importJSONToDB(db.value(), users_db.value());
assert(success); // assert(success);
}); // });
it('Transfer to remote', async function() { it('Transfer to remote', async function() {
await db_api.removeAllRecords('test'); await db_api.removeAllRecords('test');
@@ -104,157 +107,208 @@ describe('Database', async function() {
}); });
}); });
describe('Export', function() {
});
describe('Basic functions', async function() { describe('Basic functions', async function() {
beforeEach(async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('test');
});
it('Add and read record', async function() {
this.timeout(120000);
await db_api.insertRecordIntoTable('test', {test_add: 'test', test_undefined: undefined, test_null: undefined});
const added_record = await db_api.getRecord('test', {test_add: 'test', test_undefined: undefined, test_null: null});
assert(added_record['test_add'] === 'test');
await db_api.removeRecord('test', {test_add: 'test'});
});
it('Find duplicates by key', async function() { // test both local_db and remote_db
const test_duplicates = [ const local_db_modes = [false, true];
{
test: 'testing',
key: '1'
},
{
test: 'testing',
key: '2'
},
{
test: 'testing_missing',
key: '3'
},
{
test: 'testing',
key: '4'
}
];
await db_api.insertRecordsIntoTable('test', test_duplicates);
const duplicates = await db_api.findDuplicatesByKey('test', 'test');
console.log(duplicates);
});
it('Update record', async function() { for (const local_db_mode of local_db_modes) {
await db_api.insertRecordIntoTable('test', {test_update: 'test'}); let use_local_db = local_db_mode;
await db_api.updateRecord('test', {test_update: 'test'}, {added_field: true}); describe(`Use local DB - ${use_local_db}`, async function() {
const updated_record = await db_api.getRecord('test', {test_update: 'test'}); beforeEach(async function() {
assert(updated_record['added_field']); if (!use_local_db) {
await db_api.removeRecord('test', {test_update: 'test'}); this.timeout(120000);
}); await db_api.connectToDB(0);
}
it('Remove record', async function() { await db_api.removeAllRecords('test');
await db_api.insertRecordIntoTable('test', {test_remove: 'test'});
const delete_succeeded = await db_api.removeRecord('test', {test_remove: 'test'});
assert(delete_succeeded);
const deleted_record = await db_api.getRecord('test', {test_remove: 'test'});
assert(!deleted_record);
});
it('Push to record array', async function() {
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: []});
await db_api.pushToRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
const record = await db_api.getRecord('test', {test: 'test'});
assert(record);
assert(record['test_array'].length === 1);
});
it('Pull from record array', async function() {
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: ['test_item']});
await db_api.pullFromRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
const record = await db_api.getRecord('test', {test: 'test'});
assert(record);
assert(record['test_array'].length === 0);
});
it('Bulk add', async function() {
this.timeout(120000);
const NUM_RECORDS_TO_ADD = 2002; // max batch ops is 1000
const test_records = [];
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
test_records.push({
uid: uuid()
}); });
} it('Add and read record', async function() {
const succcess = await db_api.bulkInsertRecordsIntoTable('test', test_records); this.timeout(120000);
await db_api.insertRecordIntoTable('test', {test_add: 'test', test_undefined: undefined, test_null: undefined});
const received_records = await db_api.getRecords('test'); const added_record = await db_api.getRecord('test', {test_add: 'test', test_undefined: undefined, test_null: null});
assert(succcess && received_records && received_records.length === NUM_RECORDS_TO_ADD); assert(added_record['test_add'] === 'test');
}); await db_api.removeRecord('test', {test_add: 'test'});
});
it('Bulk update', async function() { it('Add and read record - Nested property', async function() {
// bulk add records this.timeout(120000);
const NUM_RECORDS_TO_ADD = 100; // max batch ops is 1000 await db_api.insertRecordIntoTable('test', {test_add: 'test', test_nested: {test_key1: 'test1', test_key2: 'test2'}});
const test_records = []; const added_record = await db_api.getRecord('test', {test_add: 'test', 'test_nested.test_key1': 'test1', 'test_nested.test_key2': 'test2'});
const update_obj = {}; const not_added_record = await db_api.getRecord('test', {test_add: 'test', 'test_nested.test_key1': 'test1', 'test_nested.test_key2': 'test3'});
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) { assert(added_record['test_add'] === 'test');
const test_uid = uuid(); assert(!not_added_record);
test_records.push({ await db_api.removeRecord('test', {test_add: 'test'});
uid: test_uid });
it('Replace filter', async function() {
this.timeout(120000);
await db_api.insertRecordIntoTable('test', {test_replace_filter: 'test', test_nested: {test_key1: 'test1', test_key2: 'test2'}}, {test_nested: {test_key1: 'test1', test_key2: 'test2'}});
await db_api.insertRecordIntoTable('test', {test_replace_filter: 'test', test_nested: {test_key1: 'test1', test_key2: 'test2'}}, {test_nested: {test_key1: 'test1', test_key2: 'test2'}});
const count = await db_api.getRecords('test', {test_replace_filter: 'test'}, true);
assert(count === 1);
await db_api.removeRecord('test', {test_replace_filter: 'test'});
});
it('Find duplicates by key', async function() {
const test_duplicates = [
{
test: 'testing',
key: '1'
},
{
test: 'testing',
key: '2'
},
{
test: 'testing_missing',
key: '3'
},
{
test: 'testing',
key: '4'
}
];
await db_api.insertRecordsIntoTable('test', test_duplicates);
const duplicates = await db_api.findDuplicatesByKey('test', 'test');
console.log(duplicates);
}); });
update_obj[test_uid] = {added_field: true};
}
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
assert(success);
// makes sure they are added it('Update record', async function() {
const received_records = await db_api.getRecords('test'); await db_api.insertRecordIntoTable('test', {test_update: 'test'});
assert(received_records && received_records.length === NUM_RECORDS_TO_ADD); await db_api.updateRecord('test', {test_update: 'test'}, {added_field: true});
const updated_record = await db_api.getRecord('test', {test_update: 'test'});
assert(updated_record['added_field']);
await db_api.removeRecord('test', {test_update: 'test'});
});
success = await db_api.bulkUpdateRecords('test', 'uid', update_obj); it('Update records', async function() {
assert(success); await db_api.insertRecordIntoTable('test', {test_update: 'test', key: 'test1'});
await db_api.insertRecordIntoTable('test', {test_update: 'test', key: 'test2'});
await db_api.updateRecords('test', {test_update: 'test'}, {added_field: true});
const updated_records = await db_api.getRecords('test', {added_field: true});
assert(updated_records.length === 2);
await db_api.removeRecord('test', {test_update: 'test'});
});
const received_updated_records = await db_api.getRecords('test'); it('Remove property from record', async function() {
for (let i = 0; i < received_updated_records.length; i++) { await db_api.insertRecordIntoTable('test', {test_keep: 'test', test_remove: 'test'});
success &= received_updated_records[i]['added_field']; await db_api.removePropertyFromRecord('test', {test_keep: 'test'}, {test_remove: true});
} const updated_record = await db_api.getRecord('test', {test_keep: 'test'});
assert(success); assert(updated_record['test_keep']);
}); assert(!updated_record['test_remove']);
await db_api.removeRecord('test', {test_keep: 'test'});
});
it('Stats', async function() { it('Remove record', async function() {
const stats = await db_api.getDBStats(); await db_api.insertRecordIntoTable('test', {test_remove: 'test'});
assert(stats); const delete_succeeded = await db_api.removeRecord('test', {test_remove: 'test'});
}); assert(delete_succeeded);
const deleted_record = await db_api.getRecord('test', {test_remove: 'test'});
assert(!deleted_record);
});
it('Query speed', async function() { it('Remove records', async function() {
this.timeout(120000); await db_api.insertRecordIntoTable('test', {test_remove: 'test', test_property: 'test'});
const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000 await db_api.insertRecordIntoTable('test', {test_remove: 'test', test_property: 'test2'});
const test_records = []; await db_api.insertRecordIntoTable('test', {test_remove: 'test'});
let random_uid = '06241f83-d1b8-4465-812c-618dfa7f2943'; const delete_succeeded = await db_api.removeAllRecords('test', {test_remove: 'test'});
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) { assert(delete_succeeded);
const uid = uuid(); const count = await db_api.getRecords('test', {test_remove: 'test'}, true);
if (i === NUM_RECORDS_TO_ADD/2) random_uid = uid; assert(count === 0);
test_records.push({"id":"RandomTextRandomText","title":"RandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomText","thumbnailURL":"https://i.ytimg.com/vi/randomurl/maxresdefault.jpg","isAudio":true,"duration":312,"url":"https://www.youtube.com/watch?v=randomvideo","uploader":"randomUploader","size":5060157,"path":"audio\\RandomTextRandomText.mp3","upload_date":"2016-05-11","description":"RandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomText","view_count":118689353,"height":null,"abr":160,"uid": uid,"registered":1626672120632}); });
}
const insert_start = Date.now();
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
const insert_end = Date.now();
console.log(`Insert time: ${(insert_end - insert_start)/1000}s`); it('Push to record array', async function() {
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: []});
await db_api.pushToRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
const record = await db_api.getRecord('test', {test: 'test'});
assert(record);
assert(record['test_array'].length === 1);
});
const query_start = Date.now(); it('Pull from record array', async function() {
const random_record = await db_api.getRecord('test', {uid: random_uid}); await db_api.insertRecordIntoTable('test', {test: 'test', test_array: ['test_item']});
const query_end = Date.now(); await db_api.pullFromRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
const record = await db_api.getRecord('test', {test: 'test'});
assert(record);
assert(record['test_array'].length === 0);
});
console.log(random_record) it('Bulk add', async function() {
this.timeout(120000);
const NUM_RECORDS_TO_ADD = 2002; // max batch ops is 1000
const test_records = [];
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
test_records.push({
uid: uuid()
});
}
const succcess = await db_api.bulkInsertRecordsIntoTable('test', test_records);
console.log(`Query time: ${(query_end - query_start)/1000}s`); const received_records = await db_api.getRecords('test');
assert(succcess && received_records && received_records.length === NUM_RECORDS_TO_ADD);
});
success = !!random_record; it('Bulk update', async function() {
// bulk add records
const NUM_RECORDS_TO_ADD = 100; // max batch ops is 1000
const test_records = [];
const update_obj = {};
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
const test_uid = uuid();
test_records.push({
uid: test_uid
});
update_obj[test_uid] = {added_field: true};
}
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
assert(success);
assert(success); // makes sure they are added
}); const received_records = await db_api.getRecords('test');
assert(received_records && received_records.length === NUM_RECORDS_TO_ADD);
success = await db_api.bulkUpdateRecordsByKey('test', 'uid', update_obj);
assert(success);
const received_updated_records = await db_api.getRecords('test');
for (let i = 0; i < received_updated_records.length; i++) {
success &= received_updated_records[i]['added_field'];
}
assert(success);
});
it('Stats', async function() {
const stats = await db_api.getDBStats();
assert(stats);
});
it('Query speed', async function() {
this.timeout(120000);
const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000
const test_records = [];
let random_uid = '06241f83-d1b8-4465-812c-618dfa7f2943';
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
const uid = uuid();
if (i === NUM_RECORDS_TO_ADD/2) random_uid = uid;
test_records.push({"id":"RandomTextRandomText","title":"RandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomText","thumbnailURL":"https://i.ytimg.com/vi/randomurl/maxresdefault.jpg","isAudio":true,"duration":312,"url":"https://www.youtube.com/watch?v=randomvideo","uploader":"randomUploader","size":5060157,"path":"audio\\RandomTextRandomText.mp3","upload_date":"2016-05-11","description":"RandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomTextRandomText","view_count":118689353,"height":null,"abr":160,"uid": uid,"registered":1626672120632});
}
const insert_start = Date.now();
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
const insert_end = Date.now();
console.log(`Insert time: ${(insert_end - insert_start)/1000}s`);
const query_start = Date.now();
const random_record = await db_api.getRecord('test', {uid: random_uid});
const query_end = Date.now();
console.log(random_record)
console.log(`Query time: ${(query_end - query_start)/1000}s`);
success = !!random_record;
assert(success);
});
});
}
}); });
describe('Local DB Filters', async function() { describe('Local DB Filters', async function() {
@@ -283,50 +337,56 @@ describe('Database', async function() {
}); });
describe('Multi User', async function() { describe('Multi User', async function() {
let user = null; const user_to_test = 'test_user';
const user_to_test = 'admin'; const user_password = 'test_pass';
const sub_to_test = 'dc834388-3454-41bf-a618-e11cb8c7de1c'; const sub_to_test = '';
const playlist_to_test = 'ysabVZz4x'; const playlist_to_test = '';
beforeEach(async function() { beforeEach(async function() {
await db_api.connectToDB(); await db_api.connectToDB();
auth_api.initialize(db_api, logger); await auth_api.deleteUser(user_to_test);
subscriptions_api.initialize(db_api, logger);
user = await auth_api.login('admin', 'pass');
}); });
describe('Authentication', function() { describe('Basic', function() {
it('login', async function() { it('Register', async function() {
const user = await auth_api.registerUser(user_to_test, user_to_test, user_password);
assert(user);
});
it('Login', async function() {
await auth_api.registerUser(user_to_test, user_to_test, user_password);
const user = await auth_api.login(user_to_test, user_password);
assert(user); assert(user);
}); });
}); });
describe('Video player - normal', async function() { describe('Video player - normal', async function() {
await db_api.removeRecord('files', {uid: sample_video_json['uid']}); beforeEach(async function() {
await db_api.insertRecordIntoTable('files', sample_video_json); await db_api.removeRecord('files', {uid: sample_video_json['uid']});
await db_api.insertRecordIntoTable('files', sample_video_json);
});
const video_to_test = sample_video_json['uid']; const video_to_test = sample_video_json['uid'];
it('Get video', async function() { it('Get video', async function() {
const video_obj = await db_api.getVideo(video_to_test); const video_obj = await files_api.getVideo(video_to_test);
assert(video_obj); assert(video_obj);
}); });
it('Video access - disallowed', async function() { it('Video access - disallowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}, user_to_test); await db_api.setVideoProperty(video_to_test, {sharingEnabled: false});
const video_obj = auth_api.getUserVideo('admin', video_to_test, true); const video_obj = auth_api.getUserVideo(user_to_test, video_to_test, true);
assert(!video_obj); assert(!video_obj);
}); });
it('Video access - allowed', async function() { it('Video access - allowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test); await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test);
const video_obj = auth_api.getUserVideo('admin', video_to_test, true); const video_obj = auth_api.getUserVideo(user_to_test, video_to_test, true);
assert(video_obj); assert(video_obj);
}); });
}); });
describe('Zip generators', function() { describe('Zip generators', function() {
it('Playlist zip generator', async function() { it('Playlist zip generator', async function() {
const playlist = await db_api.getPlaylist(playlist_to_test, user_to_test); const playlist = await files_api.getPlaylist(playlist_to_test, user_to_test);
assert(playlist); assert(playlist);
const playlist_files_to_download = []; const playlist_files_to_download = [];
for (let i = 0; i < playlist['uids'].length; i++) { for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i]; const uid = playlist['uids'][i];
const playlist_file = await db_api.getVideo(uid, user_to_test); const playlist_file = await files_api.getVideo(uid, user_to_test);
playlist_files_to_download.push(playlist_file); playlist_files_to_download.push(playlist_file);
} }
const zip_path = await utils.createContainerZipFile(playlist, playlist_files_to_download); const zip_path = await utils.createContainerZipFile(playlist, playlist_files_to_download);
@@ -354,7 +414,7 @@ describe('Multi User', async function() {
// const sub_to_test = ''; // const sub_to_test = '';
// const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e'; // const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e';
// it('Get video', async function() { // it('Get video', async function() {
// const video_obj = db_api.getVideo(video_to_test, 'admin', ); // const video_obj = files_api.getVideo(video_to_test, 'admin', );
// assert(video_obj); // assert(video_obj);
// }); // });
@@ -457,18 +517,23 @@ describe('Downloader', function() {
const new_args1 = ['--age-limit', '25', '--yes-playlist', '--abort-on-error', '-o', '%(id)s']; const new_args1 = ['--age-limit', '25', '--yes-playlist', '--abort-on-error', '-o', '%(id)s'];
const updated_args1 = utils.injectArgs(original_args1, new_args1); const updated_args1 = utils.injectArgs(original_args1, new_args1);
const expected_args1 = ['--no-resize-buffer', '--no-mtime', '--age-limit', '25', '--yes-playlist', '--abort-on-error', '-o', '%(id)s']; const expected_args1 = ['--no-resize-buffer', '--no-mtime', '--age-limit', '25', '--yes-playlist', '--abort-on-error', '-o', '%(id)s'];
assert(JSON.stringify(updated_args1), JSON.stringify(expected_args1)); assert(JSON.stringify(updated_args1) === JSON.stringify(expected_args1));
const original_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3']; const original_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3'];
const new_args2 = ['--add-metadata', '--embed-thumbnail', '--convert-thumbnails', 'jpg']; const new_args2 = ['--add-metadata', '--embed-thumbnail', '--convert-thumbnails', 'jpg'];
const updated_args2 = utils.injectArgs(original_args2, new_args2); const updated_args2 = utils.injectArgs(original_args2, new_args2);
const expected_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3', '--add-metadata', '--embed-thumbnail', '--convert_thumbnails', 'jpg']; const expected_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3', '--add-metadata', '--embed-thumbnail', '--convert-thumbnails', 'jpg'];
console.log(updated_args2); assert(JSON.stringify(updated_args2) === JSON.stringify(expected_args2));
assert(JSON.stringify(updated_args2), JSON.stringify(expected_args2));
const original_args3 = ['-o', '%(title)s.%(ext)s'];
const new_args3 = ['--min-filesize','1'];
const updated_args3 = utils.injectArgs(original_args3, new_args3);
const expected_args3 = ['-o', '%(title)s.%(ext)s', '--min-filesize', '1'];
assert(JSON.stringify(updated_args3) === JSON.stringify(expected_args3));
}); });
describe('Twitch', async function () { describe('Twitch', async function () {
const twitch_api = require('../twitch'); const twitch_api = require('../twitch');
const example_vod = '1493770675'; const example_vod = '1710641401';
it('Download VOD', async function() { it('Download VOD', async function() {
const sample_path = path.join('test', 'sample.twitch_chat.json'); const sample_path = path.join('test', 'sample.twitch_chat.json');
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path); if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
@@ -550,7 +615,7 @@ describe('Tasks', function() {
fs.copyFileSync('test/sample.mp4', 'video/sample.mp4'); fs.copyFileSync('test/sample.mp4', 'video/sample.mp4');
await tasks_api.executeTask('missing_db_records'); await tasks_api.executeTask('missing_db_records');
const imported_file = await db_api.getRecord('files', {title: 'Sample File'}); const imported_file = await db_api.getRecord('files', {title: 'Sample File'});
assert(!!imported_file, true); assert(!!imported_file === true);
// post-test cleanup // post-test cleanup
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json'); if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
@@ -593,30 +658,72 @@ describe('Tasks', function() {
}); });
describe('Archive', async function() { describe('Archive', async function() {
const archive_path = path.join('test', 'archives');
fs.ensureDirSync(archive_path);
const archive_file_path = path.join(archive_path, 'archive_video.txt');
const blacklist_file_path = path.join(archive_path, 'blacklist_video.txt');
beforeEach(async function() { beforeEach(async function() {
if (fs.existsSync(archive_file_path)) fs.unlinkSync(archive_file_path); await db_api.connectToDB();
fs.writeFileSync(archive_file_path, 'youtube testing1\nyoutube testing2\nyoutube testing3\n'); await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
if (fs.existsSync(blacklist_file_path)) fs.unlinkSync(blacklist_file_path);
fs.writeFileSync(blacklist_file_path, '');
}); });
it('Delete from archive', async function() { afterEach(async function() {
await utils.deleteFileFromArchive('N/A', 'video', archive_path, 'testing2', false); await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
const new_archive = fs.readFileSync(archive_file_path);
assert(!new_archive.includes('testing2'));
}); });
it('Delete from archive - blacklist', async function() { it('Import archive', async function() {
await utils.deleteFileFromArchive('N/A', 'video', archive_path, 'testing2', true); const archive_text = `
const new_archive = fs.readFileSync(archive_file_path); testextractor1 testing1
const new_blacklist = fs.readFileSync(blacklist_file_path); testextractor1 testing2
assert(!new_archive.includes('testing2')); testextractor2 testing1
assert(new_blacklist.includes('testing2')); testextractor1 testing3
`;
const count = await archive_api.importArchiveFile(archive_text, 'video', 'test_user', 'test_sub');
assert(count === 4)
const archive_items = await db_api.getRecords('archives', {user_uid: 'test_user', sub_id: 'test_sub'});
console.log(archive_items);
assert(archive_items.length === 4);
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor2').length === 1);
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor1').length === 3);
const success = await db_api.removeAllRecords('archives', {user_uid: 'test_user', sub_id: 'test_sub'});
assert(success);
});
it('Get archive', async function() {
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user');
const archive_item1 = await db_api.getRecord('archives', {extractor: 'testextractor1', id: 'testing1'});
const archive_item2 = await db_api.getRecord('archives', {extractor: 'testextractor2', id: 'testing1'});
assert(archive_item1 && archive_item2);
});
it('Archive duplicates', async function() {
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor1', 'testing1', 'audio', 'test_user');
const count = await db_api.getRecords('archives', {id: 'testing1'}, true);
assert(count === 3);
});
it('Remove from archive', async function() {
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_user');
const success = await archive_api.removeFromArchive('testextractor2', 'testing1', 'video', 'test_user');
assert(success);
const archive_item1 = await db_api.getRecord('archives', {extractor: 'testextractor1', id: 'testing1'});
assert(!!archive_item1);
const archive_item2 = await db_api.getRecord('archives', {extractor: 'testextractor2', id: 'testing1'});
assert(!archive_item2);
const archive_item3 = await db_api.getRecord('archives', {extractor: 'testextractor2', id: 'testing2'});
assert(!!archive_item3);
}); });
}); });
@@ -626,4 +733,129 @@ describe('Utils', async function() {
const stripped_obj = utils.stripPropertiesFromObject(test_obj, ['test1', 'test3']); const stripped_obj = utils.stripPropertiesFromObject(test_obj, ['test1', 'test3']);
assert(!stripped_obj['test1'] && stripped_obj['test2'] && !stripped_obj['test3']) assert(!stripped_obj['test1'] && stripped_obj['test2'] && !stripped_obj['test3'])
}); });
it('Convert flat object to nested object', async function() {
// No modfication
const flat_obj0 = {'test1': {'test_sub': true}, 'test2': {test_sub: true}};
const nested_obj0 = utils.convertFlatObjectToNestedObject(flat_obj0);
assert(nested_obj0['test1'] && nested_obj0['test1']['test_sub']);
assert(nested_obj0['test2'] && nested_obj0['test2']['test_sub']);
// Standard setup
const flat_obj1 = {'test1.test_sub': true, 'test2.test_sub': true};
const nested_obj1 = utils.convertFlatObjectToNestedObject(flat_obj1);
assert(nested_obj1['test1'] && nested_obj1['test1']['test_sub']);
assert(nested_obj1['test2'] && nested_obj1['test2']['test_sub']);
// Nested branches
const flat_obj2 = {'test1.test_sub': true, 'test1.test2.test_sub': true};
const nested_obj2 = utils.convertFlatObjectToNestedObject(flat_obj2);
assert(nested_obj2['test1'] && nested_obj2['test1']['test_sub']);
assert(nested_obj2['test1'] && nested_obj2['test1']['test2'] && nested_obj2['test1']['test2']['test_sub']);
});
});
describe('Categories', async function() {
beforeEach(async function() {
await db_api.connectToDB();
const new_category = {
name: 'test_category',
uid: uuid(),
rules: [],
custom_output: ''
};
await db_api.insertRecordIntoTable('categories', new_category);
});
afterEach(async function() {
await db_api.removeAllRecords('categories', {name: 'test_category'});
});
it('Categorize - includes', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'includes',
property: 'title',
value: 'Sample'
});
const category = await categories_api.categorize([sample_video_json]);
assert(category && category.name === 'test_category');
});
it('Categorize - not includes', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'not_includes',
property: 'title',
value: 'Sample'
});
const category = await categories_api.categorize([sample_video_json]);
assert(!category);
});
it('Categorize - equals', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'equals',
property: 'uploader',
value: 'Sample Uploader'
});
const category = await categories_api.categorize([sample_video_json]);
console.log(category);
assert(category && category.name === 'test_category');
});
it('Categorize - not equals', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'not_equals',
property: 'uploader',
value: 'Sample Uploader'
});
const category = await categories_api.categorize([sample_video_json]);
assert(!category);
});
it('Categorize - AND', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'equals',
property: 'uploader',
value: 'Sample Uploader'
});
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: 'and',
comparator: 'not_includes',
property: 'title',
value: 'Sample'
});
const category = await categories_api.categorize([sample_video_json]);
assert(!category);
});
it('Categorize - OR', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'equals',
property: 'uploader',
value: 'Sample Uploader'
});
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: 'or',
comparator: 'not_includes',
property: 'title',
value: 'Sample'
});
const category = await categories_api.categorize([sample_video_json]);
assert(category);
});
}); });

View File

@@ -4,19 +4,29 @@ const logger = require('./logger');
const moment = require('moment'); const moment = require('moment');
const fs = require('fs-extra') const fs = require('fs-extra')
const path = require('path'); const path = require('path');
const { promisify } = require('util');
const child_process = require('child_process');
const commandExistsSync = require('command-exists').sync;
async function getCommentsForVOD(clientID, clientSecret, vodId) { async function getCommentsForVOD(vodId) {
const { promisify } = require('util');
const child_process = require('child_process');
const exec = promisify(child_process.exec); const exec = promisify(child_process.exec);
// Reject invalid params to prevent command injection attack // Reject invalid params to prevent command injection attack
if (!clientID.match(/^[0-9a-z]+$/) || !clientSecret.match(/^[0-9a-z]+$/) || !vodId.match(/^[0-9a-z]+$/)) { if (!vodId.match(/^[0-9a-z]+$/)) {
logger.error('Client ID, client secret, and VOD ID must be purely alphanumeric. Twitch chat download failed!'); logger.error('VOD ID must be purely alphanumeric. Twitch chat download failed!');
return null; return null;
} }
const result = await exec(`tcd --video ${vodId} --client-id ${clientID} --client-secret ${clientSecret} --format json -o appdata`, {stdio:[0,1,2]}); const is_windows = process.platform === 'win32';
const cliExt = is_windows ? '.exe' : ''
const cliPath = `TwitchDownloaderCLI${cliExt}`
if (!commandExistsSync(cliPath)) {
logger.error(`${cliPath} does not exist. Twitch chat download failed! Get it here: https://github.com/lay295/TwitchDownloader`);
return null;
}
const result = await exec(`${cliPath} chatdownload -u ${vodId} -o appdata/${vodId}.json`, {stdio:[0,1,2]});
if (result['stderr']) { if (result['stderr']) {
logger.error(`Failed to download twitch comments for ${vodId}`); logger.error(`Failed to download twitch comments for ${vodId}`);
@@ -73,9 +83,7 @@ async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customFileFolderPath = null) { async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customFileFolderPath = null) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path'); const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id'); const chat = await getCommentsForVOD(vodId);
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
const chat = await getCommentsForVOD(twitch_client_id, twitch_client_secret, vodId);
// save file if needed params are included // save file if needed params are included
let file_path = null; let file_path = null;

View File

@@ -4,6 +4,7 @@ const ffmpeg = require('fluent-ffmpeg');
const archiver = require('archiver'); const archiver = require('archiver');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const ProgressBar = require('progress'); const ProgressBar = require('progress');
const winston = require('winston');
const config_api = require('./config'); const config_api = require('./config');
const logger = require('./logger'); const logger = require('./logger');
@@ -12,7 +13,7 @@ const CONSTS = require('./consts');
const is_windows = process.platform === 'win32'; const is_windows = process.platform === 'win32';
// replaces .webm with appropriate extension // replaces .webm with appropriate extension
function getTrueFileName(unfixed_path, type) { exports.getTrueFileName = (unfixed_path, type) => {
let fixed_path = unfixed_path; let fixed_path = unfixed_path;
const new_ext = (type === 'audio' ? 'mp3' : 'mp4'); const new_ext = (type === 'audio' ? 'mp3' : 'mp4');
@@ -27,13 +28,13 @@ function getTrueFileName(unfixed_path, type) {
return fixed_path; return fixed_path;
} }
async function getDownloadedFilesByType(basePath, type, full_metadata = false) { exports.getDownloadedFilesByType = async (basePath, type, full_metadata = false) => {
// return empty array if the path doesn't exist // return empty array if the path doesn't exist
if (!(await fs.pathExists(basePath))) return []; if (!(await fs.pathExists(basePath))) return [];
let files = []; let files = [];
const ext = type === 'audio' ? 'mp3' : 'mp4'; const ext = type === 'audio' ? 'mp3' : 'mp4';
var located_files = await recFindByExt(basePath, ext); var located_files = await exports.recFindByExt(basePath, ext);
for (let i = 0; i < located_files.length; i++) { for (let i = 0; i < located_files.length; i++) {
let file = located_files[i]; let file = located_files[i];
var file_path = file.substring(basePath.includes('\\') ? basePath.length+1 : basePath.length, file.length); var file_path = file.substring(basePath.includes('\\') ? basePath.length+1 : basePath.length, file.length);
@@ -41,33 +42,33 @@ async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
var stats = await fs.stat(file); var stats = await fs.stat(file);
var id = file_path.substring(0, file_path.length-4); var id = file_path.substring(0, file_path.length-4);
var jsonobj = await getJSONByType(type, id, basePath); var jsonobj = await exports.getJSONByType(type, id, basePath);
if (!jsonobj) continue; if (!jsonobj) continue;
if (full_metadata) { if (full_metadata) {
jsonobj['id'] = id; jsonobj['id'] = id;
files.push(jsonobj); files.push(jsonobj);
continue; continue;
} }
var upload_date = formatDateString(jsonobj.upload_date); var upload_date = exports.formatDateString(jsonobj.upload_date);
var isaudio = type === 'audio'; var isaudio = type === 'audio';
var file_obj = new File(id, jsonobj.title, jsonobj.thumbnail, isaudio, jsonobj.duration, jsonobj.webpage_url, jsonobj.uploader, var file_obj = new exports.File(id, jsonobj.title, jsonobj.thumbnail, isaudio, jsonobj.duration, jsonobj.webpage_url, jsonobj.uploader,
stats.size, file, upload_date, jsonobj.description, jsonobj.view_count, jsonobj.height, jsonobj.abr); stats.size, file, upload_date, jsonobj.description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
files.push(file_obj); files.push(file_obj);
} }
return files; return files;
} }
async function createContainerZipFile(file_name, container_file_objs) { exports.createContainerZipFile = async (file_name, container_file_objs) => {
const container_files_to_download = []; const container_files_to_download = [];
for (let i = 0; i < container_file_objs.length; i++) { for (let i = 0; i < container_file_objs.length; i++) {
const container_file_obj = container_file_objs[i]; const container_file_obj = container_file_objs[i];
container_files_to_download.push(container_file_obj.path); container_files_to_download.push(container_file_obj.path);
} }
return await createZipFile(path.join('appdata', file_name + '.zip'), container_files_to_download); return await exports.createZipFile(path.join('appdata', file_name + '.zip'), container_files_to_download);
} }
async function createZipFile(zip_file_path, file_paths) { exports.createZipFile = async (zip_file_path, file_paths) => {
let output = fs.createWriteStream(zip_file_path); let output = fs.createWriteStream(zip_file_path);
var archive = archiver('zip', { var archive = archiver('zip', {
@@ -91,11 +92,11 @@ async function createZipFile(zip_file_path, file_paths) {
await archive.finalize(); await archive.finalize();
// wait a tiny bit for the zip to reload in fs // wait a tiny bit for the zip to reload in fs
await wait(100); await exports.wait(100);
return zip_file_path; return zip_file_path;
} }
function getJSONMp4(name, customPath, openReadPerms = false) { exports.getJSONMp4 = (name, customPath, openReadPerms = false) => {
var obj = null; // output var obj = null; // output
if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path'); if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path');
var jsonPath = path.join(customPath, name + ".info.json"); var jsonPath = path.join(customPath, name + ".info.json");
@@ -110,7 +111,7 @@ function getJSONMp4(name, customPath, openReadPerms = false) {
return obj; return obj;
} }
function getJSONMp3(name, customPath, openReadPerms = false) { exports.getJSONMp3 = (name, customPath, openReadPerms = false) => {
var obj = null; var obj = null;
if (!customPath) customPath = config_api.getConfigItem('ytdl_audio_folder_path'); if (!customPath) customPath = config_api.getConfigItem('ytdl_audio_folder_path');
var jsonPath = path.join(customPath, name + ".info.json"); var jsonPath = path.join(customPath, name + ".info.json");
@@ -127,11 +128,11 @@ function getJSONMp3(name, customPath, openReadPerms = false) {
return obj; return obj;
} }
function getJSON(file_path, type) { exports.getJSON = (file_path, type) => {
const ext = type === 'audio' ? '.mp3' : '.mp4'; const ext = type === 'audio' ? '.mp3' : '.mp4';
let obj = null; let obj = null;
var jsonPath = removeFileExtension(file_path) + '.info.json'; var jsonPath = exports.removeFileExtension(file_path) + '.info.json';
var alternateJsonPath = removeFileExtension(file_path) + `${ext}.info.json`; var alternateJsonPath = exports.removeFileExtension(file_path) + `${ext}.info.json`;
if (fs.existsSync(jsonPath)) if (fs.existsSync(jsonPath))
{ {
obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
@@ -142,12 +143,12 @@ function getJSON(file_path, type) {
return obj; return obj;
} }
function getJSONByType(type, name, customPath, openReadPerms = false) { exports.getJSONByType = (type, name, customPath, openReadPerms = false) => {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms) return type === 'audio' ? exports.getJSONMp3(name, customPath, openReadPerms) : exports.getJSONMp4(name, customPath, openReadPerms)
} }
function getDownloadedThumbnail(file_path) { exports.getDownloadedThumbnail = (file_path) => {
const file_path_no_extension = removeFileExtension(file_path); const file_path_no_extension = exports.removeFileExtension(file_path);
let jpgPath = file_path_no_extension + '.jpg'; let jpgPath = file_path_no_extension + '.jpg';
let webpPath = file_path_no_extension + '.webp'; let webpPath = file_path_no_extension + '.webp';
@@ -163,7 +164,7 @@ function getDownloadedThumbnail(file_path) {
return null; return null;
} }
function getExpectedFileSize(input_info_jsons) { exports.getExpectedFileSize = (input_info_jsons) => {
// treat single videos as arrays to have the file sizes checked/added to. makes the code cleaner // treat single videos as arrays to have the file sizes checked/added to. makes the code cleaner
const info_jsons = Array.isArray(input_info_jsons) ? input_info_jsons : [input_info_jsons]; const info_jsons = Array.isArray(input_info_jsons) ? input_info_jsons : [input_info_jsons];
@@ -186,12 +187,12 @@ function getExpectedFileSize(input_info_jsons) {
return expected_filesize; return expected_filesize;
} }
function fixVideoMetadataPerms(file_path, type) { exports.fixVideoMetadataPerms = (file_path, type) => {
if (is_windows) return; if (is_windows) return;
const ext = type === 'audio' ? '.mp3' : '.mp4'; const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path); const file_path_no_extension = exports.removeFileExtension(file_path);
const files_to_fix = [ const files_to_fix = [
// JSONs // JSONs
@@ -208,10 +209,10 @@ function fixVideoMetadataPerms(file_path, type) {
} }
} }
function deleteJSONFile(file_path, type) { exports.deleteJSONFile = (file_path, type) => {
const ext = type === 'audio' ? '.mp3' : '.mp4'; const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path); const file_path_no_extension = exports.removeFileExtension(file_path);
let json_path = file_path_no_extension + '.info.json'; let json_path = file_path_no_extension + '.info.json';
let alternate_json_path = file_path_no_extension + ext + '.info.json'; let alternate_json_path = file_path_no_extension + ext + '.info.json';
@@ -220,58 +221,7 @@ function deleteJSONFile(file_path, type) {
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path); if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
} }
// archive helper functions exports.durationStringToNumber = (dur_str) => {
async function removeIDFromArchive(archive_path, type, id) {
const archive_file = path.join(archive_path, `archive_${type}.txt`);
const data = await fs.readFile(archive_file, {encoding: 'utf-8'});
if (!data) {
logger.error('Archive could not be found.');
return;
}
let dataArray = data.split('\n'); // convert file data in an array
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
let lastIndex = -1; // let say, we have not found the keyword
for (let index=0; index<dataArray.length; index++) {
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
lastIndex = index; // found a line includes a id keyword
break;
}
}
if (lastIndex === -1) return null;
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
await fs.writeFile(archive_file, updatedData);
if (line) return Array.isArray(line) && line.length === 1 ? line[0] : line;
}
async function writeToBlacklist(archive_folder, type, line) {
let blacklistPath = path.join(archive_folder, (type === 'audio') ? 'blacklist_audio.txt' : 'blacklist_video.txt');
// adds newline to the beginning of the line
line.replace('\n', '');
line.replace('\r', '');
line = '\n' + line;
await fs.appendFile(blacklistPath, line);
}
async function deleteFileFromArchive(uid, type, archive_path, id, blacklistMode) {
const archive_file = path.join(archive_path, `archive_${type}.txt`);
if (await fs.pathExists(archive_path)) {
const line = id ? await removeIDFromArchive(archive_path, type, id) : null;
if (blacklistMode && line) await writeToBlacklist(archive_path, type, line);
} else {
logger.info(`Could not find archive file for file ${uid}. Creating...`);
await fs.close(await fs.open(archive_file, 'w'));
}
}
function durationStringToNumber(dur_str) {
if (typeof dur_str === 'number') return dur_str; if (typeof dur_str === 'number') return dur_str;
let num_sum = 0; let num_sum = 0;
const dur_str_parts = dur_str.split(':'); const dur_str_parts = dur_str.split(':');
@@ -281,23 +231,22 @@ function durationStringToNumber(dur_str) {
return num_sum; return num_sum;
} }
function getMatchingCategoryFiles(category, files) { exports.getMatchingCategoryFiles = (category, files) => {
return files && files.filter(file => file.category && file.category.uid === category.uid); return files && files.filter(file => file.category && file.category.uid === category.uid);
} }
function addUIDsToCategory(category, files) { exports.addUIDsToCategory = (category, files) => {
const files_that_match = getMatchingCategoryFiles(category, files); const files_that_match = exports.getMatchingCategoryFiles(category, files);
category['uids'] = files_that_match.map(file => file.uid); category['uids'] = files_that_match.map(file => file.uid);
return files_that_match; return files_that_match;
} }
function getCurrentDownloader() { exports.getCurrentDownloader = () => {
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH); const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
return details_json['downloader']; return details_json['downloader'];
} }
async function recFindByExt(base, ext, files, result, recursive = true) exports.recFindByExt = async (base, ext, files, result, recursive = true) => {
{
files = files || (await fs.readdir(base)) files = files || (await fs.readdir(base))
result = result || [] result = result || []
@@ -306,7 +255,7 @@ async function recFindByExt(base, ext, files, result, recursive = true)
if ( (await fs.stat(newbase)).isDirectory() ) if ( (await fs.stat(newbase)).isDirectory() )
{ {
if (!recursive) continue; if (!recursive) continue;
result = await recFindByExt(newbase,ext,await fs.readdir(newbase),result) result = await exports.recFindByExt(newbase,ext,await fs.readdir(newbase),result)
} }
else else
{ {
@@ -319,17 +268,17 @@ async function recFindByExt(base, ext, files, result, recursive = true)
return result return result
} }
function removeFileExtension(filename) { exports.removeFileExtension = (filename) => {
const filename_parts = filename.split('.'); const filename_parts = filename.split('.');
filename_parts.splice(filename_parts.length - 1); filename_parts.splice(filename_parts.length - 1);
return filename_parts.join('.'); return filename_parts.join('.');
} }
function formatDateString(date_string) { exports.formatDateString = (date_string) => {
return date_string ? `${date_string.substring(0, 4)}-${date_string.substring(4, 6)}-${date_string.substring(6, 8)}` : 'N/A'; return date_string ? `${date_string.substring(0, 4)}-${date_string.substring(4, 6)}-${date_string.substring(6, 8)}` : 'N/A';
} }
function createEdgeNGrams(str) { exports.createEdgeNGrams = (str) => {
if (str && str.length > 3) { if (str && str.length > 3) {
const minGram = 3 const minGram = 3
const maxGram = str.length const maxGram = str.length
@@ -351,7 +300,7 @@ function createEdgeNGrams(str) {
// ffmpeg helper functions // ffmpeg helper functions
async function cropFile(file_path, start, end, ext) { exports.cropFile = async (file_path, start, end, ext) => {
return new Promise(resolve => { return new Promise(resolve => {
const temp_file_path = `${file_path}.cropped${ext}`; const temp_file_path = `${file_path}.cropped${ext}`;
let base_ffmpeg_call = ffmpeg(file_path); let base_ffmpeg_call = ffmpeg(file_path);
@@ -380,13 +329,13 @@ async function cropFile(file_path, start, end, ext) {
* setTimeout, but its a promise. * setTimeout, but its a promise.
* @param {number} ms * @param {number} ms
*/ */
async function wait(ms) { exports.wait = async (ms) => {
await new Promise(resolve => { await new Promise(resolve => {
setTimeout(resolve, ms); setTimeout(resolve, ms);
}); });
} }
async function checkExistsWithTimeout(filePath, timeout) { exports.checkExistsWithTimeout = async (filePath, timeout) => {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
var timer = setTimeout(function () { var timer = setTimeout(function () {
@@ -415,7 +364,7 @@ async function checkExistsWithTimeout(filePath, timeout) {
} }
// helper function to download file using fetch // helper function to download file using fetch
async function fetchFile(url, path, file_label) { exports.fetchFile = async (url, path, file_label) => {
var len = null; var len = null;
const res = await fetch(url); const res = await fetch(url);
@@ -442,7 +391,7 @@ async function fetchFile(url, path, file_label) {
}); });
} }
async function restartServer(is_update = false) { exports.restartServer = async (is_update = false) => {
logger.info(`${is_update ? 'Update complete! ' : ''}Restarting server...`); logger.info(`${is_update ? 'Update complete! ' : ''}Restarting server...`);
// the following line restarts the server through pm2 // the following line restarts the server through pm2
@@ -455,7 +404,7 @@ async function restartServer(is_update = false) {
// - if already exists and doesn't have value, ignore // - if already exists and doesn't have value, ignore
// - if it doesn't exist and has value, add both arg and value // - if it doesn't exist and has value, add both arg and value
// - if it doesn't exist and doesn't have value, add arg // - if it doesn't exist and doesn't have value, add arg
function injectArgs(original_args, new_args) { exports.injectArgs = (original_args, new_args) => {
const updated_args = original_args.slice(); const updated_args = original_args.slice();
try { try {
for (let i = 0; i < new_args.length; i++) { for (let i = 0; i < new_args.length; i++) {
@@ -465,10 +414,11 @@ function injectArgs(original_args, new_args) {
if (CONSTS.YTDL_ARGS_WITH_VALUES.has(new_arg)) { if (CONSTS.YTDL_ARGS_WITH_VALUES.has(new_arg)) {
if (original_args.includes(new_arg)) { if (original_args.includes(new_arg)) {
const original_index = original_args.indexOf(new_arg); const original_index = original_args.indexOf(new_arg);
original_args.splice(original_index, 2); updated_args.splice(original_index, 2);
} }
updated_args.push(new_arg, new_args[i + 1]); updated_args.push(new_arg, new_args[i + 1]);
i++; // we need to skip the arg value on the next loop
} else { } else {
if (!original_args.includes(new_arg)) { if (!original_args.includes(new_arg)) {
updated_args.push(new_arg); updated_args.push(new_arg);
@@ -483,11 +433,11 @@ function injectArgs(original_args, new_args) {
return updated_args; return updated_args;
} }
function filterArgs(args, args_to_remove) { exports.filterArgs = (args, args_to_remove) => {
return args.filter(x => !args_to_remove.includes(x)); return args.filter(x => !args_to_remove.includes(x));
} }
const searchObjectByString = function(o, s) { exports.searchObjectByString = (o, s) => {
s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
s = s.replace(/^\./, ''); // strip a leading dot s = s.replace(/^\./, ''); // strip a leading dot
var a = s.split('.'); var a = s.split('.');
@@ -502,7 +452,7 @@ const searchObjectByString = function(o, s) {
return o; return o;
} }
function stripPropertiesFromObject(obj, properties, whitelist = false) { exports.stripPropertiesFromObject = (obj, properties, whitelist = false) => {
if (!whitelist) { if (!whitelist) {
const new_obj = JSON.parse(JSON.stringify(obj)); const new_obj = JSON.parse(JSON.stringify(obj));
for (let field of properties) { for (let field of properties) {
@@ -518,7 +468,7 @@ function stripPropertiesFromObject(obj, properties, whitelist = false) {
return new_obj; return new_obj;
} }
function getArchiveFolder(type, user_uid = null, sub = null) { exports.getArchiveFolder = (type, user_uid = null, sub = null) => {
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path'); const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
const subsFolderPath = config_api.getConfigItem('ytdl_subscriptions_base_path'); const subsFolderPath = config_api.getConfigItem('ytdl_subscriptions_base_path');
@@ -537,6 +487,49 @@ function getArchiveFolder(type, user_uid = null, sub = null) {
} }
} }
exports.getBaseURL = () => {
return `${config_api.getConfigItem('ytdl_url')}:${config_api.getConfigItem('ytdl_port')}`
}
exports.updateLoggerLevel = (new_logger_level) => {
const possible_levels = ['error', 'warn', 'info', 'verbose', 'debug'];
if (!possible_levels.includes(new_logger_level)) {
logger.error(`${new_logger_level} is not a valid logger level! Choose one of the following: ${possible_levels.join(', ')}.`)
new_logger_level = 'info';
}
logger.level = new_logger_level;
winston.loggers.get('console').level = new_logger_level;
logger.transports[2].level = new_logger_level;
}
exports.convertFlatObjectToNestedObject = (obj) => {
const result = {};
for (const key in obj) {
const nestedKeys = key.split('.');
let currentObj = result;
for (let i = 0; i < nestedKeys.length; i++) {
if (i === nestedKeys.length - 1) {
currentObj[nestedKeys[i]] = obj[key];
} else {
currentObj[nestedKeys[i]] = currentObj[nestedKeys[i]] || {};
currentObj = currentObj[nestedKeys[i]];
}
}
}
return result;
}
exports.getDirectoriesInDirectory = async (basePath) => {
try {
const files = await fs.readdir(basePath, { withFileTypes: true });
return files
.filter((file) => file.isDirectory())
.map((file) => path.join(basePath, file.name));
} catch (err) {
return [];
}
}
// objects // objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) { function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
@@ -554,38 +547,7 @@ function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, p
this.view_count = view_count; this.view_count = view_count;
this.height = height; this.height = height;
this.abr = abr; this.abr = abr;
this.favorite = false;
} }
exports.File = File;
module.exports = {
getJSONMp3: getJSONMp3,
getJSONMp4: getJSONMp4,
getJSON: getJSON,
getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail,
getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms,
deleteJSONFile: deleteJSONFile,
removeIDFromArchive: removeIDFromArchive,
writeToBlacklist: writeToBlacklist,
deleteFileFromArchive: deleteFileFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber,
getMatchingCategoryFiles: getMatchingCategoryFiles,
getCurrentDownloader: getCurrentDownloader,
recFindByExt: recFindByExt,
removeFileExtension: removeFileExtension,
formatDateString: formatDateString,
cropFile: cropFile,
createEdgeNGrams: createEdgeNGrams,
wait: wait,
checkExistsWithTimeout: checkExistsWithTimeout,
fetchFile: fetchFile,
restartServer: restartServer,
injectArgs: injectArgs,
filterArgs: filterArgs,
searchObjectByString: searchObjectByString,
stripPropertiesFromObject: stripPropertiesFromObject,
getArchiveFolder: getArchiveFolder,
File: File
}

View File

@@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "4.3" appVersion: "4.3.1"

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,8 @@ services:
- "8998:17442" - "8998:17442"
image: tzahi12345/youtubedl-material:latest image: tzahi12345/youtubedl-material:latest
ytdl-mongo-db: ytdl-mongo-db:
image: mongo # If you are using a Raspberry Pi, use mongo:4.4.18
image: mongo:4
logging: logging:
driver: "none" driver: "none"
container_name: mongo-db container_name: mongo-db

View File

@@ -0,0 +1,69 @@
import platform
import requests
import shutil
import os
import re
import sys
from collections import OrderedDict
from github import Github
machine = platform.machine()
# https://stackoverflow.com/questions/45125516/possible-values-for-uname-m
MACHINES_TO_ZIP = OrderedDict([
("x86_64", "Linux-x64"),
("aarch64", "LinuxArm64"),
("armv8", "LinuxArm64"),
("arm", "LinuxArm"),
("AMD64", "Windows-x64")
])
def getZipName():
for possibleMachine, possibleZipName in MACHINES_TO_ZIP.items():
if possibleMachine in machine:
return possibleZipName
def getLatestFileInRepo(repo, search_string):
# Create an unauthenticated instance of the Github object
g = Github(os.environ.get('GH_TOKEN'))
# Replace with the repository owner and name
repo = g.get_repo(repo)
# Get all releases of the repository
releases = repo.get_releases()
# Loop through the releases in reverse order (from latest to oldest)
for release in list(releases):
# Get the release assets (files attached to the release)
assets = release.get_assets()
# Loop through the assets
for asset in assets:
if re.search(search_string, asset.name):
print(f'Downloading: {asset.name}')
response = requests.get(asset.browser_download_url)
with open(asset.name, 'wb') as f:
f.write(response.content)
print(f'Download complete: {asset.name}. Unzipping...')
shutil.unpack_archive(asset.name, './')
print(f'Unzipping complete!')
os.remove(asset.name)
break
else:
continue
break
else:
# If no matching release is found, print a message
print(f'No release found with {search_string}')
def getLatestCLIRelease():
zipName = getZipName()
if not zipName:
print(f"GetTwitchDownloader.py could not get valid path for '{machine}'. Exiting...")
sys.exit(1)
searchString = r'.*CLI.*' + zipName
getLatestFileInRepo("lay295/TwitchDownloader", searchString)
getLatestCLIRelease()

View File

@@ -0,0 +1,39 @@
#!/bin/sh
# THANK YOU TALULAH (https://github.com/nottalulah) for your help in figuring this out
# and also optimizing some code with this commit.
# xoxo :D
case $(uname -m) in
x86_64)
ARCH=Linux-x64;;
aarch64)
ARCH=LinuxArm64;;
armhf)
ARCH=LinuxArm;;
armv7)
ARCH=LinuxArm;;
armv7l)
ARCH=LinuxArm;;
*)
echo "Unsupported architecture: $(uname -m)"
exit 1
esac
echo "(INFO) Architecture detected: $ARCH"
echo "(1/5) READY - Install unzip"
apt-get update && apt-get -y install unzip curl jq libicu70
VERSION=$(curl --silent "https://api.github.com/repos/lay295/TwitchDownloader/releases" | jq -r --arg arch "$ARCH" '[.[] | select(.assets | length > 0) | select(.assets[].name | contains("CLI") and contains($arch))] | max_by(.published_at) | .tag_name')
echo "(2/5) DOWNLOAD - Acquire twitchdownloader"
curl -o twitchdownloader.zip \
--connect-timeout 5 \
--max-time 120 \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \
-L "https://github.com/lay295/TwitchDownloader/releases/download/$VERSION/TwitchDownloaderCLI-$VERSION-$ARCH.zip"
unzip twitchdownloader.zip
chmod +x TwitchDownloaderCLI
echo "(3/5) Smoke test"
./TwitchDownloaderCLI --help
cp ./TwitchDownloaderCLI /usr/local/bin/TwitchDownloaderCLI

View File

@@ -26,11 +26,11 @@ apt-get update && apt-get -y install curl xz-utils
echo "(2/5) DOWNLOAD - Acquire latest ffmpeg and ffprobe from John van Sickle's master-sourced builds in ffmpeg obtain layer" echo "(2/5) DOWNLOAD - Acquire latest ffmpeg and ffprobe from John van Sickle's master-sourced builds in ffmpeg obtain layer"
curl -o ffmpeg.txz \ curl -o ffmpeg.txz \
--connect-timeout 5 \ --connect-timeout 5 \
--max-time 10 \ --max-time 120 \
--retry 5 \ --retry 5 \
--retry-delay 0 \ --retry-delay 0 \
--retry-max-time 40 \ --retry-max-time 40 \
"https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-${ARCH}-static.tar.xz" "https://johnvansickle.com/ffmpeg/old-releases/ffmpeg-5.1.1-${ARCH}-static.tar.xz"
mkdir /tmp/ffmpeg mkdir /tmp/ffmpeg
tar xf ffmpeg.txz -C /tmp/ffmpeg tar xf ffmpeg.txz -C /tmp/ffmpeg
echo "(3/5) CLEANUP - Remove temp dependencies from ffmpeg obtain layer" echo "(3/5) CLEANUP - Remove temp dependencies from ffmpeg obtain layer"

7827
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "youtube-dl-material", "name": "youtube-dl-material",
"version": "4.3.0", "version": "4.3.1",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
@@ -21,62 +21,60 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular-devkit/core": "^13.3.3", "@angular-devkit/core": "^15.0.1",
"@angular/animations": "^13.3.4", "@angular/animations": "^15.0.1",
"@angular/cdk": "^13.3.4", "@angular/cdk": "^15.0.0",
"@angular/common": "^13.3.4", "@angular/common": "^15.0.1",
"@angular/compiler": "^13.3.4", "@angular/compiler": "^15.0.1",
"@angular/core": "^13.3.4", "@angular/core": "^15.0.1",
"@angular/forms": "^13.3.4", "@angular/forms": "^15.0.1",
"@angular/localize": "^13.3.4", "@angular/localize": "^15.0.1",
"@angular/material": "^13.3.4", "@angular/material": "^15.0.0",
"@angular/platform-browser": "^13.3.4", "@angular/platform-browser": "^15.0.1",
"@angular/platform-browser-dynamic": "^13.3.4", "@angular/platform-browser-dynamic": "^15.0.1",
"@angular/router": "^13.3.4", "@angular/router": "^15.0.1",
"@fontsource/material-icons": "^4.5.4", "@fontsource/material-icons": "^4.5.4",
"@ngneat/content-loader": "^5.0.0", "@ngneat/content-loader": "^7.0.0",
"@videogular/ngx-videogular": "^5.0.1", "@videogular/ngx-videogular": "^6.0.0",
"core-js": "^2.4.1", "core-js": "^2.4.1",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"filesize": "^6.1.0", "filesize": "^10.0.7",
"fingerprintjs2": "^2.1.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"material-icons": "^1.10.8", "material-icons": "^1.10.8",
"nan": "^2.14.1", "nan": "^2.14.1",
"ng-lazyload-image": "^7.0.1", "ngx-avatars": "^1.4.1",
"ngx-avatars": "^1.3.1", "ngx-file-drop": "^15.0.0",
"ngx-file-drop": "^13.0.0",
"rxjs": "^6.6.3", "rxjs": "^6.6.3",
"rxjs-compat": "^6.0.0-rc.0", "rxjs-compat": "^6.6.7",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"typescript": "~4.6.3", "typescript": "~4.8.4",
"xliff-to-json": "^1.0.4", "xliff-to-json": "^1.0.4",
"zone.js": "~0.11.4" "zone.js": "~0.11.4"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^13.3.3", "@angular-devkit/build-angular": "^15.0.1",
"@angular/cli": "^13.3.3", "@angular/cli": "^15.0.1",
"@angular/compiler-cli": "^13.3.4", "@angular/compiler-cli": "^15.0.1",
"@angular/language-service": "^13.3.4", "@angular/language-service": "^15.0.1",
"@types/core-js": "^2.5.2", "@types/core-js": "^2.5.2",
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",
"@types/jasmine": "~3.6.0", "@types/jasmine": "^4.3.1",
"@types/node": "^12.11.1", "@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0", "@typescript-eslint/parser": "^4.29.0",
"ajv": "^7.2.4",
"codelyzer": "^6.0.0", "codelyzer": "^6.0.0",
"electron": "^19.0.6",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"jasmine-core": "~3.6.0", "jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0", "jasmine-spec-reporter": "~5.0.0",
"karma": "~6.3.16", "karma": "~6.4.2",
"karma-chrome-launcher": "~3.1.0", "karma-chrome-launcher": "~3.1.0",
"karma-cli": "~1.0.1", "karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "~3.0.2", "karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.5.0",
"openapi-typescript-codegen": "^0.21.0", "openapi-typescript-codegen": "^0.23.0",
"protractor": "~7.0.0", "protractor": "~7.0.0",
"ts-node": "~3.0.4", "ts-node": "~3.0.4",
"tslint": "~6.1.0" "tslint": "~6.1.0"

View File

@@ -3,6 +3,7 @@
/* eslint-disable */ /* eslint-disable */
export type { AddFileToPlaylistRequest } from './models/AddFileToPlaylistRequest'; export type { AddFileToPlaylistRequest } from './models/AddFileToPlaylistRequest';
export type { Archive } from './models/Archive';
export type { BaseChangePermissionsRequest } from './models/BaseChangePermissionsRequest'; export type { BaseChangePermissionsRequest } from './models/BaseChangePermissionsRequest';
export type { binary } from './models/binary'; export type { binary } from './models/binary';
export type { body_19 } from './models/body_19'; export type { body_19 } from './models/body_19';
@@ -26,8 +27,10 @@ export type { DatabaseFile } from './models/DatabaseFile';
export { DBBackup } from './models/DBBackup'; export { DBBackup } from './models/DBBackup';
export type { DBInfoResponse } from './models/DBInfoResponse'; export type { DBInfoResponse } from './models/DBInfoResponse';
export type { DeleteAllFilesResponse } from './models/DeleteAllFilesResponse'; export type { DeleteAllFilesResponse } from './models/DeleteAllFilesResponse';
export type { DeleteArchiveItemsRequest } from './models/DeleteArchiveItemsRequest';
export type { DeleteCategoryRequest } from './models/DeleteCategoryRequest'; export type { DeleteCategoryRequest } from './models/DeleteCategoryRequest';
export type { DeleteMp3Mp4Request } from './models/DeleteMp3Mp4Request'; export type { DeleteMp3Mp4Request } from './models/DeleteMp3Mp4Request';
export type { DeleteNotificationRequest } from './models/DeleteNotificationRequest';
export type { DeletePlaylistRequest } from './models/DeletePlaylistRequest'; export type { DeletePlaylistRequest } from './models/DeletePlaylistRequest';
export type { DeleteSubscriptionFileRequest } from './models/DeleteSubscriptionFileRequest'; export type { DeleteSubscriptionFileRequest } from './models/DeleteSubscriptionFileRequest';
export type { DeleteUserRequest } from './models/DeleteUserRequest'; export type { DeleteUserRequest } from './models/DeleteUserRequest';
@@ -50,6 +53,8 @@ export type { GetAllFilesRequest } from './models/GetAllFilesRequest';
export type { GetAllFilesResponse } from './models/GetAllFilesResponse'; export type { GetAllFilesResponse } from './models/GetAllFilesResponse';
export type { GetAllSubscriptionsResponse } from './models/GetAllSubscriptionsResponse'; export type { GetAllSubscriptionsResponse } from './models/GetAllSubscriptionsResponse';
export type { GetAllTasksResponse } from './models/GetAllTasksResponse'; export type { GetAllTasksResponse } from './models/GetAllTasksResponse';
export type { GetArchivesRequest } from './models/GetArchivesRequest';
export type { GetArchivesResponse } from './models/GetArchivesResponse';
export type { GetDBBackupsResponse } from './models/GetDBBackupsResponse'; export type { GetDBBackupsResponse } from './models/GetDBBackupsResponse';
export type { GetDownloadRequest } from './models/GetDownloadRequest'; export type { GetDownloadRequest } from './models/GetDownloadRequest';
export type { GetDownloadResponse } from './models/GetDownloadResponse'; export type { GetDownloadResponse } from './models/GetDownloadResponse';
@@ -63,6 +68,7 @@ export type { GetLogsRequest } from './models/GetLogsRequest';
export type { GetLogsResponse } from './models/GetLogsResponse'; export type { GetLogsResponse } from './models/GetLogsResponse';
export type { GetMp3sResponse } from './models/GetMp3sResponse'; export type { GetMp3sResponse } from './models/GetMp3sResponse';
export type { GetMp4sResponse } from './models/GetMp4sResponse'; export type { GetMp4sResponse } from './models/GetMp4sResponse';
export type { GetNotificationsResponse } from './models/GetNotificationsResponse';
export type { GetPlaylistRequest } from './models/GetPlaylistRequest'; export type { GetPlaylistRequest } from './models/GetPlaylistRequest';
export type { GetPlaylistResponse } from './models/GetPlaylistResponse'; export type { GetPlaylistResponse } from './models/GetPlaylistResponse';
export type { GetPlaylistsRequest } from './models/GetPlaylistsRequest'; export type { GetPlaylistsRequest } from './models/GetPlaylistsRequest';
@@ -73,16 +79,22 @@ export type { GetSubscriptionResponse } from './models/GetSubscriptionResponse';
export type { GetTaskRequest } from './models/GetTaskRequest'; export type { GetTaskRequest } from './models/GetTaskRequest';
export type { GetTaskResponse } from './models/GetTaskResponse'; export type { GetTaskResponse } from './models/GetTaskResponse';
export type { GetUsersResponse } from './models/GetUsersResponse'; export type { GetUsersResponse } from './models/GetUsersResponse';
export type { ImportArchiveRequest } from './models/ImportArchiveRequest';
export type { IncrementViewCountRequest } from './models/IncrementViewCountRequest'; export type { IncrementViewCountRequest } from './models/IncrementViewCountRequest';
export type { inline_response_200_15 } from './models/inline_response_200_15'; export type { inline_response_200_15 } from './models/inline_response_200_15';
export type { LoginRequest } from './models/LoginRequest'; export type { LoginRequest } from './models/LoginRequest';
export type { LoginResponse } from './models/LoginResponse'; export type { LoginResponse } from './models/LoginResponse';
export type { Notification } from './models/Notification';
export { NotificationAction } from './models/NotificationAction';
export { NotificationType } from './models/NotificationType';
export type { Playlist } from './models/Playlist'; export type { Playlist } from './models/Playlist';
export type { RegisterRequest } from './models/RegisterRequest'; export type { RegisterRequest } from './models/RegisterRequest';
export type { RegisterResponse } from './models/RegisterResponse'; export type { RegisterResponse } from './models/RegisterResponse';
export type { RestartDownloadResponse } from './models/RestartDownloadResponse';
export type { RestoreDBBackupRequest } from './models/RestoreDBBackupRequest'; export type { RestoreDBBackupRequest } from './models/RestoreDBBackupRequest';
export { Schedule } from './models/Schedule'; export { Schedule } from './models/Schedule';
export type { SetConfigRequest } from './models/SetConfigRequest'; export type { SetConfigRequest } from './models/SetConfigRequest';
export type { SetNotificationsToReadRequest } from './models/SetNotificationsToReadRequest';
export type { SharingToggle } from './models/SharingToggle'; export type { SharingToggle } from './models/SharingToggle';
export type { Sort } from './models/Sort'; export type { Sort } from './models/Sort';
export type { SubscribeRequest } from './models/SubscribeRequest'; export type { SubscribeRequest } from './models/SubscribeRequest';
@@ -92,6 +104,7 @@ export type { SubscriptionRequestData } from './models/SubscriptionRequestData';
export type { SuccessObject } from './models/SuccessObject'; export type { SuccessObject } from './models/SuccessObject';
export type { TableInfo } from './models/TableInfo'; export type { TableInfo } from './models/TableInfo';
export type { Task } from './models/Task'; export type { Task } from './models/Task';
export { TaskType } from './models/TaskType';
export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest'; export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest';
export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse'; export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse';
export type { TransferDBRequest } from './models/TransferDBRequest'; export type { TransferDBRequest } from './models/TransferDBRequest';
@@ -108,8 +121,10 @@ export type { UpdatePlaylistRequest } from './models/UpdatePlaylistRequest';
export type { UpdaterStatus } from './models/UpdaterStatus'; export type { UpdaterStatus } from './models/UpdaterStatus';
export type { UpdateServerRequest } from './models/UpdateServerRequest'; export type { UpdateServerRequest } from './models/UpdateServerRequest';
export type { UpdateTaskDataRequest } from './models/UpdateTaskDataRequest'; export type { UpdateTaskDataRequest } from './models/UpdateTaskDataRequest';
export type { UpdateTaskOptionsRequest } from './models/UpdateTaskOptionsRequest';
export type { UpdateTaskScheduleRequest } from './models/UpdateTaskScheduleRequest'; export type { UpdateTaskScheduleRequest } from './models/UpdateTaskScheduleRequest';
export type { UpdateUserRequest } from './models/UpdateUserRequest'; export type { UpdateUserRequest } from './models/UpdateUserRequest';
export type { UploadCookiesRequest } from './models/UploadCookiesRequest';
export type { User } from './models/User'; export type { User } from './models/User';
export { UserPermission } from './models/UserPermission'; export { UserPermission } from './models/UserPermission';
export type { Version } from './models/Version'; export type { Version } from './models/Version';

View File

@@ -0,0 +1,16 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type Archive = {
extractor: string;
id: string;
type: FileType;
title: string;
user_uid?: string;
sub_id?: string;
timestamp: number;
uid: string;
};

View File

@@ -14,5 +14,6 @@ subscriptions?: TableInfo;
users?: TableInfo; users?: TableInfo;
roles?: TableInfo; roles?: TableInfo;
download_queue?: TableInfo; download_queue?: TableInfo;
archives?: TableInfo;
}; };
}; };

View File

@@ -26,6 +26,7 @@ export type DatabaseFile = {
path: string; path: string;
upload_date: string; upload_date: string;
uid: string; uid: string;
user_uid?: string;
sharingEnabled?: boolean; sharingEnabled?: boolean;
category?: Category; category?: Category;
view_count?: number; view_count?: number;
@@ -40,4 +41,5 @@ export type DatabaseFile = {
* In Kbps * In Kbps
*/ */
abr?: number; abr?: number;
favorite: boolean;
}; };

View File

@@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Archive } from './Archive';
export type DeleteArchiveItemsRequest = {
archives: Array<Archive>;
};

View File

@@ -0,0 +1,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type DeleteNotificationRequest = {
uid: string;
};

View File

@@ -2,12 +2,8 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { SubscriptionRequestData } from './SubscriptionRequestData';
export type DeleteSubscriptionFileRequest = { export type DeleteSubscriptionFileRequest = {
file: string; file_uid: string;
file_uid?: string;
sub: SubscriptionRequestData;
/** /**
* If true, does not remove id from archive. Only valid if youtube-dl archive is enabled in settings. * If true, does not remove id from archive. Only valid if youtube-dl archive is enabled in settings.
*/ */

View File

@@ -19,6 +19,10 @@ export type Download = {
* Error text, set if download fails. * Error text, set if download fails.
*/ */
error?: string | null; error?: string | null;
/**
* Error type, may or may not be set in case of an error
*/
error_type?: string | null;
user_uid?: string; user_uid?: string;
sub_id?: string; sub_id?: string;
sub_name?: string; sub_name?: string;

View File

@@ -2,8 +2,9 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { FileType } from './FileType';
export type DownloadArchiveRequest = { export type DownloadArchiveRequest = {
sub: { type?: FileType;
archive_dir: string; sub_id?: string;
};
}; };

View File

@@ -35,10 +35,18 @@ export type DownloadRequest = {
* Height of the video, if known * Height of the video, if known
*/ */
selectedHeight?: string; selectedHeight?: string;
/**
* Max height that should be used, useful for playlists. selectedHeight will override this.
*/
maxHeight?: string;
/** /**
* Specify ffmpeg/avconv audio quality * Specify ffmpeg/avconv audio quality
*/ */
maxBitrate?: string; maxBitrate?: string;
type?: FileType; type?: FileType;
cropFileSettings?: CropFileSettings; cropFileSettings?: CropFileSettings;
/**
* If using youtube-dl archive, download will ignore it
*/
ignoreArchive?: boolean;
}; };

View File

@@ -13,6 +13,10 @@ export type GetAllFilesRequest = {
*/ */
text_search?: string; text_search?: string;
file_type_filter?: FileTypeFilter; file_type_filter?: FileTypeFilter;
/**
* If set to true, only gets favorites
*/
favorite_filter?: boolean;
/** /**
* Include if you want to filter by subscription * Include if you want to filter by subscription
*/ */

View File

@@ -0,0 +1,10 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type GetArchivesRequest = {
type?: FileType;
sub_id?: string;
};

View File

@@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Archive } from './Archive';
export type GetArchivesResponse = {
archives: Array<Archive>;
};

View File

@@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Notification } from './Notification';
export type GetNotificationsResponse = {
notifications?: Array<Notification>;
};

View File

@@ -2,6 +2,8 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { TaskType } from './TaskType';
export type GetTaskRequest = { export type GetTaskRequest = {
task_key: string; task_key: TaskType;
}; };

View File

@@ -0,0 +1,11 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type ImportArchiveRequest = {
archive: string;
type: FileType;
sub_id?: string;
};

View File

@@ -0,0 +1,16 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { NotificationAction } from './NotificationAction';
import type { NotificationType } from './NotificationType';
export type Notification = {
type: NotificationType;
uid: string;
user_uid?: string;
action?: Array<NotificationAction>;
read: boolean;
data?: any;
timestamp: number;
};

View File

@@ -0,0 +1,10 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum NotificationAction {
PLAY = 'play',
RETRY_DOWNLOAD = 'retry_download',
VIEW_DOWNLOAD_ERROR = 'view_download_error',
VIEW_TASKS = 'view_tasks',
}

View File

@@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum NotificationType {
DOWNLOAD_COMPLETE = 'download_complete',
DOWNLOAD_ERROR = 'download_error',
TASK_FINISHED = 'task_finished',
}

View File

@@ -14,4 +14,5 @@ export type Playlist = {
duration: number; duration: number;
user_uid?: string; user_uid?: string;
auto?: boolean; auto?: boolean;
sharingEnabled?: boolean;
}; };

View File

@@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { SuccessObject } from './SuccessObject';
export type RestartDownloadResponse = (SuccessObject & {
new_download_uid?: string;
});

View File

@@ -9,6 +9,7 @@ dayOfWeek?: Array<number>;
hour?: number; hour?: number;
minute?: number; minute?: number;
timestamp?: number; timestamp?: number;
tz?: string;
}; };
}; };

View File

@@ -0,0 +1,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type SetNotificationsToReadRequest = {
uids: Array<string>;
};

View File

@@ -2,8 +2,11 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { Schedule } from './Schedule';
import type { TaskType } from './TaskType';
export type Task = { export type Task = {
key: string; key: TaskType;
title?: string; title?: string;
last_ran: number; last_ran: number;
last_confirmed: number; last_confirmed: number;
@@ -11,5 +14,6 @@ export type Task = {
confirming: boolean; confirming: boolean;
data: any; data: any;
error: string; error: string;
schedule: any; schedule: Schedule;
options?: any;
}; };

View File

@@ -0,0 +1,14 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum TaskType {
BACKUP_LOCAL_DB = 'backup_local_db',
MISSING_FILES_CHECK = 'missing_files_check',
MISSING_DB_RECORDS = 'missing_db_records',
DUPLICATE_FILES_CHECK = 'duplicate_files_check',
YOUTUBEDL_UPDATE_CHECK = 'youtubedl_update_check',
DELETE_OLD_FILES = 'delete_old_files',
IMPORT_LEGACY_ARCHIVES = 'import_legacy_archives',
REBUILD_DATABASE = 'rebuild_database',
}

View File

@@ -2,7 +2,9 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
import type { TaskType } from './TaskType';
export type UpdateTaskDataRequest = { export type UpdateTaskDataRequest = {
task_key: string; task_key: TaskType;
new_data: any; new_data: any;
}; };

View File

@@ -0,0 +1,10 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { TaskType } from './TaskType';
export type UpdateTaskOptionsRequest = {
task_key: TaskType;
new_options: any;
};

View File

@@ -3,8 +3,9 @@
/* eslint-disable */ /* eslint-disable */
import type { Schedule } from './Schedule'; import type { Schedule } from './Schedule';
import type { TaskType } from './TaskType';
export type UpdateTaskScheduleRequest = { export type UpdateTaskScheduleRequest = {
task_key: string; task_key: TaskType;
new_schedule: Schedule; new_schedule: Schedule;
}; };

View File

@@ -0,0 +1,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UploadCookiesRequest = {
cookies: Blob;
};

View File

@@ -9,4 +9,5 @@ export enum UserPermission {
SHARING = 'sharing', SHARING = 'sharing',
ADVANCED_DOWNLOAD = 'advanced_download', ADVANCED_DOWNLOAD = 'advanced_download',
DOWNLOADS_MANAGER = 'downloads_manager', DOWNLOADS_MANAGER = 'downloads_manager',
TASKS_MANAGER = 'tasks_manager',
} }

View File

@@ -23,7 +23,7 @@ const routes: Routes = [
]; ];
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes, { useHash: true, relativeLinkResolution: 'legacy' })], imports: [RouterModule.forRoot(routes, { useHash: true })],
exports: [RouterModule] exports: [RouterModule]
}) })
export class AppRoutingModule { } export class AppRoutingModule { }

View File

@@ -13,7 +13,6 @@
} }
.theme-slide-toggle { .theme-slide-toggle {
top: 2px;
left: 10px; left: 10px;
position: relative; position: relative;
pointer-events: none; pointer-events: none;
@@ -25,4 +24,20 @@
.top-toolbar { .top-toolbar {
height: 64px; height: 64px;
background: unset;
}
::ng-deep .top-menu-button > span {
width: 85px;
height: 26px;
}
::ng-deep .mdc-switch {
outline: none !important;
}
::ng-deep .notifications-menu {
width: 30vw !important;
max-width: 100% !important;
min-width: 280px !important;
} }

View File

@@ -1,37 +1,41 @@
<div [style.background]="postsService.theme ? postsService.theme.background_color : null" style="width: 100%; height: 100%;"> <div [style.background]="postsService.theme ? postsService.theme.background_color : null" style="width: 100%; height: 100%;">
<div class="mat-elevation-z3" style="position: relative; z-index: 10;"> <div class="mat-elevation-z3" style="position: relative; z-index: 10;">
<mat-toolbar color="primary" class="sticky-toolbar top-toolbar"> <mat-toolbar class="sticky-toolbar top-toolbar">
<div class="flex-row" width="100%" height="100%"> <div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
<div class="flex-column" style="text-align: left; margin-top: 1px;"> <div class="row" width="100%" height="100%">
<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> <div class="col-6" style="text-align: left; margin-top: 1px;">
<button (click)="goBack()" *ngIf="router.url.split(';')[0] === '/player'" mat-icon-button><mat-icon>arrow_back</mat-icon></button> <div style="display: flex; align-items: center;">
</div> <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>
<div class="flex-column" style="text-align: center; margin-top: 5px;"> <button (click)="goBack()" *ngIf="router.url.split(';')[0] === '/player'" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
<div style="font-size: 22px; text-shadow: #141414 0.25px 0.25px 1px;"> <div style="margin-left: 8px; display: inline-block;"><button mat-icon-button routerLink='/home'><img style="width: 32px;" src="assets/images/logo_128px.png"></button></div>
{{topBarTitle}} </div>
</div>
<div class="col-6" style="text-align: right; align-items: flex-end; display: inline-block">
<button *ngIf="postsService.config?.Extra.enable_notifications" [matMenuTriggerFor]="notificationsMenu" (menuOpened)="notificationMenuOpened()" mat-icon-button><mat-icon [matBadge]="notification_count" matBadgeColor="warn" matBadgeSize="small" *ngIf="notification_count > 0">notifications</mat-icon><mat-icon *ngIf="notification_count === 0">notifications_none</mat-icon></button>
<mat-menu [classList]="'notifications-menu'" (close)="notificationMenuClosed()" #notificationsMenu="matMenu">
<app-notifications #notifications (notificationCount)="notificationCountUpdate($event)" (click)="$event.stopPropagation()"></app-notifications>
</mat-menu>
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #menuSettings="matMenu">
<button class="top-menu-button" (click)="openProfileDialog()" *ngIf="postsService.isLoggedIn" mat-menu-item>
<mat-icon>person</mat-icon>
<span i18n="Profile menu label">Profile</span>
</button>
<button class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
<mat-icon>topic</mat-icon>
<span i18n="Archives menu label">Archives</span>
</button>
<button class="top-menu-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 class="top-menu-button" (click)="openAboutDialog()" mat-menu-item>
<mat-icon>info</mat-icon>
<span i18n="About menu label">About</span>
</button>
</mat-menu>
</div> </div>
</div>
<div class="flex-column" style="text-align: right; align-items: flex-end;">
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #menuSettings="matMenu">
<button (click)="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 *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('settings')))" (click)="openSettingsDialog()" mat-menu-item>
<mat-icon>settings</mat-icon>
<span i18n="Settings menu label">Settings</span>
</button> -->
<button (click)="openAboutDialog()" mat-menu-item>
<mat-icon>info</mat-icon>
<span i18n="About menu label">About</span>
</button>
</mat-menu>
</div> </div>
</div> </div>
</mat-toolbar> </mat-toolbar>
@@ -51,7 +55,7 @@
</ng-container> </ng-container>
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && postsService.hasPermission('subscriptions')"> <ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && postsService.hasPermission('subscriptions')">
<mat-divider *ngIf="postsService.subscriptions.length > 0"></mat-divider> <mat-divider *ngIf="postsService.subscriptions.length > 0"></mat-divider>
<a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatars [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatars>{{subscription.name}}</a> <a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatars [style.display]="'inline-block'" [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatars>{{subscription.name}}</a>
</ng-container> </ng-container>
</mat-nav-list> </mat-nav-list>
</mat-sidenav> </mat-sidenav>

View File

@@ -20,6 +20,8 @@ import { SettingsComponent } from './settings/settings.component';
import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.component'; import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.component';
import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-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'; import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component';
import { NotificationsComponent } from './components/notifications/notifications.component';
import { ArchiveViewerComponent } from './components/archive-viewer/archive-viewer.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -45,9 +47,12 @@ export class AppComponent implements OnInit, AfterViewInit {
enableDownloadsManager = false; enableDownloadsManager = false;
@ViewChild('sidenav') sidenav: MatSidenav; @ViewChild('sidenav') sidenav: MatSidenav;
@ViewChild('notifications') notifications: NotificationsComponent;
@ViewChild('hamburgerMenu', { read: ElementRef }) hamburgerMenuButton: ElementRef; @ViewChild('hamburgerMenu', { read: ElementRef }) hamburgerMenuButton: ElementRef;
navigator: string = null; navigator: string = null;
notification_count = 0;
constructor(public postsService: PostsService, public snackBar: MatSnackBar, private dialog: MatDialog, constructor(public postsService: PostsService, public snackBar: MatSnackBar, private dialog: MatDialog,
public router: Router, public overlayContainer: OverlayContainer, private elementRef: ElementRef) { public router: Router, public overlayContainer: OverlayContainer, private elementRef: ElementRef) {
@@ -71,7 +76,7 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
ngOnInit() { ngOnInit(): void {
if (localStorage.getItem('theme')) { if (localStorage.getItem('theme')) {
this.setTheme(localStorage.getItem('theme')); this.setTheme(localStorage.getItem('theme'));
} }
@@ -79,8 +84,8 @@ export class AppComponent implements OnInit, AfterViewInit {
this.postsService.open_create_default_admin_dialog.subscribe(open => { this.postsService.open_create_default_admin_dialog.subscribe(open => {
if (open) { if (open) {
const dialogRef = this.dialog.open(SetDefaultAdminDialogComponent); const dialogRef = this.dialog.open(SetDefaultAdminDialogComponent);
dialogRef.afterClosed().subscribe(success => { dialogRef.afterClosed().subscribe(res => {
if (success) { if (!res || !res['user']) {
if (this.router.url !== '/login') { this.router.navigate(['/login']); } if (this.router.url !== '/login') { this.router.navigate(['/login']); }
} else { } else {
console.error('Failed to create default admin account. See logs for details.'); console.error('Failed to create default admin account. See logs for details.');
@@ -90,15 +95,15 @@ export class AppComponent implements OnInit, AfterViewInit {
}); });
} }
ngAfterViewInit() { ngAfterViewInit(): void {
this.postsService.sidenav = this.sidenav; this.postsService.sidenav = this.sidenav;
} }
toggleSidenav() { toggleSidenav(): void {
this.sidenav.toggle(); this.sidenav.toggle();
} }
loadConfig() { loadConfig(): void {
// loading config // loading config
this.topBarTitle = this.postsService.config['Extra']['title_top']; this.topBarTitle = this.postsService.config['Extra']['title_top'];
const themingExists = this.postsService.config['Themes']; const themingExists = this.postsService.config['Themes'];
@@ -164,7 +169,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.componentCssClass = theme; this.componentCssClass = theme;
} }
flipTheme() { flipTheme(): void {
if (this.postsService.theme.key === 'default') { if (this.postsService.theme.key === 'default') {
this.setTheme('dark'); this.setTheme('dark');
} else if (this.postsService.theme.key === 'dark') { } else if (this.postsService.theme.key === 'dark') {
@@ -172,17 +177,12 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
} }
themeMenuItemClicked(event) { themeMenuItemClicked(event): void {
this.flipTheme(); this.flipTheme();
event.stopPropagation(); event.stopPropagation();
} }
getSubscriptions() { goBack(): void {
}
goBack() {
if (!this.navigator) { if (!this.navigator) {
this.router.navigate(['/home']); this.router.navigate(['/home']);
} else { } else {
@@ -190,23 +190,41 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
} }
openSettingsDialog() { openSettingsDialog(): void {
const dialogRef = this.dialog.open(SettingsComponent, { this.dialog.open(SettingsComponent, {
width: '80vw' width: '80vw'
}); });
} }
openAboutDialog() { openAboutDialog(): void {
const dialogRef = this.dialog.open(AboutDialogComponent, { this.dialog.open(AboutDialogComponent, {
width: '80vw' width: '80vw'
}); });
} }
openProfileDialog() { openProfileDialog(): void {
const dialogRef = this.dialog.open(UserProfileDialogComponent, { this.dialog.open(UserProfileDialogComponent, {
width: '60vw' width: '60vw'
}); });
} }
openArchivesDialog(): void {
this.dialog.open(ArchiveViewerComponent, {
width: '85vw'
});
}
notificationCountUpdate(new_count: number): void {
this.notification_count = new_count;
}
notificationMenuOpened(): void {
this.notifications.getNotifications();
}
notificationMenuClosed(): void {
this.notifications.setNotificationsToRead();
}
} }

View File

@@ -29,6 +29,8 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort'; import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatChipsModule } from '@angular/material/chips';
import { MatBadgeModule } from '@angular/material/badge';
import { DragDropModule } from '@angular/cdk/drag-drop'; import { DragDropModule } from '@angular/cdk/drag-drop';
import { ClipboardModule } from '@angular/cdk/clipboard'; import { ClipboardModule } from '@angular/cdk/clipboard';
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
@@ -51,7 +53,6 @@ import { SubscribeDialogComponent } from './dialogs/subscribe-dialog/subscribe-d
import { SubscriptionComponent } from './subscription//subscription/subscription.component'; import { SubscriptionComponent } from './subscription//subscription/subscription.component';
import { SubscriptionInfoDialogComponent } from './dialogs/subscription-info-dialog/subscription-info-dialog.component'; import { SubscriptionInfoDialogComponent } from './dialogs/subscription-info-dialog/subscription-info-dialog.component';
import { SettingsComponent } from './settings/settings.component'; import { SettingsComponent } from './settings/settings.component';
import { MatChipsModule } from '@angular/material/chips';
import { NgxFileDropModule } from 'ngx-file-drop'; import { NgxFileDropModule } from 'ngx-file-drop';
import { AvatarModule } from 'ngx-avatars'; import { AvatarModule } from 'ngx-avatars';
import { ContentLoaderModule } from '@ngneat/content-loader'; import { ContentLoaderModule } from '@ngneat/content-loader';
@@ -87,6 +88,13 @@ import { SkipAdButtonComponent } from './components/skip-ad-button/skip-ad-butto
import { TasksComponent } from './components/tasks/tasks.component'; import { TasksComponent } from './components/tasks/tasks.component';
import { UpdateTaskScheduleDialogComponent } from './dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component'; import { UpdateTaskScheduleDialogComponent } from './dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component';
import { RestoreDbDialogComponent } from './dialogs/restore-db-dialog/restore-db-dialog.component'; import { RestoreDbDialogComponent } from './dialogs/restore-db-dialog/restore-db-dialog.component';
import { NotificationsComponent } from './components/notifications/notifications.component';
import { NotificationsListComponent } from './components/notifications-list/notifications-list.component';
import { TaskSettingsComponent } from './components/task-settings/task-settings.component';
import { GenerateRssUrlComponent } from './dialogs/generate-rss-url/generate-rss-url.component';
import { SortPropertyComponent } from './components/sort-property/sort-property.component';
import { OnlyNumberDirective } from './directives/only-number.directive';
import { ArchiveViewerComponent } from './components/archive-viewer/archive-viewer.component';
registerLocaleData(es, 'es'); registerLocaleData(es, 'es');
@@ -132,7 +140,14 @@ registerLocaleData(es, 'es');
SkipAdButtonComponent, SkipAdButtonComponent,
TasksComponent, TasksComponent,
UpdateTaskScheduleDialogComponent, UpdateTaskScheduleDialogComponent,
RestoreDbDialogComponent RestoreDbDialogComponent,
NotificationsComponent,
NotificationsListComponent,
TaskSettingsComponent,
GenerateRssUrlComponent,
SortPropertyComponent,
OnlyNumberDirective,
ArchiveViewerComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@@ -170,6 +185,7 @@ registerLocaleData(es, 'es');
MatTableModule, MatTableModule,
MatDatepickerModule, MatDatepickerModule,
MatChipsModule, MatChipsModule,
MatBadgeModule,
DragDropModule, DragDropModule,
ClipboardModule, ClipboardModule,
TextFieldModule, TextFieldModule,

View File

@@ -0,0 +1,143 @@
<mat-form-field class="filter">
<mat-icon matPrefix>search</mat-icon>
<mat-label i18n="Filter">Filter</mat-label>
<input matInput [(ngModel)]="text_filter" (keyup)="applyFilter($event)" #input>
</mat-form-field>
<div [hidden]="!(archives && archives.length > 0)">
<div class="mat-elevation-z8">
<mat-table matSort [dataSource]="dataSource">
<!-- Select Column -->
<!-- Checkbox Column -->
<ng-container matColumnDef="select">
<mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)">
</mat-checkbox>
<mat-icon class="audio-video-icon">{{(row.type === 'audio') ? 'audiotrack' : 'movie'}}</mat-icon>
</mat-cell>
</ng-container>
<!-- Date Column -->
<ng-container matColumnDef="timestamp">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Date">Date</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element"> {{element.timestamp*1000 | date: 'short'}} </mat-cell>
</ng-container>
<!-- Title Column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<span class="max-two-lines" [matTooltip]="element.title ? element.title : null">
{{element.title}}
</span>
</mat-cell>
</ng-container>
<!-- ID Column -->
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="ID">ID</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<span class="one-line" [matTooltip]="element.title ? element.title : null">
{{element.id}}
</span>
</mat-cell>
</ng-container>
<!-- Extractor Column -->
<ng-container matColumnDef="extractor">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Extractor">Extractor</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<span class="one-line" [matTooltip]="element.extractor? element.extractor : null">
{{element.extractor}}
</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
</div>
</div>
<div *ngIf="(!archives || archives.length === 0)">
<h4 style="text-align: center; margin-top: 10px;" i18n="Archives empty">Archives empty</h4>
</div>
<div style="margin: 10px 10px 10px 0px; display: flex;">
<span style="flex-grow: 1;" class="flex-items">
<button [disabled]="selection.selected.length === 0" color="warn" style="margin: 10px;" mat-stroked-button i18n="Delete selected" (click)="openDeleteSelectedArchivesDialog()">Delete selected</button>
</span>
<span class="flex-items">
<button [disabled]="!(archives && archives.length > 0)" (click)="downloadArchive()" mat-stroked-button i18n="Download archive">Download archive</button>
<mat-form-field style="width: 150px; margin-bottom: -1.25em; margin-left: 10px;">
<mat-label i18n="Subscription">Subscription</mat-label>
<mat-select [ngModel]="sub_id" (ngModelChange)="subFilterSelectionChanged($event)">
<mat-option [value]="'none'" i18n="None">None</mat-option>
<mat-option *ngFor="let sub of postsService.subscriptions" [value]="sub.id">{{sub.name}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field style="width: 100px; margin-bottom: -1.25em; margin-left: 10px;">
<mat-label i18n="File type">File type</mat-label>
<mat-select [ngModel]="type" (ngModelChange)="typeFilterSelectionChanged($event)" [disabled]="sub_id !== 'none'">
<mat-option [value]="'both'" i18n="Both">Both</mat-option>
<mat-option [value]="'video'" i18n="Video">Video</mat-option>
<mat-option [value]="'audio'" i18n="Audio">Audio</mat-option>
</mat-select>
</mat-form-field>
</span>
</div>
<div class="file-drop-parent">
<ngx-file-drop [multiple]="false" accept=".txt" dropZoneLabel="Drop file here" (onFileDrop)="dropped($event)">
<ng-template class="file-drop" ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div style="text-align: center">
<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>
<div style="margin-top: 10px; color: white">
<table class="table">
<tbody class="upload-name-style">
<tr *ngFor="let item of files; let i=index">
<td style="vertical-align: middle; border-top: unset">
<strong>{{ item.relativePath }}</strong>
</td>
<td style="border-top: unset">
<div style="float: right">
<mat-form-field style="width: 150px;">
<mat-label i18n="Subscription">Subscription</mat-label>
<mat-select [ngModel]="upload_sub_id" (ngModelChange)="subUploadFilterSelectionChanged($event)">
<mat-option [value]="'none'" i18n="None">None</mat-option>
<mat-option *ngFor="let sub of postsService.subscriptions" [value]="sub.id">{{sub.name}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field style="width: 100px; margin-left: 10px">
<mat-label i18n="File type">File type</mat-label>
<mat-select [(ngModel)]="upload_type" [value]="upload_type" [disabled]="upload_sub_id !== 'none'">
<mat-option [value]="'video'" i18n="Video">Video</mat-option>
<mat-option [value]="'audio'" i18n="Audio">Audio</mat-option>
</mat-select>
</mat-form-field>
<button style="margin-left: 10px" [disabled]="uploading_archive || uploaded_archive" (click)="importArchive()" matTooltip="Upload" i18n-matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon><mat-spinner *ngIf="uploading_archive" class="spinner" [diameter]="38"></mat-spinner></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,42 @@
.filter {
width: 100%;
}
.spinner {
bottom: 1px;
left: 0.5px;
position: absolute;
}
.mat-mdc-table {
width: 100%;
max-height: 60vh;
overflow: auto;
}
.max-two-lines {
display: -webkit-box;
display: -moz-box;
max-height: 2.4em;
line-height: 1.2em;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
:host ::ng-deep .ngx-file-drop__content {
width: 100%;
top: -12px;
position: relative;
}
.file-drop-parent {
padding: 0px 10px 0px 10px;
}
.flex-items {
display: flex;
align-items: center;
}

View File

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

View File

@@ -0,0 +1,198 @@
import { SelectionModel } from '@angular/cdk/collections';
import { Component, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { FileType } from 'api-types';
import { Archive } from 'api-types/models/Archive';
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
import { PostsService } from 'app/posts.services';
import { NgxFileDropEntry } from 'ngx-file-drop';
@Component({
selector: 'app-archive-viewer',
templateUrl: './archive-viewer.component.html',
styleUrls: ['./archive-viewer.component.scss']
})
export class ArchiveViewerComponent {
// table
displayedColumns: string[] = ['select', 'timestamp', 'title', 'id', 'extractor'];
dataSource = null;
selection = new SelectionModel<Archive>(true, []);
// general
archives = null;
archives_retrieved = false;
text_filter = '';
sub_id = 'none';
upload_sub_id = 'none';
type: FileType | 'both' = 'both';
upload_type: FileType = FileType.VIDEO;
// importing
uploading_archive = false;
uploaded_archive = false;
files = [];
typeSelectOptions = {
video: {
key: 'video',
label: $localize`Video`
},
audio: {
key: 'audio',
label: $localize`Audio`
}
};
@ViewChild(MatSort) sort: MatSort;
constructor(public postsService: PostsService, private dialog: MatDialog) {
}
ngOnInit() {
this.getArchives();
}
applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.data.length;
return numSelected === numRows;
}
/** Selects all rows if they are not all selected; otherwise clear selection. */
toggleAllRows() {
if (this.isAllSelected()) {
this.selection.clear();
return;
}
this.selection.select(...this.dataSource.data);
}
typeFilterSelectionChanged(value): void {
this.type = value;
this.dataSource.filter = '';
this.text_filter = '';
this.getArchives();
}
subFilterSelectionChanged(value): void {
this.sub_id = value;
this.dataSource.filter = '';
this.text_filter = '';
if (this.sub_id !== 'none') {
this.type = this.postsService.getSubscriptionByID(this.sub_id)['type'];
}
this.getArchives();
}
subUploadFilterSelectionChanged(value): void {
this.upload_sub_id = value;
if (this.upload_sub_id !== 'none') {
this.upload_type = this.postsService.getSubscriptionByID(this.upload_sub_id)['type'];
}
}
getArchives(): void {
this.postsService.getArchives(this.type === 'both' ? null : this.type, this.sub_id === 'none' ? null : this.sub_id).subscribe(res => {
if (res['archives'] !== null
&& res['archives'] !== undefined
&& JSON.stringify(this.archives) !== JSON.stringify(res['archives'])) {
this.archives = res['archives']
this.dataSource = new MatTableDataSource<Archive>(this.archives);
this.dataSource.sort = this.sort;
} else {
// failed to get downloads
}
});
}
importArchive(): void {
this.uploading_archive = true;
for (const droppedFile of this.files) {
// Is it a file?
if (droppedFile.fileEntry.isFile) {
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
fileEntry.file(async (file: File) => {
const archive_base64 = await blobToBase64(file);
this.postsService.importArchive(archive_base64 as string, this.upload_type, this.upload_sub_id === 'none' ? null : this.upload_sub_id).subscribe(res => {
this.uploading_archive = false;
if (res['success']) {
this.uploaded_archive = true;
this.postsService.openSnackBar($localize`Archive successfully imported!`);
}
this.getArchives();
}, err => {
console.error(err);
this.uploading_archive = false;
});
});
}
}
}
downloadArchive(): void {
this.postsService.downloadArchive(this.type === 'both' ? null : this.type, this.sub_id === 'none' ? null : this.sub_id).subscribe(res => {
const blob: Blob = res;
saveAs(blob, 'archive.txt');
});
}
openDeleteSelectedArchivesDialog(): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: $localize`Delete archives`,
dialogText: $localize`Would you like to delete ${this.selection.selected.length}:selected archives amount: archive(s)?`,
submitText: $localize`Delete`,
warnSubmitColor: true
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.deleteSelectedArchives();
}
});
}
deleteSelectedArchives(): void {
for (const archive of this.selection.selected) {
this.archives = this.archives.filter((_archive: Archive) => !(archive['extractor'] === _archive['extractor'] && archive['id'] !== _archive['id']));
}
this.postsService.deleteArchiveItems(this.selection.selected).subscribe(res => {
if (res['success']) {
this.postsService.openSnackBar($localize`Successfully deleted archive items!`);
} else {
this.postsService.openSnackBar($localize`Failed to delete archive items!`);
}
this.getArchives();
});
this.selection.clear();
}
public dropped(files: NgxFileDropEntry[]) {
this.files = files;
this.uploading_archive = false;
this.uploaded_archive = false;
}
originalOrder = (): number => {
return 0;
}
}
function blobToBase64(blob: Blob) {
return new Promise((resolve, _) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}

View File

@@ -2,7 +2,7 @@
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div *ngFor="let playlist of playlists; let i = index" class="mb-2 mt-2" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]"> <div *ngFor="let playlist of playlists; let i = index" class="mb-2 mt-2" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToPlaylist($event)" [file_obj]="playlist" [is_playlist]="true" (editPlaylist)="editPlaylistDialog($event)" (deleteFile)="deletePlaylist($event)" [loading]="false"></app-unified-file-card> <app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToPlaylist($event)" [file_obj]="playlist" [is_playlist]="true" (editPlaylist)="editPlaylistDialog($event)" (deleteFile)="deletePlaylist($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? this.postsService.token : ''" [loading]="false"></app-unified-file-card>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -41,7 +41,7 @@ import { Download } from 'api-types';
}) })
export class DownloadsComponent implements OnInit, OnDestroy { export class DownloadsComponent implements OnInit, OnDestroy {
@Input() uids = null; @Input() uids: string[] = null;
downloads_check_interval = 1000; downloads_check_interval = 1000;
downloads = []; downloads = [];
@@ -200,6 +200,10 @@ export class DownloadsComponent implements OnInit, OnDestroy {
this.postsService.restartDownload(download_uid).subscribe(res => { this.postsService.restartDownload(download_uid).subscribe(res => {
if (!res['success']) { if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to restart download! See server logs for more info.`); this.postsService.openSnackBar($localize`Failed to restart download! See server logs for more info.`);
} else {
if (this.uids && res['new_download_uid']) {
this.uids.push(res['new_download_uid']);
}
} }
}); });
} }

View File

@@ -1,31 +1,36 @@
<mat-card class="login-card"> <mat-card class="login-card">
<mat-tab-group style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex"> <mat-tab-group mat-stretch-tabs style="margin-bottom: 20px" [(selectedIndex)]="selectedTabIndex">
<mat-tab label="Login" i18n-label="Login"> <mat-tab label="Login" i18n-label="Login">
<div style="margin-top: 10px;"> <div style="margin-top: 10px;">
<mat-form-field> <mat-form-field style="width: 100%">
<input [(ngModel)]="loginUsernameInput" matInput placeholder="User name" i18n-placeholder="User name"> <mat-label i18n="User name">User name</mat-label>
<input [(ngModel)]="loginUsernameInput" matInput>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field> <mat-form-field style="width: 100%">
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput placeholder="Password" i18n-placeholder="Password"> <mat-label i18n="Password">Password</mat-label>
<input [(ngModel)]="loginPasswordInput" (keyup.enter)="login()" type="password" matInput>
</mat-form-field> </mat-form-field>
</div> </div>
</mat-tab> </mat-tab>
<mat-tab *ngIf="registrationEnabled" label="Register" i18n-label="Register"> <mat-tab *ngIf="registrationEnabled" label="Register" i18n-label="Register">
<div style="margin-top: 10px;"> <div style="margin-top: 10px;">
<mat-form-field> <mat-form-field style="width: 100%">
<input [(ngModel)]="registrationUsernameInput" matInput placeholder="User name" i18n-placeholder="User name"> <mat-label i18n="User name">User name</mat-label>
<input [(ngModel)]="registrationUsernameInput" matInput>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field> <mat-form-field style="width: 100%">
<input [(ngModel)]="registrationPasswordInput" (click)="register()" type="password" matInput placeholder="Password" i18n-placeholder="Password"> <mat-label i18n="Password">Password</mat-label>
<input [(ngModel)]="registrationPasswordInput" (click)="register()" type="password" matInput>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field> <mat-form-field style="width: 100%">
<input [(ngModel)]="registrationPasswordConfirmationInput" (click)="register()" type="password" matInput placeholder="Confirm Password" i18n-placeholder="Confirm Password"> <mat-label i18n="Confirm Password">Confirm Password</mat-label>
<input [(ngModel)]="registrationPasswordConfirmationInput" (click)="register()" type="password" matInput>
</mat-form-field> </mat-form-field>
</div> </div>
</mat-tab> </mat-tab>

View File

@@ -12,17 +12,15 @@
} }
.login-button-div { .login-button-div {
margin-bottom: 10px;
margin-top: 10px;
margin-left: -16px;
margin-right: -16px;
bottom: 0px; bottom: 0px;
width: 100%; width: 100%;
height: 42px;
position: absolute; position: absolute;
} }
.login-button-div > button { .login-button-div > button {
width: 100%; width: 100%;
height: 100%;
border-radius: 0px 0px 4px 4px !important; border-radius: 0px 0px 4px 4px !important;
} }

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export class ManageRoleComponent implements OnInit {
} }
constructor(public postsService: PostsService, private dialogRef: MatDialogRef<ManageRoleComponent>, constructor(public postsService: PostsService, private dialogRef: MatDialogRef<ManageRoleComponent>,
@Inject(MAT_DIALOG_DATA) public data: any) { @Inject(MAT_DIALOG_DATA) public data: {role: string}) {
if (this.data) { if (this.data) {
this.role = this.data.role; this.role = this.data.role;
this.available_permissions = this.postsService.available_permissions; this.available_permissions = this.postsService.available_permissions;

View File

@@ -5,24 +5,23 @@
<div> <div>
<mat-form-field style="margin-right: 15px;"> <mat-form-field style="margin-right: 15px;">
<input matInput [(ngModel)]="newPasswordInput" type="password" placeholder="New password" i18n-placeholder="New password placeholder"> <mat-label i18n="New password">New password</mat-label>
<input matInput [(ngModel)]="newPasswordInput" type="password">
</mat-form-field> </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> <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>
<div> <div>
<mat-list> <div *ngFor="let permission of available_permissions">
<mat-list-item role="listitem" *ngFor="let permission of available_permissions"> <div matListItemTitle>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</div>
<h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3> <div matListItemLine>
<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-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 role default">Use role default</ng-container></mat-radio-button>
<mat-radio-button value="default"><ng-container i18n="Use role default">Use role 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="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-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button> </mat-radio-group>
</mat-radio-group> </div>
</span> </div>
</mat-list-item>
</mat-list>
</div> </div>
</mat-dialog-content> </mat-dialog-content>

View File

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

View File

@@ -1,6 +1,7 @@
import { Component, OnInit, Inject } from '@angular/core'; import { Component, OnInit, Inject } from '@angular/core';
import { PostsService } from 'app/posts.services'; import { PostsService } from 'app/posts.services';
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { User } from 'api-types';
@Component({ @Component({
selector: 'app-manage-user', selector: 'app-manage-user',
@@ -15,17 +16,18 @@ export class ManageUserComponent implements OnInit {
permissions = null; permissions = null;
permissionToLabel = { permissionToLabel = {
'filemanager': 'File manager', 'filemanager': $localize`File manager`,
'settings': 'Settings access', 'settings': $localize`Settings access`,
'subscriptions': 'Subscriptions', 'subscriptions': $localize`Subscriptions`,
'sharing': 'Share files', 'sharing': $localize`Share files`,
'advanced_download': 'Use advanced download mode', 'advanced_download': $localize`Use advanced download mode`,
'downloads_manager': 'Use downloads manager' 'downloads_manager': $localize`Use downloads manager`,
'tasks_manager': $localize`Use tasks manager`,
} }
settingNewPassword = false; settingNewPassword = false;
constructor(public postsService: PostsService, @Inject(MAT_DIALOG_DATA) public data: any) { constructor(public postsService: PostsService, @Inject(MAT_DIALOG_DATA) public data: {user: User}) {
if (this.data) { if (this.data) {
this.user = this.data.user; this.user = this.data.user;
this.available_permissions = this.postsService.available_permissions; this.available_permissions = this.postsService.available_permissions;
@@ -53,14 +55,14 @@ export class ManageUserComponent implements OnInit {
} }
changeUserPermissions(change, permission) { changeUserPermissions(change, permission) {
this.postsService.setUserPermission(this.user.uid, permission, change.value).subscribe(res => { this.postsService.setUserPermission(this.user.uid, permission, change.value).subscribe(() => {
// console.log(res); // console.log(res);
}); });
} }
setNewPassword() { setNewPassword() {
this.settingNewPassword = true; this.settingNewPassword = true;
this.postsService.changeUserPassword(this.user.uid, this.newPasswordInput).subscribe(res => { this.postsService.changeUserPassword(this.user.uid, this.newPasswordInput).subscribe(() => {
this.newPasswordInput = ''; this.newPasswordInput = '';
this.settingNewPassword = false; this.settingNewPassword = false;
}); });

View File

@@ -1,14 +1,15 @@
<div *ngIf="dataSource; else loading"> <div *ngIf="dataSource; else loading">
<div style="padding: 15px"> <div style="padding: 15px">
<div class="row"> <div class="row">
<div class="table table-responsive pb-4 pt-2"> <div class="table table-responsive pb-4 pt-4">
<div class="example-header"> <div class="example-header">
<mat-form-field> <mat-form-field appearance="outline">
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Search" i18n-placeholder="search field description"> <mat-label i18n="Search">Search</mat-label>
<input matInput (keyup)="applyFilter($event)">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="example-container mat-elevation-z8"> <div class="mat-elevation-z8" style="margin-right: 15px;">
<mat-table #table [dataSource]="dataSource" matSort> <mat-table #table [dataSource]="dataSource" matSort>

View File

@@ -8,6 +8,7 @@ import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { AddUserDialogComponent } from 'app/dialogs/add-user-dialog/add-user-dialog.component'; import { AddUserDialogComponent } from 'app/dialogs/add-user-dialog/add-user-dialog.component';
import { ManageUserComponent } from '../manage-user/manage-user.component'; import { ManageUserComponent } from '../manage-user/manage-user.component';
import { ManageRoleComponent } from '../manage-role/manage-role.component'; import { ManageRoleComponent } from '../manage-role/manage-role.component';
import { User } from 'api-types';
@Component({ @Component({
selector: 'app-modify-users', selector: 'app-modify-users',
@@ -31,7 +32,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
// MatPaginator Output // MatPaginator Output
pageEvent: PageEvent; pageEvent: PageEvent;
users: any; users: User[];
editObject = null; editObject = null;
constructedObject = {}; constructedObject = {};
roles = null; roles = null;
@@ -62,7 +63,8 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
this.pageSizeOptions = setPageSizeOptionsInput.split(',').map(str => +str); this.pageSizeOptions = setPageSizeOptionsInput.split(',').map(str => +str);
} }
applyFilter(filterValue: string) { applyFilter(event: KeyboardEvent) {
let filterValue = (event.target as HTMLInputElement).value; // "as HTMLInputElement" is required: https://angular.io/guide/user-input#type-the-event
filterValue = filterValue.trim(); // Remove whitespace filterValue = filterValue.trim(); // Remove whitespace
filterValue = filterValue.toLowerCase(); // Datasource defaults to lowercase matches filterValue = filterValue.toLowerCase(); // Datasource defaults to lowercase matches
this.dataSource.filter = filterValue; this.dataSource.filter = filterValue;
@@ -94,11 +96,9 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
}); });
} }
finishEditing(user_uid) { finishEditing(user_uid: string) {
let has_finished = false;
if (this.constructedObject && this.constructedObject['name'] && this.constructedObject['role']) { if (this.constructedObject && this.constructedObject['name'] && this.constructedObject['role']) {
if (!isEmptyOrSpaces(this.constructedObject['name']) && !isEmptyOrSpaces(this.constructedObject['role'])) { if (!isEmptyOrSpaces(this.constructedObject['name']) && !isEmptyOrSpaces(this.constructedObject['role'])) {
has_finished = true;
const index_of_object = this.indexOfUser(user_uid); const index_of_object = this.indexOfUser(user_uid);
this.users[index_of_object] = this.constructedObject; this.users[index_of_object] = this.constructedObject;
this.constructedObject = {}; this.constructedObject = {};
@@ -109,7 +109,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
} }
} }
enableEditMode(user_uid) { enableEditMode(user_uid: string) {
if (this.uidInUserList(user_uid) && this.indexOfUser(user_uid) > -1) { if (this.uidInUserList(user_uid) && this.indexOfUser(user_uid) > -1) {
const users_index = this.indexOfUser(user_uid); const users_index = this.indexOfUser(user_uid);
this.editObject = this.users[users_index]; this.editObject = this.users[users_index];
@@ -124,7 +124,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
} }
// checks if user is in users array by name // checks if user is in users array by name
uidInUserList(user_uid) { uidInUserList(user_uid: string) {
for (let i = 0; i < this.users.length; i++) { for (let i = 0; i < this.users.length; i++) {
if (this.users[i].uid === user_uid) { if (this.users[i].uid === user_uid) {
return true; return true;
@@ -134,7 +134,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
} }
// gets index of user in users array by name // gets index of user in users array by name
indexOfUser(user_uid) { indexOfUser(user_uid: string) {
for (let i = 0; i < this.users.length; i++) { for (let i = 0; i < this.users.length; i++) {
if (this.users[i].uid === user_uid) { if (this.users[i].uid === user_uid) {
return i; return i;
@@ -144,12 +144,12 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
} }
setUser(change_obj) { setUser(change_obj) {
this.postsService.changeUser(change_obj).subscribe(res => { this.postsService.changeUser(change_obj).subscribe(() => {
this.getArray(); this.getArray();
}); });
} }
manageUser(user_uid) { manageUser(user_uid: string) {
const index_of_object = this.indexOfUser(user_uid); const index_of_object = this.indexOfUser(user_uid);
const user_obj = this.users[index_of_object]; const user_obj = this.users[index_of_object];
this.dialog.open(ManageUserComponent, { this.dialog.open(ManageUserComponent, {
@@ -160,17 +160,17 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
}); });
} }
removeUser(user_uid) { removeUser(user_uid: string) {
this.postsService.deleteUser(user_uid).subscribe(res => { this.postsService.deleteUser(user_uid).subscribe(() => {
this.getArray(); this.getArray();
}, err => { }, () => {
this.getArray(); this.getArray();
}); });
} }
createAndSortData() { createAndSortData() {
// Sorts the data by last finished // Sorts the data by last finished
this.users.sort((a, b) => b.name > a.name); this.users.sort((a, b) => a.name.localeCompare(b.name));
const filteredData = []; const filteredData = [];
for (let i = 0; i < this.users.length; i++) { for (let i = 0; i < this.users.length; i++) {
@@ -188,7 +188,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
} }
}); });
dialogRef.afterClosed().subscribe(success => { dialogRef.afterClosed().subscribe(() => {
this.getRoles(); this.getRoles();
}); });
} }
@@ -197,7 +197,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public openSnackBar(message: string, action: string = '') { public openSnackBar(message: string, action = '') {
this.snackBar.open(message, action, { this.snackBar.open(message, action, {
duration: 2000, duration: 2000,
}); });

View File

@@ -0,0 +1,30 @@
<div class="card-radius mat-elevation-z2" *ngFor="let notification of notifications; let i = index;">
<mat-card class="notification-card card-radius">
<mat-card-header>
<mat-card-subtitle>
<div>
<span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span>
</div>
</mat-card-subtitle>
<mat-card-title>
<ng-container *ngIf="NOTIFICATION_PREFIX[notification.type]">
{{NOTIFICATION_PREFIX[notification.type]}}
</ng-container>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="NOTIFICATION_SUFFIX_KEY[notification.type]">
<div style="word-break: break-word">
{{notification['data'][NOTIFICATION_SUFFIX_KEY[notification.type]]}}
</div>
</ng-container>
</mat-card-content>
<mat-card-actions *ngIf="notification.actions?.length > 0">
<button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button>
<span *ngFor="let action of notification.actions">
<button [matTooltip]="NOTIFICATION_ACTION_TO_STRING[action]" (click)="emitNotificationAction(notification, action)" mat-icon-button><mat-icon>{{NOTIFICATION_ICON[action]}}</mat-icon></button>
</span>
</mat-card-actions>
<span *ngIf="!notification.read" class="dot"></span>
</mat-card>
</div>

View File

@@ -0,0 +1,33 @@
.notification-divider {
margin-bottom: 10px;
margin-top: 10px;
}
.notification-text {
margin-left: 10px;
margin-right: 10px;
display: inline-block;
}
.notification-timestamp {
font-size: 14px;
}
.notification-card {
margin-top: 5px;
}
.card-radius {
border-radius: 12px;
}
.dot {
height: 8px;
width: 8px;
background-color: red;
border-radius: 50%;
display: inline-block;
position: absolute;
right: 8px;
top: 8px;
}

View File

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

View File

@@ -0,0 +1,57 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Notification } from 'api-types';
import { NotificationAction } from 'api-types/models/NotificationAction';
import { NotificationType } from 'api-types/models/NotificationType';
@Component({
selector: 'app-notifications-list',
templateUrl: './notifications-list.component.html',
styleUrls: ['./notifications-list.component.scss']
})
export class NotificationsListComponent {
@Input() notifications = null;
@Output() deleteNotification = new EventEmitter<string>();
@Output() notificationAction = new EventEmitter<{notification: Notification, action: NotificationAction}>();
NOTIFICATION_PREFIX: { [key in NotificationType]: string } = {
download_complete: $localize`Finished downloading`,
download_error: $localize`Download failed`,
task_finished: $localize`Task finished`
}
// Attaches string to the end of the notification text
NOTIFICATION_SUFFIX_KEY: { [key in NotificationType]: string } = {
download_complete: 'file_title',
download_error: 'download_url',
task_finished: 'task_title'
}
NOTIFICATION_ACTION_TO_STRING: { [key in NotificationAction]: string } = {
play: $localize`Play`,
retry_download: $localize`Retry download`,
view_download_error: $localize`View error`,
view_tasks: $localize`View task`
}
NOTIFICATION_COLOR: { [key in NotificationAction]: string } = {
play: 'primary',
retry_download: 'primary',
view_download_error: 'warn',
view_tasks: 'primary'
}
NOTIFICATION_ICON: { [key in NotificationAction]: string } = {
play: 'smart_display',
retry_download: 'restart_alt',
view_download_error: 'warning',
view_tasks: 'task'
}
emitNotificationAction(notification: Notification, action: NotificationAction): void {
this.notificationAction.emit({notification: notification, action: action});
}
emitDeleteNotification(uid: string): void {
this.deleteNotification.emit(uid);
}
}

View File

@@ -0,0 +1,10 @@
.notification-title {
margin-bottom: 6px;
text-align: center
}
.notifications-list-parent {
max-height: 70vh;
overflow-y: auto;
padding: 0px 10px 10px 10px;
}

View File

@@ -0,0 +1,10 @@
<div *ngIf="notifications !== null && notifications.length === 0" style="text-align: center; margin: 10px;" i18n="No notifications available">No notifications available</div>
<div *ngIf="notifications?.length > 0">
<div class="notifications-list-parent">
<mat-chip-listbox [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
<mat-chip-option *ngFor="let filter of notificationFilters | keyvalue: originalOrder" [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
</mat-chip-listbox>
<app-notifications-list (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list>
</div>
<button style="margin: 10px 0px 2px 10px;" *ngIf="notifications?.length > 0" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button>
</div>

View File

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

View File

@@ -0,0 +1,124 @@
import { Component, ElementRef, EventEmitter, OnInit, Output } from '@angular/core';
import { Router } from '@angular/router';
import { PostsService } from 'app/posts.services';
import { Notification, NotificationType } from 'api-types';
import { NotificationAction } from 'api-types/models/NotificationAction';
import { MatChipListboxChange } from '@angular/material/chips';
@Component({
selector: 'app-notifications',
templateUrl: './notifications.component.html',
styleUrls: ['./notifications.component.css']
})
export class NotificationsComponent implements OnInit {
notifications: Notification[] = null;
filtered_notifications: Notification[] = null;
@Output() notificationCount = new EventEmitter<number>();
notificationFilters: { [key in NotificationType]: {key: string, label: string} } = {
download_complete: {
key: 'download_complete',
label: $localize`Download completed`
},
download_error: {
key: 'download_error',
label: $localize`Download error`
},
task_finished: {
key: 'task_finished',
label: $localize`Task`
},
};
selectedFilters = [];
constructor(public postsService: PostsService, private router: Router, private elRef: ElementRef) { }
ngOnInit(): void {
// wait for init
if (this.postsService.initialized) {
this.getNotifications();
} else {
this.postsService.service_initialized.subscribe(init => {
if (init) {
this.getNotifications();
}
});
}
}
getNotifications(): void {
this.postsService.getNotifications().subscribe(res => {
this.notifications = res['notifications'];
this.notifications.sort((a, b) => b.timestamp - a.timestamp);
this.notificationCount.emit(this.notifications.filter(notification => !notification.read).length);
this.filterNotifications();
});
}
notificationAction(action_info: {notification: Notification, action: NotificationAction}): void {
switch (action_info['action']) {
case NotificationAction.PLAY:
this.router.navigate(['player', {uid: action_info['notification']['data']['file_uid']}]);
break;
case NotificationAction.VIEW_DOWNLOAD_ERROR:
this.router.navigate(['downloads']);
break;
case NotificationAction.RETRY_DOWNLOAD:
this.postsService.restartDownload(action_info['notification']['data']['download_uid']).subscribe(res => {
this.postsService.openSnackBar($localize`Download restarted!`);
this.deleteNotification(action_info['notification']['uid']);
});
break;
case NotificationAction.VIEW_TASKS:
this.router.navigate(['tasks']);
break;
default:
console.error(`Notification action ${action_info['action']} does not exist!`);
break;
}
}
deleteNotification(uid: string): void {
this.postsService.deleteNotification(uid).subscribe(res => {
this.notifications.filter(notification => notification['uid'] !== uid);
this.filterNotifications();
this.notificationCount.emit(this.notifications.length);
this.getNotifications();
});
}
deleteAllNotifications(): void {
this.postsService.deleteAllNotifications().subscribe(res => {
this.notifications = [];
this.filtered_notifications = [];
this.getNotifications();
});
this.notificationCount.emit(0);
}
setNotificationsToRead(): void {
const uids = this.notifications.map(notification => notification.uid);
this.postsService.setNotificationsToRead(uids).subscribe(res => {
this.getNotifications();
});
this.notificationCount.emit(0);
}
filterNotifications(): void {
this.filtered_notifications = this.notifications.filter(notification => this.selectedFilters.length === 0 || this.selectedFilters.includes(notification.type));
}
selectedFiltersChanged(event: MatChipListboxChange): void {
this.selectedFilters = event.value;
this.filterNotifications();
}
originalOrder = (): number => {
return 0;
}
}

View File

@@ -1,47 +1,47 @@
<div class="container-fluid" style="max-width: 941px;"> <div class="container-fluid" style="max-width: 941px;">
<div class="row"> <div class="row">
<!-- Sorting -->
<div class="col-12 order-2 col-sm-4 order-sm-1 d-flex justify-content-center"> <div class="col-12 order-2 col-sm-4 order-sm-1 d-flex justify-content-center">
<div> <app-sort-property [(sortProperty)]="sortProperty" [(descendingMode)]="descendingMode" (sortOptionChanged)="sortOptionChanged($event)"></app-sort-property>
<div style="display: inline-block;">
<mat-form-field style="width: 132px;">
<mat-select [(ngModel)]="this.filterProperty" (selectionChange)="filterOptionChanged($event.value)">
<mat-option *ngFor="let filterOption of filterProperties | keyvalue" [value]="filterOption.value">
{{filterOption['value']['label']}}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="sort-dir-div">
<button (click)="toggleModeChange()" mat-icon-button><mat-icon>{{descendingMode ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
</div>
</div>
</div> </div>
<!-- Files title -->
<div class="col-12 order-1 col-sm-4 order-sm-2 d-flex justify-content-center"> <div class="col-12 order-1 col-sm-4 order-sm-2 d-flex justify-content-center">
<h4 *ngIf="!customHeader" class="my-videos-title" i18n="My files title">My files</h4> <h4 *ngIf="!customHeader" class="my-videos-title" i18n="My files title">My files</h4>
<h4 *ngIf="customHeader" class="my-videos-title">{{customHeader}}</h4> <h4 *ngIf="customHeader" class="my-videos-title">{{customHeader}}</h4>
</div> </div>
<!-- Search -->
<div class="col-12 order-3 col-sm-4 order-sm-3 d-flex justify-content-center"> <div class="col-12 order-3 col-sm-4 order-sm-3 d-flex justify-content-center">
<mat-form-field [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent"> <mat-form-field appearance="outline" [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
<input (focus)="searchIsFocused = true" (blur)="searchIsFocused = false" class="search-input" type="text" placeholder="Search" i18n-placeholder="Files search placeholder" [(ngModel)]="search_text" (ngModelChange)="onSearchInputChanged($event)" matInput> <mat-label i18n="Search">Search</mat-label>
<input (focus)="searchIsFocused = true" (blur)="searchIsFocused = false" class="search-input" type="text" [(ngModel)]="search_text" (ngModelChange)="onSearchInputChanged($event)" matInput>
<mat-icon matSuffix>search</mat-icon> <mat-icon matSuffix>search</mat-icon>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<!-- Filters -->
<div class="row justify-content-center">
<mat-chip-listbox class="filter-list" [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
<mat-chip-option *ngFor="let filter of fileFilters | keyvalue: originalOrder" [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
</mat-chip-listbox>
</div>
</div> </div>
<div> <div>
<!-- Files -->
<div *ngIf="!selectMode" class="container" style="margin-bottom: 16px"> <div *ngIf="!selectMode" class="container" style="margin-bottom: 16px">
<div class="row justify-content-center"> <div class="row justify-content-center">
<!-- Real cards -->
<ng-container *ngIf="normal_files_received && paged_data"> <ng-container *ngIf="normal_files_received && paged_data">
<div style="display: flex; align-items: center;" *ngFor="let file of paged_data; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]"> <div style="display: flex; align-items: center;" *ngFor="let file of paged_data; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [ngClass]="downloading_content[file.uid] ? 'blurred' : ''" [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [availablePlaylists]="playlists" (addFileToPlaylist)="addFileToPlaylist($event)" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? this.postsService.token : ''"></app-unified-file-card> <app-unified-file-card [ngClass]="downloading_content[file.uid] ? 'blurred' : ''" [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" (toggleFavorite)="toggleFavorite($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [availablePlaylists]="playlists" (addFileToPlaylist)="addFileToPlaylist($event)" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? this.postsService.token : ''"></app-unified-file-card>
<mat-spinner *ngIf="downloading_content[file.uid]" class="downloading-spinner" [diameter]="32"></mat-spinner> <mat-spinner *ngIf="downloading_content[file.uid]" class="downloading-spinner" [diameter]="32"></mat-spinner>
</div> </div>
<div *ngIf="paged_data.length === 0"> <div *ngIf="paged_data.length === 0">
<ng-container i18n="No files found">No files found.</ng-container> <ng-container i18n="No files found">No files found.</ng-container>
</div> </div>
</ng-container> </ng-container>
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0"> <!-- Fake cards -->
<div *ngFor="let file of loading_files; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]"> <ng-container>
<div *ngFor="let file of loading_files; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[normal_files_received ? 'hide' : '', postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" [loading]="true" [theme]="postsService.theme"></app-unified-file-card> <app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" [loading]="true" [theme]="postsService.theme"></app-unified-file-card>
</div> </div>
</ng-container> </ng-container>
@@ -49,6 +49,7 @@
</div> </div>
<div *ngIf="selectMode"> <div *ngIf="selectMode">
<!-- If selected files e.g. for creating a playlist -->
<mat-tab-group [(selectedIndex)]="selectedIndex"> <mat-tab-group [(selectedIndex)]="selectedIndex">
<mat-tab label="Order" i18n-label="Order"> <mat-tab label="Order" i18n-label="Order">
<div *ngIf="selected_data.length"> <div *ngIf="selected_data.length">
@@ -73,8 +74,8 @@
<mat-list-option [selected]="selected_data.includes(file.uid)" *ngFor="let file of paged_data" [value]="file"> <mat-list-option [selected]="selected_data.includes(file.uid)" *ngFor="let file of paged_data" [value]="file">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-10"> <div class="col-10 select-file-title">
<mat-icon class="audio-video-icon">{{(file.type === 'audio' || file.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon> <mat-icon class="audio-video-icon">{{file.isAudio ? 'audiotrack' : 'movie'}}</mat-icon>
{{file.title}} {{file.title}}
</div> </div>
<div class="col-2">{{file.registered | date:'shortDate'}}</div> <div class="col-2">{{file.registered | date:'shortDate'}}</div>
@@ -87,7 +88,7 @@
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0"> <ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0">
<mat-selection-list *ngIf="!normal_files_received"> <mat-selection-list *ngIf="!normal_files_received">
<mat-list-option *ngFor="let file of paged_data"> <mat-list-option *ngFor="let file of paged_data">
<content-loader class="list-ghosts" [primaryColor]="postsService.theme.ghost_primary" [secondaryColor]="postsService.theme.ghost_secondary" width="250" height="8"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="8" /></content-loader> <content-loader class="list-ghosts" [backgroundColor]="postsService.theme.ghost_primary" [foregroundColor]="postsService.theme.ghost_secondary" viewBox="0 0 250 8"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="8" /></content-loader>
</mat-list-option> </mat-list-option>
</mat-selection-list> </mat-selection-list>
</ng-container> </ng-container>
@@ -96,16 +97,6 @@
</div> </div>
<div style="position: relative;" *ngIf="usePaginator && selectedIndex > 0"> <div style="position: relative;" *ngIf="usePaginator && selectedIndex > 0">
<div style="position: absolute; margin-left: 8px; margin-top: 5px; scale: 0.8">
<mat-form-field>
<mat-label><ng-container i18n="File type">File type</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="fileTypeFilter" (selectionChange)="fileTypeFilterChanged($event.value)">
<mat-option value="both"><ng-container i18n="Both">Both</ng-container></mat-option>
<mat-option value="video_only"><ng-container i18n="Video only">Video only</ng-container></mat-option>
<mat-option value="audio_only"><ng-container i18n="Audio only">Audio only</ng-container></mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-paginator class="paginator" #paginator (page)="pageChangeEvent($event)" [length]="file_count" <mat-paginator class="paginator" #paginator (page)="pageChangeEvent($event)" [length]="file_count"
[pageSize]="pageSize" [pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]"> [pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">

View File

@@ -17,7 +17,7 @@
} }
.search-bar-unfocused { .search-bar-unfocused {
width: 132px; width: 165px;
} }
.search-input { .search-input {
@@ -41,12 +41,6 @@
display: inline-block; display: inline-block;
} }
.sort-dir-div {
display: inline-block;
position: absolute;
top: 10px;
}
.paginator { .paginator {
margin-top: 5px; margin-top: 5px;
} }
@@ -100,7 +94,7 @@
.remove-item-button { .remove-item-button {
right: 10px; right: 10px;
position: absolute; position: absolute;
top: 4px; top: 0px;
} }
.playlist-item-text { .playlist-item-text {
@@ -119,3 +113,17 @@
align-self: center; align-self: center;
position: absolute; position: absolute;
} }
.filter-list {
margin-bottom: 10px;
}
.hide {
display: none !important;
}
.select-file-title {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}

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