Compare commits

..

145 Commits

Author SHA1 Message Date
Tzahi12345
ed492e54c9 Fixed pycryptodomex build error
Changed tcd package to fix broken twitch downloads (#859)
2023-04-15 16:45:32 -04: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
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
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
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
Tzahi12345
9c0a77cb6e Merge pull request #705 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2022-07-09 21:25:07 -04:00
Azhar Pusparadhian
75915c41c7 Translated using Weblate (Indonesian)
Currently translated at 100.0% (381 of 381 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/id/
2022-07-09 20:18:52 +02:00
Tzahi12345
fc3c179f6a Reverted #696 and updated node version to avoid 243 error 2022-07-04 20:45:39 -04:00
Tzahi12345
f3572d274c Merge pull request #696 from martadinata666/patch-2
Update entrypoint.sh
2022-07-03 17:26:48 -04:00
Dedy Martadinata S
02447e0285 Update entrypoint.sh
about gosu behaviour, it looking for local "existed" UID, i  found it become blank.
That is our issue, so updating our entrypoint to change existed user "youtube" UID and GID to match compose request, will make gosu correctly find use UID:GID as it existed.
2022-07-03 13:05:53 +07:00
174 changed files with 34967 additions and 5959 deletions

View File

@@ -17,7 +17,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v2
with:
node-version: '12'
node-version: '14'
cache: 'npm'
- name: install dependencies
run: |

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"
]
}

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",
"windows": {
"options": {
"shell": {
"executable": "cmd.exe",
"args": [
"/d", "/c"
]
}
}
},
"tasks": [
{
"type": "npm",
"script": "start",
"problemMatcher": [],
"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",
"type": "shell",
"command": "set YTDL_MODE=debug && node app.js",
"command": "node app.js",
"options": {
"cwd": "./backend"
"cwd": "./backend",
"env": {
"YTDL_MODE": "debug"
}
},
"presentation": {
"echo": true,
"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"]
}
]
}

View File

@@ -3,10 +3,11 @@ FROM ubuntu:22.04 AS ffmpeg
ENV DEBIAN_FRONTEND=noninteractive
# Use script due local build compability
COPY ffmpeg-fetch.sh .
RUN chmod +x ffmpeg-fetch.sh
RUN sh ./ffmpeg-fetch.sh
# Create our Ubuntu 22.04 with node 16
# 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
FROM ubuntu:20.04 AS base
ARG DEBIAN_FRONTEND=noninteractive
@@ -21,7 +22,8 @@ RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
apt install -y --no-install-recommends curl ca-certificates tzdata && \
curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && \
apt install -y --no-install-recommends nodejs && \
npm -g install npm && \
npm -g install npm n && \
n 16.14.2 && \
apt clean && \
rm -rf /var/lib/apt/lists/*
@@ -50,10 +52,10 @@ RUN npm config set strict-ssl false && \
FROM base
RUN npm install -g pm2 && \
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 && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install tcd
RUN pip install tdh-tcd pycryptodomex
WORKDIR /app
# User 1000 already exist from base image
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ]

View File

@@ -111,6 +111,37 @@ paths:
$ref: '#/components/schemas/GetAllFilesResponse'
security:
- 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:
post:
tags:
@@ -547,6 +578,69 @@ paths:
description: If the archive dir is not found, 404 is sent as a response
security:
- 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:
get:
tags:
@@ -811,7 +905,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
$ref: '#/components/schemas/RestartDownloadResponse'
requestBody:
content:
application/json:
@@ -1589,6 +1683,10 @@ components:
type: string
description: Height of the video, if known
example: '1080'
maxHeight:
type: string
description: Max height that should be used, useful for playlists. selectedHeight will override this.
example: '1080'
maxBitrate:
type: string
description: Specify ffmpeg/avconv audio quality
@@ -1597,6 +1695,9 @@ components:
$ref: '#/components/schemas/FileType'
cropFileSettings:
$ref: '#/components/schemas/CropFileSettings'
ignoreArchive:
type: boolean
description: If using youtube-dl archive, download will ignore it
DownloadResponse:
type: object
properties:
@@ -1621,6 +1722,13 @@ components:
properties:
download:
$ref: '#/components/schemas/Download'
RestartDownloadResponse:
allOf:
- $ref: '#/components/schemas/SuccessObject'
- type: object
properties:
new_download_uid:
type: string
GetAllDownloadsRequest:
type: object
properties:
@@ -1673,6 +1781,16 @@ components:
required:
- task_key
- new_data
UpdateTaskOptionsRequest:
type: object
properties:
task_key:
type: string
new_options:
type: object
required:
- task_key
- new_options
GetTaskResponse:
type: object
properties:
@@ -1741,29 +1859,39 @@ components:
description: Two elements allowed, start index and end index
minItems: 2
maxItems: 2
default: null
text_search:
type: string
description: Filter files by title
default: null
file_type_filter:
$ref: '#/components/schemas/FileTypeFilter'
favorite_filter:
type: boolean
description: If set to true, only gets favorites
default: false
sub_id:
type: string
description: Include if you want to filter by subscription
default: null
Sort:
type: object
properties:
by:
type: string
description: Property to sort by
default: registered
order:
type: number
description: 1 for ascending, -1 for descending
default: -1
FileTypeFilter:
type: string
enum:
- audio_only
- video_only
- both
default: both
GetAllFilesResponse:
required:
- files
@@ -1881,16 +2009,11 @@ components:
description: Number of files removed
DeleteSubscriptionFileRequest:
required:
- file
- sub
- file_uid
type: object
properties:
file:
type: string
file_uid:
type: string
sub:
$ref: '#/components/schemas/SubscriptionRequestData'
deleteForever:
type: boolean
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:
$ref: '#/components/schemas/FileType'
DownloadArchiveRequest:
required:
- sub
type: object
properties:
sub:
required:
- archive_dir
type: object
properties:
archive_dir:
type: string
type:
$ref: '#/components/schemas/FileType'
sub_id:
type: string
Archive:
required:
- 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:
required:
- details
@@ -2061,8 +2250,6 @@ components:
tag:
type: string
DBInfoResponse:
required:
- db_info
type: object
properties:
using_local_db:
@@ -2084,6 +2271,8 @@ components:
$ref: '#/components/schemas/TableInfo'
download_queue:
$ref: '#/components/schemas/TableInfo'
archives:
$ref: '#/components/schemas/TableInfo'
TransferDBResponse:
required:
- success
@@ -2383,6 +2572,7 @@ components:
- upload_date
- uploader
- url
- favorite
type: object
properties:
id:
@@ -2412,6 +2602,8 @@ components:
type: string
uid:
type: string
user_uid:
type: string
sharingEnabled:
type: boolean
category:
@@ -2430,6 +2622,8 @@ components:
abr:
type: number
description: In Kbps
favorite:
type: boolean
Playlist:
required:
- uids
@@ -2461,6 +2655,8 @@ components:
type: string
auto:
type: boolean
sharingEnabled:
type: boolean
Download:
required:
- url
@@ -2505,6 +2701,10 @@ components:
type: string
description: Error text, set if download fails.
nullable: true
error_type:
type: string
description: Error type, may or may not be set in case of an error
nullable: true
user_uid:
type: string
sub_id:
@@ -2543,6 +2743,8 @@ components:
type: string
schedule:
type: object
options:
type: object
Schedule:
required:
- type
@@ -2567,6 +2769,8 @@ components:
type: number
timestamp:
type: number
tz:
type: string
DBBackup:
required:
- name
@@ -2755,6 +2959,44 @@ components:
type: string
date:
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:
required:
- permission
@@ -2886,6 +3128,29 @@ components:
type: array
items:
$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:
Auth query parameter:
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)
[![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!
@@ -14,7 +14,7 @@ Now with [Docker](#Docker) support!
## 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:
@@ -52,6 +52,8 @@ Optional dependencies:
### 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)!
2. Drag the `youtubedl-material` directory to an easily accessible directory. Navigate to the `appdata` folder and edit the `default.json` file.

View File

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

View File

@@ -18,6 +18,7 @@ const URL = require('url').URL;
const CONSTS = require('./consts')
const read_last_lines = require('read-last-lines');
const ps = require('ps-node');
const Feed = require('feed').Feed;
// 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"})
@@ -32,6 +33,7 @@ const subscriptions_api = require('./subscriptions');
const categories_api = require('./categories');
const twitch_api = require('./twitch');
const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
var app = express();
@@ -69,7 +71,8 @@ db.defaults(
downloads: {},
subscriptions: [],
files_to_db_migration_complete: false,
tasks_manager_role_migration_complete: false
tasks_manager_role_migration_complete: false,
archives_migration_complete: false
}).write();
users_db.defaults(
@@ -199,6 +202,15 @@ async function checkMigrations() {
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;
}
@@ -510,9 +522,6 @@ async function loadConfig() {
db_api.database_initialized = true;
db_api.database_initialized_bs.next(true);
// creates archive path if missing
await fs.ensureDir(utils.getArchiveFolder());
// check migrations
await checkMigrations();
@@ -558,14 +567,7 @@ function loadConfigValues() {
url_domain = new URL(url);
let logger_level = config_api.getConfigItem('ytdl_logger_level');
const possible_levels = ['error', 'warn', 'info', 'verbose', 'debug'];
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;
utils.updateLoggerLevel(logger_level);
}
function calculateSubcriptionRetrievalDelay(subscriptions_amount) {
@@ -700,7 +702,7 @@ app.use(function(req, res, next) {
next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
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();
} else {
logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`);
@@ -773,7 +775,7 @@ app.post('/api/restartServer', optionalJwt, (req, res) => {
app.get('/api/getDBInfo', optionalJwt, async (req, res) => {
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) => {
@@ -813,11 +815,13 @@ app.post('/api/downloadFile', optionalJwt, async function(req, res) {
additionalArgs: req.body.additionalArgs,
customOutput: req.body.customOutput,
selectedHeight: req.body.selectedHeight,
maxHeight: req.body.maxHeight,
customQualityConfiguration: req.body.customQualityConfiguration,
youtubeUsername: req.body.youtubeUsername,
youtubePassword: req.body.youtubePassword,
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);
@@ -843,6 +847,7 @@ app.post('/api/generateArgs', optionalJwt, async function(req, res) {
additionalArgs: req.body.additionalArgs,
customOutput: req.body.customOutput,
selectedHeight: req.body.selectedHeight,
maxHeight: req.body.maxHeight,
customQualityConfiguration: req.body.customQualityConfiguration,
youtubeUsername: req.body.youtubeUsername,
youtubePassword: req.body.youtubePassword,
@@ -921,35 +926,15 @@ app.post('/api/getFile', optionalJwt, async function (req, res) {
app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
// these are returned
let files = null;
const sort = req.body.sort;
const range = req.body.range;
const text_search = req.body.text_search;
const file_type_filter = req.body.file_type_filter;
const favorite_filter = req.body.favorite_filter;
const sub_id = req.body.sub_id;
const uuid = req.isAuthenticated() ? req.user.uid : null;
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 (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));
const {files, file_count} = await db_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
res.send({
files: files,
@@ -1092,9 +1077,6 @@ app.post('/api/disableSharing', optionalJwt, async function(req, res) {
await db_api.updateRecord('files', {uid: uid}, {sharingEnabled: false})
} else if (is_playlist) {
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 {
// error
success = false;
@@ -1109,7 +1091,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 sub_id = req.body.sub_id;
let uuid = req.body.uuid;
@@ -1244,12 +1226,9 @@ app.post('/api/unsubscribe', optionalJwt, async (req, res) => {
app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
let deleteForever = req.body.deleteForever;
let file = req.body.file;
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 db_api.deleteFile(file_uid, deleteForever);
if (success) {
res.send({
@@ -1430,10 +1409,9 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => {
app.post('/api/deleteFile', optionalJwt, async (req, res) => {
const uid = req.body.uid;
const blacklistMode = req.body.blacklistMode;
const uuid = req.isAuthenticated() ? req.user.uid : null;
let wasDeleted = false;
wasDeleted = await db_api.deleteFile(uid, uuid, blacklistMode);
wasDeleted = await db_api.deleteFile(uid, blacklistMode);
res.send(wasDeleted);
});
@@ -1465,7 +1443,7 @@ app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => {
for (let i = 0; i < files.length; i++) {
let wasDeleted = false;
wasDeleted = await db_api.deleteFile(files[i].uid, uuid, blacklistMode);
wasDeleted = await db_api.deleteFile(files[i].uid, blacklistMode);
if (wasDeleted) {
delete_count++;
}
@@ -1526,20 +1504,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) => {
let sub = req.body.sub;
let archive_dir = sub.archive;
const uuid = req.isAuthenticated() ? req.user.uid : null;
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)) {
res.sendFile(full_archive_path);
if (archive_text !== null && archive_text !== undefined) {
res.setHeader('Content-type', "application/octet-stream");
res.setHeader('Content-disposition', 'attachment; filename=archive.txt');
res.send(archive_text);
} 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/' });
app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => {
const new_path = path.join(__dirname, 'appdata', 'cookies.txt');
@@ -1731,8 +1758,8 @@ app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => {
app.post('/api/restartDownload', optionalJwt, async (req, res) => {
const download_uid = req.body.download_uid;
const success = await downloader_api.restartDownload(download_uid);
res.send({success: success});
const new_download = await downloader_api.restartDownload(download_uid);
res.send({success: !!new_download, new_download_uid: new_download ? new_download['uid'] : null});
});
app.post('/api/cancelDownload', optionalJwt, async (req, res) => {
@@ -1809,6 +1836,15 @@ app.post('/api/updateTaskData', optionalJwt, async (req, res) => {
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) => {
const backup_dir = path.join('appdata', 'db_backup');
fs.ensureDirSync(backup_dir);
@@ -1992,6 +2028,93 @@ app.post('/api/changeRolePermissions', optionalJwt, async (req, res) => {
res.send({success: success});
});
// notifications
app.post('/api/getNotifications', optionalJwt, async (req, res) => {
const uuid = req.user.uid;
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.user.uid;
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.body.uid;
const success = await db_api.removeRecord('notifications', {uid: uid});
res.send({success: success});
});
app.post('/api/deleteAllNotifications', optionalJwt, async (req, res) => {
const uuid = req.user.uid;
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 db_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) {
//if the request is not html then move along
var accept = req.accepts('html', 'json', 'xml');

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

@@ -33,7 +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');
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;
if (db_api.users_db.get('jwt_secret').value()) {

View File

@@ -185,7 +185,6 @@ const DEFAULT_CONFIG = {
"default_file_output": "",
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true,
"max_concurrent_downloads": 5,
@@ -196,9 +195,13 @@ const DEFAULT_CONFIG = {
"file_manager_enabled": true,
"allow_quality_select": true,
"download_only_mode": false,
"allow_autoplay": true,
"force_autoplay": false,
"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": {
"use_API_key": false,
@@ -210,7 +213,16 @@ const DEFAULT_CONFIG = {
"twitch_client_secret": "",
"twitch_auto_download_chat": 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": ""
},
"Themes": {
"default_theme": "default",

View File

@@ -30,10 +30,6 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_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': {
'key': 'ytdl_include_thumbnail',
'path': 'YoutubeDLMaterial.Downloader.include_thumbnail'
@@ -68,9 +64,9 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_download_only_mode',
'path': 'YoutubeDLMaterial.Extra.download_only_mode'
},
'ytdl_allow_autoplay': {
'key': 'ytdl_allow_autoplay',
'path': 'YoutubeDLMaterial.Extra.allow_autoplay'
'ytdl_force_autoplay': {
'key': 'ytdl_force_autoplay',
'path': 'YoutubeDLMaterial.Extra.force_autoplay'
},
'ytdl_enable_downloads_manager': {
'key': 'ytdl_enable_downloads_manager',
@@ -80,6 +76,22 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_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
'ytdl_use_api_key': {
@@ -122,6 +134,42 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_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'
},
// Themes
@@ -306,4 +354,4 @@ const YTDL_ARGS_WITH_VALUES = [
// we're using a Set here for performance
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
exports.CURRENT_VERSION = 'v4.3';
exports.CURRENT_VERSION = 'v4.3.1';

View File

@@ -2,6 +2,7 @@ var fs = require('fs-extra')
var path = require('path')
const { MongoClient } = require("mongodb");
const { uuid } = require('uuidv4');
const _ = require('lodash');
const config_api = require('./config');
var utils = require('./utils')
@@ -58,6 +59,13 @@ const tables = {
name: 'tasks',
primary_key: 'key'
},
notifications: {
name: 'notifications',
primary_key: 'uid'
},
archives: {
name: 'archives'
},
test: {
name: 'test'
}
@@ -148,6 +156,7 @@ exports._connectToDB = async (custom_connection_string = null) => {
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;
} catch(err) {
logger.error(err);
@@ -252,13 +261,16 @@ exports.getFileDirectoriesAndDBs = async () => {
dirs_to_check.push({
basePath: path.join(usersFileFolder, user.uid, 'audio'),
user_uid: user.uid,
type: 'audio'
type: 'audio',
archive_path: utils.getArchiveFolder('audio', user.uid)
});
// add user's video dir to check list
dirs_to_check.push({
basePath: path.join(usersFileFolder, user.uid, 'video'),
type: 'video'
user_uid: user.uid,
type: 'video',
archive_path: utils.getArchiveFolder('video', user.uid)
});
}
} else {
@@ -268,13 +280,15 @@ exports.getFileDirectoriesAndDBs = async () => {
// add audio dir to check list
dirs_to_check.push({
basePath: audioFolderPath,
type: 'audio'
type: 'audio',
archive_path: utils.getArchiveFolder('audio')
});
// add video dir to check list
dirs_to_check.push({
basePath: videoFolderPath,
type: 'video'
type: 'video',
archive_path: utils.getArchiveFolder('video')
});
}
@@ -295,7 +309,8 @@ exports.getFileDirectoriesAndDBs = async () => {
: path.join(subscriptions_base_path, subscription_to_check.isPlaylist ? 'playlists/' : 'channels/', subscription_to_check.name),
user_uid: subscription_to_check.user_uid,
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)
});
}
@@ -350,7 +365,7 @@ exports.addMetadataPropertyToDB = async (property_key) => {
}
}
return await exports.bulkUpdateRecords('files', 'uid', update_obj);
return await exports.bulkUpdateRecordsByKey('files', 'uid', update_obj);
} catch(err) {
logger.error(err);
return false;
@@ -447,8 +462,8 @@ exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null)
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);
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 ext = type === 'audio' ? 'mp3' : 'mp4';
@@ -494,16 +509,22 @@ exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_path = utils.getArchiveFolder(type, uuid);
// get id/extractor from JSON
// 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;
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)
await utils.deleteFileFromArchive(uid, type, archive_path, id, blacklistMode);
if (!blacklistMode) {
// workaround until a files_api is created (using archive_api would make a circular dependency)
await exports.removeAllRecords('archives', {extractor: retrievedExtractor, id: retrievedID, type: type, user_uid: file_obj.user_uid, sub_id: file_obj.sub_id});
// await archive_api.removeFromArchive(retrievedExtractor, retrievedID, type, file_obj.user_uid, file_obj.sub_id);
}
}
if (jsonExists) await fs.unlink(jsonPath);
@@ -534,8 +555,32 @@ 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.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 exports.getRecords('files', filter_obj, false, sort, range, text_search)));
const file_count = await exports.getRecords('files', filter_obj, true);
return {files, file_count};
}
exports.setVideoProperty = async (file_uid, assignment_obj) => {
@@ -550,7 +595,7 @@ exports.setVideoProperty = async (file_uid, assignment_obj) => {
exports.insertRecordIntoTable = async (table, doc, replaceFilter = null) => {
// local db override
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();
return true;
}
@@ -677,7 +722,7 @@ exports.updateRecords = async (table, filter_obj, update_obj) => {
return !!(output['result']['ok']);
}
exports.bulkUpdateRecords = async (table, key_label, update_obj) => {
exports.bulkUpdateRecordsByKey = async (table, key_label, update_obj) => {
// local db override
if (using_local_db) {
local_db.get(table).each((record) => {
@@ -1091,6 +1136,14 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
filtered &= (record[filter_prop].search(new RegExp(filter_prop_value['$regex'], filter_prop_value['$options'])) !== -1);
} else if ('$ne' in filter_prop_value) {
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 {
// handle case of nested property check
@@ -1105,3 +1158,8 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
});
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 { uuid } = require('uuidv4');
const path = require('path');
const mergeFiles = require('merge-files');
const NodeID3 = require('node-id3')
const Mutex = require('async-mutex').Mutex;
@@ -14,6 +13,8 @@ const { create } = require('xmlbuilder2');
const categories_api = require('./categories');
const utils = require('./utils');
const db_api = require('./db');
const notifications_api = require('./notifications');
const archive_api = require('./archive');
const mutex = new Mutex();
let should_check_downloads = true;
@@ -26,6 +27,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) => {
return await mutex.runExclusive(async () => {
const download = {
@@ -84,10 +104,10 @@ exports.resumeDownload = async (download_uid) => {
exports.restartDownload = async (download_uid) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
await exports.clearDownload(download_uid);
const success = !!(await exports.createDownload(download['url'], download['type'], download['options'], download['user_uid']));
const new_download = await exports.createDownload(download['url'], download['type'], download['options'], download['user_uid']);
should_check_downloads = true;
return success;
return new_download;
}
exports.cancelDownload = async (download_uid) => {
@@ -106,9 +126,10 @@ exports.clearDownload = async (download_uid) => {
return await db_api.removeRecord('download_queue', {uid: download_uid});
}
async function handleDownloadError(download_uid, error_message) {
if (!download_uid) return;
await db_api.updateRecord('download_queue', {uid: download_uid}, {error: error_message, finished: true, running: false});
async function handleDownloadError(download, error_message, error_type = null) {
if (!download || !download['uid']) return;
notifications_api.sendDownloadErrorNotification(download, download['user_uid'], 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() {
@@ -154,6 +175,13 @@ async function checkDownloads() {
if (max_concurrent_downloads < 0 || running_downloads_count >= max_concurrent_downloads) break;
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
running_downloads_count++;
if (waiting_download['step_index'] === 0) {
@@ -193,6 +221,20 @@ async function collectInfo(download_uid) {
return;
}
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;
// check if it fits into a category. If so, then get info again using new args
@@ -272,14 +314,14 @@ async function downloadQueuedFile(download_uid) {
clearInterval(download_checker);
if (err) {
logger.error(err.stderr);
await handleDownloadError(download_uid, err.stderr);
await handleDownloadError(download, err.stderr);
resolve(false);
return;
} else if (output) {
if (output.length === 0 || output[0].length === 0) {
// ERROR!
const error_message = `No output received for video download, check if it exists in your archive.`;
await handleDownloadError(download_uid, error_message);
await handleDownloadError(download, error_message, 'no_output');
logger.warn(error_message);
resolve(false);
return;
@@ -288,7 +330,10 @@ async function downloadQueuedFile(download_uid) {
for (let i = 0; i < output.length; i++) {
let output_json = null;
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) {
output_json = null;
}
@@ -341,15 +386,12 @@ async function downloadQueuedFile(download_uid) {
// registers file in DB
const file_obj = await db_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
file_objs.push(file_obj);
}
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive && !options.ignoreArchive) await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
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);
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
file_objs.push(file_obj);
}
let container = null;
@@ -363,7 +405,7 @@ async function downloadQueuedFile(download_uid) {
} else {
const error_message = 'Downloaded file failed to result in metadata object.';
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);
@@ -379,6 +421,10 @@ async function downloadQueuedFile(download_uid) {
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
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 videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
@@ -426,7 +472,8 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
if (customQualityConfiguration) {
qualityPath = ['-f', customQualityConfiguration, '--merge-output-format', 'mp4'];
} 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) {
qualityPath = ['--audio-quality', maxBitrate ? maxBitrate : '0']
}
@@ -463,28 +510,6 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
downloadConfig.splice(0, 0, '--external-downloader', customDownloadingAgent);
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_folder = getArchiveFolder(fileFolderPath, options, user_uid);
const archive_path = path.join(archive_folder, `archive_${type}.txt`);
await fs.ensureDir(archive_folder);
await fs.ensureFile(archive_path);
const blacklist_path = path.join(archive_folder, `blacklist_${type}.txt`);
await fs.ensureFile(blacklist_path);
const merged_path = path.join(archive_folder, `merged_${type}.txt`);
await fs.ensureFile(merged_path);
// merges blacklist and regular archive
let inputPathList = [archive_path, blacklist_path];
await mergeFiles(inputPathList, merged_path);
options.merged_string = await fs.readFile(merged_path, "utf8");
downloadConfig.push('--download-archive', merged_path);
}
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
downloadConfig.push('--write-thumbnail');
}
@@ -559,7 +584,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}`;
logger.error(error);
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);
}
@@ -568,7 +594,8 @@ exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
if (err.stderr) error_message += `\n\n${err.stderr}`;
logger.error(error_message);
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);
}
resolve(null);
}
@@ -634,13 +661,3 @@ exports.generateNFOFile = (info, output_path) => {
const xml = doc.end({ prettyPrint: true });
fs.writeFileSync(output_path, xml);
}
function getArchiveFolder(fileFolderPath, options, user_uid) {
if (options.customArchivePath) {
return path.join(options.customArchivePath);
} else if (user_uid) {
return path.join(fileFolderPath, 'archives');
} else {
return path.join('appdata', 'archives');
}
}

157
backend/notifications.js Normal file
View File

@@ -0,0 +1,157 @@
const db_api = require('./db');
const config_api = require('./config');
const logger = require('./logger');
const utils = require('./utils');
const { uuid } = require('uuidv4');
const fetch = require('node-fetch');
const { gotify } = require("gotify");
const TelegramBot = require('node-telegram-bot-api');
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_type']}\nURL: ${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);
}
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_type = null) => {
if (!notificationEnabled('download_error')) return;
const data = {download_uid: download.uid, download_url: download.url, 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'});
}
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),
});
}

2039
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,22 +27,25 @@
"compression": "^1.7.4",
"config": "^3.2.3",
"express": "^4.17.3",
"feed": "^4.2.2",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^9.0.0",
"gotify": "^1.1.0",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"lowdb": "^1.0.0",
"md5": "^2.2.1",
"merge-files": "^0.1.2",
"mocha": "^9.2.2",
"moment": "^2.29.2",
"moment": "^2.29.4",
"mongodb": "^3.6.9",
"multer": "1.4.5-lts.1",
"node-fetch": "^2.6.7",
"node-id3": "^0.1.14",
"node-schedule": "^2.1.0",
"node-telegram-bot-api": "^0.61.0",
"passport": "^0.4.1",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.0",
"passport-jwt": "^4.0.1",
"passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0",
"progress": "^2.0.3",

View File

@@ -3,6 +3,7 @@ const path = require('path');
const youtubedl = require('youtube-dl');
const config_api = require('./config');
const archive_api = require('./archive');
const utils = require('./utils');
const logger = require('./logger');
@@ -138,28 +139,25 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
const appendedBasePath = getAppendedBasePath(sub, basePath);
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 db_api.removeAllRecords('archives', {sub_id: sub.id});
}
async function deleteSubscriptionFile(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
let basePath = null;
basePath = user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions')
: config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const name = file;
let retrievedID = null;
let retrievedExtractor = null;
await db_api.removeRecord('files', {uid: file_uid});
@@ -178,7 +176,9 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
]);
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);
}
@@ -196,11 +196,9 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
return false;
} else {
// check if the user wants the video to be redownloaded (deleteForever === false)
if (useArchive && retrievedID) {
const archive_path = utils.getArchiveFolder(sub.type, user_uid, sub);
// Remove file ID from the archive file, and write it to the blacklist (if enabled)
await utils.deleteFileFromArchive(file_uid, sub.type, archive_path, retrievedID, deleteForever);
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useArchive && !deleteForever) {
await archive_api.removeFromArchive(retrievedExtractor, retrievedID, sub.type, user_uid, sub.id);
}
return true;
}
@@ -331,8 +329,6 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
let appendedBasePath = getAppendedBasePath(sub, basePath);
const file_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
@@ -368,21 +364,6 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
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) {
downloadConfig.push('--dateafter', sub.timerange);
}
@@ -425,7 +406,14 @@ async function getFilesToDownload(sub, output_jsons) {
if (file_with_path_exists) {
// or maybe just overwrite???
logger.info(`Skipping adding file ${output_json['_filename']} for subscription ${sub.name} as a file with that path already exists.`)
continue;
}
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
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);
}
}
@@ -444,7 +432,12 @@ async function getAllSubscriptions() {
}
async function getSubscription(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: sub.id}, true);
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
return sub;
}
async function getSubscriptionByName(subName, user_uid = null) {

View File

@@ -1,5 +1,7 @@
const db_api = require('./db');
const notifications_api = require('./notifications');
const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
const fs = require('fs-extra');
const logger = require('./logger');
@@ -33,6 +35,28 @@ const TASKS = {
confirm: youtubedl_api.updateYoutubeDL,
title: 'Update youtube-dl',
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
}
}
const defaultOptions = {
all: {
auto_confirm: false
},
delete_old_files: {
blacklist_files: false,
blacklist_subscription_files: false,
threshold_days: ''
}
}
@@ -45,7 +69,7 @@ function scheduleJob(task_key, schedule) {
const dayOfWeek = schedule['data']['dayOfWeek'] != null ? schedule['data']['dayOfWeek'] : null;
const hour = schedule['data']['hour'] != null ? schedule['data']['hour'] : 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 {
logger.error(`Failed to schedule job '${task_key}' as the type '${schedule['type']}' is invalid.`)
return null;
@@ -57,7 +81,7 @@ function scheduleJob(task_key, schedule) {
logger.verbose(`Skipping running task ${task_state['key']} as it is already in progress.`);
return;
}
// remove schedule if it's a one-time task
if (task_state['schedule']['type'] !== 'recurring') await db_api.updateRecord('tasks', {key: task_key}, {schedule: null});
// we're just "running" the task, any confirmation should be user-initiated
@@ -77,9 +101,10 @@ exports.setupTasks = async () => {
const tasks_keys = Object.keys(TASKS);
for (let i = 0; i < tasks_keys.length; 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});
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', {
key: task_key,
title: TASKS[task_key]['title'],
@@ -90,9 +115,17 @@ exports.setupTasks = async () => {
data: null,
error: null,
schedule: null,
options: {}
options: Object.assign(defaultOptions['all'], defaultOptions[task_key] || {})
});
} else {
// verify all options exist in task
for (const key of Object.keys(mergedDefaultOptions)) {
if (!(task_in_db.options && task_in_db.options.hasOwnProperty(key))) {
const option_key = `options.${key}`
await db_api.updateRecord('tasks', {key: task_key}, {[option_key]: mergedDefaultOptions[key]});
}
}
// reset task if necessary
await db_api.updateRecord('tasks', {key: task_key}, {running: false, confirming: false});
@@ -123,15 +156,23 @@ exports.executeTask = async (task_key) => {
exports.executeRun = async (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
if (task_key !== 'backup_local_db') await db_api.updateRecord('tasks', {key: task_key}, {running: true});
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});
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) => {
logger.verbose(`Confirming task ${task_key}`);
await db_api.updateRecord('tasks', {key: task_key}, {error: null})
if (!TASKS[task_key]['confirm']) {
return null;
}
@@ -141,6 +182,7 @@ exports.executeConfirm = async (task_key) => {
await TASKS[task_key].confirm(data);
await db_api.updateRecord('tasks', {key: task_key}, {confirming: false, last_confirmed: Date.now()/1000, data: null});
logger.verbose(`Finished confirming task ${task_key}`);
await notifications_api.sendTaskNotification(task_obj, false);
}
exports.updateTaskSchedule = async (task_key, schedule) => {
@@ -193,4 +235,31 @@ 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 db_api.deleteFile(file_to_remove['uid'], task_obj['options']['blacklist_files'] || (file_to_remove['sub_id'] && file_to_remove['blacklist_subscription_files']));
}
}
}
exports.TASKS = TASKS;

View File

@@ -3,7 +3,6 @@ const low = require('lowdb')
const winston = require('winston');
const path = require('path');
process.chdir('./backend')
const FileSync = require('lowdb/adapters/FileSync');
@@ -38,6 +37,7 @@ var auth_api = require('../authentication/auth');
var db_api = require('../db');
const utils = require('../utils');
const subscriptions_api = require('../subscriptions');
const archive_api = require('../archive');
const fs = require('fs-extra');
const { uuid } = require('uuidv4');
const NodeID3 = require('node-id3');
@@ -66,12 +66,12 @@ const sample_video_json = {
describe('Database', async function() {
describe('Import', async function() {
it('Migrate', async function() {
await db_api.connectToDB();
await db_api.removeAllRecords();
const success = await db_api.importJSONToDB(db.value(), users_db.value());
assert(success);
});
// it('Migrate', async function() {
// await db_api.connectToDB();
// await db_api.removeAllRecords();
// const success = await db_api.importJSONToDB(db.value(), users_db.value());
// assert(success);
// });
it('Transfer to remote', async function() {
await db_api.removeAllRecords('test');
@@ -104,157 +104,190 @@ describe('Database', async function() {
});
});
describe('Export', 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'});
});
// test both local_db and remote_db
const local_db_modes = [false, true];
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);
});
it('Update record', async function() {
await db_api.insertRecordIntoTable('test', {test_update: 'test'});
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'});
});
it('Remove record', async function() {
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()
for (const local_db_mode of local_db_modes) {
let use_local_db = local_db_mode;
describe(`Use local DB - ${use_local_db}`, async function() {
beforeEach(async function() {
if (!use_local_db) {
this.timeout(120000);
await db_api.connectToDB(0);
}
await db_api.removeAllRecords('test');
});
}
const succcess = await db_api.bulkInsertRecordsIntoTable('test', test_records);
const received_records = await db_api.getRecords('test');
assert(succcess && received_records && received_records.length === NUM_RECORDS_TO_ADD);
});
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
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('Add and read record - Nested property', async function() {
this.timeout(120000);
await db_api.insertRecordIntoTable('test', {test_add: 'test', test_nested: {test_key1: 'test1', test_key2: 'test2'}});
const added_record = await db_api.getRecord('test', {test_add: 'test', 'test_nested.test_key1': 'test1', 'test_nested.test_key2': 'test2'});
const not_added_record = await db_api.getRecord('test', {test_add: 'test', 'test_nested.test_key1': 'test1', 'test_nested.test_key2': 'test3'});
assert(added_record['test_add'] === 'test');
assert(!not_added_record);
await db_api.removeRecord('test', {test_add: 'test'});
});
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
const received_records = await db_api.getRecords('test');
assert(received_records && received_records.length === NUM_RECORDS_TO_ADD);
it('Update record', async function() {
await db_api.insertRecordIntoTable('test', {test_update: 'test'});
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);
assert(success);
it('Remove record', async function() {
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);
});
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('Remove records', async function() {
await db_api.insertRecordIntoTable('test', {test_remove: 'test', test_property: 'test'});
await db_api.insertRecordIntoTable('test', {test_remove: 'test', test_property: 'test2'});
await db_api.insertRecordIntoTable('test', {test_remove: 'test'});
const delete_succeeded = await db_api.removeAllRecords('test', {test_remove: 'test'});
assert(delete_succeeded);
const count = await db_api.getRecords('test', {test_remove: 'test'}, true);
assert(count === 0);
});
it('Stats', async function() {
const stats = await db_api.getDBStats();
assert(stats);
});
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('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();
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);
});
console.log(`Insert time: ${(insert_end - insert_start)/1000}s`);
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);
const query_start = Date.now();
const random_record = await db_api.getRecord('test', {uid: random_uid});
const query_end = Date.now();
const received_records = await db_api.getRecords('test');
assert(succcess && received_records && received_records.length === NUM_RECORDS_TO_ADD);
});
console.log(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);
console.log(`Query time: ${(query_end - query_start)/1000}s`);
// makes sure they are added
const received_records = await db_api.getRecords('test');
assert(received_records && received_records.length === NUM_RECORDS_TO_ADD);
success = !!random_record;
success = await db_api.bulkUpdateRecordsByKey('test', 'uid', update_obj);
assert(success);
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() {
@@ -289,8 +322,6 @@ describe('Multi User', async function() {
const playlist_to_test = 'ysabVZz4x';
beforeEach(async function() {
await db_api.connectToDB();
auth_api.initialize(db_api, logger);
subscriptions_api.initialize(db_api, logger);
user = await auth_api.login('admin', 'pass');
});
describe('Authentication', function() {
@@ -593,30 +624,72 @@ describe('Tasks', 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() {
if (fs.existsSync(archive_file_path)) fs.unlinkSync(archive_file_path);
fs.writeFileSync(archive_file_path, 'youtube testing1\nyoutube testing2\nyoutube testing3\n');
if (fs.existsSync(blacklist_file_path)) fs.unlinkSync(blacklist_file_path);
fs.writeFileSync(blacklist_file_path, '');
});
it('Delete from archive', async function() {
await utils.deleteFileFromArchive('N/A', 'video', archive_path, 'testing2', false);
const new_archive = fs.readFileSync(archive_file_path);
assert(!new_archive.includes('testing2'));
await db_api.connectToDB();
await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
});
it('Delete from archive - blacklist', async function() {
await utils.deleteFileFromArchive('N/A', 'video', archive_path, 'testing2', true);
const new_archive = fs.readFileSync(archive_file_path);
const new_blacklist = fs.readFileSync(blacklist_file_path);
assert(!new_archive.includes('testing2'));
assert(new_blacklist.includes('testing2'));
afterEach(async function() {
await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
});
it('Import archive', async function() {
const archive_text = `
testextractor1 testing1
testextractor1 testing2
testextractor2 testing1
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);
});
});

View File

@@ -4,6 +4,7 @@ const ffmpeg = require('fluent-ffmpeg');
const archiver = require('archiver');
const fetch = require('node-fetch');
const ProgressBar = require('progress');
const winston = require('winston');
const config_api = require('./config');
const logger = require('./logger');
@@ -12,7 +13,7 @@ const CONSTS = require('./consts');
const is_windows = process.platform === 'win32';
// replaces .webm with appropriate extension
function getTrueFileName(unfixed_path, type) {
exports.getTrueFileName = (unfixed_path, type) => {
let fixed_path = unfixed_path;
const new_ext = (type === 'audio' ? 'mp3' : 'mp4');
@@ -27,13 +28,13 @@ function getTrueFileName(unfixed_path, type) {
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
if (!(await fs.pathExists(basePath))) return [];
let files = [];
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++) {
let file = located_files[i];
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 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 (full_metadata) {
jsonobj['id'] = id;
files.push(jsonobj);
continue;
}
var upload_date = formatDateString(jsonobj.upload_date);
var upload_date = exports.formatDateString(jsonobj.upload_date);
var isaudio = type === 'audio';
var file_obj = new File(id, jsonobj.title, jsonobj.thumbnail, isaudio, jsonobj.duration, jsonobj.webpage_url, jsonobj.uploader,
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);
files.push(file_obj);
}
return files;
}
async function createContainerZipFile(file_name, container_file_objs) {
exports.createContainerZipFile = async (file_name, container_file_objs) => {
const container_files_to_download = [];
for (let i = 0; i < container_file_objs.length; i++) {
const container_file_obj = container_file_objs[i];
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);
var archive = archiver('zip', {
@@ -95,7 +96,7 @@ async function createZipFile(zip_file_path, file_paths) {
return zip_file_path;
}
function getJSONMp4(name, customPath, openReadPerms = false) {
exports.getJSONMp4 = (name, customPath, openReadPerms = false) => {
var obj = null; // output
if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path');
var jsonPath = path.join(customPath, name + ".info.json");
@@ -110,7 +111,7 @@ function getJSONMp4(name, customPath, openReadPerms = false) {
return obj;
}
function getJSONMp3(name, customPath, openReadPerms = false) {
exports.getJSONMp3 = (name, customPath, openReadPerms = false) => {
var obj = null;
if (!customPath) customPath = config_api.getConfigItem('ytdl_audio_folder_path');
var jsonPath = path.join(customPath, name + ".info.json");
@@ -127,11 +128,11 @@ function getJSONMp3(name, customPath, openReadPerms = false) {
return obj;
}
function getJSON(file_path, type) {
exports.getJSON = (file_path, type) => {
const ext = type === 'audio' ? '.mp3' : '.mp4';
let obj = null;
var jsonPath = removeFileExtension(file_path) + '.info.json';
var alternateJsonPath = removeFileExtension(file_path) + `${ext}.info.json`;
var jsonPath = exports.removeFileExtension(file_path) + '.info.json';
var alternateJsonPath = exports.removeFileExtension(file_path) + `${ext}.info.json`;
if (fs.existsSync(jsonPath))
{
obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
@@ -142,12 +143,12 @@ function getJSON(file_path, type) {
return obj;
}
function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
exports.getJSONByType = (type, name, customPath, openReadPerms = false) => {
return type === 'audio' ? exports.getJSONMp3(name, customPath, openReadPerms) : exports.getJSONMp4(name, customPath, openReadPerms)
}
function getDownloadedThumbnail(file_path) {
const file_path_no_extension = removeFileExtension(file_path);
exports.getDownloadedThumbnail = (file_path) => {
const file_path_no_extension = exports.removeFileExtension(file_path);
let jpgPath = file_path_no_extension + '.jpg';
let webpPath = file_path_no_extension + '.webp';
@@ -163,7 +164,7 @@ function getDownloadedThumbnail(file_path) {
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
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;
}
function fixVideoMetadataPerms(file_path, type) {
exports.fixVideoMetadataPerms = (file_path, type) => {
if (is_windows) return;
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 = [
// 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 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 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);
}
// archive helper functions
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) {
exports.durationStringToNumber = (dur_str) => {
if (typeof dur_str === 'number') return dur_str;
let num_sum = 0;
const dur_str_parts = dur_str.split(':');
@@ -281,23 +231,22 @@ function durationStringToNumber(dur_str) {
return num_sum;
}
function getMatchingCategoryFiles(category, files) {
exports.getMatchingCategoryFiles = (category, files) => {
return files && files.filter(file => file.category && file.category.uid === category.uid);
}
function addUIDsToCategory(category, files) {
const files_that_match = getMatchingCategoryFiles(category, files);
exports.addUIDsToCategory = (category, files) => {
const files_that_match = exports.getMatchingCategoryFiles(category, files);
category['uids'] = files_that_match.map(file => file.uid);
return files_that_match;
}
function getCurrentDownloader() {
exports.getCurrentDownloader = () => {
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
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))
result = result || []
@@ -306,7 +255,7 @@ async function recFindByExt(base, ext, files, result, recursive = true)
if ( (await fs.stat(newbase)).isDirectory() )
{
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
{
@@ -319,17 +268,17 @@ async function recFindByExt(base, ext, files, result, recursive = true)
return result
}
function removeFileExtension(filename) {
exports.removeFileExtension = (filename) => {
const filename_parts = filename.split('.');
filename_parts.splice(filename_parts.length - 1);
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';
}
function createEdgeNGrams(str) {
exports.createEdgeNGrams = (str) => {
if (str && str.length > 3) {
const minGram = 3
const maxGram = str.length
@@ -351,7 +300,7 @@ function createEdgeNGrams(str) {
// ffmpeg helper functions
async function cropFile(file_path, start, end, ext) {
exports.cropFile = async (file_path, start, end, ext) => {
return new Promise(resolve => {
const temp_file_path = `${file_path}.cropped${ext}`;
let base_ffmpeg_call = ffmpeg(file_path);
@@ -380,13 +329,13 @@ async function cropFile(file_path, start, end, ext) {
* setTimeout, but its a promise.
* @param {number} ms
*/
async function wait(ms) {
exports.wait = async (ms) => {
await new Promise(resolve => {
setTimeout(resolve, ms);
});
}
async function checkExistsWithTimeout(filePath, timeout) {
exports.checkExistsWithTimeout = async (filePath, timeout) => {
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
@@ -415,7 +364,7 @@ async function checkExistsWithTimeout(filePath, timeout) {
}
// helper function to download file using fetch
async function fetchFile(url, path, file_label) {
exports.fetchFile = async (url, path, file_label) => {
var len = null;
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...`);
// 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 it doesn't exist and has value, add both arg and value
// - 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();
try {
for (let i = 0; i < new_args.length; i++) {
@@ -483,11 +432,11 @@ function injectArgs(original_args, new_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));
}
const searchObjectByString = function(o, s) {
exports.searchObjectByString = (o, s) => {
s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
s = s.replace(/^\./, ''); // strip a leading dot
var a = s.split('.');
@@ -502,7 +451,7 @@ const searchObjectByString = function(o, s) {
return o;
}
function stripPropertiesFromObject(obj, properties, whitelist = false) {
exports.stripPropertiesFromObject = (obj, properties, whitelist = false) => {
if (!whitelist) {
const new_obj = JSON.parse(JSON.stringify(obj));
for (let field of properties) {
@@ -518,7 +467,7 @@ function stripPropertiesFromObject(obj, properties, whitelist = false) {
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 subsFolderPath = config_api.getConfigItem('ytdl_subscriptions_base_path');
@@ -537,6 +486,21 @@ 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;
}
// objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
@@ -554,38 +518,7 @@ function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, p
this.view_count = view_count;
this.height = height;
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
# follow Semantic Versioning. They should reflect the version the application is using.
# 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,7 @@ services:
- "8998:17442"
image: tzahi12345/youtubedl-material:latest
ytdl-mongo-db:
image: mongo
image: mongo:4
logging:
driver: "none"
container_name: mongo-db

View File

@@ -26,7 +26,7 @@ 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"
curl -o ffmpeg.txz \
--connect-timeout 5 \
--max-time 10 \
--max-time 120 \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \

6952
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,6 +3,7 @@
/* eslint-disable */
export type { AddFileToPlaylistRequest } from './models/AddFileToPlaylistRequest';
export type { Archive } from './models/Archive';
export type { BaseChangePermissionsRequest } from './models/BaseChangePermissionsRequest';
export type { binary } from './models/binary';
export type { body_19 } from './models/body_19';
@@ -26,8 +27,10 @@ export type { DatabaseFile } from './models/DatabaseFile';
export { DBBackup } from './models/DBBackup';
export type { DBInfoResponse } from './models/DBInfoResponse';
export type { DeleteAllFilesResponse } from './models/DeleteAllFilesResponse';
export type { DeleteArchiveItemsRequest } from './models/DeleteArchiveItemsRequest';
export type { DeleteCategoryRequest } from './models/DeleteCategoryRequest';
export type { DeleteMp3Mp4Request } from './models/DeleteMp3Mp4Request';
export type { DeleteNotificationRequest } from './models/DeleteNotificationRequest';
export type { DeletePlaylistRequest } from './models/DeletePlaylistRequest';
export type { DeleteSubscriptionFileRequest } from './models/DeleteSubscriptionFileRequest';
export type { DeleteUserRequest } from './models/DeleteUserRequest';
@@ -50,6 +53,8 @@ export type { GetAllFilesRequest } from './models/GetAllFilesRequest';
export type { GetAllFilesResponse } from './models/GetAllFilesResponse';
export type { GetAllSubscriptionsResponse } from './models/GetAllSubscriptionsResponse';
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 { GetDownloadRequest } from './models/GetDownloadRequest';
export type { GetDownloadResponse } from './models/GetDownloadResponse';
@@ -63,6 +68,7 @@ export type { GetLogsRequest } from './models/GetLogsRequest';
export type { GetLogsResponse } from './models/GetLogsResponse';
export type { GetMp3sResponse } from './models/GetMp3sResponse';
export type { GetMp4sResponse } from './models/GetMp4sResponse';
export type { GetNotificationsResponse } from './models/GetNotificationsResponse';
export type { GetPlaylistRequest } from './models/GetPlaylistRequest';
export type { GetPlaylistResponse } from './models/GetPlaylistResponse';
export type { GetPlaylistsRequest } from './models/GetPlaylistsRequest';
@@ -73,16 +79,22 @@ export type { GetSubscriptionResponse } from './models/GetSubscriptionResponse';
export type { GetTaskRequest } from './models/GetTaskRequest';
export type { GetTaskResponse } from './models/GetTaskResponse';
export type { GetUsersResponse } from './models/GetUsersResponse';
export type { ImportArchiveRequest } from './models/ImportArchiveRequest';
export type { IncrementViewCountRequest } from './models/IncrementViewCountRequest';
export type { inline_response_200_15 } from './models/inline_response_200_15';
export type { LoginRequest } from './models/LoginRequest';
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 { RegisterRequest } from './models/RegisterRequest';
export type { RegisterResponse } from './models/RegisterResponse';
export type { RestartDownloadResponse } from './models/RestartDownloadResponse';
export type { RestoreDBBackupRequest } from './models/RestoreDBBackupRequest';
export { Schedule } from './models/Schedule';
export type { SetConfigRequest } from './models/SetConfigRequest';
export type { SetNotificationsToReadRequest } from './models/SetNotificationsToReadRequest';
export type { SharingToggle } from './models/SharingToggle';
export type { Sort } from './models/Sort';
export type { SubscribeRequest } from './models/SubscribeRequest';
@@ -108,8 +120,10 @@ export type { UpdatePlaylistRequest } from './models/UpdatePlaylistRequest';
export type { UpdaterStatus } from './models/UpdaterStatus';
export type { UpdateServerRequest } from './models/UpdateServerRequest';
export type { UpdateTaskDataRequest } from './models/UpdateTaskDataRequest';
export type { UpdateTaskOptionsRequest } from './models/UpdateTaskOptionsRequest';
export type { UpdateTaskScheduleRequest } from './models/UpdateTaskScheduleRequest';
export type { UpdateUserRequest } from './models/UpdateUserRequest';
export type { UploadCookiesRequest } from './models/UploadCookiesRequest';
export type { User } from './models/User';
export { UserPermission } from './models/UserPermission';
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;
roles?: TableInfo;
download_queue?: TableInfo;
archives?: TableInfo;
};
};

View File

@@ -26,6 +26,7 @@ export type DatabaseFile = {
path: string;
upload_date: string;
uid: string;
user_uid?: string;
sharingEnabled?: boolean;
category?: Category;
view_count?: number;
@@ -40,4 +41,5 @@ export type DatabaseFile = {
* In Kbps
*/
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 */
/* eslint-disable */
import type { SubscriptionRequestData } from './SubscriptionRequestData';
export type DeleteSubscriptionFileRequest = {
file: string;
file_uid?: string;
sub: SubscriptionRequestData;
file_uid: string;
/**
* 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?: string | null;
/**
* Error type, may or may not be set in case of an error
*/
error_type?: string | null;
user_uid?: string;
sub_id?: string;
sub_name?: string;

View File

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

View File

@@ -35,10 +35,18 @@ export type DownloadRequest = {
* Height of the video, if known
*/
selectedHeight?: string;
/**
* Max height that should be used, useful for playlists. selectedHeight will override this.
*/
maxHeight?: string;
/**
* Specify ffmpeg/avconv audio quality
*/
maxBitrate?: string;
type?: FileType;
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;
file_type_filter?: FileTypeFilter;
/**
* If set to true, only gets favorites
*/
favorite_filter?: boolean;
/**
* 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

@@ -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;
user_uid?: string;
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;
minute?: 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

@@ -12,4 +12,5 @@ export type Task = {
data: any;
error: string;
schedule: any;
options?: any;
};

View File

@@ -0,0 +1,8 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type UpdateTaskOptionsRequest = {
task_key: string;
new_options: any;
};

View File

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

View File

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

View File

@@ -13,7 +13,6 @@
}
.theme-slide-toggle {
top: 2px;
left: 10px;
position: relative;
pointer-events: none;
@@ -25,4 +24,20 @@
.top-toolbar {
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 class="mat-elevation-z3" style="position: relative; z-index: 10;">
<mat-toolbar color="primary" class="sticky-toolbar top-toolbar">
<div class="flex-row" width="100%" height="100%">
<div class="flex-column" style="text-align: left; margin-top: 1px;">
<button #hamburgerMenu style="outline: none" *ngIf="router.url.split(';')[0] !== '/player'" mat-icon-button aria-label="Toggle side navigation" (click)="toggleSidenav()"><mat-icon>menu</mat-icon></button>
<button (click)="goBack()" *ngIf="router.url.split(';')[0] === '/player'" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
</div>
<div class="flex-column" style="text-align: center; margin-top: 5px;">
<div style="font-size: 22px; text-shadow: #141414 0.25px 0.25px 1px;">
{{topBarTitle}}
<mat-toolbar class="sticky-toolbar top-toolbar">
<div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
<div class="row" width="100%" height="100%">
<div class="col-6" style="text-align: left; margin-top: 1px;">
<div style="display: flex; align-items: center;">
<button #hamburgerMenu style="outline: none" *ngIf="router.url.split(';')[0] !== '/player'" mat-icon-button aria-label="Toggle side navigation" (click)="toggleSidenav()"><mat-icon>menu</mat-icon></button>
<button (click)="goBack()" *ngIf="router.url.split(';')[0] === '/player'" mat-icon-button><mat-icon>arrow_back</mat-icon></button>
<div 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>
</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 *ngIf="postsService.config && postsService.config.Downloader.use_youtubedl_archive" 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 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>
</mat-toolbar>
@@ -51,7 +55,7 @@
</ng-container>
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && postsService.hasPermission('subscriptions')">
<mat-divider *ngIf="postsService.subscriptions.length > 0"></mat-divider>
<a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-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>
</mat-nav-list>
</mat-sidenav>

View File

@@ -20,6 +20,8 @@ import { SettingsComponent } from './settings/settings.component';
import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.component';
import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component';
import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component';
import { NotificationsComponent } from './components/notifications/notifications.component';
import { ArchiveViewerComponent } from './components/archive-viewer/archive-viewer.component';
@Component({
selector: 'app-root',
@@ -45,9 +47,12 @@ export class AppComponent implements OnInit, AfterViewInit {
enableDownloadsManager = false;
@ViewChild('sidenav') sidenav: MatSidenav;
@ViewChild('notifications') notifications: NotificationsComponent;
@ViewChild('hamburgerMenu', { read: ElementRef }) hamburgerMenuButton: ElementRef;
navigator: string = null;
notification_count = 0;
constructor(public postsService: PostsService, public snackBar: MatSnackBar, private dialog: MatDialog,
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')) {
this.setTheme(localStorage.getItem('theme'));
}
@@ -90,15 +95,15 @@ export class AppComponent implements OnInit, AfterViewInit {
});
}
ngAfterViewInit() {
ngAfterViewInit(): void {
this.postsService.sidenav = this.sidenav;
}
toggleSidenav() {
toggleSidenav(): void {
this.sidenav.toggle();
}
loadConfig() {
loadConfig(): void {
// loading config
this.topBarTitle = this.postsService.config['Extra']['title_top'];
const themingExists = this.postsService.config['Themes'];
@@ -164,7 +169,7 @@ export class AppComponent implements OnInit, AfterViewInit {
this.componentCssClass = theme;
}
flipTheme() {
flipTheme(): void {
if (this.postsService.theme.key === 'default') {
this.setTheme('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();
event.stopPropagation();
}
getSubscriptions() {
}
goBack() {
goBack(): void {
if (!this.navigator) {
this.router.navigate(['/home']);
} else {
@@ -190,23 +190,41 @@ export class AppComponent implements OnInit, AfterViewInit {
}
}
openSettingsDialog() {
const dialogRef = this.dialog.open(SettingsComponent, {
openSettingsDialog(): void {
this.dialog.open(SettingsComponent, {
width: '80vw'
});
}
openAboutDialog() {
const dialogRef = this.dialog.open(AboutDialogComponent, {
openAboutDialog(): void {
this.dialog.open(AboutDialogComponent, {
width: '80vw'
});
}
openProfileDialog() {
const dialogRef = this.dialog.open(UserProfileDialogComponent, {
openProfileDialog(): void {
this.dialog.open(UserProfileDialogComponent, {
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 { MatTableModule } from '@angular/material/table';
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 { ClipboardModule } from '@angular/cdk/clipboard';
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 { SubscriptionInfoDialogComponent } from './dialogs/subscription-info-dialog/subscription-info-dialog.component';
import { SettingsComponent } from './settings/settings.component';
import { MatChipsModule } from '@angular/material/chips';
import { NgxFileDropModule } from 'ngx-file-drop';
import { AvatarModule } from 'ngx-avatars';
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 { UpdateTaskScheduleDialogComponent } from './dialogs/update-task-schedule-dialog/update-task-schedule-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');
@@ -132,7 +140,14 @@ registerLocaleData(es, 'es');
SkipAdButtonComponent,
TasksComponent,
UpdateTaskScheduleDialogComponent,
RestoreDbDialogComponent
RestoreDbDialogComponent,
NotificationsComponent,
NotificationsListComponent,
TaskSettingsComponent,
GenerateRssUrlComponent,
SortPropertyComponent,
OnlyNumberDirective,
ArchiveViewerComponent
],
imports: [
CommonModule,
@@ -170,6 +185,7 @@ registerLocaleData(es, 'es');
MatTableModule,
MatDatepickerModule,
MatChipsModule,
MatBadgeModule,
DragDropModule,
ClipboardModule,
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;
}
::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

@@ -41,7 +41,7 @@ import { Download } from 'api-types';
})
export class DownloadsComponent implements OnInit, OnDestroy {
@Input() uids = null;
@Input() uids: string[] = null;
downloads_check_interval = 1000;
downloads = [];
@@ -200,6 +200,10 @@ export class DownloadsComponent implements OnInit, OnDestroy {
this.postsService.restartDownload(download_uid).subscribe(res => {
if (!res['success']) {
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

@@ -3,29 +3,34 @@
<mat-tab label="Login" i18n-label="Login">
<div style="margin-top: 10px;">
<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>
</div>
<div>
<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>
</div>
</mat-tab>
<mat-tab *ngIf="registrationEnabled" label="Register" i18n-label="Register">
<div style="margin-top: 10px;">
<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>
</div>
<div>
<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>
</div>
<div>
<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>
</div>
</mat-tab>

View File

@@ -12,17 +12,15 @@
}
.login-button-div {
margin-bottom: 10px;
margin-top: 10px;
margin-left: -16px;
margin-right: -16px;
bottom: 0px;
width: 100%;
height: 42px;
position: absolute;
}
.login-button-div > button {
width: 100%;
height: 100%;
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-list>
<mat-list-item role="listitem" *ngFor="let permission of available_permissions">
<h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3>
<span matLine>
<mat-radio-group [disabled]="permission === 'settings' && role.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="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
</mat-radio-group>
</span>
</mat-list-item>
</mat-list>
<div *ngFor="let permission of available_permissions">
<div matListItemTitle>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</div>
<div matListItemLine>
<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-button value="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button>
<mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
</mat-radio-group>
</div>
</div>
</mat-dialog-content>
<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>,
@Inject(MAT_DIALOG_DATA) public data: any) {
@Inject(MAT_DIALOG_DATA) public data: {role: string}) {
if (this.data) {
this.role = this.data.role;
this.available_permissions = this.postsService.available_permissions;

View File

@@ -5,24 +5,23 @@
<div>
<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>
<button mat-raised-button color="accent" (click)="setNewPassword()" [disabled]="newPasswordInput.length === 0"><ng-container i18n="Set new password">Set new password</ng-container></button>
</div>
<div>
<mat-list>
<mat-list-item role="listitem" *ngFor="let permission of available_permissions">
<h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3>
<span matLine>
<mat-radio-group [disabled]="permission === 'settings' && postsService.user.uid === user.uid" (change)="changeUserPermissions($event, permission)" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give user permission for ' + permission">
<mat-radio-button value="default"><ng-container i18n="Use 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="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
</mat-radio-group>
</span>
</mat-list-item>
</mat-list>
<div *ngFor="let permission of available_permissions">
<div matListItemTitle>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</div>
<div matListItemLine>
<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="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button>
<mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
</mat-radio-group>
</div>
</div>
</div>
</mat-dialog-content>

View File

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

View File

@@ -1,6 +1,7 @@
import { Component, OnInit, Inject } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { User } from 'api-types';
@Component({
selector: 'app-manage-user',
@@ -15,17 +16,18 @@ export class ManageUserComponent implements OnInit {
permissions = null;
permissionToLabel = {
'filemanager': 'File manager',
'settings': 'Settings access',
'subscriptions': 'Subscriptions',
'sharing': 'Share files',
'advanced_download': 'Use advanced download mode',
'downloads_manager': 'Use downloads manager'
'filemanager': $localize`File manager`,
'settings': $localize`Settings access`,
'subscriptions': $localize`Subscriptions`,
'sharing': $localize`Share files`,
'advanced_download': $localize`Use advanced download mode`,
'downloads_manager': $localize`Use downloads manager`,
'tasks_manager': $localize`Use tasks manager`,
}
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) {
this.user = this.data.user;
this.available_permissions = this.postsService.available_permissions;
@@ -53,14 +55,14 @@ export class ManageUserComponent implements OnInit {
}
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);
});
}
setNewPassword() {
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.settingNewPassword = false;
});

View File

@@ -1,14 +1,15 @@
<div *ngIf="dataSource; else loading">
<div style="padding: 15px">
<div class="row">
<div class="table table-responsive pb-4 pt-2">
<div class="table table-responsive pb-4 pt-4">
<div class="example-header">
<mat-form-field>
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Search" i18n-placeholder="search field description">
<mat-form-field appearance="outline">
<mat-label i18n="Search">Search</mat-label>
<input matInput (keyup)="applyFilter($event)">
</mat-form-field>
</div>
<div class="example-container mat-elevation-z8">
<div class="mat-elevation-z8" style="margin-right: 15px;">
<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 { ManageUserComponent } from '../manage-user/manage-user.component';
import { ManageRoleComponent } from '../manage-role/manage-role.component';
import { User } from 'api-types';
@Component({
selector: 'app-modify-users',
@@ -31,7 +32,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
// MatPaginator Output
pageEvent: PageEvent;
users: any;
users: User[];
editObject = null;
constructedObject = {};
roles = null;
@@ -62,7 +63,8 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
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.toLowerCase(); // Datasource defaults to lowercase matches
this.dataSource.filter = filterValue;
@@ -94,11 +96,9 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
});
}
finishEditing(user_uid) {
let has_finished = false;
finishEditing(user_uid: string) {
if (this.constructedObject && this.constructedObject['name'] && this.constructedObject['role']) {
if (!isEmptyOrSpaces(this.constructedObject['name']) && !isEmptyOrSpaces(this.constructedObject['role'])) {
has_finished = true;
const index_of_object = this.indexOfUser(user_uid);
this.users[index_of_object] = this.constructedObject;
this.constructedObject = {};
@@ -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) {
const users_index = this.indexOfUser(user_uid);
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
uidInUserList(user_uid) {
uidInUserList(user_uid: string) {
for (let i = 0; i < this.users.length; i++) {
if (this.users[i].uid === user_uid) {
return true;
@@ -134,7 +134,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
}
// 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++) {
if (this.users[i].uid === user_uid) {
return i;
@@ -144,12 +144,12 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
}
setUser(change_obj) {
this.postsService.changeUser(change_obj).subscribe(res => {
this.postsService.changeUser(change_obj).subscribe(() => {
this.getArray();
});
}
manageUser(user_uid) {
manageUser(user_uid: string) {
const index_of_object = this.indexOfUser(user_uid);
const user_obj = this.users[index_of_object];
this.dialog.open(ManageUserComponent, {
@@ -160,17 +160,17 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
});
}
removeUser(user_uid) {
this.postsService.deleteUser(user_uid).subscribe(res => {
removeUser(user_uid: string) {
this.postsService.deleteUser(user_uid).subscribe(() => {
this.getArray();
}, err => {
}, () => {
this.getArray();
});
}
createAndSortData() {
// 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 = [];
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();
});
}
@@ -197,7 +197,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
this.dialogRef.close();
}
public openSnackBar(message: string, action: string = '') {
public openSnackBar(message: string, action = '') {
this.snackBar.open(message, action, {
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="row">
<!-- Sorting -->
<div class="col-12 order-2 col-sm-4 order-sm-1 d-flex justify-content-center">
<div>
<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>
<app-sort-property [(sortProperty)]="sortProperty" [(descendingMode)]="descendingMode" (sortOptionChanged)="sortOptionChanged($event)"></app-sort-property>
</div>
<!-- Files title -->
<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">{{customHeader}}</h4>
</div>
<!-- Search -->
<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">
<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-form-field appearance="outline" [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
<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-form-field>
</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>
<!-- Files -->
<div *ngIf="!selectMode" class="container" style="margin-bottom: 16px">
<div class="row justify-content-center">
<!-- Real cards -->
<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' : '' ]">
<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>
</div>
<div *ngIf="paged_data.length === 0">
<ng-container i18n="No files found">No files found.</ng-container>
</div>
</ng-container>
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0">
<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' : '' ]">
<!-- Fake cards -->
<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>
</div>
</ng-container>
@@ -49,6 +49,7 @@
</div>
<div *ngIf="selectMode">
<!-- If selected files e.g. for creating a playlist -->
<mat-tab-group [(selectedIndex)]="selectedIndex">
<mat-tab label="Order" i18n-label="Order">
<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">
<div class="container">
<div class="row justify-content-center">
<div class="col-10">
<mat-icon class="audio-video-icon">{{(file.type === 'audio' || file.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon>
<div class="col-10 select-file-title">
<mat-icon class="audio-video-icon">{{file.isAudio ? 'audiotrack' : 'movie'}}</mat-icon>
{{file.title}}
</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">
<mat-selection-list *ngIf="!normal_files_received">
<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" [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>
</mat-list-option>
</mat-selection-list>
</ng-container>
@@ -96,16 +97,6 @@
</div>
<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"
[pageSize]="pageSize"
[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 {
width: 132px;
width: 165px;
}
.search-input {
@@ -41,12 +41,6 @@
display: inline-block;
}
.sort-dir-div {
display: inline-block;
position: absolute;
top: 10px;
}
.paginator {
margin-top: 5px;
}
@@ -100,7 +94,7 @@
.remove-item-button {
right: 10px;
position: absolute;
top: 4px;
top: 0px;
}
.playlist-item-text {
@@ -118,4 +112,18 @@
.downloading-spinner {
align-self: center;
position: absolute;
}
.filter-list {
margin-bottom: 10px;
}
.hide {
display: none !important;
}
.select-file-title {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}

View File

@@ -1,11 +1,13 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { DatabaseFile, FileType, FileTypeFilter } from '../../../api-types';
import { DatabaseFile, FileType, FileTypeFilter, Sort } from '../../../api-types';
import { MatPaginator } from '@angular/material/paginator';
import { Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { MatChipListboxChange } from '@angular/material/chips';
import { MatSelectionListChange } from '@angular/material/list';
@Component({
selector: 'app-recent-videos',
@@ -46,35 +48,27 @@ export class RecentVideosComponent implements OnInit {
search_text = '';
searchIsFocused = false;
descendingMode = true;
filterProperties = {
'registered': {
'key': 'registered',
'label': 'Download Date',
'property': 'registered'
fileFilters = {
video_only: {
key: 'video_only',
label: $localize`Video only`,
incompatible: ['audio_only']
},
'upload_date': {
'key': 'upload_date',
'label': 'Upload Date',
'property': 'upload_date'
audio_only: {
key: 'audio_only',
label: $localize`Audio only`,
incompatible: ['video_only']
},
'name': {
'key': 'name',
'label': 'Name',
'property': 'title'
favorited: {
key: 'favorited',
label: $localize`Favorited`
},
'file_size': {
'key': 'file_size',
'label': 'File Size',
'property': 'size'
},
'duration': {
'key': 'duration',
'label': 'Duration',
'property': 'duration'
}
};
filterProperty = this.filterProperties['upload_date'];
fileTypeFilter = 'both';
selectedFilters = [];
sortProperty = 'registered';
playlists = null;
@@ -88,15 +82,17 @@ export class RecentVideosComponent implements OnInit {
}
// set filter property to cached value
const cached_filter_property = localStorage.getItem('filter_property');
if (cached_filter_property && this.filterProperties[cached_filter_property]) {
this.filterProperty = this.filterProperties[cached_filter_property];
const cached_sort_property = localStorage.getItem('sort_property');
if (cached_sort_property) {
this.sortProperty = cached_sort_property;
}
// set file type filter to cached value
const cached_file_type_filter = localStorage.getItem('file_type_filter');
if (this.usePaginator && cached_file_type_filter) {
this.fileTypeFilter = cached_file_type_filter;
const cached_file_filter = localStorage.getItem('file_filter');
if (this.usePaginator && cached_file_filter) {
this.selectedFilters = JSON.parse(cached_file_filter)
} else {
this.selectedFilters = [];
}
const sort_order = localStorage.getItem('recent_videos_sort_order');
@@ -107,6 +103,12 @@ export class RecentVideosComponent implements OnInit {
}
ngOnInit(): void {
if (this.sub_id) {
// subscriptions can't download both audio and video (for now), so don't let users filter for these
delete this.fileFilters['audio_only'];
delete this.fileFilters['video_only'];
}
if (this.postsService.initialized) {
this.getAllFiles();
this.getAllPlaylists();
@@ -161,30 +163,59 @@ export class RecentVideosComponent implements OnInit {
this.searchChangedSubject.next(newvalue);
}
filterOptionChanged(value: string): void {
localStorage.setItem('filter_property', value['key']);
sortOptionChanged(value: Sort): void {
localStorage.setItem('sort_property', value['by']);
localStorage.setItem('recent_videos_sort_order', value['order'] === -1 ? 'descending' : 'ascending');
this.descendingMode = value['order'] === -1;
this.sortProperty = value['by'];
this.getAllFiles();
}
fileTypeFilterChanged(value: string): void {
localStorage.setItem('file_type_filter', value);
this.getAllFiles();
filterChanged(value: string): void {
localStorage.setItem('file_filter', value);
// wait a bit for the animation to finish
setTimeout(() => this.getAllFiles(), 150);
}
toggleModeChange(): void {
this.descendingMode = !this.descendingMode;
localStorage.setItem('recent_videos_sort_order', this.descendingMode ? 'descending' : 'ascending');
this.getAllFiles();
selectedFiltersChanged(event: MatChipListboxChange): void {
// in some cases this function will fire even if the selected filters haven't changed
if (event.value.length === this.selectedFilters.length) return;
if (event.value.length > this.selectedFilters.length) {
const filter_key = event.value.filter(possible_new_key => !this.selectedFilters.includes(possible_new_key))[0];
this.selectedFilters = this.selectedFilters.filter(existing_filter => !this.fileFilters[existing_filter].incompatible || !this.fileFilters[existing_filter].incompatible.includes(filter_key));
this.selectedFilters.push(filter_key);
} else {
this.selectedFilters = event.value;
}
this.filterChanged(JSON.stringify(this.selectedFilters));
}
getFileTypeFilter(): string {
if (this.selectedFilters.includes('audio_only')) {
return 'audio_only';
} else if (this.selectedFilters.includes('video_only')) {
return 'video_only';
} else {
return 'both';
}
}
getFavoriteFilter(): boolean {
return this.selectedFilters.includes('favorited');
}
// get files
getAllFiles(cache_mode = false): void {
this.normal_files_received = cache_mode;
const current_file_index = (this.paginator?.pageIndex ? this.paginator.pageIndex : 0)*this.pageSize;
const sort = {by: this.filterProperty['property'], order: this.descendingMode ? -1 : 1};
const sort = {by: this.sortProperty, order: this.descendingMode ? -1 : 1};
const range = [current_file_index, current_file_index + this.pageSize];
this.postsService.getAllFiles(sort, range, this.search_mode ? this.search_text : null, this.fileTypeFilter as FileTypeFilter, this.sub_id).subscribe(res => {
const fileTypeFilter = this.getFileTypeFilter();
const favoriteFilter = this.getFavoriteFilter();
this.postsService.getAllFiles(sort, this.usePaginator ? range : null, this.search_mode ? this.search_text : null, fileTypeFilter as FileTypeFilter, favoriteFilter, this.sub_id).subscribe(res => {
this.file_count = res['file_count'];
this.paged_data = res['files'];
for (let i = 0; i < this.paged_data.length; i++) {
@@ -286,16 +317,14 @@ export class RecentVideosComponent implements OnInit {
}
deleteAndRedownload(file: DatabaseFile): void {
const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.deleteSubscriptionFile(sub, file.id, false, file.uid).subscribe(() => {
this.postsService.deleteSubscriptionFile(file.uid, false).subscribe(() => {
this.postsService.openSnackBar($localize`Successfully deleted file: ` + file.id);
this.removeFileCard(file);
});
}
deleteForever(file: DatabaseFile): void {
const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.deleteSubscriptionFile(sub, file.id, true, file.uid).subscribe(() => {
this.postsService.deleteSubscriptionFile(file.uid, true).subscribe(() => {
this.postsService.openSnackBar($localize`Successfully deleted file: ` + file.id);
this.removeFileCard(file);
});
@@ -348,8 +377,9 @@ export class RecentVideosComponent implements OnInit {
this.getAllFiles();
}
fileSelectionChanged(event: { option: { _selected: boolean; value: DatabaseFile; } }): void {
const adding = event.option._selected;
fileSelectionChanged(event: MatSelectionListChange): void {
// TODO: make sure below line is possible (_selected is private)
const adding = event.option['_selected'];
const value = event.option.value;
if (adding) {
this.selected_data.push(value.uid);
@@ -385,4 +415,13 @@ export class RecentVideosComponent implements OnInit {
this.selected_data_objs.splice(index, 1);
this.fileSelectionEmitter.emit({new_selection: this.selected_data, thumbnailURL: this.selected_data_objs[0].thumbnailURL});
}
originalOrder = (): number => {
return 0;
}
toggleFavorite(file_obj): void {
file_obj.favorite = !file_obj.favorite;
this.postsService.updateFile(file_obj.uid, {favorite: file_obj.favorite}).subscribe(res => {});
}
}

View File

@@ -0,0 +1,14 @@
<div>
<div style="display: inline-block;">
<mat-form-field appearance="outline" style="width: 165px;">
<mat-select [(ngModel)]="this.sortProperty" (selectionChange)="emitSortOptionChanged()">
<mat-option *ngFor="let sortOption of sortProperties | keyvalue" [value]="sortOption.key">
{{sortOption['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>

View File

@@ -0,0 +1,5 @@
.sort-dir-div {
display: inline-block;
position: absolute;
top: 4px;
}

View File

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

View File

@@ -0,0 +1,54 @@
import { Component, Input, EventEmitter, Output } from '@angular/core';
import { Sort } from 'api-types';
@Component({
selector: 'app-sort-property',
templateUrl: './sort-property.component.html',
styleUrls: ['./sort-property.component.scss']
})
export class SortPropertyComponent {
sortProperties = {
'registered': {
'key': 'registered',
'label': $localize`Download Date`
},
'upload_date': {
'key': 'upload_date',
'label': $localize`Upload Date`
},
'title': {
'key': 'title',
'label': $localize`Name`
},
'size': {
'key': 'size',
'label': $localize`File Size`
},
'duration': {
'key': 'duration',
'label': $localize`Duration`
}
};
@Input() sortProperty = 'registered';
@Input() descendingMode = true;
@Output() sortPropertyChange = new EventEmitter<string>();
@Output() descendingModeChange = new EventEmitter<boolean>();
@Output() sortOptionChanged = new EventEmitter<Sort>();
toggleModeChange(): void {
this.descendingMode = !this.descendingMode;
this.emitSortOptionChanged();
}
emitSortOptionChanged(): void {
if (!this.sortProperty || !this.sortProperties[this.sortProperty]) {
return;
}
this.sortOptionChanged.emit({
by: this.sortProperty,
order: this.descendingMode ? -1 : 1
});
}
}

View File

@@ -0,0 +1,29 @@
<h4 mat-dialog-title><ng-container i18n="Task settings">Task settings - {{task.title}}</ng-container></h4>
<mat-dialog-content>
<div *ngIf="task_key === 'delete_old_files'">
<mat-form-field color="accent">
<mat-label i18n="Delete files older than">Delete files older than</mat-label>
<input [(ngModel)]="new_options['threshold_days']" matInput onlyNumber required>
<span matTextSuffix>days</span>
</mat-form-field>
<div>
<mat-checkbox [(ngModel)]="new_options['blacklist_files']" i18n="Blacklist deleted files" placeholder="Archive mode must be enabled" placeholder-i18n>Blacklist all files</mat-checkbox>
</div>
<div>
<mat-checkbox [disabled]="new_options['blacklist_files']" [(ngModel)]="new_options['blacklist_subscription_files']" i18n="Blacklist deleted subscription files" placeholder="Archive mode must be enabled" placeholder-i18n>Blacklist deleted subscription files</mat-checkbox>
</div>
</div>
<div>
<mat-checkbox [(ngModel)]="new_options['auto_confirm']" i18n="Do not ask for confirmation">Do not ask for confirmation</mat-checkbox>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>
<ng-container *ngIf="optionsChanged()" i18n="Task settings cancel button">Cancel</ng-container>
<ng-container *ngIf="!optionsChanged()" i18n="Task settings close button">Close</ng-container>
</button>
<button mat-button [disabled]="!optionsChanged()" (click)="saveSettings()"><ng-container i18n="Save button">Save</ng-container></button>
</mat-dialog-actions>

View File

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

View File

@@ -0,0 +1,46 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Task } from 'api-types';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-task-settings',
templateUrl: './task-settings.component.html',
styleUrls: ['./task-settings.component.scss']
})
export class TaskSettingsComponent {
task_key: string;
new_options = {};
task: Task = null;
constructor(private postsService: PostsService, @Inject(MAT_DIALOG_DATA) public data: {task: Task}) {
this.task_key = this.data.task.key;
this.task = this.data.task;
if (!this.task.options) {
this.task.options = {};
}
}
ngOnInit(): void {
this.getSettings();
}
getSettings(): void {
this.postsService.getTask(this.task_key).subscribe(res => {
this.task = res['task'];
this.new_options = JSON.parse(JSON.stringify(this.task['options'])) || {};
});
}
saveSettings(): void {
this.postsService.updateTaskOptions(this.task_key, this.new_options).subscribe(() => {
this.getSettings();
}, () => {
this.getSettings();
});
}
optionsChanged(): boolean {
return JSON.stringify(this.new_options) !== JSON.stringify(this.task.options);
}
}

View File

@@ -35,8 +35,8 @@
<mat-cell *matCellDef="let element">
<span *ngIf="element.running || element.confirming"><mat-spinner matTooltip="Busy" i18n-matTooltip="Busy" [diameter]="25"></mat-spinner></span>
<span *ngIf="!(element.running || element.confirming) && element.schedule" style="display: flex">
<ng-container i18n="Scheduled">Scheduled for</ng-container>&nbsp;
{{element.next_invocation | date: 'short'}}<mat-icon style="font-size: 16px; display: inline-flex; align-items: center; padding-left: 5px;" *ngIf="element.schedule.type === 'recurring'">repeat</mat-icon>
<ng-container i18n="Scheduled">Scheduled for</ng-container>
{{element.next_invocation | date: 'short'}}<mat-icon style="font-size: 16px; display: inline-flex; align-items: center; padding-left: 5px; padding-bottom: 6px;" *ngIf="element.schedule.type === 'recurring'">repeat</mat-icon>
</span>
<span *ngIf="!(element.running || element.confirming) && !element.schedule">
<ng-container i18n="Not scheduled">Not scheduled</ng-container>
@@ -62,6 +62,9 @@
<ng-container *ngIf="element.key == 'youtubedl_update_check'">
<ng-container i18n="Update binary to">Update binary to:</ng-container>&nbsp;{{element.data}}
</ng-container>
<ng-container *ngIf="element.key == 'delete_old_files'">
<ng-container i18n="Delete old files">Delete old files:</ng-container>&nbsp;{{element.data.uids.length}}
</ng-container>
</button>
</ng-container>
</div>
@@ -71,6 +74,12 @@
<div class="col-3">
<button (click)="scheduleTask(element)" mat-icon-button matTooltip="Schedule" i18n-matTooltip="Schedule"><mat-icon>schedule</mat-icon></button>
</div>
<div class="col-3">
<button (click)="openTaskSettings(element)" mat-icon-button matTooltip="Settings" i18n-matTooltip="Settings"><mat-icon>settings</mat-icon></button>
</div>
<div *ngIf="element.error" class="col-3">
<button (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button>
</div>
</div>
</div>
</mat-cell>
@@ -80,7 +89,7 @@
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
<mat-paginator [pageSizeOptions]="[5, 10, 20]"
<mat-paginator [pageSizeOptions]="[10, 20]"
showFirstLastButtons
aria-label="Select page of tasks">
</mat-paginator>

View File

@@ -29,4 +29,8 @@ mat-header-cell, mat-cell {
.rounded {
border-radius: 16px 16px 16px 16px !important;
}
::ng-deep mat-row {
height: fit-content !important;
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { Component, EventEmitter, OnInit, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
@@ -8,6 +8,8 @@ import { RestoreDbDialogComponent } from 'app/dialogs/restore-db-dialog/restore-
import { UpdateTaskScheduleDialogComponent } from 'app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component';
import { PostsService } from 'app/posts.services';
import { Task } from 'api-types';
import { TaskSettingsComponent } from '../task-settings/task-settings.component';
import { Clipboard } from '@angular/cdk/clipboard';
@Component({
selector: 'app-tasks',
@@ -29,7 +31,7 @@ export class TasksComponent implements OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
constructor(private postsService: PostsService, private dialog: MatDialog) { }
constructor(private postsService: PostsService, private dialog: MatDialog, private clipboard: Clipboard) { }
ngOnInit(): void {
if (this.postsService.initialized) {
@@ -117,6 +119,14 @@ export class TasksComponent implements OnInit {
});
}
openTaskSettings(task: Task): void {
this.dialog.open(TaskSettingsComponent, {
data: {
task: task
}
});
}
getDBBackups(): void {
this.postsService.getDBBackups().subscribe(res => {
this.db_backups = res['db_backups'];
@@ -157,4 +167,24 @@ export class TasksComponent implements OnInit {
});
}
showError(task: Task): void {
const copyToClipboardEmitter = new EventEmitter<boolean>();
this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: $localize`Error for: ${task['title']}`,
dialogText: task['error'],
submitText: $localize`Copy to clipboard`,
cancelText: $localize`Close`,
closeOnSubmit: false,
onlyEmitOnDone: true,
doneEmitter: copyToClipboardEmitter
}
});
copyToClipboardEmitter.subscribe((done: boolean) => {
if (done) {
this.postsService.openSnackBar($localize`Copied to clipboard!`);
this.clipboard.copy(task['error']);
}
});
}
}

View File

@@ -1,4 +1,5 @@
import { Component, ElementRef, Input, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { DatabaseFile } from 'api-types';
import { PostsService } from 'app/posts.services';
@Component({
@@ -20,7 +21,7 @@ export class TwitchChatComponent implements OnInit, OnDestroy {
scrollContainer = null;
@Input() db_file = null;
@Input() db_file: DatabaseFile = null;
@Input() sub = null;
@Input() current_timestamp = null;
@@ -44,7 +45,7 @@ export class TwitchChatComponent implements OnInit, OnDestroy {
return position > height - threshold;
}
scrollToBottom = (force_scroll) => {
scrollToBottom = (force_scroll = false) => {
if (force_scroll || this.isUserNearBottom()) {
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
}

View File

@@ -5,7 +5,7 @@
<ng-container i18n="Auto-generated label" *ngIf="file_obj.auto">Auto-generated</ng-container>
<ng-container *ngIf="!file_obj.auto">{{file_obj.registered | date:'shortDate' : undefined : locale.ngID}}</ng-container>
</div>
<div *ngIf="loading" class="download-time" style="width: 75%; margin-top: 5px;"><content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" width="250" height="30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></div>
<div *ngIf="loading" class="download-time" style="width: 75%; margin-top: 5px;"><content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" [width]="250" [height]="30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></div>
<!-- The context menu trigger must be kept above the "more info" menu -->
<div style="visibility: hidden; position: fixed"
[style.left]="contextMenuPosition.x"
@@ -21,6 +21,11 @@
</mat-menu>
<mat-menu #action_menu="matMenu">
<ng-container *ngIf="!is_playlist && !loading">
<button (click)="emitToggleFavorite()" mat-menu-item>
<mat-icon>{{file_obj.favorite ? 'favorite_filled' : 'favorite_outline'}}</mat-icon>
<ng-container *ngIf="!file_obj.favorite" i18n="Favorite button">Favorite</ng-container>
<ng-container *ngIf="file_obj.favorite" i18n="Unfavorite button">Unfavorite</ng-container>
</button>
<button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
<button (click)="navigateToSubscription()" mat-menu-item *ngIf="file_obj.sub_id"><mat-icon>{{file_obj.isAudio ? 'library_music' : 'video_library'}}</mat-icon>&nbsp;<ng-container i18n="Go to subscription menu item">Go to subscription</ng-container></button>
<button [disabled]="!availablePlaylists || availablePlaylists.length === 0" [matMenuTriggerFor]="addtoplaylist" mat-menu-item><mat-icon>playlist_add</mat-icon>&nbsp;<ng-container i18n="Add to playlist menu item">Add to playlist</ng-container></button>
@@ -63,11 +68,11 @@
</div>
<div *ngIf="loading" class="img-div">
<content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" width="100" height="55"><svg:rect x="0" y="0" rx="0" ry="0" width="100" height="55" /></content-loader>
<content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" [width]="100" [height]="55"><svg:rect x="0" y="0" rx="0" ry="0" width="100" height="55" /></content-loader>
</div>
<span *ngIf="!loading" [ngClass]="{'max-two-lines': card_size !== 'small', 'max-one-line': card_size === 'small' }">{{card_size === 'large' && file_obj.uploader ? file_obj.uploader + ' - ' : ''}}<strong>{{!is_playlist ? file_obj.title : file_obj.name}}</strong></span>
<span *ngIf="loading" class="title-loading"><content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" width="250" height="30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></span>
<span *ngIf="loading" class="title-loading"><content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" [width]="250" [height]="30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></span>
</div>
</mat-card>
</div>

View File

@@ -21,14 +21,20 @@
.menuButton {
right: 0px;
top: -1px;
width: 32px !important;
height: 32px !important;
position: absolute;
display: flex;
align-items: center;
z-index: 999;
justify-content: center;
padding: 0px !important;
top: 2px;
}
/* Coerce the <span> icon container away from display:inline */
.mat-icon-button .mat-button-wrapper {
/* TODO(mdc-migration): The following rule targets internal classes of button that may no longer apply for the MDC version. */
.mat-mdc-icon-button .mat-button-wrapper {
display: flex;
justify-content: center;
}
@@ -164,3 +170,7 @@
// }
}
::ng-deep.mat-mdc-menu-panel {
max-width: none !important;
}

View File

@@ -9,6 +9,7 @@ import localeES from '@angular/common/locales/es';
import localeDE from '@angular/common/locales/de';
import localeZH from '@angular/common/locales/zh';
import localeNB from '@angular/common/locales/nb';
import { DatabaseFile } from 'api-types';
registerLocaleData(localeGB);
registerLocaleData(localeFR);
@@ -50,6 +51,7 @@ export class UnifiedFileCardComponent implements OnInit {
@Input() jwtString = null;
@Input() availablePlaylists = null;
@Output() goToFile = new EventEmitter<any>();
@Output() toggleFavorite = new EventEmitter<DatabaseFile>();
@Output() goToSubscription = new EventEmitter<any>();
@Output() deleteFile = new EventEmitter<any>();
@Output() addFileToPlaylist = new EventEmitter<any>();
@@ -158,6 +160,10 @@ export class UnifiedFileCardComponent implements OnInit {
this.hide_image = false;
}
emitToggleFavorite() {
this.toggleFavorite.emit(this.file_obj);
}
}
function fancyTimeFormat(time) {

View File

@@ -1 +1 @@
export const CURRENT_VERSION = 'v4.3';
export const CURRENT_VERSION = 'v4.3.1';

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