Compare commits

...

235 Commits

Author SHA1 Message Date
simon
e763c6d13e merge for v0.1.5 build 2022-05-08 08:25:10 +07:00
simon
14387eb8d6 update roadmap 2022-05-08 08:18:17 +07:00
simon
619370c670 add metrics to extended universe 2022-05-08 08:06:59 +07:00
simon
ca13ddec26 update es version 2022-05-08 08:06:37 +07:00
simon
0ab809447a update version 2022-05-07 19:06:43 +07:00
simon
f94bbec672 add cookie wiki link 2022-05-07 19:00:40 +07:00
simon
8cc6e77169 switch to compose v2 2022-05-07 18:33:16 +07:00
simon
5f63dc93ae add Extended Universe section to readme 2022-05-07 10:09:53 +07:00
simon
3722f11a65 cleanup cookie file after task 2022-05-07 09:34:51 +07:00
simon
087043811f avoid unneeded cookie open 2022-05-07 09:34:33 +07:00
simon
3ca86ba91d fix typo, improve wording, add ansible docker playbook 2022-05-06 10:17:23 +07:00
simon
93265a8dca implement cookie validation, #build
Changed:
- Added cookie validation button
- Fixed all the open bug reports! :-)
2022-05-06 08:13:26 +07:00
simon
160c23c36c conform import path style 2022-05-06 08:09:19 +07:00
simon
891911e56b note about cookie validation 2022-05-06 08:02:34 +07:00
simon
94fa191fb8 skip premium videos, clean exit for empty bulk_list, #237 2022-05-05 23:38:10 +07:00
simon
9a2b29c2ae implement channle name change in reindex, #211 2022-05-05 22:59:56 +07:00
simon
7a51368955 fix skip deleting none existing subtitles, add update_media_url 2022-05-05 22:56:22 +07:00
simon
7fa0d0f331 untangle delete subtitles from delete media file, #195 2022-05-05 09:11:37 +07:00
Nathan DeTar
4eba36f7a0 Cookie Validation Button (#242)
* Added cookie validate button & message placeholder

* Added handleCookieValidate(), postCookieValidate()

* show validation only if enbled, add processing message for immediate feedback

Co-authored-by: simon <simobilleter@gmail.com>
2022-05-04 17:07:01 +07:00
simon
6b3ff009da extend cookie functionality, #build
changes:
- integrate watch later and liked videos playlist
- merges sponsorblock state issue #236
- lots of bug fixes
2022-05-03 20:07:45 +07:00
simon
fcac10a6ce add cookie documentation 2022-05-03 18:17:36 +07:00
simon
1613b71942 hide cookie after rescan and download process 2022-05-03 17:32:32 +07:00
simon
83beb53860 simplify _add_single to call update_status in PendingInteract directly 2022-05-02 21:38:03 +07:00
simon
8a4c50779a standardize prints for PostData mapper 2022-05-02 18:39:54 +07:00
simon
ca5b00a373 refresh for PendingInteract delete to avoid race condition, #217 2022-05-02 18:20:56 +07:00
simon
34a1fe9e8e fix thumb re-embed task, #231 2022-05-02 17:38:20 +07:00
simon
34c18e75f5 add LL and WL as valid playlists with cookie 2022-05-02 11:30:38 +07:00
simon
6fbac12314 fix channel_validate in PlaylistSubscription, #223 2022-05-02 11:27:50 +07:00
Nathan DeTar
c4107fc370 Fix to prevent holding onto SB info. (#236) 2022-05-02 10:41:42 +07:00
simon
ca3ea20283 add socket_timeout when passing check_formats to yt-dlp 2022-05-02 09:48:52 +07:00
simon
8f72c5f42c implement cookie import, #build
Changed:
- fix arm64 ffmpeg issue
- added cookie import
- use cookie for all yt-dlp calls
- API: add cookie validation view
2022-04-30 19:34:10 +07:00
simon
14d85e83e6 move ffmpeg install to final container, #234 2022-04-30 19:33:11 +07:00
simon
cd139dfc1c API: add cookie validation view 2022-04-30 19:13:49 +07:00
simon
bc7d90f1f4 raise FileNotFoundError when trying to use unavailable cookie 2022-04-30 19:09:19 +07:00
simon
3a21690309 implement post_process_updated for settings, import cookie 2022-04-30 18:35:04 +07:00
simon
487c81d50b fix FileNotFoundError in cookie hide 2022-04-30 18:34:28 +07:00
simon
088e87bccf add cookiefile to PendingList and VideoDownloader 2022-04-30 17:27:57 +07:00
simon
6cc5fd8890 add cookiefile to yt_obs in YouTubeItem baseclass 2022-04-30 16:40:36 +07:00
simon
4e3d71e0e3 simplify, un-userspaced 2022-04-30 16:38:23 +07:00
simon
9b107b9940 implement cookie validator 2022-04-30 13:05:01 +07:00
simon
43eec5aac0 move CookieHandler to separate module 2022-04-30 12:05:41 +07:00
simon
430fdb6f1c add cookie import to frontend 2022-04-29 18:38:43 +07:00
simon
1fa26cdc44 added basic cookie import functionality 2022-04-23 21:59:59 +07:00
simon
fa9d6df406 api extension, #build
changed:
- add localhost to allowed hosts, flag to disable cors
- API: pagination
- API: sort and filter download list
- API: add task view
2022-04-23 20:53:51 +07:00
simon
eb7313fe6b API: add run task view 2022-04-23 20:50:38 +07:00
simon
71b3654942 add localhost:3000 to allowed 2022-04-23 20:17:31 +07:00
simon
3f99f7edff add localhost to allowed origin, optional cors disable 2022-04-23 20:16:28 +07:00
simon
b76f38e0bc API: fix downloads list sort 2022-04-21 05:45:55 +07:00
simon
1477370376 init data instead of class attribute 2022-04-20 22:51:30 +07:00
simon
04fc6ed26a API: add pagination 2022-04-20 22:43:07 +07:00
simon
7305216485 add link to ES documentation for disk usage 2022-04-19 08:07:47 +07:00
simon
3147df20da skip subtitle segments without duration, take 2 2022-04-18 11:52:13 +07:00
simon
d086f63861 API: sort and query filter download view, delete by filter 2022-04-17 20:10:49 +07:00
simon
40bb3e880e API: implement status update and delete of item in queue 2022-04-17 19:15:40 +07:00
simon
251e98cfa3 add channel-video playlist-video api, #build
Changed:
- Added /api/channel/channel-id/video/
- Added /api/playlist/playlist-id/video/
2022-04-17 10:08:45 +07:00
simon
eb6d6be3b9 handle 404 in channel-video and playlist-video api view 2022-04-17 10:08:24 +07:00
simon
b8ca324aaf add playlist-video and channel-video api views 2022-04-17 09:58:18 +07:00
simon
7d45d23767 process ta_download search results 2022-04-17 05:21:35 +07:00
simon
9224696e33 Merge branch 'api-fix' into testing 2022-04-17 05:03:05 +07:00
simon
50f4e0e5b7 skip base64 generator, hotfix 2022-04-16 13:08:32 +07:00
simon
fcdb31e848 bump release number 2022-04-16 09:30:40 +07:00
simon
70ac33928f update roadmap 2022-04-16 09:29:49 +07:00
simon
59f1c111aa extend timeout for sleep interval 2022-04-16 08:58:35 +07:00
Nathan DeTar
1cf82bdbbd Cleanup & Prevent repeat of skip message. (#227)
* Cleanup & Prevent repeat of skip message.

* skip sponsorblock segments if not indexed

* disable onVideoProgress sb check if not indexed

Co-authored-by: simon <simobilleter@gmail.com>
2022-04-16 08:53:15 +07:00
simon
ef803a157a standard api key processor for list and single views 2022-04-16 00:37:56 +07:00
simon
51ceffd58f update all links to tubearchivist organization 2022-04-15 22:35:13 +07:00
simon
f05c292170 add new home message 2022-04-15 21:35:44 +07:00
simon
b819c2f723 merge testing into master before repo migration 2022-04-15 21:16:28 +07:00
simon
fe610fdaca add log output for backup progress 2022-04-15 20:41:38 +07:00
simon
2fc4ed419e add note about tubearchivist-es to readme 2022-04-15 20:07:59 +07:00
simon
c84fbdfb9c fix _check_get_sb for per channel deactivate 2022-04-15 20:04:01 +07:00
simon
7231b5d245 implement unset of sb per channel overwrite 2022-04-15 20:03:15 +07:00
simon
3d16954c79 change to tubearchivist-es, add comments 2022-04-15 17:56:17 +07:00
simon
c5746089f5 auto dedect new es version 2022-04-15 17:46:16 +07:00
Ainsey11
dc29c6718c Fix: #210 - Download UI Box being removed when file is still processing (#219)
* Fix: https://github.com/bbilly1/tubearchivist/issues/210 - Download UI element is removed when the file has finished downloading, but is still being moved on disk to the storage destination. This means the user is presented with nothing in the UI yet the file is still being processed.

* FIX: Resolving linting issue

* FIX: Set moving message to never expire and then a second message after the move with a 4 second expiry timer.

Co-authored-by: Rob Ainsworth <roba@immjsystems.com>
2022-04-15 15:21:24 +07:00
simon
d4b1d97f5c add mappings for sponsorblock segments 2022-04-15 15:16:31 +07:00
simon
f71644c8bc guarantee stats in mapping for sort 2022-04-15 15:03:43 +07:00
simon
80c76c22ca add wiki documentation for sponsorblock integration 2022-04-14 16:49:27 +07:00
simon
4853a8b12b limit video_daily to below 10k in daily refresh 2022-04-14 16:18:08 +07:00
simon
fd00369859 fix last page pagination link building error, #221 2022-04-14 16:04:21 +07:00
simon
a8a7edb93e skip subtilte events without duration, #196 2022-04-14 15:53:10 +07:00
simon
5f90d21234 update roadmap, extend user created playlist functionality 2022-04-13 16:13:43 +07:00
simon
c59c1fc007 improved sponsorblock indexing, #build
Changed:
- improved more flexible sb timestamps indexing
- fixed per channel sb activate/deactivate
- improved Dockerfile with multi stage build
- added playlist API list view
2022-04-13 15:56:05 +07:00
simon
3007e02fe5 fix per channel sb integration 2022-04-13 15:53:00 +07:00
Nathan DeTar
39c4bd8883 Adjust to API changes for SB integration. (#222) 2022-04-13 10:30:32 +07:00
simon
cd3e9dd024 add playlist API list view 2022-04-13 09:51:15 +07:00
simon
8edde732b6 improved sponsorblock key with additional metadata 2022-04-12 17:47:13 +07:00
simon
3df1df8b5a set explicit mappings for sponsorblock key 2022-04-12 17:45:06 +07:00
simon
1c233dd4f4 move install requirements after ffmpeg curl for better cache use 2022-04-11 17:58:19 +07:00
simon
385f53372c bump django 2022-04-11 17:57:29 +07:00
simon
f79d30ab54 multi stage build Dockerfile for python wheel 2022-04-11 00:05:03 +07:00
Ainsey11
a8ee0da2b8 Addiing clarification on when the speed limit change takes effect (#218)
Relates to https://github.com/bbilly1/tubearchivist/issues/216 

Speed limits work as designed, however the wording in the tooltip is a little unclear to newer users about when the speed limit takes effect on their downloads
2022-04-10 22:33:00 +07:00
simon
0c84386209 add link to installation.md to home 2022-04-10 16:53:07 +07:00
simon
f52a00937f renamed to generic installation.md wiki page 2022-04-10 16:51:40 +07:00
simon
4b33559212 build failure workaround, upgrade pip and setuptools 2022-04-10 16:45:50 +07:00
simon
fd5de99674 implement sponsorblock skipping, #build
Changed:
- sponsorblock frontent implementation, #208
- per channel sponsorblock
- improved scheduler input validation
2022-04-10 16:26:47 +07:00
simon
582011d247 Merge branch 'master' into testing 2022-04-10 16:25:48 +07:00
Nathan DeTar
c316d05549 Added sponsorblock skipping (#208)
* Added sponsorblock skipping.

* Basic framework for sending SB timestamps

* Sponsorblock send timestamp UI improvements

* Added Sponsorblock Icons

* Minor UI tweaks

* Revert UI changes, implement in new UI

* Added notification when sponsor segment is skipped

* Add formatting for notifications & SB messages

* Added SB messages to JS player

* Added SB skip notifcation to videos page.

* Added SB messages to video page

* Change SB messages.

* Check channel_overwrites

* Check Per Channel Settings.

* Cleanup
2022-04-10 16:20:58 +07:00
crocs
77a19a62df added tumbnail reference in the wiki page (#213)
Co-authored-by: user.crocs <user.pairofcrocs@protonmail.com>
2022-04-10 16:19:39 +07:00
simon
31378ac756 better generic schedule validator for impossible input, #209 2022-04-10 15:58:11 +07:00
simon
10385b1414 bump bs4 2022-04-10 15:57:15 +07:00
simon
d8c7b3df0b better error message for wrong es version, #197 2022-04-09 14:12:03 +07:00
simon
99e0c1c90e bump yt-dlp 2022-04-08 23:15:58 +07:00
simon
236215cc4c add config var to video.html template 2022-04-08 22:10:16 +07:00
simon
552636d882 handle 404 in video item API view 2022-04-08 17:55:24 +07:00
simon
9d73dbc45a implement video overwrites index for sponsorblock 2022-04-08 17:19:25 +07:00
simon
859bf2a28d add target _blank to external links 2022-04-08 17:18:33 +07:00
simon
365ebf53a5 implement per channel sponsorblock 2022-04-08 17:17:39 +07:00
simon
fe3e3cfacc fix filenotfounderror for missing subtitles when deleting video 2022-04-08 15:56:34 +07:00
simon
efa240440b fix userspace for vide base config 2022-04-08 15:15:59 +07:00
simon
e2f4dd124a bump beautifulsoup4 2022-04-08 15:15:37 +07:00
simon
90337745bf Merge branch 'master' into testing 2022-04-08 14:17:10 +07:00
crocs
f885965c2b Merge pull request #207 from pairofcrocs/patch-7
Create AlternativeInstallation.md
2022-04-07 15:46:58 -05:00
simon
a07d789e66 add base64 blur video thumb 2022-04-08 00:29:09 +07:00
simon
6a1cb15114 validate hours to not be greater than 23, #209 2022-04-07 23:02:07 +07:00
simon
b1a7a6a148 use cleaned_data for config form parser 2022-04-07 22:30:20 +07:00
simon
39a6fbea74 add links to roadmap issues 2022-04-05 22:56:47 +07:00
simon
573c1514bf simulate sponsorblock voting api endpoints, #build
Changed:
- added simulated post sponsorblock api endpoints
- updated api documentation
- updated roadmap
2022-04-05 22:34:51 +07:00
simon
348a114981 update Roadmap 2022-04-05 22:32:55 +07:00
simon
a84e657577 add documentation for sponsorblock api endpoints 2022-04-05 22:27:55 +07:00
simon
03dd25cff4 implement vote on sponsorblock segments api 2022-04-05 22:25:40 +07:00
simon
286b1cf9b6 simulate sponsorblock post request 2022-04-05 21:51:45 +07:00
simon
bace7d41af add random string generator helper function 2022-04-05 21:51:10 +07:00
simon
738b083a7f create dedicated SponsorBlock class to handle integration 2022-04-05 21:50:40 +07:00
simon
1ac92254ad add global vars for version and url 2022-04-05 18:43:15 +07:00
simon
e404bac7c3 bump celery 2022-04-05 18:35:06 +07:00
simon
0a839ff960 Merge branch 'testing' 2022-04-05 00:05:47 +07:00
simon
a8b62e5cdc better error message on sponsorblock fail 2022-04-04 23:28:32 +07:00
simon
730d12212f implement basic sponsblock indexing, #119 2022-04-04 23:28:32 +07:00
simon
29f8e148da bump libraries 2022-04-04 23:28:32 +07:00
simon
71c441d871 better error message on sponsorblock fail 2022-04-04 23:24:51 +07:00
crocs
00750500a1 Add files via upload 2022-04-04 11:16:24 -05:00
simon
77228ccb65 implement basic sponsblock indexing, #119 2022-04-04 23:08:44 +07:00
crocs
f0fba0644d Create AlternativeInstallation.md 2022-04-04 11:08:36 -05:00
simon
2292e6506d bump libraries 2022-04-04 22:55:43 +07:00
simon
4775c9d830 connection test API endpoing, #build
Changed:
- added /api/ping/ to test your connection
- moved browserextension to dedicated repo
- merges nginx ipv6 fix, #203
2022-04-03 08:24:38 +07:00
simon
cae00b032b return better data for ping api, add to readme 2022-04-01 16:24:40 +07:00
Lickitysplitted
3ae9fe5405 Modify Dockerfile and the nginx.conf file to avoid Nginx default conf… (#203)
* Modify Dockerfile and the nginx.conf file to avoid Nginx default config IPv6 conflict.

* Adjust nginx.conf name and run.sh nginx modification.

* but nginx.conf file back

Co-authored-by: simon <simobilleter@gmail.com>
2022-04-01 11:43:24 +07:00
simon
12fa5748d4 move extension to tubearchivist/browser-extension 2022-04-01 08:48:42 +07:00
simon
ab9c02dfad redefine scope of current state 2022-03-31 23:39:46 +07:00
simon
4de5b2d091 Merge branch 'browserextension' into testing 2022-03-31 23:29:11 +07:00
simon
ed6521acad reimagine browser extension, simple download button 2022-03-31 23:28:53 +07:00
simon
9ab6955125 add connection test api view 2022-03-31 21:13:21 +07:00
simon
46622d9ab2 bump redis lib 2022-03-31 21:13:04 +07:00
simon
607d9b3030 add sync_latest_es function to update tubearchivist-es:latest container 2022-03-31 14:47:19 +07:00
simon
4eb2fb01d9 a note about SSO, LDAP, 2FA in the FAQ 2022-03-30 17:49:03 +07:00
simon
3caafc6e21 update roadmap, rewording 2022-03-30 17:44:54 +07:00
simon
8a3349c6a5 a note about transcoding quality, #138 2022-03-30 17:27:53 +07:00
simon
b46910ec90 update roadmap, add issue links 2022-03-30 17:21:25 +07:00
simon
1cd285aec4 parse api views, #build
Changed:
- return frontend relevant paths for api views
- add video api list view
2022-03-29 18:36:54 +07:00
simon
e51193f988 update roadmap 2022-03-29 18:35:01 +07:00
simon
0783d47ffe bump python version 2022-03-29 18:34:31 +07:00
simon
6f5969f520 add video list api view 2022-03-29 17:05:22 +07:00
simon
0c8fa8f49e clean up prints 2022-03-29 17:03:34 +07:00
simon
80af255f25 reinvent SearchProcess class for API endpoints 2022-03-29 16:48:04 +07:00
simon
cdc617c382 add datestr generator 2022-03-29 16:47:21 +07:00
simon
f1fb504874 fix spacing 2022-03-29 10:33:43 +07:00
simon
e04690d96d add API login view, #build
Changed:
- get token and userid from api endpoint
2022-03-29 10:18:56 +07:00
simon
5dce2441fa add API login view 2022-03-29 10:17:47 +07:00
simon
5d8e314983 be more explicit what you are searching now 2022-03-28 12:43:57 +07:00
simon
9e37c0fab1 add ibracorp review video 2022-03-27 19:43:40 +07:00
simon
cc49c256c1 note about min ES version 7.17 2022-03-27 18:28:17 +07:00
simon
dc8e330b22 tail git tag output, too many versions now 2022-03-27 18:27:36 +07:00
simon
358194468e increase version to v0.1.3 2022-03-26 19:47:50 +07:00
simon
1d3d3b13ed reduce max-complexity to 10 2022-03-26 18:36:10 +07:00
simon
f15bb50cfe fix playlist reindex after PendingList refactor 2022-03-26 18:31:00 +07:00
simon
fcadb5ead8 add random headers for requests outside of yt-dlp 2022-03-26 11:49:53 +07:00
simon
6d874f4b7a add section for per channel customization 2022-03-26 11:13:48 +07:00
simon
a0ed88580f fix index validate_mappings for new nested object 2022-03-26 10:52:57 +07:00
simon
9537c388ab Merge branch 'master' into testing 2022-03-25 17:31:59 +07:00
simon
40d7e6da37 add secondary sort for channel and title, #193 2022-03-25 17:28:17 +07:00
simon
fa60f2e5f8 Style channel configure form, #build
Changed:
- layout of the channel configure form
- hide if no overwrites set
- validate min es version 7.17.x
- improved build to incl debug tools
2022-03-25 15:44:01 +07:00
simon
e4b4430e69 bump python version 2022-03-25 15:33:54 +07:00
simon
bcf9185bd3 implement es version check at startup 2022-03-25 15:33:09 +07:00
simon
337b373628 improve build to install debug tools in testing 2022-03-24 19:54:47 +07:00
simon
59d58f8866 bump redis version 2022-03-24 19:51:45 +07:00
simon
d0f826485f move and rename configure button 2022-03-24 16:10:40 +07:00
simon
d5cd90eb34 style and toggle hide overwrite form 2022-03-24 15:43:15 +07:00
simon
8d1d09e698 extend IndexPaginate to optionally return _source key 2022-03-23 17:17:42 +07:00
simon
fb4c11bd38 refactor ElasticIndex and ElasticBackup to use ElasticWrap and IndexPaginate 2022-03-23 17:17:17 +07:00
simon
bfcc538ed1 fix concurrency issue with subtitle indexing 2022-03-23 16:20:33 +07:00
simon
912c19f6cf use ElasticWrap in FilesystemScanner 2022-03-23 15:56:53 +07:00
simon
fda520ad44 refactor and consolidate Reindex class 2022-03-23 15:48:38 +07:00
simon
1f7d6871cf use ElasticWrap to delete item from pending 2022-03-23 11:09:21 +07:00
simon
5b1c9c64de cleanup, use ElasticWrap 2022-03-23 09:44:31 +07:00
simon
f802c4d596 remove logging 2022-03-22 18:43:29 +07:00
simon
192d379a3e refacter api views to use ElasticWrap 2022-03-22 18:43:16 +07:00
simon
9f5c9b17a5 refactor redis connection, fix sync_redis_state task setup issue 2022-03-22 17:50:54 +07:00
simon
fa25a56126 fail open for in continue watching videos building 2022-03-22 13:07:22 +07:00
simon
75cd9d382d fix duration builder on reindex by ignoring vtt files 2022-03-22 12:59:39 +07:00
simon
22a3d3f6cd implement per channel overwrites, #build
Changes:
- merges per channel overwrite for:
  - download format
  - delete after x days
  - playlist index
- merges major refactor of download queue handeling classes
- merges improved startup checks
2022-03-22 11:49:30 +07:00
simon
fd4f15ab66 refactor StartupCheck into class, lock if already run for multithreading 2022-03-22 10:35:36 +07:00
simon
1f1dfcb54f better playlist scan message, private methods in YoutubeChannel class 2022-03-22 10:07:33 +07:00
simon
3d451e47e5 full playlist refresh and index for channel overwrites 2022-03-22 09:42:41 +07:00
simon
fc10fa62e8 remove now redundant find playlist botton 2022-03-21 19:07:58 +07:00
simon
875c601f4e implement index channel playlist from form 2022-03-21 18:27:00 +07:00
simon
91a9477bba refactor index_channel_playlists task, move to YoutubeChannel class 2022-03-21 17:59:21 +07:00
simon
c9399f61d0 bump es version 2022-03-21 17:04:38 +07:00
simon
fc311a7b7e refactor validate_playlists and move into DownloadPostProcess class 2022-03-21 11:14:44 +07:00
simon
f803c5298b fix missing update_status method call in ignore video 2022-03-21 11:09:16 +07:00
simon
e67d576d79 fix missing video list in index_channel_playlists 2022-03-21 10:47:15 +07:00
simon
7c6041d686 refactor VideoDownloader and implement per channel delete older than 2022-03-19 17:37:28 +07:00
simon
1411262793 remember channel overwrites on rescan, remove unused rescape_all_channels method 2022-03-19 15:12:29 +07:00
simon
9fd7e2b11a rename channel_overwrites attribute 2022-03-19 15:03:38 +07:00
simon
3ba6e0478d fix filesysem rescan function 2022-03-19 14:36:46 +07:00
simon
b68a00073f fix thumbnail validation with new PendingList class 2022-03-19 14:27:37 +07:00
simon
89428297c9 add download format channel overwrite per video 2022-03-18 22:22:41 +07:00
simon
a7945e30e3 fix building missing vid ids list 2022-03-18 22:11:41 +07:00
simon
ac9df4e082 map channel overwrite to video id for later efficient mapping 2022-03-18 21:39:33 +07:00
simon
a6937db5fd use the refactored PendingList class 2022-03-18 18:27:25 +07:00
simon
05b8dbc02f refacor PendingList class into subclasses 2022-03-18 17:19:21 +07:00
simon
1498fadf27 implement channel playlist index overwrite 2022-03-16 12:32:42 +07:00
simon
f0e82caebb add bool true to channel overwrite form parser 2022-03-16 12:32:02 +07:00
simon
2ef8823c2d better channel overwrite update form 2022-03-16 12:09:50 +07:00
simon
dc67293052 add fallback channel overwrite fallback to frontend 2022-03-15 17:45:43 +07:00
simon
544d842a4a rename channel format overwrite field 2022-03-15 17:45:15 +07:00
simon
774220ab1f add channel overwrite form to frontend, store in es 2022-03-15 12:00:48 +07:00
simon
105d5bf3f7 add auto label to subtitle track 2022-03-13 22:56:20 +07:00
simon
026cc378fe add get_overwrites and set_overwrites methods for channel 2022-03-12 22:19:42 +07:00
simon
8170d4913b add fallback total video length if ffmpeg extraction failes 2022-03-12 21:53:03 +07:00
simon
34708dd59f Subtitle parser rewrite, #build
Changes:
- merges fix for progress bar issue on player close
- rewrite subtitle parser to use json3
- combining 5 cues into single es document for more efficient indexing
2022-03-12 20:37:54 +07:00
simon
7595e7501f sort continue watching videos 2022-03-12 20:29:26 +07:00
simon
f6950a2ca5 list all in progress videos 2022-03-12 17:29:34 +07:00
simon
aff0cfb794 fix retiming issue for auto subtitles 2022-03-11 17:47:04 +07:00
simon
d3e9646fb6 private methods for YoutubeSubtitle and SubtitleParser 2022-03-10 20:45:13 +07:00
Nathan DeTar
6e3df21f8c Continue Watching Section (#188)
* Replaced isWatched() function.

* Switched to `updateVideoWatchStatus()` function

* Updated Onclick to `updateVideoWatchStatus(this)`

* Handle `this` input in `updateVideoWatchStatus()`
2022-03-10 20:20:23 +07:00
simon
1ce832b846 fix autocaption extraction, flatten words, #180 2022-03-10 19:58:13 +07:00
simon
6a6c8fa5d8 bump yt-dlp version 2022-03-10 17:39:35 +07:00
simon
c186798e78 rewrite SubtitleParser, #180 2022-03-09 00:25:44 +07:00
simon
8c4607fee9 Continue Watching, #build
Changed:
- merges progress bar fix on 100%
- merges new content area on home for continue watching
2022-03-07 20:58:46 +07:00
simon
40c8e6d146 standardize template jinja indentation 2022-03-07 20:58:17 +07:00
simon
fb12a32d4f use generic data-id and data-status attributes for watched checkbox 2022-03-07 20:48:45 +07:00
simon
437e83b2ae add continue watching section to home page 2022-03-07 20:26:37 +07:00
simon
25bc66ae80 bump django version 2022-03-07 20:23:23 +07:00
Nathan DeTar
ad5e74cb27 Prevent setting progress bar on player close if video is watched. (#182)
* Prevent setting progress bar on close if watched.
2022-02-26 18:11:09 +07:00
76 changed files with 3138 additions and 1927 deletions

View File

@@ -11,7 +11,7 @@ jobs:
flake8-comprehensions isort flake8-comprehensions isort
- run: black --check --diff --line-length 79 . - run: black --check --diff --line-length 79 .
- run: codespell - run: codespell
- run: flake8 . --count --max-complexity=12 --max-line-length=79 - run: flake8 . --count --max-complexity=10 --max-line-length=79
--show-source --statistics --show-source --statistics
- run: isort --check-only --line-length 79 --profile black . - run: isort --check-only --line-length 79 --profile black .
# - run: pip install -r tubearchivist/requirements.txt # - run: pip install -r tubearchivist/requirements.txt

View File

@@ -5,7 +5,7 @@ If you haven't already, the best place to start is the README. This will give yo
## Report a bug ## Report a bug
If you notice something is not working as expected, check to see if it has been previously reported in the [open issues](https://github.com/bbilly1/tubearchivist/issues). If you notice something is not working as expected, check to see if it has been previously reported in the [open issues](https://github.com/tubearchivist/tubearchivist/issues).
If it has not yet been disclosed, go ahead and create an issue. If it has not yet been disclosed, go ahead and create an issue.
If the issue doesn't move forward due to a lack of response, I assume it's solved and will close it after some time to keep the list fresh. If the issue doesn't move forward due to a lack of response, I assume it's solved and will close it after some time to keep the list fresh.
@@ -19,24 +19,24 @@ I have learned the hard way, that working on a dockerized application outside of
This is my setup I have landed on, YMMV: This is my setup I have landed on, YMMV:
- Clone the repo, work on it with your favorite code editor in your local filesystem. *testing* branch is the where all the changes are happening, might be unstable and is WIP. - Clone the repo, work on it with your favorite code editor in your local filesystem. *testing* branch is the where all the changes are happening, might be unstable and is WIP.
- Then I have a VM on KVM hypervisor running standard Ubuntu Server LTS with docker installed. The VM keeps my projects separate and offers convenient snapshot functionality. The VM also offers ways to simulate lowend environments by limiting CPU cores and memory. But you could also just run docker on your host system. - Then I have a VM on KVM hypervisor running standard Ubuntu Server LTS with docker installed. The VM keeps my projects separate and offers convenient snapshot functionality. The VM also offers ways to simulate lowend environments by limiting CPU cores and memory. You can use this [Ansible Docker Ubuntu](https://github.com/bbilly1/ansible-playbooks) playbook to get started quickly. But you could also just run docker on your host system.
- The `Dockerfile` is structured in a way that the actual application code is in the last layer so rebuilding the image with only code changes utilizes the build cache for everything else and will just take a few seconds. - The `Dockerfile` is structured in a way that the actual application code is in the last layer so rebuilding the image with only code changes utilizes the build cache for everything else and will just take a few seconds.
- Take a look at the `deploy.sh` file. I have my local DNS resolve `tubearchivist.local` to the IP of the VM for convenience. To deploy the latest changes and rebuild the application to the testing VM run: - Take a look at the `deploy.sh` file. I have my local DNS resolve `tubearchivist.local` to the IP of the VM for convenience. To deploy the latest changes and rebuild the application to the testing VM run:
```bash ```bash
./deploy.sh test ./deploy.sh test
``` ```
- The command above will also copy the file `tubarchivist/testing.sh` into the working folder of the container. Running this script will install additional debugging tools I regularly use in testing. - The command above will call the docker build command with `--build-arg INSTALL_DEBUG=1` to install additional useful debug tools.
- The `test` argument takes another optional argument to build for a specific architecture valid options are: `amd64`, `arm64` and `multi`, default is `amd64`. - The `test` argument takes another optional argument to build for a specific architecture valid options are: `amd64`, `arm64` and `multi`, default is `amd64`.
- This `deploy.sh` file is not meant to be universally usable for every possible environment but could serve as an idea on how to automatically rebuild containers to test changes - customize to your liking. - This `deploy.sh` file is not meant to be universally usable for every possible environment but could serve as an idea on how to automatically rebuild containers to test changes - customize to your liking.
## Working with Elasticsearch ## Working with Elasticsearch
Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries. Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries.
If you want to run queries in on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it. If you want to run queries on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it.
## Implementing a new feature ## Implementing a new feature
Do you see anything on the roadmap that you would like to take a closer look at but you are not sure, what's the best way to tackle that? Or anything not on there yet you'd like to implement but are not sure how? Open up an issue and we try to find a solution together. Do you see anything on the roadmap that you would like to take a closer look at but you are not sure, what's the best way to tackle that? Or anything not on there yet you'd like to implement but are not sure how? Reach out on Discord and we'll look into it together.
## Making changes ## Making changes
@@ -53,10 +53,10 @@ If you want to see what's in your container, checkout the matching release tag.
## Code formatting and linting ## Code formatting and linting
To keep things clean and consistent for everybody, there is a github action setup to lint and check the changes. You can test your code locally first if you want. For example if you made changes in the **download** module, run To keep things clean and consistent for everybody, there is a github action setup to lint and check the changes. You can test your code locally first if you want. For example if you made changes in the **video** module, run
```shell ```shell
./deploy.sh validate tubearchivist/home/src/download.py ./deploy.sh validate tubearchivist/home/src/index/video.py
``` ```
to validate your changes. If you omit the path, all the project files will get checked. This is subject to change as the codebase improves. to validate your changes. If you omit the path, all the project files will get checked. This is subject to change as the codebase improves.

View File

@@ -1,16 +1,36 @@
# build the tube archivist image from default python slim image # multi stage to build tube archivist
# first stage to build python wheel, copy into final image
FROM python:3.10.2-slim-bullseye
# First stage to build python wheel
FROM python:3.10.4-slim-bullseye AS builder
ARG TARGETPLATFORM ARG TARGETPLATFORM
RUN apt-get update
RUN apt-get install -y --no-install-recommends build-essential gcc
# install requirements
COPY ./tubearchivist/requirements.txt /requirements.txt
RUN pip install --user -r requirements.txt
# build final image
FROM python:3.10.4-slim-bullseye as tubearchivist
ARG TARGETPLATFORM
ARG INSTALL_DEBUG
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
# copy build requirements
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# install distro packages needed # install distro packages needed
RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recommends \ RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recommends \
build-essential \
nginx \ nginx \
atomicparsley \ atomicparsley \
curl && rm -rf /var/lib/apt/lists/* curl \
xz-utils && rm -rf /var/lib/apt/lists/*
# get newest patched ffmpeg and ffprobe builds for amd64 fall back to repo ffmpeg for arm64 # get newest patched ffmpeg and ffprobe builds for amd64 fall back to repo ffmpeg for arm64
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ] ; then \ RUN if [ "$TARGETPLATFORM" = "linux/amd64" ] ; then \
@@ -26,17 +46,20 @@ RUN if [ "$TARGETPLATFORM" = "linux/amd64" ] ; then \
apt-get -y update && apt-get -y install --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* \ apt-get -y update && apt-get -y install --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* \
; fi ; fi
# install debug tools for testing environment
RUN if [ "$INSTALL_DEBUG" ] ; then \
apt-get -y update && apt-get -y install --no-install-recommends \
vim htop bmon net-tools iputils-ping procps \
&& pip install --user ipython \
; fi
# make folders # make folders
RUN mkdir /cache RUN mkdir /cache
RUN mkdir /youtube RUN mkdir /youtube
RUN mkdir /app RUN mkdir /app
# install python dependencies
COPY ./tubearchivist/requirements.txt /requirements.txt
RUN pip install --no-cache-dir -r requirements.txt --src /usr/local/src
# copy config files # copy config files
COPY docker_assets/nginx.conf /etc/nginx/conf.d/ COPY docker_assets/nginx.conf /etc/nginx/sites-available/default
# copy application into container # copy application into container
COPY ./tubearchivist /app COPY ./tubearchivist /app

View File

@@ -2,12 +2,15 @@
<center><h1>Your self hosted YouTube media server</h1></center> <center><h1>Your self hosted YouTube media server</h1></center>
Tube Archivist has a new home: https://github.com/tubearchivist/tubearchivist
## Table of contents: ## Table of contents:
* [Wiki](https://github.com/bbilly1/tubearchivist/wiki) for a detailed documentation, with [FAQ](https://github.com/bbilly1/tubearchivist/wiki/FAQ) * [Wiki](https://github.com/tubearchivist/tubearchivist/wiki) with [FAQ](https://github.com/tubearchivist/tubearchivist/wiki/FAQ)
* [Core functionality](#core-functionality) * [Core functionality](#core-functionality)
* [Screenshots](#screenshots) * [Screenshots](#screenshots)
* [Problem Tube Archivist tries to solve](#problem-tube-archivist-tries-to-solve) * [Problem Tube Archivist tries to solve](#problem-tube-archivist-tries-to-solve)
* [Connect](#connect) * [Connect](#connect)
* [Extended Universe](#extended-universe)
* [Installing and updating](#installing-and-updating) * [Installing and updating](#installing-and-updating)
* [Getting Started](#getting-started) * [Getting Started](#getting-started)
* [Potential pitfalls](#potential-pitfalls) * [Potential pitfalls](#potential-pitfalls)
@@ -24,6 +27,9 @@
* Play videos * Play videos
* Keep track of viewed and unviewed videos * Keep track of viewed and unviewed videos
## Tube Archivist on YouTube
[![ibracorp-youtube-video-thumb](assets/tube-archivist-ibracorp-O8H8Z01c0Ys.jpg)](https://www.youtube.com/watch?v=O8H8Z01c0Ys)
## Screenshots ## Screenshots
![home screenshot](assets/tube-archivist-screenshot-home.png?raw=true "Tube Archivist Home") ![home screenshot](assets/tube-archivist-screenshot-home.png?raw=true "Tube Archivist Home")
*Home Page* *Home Page*
@@ -44,8 +50,12 @@
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface. Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface.
## Connect ## Connect
- [Discord](https://discord.gg/AFwz8nE7BK): Connect with us on our brand new Discord server. - [Discord](https://discord.gg/AFwz8nE7BK): Connect with us on our Discord server.
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our brand new Subreddit. - [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
## Extended Universe
- [Browser Extension](https://github.com/tubearchivist/browser-extension) Tube Archivist Companion, for [Firefox](https://addons.mozilla.org/addon/tubearchivist-companion/) and [Chrome](https://chrome.google.com/webstore/detail/tubearchivist-companion/jjnkmicfnfojkkgobdfeieblocadmcie)
- [Tube Archivist Metrics](https://github.com/tubearchivist/tubearchivist-metrics) to create statistics in Prometheus/OpenMetrics format.
## Installing and updating ## Installing and updating
Take a look at the example `docker-compose.yml` file provided. Use the *latest* or the named semantic version tag. The *unstable* tag is for intermediate testing and as the name implies, is **unstable** and not be used on your main installation but in a [testing environment](CONTRIBUTING.md). Take a look at the example `docker-compose.yml` file provided. Use the *latest* or the named semantic version tag. The *unstable* tag is for intermediate testing and as the name implies, is **unstable** and not be used on your main installation but in a [testing environment](CONTRIBUTING.md).
@@ -73,6 +83,10 @@ Should that not be an option, the Tube Archivist container takes these two addit
Changing any of these two environment variables will change the files *nginx.conf* and *uwsgi.ini* at startup using `sed` in your container. Changing any of these two environment variables will change the files *nginx.conf* and *uwsgi.ini* at startup using `sed` in your container.
### Elasticsearch ### Elasticsearch
**Note**: Newest Tube Archivist depends on Elasticsearch version 7.17 to provide an automatic updatepath in the future.
Use `bbilly1/tubearchivist-es` to automatically get the recommended version, or use the official image with the version tag in the docker-compose file.
Stores video meta data and makes everything searchable. Also keeps track of the download queue. Stores video meta data and makes everything searchable. Also keeps track of the download queue.
- Needs to be accessible over the default port `9200` - Needs to be accessible over the default port `9200`
- Needs a volume at **/usr/share/elasticsearch/data** to store data - Needs a volume at **/usr/share/elasticsearch/data** to store data
@@ -93,11 +107,11 @@ For some architectures it might be required to run Redis JSON on a nonstandard p
### Updating Tube Archivist ### Updating Tube Archivist
You will see the current version number of **Tube Archivist** in the footer of the interface so you can compare it with the latest release to make sure you are running the *latest and greatest*. You will see the current version number of **Tube Archivist** in the footer of the interface so you can compare it with the latest release to make sure you are running the *latest and greatest*.
* There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there. * There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there.
* All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. * All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version.
### Alternative installation instructions: ### Alternative installation instructions:
- **arm64**: The Tube Archivist container is multi arch, so is Elasticsearch. RedisJSON doesn't offer arm builds, you can use `bbilly1/rejson`, an unofficial rebuild for arm64. - **arm64**: The Tube Archivist container is multi arch, so is Elasticsearch. RedisJSON doesn't offer arm builds, you can use `bbilly1/rejson`, an unofficial rebuild for arm64.
- **Synology**: There is a [discussion thread](https://github.com/bbilly1/tubearchivist/discussions/48) with Synology installation instructions. - **Synology**: There is a [discussion thread](https://github.com/tubearchivist/tubearchivist/discussions/48) with Synology installation instructions.
- **Unraid**: The three containers needed are all in the Community Applications. First install `TubeArchivist RedisJSON` followed by `TubeArchivist ES`, and finally you can install `TubeArchivist`. If you have unraid specific issues, report those to the [support thread](https://forums.unraid.net/topic/114073-support-crocs-tube-archivist/ "support thread"). - **Unraid**: The three containers needed are all in the Community Applications. First install `TubeArchivist RedisJSON` followed by `TubeArchivist ES`, and finally you can install `TubeArchivist`. If you have unraid specific issues, report those to the [support thread](https://forums.unraid.net/topic/114073-support-crocs-tube-archivist/ "support thread").
- **Helm Chart**: There is a Helm Chart available at https://github.com/insuusvenerati/helm-charts. Mostly self-explanatory but feel free to ask questions in the discord / subreddit. - **Helm Chart**: There is a Helm Chart available at https://github.com/insuusvenerati/helm-charts. Mostly self-explanatory but feel free to ask questions in the discord / subreddit.
@@ -125,7 +139,9 @@ chown 1000:0 /path/to/mount/point
This will match the permissions with the **UID** and **GID** of elasticsearch within the container and should fix the issue. This will match the permissions with the **UID** and **GID** of elasticsearch within the container and should fix the issue.
### Disk usage ### Disk usage
The Elasticsearch index will turn to *read only* if the disk usage of the container goes above 95% until the usage drops below 90% again. Similar to that, TubeArchivist will become all sorts of messed up when running out of disk space. There are some error messages in the logs when that happens, but it's best to make sure to have enough disk space before starting to download. The Elasticsearch index will turn to *read only* if the disk usage of the container goes above 95% until the usage drops below 90% again, you will see error messages like `disk usage exceeded flood-stage watermark`, [link](https://github.com/tubearchivist/tubearchivist#disk-usage).
Similar to that, TubeArchivist will become all sorts of messed up when running out of disk space. There are some error messages in the logs when that happens, but it's best to make sure to have enough disk space before starting to download.
## Getting Started ## Getting Started
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example: 1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example:
@@ -138,24 +154,29 @@ bestvideo[VCODEC=avc1]+bestaudio[ACODEC=mp4a]/mp4
5. Enjoy your archived collection! 5. Enjoy your archived collection!
## Roadmap ## Roadmap
We have come far, nonetheless we are not short of ideas on how to improve and extend this project: We have come far, nonetheless we are not short of ideas on how to improve and extend this project. Issues waiting for you to be tackled in no particular order:
- [ ] User roles - [ ] User roles
- [ ] Podcast mode to serve channel as mp3 - [ ] Podcast mode to serve channel as mp3
- [ ] Implement [PyFilesystem](https://github.com/PyFilesystem/pyfilesystem2) for flexible video storage - [ ] Implement [PyFilesystem](https://github.com/PyFilesystem/pyfilesystem2) for flexible video storage
- [ ] Implement [Apprise](https://github.com/caronc/apprise) for notifications - [ ] Implement [Apprise](https://github.com/caronc/apprise) for notifications ([#97](https://github.com/tubearchivist/tubearchivist/issues/97))
- [ ] Add [SponsorBlock](https://sponsor.ajay.app/) integration - [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
- [ ] Implement per channel settings - [ ] Auto play or play next link ([#226](https://github.com/tubearchivist/tubearchivist/issues/226))
- [ ] User created playlists
- [ ] Auto play or play next link
- [ ] SSO / LDAP support
- [ ] Show similar videos on video page - [ ] Show similar videos on video page
- [ ] Multi language support - [ ] Multi language support
- [ ] Show total video downloaded vs total videos available in channel - [ ] Show total video downloaded vs total videos available in channel
- [ ] Make items in grid row configurable - [ ] Make items in grid row configurable to use more of the screen
- [ ] Add statistics of index - [ ] Add statistics of index
- [ ] Implement complete offline media file import from json file ([#138](https://github.com/tubearchivist/tubearchivist/issues/138))
- [ ] Filter and query in search form, search by url query ([#134](https://github.com/tubearchivist/tubearchivist/issues/134), [#139](https://github.com/tubearchivist/tubearchivist/issues/139))
- [ ] Auto ignore videos by keyword ([#163](https://github.com/tubearchivist/tubearchivist/issues/163))
- [ ] Custom searchable notes to videos, channels, playlists ([#144](https://github.com/tubearchivist/tubearchivist/issues/144))
- [ ] Download video comments
Implemented: Implemented:
- [X] Add passing browser cookies to yt-dlp [2022-05-08]
- [X] Add [SponsorBlock](https://sponsor.ajay.app/) integration [2022-04-16]
- [X] Implement per channel settings [2022-03-26]
- [X] Subtitle download & indexing [2022-02-13] - [X] Subtitle download & indexing [2022-02-13]
- [X] Fancy advanced unified search interface [2022-01-08] - [X] Fancy advanced unified search interface [2022-01-08]
- [X] Auto rescan and auto download on a schedule [2021-12-17] - [X] Auto rescan and auto download on a schedule [2021-12-17]

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -1,26 +0,0 @@
# Tube Archivist Companion
A browser extension to directly add videos from YouTube to Tube Archivist.
## MVP or better *bearly viable product*
This is a proof of concept with the following functionality:
- Add your Tube Archivist connection details in the addon popup
- Inject a download button into youtube search results page
- Clicking the button will automatically add the video to the your download queue
## Test this extension
- Firefox
- Open `about:debugging#/runtime/this-firefox`
- Click on *Load Temporary Add-on*
- Select the *manifest.json* file to load the addon.
- Chrome / Chromium
- Open `chrome://extensions/`
- Toggle *Developer mode* on top right
- Click on *Load unpacked*
- Open the folder containing the *manifest.json* file.
## Help needed
This is only minimally useful in this state. Join us on our Discord and please help us improve that.
## Note:
- For mysterious reasons sometimes the download buttons will only load when refreshing the YouTube search page and not on first load... Hence: Help needed!
- For your testing environment only for now: Point the extension to the newest *unstable* build.

View File

@@ -1,94 +0,0 @@
/*
extension background script listening for events
*/
console.log("running background.js");
let browserType = getBrowser();
// boilerplate to dedect browser type api
function getBrowser() {
if (typeof chrome !== "undefined") {
if (typeof browser !== "undefined") {
console.log("detected firefox");
return browser;
} else {
console.log("detected chrome");
return chrome;
}
} else {
console.log("failed to dedect browser");
throw "browser detection error"
};
}
// send post request to API backend
async function sendPayload(url, token, payload) {
const rawResponse = await fetch(url, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": token,
"mode": "no-cors"
},
body: JSON.stringify(payload)
});
const content = await rawResponse.json();
return content;
}
// read access storage and send
function forwardRequest(payload) {
console.log("running forwardRequest");
function onGot(item) {
console.log(item.access);
const url = `${item.access.url}:${item.access.port}/api/download/`;
console.log(`sending to ${url}`);
const token = `Token ${item.access.apiKey}`;
sendPayload(url, token, payload).then(content => {
console.log(content);
})
};
function onError(error) {
console.local("failed to get access details");
console.log(`Error: ${error}`);
};
browserType.storage.local.get("access", function(result) {
onGot(result)
});
}
// listen for messages
browserType.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
console.log("responding from background.js listener");
console.log(JSON.stringify(request));
if (request.download) {
console.log("found new download task");
let payload = {
"data": [
{
"youtube_id": request.download["videoId"],
"status": "pending",
}
]
}
forwardRequest(payload);
};
}
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1,102 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 350 75" style="enable-background:new 0 0 350 75;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#2C9D8A;}
</style>
<g id="Layer_1_00000061458428874772671790000018384579824765206440_">
<g id="archivist">
<path class="st0" d="M218.5,15.2v4.6c-0.7-0.5-1.5-0.8-2.5-0.8c-1.8,0-3,1.4-3.9,3.1v8.9h-4v-16h4v3c1.1-1.9,2.6-3.3,4.5-3.3
C217.3,14.7,217.9,14.9,218.5,15.2z"/>
<path class="st0" d="M233.6,16.3V20c-1.1-0.8-2.4-1.4-4.4-1.4c-2.4,0-4.5,1.9-4.5,4.5c0,2.6,1.9,4.5,4.5,4.5c2,0,3.3-0.6,4.4-1.4
v3.7c-0.9,0.8-2.3,1.6-4.7,1.6c-4.8,0-8.4-3.9-8.4-8.4c0-4.5,3.6-8.5,8.4-8.5C231.3,14.6,232.7,15.6,233.6,16.3z"/>
<path class="st0" d="M251.8,21.2v9.8h-4l0-9.3c0-2.2-1.1-3.2-2.9-3.2c-1.4,0-2.7,1.1-3.6,2.3v10.1h-4V8.2h4v9.1
c1-1.3,2.7-2.7,4.8-2.7C249.3,14.6,251.8,16.9,251.8,21.2z"/>
<path class="st0" d="M262.1,31.1h-4V18.5h-2.5v-3.4h6.5V31.1z M256.9,10.1c0-1.5,1.2-2.6,2.6-2.6s2.7,1.2,2.7,2.6
c0,1.5-1.2,2.7-2.7,2.7S256.9,11.6,256.9,10.1z"/>
<path class="st0" d="M272.2,31.1l-7.5-16h4.7l4,9.9l4.2-9.9h4.7l-7.6,16H272.2z"/>
<path class="st0" d="M290.8,31.1h-4V18.5h-2.5v-3.4h6.5V31.1z M285.6,10.1c0-1.5,1.2-2.6,2.6-2.6c1.5,0,2.7,1.2,2.7,2.6
c0,1.5-1.2,2.7-2.7,2.7C286.8,12.8,285.6,11.6,285.6,10.1z"/>
<path class="st0" d="M305.5,16.1v3.5c-1-0.7-2.5-1.5-4.1-1.5c-1.5,0-2.5,0.8-2.5,1.7c0,0.7,0.4,1.2,1.8,1.7l1.8,0.6
c2.5,0.7,3.6,2.2,3.6,4.2c0,2.9-2.8,5.1-6.2,5.1c-2.2,0-4.4-0.8-5.5-1.6v-3.5c1.2,0.8,3.4,1.6,4.9,1.6c1.6,0,2.5-0.6,2.5-1.6
c0-0.8-0.7-1.2-2.1-1.6l-2-0.7c-2.4-0.8-3.2-2.3-3.2-4.3c0-3,2.5-5.1,6.1-5.1C302.7,14.6,304.5,15.3,305.5,16.1z"/>
<path class="st0" d="M319.8,15.1v3.4h-4.4v6.9c0,1.4,0.7,2.3,2,2.3c1.1,0,1.9-0.3,2.6-0.7v3.2c-0.8,0.8-1.9,1.4-3.8,1.4
c-2.9,0-4.8-2-4.8-5.2v-8h-2.9v-2.1c2.1-0.7,4.2-2.7,5.2-6h1.7v4.7H319.8z"/>
<polygon id="A" class="st0" points="183.9,31.1 192.7,8 196.6,8 205.4,31.1 201,31.1 194.7,13.5 188.3,31.1 "/>
<path class="st1" d="M197.7,26.2l-1.8,1.6v-5.2v-0.7v-1.8c0-0.8-0.6-1.4-1.3-1.4s-1.3,0.6-1.3,1.4v1.8v0.7v5.2l-1.8-1.6l-1.2,1.2
l4.3,3.9l4.3-3.9L197.7,26.2z"/>
</g>
<g id="tube">
<g id="T_00000039852614045324270430000005353782644941079486_">
<polygon class="st0" points="94.7,8.3 94.7,12.3 101.3,12.3 101.3,18.4 105.5,18.4 105.5,12.3 111.9,12.3 111.9,8.3 "/>
<rect x="101.3" y="22.6" class="st0" width="4.1" height="9"/>
<path class="st1" d="M105.8,21.7H101c-0.7,0-1.3-0.6-1.3-1.3l0,0c0-0.7,0.6-1.3,1.3-1.3h4.8c0.7,0,1.3,0.6,1.3,1.3l0,0
C107.1,21.1,106.5,21.7,105.8,21.7z"/>
</g>
<path class="st0" d="M127.4,24.5c0,4.2-3,6.8-7.1,6.8c-4.1,0-7.1-2.6-7.1-6.8v-9.4h4l0,8.7c0,2.4,1.3,3.7,3.1,3.7
c1.8,0,3.1-1.2,3.1-3.7v-8.7h4V24.5z"/>
<path class="st0" d="M136,17.3c1-1.8,2.8-3.1,5.2-3.1c3.9,0,7.3,3,7.3,8.4c0,5.4-3.4,8.7-7.5,8.7c-1.8,0-3.7-1-4.9-2.3v1.8h-4.1
V7.7h4.1V17.3z M136,22.9v2.3c1.2,1.2,2.5,2.1,4.1,2.1c2.5,0,4.1-2.3,4.1-4.7c0-2.4-1.6-4.3-4-4.3C138,18.3,135.9,20,136,22.9z"/>
<path class="st0" d="M167,24.1h-11.7c0.4,2.2,2.3,3.7,4.8,3.7c2.4,0,3.9-1,4.9-1.6v3.7c-0.9,0.7-2.6,1.6-5.1,1.6
c-4.8,0-8.7-3.5-8.7-8.4c0-4.7,3.6-8.7,8.2-8.7c4.8,0,7.6,3.3,7.6,8.1V24.1z M163.1,21.5c-0.2-2.3-2-3.5-3.8-3.5
c-2.1,0-3.5,1.4-3.9,3.5H163.1z"/>
</g>
<g id="icon">
<path class="st1" d="M27.6,33.7l1,0.4l0.5-2l-6.7-2l-0.7,2l0.9,0.4C20.9,44.4,25,54.5,34,61.9l2.9-4
C29.6,51.4,25.8,43.2,27.6,33.7z"/>
<path class="st1" d="M41.2,60.5l0.6-0.8L40,58.4L35.7,64l1.8,1.3l0.6-0.7c10.7,5.7,21.9,4.7,31.8-1.5L66.8,59
C58.3,63.9,49.9,65.3,41.2,60.5z"/>
<path class="st1" d="M70.8,55.6l-0.7-0.7l-1.8,1.3l4.3,5.9l1.8-1.3L73.9,60c8.7-8.5,11.2-18.7,8.4-30l-4.9,1.4
C79.3,40.9,78,48.7,70.8,55.6z"/>
<path class="st1" d="M75.5,26.5l-0.9,0.4l0.6,2.4l7.2-2.1l-0.7-2.4l-0.9,0.3c-5.7-10.7-15-17.4-26.6-17.9v5.4
C63.8,13.4,70.9,17.6,75.5,26.5z"/>
<path class="st1" d="M49,12.8l0.1,0.9l2.3-0.1v-7h-2.3v0.9C40,8.8,28.6,14.8,24,27.6l4.9,1.4C32.4,19,40.2,14.7,49,12.8z"/>
<polygon id="A_00000111884739883312047940000001222474654974762168_" class="st0" points="48,54 60.6,20.9 66.1,20.9 78.8,54
72.5,54 63.4,28.8 54.3,54 "/>
<g id="T">
<polygon class="st0" points="27.6,20.4 27.6,26.1 37.1,26.1 37.1,35 43.1,35 43.1,26.1 52.4,26.1 52.4,20.4 "/>
<rect x="37.1" y="41" class="st0" width="6" height="13"/>
</g>
<path class="st1" d="M43.5,39.7h-6.9c-1,0-1.9-0.8-1.9-1.9l0,0c0-1,0.8-1.9,1.9-1.9h6.9c1,0,1.9,0.8,1.9,1.9l0,0
C45.4,38.9,44.5,39.7,43.5,39.7z"/>
<path class="st1" d="M67.8,47l-2.5,2.3v-7.5v-1v-2.5c0-1.1-0.8-2-1.9-2s-1.9,0.9-1.9,2v2.5v1v7.5L58.9,47l-1.7,1.7l6.2,5.6
l6.1-5.6L67.8,47z"/>
</g>
</g>
<g id="Layer_2">
<g>
<path class="st0" d="M142.1,58c0,6-4.3,9.6-10.1,9.6c-5.8,0-10.1-3.6-10.1-9.6V44.7h5.7l0,12.3c0,3.4,1.8,5.2,4.4,5.2
c2.6,0,4.4-1.7,4.4-5.2V44.7h5.7V58z"/>
<path class="st0" d="M154.1,48.5c1.3-2.4,3.8-4.3,7.2-4.3c5.4,0,10,4.1,10,11.6c0,7.4-4.6,12-10.4,12c-2.4,0-5.1-1.3-6.8-3.2V67
h-5.6V35.2h5.6V48.5z M154.1,56.1v3.2c1.6,1.7,3.4,2.9,5.6,2.9c3.5,0,5.7-3.2,5.7-6.5c0-3.4-2.2-6-5.6-6
C156.9,49.7,154.1,52.1,154.1,56.1z"/>
<path class="st0" d="M190.4,46.2v5c-1.4-0.9-3.5-2-5.7-2c-2.1,0-3.4,1.1-3.4,2.4c0,1,0.6,1.7,2.6,2.3l2.5,0.8c3.4,1,5.1,3,5.1,5.9
c0,4.1-3.9,7.2-8.7,7.2c-3,0-6.1-1.1-7.6-2.3v-4.9c1.7,1.1,4.7,2.3,6.9,2.3c2.3,0,3.5-0.8,3.5-2.3c0-1.2-1-1.6-3-2.3l-2.7-1
c-3.4-1.1-4.5-3.2-4.5-6.1c0-4.2,3.5-7.1,8.6-7.1C186.6,44.1,189.2,45.1,190.4,46.2z"/>
<path class="st0" d="M212.9,46.5v5.2c-1.5-1.1-3.4-2-6.1-2c-3.4,0-6.2,2.7-6.2,6.3s2.6,6.3,6.2,6.3c2.7,0,4.6-0.8,6.1-1.9v5.1
c-1.2,1.1-3.3,2.3-6.6,2.3c-6.7,0-11.7-5.4-11.7-11.7s5-11.9,11.7-11.9C209.7,44.1,211.7,45.5,212.9,46.5z"/>
<path class="st0" d="M232.5,44.9v6.4c-1-0.7-2.1-1.1-3.4-1.1c-2.6,0-4.2,1.9-5.5,4.4V67h-5.6V44.7h5.6v4.2
c1.5-2.7,3.6-4.6,6.3-4.6C230.9,44.3,231.7,44.5,232.5,44.9z"/>
<path class="st0" d="M244.7,67h-5.6V49.5h-3.5v-4.8h9.1V67z M237.5,37.8c0-2,1.6-3.7,3.7-3.7s3.7,1.6,3.7,3.7s-1.7,3.7-3.7,3.7
S237.5,39.8,237.5,37.8z"/>
<path class="st0" d="M257,48.5c1.3-2.4,3.8-4.3,7.2-4.3c5.4,0,10,4.1,10,11.6c0,7.4-4.6,12-10.4,12c-2.4,0-5.1-1.3-6.8-3.2V67
h-5.6V35.2h5.6V48.5z M257,56.1v3.2c1.6,1.7,3.4,2.9,5.6,2.9c3.5,0,5.7-3.2,5.7-6.5c0-3.4-2.2-6-5.6-6
C259.8,49.7,257,52.1,257,56.1z"/>
<path class="st0" d="M299.8,57.6h-16.2c0.6,3.1,3.1,5,6.5,5c3.3,0,5.4-1.4,6.8-2.3v5.1c-1.2,0.9-3.6,2.3-7,2.3
c-6.6,0-12-4.9-12-11.6c0-6.5,5-12,11.3-12c6.6,0,10.5,4.6,10.5,11.1V57.6z M294.4,54c-0.2-3.2-2.7-4.8-5.3-4.8
c-2.9,0-4.8,1.9-5.4,4.8H294.4z"/>
<path class="st0" d="M319.5,44.9v6.4c-1-0.7-2.1-1.1-3.4-1.1c-2.6,0-4.2,1.9-5.5,4.4V67H305V44.7h5.6v4.2c1.5-2.7,3.6-4.6,6.3-4.6
C317.9,44.3,318.7,44.5,319.5,44.9z"/>
</g>
<g>
<path class="st0" d="M102.6,54l2.4,0.9v-6.1c0-0.2,0-0.3,0.1-0.5c-1.2-0.6-1.9-1.4-1.9-2.6c0-2.3,2.6-3.2,4.9-3.2
c3.1,0,5.4,1.3,7.5,2.7v-6.1c-1.9-1.2-4.8-2.3-8.3-2.3c-5.5,0-10.5,3.5-10.5,9C96.7,49.3,98,52.3,102.6,54z"/>
<path class="st0" d="M111.1,51l-1.9-0.8v6.6c0.8,0.5,1.3,1.2,1.3,2.1c0,1.9-2,2.9-4.5,2.9c-3.5,0-6.7-1.5-9.3-3.4v6.1
c2.4,1.7,5.7,3.1,9.2,3.1c6.1,0,11.2-3.8,11.2-9C117.2,55.3,115.3,52.8,111.1,51z"/>
</g>
</g>
<path class="st1" d="M105.5,56.1v-6.9c0-1,0.8-1.9,1.6-1.9l0,0c0.9,0,1.6,0.9,1.6,1.9v6.9c0,1-0.8,1.9-1.6,1.9l0,0
C106.2,58,105.5,57.1,105.5,56.1z"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 374.4 375.1" style="enable-background:new 0 0 374.4 375.1;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path class="st0" d="M187.5,0C84.2-0.1,0.1,83.8,0,187.2C-0.2,290.8,83.7,375,187.1,375.1c103.3,0,187.2-83.9,187.3-187.4
C374.5,84.3,290.7,0.2,187.5,0z M187.9,304.5c-16,0-29.2-13.3-29.2-29.3c0.1-16,13.2-29,29.1-29.1c16,0,29.2,13.3,29.2,29.3
C216.9,291.4,203.8,304.5,187.9,304.5z M238.2,195.7c-4.8,2.9-9.7,5.8-14.5,8.7c-9.1,5.5-13.6,13.6-13.9,24.2c0,0.8,0,1.7,0,2.7
c-14.6,0-29,0-43.8,0c0.1-2.8,0.2-5.6,0.4-8.5c0.8-10.6,3.2-20.9,8.4-30.3c5.9-11,14.9-18.9,25.5-25.1c4.1-2.4,8.4-4.7,12.5-7.1
c12.7-7.7,13.1-19.1,6.2-29.9c-6.5-10.3-16.3-14.9-28.1-15.7c-8-0.5-15.7,0.7-22.6,4.9c-10.1,6.2-15,15.6-16.9,26.9
c-0.1,0.4-0.1,0.9-0.2,1.6c-14.5-1.9-28.9-3.8-43.9-5.8c1.4-5.4,2.5-10.8,4.2-15.9c5.2-14.9,13.6-27.8,25.8-38
c11.1-9.3,23.9-14.7,38.2-16.6c2.3-0.3,4.6-0.6,6.9-1c3.2,0,6.4,0,9.6,0c4.4,0.6,8.9,1.1,13.3,1.9c29.8,5.9,55.4,28.1,61.4,59.3
C271.5,157.5,260.7,182.2,238.2,195.7z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 384 384" style="enable-background:new 0 0 384 384;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<path class="st0" d="M191.57,379.5C88.15,379.44,4.32,295.25,4.46,191.57C4.6,88.18,88.66,4.28,191.95,4.42
c103.29,0.14,187.02,84.23,186.93,187.72C378.8,295.6,294.86,379.55,191.57,379.5z M244.55,271.24
c4.89,5.91,9.44,11.19,13.73,16.69c1.8,2.32,3.72,3.11,6.64,2.93c28.39-1.75,53.17-11.56,72.96-32.55c1.12-1.19,2.08-3.2,1.97-4.76
c-0.79-11.71-1.27-23.48-2.97-35.07c-5.07-34.65-16.23-67.36-32.74-98.23c-0.73-1.36-1.88-2.67-3.15-3.55
c-19.34-13.43-40.61-21.62-64.16-23.63c-3.19-0.27-5.5,0.48-6.89,3.09c10.27,3.97,20.59,7.65,30.65,11.94
c10.1,4.32,19.64,9.74,28.21,16.92c-64.82-29.49-129.53-29.49-194.34,0.02c17.77-13.75,38.02-22.07,59.27-28.39
c-1.74-3.15-4.12-3.86-7.32-3.58c-23.15,2.02-44.13,9.94-63.2,23.08c-1.76,1.21-3.51,2.96-4.4,4.86
c-5.36,11.39-11.02,22.69-15.57,34.41c-12.22,31.43-19.35,64-19.75,97.86c-0.02,1.71,0.88,3.85,2.07,5.1
c18.74,19.74,41.98,29.72,68.88,32.19c5.73,0.53,9.64-0.46,12.96-5.26c3.36-4.86,7.35-9.28,11.2-14.05
c-22.46-8.05-39.37-18.53-42.93-26.17c63.97,36.11,128.17,36.13,192.51-0.02C276.69,258.45,261.71,265.95,244.55,271.24z"/>
<path class="st0" d="M237.28,232.74c-14.34-0.02-26.07-12-25.9-26.45c0.17-14.67,11.86-26.25,26.56-26.33
c13.63-0.07,25.18,12,25.21,26.35C263.18,220.98,251.66,232.76,237.28,232.74z"/>
<path class="st0" d="M146.11,232.74c-14.35,0.05-25.9-11.7-25.92-26.38c-0.02-14.3,11.56-26.46,25.16-26.41
c14.85,0.06,26.74,11.96,26.62,26.66C171.84,221.04,160.32,232.69,146.11,232.74z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 384 384" style="enable-background:new 0 0 384 384;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<path class="st0" d="M244.51,375.15c0-10.59-0.31-21.12,0.08-31.61c0.5-13.65,0.02-26.89-10.41-37.88
c8.18-2.06,15.59-3.84,22.95-5.83c3.51-0.95,6.95-2.22,10.37-3.47c27.26-9.99,40.88-31.15,45.79-58.73
c2.71-15.21,4.11-30.82-0.74-45.88c-2.65-8.23-6.01-16.69-11.08-23.5c-3.83-5.14-3.43-9.05-2.23-14.27
c2.86-12.48,0.71-24.71-2.64-36.79c-0.99-3.58-3.09-4.99-6.88-4.6c-11.45,1.18-21.48,6.12-31.34,11.6
c-1.42,0.79-2.97,1.46-4.16,2.52c-5.55,4.99-11.4,5.13-18.58,3.42c-25.28-6.04-50.67-5.14-75.85,1.12
c-3.59,0.89-6.27,0.27-9.31-1.86c-11.08-7.73-22.87-14.02-36.37-16.31c-0.13-0.02-0.26-0.07-0.4-0.09
c-7.12-1.23-8.12-0.76-10.11,6.16c-3.51,12.23-5.18,24.6-1.55,37.14c1.14,3.94,0.44,7.16-2.01,10.61
c-23.14,32.46-16.36,80.65,0.11,103.41c12.16,16.81,29.02,26.2,48.63,31.06c5.49,1.36,11.11,2.15,17.1,3.28
c-0.24,0.79-0.43,1.81-0.85,2.71c-3.15,6.91-6.27,13.83-9.6,20.65c-0.67,1.38-2.06,3.14-3.34,3.35c-7.3,1.18-14.65,2.46-22.02,2.7
c-6.5,0.21-12.09-3.32-16.37-8c-5.11-5.59-9.65-11.73-14.19-17.83c-6.07-8.15-14.2-12.42-24.05-13.3c-2.22-0.2-4.58,1.12-7.99,2.05
c2.69,3.23,4.46,6.17,6.96,8.2c10.32,8.35,17.42,19.1,22.26,31.24c4.72,11.85,14.1,16.55,25.31,19.27
c9.24,2.24,18.51,1.5,27.83,0.78c6.07-0.47,6.09-0.22,6.09,5.98c0.01,5.58,0,11.17,0,16.81C86.7,368.22,7.24,301.96,4.67,198.28
C2.19,97.88,77.65,13.69,175.94,5.34c103.69-8.81,192.95,68.52,202.14,171.23C387.22,278.72,317.17,355.78,244.51,375.15z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 384 384" style="enable-background:new 0 0 384 384;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<path class="st0" d="M240.82,274c-3.86,3.78-8.45,6.33-13.43,8.26c-9.77,3.79-19.98,5.34-30.38,5.85
c-5.42,0.26-10.85,0.12-16.26-0.44c-5.89-0.61-11.72-1.56-17.42-3.18c-7.65-2.17-14.86-5.18-20.68-10.86
c-0.98-0.96-2.26-1.44-3.64-1.48c-2.72-0.08-4.71,1.17-5.94,3.55c-1.14,2.2-0.8,4.87,0.94,6.7c1.28,1.35,2.7,2.59,4.17,3.73
c5.88,4.57,12.58,7.49,19.65,9.63c11.11,3.37,22.52,4.66,36.04,4.85c2.63-0.18,7.2-0.42,11.76-0.82c6.75-0.6,13.38-1.86,19.88-3.8
c6.56-1.95,12.83-4.56,18.42-8.57c1.94-1.4,3.82-2.93,5.52-4.61c2.44-2.4,2.35-6.62,0.01-8.95
C247.1,271.52,243.37,271.51,240.82,274z"/>
<path class="st0" d="M192.39,4.72C89.03,4.5,5.07,88.18,4.86,191.61c-0.21,103.43,83.4,187.46,186.75,187.67
c103.35,0.21,187.31-83.46,187.53-186.89C379.36,88.96,295.74,4.93,192.39,4.72z M327.66,225.47c0.14,1.32,0.27,2.64,0.41,3.96
c0.53,4.98,0.43,9.96-0.23,14.91c-2.24,16.85-10.03,30.98-21.45,43.28c-8.94,9.62-19.42,17.22-30.95,23.42
c-13.96,7.52-28.79,12.64-44.3,15.87c-8.46,1.76-17,2.91-25.62,3.51c-6.58,0.46-13.16,0.6-19.75,0.39
c-6.8-0.22-13.58-0.8-20.34-1.72c-11.93-1.64-23.6-4.38-34.96-8.39c-14.15-4.99-27.38-11.73-39.34-20.86
c-9.74-7.44-18.15-16.12-24.63-26.59c-5.11-8.26-8.64-17.14-10.2-26.75c-1.1-6.76-1.23-13.55-0.14-20.33c0.03-0.21,0-0.44,0-0.7
c-10.51-5.25-17.19-13.54-19.32-25.14c-1.71-9.32,0.32-17.97,5.8-25.72c11.73-16.6,35.97-19.53,51.41-4.96
c27.77-19.23,59-27.5,92.46-28.92c0.46-2.12,0.92-4.2,1.36-6.28c3.46-16.26,6.92-32.53,10.37-48.79
c1.82-8.57,3.63-17.14,5.43-25.71c0.26-1.23,0.59-2.42,1.44-3.4c1.68-1.92,3.83-2.34,6.2-1.85c6.31,1.3,12.62,2.66,18.92,3.99
c10.35,2.19,20.7,4.39,31.05,6.59c2,0.42,4,0.84,6.09,1.28c3.7-6.86,9.29-11.3,16.92-12.93c5.54-1.19,10.89-0.41,15.96,2.14
c10.03,5.06,15.32,16.56,12.65,27.42c-2.74,11.12-12.68,18.73-24.01,18.65c-11.69-0.08-23.34-9.38-24.16-23.21
c-1.43-0.6-49.41-10.81-50.5-10.7c-5.15,24.12-10.32,48.27-15.51,72.57c0.64,0.04,1.07,0.07,1.51,0.09
c19.74,0.93,38.91,4.68,57.37,11.79c11.05,4.25,21.5,9.64,31.21,16.44c0.29,0.2,0.6,0.38,0.94,0.59
c8.62-7.75,18.62-11.01,30.07-8.89c9.47,1.76,16.97,6.78,22.22,14.87c5.67,8.73,7.06,18.26,4.21,28.25
C343.42,213.65,337.03,220.82,327.66,225.47z"/>
<path class="st0" d="M162.26,218.68c0.08-13.49-10.62-24.33-24.09-24.41c-13.86,0-24.09,11.31-24.22,23.89
c-0.13,12.52,9.75,24.24,23.96,24.42C151.22,242.73,162.19,231.9,162.26,218.68z"/>
<path class="st0" d="M245.16,194.56c-13.42,0.03-24.09,10.83-24.11,24.24c-0.02,12.58,10.14,24.01,24.17,24.05
c13.33,0.04,24.16-10.89,24.12-24.26C269.32,205.3,258.48,194.52,245.16,194.56z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TubeArchivist Companion</title>
<link rel="stylesheet" href="https://fonts.googleapi.com/css?family=Sen" type="text/css">
<style>
body {
font-family: Sen-Regular, sans-serif;
background-color: #00202f;
color: #97d4c8;
}
.container {
padding: 10px;
min-width: 300px;
}
hr {
height: 1px;
color: white;
background-color: white;
}
.login-form {
display: grid;
grid-template-columns: 1fr 1fr;
}
.login-form label,
.login-form input {
margin: 3px 0;
}
.submit button {
margin: 10px auto 15px auto;
display: block;
border-radius: 0;
padding: 5px 13px;
border: none;
cursor: pointer;
background-color: #259485;
color: #ffffff;
}
.submit button:hover {
background-color: #97d4c8;
transform: scale(1.05);
color: #00202f;
}
.icons {
display: flex;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
}
.icons img {
width: 25px;
}
</style>
</head>
<body>
<div class="container">
<img src="/images/logo.svg" alt="ta-logo">
<hr>
<form class="login-form">
<label for="url">Tube Archivist Url:</label>
<input type="text" id="url" name="url">
<label for="port">Tube Archivist Port:</label>
<input type="text" id="port" name="port">
<label for="api-key">API key:</label>
<input type="text" id="api-key" name="api-key">
</form>
<div class="submit">
<button onclick="storeLogin()" id="save-login">Save</button>
</div>
<div class="icons">
<div>
<a href="https://www.reddit.com/r/TubeArchivist/" target="_blank">
<img src="/images/social/reddit.svg" alt="reddit-icon">
</a>
<a href="https://discord.gg/AFwz8nE7BK" target="_blank">
<img src="/images/social/discord.svg" alt="discord-icon">
</a>
<a href="https://github.com/bbilly1/tubearchivist" target="_blank">
<img src="/images/social/github.svg" alt="github-icon">
</a>
</div>
<div>
<a href="#">
<img src="/images/question.svg" alt="question-icon">
</a>
</div>
</div>
</div>
<script type="text/javascript" src="popup.js"></script>
</body>
</html>

View File

@@ -1,25 +0,0 @@
{
"manifest_version": 2,
"name": "TubeArchivist Companion",
"description": "Interact with your selhosted TA server.",
"version": "0.0.1",
"icons": {
"128": "/images/icon128.png"
},
"browser_action": {
"default_icon": "/images/icon.png",
"default_popup": "index.html"
},
"permissions": [
"storage"
],
"content_scripts": [
{
"matches": ["https://www.youtube.com/results*"],
"js": ["script.js"]
}
],
"background": {
"scripts": ["background.js"]
}
}

View File

@@ -1,63 +0,0 @@
/*
Loaded into popup index.html
*/
let browserType = getBrowser();
// boilerplate to dedect browser type api
function getBrowser() {
if (typeof chrome !== "undefined") {
if (typeof browser !== "undefined") {
console.log("detected firefox");
return browser;
} else {
console.log("detected chrome");
return chrome;
}
} else {
console.log("failed to dedect browser");
throw "browser detection error"
};
}
// store access details
document.getElementById("save-login").addEventListener("click", function () {
console.log("save form");
let toStore = {
"access": {
"url": document.getElementById("url").value,
"port": document.getElementById("port").value,
"apiKey": document.getElementById("api-key").value
}
};
console.log(toStore);
browserType.storage.local.set(toStore, function() {
console.log("Stored connection details: " + JSON.stringify(toStore));
});
})
// fill in form
document.addEventListener("DOMContentLoaded", async () => {
console.log("executing dom loader");
function onGot(item) {
if (!item.access) {
console.log("no access details found");
return
}
console.log(item.access);
document.getElementById("url").value = item.access.url;
document.getElementById("port").value = item.access.port;
document.getElementById("api-key").value = item.access.apiKey;
};
function onError(error) {
console.log(`Error: ${error}`);
};
browserType.storage.local.get("access", function(result) {
onGot(result)
});
})

View File

@@ -1,72 +0,0 @@
/*
content script running on youtube.com
*/
console.log("running script.js");
let browserType = getBrowser();
setTimeout(function(){
console.log("running setimeout")
linkFinder();
return false;
}, 2000);
// boilerplate to dedect browser type api
function getBrowser() {
if (typeof chrome !== "undefined") {
if (typeof browser !== "undefined") {
console.log("detected firefox");
return browser;
} else {
console.log("detected chrome");
return chrome;
}
} else {
console.log("failed to dedect browser");
throw "browser detection error"
};
}
// event handler for download task
function addToDownload(videoId) {
console.log(`downloading ${videoId}`);
let payload = {
"download": {
"videoId": videoId
}
};
browserType.runtime.sendMessage(payload);
}
// find relevant links to add a button to
function linkFinder() {
console.log("running link finder");
var allLinks = document.links;
for (let i = 0; i < allLinks.length; i++) {
const linkItem = allLinks[i];
const linkDest = linkItem.getAttribute("href");
if (linkDest.startsWith("/watch?v=") && linkItem.id == "video-title") {
var dlButton = document.createElement("button");
dlButton.innerText = "download";
var videoId = linkDest.split("=")[1];
dlButton.setAttribute("data-id", videoId);
dlButton.setAttribute("id", "ta-dl-" + videoId);
dlButton.onclick = function(event) {
var videoId = this.getAttribute("data-id");
addToDownload(videoId);
};
linkItem.parentElement.appendChild(dlButton);
}
}
}

View File

@@ -12,7 +12,7 @@
# docker buildx inspect --bootstrap # docker buildx inspect --bootstrap
# more details: # more details:
# https://github.com/bbilly1/tubearchivist/issues/6 # https://github.com/tubearchivist/tubearchivist/issues/6
set -e set -e
@@ -33,7 +33,7 @@ function sync_blackhole {
. -e ssh "$host":tubearchivist . -e ssh "$host":tubearchivist
echo "$PASS" | ssh "$host" 'sudo -S docker buildx build --platform linux/amd64 -t bbilly1/tubearchivist:latest tubearchivist --load 2>/dev/null' echo "$PASS" | ssh "$host" 'sudo -S docker buildx build --platform linux/amd64 -t bbilly1/tubearchivist:latest tubearchivist --load 2>/dev/null'
echo "$PASS" | ssh "$host" 'sudo -S docker-compose up -d 2>/dev/null' echo "$PASS" | ssh "$host" 'sudo -S docker compose up -d 2>/dev/null'
} }
@@ -43,7 +43,10 @@ function sync_test {
# pass argument to build for specific platform # pass argument to build for specific platform
host="tubearchivist.local" host="tubearchivist.local"
# make base folder
ssh "$host" "mkdir -p docker"
# copy project files to build image
rsync -a --progress --delete-after \ rsync -a --progress --delete-after \
--exclude ".git" \ --exclude ".git" \
--exclude ".gitignore" \ --exclude ".gitignore" \
@@ -52,8 +55,8 @@ function sync_test {
--exclude "db.sqlite3" \ --exclude "db.sqlite3" \
. -e ssh "$host":tubearchivist . -e ssh "$host":tubearchivist
# uncomment or copy your own docker-compose file # copy default docker-compose file if not exist
# rsync -r --progress --delete docker-compose.yml -e ssh "$host":docker rsync --progress --ignore-existing docker-compose.yml -e ssh "$host":docker
if [[ $1 = "amd64" ]]; then if [[ $1 = "amd64" ]]; then
platform="linux/amd64" platform="linux/amd64"
@@ -65,11 +68,8 @@ function sync_test {
platform="linux/amd64" platform="linux/amd64"
fi fi
ssh "$host" "docker buildx build --platform $platform -t bbilly1/tubearchivist:latest tubearchivist --load" ssh "$host" "docker buildx build --build-arg INSTALL_DEBUG=1 --platform $platform -t bbilly1/tubearchivist:latest tubearchivist --load"
ssh "$host" 'docker-compose -f docker/docker-compose.yml up -d' ssh "$host" 'docker compose -f docker/docker-compose.yml up -d'
ssh "$host" 'docker cp tubearchivist/tubearchivist/testing.sh tubearchivist:/app/testing.sh'
ssh "$host" 'docker exec tubearchivist chmod +x /app/testing.sh'
} }
@@ -91,7 +91,7 @@ function validate {
echo "running codespell" echo "running codespell"
codespell --skip="./.git" "$check_path" codespell --skip="./.git" "$check_path"
echo "running flake8" echo "running flake8"
flake8 "$check_path" --count --max-complexity=12 --max-line-length=79 \ flake8 "$check_path" --count --max-complexity=10 --max-line-length=79 \
--show-source --statistics --show-source --statistics
echo "running isort" echo "running isort"
isort --check-only --diff --profile black -l 79 "$check_path" isort --check-only --diff --profile black -l 79 "$check_path"
@@ -100,6 +100,34 @@ function validate {
} }
# update latest tag compatible es for set and forget
function sync_latest_es {
VERSION=$(grep "bbilly1/tubearchivist-es" docker-compose.yml | awk '{print $NF}')
printf "\nsync new ES version %s\nContinue?\n" "$VERSION"
read -rn 1
if [[ $(systemctl is-active docker) != 'active' ]]; then
echo "starting docker"
sudo systemctl start docker
fi
sudo docker image pull docker.elastic.co/elasticsearch/elasticsearch:"$VERSION"
sudo docker tag \
docker.elastic.co/elasticsearch/elasticsearch:"$VERSION" \
bbilly1/tubearchivist-es
sudo docker tag \
docker.elastic.co/elasticsearch/elasticsearch:"$VERSION" \
bbilly1/tubearchivist-es:"$VERSION"
sudo docker push bbilly1/tubearchivist-es
sudo docker push bbilly1/tubearchivist-es:"$VERSION"
}
# publish unstable tag to docker # publish unstable tag to docker
function sync_unstable { function sync_unstable {
@@ -130,10 +158,7 @@ function sync_docker {
fi fi
echo "latest tags:" echo "latest tags:"
git tag git tag | tail -n 10
echo "latest docker images:"
sudo docker image ls bbilly1/tubearchivist
printf "\ncreate new version:\n" printf "\ncreate new version:\n"
read -r VERSION read -r VERSION
@@ -156,23 +181,23 @@ function sync_docker {
} }
# check package versions in requirements.txt for updates
python version_check.py
if [[ $1 == "blackhole" ]]; then if [[ $1 == "blackhole" ]]; then
sync_blackhole sync_blackhole
elif [[ $1 == "test" ]]; then elif [[ $1 == "test" ]]; then
sync_test "$2" sync_test "$2"
elif [[ $1 == "validate" ]]; then elif [[ $1 == "validate" ]]; then
# check package versions in requirements.txt for updates
python version_check.py
validate "$2" validate "$2"
elif [[ $1 == "docker" ]]; then elif [[ $1 == "docker" ]]; then
sync_docker sync_docker
sync_unstable sync_unstable
elif [[ $1 == "unstable" ]]; then elif [[ $1 == "unstable" ]]; then
sync_unstable sync_unstable
elif [[ $1 == "es" ]]; then
sync_latest_es
else else
echo "valid options are: blackhole | test | validate | docker | unstable" echo "valid options are: blackhole | test | validate | docker | unstable | es"
fi fi

View File

@@ -11,19 +11,19 @@ services:
- media:/youtube - media:/youtube
- cache:/cache - cache:/cache
environment: environment:
- ES_URL=http://archivist-es:9200 - ES_URL=http://archivist-es:9200 # needs protocol e.g. http and port
- REDIS_HOST=archivist-redis - REDIS_HOST=archivist-redis # don't add protocol
- HOST_UID=1000 - HOST_UID=1000
- HOST_GID=1000 - HOST_GID=1000
- TA_USERNAME=tubearchivist - TA_USERNAME=tubearchivist # your initial TA credentials
- TA_PASSWORD=verysecret - TA_PASSWORD=verysecret # your initial TA credentials
- ELASTIC_PASSWORD=verysecret - ELASTIC_PASSWORD=verysecret # set password for Elasticsearch
- TZ=America/New_York - TZ=America/New_York # set your time zone
depends_on: depends_on:
- archivist-es - archivist-es
- archivist-redis - archivist-redis
archivist-redis: archivist-redis:
image: redislabs/rejson:latest # For arm64 just update this line with bbilly1/rejson:latest image: redislabs/rejson:latest # for arm64 use bbilly1/rejson
container_name: archivist-redis container_name: archivist-redis
restart: always restart: always
expose: expose:
@@ -33,12 +33,12 @@ services:
depends_on: depends_on:
- archivist-es - archivist-es
archivist-es: archivist-es:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.0 image: bbilly1/tubearchivist-es # only for amd64, or use official es 7.17.3
container_name: archivist-es container_name: archivist-es
restart: always restart: always
environment: environment:
- "xpack.security.enabled=true" - "xpack.security.enabled=true"
- "ELASTIC_PASSWORD=verysecret" - "ELASTIC_PASSWORD=verysecret" # matching Elasticsearch password
- "discovery.type=single-node" - "discovery.type=single-node"
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits: ulimits:
@@ -46,7 +46,7 @@ services:
soft: -1 soft: -1
hard: -1 hard: -1
volumes: volumes:
- es:/usr/share/elasticsearch/data - es:/usr/share/elasticsearch/data # check for permission error when using bind mount, see readme
expose: expose:
- "9200" - "9200"

View File

@@ -1,30 +1,29 @@
server { server {
listen 8000; listen 8000;
location /cache/videos/ { location /cache/videos/ {
alias /cache/videos/; alias /cache/videos/;
} }
location /cache/channels/ { location /cache/channels/ {
alias /cache/channels/; alias /cache/channels/;
} }
location /cache/playlists/ { location /cache/playlists/ {
alias /cache/playlists/; alias /cache/playlists/;
} }
location /media/ { location /media/ {
alias /youtube/; alias /youtube/;
types { types {
text/vtt vtt; text/vtt vtt;
} }
} }
location / { location / {
include uwsgi_params; include uwsgi_params;
uwsgi_pass localhost:8080; uwsgi_pass localhost:8080;
} }
} }

View File

@@ -15,11 +15,11 @@ done
# ugly nginx and uwsgi port overwrite with env vars # ugly nginx and uwsgi port overwrite with env vars
if [[ -n "$TA_PORT" ]]; then if [[ -n "$TA_PORT" ]]; then
sed -i "s/8000/$TA_PORT/g" /etc/nginx/conf.d/nginx.conf sed -i "s/8000/$TA_PORT/g" /etc/nginx/sites-available/default
fi fi
if [[ -n "$TA_UWSGI_PORT" ]]; then if [[ -n "$TA_UWSGI_PORT" ]]; then
sed -i "s/8080/$TA_UWSGI_PORT/g" /etc/nginx/conf.d/nginx.conf sed -i "s/8080/$TA_UWSGI_PORT/g" /etc/nginx/sites-available/default
sed -i "s/8080/$TA_UWSGI_PORT/g" /app/uwsgi.ini sed -i "s/8080/$TA_UWSGI_PORT/g" /app/uwsgi.ini
fi fi

View File

@@ -22,5 +22,11 @@ Each channel will get a dedicated channel detail page accessible at `/channel/<c
- You can *Show* the channel description, that matches with the *About* tab on YouTube. - You can *Show* the channel description, that matches with the *About* tab on YouTube.
- The **Mark as Watched** button will mark all videos of this channel as watched. - The **Mark as Watched** button will mark all videos of this channel as watched.
- The button **Delete Channel** will delete the channel plus all videos of this channel, both media files and metadata additionally this will also delete playlists metadata belonging to that channel. - The button **Delete Channel** will delete the channel plus all videos of this channel, both media files and metadata additionally this will also delete playlists metadata belonging to that channel.
- The button **Find Playlists** will go out to youtube and look for playlists belonging to this channel and index all with at least one video downloaded. Only do this for channels where you care about playlists as this will slow down indexing new videos for having to check which playlist this belongs to.
- The button **Show Playlists** will go to the [playlists](Playlists) page and filter the list to only show playlists from this channel. - The button **Show Playlists** will go to the [playlists](Playlists) page and filter the list to only show playlists from this channel.
### Channel Customize
Clicking on the *Configure* button will open a form with options to configure settings on a per channel basis. Any configurations here will overwrite your settings from the [settings](Settings) page.
- **Download Format**: Overwrite the download qualities for videos from this channel.
- **Auto Delete**: Automatically delete watched videos from this channel after selected days.
- **Index Playlists**: Automatically add all Playlists with at least a video downloaded to your index. Only do this for channels where you care about playlists as this will slow down indexing new videos for having to check which playlist this belongs to.
- **SponsorBlock**: Using [SponsorBlock](https://sponsor.ajay.app/) to get and skip sponsored content. Customize per channel: You can *disable* or *enable* SponsorBlock for certain channels only to overwrite the behavior set on the [Settings](settings) page. Selecting *unset* will remove the overwrite and your setting will fall back to the default on the settings page.

View File

@@ -29,3 +29,6 @@ So Docker is the only supported installation method. If you don't have any exper
## 4. Finetuning Elasticsearch ## 4. Finetuning Elasticsearch
A minimal configuration of Elasticsearch (ES) is provided in the example docker-compose.yml file. ES is highly configurable and very interesting to learn more about. Refer to the [documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) if you want to get into it. A minimal configuration of Elasticsearch (ES) is provided in the example docker-compose.yml file. ES is highly configurable and very interesting to learn more about. Refer to the [documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) if you want to get into it.
## 5. Advanced Authentication
If you like to use things like SSO, LDAP or 2FA to login, consider using something like Authelia as a reverse proxy so this project can focus on the core task. Tube Archivist has a *remember me* checkbox at login to extend your sessions lifetime in your browser.

View File

@@ -9,6 +9,7 @@ Table of contents:
* [Settings](Settings): All the configuration options * [Settings](Settings): All the configuration options
* [Video](Video): All details of a single video and playlist navigation. * [Video](Video): All details of a single video and playlist navigation.
* [Users](Users): User management admin interface * [Users](Users): User management admin interface
* [Installation](Installation): WIP - detailed installation instructions for various platforms.
## Getting Started ## Getting Started
1. [Subscribe](Channels#channels-overview) to some of your favourite YouTube channels. 1. [Subscribe](Channels#channels-overview) to some of your favourite YouTube channels.

56
docs/Installation.md Normal file
View File

@@ -0,0 +1,56 @@
# Detailed Installation Instructions for Various Platforms
## Unraid
Tube Archivist, and all if it's dependencies are located in the [community applications](https://forums.unraid.net/topic/38582-plug-in-community-applications/) store. The three containers you will need are as follows:
- **TubeArchivist-RedisJSON**: This container acts as a cache and temporary link between the application and the file system. Used to store and display messages and configuration variables.
- **TubeArchivist-ES**: ElasticSearch stores video meta data and makes everything searchable. Also keeps track of the download queue.
- **TubeArchivist**: Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface.
### Step 1: Install `TubeArchivist-RedisJSON`
![enter image description here](https://i.imgur.com/ycAqFRU.png)
This is the easiest container to setup of the thee, just make sure that you do not have any port conflicts, and that your `/data` is mounted to the correct path. The other containers will map to the same directory.
If you need to install `TubeArchivist-RedisJSON`on a different port, you'll have to follow [these steps](https://github.com/tubearchivist/tubearchivist#redis-on-a-custom-port) later on when installing the `TubeArchivist` container
### Step 2: Install `TubeArchivist-ES`
![enter image description here](https://i.imgur.com/o6tsTdt.png)
ElasticSeach is also pretty easy to setup. Again, make sure you have no port conflicts, make sure that you mapped `/usr/share/elasticsearch/data` to the same directory as `RedisJSON`, and make sure to change the default password to something more secure.
There is three additional settings in the "show more settings" area, but leave those as they are.
### Step 3: Install `TubeArchivist`
![enter image description here](https://i.imgur.com/dwSCfgO.png)
It's finally time to set up TubeArchivist!
- `Port:`Again, make sure that you have no port conflicts on 8000.
- `Youtube Media Path:` is where you'll download all of your videos to.
Make sure that this is an empty directory to not cause confusion when
starting the application. If you have existing videos that you'd like
to import into Tube Archivist, please checkout the [settings
wiki.](https://github.com/tubearchivist/tubearchivist/wiki/Settings#manual-media-files-import)
- `Appdata:` This should be the same base path as the other two containers.
- `TA Username:`This will be your username for TubeArchivist.
- `TA Password:`This will be your password for TubeArchivist.
- `Redis` This will be JUST the ip address of your redis container
- `ElasticSearch Password:`This is the password you defined in the `TubeArchivist-ES` container.
- `ElasticSearch:` This seems to cause some confusion, but it's a pretty simple step, just replace the IP and Port to match you `TubeArchivist-ES` container.
(example: if your IP is 192.168.1.15, the value should be http://192.168.1.15:9200)
- `Time Zone:` This is an important step for your scheduler, to find your timezone, use a site like [TimeZoneConverter](http://www.timezoneconverter.com/cgi-bin/findzone.tzc)
### From there, you should be able to start up your containers and you're good to go!
If you're still having trouble, join us on [discord](https://discord.gg/AFwz8nE7BK) and come to the #unraid channel.

View File

@@ -32,10 +32,34 @@ Additional settings passed to yt-dlp.
- **Source Settings**: User created subtitles are provided from the uploader and are usually the video script. Auto generated is from YouTube, quality varies, particularly for auto translated tracks. - **Source Settings**: User created subtitles are provided from the uploader and are usually the video script. Auto generated is from YouTube, quality varies, particularly for auto translated tracks.
- **Index Settings**: Enabling subtitle indexing will add the lines to Elasticsearch and will make subtitles searchable. This will increase the index size and is not recommended on low-end hardware. - **Index Settings**: Enabling subtitle indexing will add the lines to Elasticsearch and will make subtitles searchable. This will increase the index size and is not recommended on low-end hardware.
## Cookie
Importing your YouTube Cookie into Tube Archivist allows yt-dlp to bypass age restrictions, gives access to private videos and your *watch later* or *liked videos*.
### Security concerns
Cookies are used to store your session and contain your access token to your google account, this file can be used to take over your account. Treat that file with utmost care as you would any other password or credential. *Tube Archivist* stores your cookie in Redis and will export the file container internally if it's needed.
### Export your cookie
- Install **Cookies.txt** addon for [chrome](https://chrome.google.com/webstore/detail/get-cookiestxt/bgaddhkoddajcdgocldbbfleckgcbcid) or [firefox](https://addons.mozilla.org/firefox/addon/cookies-txt).
- Visit YouTube and login with whichever YouTube account you wish to use to generate the cookies.
- Click on the extension icon in the toolbar - it will drop down showing the active cookies for YT.
- Click Export to export the cookies, filename is by default *cookies.google.txt*.
### Import your cookie
Place the file *cookies.google.txt* into the *cache/import* folder of Tube Archivist and enable the cookie import. Once you click on *Update Application Configurations* to save your changes, your cookie will get imported and stored internally.
Once imported, a **Validate Cookie File** button will show, where you can confirm if your cookie is working or not.
### Use your cookie
Once imported, additionally to the advantages above, your [Watch Later](https://www.youtube.com/playlist?list=WL) and [Liked Videos](https://www.youtube.com/playlist?list=LL) become a regular playlist you can download and subscribe to as any other [playlist](Playlists).
### Limitation
There is only one cookie per Tube Archivist instance, this will be shared between all users.
## Integrations ## Integrations
All third party integrations of TubeArchivist will **always** be *opt in*. All third party integrations of TubeArchivist will **always** be *opt in*.
- **API**: Your access token for the Tube Archivist API. - **API**: Your access token for the Tube Archivist API.
- **returnyoutubedislike.com**: This will get return dislikes and average ratings for each video by integrating with the API from [returnyoutubedislike.com](https://www.returnyoutubedislike.com/). - **returnyoutubedislike.com**: This will get return dislikes and average ratings for each video by integrating with the API from [returnyoutubedislike.com](https://www.returnyoutubedislike.com/).
- **SponsorBlock**: Using [SponsorBlock](https://sponsor.ajay.app/) to get and skip sponsored content. If a video doesn't have timestamps, or has unlocked timestamps, use the browser addon to contribute to this excellent project. Can also be activated and deactivated as a per [channel overwrite](Settings#channel-customize).
- **Cast**: Enabling the cast integration in the settings page will load an additional JS library from **Google**. - **Cast**: Enabling the cast integration in the settings page will load an additional JS library from **Google**.
* Requirements * Requirements
- HTTPS - HTTPS
@@ -55,7 +79,7 @@ Examples:
- **0 15 \***: Run task every day at 15:00 in the afternoon. - **0 15 \***: Run task every day at 15:00 in the afternoon.
- **30 8 \*/2**: Run task every second day of the week (Sun, Tue, Thu, Sat) at 08:30 in the morning. - **30 8 \*/2**: Run task every second day of the week (Sun, Tue, Thu, Sat) at 08:30 in the morning.
- **0 \*/3,8-17 \***: Execute every hour divisible by 3, and every hour during office hours (8 in the morning - 5 in the afternoon). - **0 \*/3,8-17 \***: Execute every hour divisible by 3, and every hour during office hours (8 in the morning - 5 in the afternoon).
- **0 8,16 \***: Execute every day at 8 in the morning and at 4 in the afternoon. - **0 8,16 \***: Execute every day at 8 in the morning and at 4 in the afternoon.
- **auto**: Sensible default. - **auto**: Sensible default.
- **0**: (zero), deactivate that task. - **0**: (zero), deactivate that task.
@@ -71,9 +95,9 @@ That's the equivalent task as run from the downloads page looking through your c
Start downloading all videos currently in the download queue. Start downloading all videos currently in the download queue.
## Refresh Metadata ## Refresh Metadata
Rescan videos, channels and playlists on youtube and update metadata periodically. This will also deactivate an item and exclude it from future refreshes if the link on YouTube is no longer available. This task is meant to be run once per day, set your schedule accordingly. Rescan videos, channels and playlists on youtube and update metadata periodically. This will also deactivate an item and exclude it from future refreshes if the link on YouTube is no longer available. This task is meant to be run once per day, set your schedule accordingly.
The field **Refresh older than x days** takes a number where TubeArchivist will consider an item as *outdated*. This value is used to calculate how many items need to be refreshed today based on the total indexed. This will spread out the requests to YouTube. Sensible value here is **90** days. The field **Refresh older than x days** takes a number where TubeArchivist will consider an item as *outdated*. This value is used to calculate how many items need to be refreshed today based on the total indexed. This will spread out the requests to YouTube. Sensible value here is **90** days.
## Thumbnail check ## Thumbnail check
This will check if all expected thumbnails are there and will delete any artwork without matching video. This will check if all expected thumbnails are there and will delete any artwork without matching video.
@@ -103,6 +127,7 @@ Detect the YouTube ID from filename, this accepts the default yt-dlp naming conv
### Some notes: ### Some notes:
- This will **consume** the files you put into the import folder: Files will get converted to mp4 if needed (this might take a long time...) and moved to the archive, *.json* files will get deleted upon completion to avoid having duplicates on the next run. - This will **consume** the files you put into the import folder: Files will get converted to mp4 if needed (this might take a long time...) and moved to the archive, *.json* files will get deleted upon completion to avoid having duplicates on the next run.
- For best file transcoding quality, convert your media files with desired settings first before importing (#138).
- There should be no subdirectories added to */cache/import*, only video files. If your existing video library has video files inside subdirectories, you can get all the files into one directory by running `find ./ -mindepth 2 -type f -exec mv '{}' . \;` from the top-level directory of your existing video library. You can also delete any remaining empty subdirectories with `find ./ -mindepth 1 -type d -delete`. - There should be no subdirectories added to */cache/import*, only video files. If your existing video library has video files inside subdirectories, you can get all the files into one directory by running `find ./ -mindepth 2 -type f -exec mv '{}' . \;` from the top-level directory of your existing video library. You can also delete any remaining empty subdirectories with `find ./ -mindepth 1 -type d -delete`.
- Maybe start with a subset of your files to import to make sure everything goes well... - Maybe start with a subset of your files to import to make sure everything goes well...
- Follow the logs to monitor progress and errors: `docker-compose logs -f tubearchivist`. - Follow the logs to monitor progress and errors: `docker-compose logs -f tubearchivist`.
@@ -129,5 +154,6 @@ This function will go through all your media files and looks at the whole index
- Should the filename not match with the indexed media url, this will rename the video files correctly and update the index with the new link. - Should the filename not match with the indexed media url, this will rename the video files correctly and update the index with the new link.
- When you delete media files from the filesystem outside of the Tube Archivist interface, this will delete leftover metadata from the index. - When you delete media files from the filesystem outside of the Tube Archivist interface, this will delete leftover metadata from the index.
- When you have media files that are not indexed yet, this will grab the metadata from YouTube like it was a newly downloaded video. This can be useful when restoring from an older backup file with missing metadata but already downloaded mediafiles. NOTE: This only works if the media files are named in the same convention as Tube Archivist does, particularly the YouTube ID needs to be at the same index in the filename, alternatively see above for *Manual Media Files Import*. - When you have media files that are not indexed yet, this will grab the metadata from YouTube like it was a newly downloaded video. This can be useful when restoring from an older backup file with missing metadata but already downloaded mediafiles. NOTE: This only works if the media files are named in the same convention as Tube Archivist does, particularly the YouTube ID needs to be at the same index in the filename, alternatively see above for *Manual Media Files Import*.
-This will also check all of your thumbnails and download any that are missing.
BE AWARE: There is no undo. BE AWARE: There is no undo.

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -20,6 +20,40 @@ headers = {"Authorization": "Token xxxxxxxxxx"}
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
``` ```
## Pagination
The list views return a paginate object with the following keys:
- page_size: int current page size set in config
- page_from: int first result idx
- prev_pages: array of ints of previous pages, if available
- current_page: int current page from query
- max_hits: reached: bool if max of 10k results is reached
- last_page: int of last page link
- next_pages: array of ints of next pages
- total_hits: int total results
Pass page number as a query parameter: `page=2`. Defaults to *0*, `page=1` is redundant and falls back to *0*. If a page query doesn't return any results, you'll get `HTTP 404 Not Found`.
## Login View
Return token and user ID for username and password:
POST /api/login
```json
{
"username": "tubearchivist",
"password": "verysecret"
}
```
after successful login returns
```json
{
"token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"user_id": 1
}
```
## Video List View
/api/video/
## Video Item View ## Video Item View
/api/video/\<video_id>/ /api/video/\<video_id>/
@@ -49,6 +83,43 @@ POST /api/video/\<video_id>/progress
### Delete player position of video ### Delete player position of video
DELETE /api/video/\<video_id>/progress DELETE /api/video/\<video_id>/progress
## Sponsor Block View
/api/video/\<video_id>/sponsor/
Integrate with sponsorblock
### Get list of segments
GET /api/video/\<video_id>/sponsor/
### Vote on existing segment
**This only simulates the request**
POST /api/video/\<video_id>/sponsor/
```json
{
"vote": {
"uuid": "<uuid>",
"yourVote": 1
}
}
```
yourVote needs to be *int*: 0 for downvote, 1 for upvote, 20 to undo vote
### Create new segment
**This only simulates the request**
POST /api/video/\<video_id>/sponsor/
```json
{
"segment": {
"startTime": 5,
"endTime": 10
}
}
```
Timestamps either *int* or *float*, end time can't be before start time.
## Channel List View ## Channel List View
/api/channel/ /api/channel/
@@ -65,11 +136,23 @@ POST /api/channel/
## Channel Item View ## Channel Item View
/api/channel/\<channel_id>/ /api/channel/\<channel_id>/
## Channel Videos View
/api/channel/\<channel_id>/video/
## Playlist List View
/api/playlist/
## Playlists Item View ## Playlists Item View
/api/playlist/\<playlist_id>/ /api/playlist/\<playlist_id>/
## Playlist Videos View
/api/playlist/\<playlist_id>/video/
## Download Queue List View ## Download Queue List View
/api/download/ GET /api/download/
Parameter:
- filter: pending, ignore
### Add list of videos to download queue ### Add list of videos to download queue
POST /api/download/ POST /api/download/
@@ -81,5 +164,70 @@ POST /api/download/
} }
``` ```
### Delete download queue items by filter
DELETE /api/download/?filter=ignore
DELETE /api/download/?filter=pending
## Download Queue Item View ## Download Queue Item View
/api/download/\<video_id>/ GET /api/download/\<video_id>/
POST /api/download/\<video_id>/
Ignore video in download queue:
```json
{
"status": "ignore"
}
```
Add to queue previously ignored video:
```json
{
"status": "pending"
}
```
DELETE /api/download/\<video_id>/
Forget or delete from download queue
## Ping View
Validate your connection with the API
GET /api/ping
When valid returns message with user id:
```json
{
"response": "pong",
"user": 1
}
```
## Task View
Start a background task
POST /api/task/
```json
{
"run": "task_name"
}
```
List of valid task names:
- **download_pending**: Start the download queue
- **rescan_pending**: Rescan your subscriptions
## Cookie View
Check your youtube cookie settings
GET /api/cookie/
```json
{
"cookie_enabled": true
}
```
POST /api/cookie/
Send empty post request to validate cookie.
```json
{
"cookie_validated": true
}
```

View File

View File

@@ -0,0 +1,125 @@
"""
Functionality:
- processing search results for frontend
- this is duplicated code from home.src.frontend.searching.SearchHandler
"""
import urllib.parse
from home.src.download.thumbnails import ThumbManager
from home.src.ta.config import AppConfig
from home.src.ta.helper import date_praser
class SearchProcess:
"""process search results"""
CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"]
def __init__(self, response):
self.response = response
self.processed = False
def process(self):
"""dedect type and process"""
if "_source" in self.response.keys():
# single
self.processed = self._process_result(self.response)
elif "hits" in self.response.keys():
# multiple
self.processed = []
all_sources = self.response["hits"]["hits"]
for result in all_sources:
self.processed.append(self._process_result(result))
return self.processed
def _process_result(self, result):
"""dedect which type of data to process"""
index = result["_index"]
processed = False
if index == "ta_video":
processed = self._process_video(result["_source"])
if index == "ta_channel":
processed = self._process_channel(result["_source"])
if index == "ta_playlist":
processed = self._process_playlist(result["_source"])
if index == "ta_download":
processed = self._process_download(result["_source"])
return processed
@staticmethod
def _process_channel(channel_dict):
"""run on single channel"""
channel_id = channel_dict["channel_id"]
art_base = f"/cache/channels/{channel_id}"
date_str = date_praser(channel_dict["channel_last_refresh"])
channel_dict.update(
{
"channel_last_refresh": date_str,
"channel_banner_url": f"{art_base}_banner.jpg",
"channel_thumb_url": f"{art_base}_thumb.jpg",
"channel_tvart_url": False,
}
)
return dict(sorted(channel_dict.items()))
def _process_video(self, video_dict):
"""run on single video dict"""
video_id = video_dict["youtube_id"]
media_url = urllib.parse.quote(video_dict["media_url"])
vid_last_refresh = date_praser(video_dict["vid_last_refresh"])
published = date_praser(video_dict["published"])
vid_thumb_url = ThumbManager().vid_thumb_path(video_id)
channel = self._process_channel(video_dict["channel"])
if "subtitles" in video_dict:
for idx, _ in enumerate(video_dict["subtitles"]):
url = video_dict["subtitles"][idx]["media_url"]
video_dict["subtitles"][idx]["media_url"] = f"/media/{url}"
video_dict.update(
{
"channel": channel,
"media_url": f"/media/{media_url}",
"vid_last_refresh": vid_last_refresh,
"published": published,
"vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}",
}
)
return dict(sorted(video_dict.items()))
@staticmethod
def _process_playlist(playlist_dict):
"""run on single playlist dict"""
playlist_id = playlist_dict["playlist_id"]
playlist_last_refresh = date_praser(
playlist_dict["playlist_last_refresh"]
)
playlist_dict.update(
{
"playlist_thumbnail": f"/cache/playlists/{playlist_id}.jpg",
"playlist_last_refresh": playlist_last_refresh,
}
)
return dict(sorted(playlist_dict.items()))
def _process_download(self, download_dict):
"""run on single download item"""
video_id = download_dict["youtube_id"]
vid_thumb_url = ThumbManager().vid_thumb_path(video_id)
published = date_praser(download_dict["published"])
download_dict.update(
{
"vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}",
"published": published,
}
)
return dict(sorted(download_dict.items()))

View File

@@ -0,0 +1,54 @@
"""
Functionality:
- process tasks from API
- validate
- handover to celery
"""
from home.src.ta.ta_redis import RedisArchivist
from home.tasks import download_pending, update_subscribed
class TaskHandler:
"""handle tasks from api"""
def __init__(self, data):
self.data = data
def run_task(self):
"""map data and run"""
task_name = self.data["run"]
try:
to_run = self.exec_map(task_name)
except KeyError as err:
print(f"invalid task name {task_name}")
raise ValueError from err
response = to_run()
response.update({"task": task_name})
return response
def exec_map(self, task_name):
"""map dict key and return function to execute"""
exec_map = {
"download_pending": self._download_pending,
"rescan_pending": self._rescan_pending,
}
return exec_map[task_name]
@staticmethod
def _rescan_pending():
"""look for new items in subscribed channels"""
print("rescan subscribed channels")
update_subscribed.delay()
return {"success": True}
@staticmethod
def _download_pending():
"""start the download queue"""
print("download pending")
running = download_pending.delay()
print("set task id: " + running.id)
RedisArchivist().set_message("dl_queue_id", running.id, expire=False)
return {"success": True}

View File

@@ -2,16 +2,32 @@
from api.views import ( from api.views import (
ChannelApiListView, ChannelApiListView,
ChannelApiVideoView,
ChannelApiView, ChannelApiView,
CookieView,
DownloadApiListView, DownloadApiListView,
DownloadApiView, DownloadApiView,
LoginApiView,
PingView,
PlaylistApiListView,
PlaylistApiVideoView,
PlaylistApiView, PlaylistApiView,
TaskApiView,
VideoApiListView,
VideoApiView, VideoApiView,
VideoProgressView, VideoProgressView,
VideoSponsorView,
) )
from django.urls import path from django.urls import path
urlpatterns = [ urlpatterns = [
path("ping/", PingView.as_view(), name="ping"),
path("login/", LoginApiView.as_view(), name="api-login"),
path(
"video/",
VideoApiListView.as_view(),
name="api-video-list",
),
path( path(
"video/<slug:video_id>/", "video/<slug:video_id>/",
VideoApiView.as_view(), VideoApiView.as_view(),
@@ -22,6 +38,11 @@ urlpatterns = [
VideoProgressView.as_view(), VideoProgressView.as_view(),
name="api-video-progress", name="api-video-progress",
), ),
path(
"video/<slug:video_id>/sponsor/",
VideoSponsorView.as_view(),
name="api-video-sponsor",
),
path( path(
"channel/", "channel/",
ChannelApiListView.as_view(), ChannelApiListView.as_view(),
@@ -32,11 +53,26 @@ urlpatterns = [
ChannelApiView.as_view(), ChannelApiView.as_view(),
name="api-channel", name="api-channel",
), ),
path(
"channel/<slug:channel_id>/video/",
ChannelApiVideoView.as_view(),
name="api-channel-video",
),
path(
"playlist/",
PlaylistApiListView.as_view(),
name="api-playlist-list",
),
path( path(
"playlist/<slug:playlist_id>/", "playlist/<slug:playlist_id>/",
PlaylistApiView.as_view(), PlaylistApiView.as_view(),
name="api-playlist", name="api-playlist",
), ),
path(
"playlist/<slug:playlist_id>/video/",
PlaylistApiVideoView.as_view(),
name="api-playlist-video",
),
path( path(
"download/", "download/",
DownloadApiListView.as_view(), DownloadApiListView.as_view(),
@@ -47,4 +83,14 @@ urlpatterns = [
DownloadApiView.as_view(), DownloadApiView.as_view(),
name="api-download", name="api-download",
), ),
path(
"task/",
TaskApiView.as_view(),
name="api-task",
),
path(
"cookie/",
CookieView.as_view(),
name="api-cookie",
),
] ]

View File

@@ -1,15 +1,22 @@
"""all API views""" """all API views"""
import requests from api.src.search_processor import SearchProcess
from home.src.download.thumbnails import ThumbManager from api.src.task_processor import TaskHandler
from home.src.download.queue import PendingInteract
from home.src.download.yt_cookie import CookieHandler
from home.src.es.connect import ElasticWrap
from home.src.index.generic import Pagination
from home.src.index.video import SponsorBlock
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
from home.src.ta.helper import UrlListParser from home.src.ta.helper import UrlListParser
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist, RedisQueue
from home.tasks import extrac_dl, subscribe_to from home.tasks import extrac_dl, subscribe_to
from rest_framework.authentication import ( from rest_framework.authentication import (
SessionAuthentication, SessionAuthentication,
TokenAuthentication, TokenAuthentication,
) )
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -21,67 +28,54 @@ class ApiBaseView(APIView):
authentication_classes = [SessionAuthentication, TokenAuthentication] authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
search_base = False search_base = False
data = False
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.response = {"data": False} self.response = {"data": False, "config": AppConfig().config}
self.data = {"query": {"match_all": {}}}
self.status_code = False self.status_code = False
self.context = False self.context = False
self.default_conf = AppConfig().config self.pagination_handler = False
def config_builder(self):
"""build confic context"""
self.context = {
"es_url": self.default_conf["application"]["es_url"],
"es_auth": self.default_conf["application"]["es_auth"],
}
self.response["config"] = self.default_conf
def get_document(self, document_id): def get_document(self, document_id):
"""get single document from es""" """get single document from es"""
es_url = self.context["es_url"] path = f"{self.search_base}{document_id}"
url = f"{es_url}{self.search_base}{document_id}" print(path)
print(url) response, status_code = ElasticWrap(path).get()
response = requests.get(url, auth=self.context["es_auth"])
try: try:
self.response["data"] = response.json()["_source"] self.response["data"] = SearchProcess(response).process()
except KeyError: except KeyError:
print(f"item not found: {document_id}") print(f"item not found: {document_id}")
self.response["data"] = False self.response["data"] = False
self.status_code = response.status_code self.status_code = status_code
def process_keys(self): def initiate_pagination(self, request):
"""process keys for frontend""" """set initial pagination values"""
all_keys = self.response["data"].keys() user_id = request.user.id
if "media_url" in all_keys: page_get = int(request.GET.get("page", 0))
media_url = self.response["data"]["media_url"] self.pagination_handler = Pagination(page_get, user_id)
self.response["data"]["media_url"] = f"/media/{media_url}" self.data.update(
if "vid_thumb_url" in all_keys: {
youtube_id = self.response["data"]["youtube_id"] "size": self.pagination_handler.pagination["page_size"],
vid_thumb_url = ThumbManager().vid_thumb_path(youtube_id) "from": self.pagination_handler.pagination["page_from"],
cache_dir = self.default_conf["application"]["cache_dir"] }
new_thumb = f"{cache_dir}/{vid_thumb_url}" )
self.response["data"]["vid_thumb_url"] = new_thumb
if "subtitles" in all_keys:
all_subtitles = self.response["data"]["subtitles"]
for idx, _ in enumerate(all_subtitles):
url = self.response["data"]["subtitles"][idx]["media_url"]
new_url = f"/media/{url}"
self.response["data"]["subtitles"][idx]["media_url"] = new_url
def get_paginate(self): def get_document_list(self, request):
"""add pagination detail to response"""
self.response["paginate"] = False
def get_document_list(self, data):
"""get a list of results""" """get a list of results"""
es_url = self.context["es_url"] print(self.search_base)
url = f"{es_url}{self.search_base}" self.initiate_pagination(request)
print(url) es_handler = ElasticWrap(self.search_base)
response = requests.get(url, json=data, auth=self.context["es_auth"]) response, status_code = es_handler.get(data=self.data)
all_hits = response.json()["hits"]["hits"] self.response["data"] = SearchProcess(response).process()
self.response["data"] = [i["_source"] for i in all_hits] if self.response["data"]:
self.status_code = response.status_code self.status_code = status_code
else:
self.status_code = 404
self.pagination_handler.validate(response["hits"]["total"]["value"])
self.response["paginate"] = self.pagination_handler.pagination
class VideoApiView(ApiBaseView): class VideoApiView(ApiBaseView):
@@ -89,17 +83,30 @@ class VideoApiView(ApiBaseView):
GET: returns metadata dict of video GET: returns metadata dict of video
""" """
search_base = "/ta_video/_doc/" search_base = "ta_video/_doc/"
def get(self, request, video_id): def get(self, request, video_id):
# pylint: disable=unused-argument # pylint: disable=unused-argument
"""get request""" """get request"""
self.config_builder()
self.get_document(video_id) self.get_document(video_id)
self.process_keys()
return Response(self.response, status=self.status_code) return Response(self.response, status=self.status_code)
class VideoApiListView(ApiBaseView):
"""resolves to /api/video/
GET: returns list of videos
"""
search_base = "ta_video/_search/"
def get(self, request):
"""get request"""
self.data.update({"sort": [{"published": {"order": "desc"}}]})
self.get_document_list(request)
return Response(self.response)
class VideoProgressView(ApiBaseView): class VideoProgressView(ApiBaseView):
"""resolves to /api/video/<video_id>/ """resolves to /api/video/<video_id>/
handle progress status for video handle progress status for video
@@ -138,17 +145,65 @@ class VideoProgressView(ApiBaseView):
return Response(self.response) return Response(self.response)
class VideoSponsorView(ApiBaseView):
"""resolves to /api/video/<video_id>/sponsor/
handle sponsor block integration
"""
search_base = "ta_video/_doc/"
def get(self, request, video_id):
"""get sponsor info"""
# pylint: disable=unused-argument
self.get_document(video_id)
sponsorblock = self.response["data"].get("sponsorblock")
return Response(sponsorblock)
def post(self, request, video_id):
"""post verification and timestamps"""
if "segment" in request.data:
response, status_code = self._create_segment(request, video_id)
elif "vote" in request.data:
response, status_code = self._vote_on_segment(request)
return Response(response, status=status_code)
@staticmethod
def _create_segment(request, video_id):
"""create segment in API"""
start_time = request.data["segment"]["startTime"]
end_time = request.data["segment"]["endTime"]
response, status_code = SponsorBlock(request.user.id).post_timestamps(
video_id, start_time, end_time
)
return response, status_code
@staticmethod
def _vote_on_segment(request):
"""validate on existing segment"""
user_id = request.user.id
uuid = request.data["vote"]["uuid"]
vote = request.data["vote"]["yourVote"]
response, status_code = SponsorBlock(user_id).vote_on_segment(
uuid, vote
)
return response, status_code
class ChannelApiView(ApiBaseView): class ChannelApiView(ApiBaseView):
"""resolves to /api/channel/<channel_id>/ """resolves to /api/channel/<channel_id>/
GET: returns metadata dict of channel GET: returns metadata dict of channel
""" """
search_base = "/ta_channel/_doc/" search_base = "ta_channel/_doc/"
def get(self, request, channel_id): def get(self, request, channel_id):
# pylint: disable=unused-argument # pylint: disable=unused-argument
"""get request""" """get request"""
self.config_builder()
self.get_document(channel_id) self.get_document(channel_id)
return Response(self.response, status=self.status_code) return Response(self.response, status=self.status_code)
@@ -159,15 +214,14 @@ class ChannelApiListView(ApiBaseView):
POST: edit a list of channels POST: edit a list of channels
""" """
search_base = "/ta_channel/_search/" search_base = "ta_channel/_search/"
def get(self, request): def get(self, request):
# pylint: disable=unused-argument
"""get request""" """get request"""
data = {"query": {"match_all": {}}} self.get_document_list(request)
self.config_builder() self.data.update(
self.get_document_list(data) {"sort": [{"channel_name.keyword": {"order": "asc"}}]}
self.get_paginate() )
return Response(self.response) return Response(self.response)
@@ -189,51 +243,139 @@ class ChannelApiListView(ApiBaseView):
return Response(data) return Response(data)
class ChannelApiVideoView(ApiBaseView):
"""resolves to /api/channel/<channel-id>/video
GET: returns a list of videos of channel
"""
search_base = "ta_video/_search/"
def get(self, request, channel_id):
"""handle get request"""
self.data.update(
{
"query": {
"term": {"channel.channel_id": {"value": channel_id}}
},
"sort": [{"published": {"order": "desc"}}],
}
)
self.get_document_list(request)
return Response(self.response, status=self.status_code)
class PlaylistApiListView(ApiBaseView):
"""resolves to /api/playlist/
GET: returns list of indexed playlists
"""
search_base = "ta_playlist/_search/"
def get(self, request):
"""handle get request"""
self.data.update(
{"sort": [{"playlist_name.keyword": {"order": "asc"}}]}
)
self.get_document_list(request)
return Response(self.response)
class PlaylistApiView(ApiBaseView): class PlaylistApiView(ApiBaseView):
"""resolves to /api/playlist/<playlist_id>/ """resolves to /api/playlist/<playlist_id>/
GET: returns metadata dict of playlist GET: returns metadata dict of playlist
""" """
search_base = "/ta_playlist/_doc/" search_base = "ta_playlist/_doc/"
def get(self, request, playlist_id): def get(self, request, playlist_id):
# pylint: disable=unused-argument # pylint: disable=unused-argument
"""get request""" """get request"""
self.config_builder()
self.get_document(playlist_id) self.get_document(playlist_id)
return Response(self.response, status=self.status_code) return Response(self.response, status=self.status_code)
class PlaylistApiVideoView(ApiBaseView):
"""resolves to /api/playlist/<playlist_id>/video
GET: returns list of videos in playlist
"""
search_base = "ta_video/_search/"
def get(self, request, playlist_id):
"""handle get request"""
self.data["query"] = {
"term": {"playlist.keyword": {"value": playlist_id}}
}
self.data.update({"sort": [{"published": {"order": "desc"}}]})
self.get_document_list(request)
return Response(self.response, status=self.status_code)
class DownloadApiView(ApiBaseView): class DownloadApiView(ApiBaseView):
"""resolves to /api/download/<video_id>/ """resolves to /api/download/<video_id>/
GET: returns metadata dict of an item in the download queue GET: returns metadata dict of an item in the download queue
POST: update status of item to pending or ignore
DELETE: forget from download queue
""" """
search_base = "/ta_download/_doc/" search_base = "ta_download/_doc/"
valid_status = ["pending", "ignore"]
def get(self, request, video_id): def get(self, request, video_id):
# pylint: disable=unused-argument # pylint: disable=unused-argument
"""get request""" """get request"""
self.config_builder()
self.get_document(video_id) self.get_document(video_id)
return Response(self.response, status=self.status_code) return Response(self.response, status=self.status_code)
def post(self, request, video_id):
"""post to video to change status"""
item_status = request.data["status"]
if item_status not in self.valid_status:
message = f"{video_id}: invalid status {item_status}"
print(message)
return Response({"message": message}, status=400)
print(f"{video_id}: change status to {item_status}")
PendingInteract(video_id=video_id, status=item_status).update_status()
RedisQueue().clear_item(video_id)
return Response(request.data)
@staticmethod
def delete(request, video_id):
# pylint: disable=unused-argument
"""delete single video from queue"""
print(f"{video_id}: delete from queue")
PendingInteract(video_id=video_id).delete_item()
return Response({"success": True})
class DownloadApiListView(ApiBaseView): class DownloadApiListView(ApiBaseView):
"""resolves to /api/download/ """resolves to /api/download/
GET: returns latest videos in the download queue GET: returns latest videos in the download queue
POST: add a list of videos to download queue POST: add a list of videos to download queue
DELETE: remove items based on query filter
""" """
search_base = "/ta_download/_search/" search_base = "ta_download/_search/"
valid_filter = ["pending", "ignore"]
def get(self, request): def get(self, request):
# pylint: disable=unused-argument
"""get request""" """get request"""
data = {"query": {"match_all": {}}} query_filter = request.GET.get("filter", False)
self.config_builder() self.data.update({"sort": [{"timestamp": {"order": "asc"}}]})
self.get_document_list(data) if query_filter:
self.get_paginate() if query_filter not in self.valid_filter:
message = f"invalid url query filder: {query_filter}"
print(message)
return Response({"message": message}, status=400)
self.data["query"] = {"term": {"status": {"value": query_filter}}}
self.get_document_list(request)
return Response(self.response) return Response(self.response)
@staticmethod @staticmethod
@@ -260,3 +402,88 @@ class DownloadApiListView(ApiBaseView):
extrac_dl.delay(youtube_ids) extrac_dl.delay(youtube_ids)
return Response(data) return Response(data)
def delete(self, request):
"""delete download queue"""
query_filter = request.GET.get("filter", False)
if query_filter not in self.valid_filter:
message = f"invalid url query filter: {query_filter}"
print(message)
return Response({"message": message}, status=400)
message = f"delete queue by status: {query_filter}"
print(message)
PendingInteract(status=query_filter).delete_by_status()
return Response({"message": message})
class PingView(ApiBaseView):
"""resolves to /api/ping/
GET: test your connection
"""
@staticmethod
def get(request):
"""get pong"""
data = {"response": "pong", "user": request.user.id}
return Response(data)
class LoginApiView(ObtainAuthToken):
"""resolves to /api/login/
POST: return token and username after successful login
"""
def post(self, request, *args, **kwargs):
"""post data"""
# pylint: disable=no-member
serializer = self.serializer_class(
data=request.data, context={"request": request}
)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data["user"]
token, _ = Token.objects.get_or_create(user=user)
print(f"returning token for user with id {user.pk}")
return Response({"token": token.key, "user_id": user.pk})
class TaskApiView(ApiBaseView):
"""resolves to /api/task/
POST: start a new background task
"""
def post(self, request):
"""handle post request"""
data = request.data
print(data)
response = TaskHandler(data).run_task()
return Response(response)
class CookieView(ApiBaseView):
"""resolves to /api/cookie/
GET: check if cookie is enabled
POST: verify validity of cookie
"""
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
config = AppConfig().config
cookie_enabled = config["downloads"]["cookie_import"]
return Response({"cookie_enabled": cookie_enabled})
@staticmethod
def post(request):
"""handle post request"""
# pylint: disable=unused-argument
validated = CookieHandler().validate()
return Response({"cookie_validated": validated})

View File

@@ -146,8 +146,21 @@ LOGOUT_REDIRECT_URL = "/login/"
# Cors needed for browser extension # Cors needed for browser extension
# background.js makes the request so HTTP_ORIGIN will be from extension # background.js makes the request so HTTP_ORIGIN will be from extension
CORS_ALLOWED_ORIGIN_REGEXES = [r"moz-extension://*", r"chrome-extension://*"] if environ.get("DISABLE_CORS"):
# disable cors
CORS_ORIGIN_ALLOW_ALL = True
else:
CORS_ALLOWED_ORIGIN_REGEXES = [
r"moz-extension://*",
r"chrome-extension://*",
]
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]
CORS_ALLOW_HEADERS = list(default_headers) + [ CORS_ALLOW_HEADERS = list(default_headers) + [
"mode", "mode",
] ]
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.1.5"

View File

@@ -1,53 +1,108 @@
"""handle custom startup functions""" """handle custom startup functions"""
import os import os
import sys
from django.apps import AppConfig from django.apps import AppConfig
from home.src.es.connect import ElasticWrap
from home.src.es.index_setup import index_check from home.src.es.index_setup import index_check
from home.src.ta.config import AppConfig as ArchivistConfig from home.src.ta.config import AppConfig as ArchivistConfig
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
def sync_redis_state(): class StartupCheck:
"""make sure redis gets new config.json values""" """checks to run at application startup"""
print("sync redis")
config_handler = ArchivistConfig()
config_handler.load_new_defaults()
MIN_MAJOR, MAX_MAJOR = 7, 7
MIN_MINOR = 17
def make_folders(): def __init__(self):
"""make needed cache folders here so docker doesn't mess it up""" self.config_handler = ArchivistConfig()
folders = [ self.redis_con = RedisArchivist()
"download", self.has_run = self.get_has_run()
"channels",
"videos",
"playlists",
"import",
"backup",
]
config = ArchivistConfig().config
cache_dir = config["application"]["cache_dir"]
for folder in folders:
folder_path = os.path.join(cache_dir, folder)
try:
os.makedirs(folder_path)
except FileExistsError:
continue
def run(self):
"""run all startup checks"""
print("run startup checks")
self.es_version_check()
self.release_lock()
index_check()
self.sync_redis_state()
self.make_folders()
self.set_has_run()
def release_lock(): def get_has_run(self):
"""make sure there are no leftover locks set in redis on container start""" """validate if check has already executed"""
all_locks = [ return self.redis_con.get_message("startup_check")
"manual_import",
"downloading", def set_has_run(self):
"dl_queue", """startup checks run"""
"dl_queue_id", message = {"status": True}
"rescan", self.redis_con.set_message("startup_check", message, expire=120)
]
for lock in all_locks: def sync_redis_state(self):
response = RedisArchivist().del_message(lock) """make sure redis gets new config.json values"""
if response: print("sync redis")
print("deleted leftover key from redis: " + lock) self.config_handler.load_new_defaults()
def make_folders(self):
"""make needed cache folders here so docker doesn't mess it up"""
folders = [
"download",
"channels",
"videos",
"playlists",
"import",
"backup",
]
cache_dir = self.config_handler.config["application"]["cache_dir"]
for folder in folders:
folder_path = os.path.join(cache_dir, folder)
try:
os.makedirs(folder_path)
except FileExistsError:
continue
def release_lock(self):
"""make sure there are no leftover locks set in redis"""
all_locks = [
"startup_check",
"manual_import",
"downloading",
"dl_queue",
"dl_queue_id",
"rescan",
]
for lock in all_locks:
response = self.redis_con.del_message(lock)
if response:
print("deleted leftover key from redis: " + lock)
def is_invalid(self, version):
"""return true if es version is invalid, false if ok"""
major, minor = [int(i) for i in version.split(".")[:2]]
if not self.MIN_MAJOR <= major <= self.MAX_MAJOR:
return True
if minor >= self.MIN_MINOR:
return False
return True
def es_version_check(self):
"""check for minimal elasticsearch version"""
response, _ = ElasticWrap("/").get()
version = response["version"]["number"]
invalid = self.is_invalid(version)
if invalid:
print(
"required elasticsearch version: "
+ f"{self.MIN_MAJOR}.{self.MIN_MINOR}"
)
sys.exit(1)
print("elasticsearch version check passed")
class HomeConfig(AppConfig): class HomeConfig(AppConfig):
@@ -57,7 +112,9 @@ class HomeConfig(AppConfig):
name = "home" name = "home"
def ready(self): def ready(self):
release_lock() startup = StartupCheck()
index_check() if startup.has_run["status"]:
sync_redis_state() print("startup checks run in other thread")
make_folders() return
startup.run()

View File

@@ -26,8 +26,10 @@
"subtitle": false, "subtitle": false,
"subtitle_source": false, "subtitle_source": false,
"subtitle_index": false, "subtitle_index": false,
"cookie_import": false,
"throttledratelimit": false, "throttledratelimit": false,
"integrate_ryd": false "integrate_ryd": false,
"integrate_sponsorblock": false
}, },
"application": { "application": {
"app_root": "/app", "app_root": "/app",

View File

@@ -5,36 +5,162 @@ Functionality:
""" """
import json import json
import os
from datetime import datetime from datetime import datetime
import requests
import yt_dlp import yt_dlp
from home.src.download.subscriptions import ChannelSubscription from home.src.download.subscriptions import (
from home.src.es.connect import IndexPaginate ChannelSubscription,
PlaylistSubscription,
)
from home.src.download.thumbnails import ThumbManager
from home.src.download.yt_cookie import CookieHandler
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.playlist import YoutubePlaylist from home.src.index.playlist import YoutubePlaylist
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
from home.src.ta.helper import DurationConverter, ignore_filelist from home.src.ta.helper import DurationConverter
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
class PendingList: class PendingIndex:
"""manage the pending videos list""" """base class holding all export methods"""
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
VIDEOS = CONFIG["application"]["videos"]
def __init__(self): def __init__(self):
self.all_channel_ids = False self.all_pending = False
self.all_downloaded = False self.all_ignored = False
self.missing_from_playlists = [] self.all_videos = False
self.all_channels = False
self.channel_overwrites = False
self.video_overwrites = False
self.to_skip = False
def parse_url_list(self, youtube_ids): def get_download(self):
"""get a list of all pending videos in ta_download"""
data = {
"query": {"match_all": {}},
"sort": [{"timestamp": {"order": "asc"}}],
}
all_results = IndexPaginate("ta_download", data).get_results()
self.all_pending = []
self.all_ignored = []
self.to_skip = []
for result in all_results:
self.to_skip.append(result["youtube_id"])
if result["status"] == "pending":
self.all_pending.append(result)
elif result["status"] == "ignore":
self.all_ignored.append(result)
def get_indexed(self):
"""get a list of all videos indexed"""
data = {
"query": {"match_all": {}},
"sort": [{"published": {"order": "desc"}}],
}
self.all_videos = IndexPaginate("ta_video", data).get_results()
for video in self.all_videos:
self.to_skip.append(video["youtube_id"])
def get_channels(self):
"""get a list of all channels indexed"""
self.all_channels = []
self.channel_overwrites = {}
data = {
"query": {"match_all": {}},
"sort": [{"channel_id": {"order": "asc"}}],
}
channels = IndexPaginate("ta_channel", data).get_results()
for channel in channels:
channel_id = channel["channel_id"]
self.all_channels.append(channel_id)
if channel.get("channel_overwrites"):
self.channel_overwrites.update(
{channel_id: channel.get("channel_overwrites")}
)
self._map_overwrites()
def _map_overwrites(self):
"""map video ids to channel ids overwrites"""
self.video_overwrites = {}
for video in self.all_pending:
video_id = video["youtube_id"]
channel_id = video["channel_id"]
overwrites = self.channel_overwrites.get(channel_id, False)
if overwrites:
self.video_overwrites.update({video_id: overwrites})
class PendingInteract:
"""interact with items in download queue"""
def __init__(self, video_id=False, status=False):
self.video_id = video_id
self.status = status
def delete_item(self):
"""delete single item from pending"""
path = f"ta_download/_doc/{self.video_id}"
_, _ = ElasticWrap(path).delete(refresh=True)
def delete_by_status(self):
"""delete all matching item by status"""
data = {"query": {"term": {"status": {"value": self.status}}}}
path = "ta_download/_delete_by_query"
_, _ = ElasticWrap(path).post(data=data)
def update_status(self):
"""update status field of pending item"""
data = {"doc": {"status": self.status}}
path = f"ta_download/_update/{self.video_id}"
_, _ = ElasticWrap(path).post(data=data)
class PendingList(PendingIndex):
"""manage the pending videos list"""
yt_obs = {
"default_search": "ytsearch",
"quiet": True,
"check_formats": "selected",
"noplaylist": True,
"writethumbnail": True,
"simulate": True,
"socket_timeout": 3,
}
def __init__(self, youtube_ids=False):
super().__init__()
self.process_config()
self.youtube_ids = youtube_ids
self.to_skip = False
self.missing_videos = False
def process_config(self):
"""add user config to yt_obs"""
config = AppConfig().config
if config["downloads"]["cookie_import"]:
cookie_path = CookieHandler().use()
self.yt_obs.update({"cookiefile": cookie_path})
def close_config(self):
"""remove config after task finished"""
config = AppConfig().config
if config["downloads"]["cookie_import"]:
CookieHandler().hide()
try:
del self.yt_obs["cookiefile"]
except KeyError:
pass
def parse_url_list(self):
"""extract youtube ids from list""" """extract youtube ids from list"""
missing_videos = [] self.missing_videos = []
for entry in youtube_ids: self.get_download()
self.get_indexed()
for entry in self.youtube_ids:
# notify # notify
mess_dict = { mess_dict = {
"status": "message:add", "status": "message:add",
@@ -43,221 +169,128 @@ class PendingList:
"message": "Extracting lists", "message": "Extracting lists",
} }
RedisArchivist().set_message("message:add", mess_dict) RedisArchivist().set_message("message:add", mess_dict)
# extract self._process_entry(entry)
url = entry["url"]
url_type = entry["type"]
if url_type == "video":
missing_videos.append(url)
elif url_type == "channel":
video_results = ChannelSubscription().get_last_youtube_videos(
url, limit=False
)
youtube_ids = [i[0] for i in video_results]
missing_videos = missing_videos + youtube_ids
elif url_type == "playlist":
self.missing_from_playlists.append(entry)
playlist = YoutubePlaylist(url)
playlist.build_json()
video_results = playlist.json_data.get("playlist_entries")
youtube_ids = [i["youtube_id"] for i in video_results]
missing_videos = missing_videos + youtube_ids
return missing_videos def _process_entry(self, entry):
"""process single entry from url list"""
if entry["type"] == "video":
self._add_video(entry["url"])
elif entry["type"] == "channel":
self._parse_channel(entry["url"])
elif entry["type"] == "playlist":
self._parse_playlist(entry["url"])
new_thumbs = PlaylistSubscription().process_url_str(
[entry], subscribed=False
)
ThumbManager().download_playlist(new_thumbs)
else:
raise ValueError(f"invalid url_type: {entry}")
def add_to_pending(self, missing_videos, ignore=False): def _add_video(self, url):
"""build the bulk json data from pending""" """add video to list"""
# check if channel is indexed if url not in self.missing_videos and url not in self.to_skip:
channel_handler = ChannelSubscription() self.missing_videos.append(url)
all_indexed = channel_handler.get_channels(subscribed_only=False)
self.all_channel_ids = [i["channel_id"] for i in all_indexed]
# check if already there
self.all_downloaded = self.get_all_downloaded()
bulk_list, all_videos_added = self.build_bulk(missing_videos, ignore) def _parse_channel(self, url):
# add last newline """add all videos of channel to list"""
bulk_list.append("\n") video_results = ChannelSubscription().get_last_youtube_videos(
query_str = "\n".join(bulk_list) url, limit=False
headers = {"Content-type": "application/x-ndjson"}
url = self.ES_URL + "/_bulk"
request = requests.post(
url, data=query_str, headers=headers, auth=self.ES_AUTH
) )
if not request.ok: youtube_ids = [i[0] for i in video_results]
print(request) for video_id in youtube_ids:
raise ValueError("failed to add video to download queue") self._add_video(video_id)
return all_videos_added def _parse_playlist(self, url):
"""add all videos of playlist to list"""
playlist = YoutubePlaylist(url)
playlist.build_json()
video_results = playlist.json_data.get("playlist_entries")
youtube_ids = [i["youtube_id"] for i in video_results]
for video_id in youtube_ids:
self._add_video(video_id)
def build_bulk(self, missing_videos, ignore=False): def add_to_pending(self, status="pending"):
"""build the bulk lists""" """add missing videos to pending list"""
self.get_channels()
bulk_list = [] bulk_list = []
all_videos_added = []
for idx, youtube_id in enumerate(missing_videos): thumb_handler = ThumbManager()
# check if already downloaded for idx, youtube_id in enumerate(self.missing_videos):
if youtube_id in self.all_downloaded: video_details = self.get_youtube_details(youtube_id)
if not video_details:
continue continue
video = self.get_youtube_details(youtube_id) video_details["status"] = status
# skip on download error
if not video:
continue
channel_indexed = video["channel_id"] in self.all_channel_ids
video["channel_indexed"] = channel_indexed
if ignore:
video["status"] = "ignore"
else:
video["status"] = "pending"
action = {"create": {"_id": youtube_id, "_index": "ta_download"}} action = {"create": {"_id": youtube_id, "_index": "ta_download"}}
bulk_list.append(json.dumps(action)) bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(video)) bulk_list.append(json.dumps(video_details))
all_videos_added.append((youtube_id, video["vid_thumb_url"]))
# notify
progress = f"{idx + 1}/{len(missing_videos)}"
mess_dict = {
"status": "message:add",
"level": "info",
"title": "Adding new videos to download queue.",
"message": "Progress: " + progress,
}
if idx + 1 == len(missing_videos):
RedisArchivist().set_message(
"message:add", mess_dict, expire=4
)
else:
RedisArchivist().set_message("message:add", mess_dict)
if idx + 1 % 25 == 0:
print("adding to queue progress: " + progress)
return bulk_list, all_videos_added thumb_needed = [(youtube_id, video_details["vid_thumb_url"])]
thumb_handler.download_vid(thumb_needed)
self._notify_add(idx)
@staticmethod if bulk_list:
def get_youtube_details(youtube_id): # add last newline
"""get details from youtubedl for single pending video""" bulk_list.append("\n")
obs = { query_str = "\n".join(bulk_list)
"default_search": "ytsearch", _, _ = ElasticWrap("_bulk").post(query_str, ndjson=True)
"quiet": True,
"check_formats": "selected", self.close_config()
"noplaylist": True,
"writethumbnail": True, def _notify_add(self, idx):
"simulate": True, """send notification for adding videos to download queue"""
progress = f"{idx + 1}/{len(self.missing_videos)}"
mess_dict = {
"status": "message:add",
"level": "info",
"title": "Adding new videos to download queue.",
"message": "Progress: " + progress,
} }
if idx + 1 == len(self.missing_videos):
RedisArchivist().set_message("message:add", mess_dict, expire=4)
else:
RedisArchivist().set_message("message:add", mess_dict)
if idx + 1 % 25 == 0:
print("adding to queue progress: " + progress)
def get_youtube_details(self, youtube_id):
"""get details from youtubedl for single pending video"""
try: try:
vid = yt_dlp.YoutubeDL(obs).extract_info(youtube_id) vid = yt_dlp.YoutubeDL(self.yt_obs).extract_info(youtube_id)
except yt_dlp.utils.DownloadError: except yt_dlp.utils.DownloadError:
print("failed to extract info for: " + youtube_id) print(f"{youtube_id}: failed to extract info")
return False
if vid.get("id") != youtube_id:
# skip premium videos with different id
print(f"{youtube_id}: skipping premium video, id not matching")
return False return False
# stop if video is streaming live now # stop if video is streaming live now
if vid["is_live"]: if vid["is_live"]:
return False return False
# parse response
seconds = vid["duration"] return self._parse_youtube_details(vid)
duration_str = DurationConverter.get_str(seconds)
def _parse_youtube_details(self, vid):
"""parse response"""
vid_id = vid.get("id")
duration_str = DurationConverter.get_str(vid["duration"])
if duration_str == "NA": if duration_str == "NA":
print(f"skip extracting duration for: {youtube_id}") print(f"skip extracting duration for: {vid_id}")
upload_date = vid["upload_date"] published = datetime.strptime(vid["upload_date"], "%Y%m%d").strftime(
upload_dt = datetime.strptime(upload_date, "%Y%m%d") "%Y-%m-%d"
published = upload_dt.strftime("%Y-%m-%d") )
# build dict # build dict
youtube_details = { youtube_details = {
"youtube_id": youtube_id, "youtube_id": vid_id,
"channel_name": vid["channel"], "channel_name": vid["channel"],
"vid_thumb_url": vid["thumbnail"], "vid_thumb_url": vid["thumbnail"],
"title": vid["title"], "title": vid["title"],
"channel_id": vid["channel_id"], "channel_id": vid["channel_id"],
"channel_indexed": vid["channel_id"] in self.all_channels,
"duration": duration_str, "duration": duration_str,
"published": published, "published": published,
"timestamp": int(datetime.now().strftime("%s")), "timestamp": int(datetime.now().strftime("%s")),
} }
return youtube_details return youtube_details
@staticmethod
def get_all_pending():
"""get a list of all pending videos in ta_download"""
data = {
"query": {"match_all": {}},
"sort": [{"timestamp": {"order": "asc"}}],
}
all_results = IndexPaginate("ta_download", data).get_results()
all_pending = []
all_ignore = []
for result in all_results:
if result["status"] == "pending":
all_pending.append(result)
elif result["status"] == "ignore":
all_ignore.append(result)
return all_pending, all_ignore
@staticmethod
def get_all_indexed():
"""get a list of all videos indexed"""
data = {
"query": {"match_all": {}},
"sort": [{"published": {"order": "desc"}}],
}
all_indexed = IndexPaginate("ta_video", data).get_results()
return all_indexed
def get_all_downloaded(self):
"""get a list of all videos in archive"""
channel_folders = os.listdir(self.VIDEOS)
all_channel_folders = ignore_filelist(channel_folders)
all_downloaded = []
for channel_folder in all_channel_folders:
channel_path = os.path.join(self.VIDEOS, channel_folder)
videos = os.listdir(channel_path)
all_videos = ignore_filelist(videos)
youtube_vids = [i[9:20] for i in all_videos]
for youtube_id in youtube_vids:
all_downloaded.append(youtube_id)
return all_downloaded
def delete_from_pending(self, youtube_id):
"""delete the youtube_id from ta_download"""
url = f"{self.ES_URL}/ta_download/_doc/{youtube_id}"
response = requests.delete(url, auth=self.ES_AUTH)
if not response.ok:
print(response.text)
def delete_pending(self, status):
"""delete download queue based on status value"""
data = {"query": {"term": {"status": {"value": status}}}}
payload = json.dumps(data)
url = self.ES_URL + "/ta_download/_delete_by_query"
headers = {"Content-type": "application/json"}
response = requests.post(
url, data=payload, headers=headers, auth=self.ES_AUTH
)
if not response.ok:
print(response.text)
def ignore_from_pending(self, ignore_list):
"""build the bulk query string"""
stamp = int(datetime.now().strftime("%s"))
bulk_list = []
for youtube_id in ignore_list:
action = {"update": {"_id": youtube_id, "_index": "ta_download"}}
source = {"doc": {"status": "ignore", "timestamp": stamp}}
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(source))
# add last newline
bulk_list.append("\n")
query_str = "\n".join(bulk_list)
headers = {"Content-type": "application/x-ndjson"}
url = self.ES_URL + "/_bulk"
request = requests.post(
url, data=query_str, headers=headers, auth=self.ES_AUTH
)
if not request.ok:
print(request)
raise ValueError("failed to set video to ignore")

View File

@@ -61,11 +61,9 @@ class ChannelSubscription:
def find_missing(self): def find_missing(self):
"""add missing videos from subscribed channels to pending""" """add missing videos from subscribed channels to pending"""
all_channels = self.get_channels() all_channels = self.get_channels()
pending_handler = queue.PendingList() pending = queue.PendingList()
all_pending, all_ignore = pending_handler.get_all_pending() pending.get_download()
all_ids = [i["youtube_id"] for i in all_ignore + all_pending] pending.get_indexed()
all_downloaded = pending_handler.get_all_downloaded()
to_ignore = all_ids + all_downloaded
missing_videos = [] missing_videos = []
@@ -75,7 +73,7 @@ class ChannelSubscription:
if last_videos: if last_videos:
for video in last_videos: for video in last_videos:
if video[0] not in to_ignore: if video[0] not in pending.to_skip:
missing_videos.append(video[0]) missing_videos.append(video[0])
# notify # notify
message = { message = {
@@ -129,7 +127,11 @@ class PlaylistSubscription:
def process_url_str(self, new_playlists, subscribed=True): def process_url_str(self, new_playlists, subscribed=True):
"""process playlist subscribe form url_str""" """process playlist subscribe form url_str"""
all_indexed = queue.PendingList().get_all_indexed() data = {
"query": {"match_all": {}},
"sort": [{"published": {"order": "desc"}}],
}
all_indexed = IndexPaginate("ta_video", data).get_results()
all_youtube_ids = [i["youtube_id"] for i in all_indexed] all_youtube_ids = [i["youtube_id"] for i in all_indexed]
new_thumbs = [] new_thumbs = []
@@ -166,7 +168,7 @@ class PlaylistSubscription:
def channel_validate(channel_id): def channel_validate(channel_id):
"""make sure channel of playlist is there""" """make sure channel of playlist is there"""
channel = YoutubeChannel(channel_id) channel = YoutubeChannel(channel_id)
channel.build_json() channel.build_json(upload=True)
@staticmethod @staticmethod
def change_subscribe(playlist_id, subscribe_status): def change_subscribe(playlist_id, subscribe_status):
@@ -179,12 +181,11 @@ class PlaylistSubscription:
@staticmethod @staticmethod
def get_to_ignore(): def get_to_ignore():
"""get all youtube_ids already downloaded or ignored""" """get all youtube_ids already downloaded or ignored"""
pending_handler = queue.PendingList() pending = queue.PendingList()
all_pending, all_ignore = pending_handler.get_all_pending() pending.get_download()
all_ids = [i["youtube_id"] for i in all_ignore + all_pending] pending.get_indexed()
all_downloaded = pending_handler.get_all_downloaded()
to_ignore = all_ids + all_downloaded return pending.to_skip
return to_ignore
def find_missing(self): def find_missing(self):
"""find videos in subscribed playlists not downloaded yet""" """find videos in subscribed playlists not downloaded yet"""

View File

@@ -4,8 +4,10 @@ functionality:
- check for missing thumbnails - check for missing thumbnails
""" """
import base64
import os import os
from collections import Counter from collections import Counter
from io import BytesIO
from time import sleep from time import sleep
import requests import requests
@@ -15,7 +17,7 @@ from home.src.ta.config import AppConfig
from home.src.ta.helper import ignore_filelist from home.src.ta.helper import ignore_filelist
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
from PIL import Image from PIL import Image, ImageFilter
class ThumbManager: class ThumbManager:
@@ -58,11 +60,13 @@ class ThumbManager:
def get_needed_thumbs(self, missing_only=False): def get_needed_thumbs(self, missing_only=False):
"""get a list of all missing thumbnails""" """get a list of all missing thumbnails"""
all_thumbs = self.get_all_thumbs() all_thumbs = self.get_all_thumbs()
all_indexed = queue.PendingList().get_all_indexed()
all_in_queue, all_ignored = queue.PendingList().get_all_pending() pending = queue.PendingList()
pending.get_download()
pending.get_indexed()
needed_thumbs = [] needed_thumbs = []
for video in all_indexed: for video in pending.all_videos:
youtube_id = video["youtube_id"] youtube_id = video["youtube_id"]
thumb_url = video["vid_thumb_url"] thumb_url = video["vid_thumb_url"]
if missing_only: if missing_only:
@@ -71,7 +75,7 @@ class ThumbManager:
else: else:
needed_thumbs.append((youtube_id, thumb_url)) needed_thumbs.append((youtube_id, thumb_url))
for video in all_in_queue + all_ignored: for video in pending.all_pending + pending.all_ignored:
youtube_id = video["youtube_id"] youtube_id = video["youtube_id"]
thumb_url = video["vid_thumb_url"] thumb_url = video["vid_thumb_url"]
if missing_only: if missing_only:
@@ -239,6 +243,21 @@ class ThumbManager:
} }
RedisArchivist().set_message("message:download", mess_dict) RedisArchivist().set_message("message:download", mess_dict)
def get_base64_blur(self, youtube_id):
"""return base64 encoded placeholder"""
img_path = self.vid_thumb_path(youtube_id)
file_path = os.path.join(self.CACHE_DIR, img_path)
img_raw = Image.open(file_path)
img_raw.thumbnail((img_raw.width // 20, img_raw.height // 20))
img_blur = img_raw.filter(ImageFilter.BLUR)
buffer = BytesIO()
img_blur.save(buffer, format="JPEG")
img_data = buffer.getvalue()
img_base64 = base64.b64encode(img_data).decode()
data_url = f"data:image/jpg;base64,{img_base64}"
return data_url
@staticmethod @staticmethod
def vid_thumb_path(youtube_id): def vid_thumb_path(youtube_id):
"""build expected path for video thumbnail from youtube_id""" """build expected path for video thumbnail from youtube_id"""
@@ -276,9 +295,12 @@ class ThumbManager:
def get_thumb_list(self): def get_thumb_list(self):
"""get list of mediafiles and matching thumbnails""" """get list of mediafiles and matching thumbnails"""
all_indexed = queue.PendingList().get_all_indexed() pending = queue.PendingList()
pending.get_download()
pending.get_indexed()
video_list = [] video_list = []
for video in all_indexed: for video in pending.all_videos:
youtube_id = video["youtube_id"] youtube_id = video["youtube_id"]
media_url = os.path.join(self.MEDIA_DIR, video["media_url"]) media_url = os.path.join(self.MEDIA_DIR, video["media_url"])
thumb_path = os.path.join( thumb_path = os.path.join(

View File

@@ -0,0 +1,88 @@
"""
functionality:
- import yt cookie from filesystem
- make cookie available for yt-dlp
"""
import os
import yt_dlp
from home.src.ta.config import AppConfig
from home.src.ta.ta_redis import RedisArchivist
class CookieHandler:
"""handle youtube cookie for yt-dlp"""
CONFIG = AppConfig().config
CACHE_PATH = CONFIG["application"]["cache_dir"]
COOKIE_FILE_NAME = "cookies.google.txt"
COOKIE_KEY = "cookie"
COOKIE_PATH = "cookie.txt"
def import_cookie(self):
"""import cookie from file"""
import_path = os.path.join(
self.CACHE_PATH, "import", self.COOKIE_FILE_NAME
)
with open(import_path, encoding="utf-8") as cookie_file:
cookie = cookie_file.read()
RedisArchivist().set_message(self.COOKIE_KEY, cookie, expire=False)
os.remove(import_path)
print("cookie: import successfully")
def use(self):
"""make cookie available in FS"""
cookie = RedisArchivist().get_message(self.COOKIE_KEY)
if isinstance(cookie, dict):
print("no cookie imported")
raise FileNotFoundError
if os.path.exists(self.COOKIE_PATH):
return self.COOKIE_PATH
with open(self.COOKIE_PATH, "w", encoding="utf-8") as cookie_file:
cookie_file.write(cookie)
print("cookie: made available")
return self.COOKIE_PATH
def hide(self):
"""hide cookie file if not in use"""
try:
os.remove(self.COOKIE_PATH)
except FileNotFoundError:
print("cookie: not available")
return
print("cookie: hidden")
def revoke(self):
"""revoke cookie"""
self.hide()
RedisArchivist().del_message(self.COOKIE_KEY)
print("cookie: revoked")
def validate(self):
"""validate cookie using the liked videos playlist"""
try:
_ = self.use()
except FileNotFoundError:
return False
url = "https://www.youtube.com/playlist?list=LL"
yt_obs = {
"quiet": True,
"skip_download": True,
"extract_flat": True,
"cookiefile": self.COOKIE_PATH,
}
try:
response = yt_dlp.YoutubeDL(yt_obs).extract_info(url)
except yt_dlp.utils.DownloadError:
print("failed to validate cookie")
response = False
return bool(response)

View File

@@ -11,11 +11,11 @@ import shutil
from datetime import datetime from datetime import datetime
from time import sleep from time import sleep
import requests
import yt_dlp import yt_dlp
from home.src.download.queue import PendingList from home.src.download.queue import PendingList
from home.src.download.subscriptions import PlaylistSubscription from home.src.download.subscriptions import PlaylistSubscription
from home.src.es.connect import IndexPaginate from home.src.download.yt_cookie import CookieHandler
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.channel import YoutubeChannel from home.src.index.channel import YoutubeChannel
from home.src.index.playlist import YoutubePlaylist from home.src.index.playlist import YoutubePlaylist
from home.src.index.video import YoutubeVideo, index_new_video from home.src.index.video import YoutubeVideo, index_new_video
@@ -24,6 +24,124 @@ from home.src.ta.helper import clean_string, ignore_filelist
from home.src.ta.ta_redis import RedisArchivist, RedisQueue from home.src.ta.ta_redis import RedisArchivist, RedisQueue
class DownloadPostProcess:
"""handle task to run after download queue finishes"""
def __init__(self, download):
self.download = download
self.now = int(datetime.now().strftime("%s"))
self.pending = False
def run(self):
"""run all functions"""
self.pending = PendingList()
self.pending.get_download()
self.pending.get_channels()
self.pending.get_indexed()
self.auto_delete_all()
self.auto_delete_overwrites()
self.validate_playlists()
self.pending.close_config()
def auto_delete_all(self):
"""handle auto delete"""
autodelete_days = self.download.config["downloads"]["autodelete_days"]
if not autodelete_days:
return
print(f"auto delete older than {autodelete_days} days")
now_lte = self.now - autodelete_days * 24 * 60 * 60
data = {
"query": {"range": {"player.watched_date": {"lte": now_lte}}},
"sort": [{"player.watched_date": {"order": "asc"}}],
}
self._auto_delete_watched(data)
def auto_delete_overwrites(self):
"""handle per channel auto delete from overwrites"""
for channel_id, value in self.pending.channel_overwrites.items():
if "autodelete_days" in value:
autodelete_days = value.get("autodelete_days")
print(f"{channel_id}: delete older than {autodelete_days}d")
now_lte = self.now - autodelete_days * 24 * 60 * 60
must_list = [
{"range": {"player.watched_date": {"lte": now_lte}}},
{"term": {"channel.channel_id": {"value": channel_id}}},
]
data = {
"query": {"bool": {"must": must_list}},
"sort": [{"player.watched_date": {"order": "desc"}}],
}
self._auto_delete_watched(data)
@staticmethod
def _auto_delete_watched(data):
"""delete watched videos after x days"""
to_delete = IndexPaginate("ta_video", data).get_results()
if not to_delete:
return
for video in to_delete:
youtube_id = video["youtube_id"]
print(f"{youtube_id}: auto delete video")
YoutubeVideo(youtube_id).delete_media_file()
print("add deleted to ignore list")
vids = [{"type": "video", "url": i["youtube_id"]} for i in to_delete]
pending = PendingList(youtube_ids=vids)
pending.parse_url_list()
pending.add_to_pending(status="ignore")
def validate_playlists(self):
"""look for playlist needing to update"""
for id_c, channel_id in enumerate(self.download.channels):
channel = YoutubeChannel(channel_id)
overwrites = self.pending.channel_overwrites.get(channel_id, False)
if overwrites and overwrites.get("index_playlists"):
# validate from remote
channel.index_channel_playlists()
continue
# validate from local
playlists = channel.get_indexed_playlists()
all_channel_playlist = [i["playlist_id"] for i in playlists]
self._validate_channel_playlist(all_channel_playlist, id_c)
def _validate_channel_playlist(self, all_channel_playlist, id_c):
"""scan channel for playlist needing update"""
all_youtube_ids = [i["youtube_id"] for i in self.pending.all_videos]
for id_p, playlist_id in enumerate(all_channel_playlist):
playlist = YoutubePlaylist(playlist_id)
playlist.all_youtube_ids = all_youtube_ids
playlist.build_json(scrape=True)
if not playlist.json_data:
playlist.deactivate()
playlist.add_vids_to_playlist()
playlist.upload_to_es()
self._notify_playlist_progress(all_channel_playlist, id_c, id_p)
def _notify_playlist_progress(self, all_channel_playlist, id_c, id_p):
"""notify to UI"""
title = (
"Processing playlists for channels: "
+ f"{id_c + 1}/{len(self.download.channels)}"
)
message = f"Progress: {id_p + 1}/{len(all_channel_playlist)}"
mess_dict = {
"status": "message:download",
"level": "info",
"title": title,
"message": message,
}
if id_p + 1 == len(all_channel_playlist):
RedisArchivist().set_message(
"message:download", mess_dict, expire=4
)
else:
RedisArchivist().set_message("message:download", mess_dict)
class VideoDownloader: class VideoDownloader:
""" """
handle the video download functionality handle the video download functionality
@@ -32,6 +150,7 @@ class VideoDownloader:
def __init__(self, youtube_id_list=False): def __init__(self, youtube_id_list=False):
self.obs = False self.obs = False
self.video_overwrites = False
self.youtube_id_list = youtube_id_list self.youtube_id_list = youtube_id_list
self.config = AppConfig().config self.config = AppConfig().config
self._build_obs() self._build_obs()
@@ -39,7 +158,12 @@ class VideoDownloader:
def run_queue(self): def run_queue(self):
"""setup download queue in redis loop until no more items""" """setup download queue in redis loop until no more items"""
queue = RedisQueue("dl_queue") pending = PendingList()
pending.get_download()
pending.get_channels()
self.video_overwrites = pending.video_overwrites
queue = RedisQueue()
limit_queue = self.config["downloads"]["limit_count"] limit_queue = self.config["downloads"]["limit_count"]
if limit_queue: if limit_queue:
@@ -55,15 +179,31 @@ class VideoDownloader:
except yt_dlp.utils.DownloadError: except yt_dlp.utils.DownloadError:
print("failed to download " + youtube_id) print("failed to download " + youtube_id)
continue continue
vid_dict = index_new_video(youtube_id) vid_dict = index_new_video(
youtube_id, video_overwrites=self.video_overwrites
)
self.channels.add(vid_dict["channel"]["channel_id"]) self.channels.add(vid_dict["channel"]["channel_id"])
mess_dict = {
"status": "message:download",
"level": "info",
"title": "Moving....",
"message": "Moving downloaded file to storage folder",
}
RedisArchivist().set_message("message:download", mess_dict, False)
self.move_to_archive(vid_dict) self.move_to_archive(vid_dict)
mess_dict = {
"status": "message:download",
"level": "info",
"title": "Completed",
"message": "",
}
RedisArchivist().set_message("message:download", mess_dict, 10)
self._delete_from_pending(youtube_id) self._delete_from_pending(youtube_id)
autodelete_days = self.config["downloads"]["autodelete_days"] # post processing
if autodelete_days: self._add_subscribed_channels()
print(f"auto delete older than {autodelete_days} days") DownloadPostProcess(self).run()
self.auto_delete_watched(autodelete_days)
@staticmethod @staticmethod
def add_pending(): def add_pending():
@@ -75,8 +215,9 @@ class VideoDownloader:
"message": "Scanning your download queue.", "message": "Scanning your download queue.",
} }
RedisArchivist().set_message("message:download", mess_dict) RedisArchivist().set_message("message:download", mess_dict)
all_pending, _ = PendingList().get_all_pending() pending = PendingList()
to_add = [i["youtube_id"] for i in all_pending] pending.get_download()
to_add = [i["youtube_id"] for i in pending.all_pending]
if not to_add: if not to_add:
# there is nothing pending # there is nothing pending
print("download queue is empty") print("download queue is empty")
@@ -89,8 +230,7 @@ class VideoDownloader:
RedisArchivist().set_message("message:download", mess_dict) RedisArchivist().set_message("message:download", mess_dict)
return return
queue = RedisQueue("dl_queue") RedisQueue().add_list(to_add)
queue.add_list(to_add)
@staticmethod @staticmethod
def _progress_hook(response): def _progress_hook(response):
@@ -142,6 +282,7 @@ class VideoDownloader:
"writethumbnail": False, "writethumbnail": False,
"noplaylist": True, "noplaylist": True,
"check_formats": "selected", "check_formats": "selected",
"socket_timeout": 3,
} }
def _build_obs_user(self): def _build_obs_user(self):
@@ -152,6 +293,9 @@ class VideoDownloader:
self.obs["ratelimit"] = ( self.obs["ratelimit"] = (
self.config["downloads"]["limit_speed"] * 1024 self.config["downloads"]["limit_speed"] * 1024
) )
if self.config["downloads"]["cookie_import"]:
cookie_path = CookieHandler().use()
self.obs["cookiefile"] = cookie_path
throttle = self.config["downloads"]["throttledratelimit"] throttle = self.config["downloads"]["throttledratelimit"]
if throttle: if throttle:
@@ -181,16 +325,30 @@ class VideoDownloader:
self.obs["postprocessors"] = postprocessors self.obs["postprocessors"] = postprocessors
def get_format_overwrites(self, youtube_id):
"""get overwrites from single video"""
overwrites = self.video_overwrites.get(youtube_id, False)
if overwrites:
return overwrites.get("download_format", False)
return False
def _dl_single_vid(self, youtube_id): def _dl_single_vid(self, youtube_id):
"""download single video""" """download single video"""
obs = self.obs.copy()
format_overwrite = self.get_format_overwrites(youtube_id)
if format_overwrite:
obs["format"] = format_overwrite
dl_cache = self.config["application"]["cache_dir"] + "/download/" dl_cache = self.config["application"]["cache_dir"] + "/download/"
# check if already in cache to continue from there # check if already in cache to continue from there
all_cached = ignore_filelist(os.listdir(dl_cache)) all_cached = ignore_filelist(os.listdir(dl_cache))
for file_name in all_cached: for file_name in all_cached:
if youtube_id in file_name: if youtube_id in file_name:
self.obs["outtmpl"] = os.path.join(dl_cache, file_name) obs["outtmpl"] = os.path.join(dl_cache, file_name)
with yt_dlp.YoutubeDL(self.obs) as ydl:
with yt_dlp.YoutubeDL(obs) as ydl:
try: try:
ydl.download([youtube_id]) ydl.download([youtube_id])
except yt_dlp.utils.DownloadError: except yt_dlp.utils.DownloadError:
@@ -234,14 +392,11 @@ class VideoDownloader:
if host_uid and host_gid: if host_uid and host_gid:
os.chown(new_file_path, host_uid, host_gid) os.chown(new_file_path, host_uid, host_gid)
def _delete_from_pending(self, youtube_id): @staticmethod
def _delete_from_pending(youtube_id):
"""delete downloaded video from pending index if its there""" """delete downloaded video from pending index if its there"""
es_url = self.config["application"]["es_url"] path = f"ta_download/_doc/{youtube_id}"
es_auth = self.config["application"]["es_auth"] _, _ = ElasticWrap(path).delete()
url = f"{es_url}/ta_download/_doc/{youtube_id}"
response = requests.delete(url, auth=es_auth)
if not response.ok and not response.status_code == 404:
print(response.text)
def _add_subscribed_channels(self): def _add_subscribed_channels(self):
"""add all channels subscribed to refresh""" """add all channels subscribed to refresh"""
@@ -254,63 +409,3 @@ class VideoDownloader:
self.channels.add(channel_id) self.channels.add(channel_id)
return return
def validate_playlists(self):
"""look for playlist needing to update"""
print("sync playlists")
self._add_subscribed_channels()
all_indexed = PendingList().get_all_indexed()
all_youtube_ids = [i["youtube_id"] for i in all_indexed]
for id_c, channel_id in enumerate(self.channels):
playlists = YoutubeChannel(channel_id).get_indexed_playlists()
all_playlist_ids = [i["playlist_id"] for i in playlists]
for id_p, playlist_id in enumerate(all_playlist_ids):
playlist = YoutubePlaylist(playlist_id)
playlist.all_youtube_ids = all_youtube_ids
playlist.build_json(scrape=True)
if not playlist.json_data:
playlist.deactivate()
playlist.add_vids_to_playlist()
playlist.upload_to_es()
# notify
title = (
"Processing playlists for channels: "
+ f"{id_c + 1}/{len(self.channels)}"
)
message = f"Progress: {id_p + 1}/{len(all_playlist_ids)}"
mess_dict = {
"status": "message:download",
"level": "info",
"title": title,
"message": message,
}
if id_p + 1 == len(all_playlist_ids):
RedisArchivist().set_message(
"message:download", mess_dict, expire=4
)
else:
RedisArchivist().set_message("message:download", mess_dict)
@staticmethod
def auto_delete_watched(autodelete_days):
"""delete watched videos after x days"""
now = int(datetime.now().strftime("%s"))
now_lte = now - autodelete_days * 24 * 60 * 60
data = {
"query": {"range": {"player.watched_date": {"lte": now_lte}}},
"sort": [{"player.watched_date": {"order": "asc"}}],
}
all_to_delete = IndexPaginate("ta_video", data).get_results()
all_youtube_ids = [i["youtube_id"] for i in all_to_delete]
if not all_youtube_ids:
return
for youtube_id in all_youtube_ids:
print(f"autodelete {youtube_id}")
YoutubeVideo(youtube_id).delete_media_file()
print("add deleted to ignore list")
pending_handler = PendingList()
pending_handler.add_to_pending(all_youtube_ids, ignore=True)

View File

@@ -75,8 +75,10 @@ class ElasticWrap:
return response.json(), response.status_code return response.json(), response.status_code
def delete(self, data=False): def delete(self, data=False, refresh=False):
"""delete document from es""" """delete document from es"""
if refresh:
self.url = f"{self.url}/?refresh=true"
if data: if data:
response = requests.delete(self.url, json=data, auth=self.auth) response = requests.delete(self.url, json=data, auth=self.auth)
else: else:
@@ -93,11 +95,12 @@ class IndexPaginate:
DEFAULT_SIZE = 500 DEFAULT_SIZE = 500
def __init__(self, index_name, data, size=False): def __init__(self, index_name, data, size=False, keep_source=False):
self.index_name = index_name self.index_name = index_name
self.data = data self.data = data
self.pit_id = False self.pit_id = False
self.size = size self.size = size
self.keep_source = keep_source
def get_results(self): def get_results(self):
"""get all results""" """get all results"""
@@ -132,7 +135,10 @@ class IndexPaginate:
all_hits = response["hits"]["hits"] all_hits = response["hits"]["hits"]
if all_hits: if all_hits:
for hit in all_hits: for hit in all_hits:
source = hit["_source"] if self.keep_source:
source = hit
else:
source = hit["_source"]
search_after = hit["sort"] search_after = hit["sort"]
all_results.append(source) all_results.append(source)
# update search_after with last hit data # update search_after with last hit data

View File

@@ -39,6 +39,22 @@
"channel_last_refresh": { "channel_last_refresh": {
"type": "date", "type": "date",
"format": "epoch_second" "format": "epoch_second"
},
"channel_overwrites": {
"properties": {
"download_format": {
"type": "text"
},
"autodelete_days": {
"type": "long"
},
"index_playlists": {
"type": "boolean"
},
"integrate_sponsorblock": {
"type" : "boolean"
}
}
} }
}, },
"expected_set": { "expected_set": {
@@ -60,6 +76,10 @@
"type": "text", "type": "text",
"index": false "index": false
}, },
"vid_thumb_base64": {
"type": "text",
"index": false
},
"date_downloaded": { "date_downloaded": {
"type": "date" "type": "date"
}, },
@@ -102,6 +122,22 @@
"channel_last_refresh": { "channel_last_refresh": {
"type": "date", "type": "date",
"format": "epoch_second" "format": "epoch_second"
},
"channel_overwrites": {
"properties": {
"download_format": {
"type": "text"
},
"autodelete_days": {
"type": "long"
},
"index_playlists": {
"type": "boolean"
},
"integrate_sponsorblock": {
"type" : "boolean"
}
}
} }
} }
}, },
@@ -157,6 +193,22 @@
} }
} }
}, },
"stats" : {
"properties" : {
"average_rating" : {
"type" : "float"
},
"dislike_count" : {
"type" : "long"
},
"like_count" : {
"type" : "long"
},
"view_count" : {
"type" : "long"
}
}
},
"subtitles": { "subtitles": {
"properties": { "properties": {
"ext": { "ext": {
@@ -182,6 +234,44 @@
"index": false "index": false
} }
} }
},
"sponsorblock": {
"properties": {
"last_refresh": {
"type": "date"
},
"has_unlocked": {
"type": "boolean"
},
"is_enabled": {
"type": "boolean"
},
"segments" : {
"properties" : {
"UUID" : {
"type": "keyword"
},
"actionType" : {
"type": "keyword"
},
"category" : {
"type": "keyword"
},
"locked" : {
"type" : "short"
},
"segment" : {
"type" : "float"
},
"videoDuration" : {
"type" : "float"
},
"votes" : {
"type" : "long"
}
}
}
}
} }
}, },
"expected_set": { "expected_set": {

View File

@@ -10,7 +10,7 @@ import os
import zipfile import zipfile
from datetime import datetime from datetime import datetime
import requests from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
from home.src.ta.helper import ignore_filelist from home.src.ta.helper import ignore_filelist
@@ -20,11 +20,6 @@ class ElasticIndex:
handle mapping and settings on elastic search for a given index handle mapping and settings on elastic search for a given index
""" """
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
HEADERS = {"Content-type": "application/json"}
def __init__(self, index_name, expected_map, expected_set): def __init__(self, index_name, expected_map, expected_set):
self.index_name = index_name self.index_name = index_name
self.expected_map = expected_map self.expected_map = expected_map
@@ -33,15 +28,9 @@ class ElasticIndex:
def index_exists(self): def index_exists(self):
"""check if index already exists and return mapping if it does""" """check if index already exists and return mapping if it does"""
index_name = self.index_name response, status_code = ElasticWrap(f"ta_{self.index_name}").get()
url = f"{self.ES_URL}/ta_{index_name}" exists = status_code == 200
response = requests.get(url, auth=self.ES_AUTH) details = response.get(f"ta_{self.index_name}", False)
exists = response.ok
if exists:
details = response.json()[f"ta_{index_name}"]
else:
details = False
return exists, details return exists, details
@@ -65,14 +54,15 @@ class ElasticIndex:
def validate_mappings(self): def validate_mappings(self):
"""check if all mappings are as expected""" """check if all mappings are as expected"""
expected_map = self.expected_map
now_map = self.details["mappings"]["properties"] now_map = self.details["mappings"]["properties"]
for key, value in expected_map.items(): for key, value in self.expected_map.items():
# nested # nested
if list(value.keys()) == ["properties"]: if list(value.keys()) == ["properties"]:
for key_n, value_n in value["properties"].items(): for key_n, value_n in value["properties"].items():
if key not in now_map:
print(key_n, value_n)
return True
if key_n not in now_map[key]["properties"].keys(): if key_n not in now_map[key]["properties"].keys():
print(key_n, value_n) print(key_n, value_n)
return True return True
@@ -110,63 +100,41 @@ class ElasticIndex:
def rebuild_index(self): def rebuild_index(self):
"""rebuild with new mapping""" """rebuild with new mapping"""
# backup
self.reindex("backup") self.reindex("backup")
# delete original
self.delete_index(backup=False) self.delete_index(backup=False)
# create new
self.create_blank() self.create_blank()
self.reindex("restore") self.reindex("restore")
# delete backup
self.delete_index() self.delete_index()
def reindex(self, method): def reindex(self, method):
"""create on elastic search""" """create on elastic search"""
index_name = self.index_name
if method == "backup": if method == "backup":
source = f"ta_{index_name}" source = f"ta_{self.index_name}"
destination = f"ta_{index_name}_backup" destination = f"ta_{self.index_name}_backup"
elif method == "restore": elif method == "restore":
source = f"ta_{index_name}_backup" source = f"ta_{self.index_name}_backup"
destination = f"ta_{index_name}" destination = f"ta_{self.index_name}"
query = {"source": {"index": source}, "dest": {"index": destination}} data = {"source": {"index": source}, "dest": {"index": destination}}
data = json.dumps(query) _, _ = ElasticWrap("_reindex?refresh=true").post(data=data)
url = self.ES_URL + "/_reindex?refresh=true"
response = requests.post(
url=url, data=data, headers=self.HEADERS, auth=self.ES_AUTH
)
if not response.ok:
print(response.text)
def delete_index(self, backup=True): def delete_index(self, backup=True):
"""delete index passed as argument""" """delete index passed as argument"""
path = f"ta_{self.index_name}"
if backup: if backup:
url = f"{self.ES_URL}/ta_{self.index_name}_backup" path = path + "_backup"
else:
url = f"{self.ES_URL}/ta_{self.index_name}" _, _ = ElasticWrap(path).delete()
response = requests.delete(url, auth=self.ES_AUTH)
if not response.ok:
print(response.text)
def create_blank(self): def create_blank(self):
"""apply new mapping and settings for blank new index""" """apply new mapping and settings for blank new index"""
expected_map = self.expected_map data = {}
expected_set = self.expected_set if self.expected_set:
# stich payload data.update({"settings": self.expected_set})
payload = {} if self.expected_map:
if expected_set: data.update({"mappings": {"properties": self.expected_map}})
payload.update({"settings": expected_set})
if expected_map: _, _ = ElasticWrap(f"ta_{self.index_name}").put(data)
payload.update({"mappings": {"properties": expected_map}})
# create
url = f"{self.ES_URL}/ta_{self.index_name}"
data = json.dumps(payload)
response = requests.put(
url=url, data=data, headers=self.HEADERS, auth=self.ES_AUTH
)
if not response.ok:
print(response.text)
class ElasticBackup: class ElasticBackup:
@@ -174,52 +142,21 @@ class ElasticBackup:
def __init__(self, index_config, reason): def __init__(self, index_config, reason):
self.config = AppConfig().config self.config = AppConfig().config
self.cache_dir = self.config["application"]["cache_dir"]
self.index_config = index_config self.index_config = index_config
self.reason = reason self.reason = reason
self.timestamp = datetime.now().strftime("%Y%m%d") self.timestamp = datetime.now().strftime("%Y%m%d")
self.backup_files = [] self.backup_files = []
def get_all_documents(self, index_name): @staticmethod
def get_all_documents(index_name):
"""export all documents of a single index""" """export all documents of a single index"""
headers = {"Content-type": "application/json"}
es_url = self.config["application"]["es_url"]
es_auth = self.config["application"]["es_auth"]
# get PIT ID
url = f"{es_url}/ta_{index_name}/_pit?keep_alive=1m"
response = requests.post(url, auth=es_auth)
json_data = json.loads(response.text)
pit_id = json_data["id"]
# build query
data = { data = {
"query": {"match_all": {}}, "query": {"match_all": {}},
"size": 100, "sort": [{"_doc": {"order": "desc"}}],
"pit": {"id": pit_id, "keep_alive": "1m"},
"sort": [{"_id": {"order": "asc"}}],
} }
query_str = json.dumps(data) paginate = IndexPaginate(f"ta_{index_name}", data, keep_source=True)
url = es_url + "/_search" all_results = paginate.get_results()
# loop until nothing left
all_results = []
while True:
response = requests.get(
url, data=query_str, headers=headers, auth=es_auth
)
json_data = json.loads(response.text)
all_hits = json_data["hits"]["hits"]
if all_hits:
for hit in all_hits:
search_after = hit["sort"]
all_results.append(hit)
# update search_after with last hit data
data["search_after"] = search_after
query_str = json.dumps(data)
else:
break
# clean up PIT
query_str = json.dumps({"id": pit_id})
requests.delete(
es_url + "/_pit", data=query_str, headers=headers, auth=es_auth
)
return all_results return all_results
@@ -244,9 +181,8 @@ class ElasticBackup:
def write_es_json(self, file_content, index_name): def write_es_json(self, file_content, index_name):
"""write nd-json file for es _bulk API to disk""" """write nd-json file for es _bulk API to disk"""
cache_dir = self.config["application"]["cache_dir"]
file_name = f"es_{index_name}-{self.timestamp}.json" file_name = f"es_{index_name}-{self.timestamp}.json"
file_path = os.path.join(cache_dir, "backup", file_name) file_path = os.path.join(self.cache_dir, "backup", file_name)
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
f.write(file_content) f.write(file_content)
@@ -254,9 +190,8 @@ class ElasticBackup:
def write_ta_json(self, all_results, index_name): def write_ta_json(self, all_results, index_name):
"""write generic json file to disk""" """write generic json file to disk"""
cache_dir = self.config["application"]["cache_dir"]
file_name = f"ta_{index_name}-{self.timestamp}.json" file_name = f"ta_{index_name}-{self.timestamp}.json"
file_path = os.path.join(cache_dir, "backup", file_name) file_path = os.path.join(self.cache_dir, "backup", file_name)
to_write = [i["_source"] for i in all_results] to_write = [i["_source"] for i in all_results]
file_content = json.dumps(to_write) file_content = json.dumps(to_write)
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
@@ -266,9 +201,8 @@ class ElasticBackup:
def zip_it(self): def zip_it(self):
"""pack it up into single zip file""" """pack it up into single zip file"""
cache_dir = self.config["application"]["cache_dir"]
file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip" file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip"
backup_folder = os.path.join(cache_dir, "backup") backup_folder = os.path.join(self.cache_dir, "backup")
backup_file = os.path.join(backup_folder, file_name) backup_file = os.path.join(backup_folder, file_name)
with zipfile.ZipFile( with zipfile.ZipFile(
@@ -283,29 +217,18 @@ class ElasticBackup:
def post_bulk_restore(self, file_name): def post_bulk_restore(self, file_name):
"""send bulk to es""" """send bulk to es"""
cache_dir = self.config["application"]["cache_dir"] file_path = os.path.join(self.cache_dir, file_name)
es_url = self.config["application"]["es_url"]
es_auth = self.config["application"]["es_auth"]
headers = {"Content-type": "application/x-ndjson"}
file_path = os.path.join(cache_dir, file_name)
with open(file_path, "r", encoding="utf-8") as f: with open(file_path, "r", encoding="utf-8") as f:
query_str = f.read() data = f.read()
if not query_str.strip(): if not data.strip():
return return
url = es_url + "/_bulk" _, _ = ElasticWrap("_bulk").post(data=data, ndjson=True)
request = requests.post(
url, data=query_str, headers=headers, auth=es_auth
)
if not request.ok:
print(request.text)
def get_all_backup_files(self): def get_all_backup_files(self):
"""build all available backup files for view""" """build all available backup files for view"""
cache_dir = self.config["application"]["cache_dir"] backup_dir = os.path.join(self.cache_dir, "backup")
backup_dir = os.path.join(cache_dir, "backup")
backup_files = os.listdir(backup_dir) backup_files = os.listdir(backup_dir)
all_backup_files = ignore_filelist(backup_files) all_backup_files = ignore_filelist(backup_files)
all_available_backups = [ all_available_backups = [
@@ -336,8 +259,7 @@ class ElasticBackup:
def unpack_zip_backup(self, filename): def unpack_zip_backup(self, filename):
"""extract backup zip and return filelist""" """extract backup zip and return filelist"""
cache_dir = self.config["application"]["cache_dir"] backup_dir = os.path.join(self.cache_dir, "backup")
backup_dir = os.path.join(cache_dir, "backup")
file_path = os.path.join(backup_dir, filename) file_path = os.path.join(backup_dir, filename)
with zipfile.ZipFile(file_path, "r") as z: with zipfile.ZipFile(file_path, "r") as z:
@@ -348,9 +270,7 @@ class ElasticBackup:
def restore_json_files(self, zip_content): def restore_json_files(self, zip_content):
"""go through the unpacked files and restore""" """go through the unpacked files and restore"""
backup_dir = os.path.join(self.cache_dir, "backup")
cache_dir = self.config["application"]["cache_dir"]
backup_dir = os.path.join(cache_dir, "backup")
for json_f in zip_content: for json_f in zip_content:
@@ -364,14 +284,13 @@ class ElasticBackup:
self.post_bulk_restore(file_name) self.post_bulk_restore(file_name)
os.remove(file_name) os.remove(file_name)
def index_exists(self, index_name): @staticmethod
def index_exists(index_name):
"""check if index already exists to skip""" """check if index already exists to skip"""
es_url = self.config["application"]["es_url"] _, status_code = ElasticWrap(f"ta_{index_name}").get()
es_auth = self.config["application"]["es_auth"] exists = status_code == 200
url = f"{es_url}/ta_{index_name}"
response = requests.get(url, auth=es_auth)
return response.ok return exists
def rotate_backup(self): def rotate_backup(self):
"""delete old backups if needed""" """delete old backups if needed"""
@@ -386,8 +305,7 @@ class ElasticBackup:
print("no backup files to rotate") print("no backup files to rotate")
return return
cache_dir = self.config["application"]["cache_dir"] backup_dir = os.path.join(self.cache_dir, "backup")
backup_dir = os.path.join(cache_dir, "backup")
all_to_delete = auto[rotate:] all_to_delete = auto[rotate:]
for to_delete in all_to_delete: for to_delete in all_to_delete:
@@ -459,6 +377,7 @@ def backup_all_indexes(reason):
for index in backup_handler.index_config: for index in backup_handler.index_config:
index_name = index["index_name"] index_name = index["index_name"]
print(f"backup: export in progress for {index_name}")
if not backup_handler.index_exists(index_name): if not backup_handler.index_exists(index_name):
continue continue
all_results = backup_handler.get_all_documents(index_name) all_results = backup_handler.get_all_documents(index_name)

View File

@@ -4,7 +4,7 @@ Functionality:
- called via user input - called via user input
""" """
from home.src.download.queue import PendingList from home.src.download.queue import PendingInteract
from home.src.download.subscriptions import ( from home.src.download.subscriptions import (
ChannelSubscription, ChannelSubscription,
PlaylistSubscription, PlaylistSubscription,
@@ -19,7 +19,6 @@ from home.src.ta.ta_redis import RedisArchivist, RedisQueue
from home.tasks import ( from home.tasks import (
download_pending, download_pending,
download_single, download_single,
extrac_dl,
index_channel_playlists, index_channel_playlists,
kill_dl, kill_dl,
re_sync_thumbs, re_sync_thumbs,
@@ -110,12 +109,11 @@ class PostData:
def _ignore(self): def _ignore(self):
"""ignore from download queue""" """ignore from download queue"""
id_to_ignore = self.exec_val video_id = self.exec_val
print("ignore video " + id_to_ignore) print(f"{video_id}: ignore video from download queue")
handler = PendingList() PendingInteract(video_id=video_id, status="ignore").update_status()
handler.ignore_from_pending([id_to_ignore])
# also clear from redis queue # also clear from redis queue
RedisQueue("dl_queue").clear_item(id_to_ignore) RedisQueue().clear_item(video_id)
return {"success": True} return {"success": True}
@staticmethod @staticmethod
@@ -124,7 +122,7 @@ class PostData:
print("download pending") print("download pending")
running = download_pending.delay() running = download_pending.delay()
task_id = running.id task_id = running.id
print("set task id: " + task_id) print(f"{task_id}: set task id")
RedisArchivist().set_message("dl_queue_id", task_id, expire=False) RedisArchivist().set_message("dl_queue_id", task_id, expire=False)
return {"success": True} return {"success": True}
@@ -133,7 +131,7 @@ class PostData:
to_execute = self.exec_val to_execute = self.exec_val
if to_execute == "stop": if to_execute == "stop":
print("stopping download queue") print("stopping download queue")
RedisQueue("dl_queue").clear() RedisQueue().clear()
elif to_execute == "kill": elif to_execute == "kill":
task_id = RedisArchivist().get_message("dl_queue_id") task_id = RedisArchivist().get_message("dl_queue_id")
if not isinstance(task_id, str): if not isinstance(task_id, str):
@@ -147,7 +145,7 @@ class PostData:
def _unsubscribe(self): def _unsubscribe(self):
"""unsubscribe from channels or playlists""" """unsubscribe from channels or playlists"""
id_unsub = self.exec_val id_unsub = self.exec_val
print("unsubscribe from " + id_unsub) print(f"{id_unsub}: unsubscribe")
to_unsub_list = UrlListParser(id_unsub).process_list() to_unsub_list = UrlListParser(id_unsub).process_list()
for to_unsub in to_unsub_list: for to_unsub in to_unsub_list:
unsub_type = to_unsub["type"] unsub_type = to_unsub["type"]
@@ -168,7 +166,7 @@ class PostData:
def _subscribe(self): def _subscribe(self):
"""subscribe to channel or playlist, called from js buttons""" """subscribe to channel or playlist, called from js buttons"""
id_sub = self.exec_val id_sub = self.exec_val
print("subscribe to " + id_sub) print(f"{id_sub}: subscribe")
subscribe_to.delay(id_sub) subscribe_to.delay(id_sub)
return {"success": True} return {"success": True}
@@ -204,7 +202,7 @@ class PostData:
def _dlnow(self): def _dlnow(self):
"""start downloading single vid now""" """start downloading single vid now"""
youtube_id = self.exec_val youtube_id = self.exec_val
print("downloading: " + youtube_id) print(f"{youtube_id}: downloading now")
running = download_single.delay(youtube_id=youtube_id) running = download_single.delay(youtube_id=youtube_id)
task_id = running.id task_id = running.id
print("set task id: " + task_id) print("set task id: " + task_id)
@@ -222,28 +220,23 @@ class PostData:
def _forget_ignore(self): def _forget_ignore(self):
"""delete from ta_download index""" """delete from ta_download index"""
youtube_id = self.exec_val video_id = self.exec_val
print("forgetting from download index: " + youtube_id) print(f"{video_id}: forget from download")
PendingList().delete_from_pending(youtube_id) PendingInteract(video_id=video_id).delete_item()
return {"success": True} return {"success": True}
def _add_single(self): def _add_single(self):
"""add single youtube_id to download queue""" """add single youtube_id to download queue"""
youtube_id = self.exec_val video_id = self.exec_val
print("add vid to dl queue: " + youtube_id) print(f"{video_id}: add single vid to download queue")
PendingList().delete_from_pending(youtube_id) PendingInteract(video_id=video_id, status="pending").update_status()
youtube_ids = UrlListParser(youtube_id).process_list()
extrac_dl.delay(youtube_ids)
return {"success": True} return {"success": True}
def _delete_queue(self): def _delete_queue(self):
"""delete download queue""" """delete download queue"""
status = self.exec_val status = self.exec_val
print("deleting from download queue: " + status) print("deleting from download queue: " + status)
if status == "pending": PendingInteract(status=status).delete_by_status()
PendingList().delete_pending("pending")
elif status == "ignore":
PendingList().delete_pending("ignore")
return {"success": True} return {"success": True}
@staticmethod @staticmethod

View File

@@ -62,6 +62,12 @@ class ApplicationSettingsForm(forms.Form):
("1", "enable ryd integration"), ("1", "enable ryd integration"),
] ]
SP_CHOICES = [
("", "-- change sponsorblock integrations"),
("0", "disable sponsorblock integration"),
("1", "enable sponsorblock integration"),
]
CAST_CHOICES = [ CAST_CHOICES = [
("", "-- change Cast integration --"), ("", "-- change Cast integration --"),
("0", "disable Cast"), ("0", "disable Cast"),
@@ -80,6 +86,12 @@ class ApplicationSettingsForm(forms.Form):
("1", "enable subtitle index"), ("1", "enable subtitle index"),
] ]
COOKIE_IMPORT_CHOICES = [
("", "-- change cookie settings"),
("0", "disable cookie"),
("1", "enable cookie"),
]
subscriptions_channel_size = forms.IntegerField(required=False) subscriptions_channel_size = forms.IntegerField(required=False)
downloads_limit_count = forms.IntegerField(required=False) downloads_limit_count = forms.IntegerField(required=False)
downloads_limit_speed = forms.IntegerField(required=False) downloads_limit_speed = forms.IntegerField(required=False)
@@ -100,9 +112,15 @@ class ApplicationSettingsForm(forms.Form):
downloads_subtitle_index = forms.ChoiceField( downloads_subtitle_index = forms.ChoiceField(
widget=forms.Select, choices=SUBTITLE_INDEX_CHOICES, required=False widget=forms.Select, choices=SUBTITLE_INDEX_CHOICES, required=False
) )
downloads_cookie_import = forms.ChoiceField(
widget=forms.Select, choices=COOKIE_IMPORT_CHOICES, required=False
)
downloads_integrate_ryd = forms.ChoiceField( downloads_integrate_ryd = forms.ChoiceField(
widget=forms.Select, choices=RYD_CHOICES, required=False widget=forms.Select, choices=RYD_CHOICES, required=False
) )
downloads_integrate_sponsorblock = forms.ChoiceField(
widget=forms.Select, choices=SP_CHOICES, required=False
)
application_enable_cast = forms.ChoiceField( application_enable_cast = forms.ChoiceField(
widget=forms.Select, choices=CAST_CHOICES, required=False widget=forms.Select, choices=CAST_CHOICES, required=False
) )
@@ -178,3 +196,29 @@ class SubscribeToPlaylistForm(forms.Form):
} }
), ),
) )
class ChannelOverwriteForm(forms.Form):
"""custom overwrites for channel settings"""
PLAYLIST_INDEX = [
("", "-- change playlist index --"),
("0", "Disable playlist index"),
("1", "Enable playlist index"),
]
SP_CHOICES = [
("", "-- change sponsorblock integrations"),
("disable", "disable sponsorblock integration"),
("1", "enable sponsorblock integration"),
("0", "unset sponsorblock integration"),
]
download_format = forms.CharField(label=False, required=False)
autodelete_days = forms.IntegerField(label=False, required=False)
index_playlists = forms.ChoiceField(
widget=forms.Select, choices=PLAYLIST_INDEX, required=False
)
integrate_sponsorblock = forms.ChoiceField(
widget=forms.Select, choices=SP_CHOICES, required=False
)

View File

@@ -3,22 +3,15 @@ functionality:
- handle watched state for videos, channels and playlists - handle watched state for videos, channels and playlists
""" """
import json
from datetime import datetime from datetime import datetime
import requests from home.src.es.connect import ElasticWrap
from home.src.ta.config import AppConfig
from home.src.ta.helper import UrlListParser from home.src.ta.helper import UrlListParser
class WatchState: class WatchState:
"""handle watched checkbox for videos and channels""" """handle watched checkbox for videos and channels"""
CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
HEADERS = {"Content-type": "application/json"}
def __init__(self, youtube_id): def __init__(self, youtube_id):
self.youtube_id = youtube_id self.youtube_id = youtube_id
self.stamp = int(datetime.now().strftime("%s")) self.stamp = int(datetime.now().strftime("%s"))
@@ -33,7 +26,7 @@ class WatchState:
elif url_type == "playlist": elif url_type == "playlist":
self.mark_playlist_watched() self.mark_playlist_watched()
print(f"marked {self.youtube_id} as watched") print(f"{self.youtube_id}: marked as watched")
def mark_as_unwatched(self): def mark_as_unwatched(self):
"""revert watched state to false""" """revert watched state to false"""
@@ -41,7 +34,7 @@ class WatchState:
if url_type == "video": if url_type == "video":
self.mark_vid_watched(revert=True) self.mark_vid_watched(revert=True)
print(f"revert {self.youtube_id} as unwatched") print(f"{self.youtube_id}: revert as unwatched")
def dedect_type(self): def dedect_type(self):
"""find youtube id type""" """find youtube id type"""
@@ -52,77 +45,54 @@ class WatchState:
def mark_vid_watched(self, revert=False): def mark_vid_watched(self, revert=False):
"""change watched status of single video""" """change watched status of single video"""
url = self.ES_URL + "/ta_video/_update/" + self.youtube_id path = f"ta_video/_update/{self.youtube_id}"
data = { data = {
"doc": {"player": {"watched": True, "watched_date": self.stamp}} "doc": {"player": {"watched": True, "watched_date": self.stamp}}
} }
if revert: if revert:
data["doc"]["player"]["watched"] = False data["doc"]["player"]["watched"] = False
payload = json.dumps(data) response, status_code = ElasticWrap(path).post(data=data)
request = requests.post( if status_code != 200:
url, data=payload, headers=self.HEADERS, auth=self.ES_AUTH print(response)
)
if not request.ok:
print(request.text)
raise ValueError("failed to mark video as watched") raise ValueError("failed to mark video as watched")
def mark_channel_watched(self): def mark_channel_watched(self):
"""change watched status of every video in channel""" """change watched status of every video in channel"""
path = "ta_video/_update_by_query"
must_list = [
{"term": {"channel.channel_id": {"value": self.youtube_id}}},
{"term": {"player.watched": {"value": False}}},
]
data = { data = {
"query": { "query": {"bool": {"must": must_list}},
"bool": {
"must": [
{
"term": {
"channel.channel_id": {
"value": self.youtube_id
}
}
},
{"term": {"player.watched": {"value": False}}},
]
}
},
"script": { "script": {
"source": "ctx._source.player['watched'] = true", "source": "ctx._source.player['watched'] = true",
"lang": "painless", "lang": "painless",
}, },
} }
payload = json.dumps(data)
url = f"{self.ES_URL}/ta_video/_update_by_query" response, status_code = ElasticWrap(path).post(data=data)
request = requests.post( if status_code != 200:
url, data=payload, headers=self.HEADERS, auth=self.ES_AUTH print(response)
)
if not request.ok:
print(request.text)
raise ValueError("failed mark channel as watched") raise ValueError("failed mark channel as watched")
def mark_playlist_watched(self): def mark_playlist_watched(self):
"""change watched state of all videos in playlist""" """change watched state of all videos in playlist"""
path = "ta_video/_update_by_query"
must_list = [
{"term": {"playlist.keyword": {"value": self.youtube_id}}},
{"term": {"player.watched": {"value": False}}},
]
data = { data = {
"query": { "query": {"bool": {"must": must_list}},
"bool": {
"must": [
{
"term": {
"playlist.keyword": {"value": self.youtube_id}
}
},
{"term": {"player.watched": {"value": False}}},
]
}
},
"script": { "script": {
"source": "ctx._source.player['watched'] = true", "source": "ctx._source.player['watched'] = true",
"lang": "painless", "lang": "painless",
}, },
} }
payload = json.dumps(data)
url = f"{self.ES_URL}/ta_video/_update_by_query" response, status_code = ElasticWrap(path).post(data=data)
request = requests.post( if status_code != 200:
url, data=payload, headers=self.HEADERS, auth=self.ES_AUTH print(response)
)
if not request.ok:
print(request.text)
raise ValueError("failed mark playlist as watched") raise ValueError("failed mark playlist as watched")

View File

@@ -12,11 +12,13 @@ from datetime import datetime
import requests import requests
import yt_dlp import yt_dlp
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from home.src.download import queue # partial import
from home.src.download.thumbnails import ThumbManager from home.src.download.thumbnails import ThumbManager
from home.src.es.connect import ElasticWrap, IndexPaginate from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.generic import YouTubeItem from home.src.index.generic import YouTubeItem
from home.src.index.playlist import YoutubePlaylist from home.src.index.playlist import YoutubePlaylist
from home.src.ta.helper import clean_string from home.src.ta.helper import clean_string, requests_headers
from home.src.ta.ta_redis import RedisArchivist
class ChannelScraper: class ChannelScraper:
@@ -44,7 +46,9 @@ class ChannelScraper:
print(f"{self.channel_id}: scrape channel data from youtube") print(f"{self.channel_id}: scrape channel data from youtube")
url = f"https://www.youtube.com/channel/{self.channel_id}/about?hl=en" url = f"https://www.youtube.com/channel/{self.channel_id}/about?hl=en"
cookies = {"CONSENT": "YES+xxxxxxxxxxxxxxxxxxxxxxxxxxx"} cookies = {"CONSENT": "YES+xxxxxxxxxxxxxxxxxxxxxxxxxxx"}
response = requests.get(url, cookies=cookies) response = requests.get(
url, cookies=cookies, headers=requests_headers()
)
if response.ok: if response.ok:
channel_page = response.text channel_page = response.text
else: else:
@@ -153,6 +157,7 @@ class YoutubeChannel(YouTubeItem):
def __init__(self, youtube_id): def __init__(self, youtube_id):
super().__init__(youtube_id) super().__init__(youtube_id)
self.es_path = f"{self.index_name}/_doc/{youtube_id}" self.es_path = f"{self.index_name}/_doc/{youtube_id}"
self.all_playlists = False
def build_json(self, upload=False): def build_json(self, upload=False):
"""get from es or from youtube""" """get from es or from youtube"""
@@ -241,6 +246,68 @@ class YoutubeChannel(YouTubeItem):
self.delete_es_videos() self.delete_es_videos()
self.del_in_es() self.del_in_es()
def index_channel_playlists(self):
"""add all playlists of channel to index"""
print(f"{self.youtube_id}: index all playlists")
self.get_from_es()
channel_name = self.json_data["channel_name"]
mess_dict = {
"status": "message:playlistscan",
"level": "info",
"title": "Looking for playlists",
"message": f"{channel_name}: Scanning channel in progress",
}
RedisArchivist().set_message("message:playlistscan", mess_dict)
self.get_all_playlists()
if not self.all_playlists:
print(f"{self.youtube_id}: no playlists found.")
return
all_youtube_ids = self.get_all_video_ids()
for idx, playlist in enumerate(self.all_playlists):
self._notify_single_playlist(idx, playlist)
self._index_single_playlist(playlist, all_youtube_ids)
def _notify_single_playlist(self, idx, playlist):
"""send notification"""
channel_name = self.json_data["channel_name"]
mess_dict = {
"status": "message:playlistscan",
"level": "info",
"title": f"{channel_name}: Scanning channel for playlists",
"message": f"Progress: {idx + 1}/{len(self.all_playlists)}",
}
RedisArchivist().set_message("message:playlistscan", mess_dict)
print("add playlist: " + playlist[1])
@staticmethod
def _index_single_playlist(playlist, all_youtube_ids):
"""add single playlist if needed"""
playlist = YoutubePlaylist(playlist[0])
playlist.all_youtube_ids = all_youtube_ids
playlist.build_json()
if not playlist.json_data:
return
entries = playlist.json_data["playlist_entries"]
downloaded = [i for i in entries if i["downloaded"]]
if not downloaded:
return
playlist.upload_to_es()
playlist.add_vids_to_playlist()
playlist.get_playlist_art()
@staticmethod
def get_all_video_ids():
"""match all playlists with videos"""
handler = queue.PendingList()
handler.get_download()
handler.get_indexed()
all_youtube_ids = [i["youtube_id"] for i in handler.all_videos]
return all_youtube_ids
def get_all_playlists(self): def get_all_playlists(self):
"""get all playlists owned by this channel""" """get all playlists owned by this channel"""
url = ( url = (
@@ -254,8 +321,7 @@ class YoutubeChannel(YouTubeItem):
} }
playlists = yt_dlp.YoutubeDL(obs).extract_info(url) playlists = yt_dlp.YoutubeDL(obs).extract_info(url)
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]] all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
self.all_playlists = all_entries
return all_entries
def get_indexed_playlists(self): def get_indexed_playlists(self):
"""get all indexed playlists from channel""" """get all indexed playlists from channel"""
@@ -267,3 +333,43 @@ class YoutubeChannel(YouTubeItem):
} }
all_playlists = IndexPaginate("ta_playlist", data).get_results() all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists return all_playlists
def get_overwrites(self):
"""get all per channel overwrites"""
return self.json_data.get("channel_overwrites", False)
def set_overwrites(self, overwrites):
"""set per channel overwrites"""
valid_keys = [
"download_format",
"autodelete_days",
"index_playlists",
"integrate_sponsorblock",
]
to_write = self.json_data.get("channel_overwrites", {})
for key, value in overwrites.items():
if key not in valid_keys:
raise ValueError(f"invalid overwrite key: {key}")
if value == "disable":
to_write[key] = False
continue
if value in [0, "0"]:
del to_write[key]
continue
if value == "1":
to_write[key] = True
continue
if value:
to_write.update({key: value})
self.json_data["channel_overwrites"] = to_write
def channel_overwrites(channel_id, overwrites):
"""collection to overwrite settings per channel"""
channel = YoutubeChannel(channel_id)
channel.build_json()
channel.set_overwrites(overwrites)
channel.upload_to_es()
channel.sync_to_videos()

View File

@@ -10,11 +10,11 @@ import os
import re import re
import shutil import shutil
import subprocess import subprocess
from datetime import datetime
import requests
from home.src.download.queue import PendingList from home.src.download.queue import PendingList
from home.src.download.yt_cookie import CookieHandler
from home.src.download.yt_dlp_handler import VideoDownloader from home.src.download.yt_dlp_handler import VideoDownloader
from home.src.es.connect import ElasticWrap
from home.src.index.reindex import Reindex from home.src.index.reindex import Reindex
from home.src.index.video import index_new_video from home.src.index.video import index_new_video
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
@@ -26,8 +26,6 @@ class FilesystemScanner:
"""handle scanning and fixing from filesystem""" """handle scanning and fixing from filesystem"""
CONFIG = AppConfig().config CONFIG = AppConfig().config
ES_URL = CONFIG["application"]["es_url"]
ES_AUTH = CONFIG["application"]["es_auth"]
VIDEOS = CONFIG["application"]["videos"] VIDEOS = CONFIG["application"]["videos"]
def __init__(self): def __init__(self):
@@ -59,9 +57,11 @@ class FilesystemScanner:
def get_all_indexed(): def get_all_indexed():
"""get a list of all indexed videos""" """get a list of all indexed videos"""
index_handler = PendingList() index_handler = PendingList()
all_indexed_raw = index_handler.get_all_indexed() index_handler.get_download()
index_handler.get_indexed()
all_indexed = [] all_indexed = []
for video in all_indexed_raw: for video in index_handler.all_videos:
youtube_id = video["youtube_id"] youtube_id = video["youtube_id"]
media_url = video["media_url"] media_url = video["media_url"]
published = video["published"] published = video["published"]
@@ -146,25 +146,16 @@ class FilesystemScanner:
bulk_list.append(json.dumps(source)) bulk_list.append(json.dumps(source))
# add last newline # add last newline
bulk_list.append("\n") bulk_list.append("\n")
query_str = "\n".join(bulk_list) data = "\n".join(bulk_list)
# make the call _, _ = ElasticWrap("_bulk").post(data=data, ndjson=True)
headers = {"Content-type": "application/x-ndjson"}
url = self.ES_URL + "/_bulk"
request = requests.post(
url, data=query_str, headers=headers, auth=self.ES_AUTH
)
if not request.ok:
print(request.text)
def delete_from_index(self): def delete_from_index(self):
"""find indexed but deleted mediafile""" """find indexed but deleted mediafile"""
for indexed in self.to_delete: for indexed in self.to_delete:
youtube_id = indexed[0] youtube_id = indexed[0]
print(f"deleting {youtube_id} from index") print(f"deleting {youtube_id} from index")
url = self.ES_URL + "/ta_video/_doc/" + youtube_id path = f"ta_video/_doc/{youtube_id}"
request = requests.delete(url, auth=self.ES_AUTH) _, _ = ElasticWrap(path).delete()
if not request.ok:
print(request.text)
class ManualImport: class ManualImport:
@@ -317,10 +308,13 @@ def scan_filesystem():
def reindex_old_documents(): def reindex_old_documents():
"""daily refresh of old documents""" """daily refresh of old documents"""
# continue if needed handler = Reindex()
reindex_handler = Reindex() if handler.config["downloads"]["cookie_import"]:
reindex_handler.check_outdated() CookieHandler().use()
reindex_handler.reindex() try:
# set timestamp handler.check_outdated()
now = int(datetime.now().strftime("%s")) handler.reindex()
RedisArchivist().set_message("last_reindex", now, expire=False) RedisArchivist().set_message("last_reindex", handler.now, expire=False)
finally:
if handler.config["downloads"]["cookie_import"]:
CookieHandler().hide()

View File

@@ -6,6 +6,7 @@ functionality:
import math import math
import yt_dlp import yt_dlp
from home.src.download.yt_cookie import CookieHandler
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
@@ -22,6 +23,7 @@ class YouTubeItem:
"default_search": "ytsearch", "default_search": "ytsearch",
"skip_download": True, "skip_download": True,
"check_formats": "selected", "check_formats": "selected",
"socket_timeout": 3,
"noplaylist": True, "noplaylist": True,
} }
@@ -37,6 +39,9 @@ class YouTubeItem:
"""read user conf""" """read user conf"""
self.config = AppConfig().config self.config = AppConfig().config
self.app_conf = self.config["application"] self.app_conf = self.config["application"]
if self.config["downloads"]["cookie_import"]:
cookie_path = CookieHandler().use()
self.yt_obs.update({"cookiefile": cookie_path})
def get_from_youtube(self): def get_from_youtube(self):
"""use yt-dlp to get meta data from youtube""" """use yt-dlp to get meta data from youtube"""
@@ -81,7 +86,7 @@ class YouTubeItem:
def del_in_es(self): def del_in_es(self):
"""delete item from elastic search""" """delete item from elastic search"""
print(f"{self.youtube_id}: delete from es") print(f"{self.youtube_id}: delete from es")
_, _ = ElasticWrap(self.es_path).delete() _, _ = ElasticWrap(self.es_path).delete(refresh=True)
class Pagination: class Pagination:
@@ -133,7 +138,7 @@ class Pagination:
"""validate pagination with total_hits after making api call""" """validate pagination with total_hits after making api call"""
page_get = self.page_get page_get = self.page_get
max_pages = math.ceil(total_hits / self.page_size) max_pages = math.ceil(total_hits / self.page_size)
if total_hits > 10000: if total_hits >= 10000:
# es returns maximal 10000 results # es returns maximal 10000 results
self.pagination["max_hits"] = True self.pagination["max_hits"] = True
max_pages = max_pages - 1 max_pages = max_pages - 1
@@ -147,3 +152,4 @@ class Pagination:
] ]
self.pagination["next_pages"] = next_pages self.pagination["next_pages"] = next_pages
self.pagination["total_hits"] = total_hits

View File

@@ -4,86 +4,66 @@ functionality:
- index and update in es - index and update in es
""" """
import json import os
import shutil
from datetime import datetime from datetime import datetime
from math import ceil from math import ceil
from time import sleep from time import sleep
import requests
from home.src.download.queue import PendingList from home.src.download.queue import PendingList
from home.src.download.subscriptions import ChannelSubscription
from home.src.download.thumbnails import ThumbManager from home.src.download.thumbnails import ThumbManager
from home.src.download.yt_dlp_handler import VideoDownloader
from home.src.es.connect import ElasticWrap
from home.src.index.channel import YoutubeChannel from home.src.index.channel import YoutubeChannel
from home.src.index.playlist import YoutubePlaylist from home.src.index.playlist import YoutubePlaylist
from home.src.index.video import YoutubeVideo from home.src.index.video import YoutubeVideo
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
from home.src.ta.helper import get_total_hits
class Reindex: class Reindex:
"""check for outdated documents and refresh data from youtube""" """check for outdated documents and refresh data from youtube"""
MATCH_FIELD = {
"ta_video": "active",
"ta_channel": "channel_active",
"ta_playlist": "playlist_active",
}
MULTIPLY = 1.2
def __init__(self): def __init__(self):
# config # config
config = AppConfig().config self.now = int(datetime.now().strftime("%s"))
self.sleep_interval = config["downloads"]["sleep_interval"] self.config = AppConfig().config
self.es_url = config["application"]["es_url"] self.interval = self.config["scheduler"]["check_reindex_days"]
self.es_auth = config["application"]["es_auth"]
self.refresh_interval = config["scheduler"]["check_reindex_days"]
self.integrate_ryd = config["downloads"]["integrate_ryd"]
# scan # scan
self.all_youtube_ids = False self.all_youtube_ids = False
self.all_channel_ids = False self.all_channel_ids = False
self.all_playlist_ids = False self.all_playlist_ids = False
def get_daily(self): def _get_daily(self):
"""get daily refresh values""" """get daily refresh values"""
total_videos = get_total_hits( total_videos = self._get_total_hits("ta_video")
"ta_video", self.es_url, self.es_auth, "active" video_daily = ceil(total_videos / self.interval * self.MULTIPLY)
) if video_daily >= 10000:
video_daily = ceil(total_videos / self.refresh_interval * 1.2) video_daily = 9999
total_channels = get_total_hits(
"ta_channel", self.es_url, self.es_auth, "channel_active" total_channels = self._get_total_hits("ta_channel")
) channel_daily = ceil(total_channels / self.interval * self.MULTIPLY)
channel_daily = ceil(total_channels / self.refresh_interval * 1.2) total_playlists = self._get_total_hits("ta_playlist")
total_playlists = get_total_hits( playlist_daily = ceil(total_playlists / self.interval * self.MULTIPLY)
"ta_playlist", self.es_url, self.es_auth, "playlist_active"
)
playlist_daily = ceil(total_playlists / self.refresh_interval * 1.2)
return (video_daily, channel_daily, playlist_daily) return (video_daily, channel_daily, playlist_daily)
def get_outdated_vids(self, size): def _get_total_hits(self, index):
"""get daily videos to refresh""" """get total hits from index"""
headers = {"Content-type": "application/json"} match_field = self.MATCH_FIELD[index]
now = int(datetime.now().strftime("%s")) path = f"{index}/_search?filter_path=hits.total"
now_lte = now - self.refresh_interval * 24 * 60 * 60 data = {"query": {"match": {match_field: True}}}
data = { response, _ = ElasticWrap(path).post(data=data)
"size": size, total_hits = response["hits"]["total"]["value"]
"query": { return total_hits
"bool": {
"must": [
{"match": {"active": True}},
{"range": {"vid_last_refresh": {"lte": now_lte}}},
]
}
},
"sort": [{"vid_last_refresh": {"order": "asc"}}],
"_source": False,
}
query_str = json.dumps(data)
url = self.es_url + "/ta_video/_search"
response = requests.get(
url, data=query_str, headers=headers, auth=self.es_auth
)
if not response.ok:
print(response.text)
response_dict = json.loads(response.text)
all_youtube_ids = [i["_id"] for i in response_dict["hits"]["hits"]]
return all_youtube_ids
def get_unrated_vids(self): def _get_unrated_vids(self):
"""get all videos without rating if ryd integration is enabled""" """get max 200 videos without rating if ryd integration is enabled"""
headers = {"Content-type": "application/json"}
data = { data = {
"size": 200, "size": 200,
"query": { "query": {
@@ -92,104 +72,78 @@ class Reindex:
} }
}, },
} }
query_str = json.dumps(data) response, _ = ElasticWrap("ta_video/_search").get(data=data)
url = self.es_url + "/ta_video/_search"
response = requests.get( missing_rating = [i["_id"] for i in response["hits"]["hits"]]
url, data=query_str, headers=headers, auth=self.es_auth
)
if not response.ok:
print(response.text)
response_dict = json.loads(response.text)
missing_rating = [i["_id"] for i in response_dict["hits"]["hits"]]
self.all_youtube_ids = self.all_youtube_ids + missing_rating self.all_youtube_ids = self.all_youtube_ids + missing_rating
def get_outdated_channels(self, size): def _get_outdated_vids(self, size):
"""get daily channels to refresh""" """get daily videos to refresh"""
headers = {"Content-type": "application/json"} now_lte = self.now - self.interval * 24 * 60 * 60
now = int(datetime.now().strftime("%s")) must_list = [
now_lte = now - self.refresh_interval * 24 * 60 * 60 {"match": {"active": True}},
{"range": {"vid_last_refresh": {"lte": now_lte}}},
]
data = { data = {
"size": size, "size": size,
"query": { "query": {"bool": {"must": must_list}},
"bool": { "sort": [{"vid_last_refresh": {"order": "asc"}}],
"must": [ "_source": False,
{"match": {"channel_active": True}}, }
{"range": {"channel_last_refresh": {"lte": now_lte}}}, response, _ = ElasticWrap("ta_video/_search").get(data=data)
]
} all_youtube_ids = [i["_id"] for i in response["hits"]["hits"]]
}, return all_youtube_ids
def _get_outdated_channels(self, size):
"""get daily channels to refresh"""
now_lte = self.now - self.interval * 24 * 60 * 60
must_list = [
{"match": {"channel_active": True}},
{"range": {"channel_last_refresh": {"lte": now_lte}}},
]
data = {
"size": size,
"query": {"bool": {"must": must_list}},
"sort": [{"channel_last_refresh": {"order": "asc"}}], "sort": [{"channel_last_refresh": {"order": "asc"}}],
"_source": False, "_source": False,
} }
query_str = json.dumps(data) response, _ = ElasticWrap("ta_channel/_search").get(data=data)
url = self.es_url + "/ta_channel/_search"
response = requests.get( all_channel_ids = [i["_id"] for i in response["hits"]["hits"]]
url, data=query_str, headers=headers, auth=self.es_auth
)
if not response.ok:
print(response.text)
response_dict = json.loads(response.text)
all_channel_ids = [i["_id"] for i in response_dict["hits"]["hits"]]
return all_channel_ids return all_channel_ids
def get_outdated_playlists(self, size): def _get_outdated_playlists(self, size):
"""get daily outdated playlists to refresh""" """get daily outdated playlists to refresh"""
headers = {"Content-type": "application/json"} now_lte = self.now - self.interval * 24 * 60 * 60
now = int(datetime.now().strftime("%s")) must_list = [
now_lte = now - self.refresh_interval * 24 * 60 * 60 {"match": {"playlist_active": True}},
{"range": {"playlist_last_refresh": {"lte": now_lte}}},
]
data = { data = {
"size": size, "size": size,
"query": { "query": {"bool": {"must": must_list}},
"bool": {
"must": [
{"match": {"playlist_active": True}},
{"range": {"playlist_last_refresh": {"lte": now_lte}}},
]
}
},
"sort": [{"playlist_last_refresh": {"order": "asc"}}], "sort": [{"playlist_last_refresh": {"order": "asc"}}],
"_source": False, "_source": False,
} }
query_str = json.dumps(data) response, _ = ElasticWrap("ta_playlist/_search").get(data=data)
url = self.es_url + "/ta_playlist/_search"
response = requests.get( all_playlist_ids = [i["_id"] for i in response["hits"]["hits"]]
url, data=query_str, headers=headers, auth=self.es_auth
)
if not response.ok:
print(response.text)
response_dict = json.loads(response.text)
all_playlist_ids = [i["_id"] for i in response_dict["hits"]["hits"]]
return all_playlist_ids return all_playlist_ids
def check_outdated(self): def check_outdated(self):
"""add missing vids and channels""" """add missing vids and channels"""
video_daily, channel_daily, playlist_daily = self.get_daily() video_daily, channel_daily, playlist_daily = self._get_daily()
self.all_youtube_ids = self.get_outdated_vids(video_daily) self.all_youtube_ids = self._get_outdated_vids(video_daily)
self.all_channel_ids = self.get_outdated_channels(channel_daily) self.all_channel_ids = self._get_outdated_channels(channel_daily)
self.all_playlist_ids = self.get_outdated_playlists(playlist_daily) self.all_playlist_ids = self._get_outdated_playlists(playlist_daily)
if self.integrate_ryd:
self.get_unrated_vids()
def rescrape_all_channels(self): integrate_ryd = self.config["downloads"]["integrate_ryd"]
"""sync new data from channel to all matching videos""" if integrate_ryd:
sleep_interval = self.sleep_interval self._get_unrated_vids()
channel_sub_handler = ChannelSubscription()
all_channels = channel_sub_handler.get_channels(subscribed_only=False)
all_channel_ids = [i["channel_id"] for i in all_channels]
for channel_id in all_channel_ids:
channel = YoutubeChannel(channel_id)
subscribed = channel.json_data["channel_subscribed"]
channel.get_from_youtube()
channel.json_data["channel_subscribed"] = subscribed
channel.upload_to_es()
channel.sync_to_videos()
if sleep_interval:
sleep(sleep_interval)
@staticmethod @staticmethod
def reindex_single_video(youtube_id): def _reindex_single_video(youtube_id):
"""refresh data for single video""" """refresh data for single video"""
video = YoutubeVideo(youtube_id) video = YoutubeVideo(youtube_id)
@@ -207,6 +161,8 @@ class Reindex:
return return
video.delete_subtitles() video.delete_subtitles()
video.check_subtitles()
# add back # add back
video.json_data["player"] = player video.json_data["player"] = player
video.json_data["date_downloaded"] = date_downloaded video.json_data["date_downloaded"] = date_downloaded
@@ -223,18 +179,21 @@ class Reindex:
return return
@staticmethod @staticmethod
def reindex_single_channel(channel_id): def _reindex_single_channel(channel_id):
"""refresh channel data and sync to videos""" """refresh channel data and sync to videos"""
channel = YoutubeChannel(channel_id) channel = YoutubeChannel(channel_id)
channel.get_from_es() channel.get_from_es()
subscribed = channel.json_data["channel_subscribed"] subscribed = channel.json_data["channel_subscribed"]
overwrites = channel.json_data.get("channel_overwrites", False)
channel.get_from_youtube() channel.get_from_youtube()
channel.json_data["channel_subscribed"] = subscribed channel.json_data["channel_subscribed"] = subscribed
if overwrites:
channel.json_data["channel_overwrites"] = overwrites
channel.upload_to_es() channel.upload_to_es()
channel.sync_to_videos() channel.sync_to_videos()
@staticmethod @staticmethod
def reindex_single_playlist(playlist_id, all_indexed_ids): def _reindex_single_playlist(playlist_id, all_indexed_ids):
"""refresh playlist data""" """refresh playlist data"""
playlist = YoutubePlaylist(playlist_id) playlist = YoutubePlaylist(playlist_id)
playlist.get_from_es() playlist.get_from_es()
@@ -251,24 +210,91 @@ class Reindex:
def reindex(self): def reindex(self):
"""reindex what's needed""" """reindex what's needed"""
sleep_interval = self.config["downloads"]["sleep_interval"]
# videos # videos
print(f"reindexing {len(self.all_youtube_ids)} videos") print(f"reindexing {len(self.all_youtube_ids)} videos")
for youtube_id in self.all_youtube_ids: for youtube_id in self.all_youtube_ids:
self.reindex_single_video(youtube_id) try:
if self.sleep_interval: self._reindex_single_video(youtube_id)
sleep(self.sleep_interval) except FileNotFoundError:
# handle channel name change here
ChannelUrlFixer(youtube_id, self.config).run()
self._reindex_single_video(youtube_id)
if sleep_interval:
sleep(sleep_interval)
# channels # channels
print(f"reindexing {len(self.all_channel_ids)} channels") print(f"reindexing {len(self.all_channel_ids)} channels")
for channel_id in self.all_channel_ids: for channel_id in self.all_channel_ids:
self.reindex_single_channel(channel_id) self._reindex_single_channel(channel_id)
if self.sleep_interval: if sleep_interval:
sleep(self.sleep_interval) sleep(sleep_interval)
# playlist # playlist
print(f"reindexing {len(self.all_playlist_ids)} playlists") print(f"reindexing {len(self.all_playlist_ids)} playlists")
if self.all_playlist_ids: if self.all_playlist_ids:
all_indexed = PendingList().get_all_indexed() handler = PendingList()
all_indexed_ids = [i["youtube_id"] for i in all_indexed] handler.get_download()
handler.get_indexed()
all_indexed_ids = [i["youtube_id"] for i in handler.all_videos]
for playlist_id in self.all_playlist_ids: for playlist_id in self.all_playlist_ids:
self.reindex_single_playlist(playlist_id, all_indexed_ids) self._reindex_single_playlist(playlist_id, all_indexed_ids)
if self.sleep_interval: if sleep_interval:
sleep(self.sleep_interval) sleep(sleep_interval)
class ChannelUrlFixer:
"""fix not matching channel names in reindex"""
def __init__(self, youtube_id, config):
self.youtube_id = youtube_id
self.config = config
self.video = False
def run(self):
"""check and run if needed"""
print(f"{self.youtube_id}: failed to build channel path, try to fix.")
video_path_is, video_folder_is = self.get_as_is()
if not os.path.exists(video_path_is):
print(f"giving up reindex, video in video: {self.video.json_data}")
raise ValueError
_, video_folder_should = self.get_as_should()
if video_folder_is != video_folder_should:
self.process(video_path_is)
else:
print(f"{self.youtube_id}: skip channel url fixer")
def get_as_is(self):
"""get video object as is"""
self.video = YoutubeVideo(self.youtube_id)
self.video.get_from_es()
video_path_is = os.path.join(
self.config["application"]["videos"],
self.video.json_data["media_url"],
)
video_folder_is = os.path.split(video_path_is)[0]
return video_path_is, video_folder_is
def get_as_should(self):
"""add fresh metadata from remote"""
self.video.get_from_youtube()
self.video.add_file_path()
video_path_should = os.path.join(
self.config["application"]["videos"],
self.video.json_data["media_url"],
)
video_folder_should = os.path.split(video_path_should)[0]
return video_path_should, video_folder_should
def process(self, video_path_is):
"""fix filepath"""
print(f"{self.youtube_id}: fixing channel rename.")
cache_dir = self.config["application"]["cache_dir"]
new_file_path = os.path.join(
cache_dir, "download", self.youtube_id + ".mp4"
)
shutil.move(video_path_is, new_file_path)
VideoDownloader().move_to_archive(self.video.json_data)
self.video.update_media_url()

View File

@@ -6,14 +6,20 @@ functionality:
import json import json
import os import os
import re
from datetime import datetime from datetime import datetime
import requests import requests
from django.conf import settings
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.index import channel as ta_channel from home.src.index import channel as ta_channel
from home.src.index.generic import YouTubeItem from home.src.index.generic import YouTubeItem
from home.src.ta.helper import DurationConverter, clean_string from home.src.ta.helper import (
DurationConverter,
clean_string,
randomizor,
requests_headers,
)
from home.src.ta.ta_redis import RedisArchivist
from ryd_client import ryd_client from ryd_client import ryd_client
@@ -24,7 +30,7 @@ class YoutubeSubtitle:
self.video = video self.video = video
self.languages = False self.languages = False
def sub_conf_parse(self): def _sub_conf_parse(self):
"""add additional conf values to self""" """add additional conf values to self"""
languages_raw = self.video.config["downloads"]["subtitle"] languages_raw = self.video.config["downloads"]["subtitle"]
if languages_raw: if languages_raw:
@@ -32,26 +38,26 @@ class YoutubeSubtitle:
def get_subtitles(self): def get_subtitles(self):
"""check what to do""" """check what to do"""
self.sub_conf_parse() self._sub_conf_parse()
if not self.languages: if not self.languages:
# no subtitles # no subtitles
return False return False
relevant_subtitles = [] relevant_subtitles = []
for lang in self.languages: for lang in self.languages:
user_sub = self.get_user_subtitles(lang) user_sub = self._get_user_subtitles(lang)
if user_sub: if user_sub:
relevant_subtitles.append(user_sub) relevant_subtitles.append(user_sub)
continue continue
if self.video.config["downloads"]["subtitle_source"] == "auto": if self.video.config["downloads"]["subtitle_source"] == "auto":
auto_cap = self.get_auto_caption(lang) auto_cap = self._get_auto_caption(lang)
if auto_cap: if auto_cap:
relevant_subtitles.append(auto_cap) relevant_subtitles.append(auto_cap)
return relevant_subtitles return relevant_subtitles
def get_auto_caption(self, lang): def _get_auto_caption(self, lang):
"""get auto_caption subtitles""" """get auto_caption subtitles"""
print(f"{self.video.youtube_id}-{lang}: get auto generated subtitles") print(f"{self.video.youtube_id}-{lang}: get auto generated subtitles")
all_subtitles = self.video.youtube_meta.get("automatic_captions") all_subtitles = self.video.youtube_meta.get("automatic_captions")
@@ -65,7 +71,7 @@ class YoutubeSubtitle:
if not all_formats: if not all_formats:
return False return False
subtitle = [i for i in all_formats if i["ext"] == "vtt"][0] subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
subtitle.update( subtitle.update(
{"lang": lang, "source": "auto", "media_url": media_url} {"lang": lang, "source": "auto", "media_url": media_url}
) )
@@ -88,7 +94,7 @@ class YoutubeSubtitle:
return all_subtitles return all_subtitles
def get_user_subtitles(self, lang): def _get_user_subtitles(self, lang):
"""get subtitles uploaded from channel owner""" """get subtitles uploaded from channel owner"""
print(f"{self.video.youtube_id}-{lang}: get user uploaded subtitles") print(f"{self.video.youtube_id}-{lang}: get user uploaded subtitles")
all_subtitles = self._normalize_lang() all_subtitles = self._normalize_lang()
@@ -102,7 +108,7 @@ class YoutubeSubtitle:
# no user subtitles found # no user subtitles found
return False return False
subtitle = [i for i in all_formats if i["ext"] == "vtt"][0] subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
subtitle.update( subtitle.update(
{"lang": lang, "source": "user", "media_url": media_url} {"lang": lang, "source": "user", "media_url": media_url}
) )
@@ -115,12 +121,16 @@ class YoutubeSubtitle:
for subtitle in relevant_subtitles: for subtitle in relevant_subtitles:
dest_path = os.path.join(videos_base, subtitle["media_url"]) dest_path = os.path.join(videos_base, subtitle["media_url"])
source = subtitle["source"] source = subtitle["source"]
response = requests.get(subtitle["url"]) lang = subtitle.get("lang")
response = requests.get(
subtitle["url"], headers=requests_headers()
)
if not response.ok: if not response.ok:
print(f"{self.video.youtube_id}: failed to download subtitle") print(f"{self.video.youtube_id}: failed to download subtitle")
print(response.text)
continue continue
parser = SubtitleParser(response.text, subtitle.get("lang")) parser = SubtitleParser(response.text, lang, source)
parser.process() parser.process()
subtitle_str = parser.get_subtitle_str() subtitle_str = parser.get_subtitle_str()
self._write_subtitle_file(dest_path, subtitle_str) self._write_subtitle_file(dest_path, subtitle_str)
@@ -141,113 +151,128 @@ class YoutubeSubtitle:
"""send subtitle to es for indexing""" """send subtitle to es for indexing"""
_, _ = ElasticWrap("_bulk").post(data=query_str, ndjson=True) _, _ = ElasticWrap("_bulk").post(data=query_str, ndjson=True)
def delete(self):
"""delete subtitles from index and filesystem"""
youtube_id = self.video.youtube_id
# delete files
videos_base = self.video.config["application"]["videos"]
if not self.video.json_data.get("subtitles"):
return
files = [i["media_url"] for i in self.video.json_data["subtitles"]]
for file_name in files:
file_path = os.path.join(videos_base, file_name)
try:
os.remove(file_path)
except FileNotFoundError:
print(f"{youtube_id}: {file_path} failed to delete")
# delete from index
path = "ta_subtitle/_delete_by_query?refresh=true"
data = {"query": {"term": {"youtube_id": {"value": youtube_id}}}}
_, _ = ElasticWrap(path).post(data=data)
class SubtitleParser: class SubtitleParser:
"""parse subtitle str from youtube""" """parse subtitle str from youtube"""
time_reg = r"^([0-9]{2}:?){3}\.[0-9]{3} --> ([0-9]{2}:?){3}\.[0-9]{3}" def __init__(self, subtitle_str, lang, source):
stamp_reg = r"<([0-9]{2}:?){3}\.[0-9]{3}>" self.subtitle_raw = json.loads(subtitle_str)
tag_reg = r"</?c>"
def __init__(self, subtitle_str, lang):
self.subtitle_str = subtitle_str
self.lang = lang self.lang = lang
self.header = False self.source = source
self.parsed_cue_list = False self.all_cues = False
self.all_text_lines = False
self.matched = False
def process(self): def process(self):
"""collection to process subtitle string""" """extract relevant que data"""
self._parse_cues() all_events = self.subtitle_raw.get("events")
self._match_text_lines() if self.source == "auto":
self._add_id() all_events = self._flat_auto_caption(all_events)
self._timestamp_check()
def _parse_cues(self): self.all_cues = []
"""split into cues""" for idx, event in enumerate(all_events):
all_cues = self.subtitle_str.replace("\n \n", "\n").split("\n\n") if "dDurationMs" not in event:
self.header = all_cues[0] # some events won't have a duration
self.all_text_lines = [] print(f"failed to parse event without duration: {event}")
self.parsed_cue_list = [self._cue_cleaner(i) for i in all_cues[1:]]
def _cue_cleaner(self, cue):
"""parse single cue"""
all_lines = cue.split("\n")
cue_dict = {"lines": []}
for line in all_lines:
if re.match(self.time_reg, line):
clean = re.search(self.time_reg, line).group()
start, end = clean.split(" --> ")
cue_dict.update({"start": start, "end": end})
else:
clean = re.sub(self.stamp_reg, "", line)
clean = re.sub(self.tag_reg, "", clean)
cue_dict["lines"].append(clean)
if clean.strip() and clean not in self.all_text_lines[-4:]:
# remove immediate duplicates
self.all_text_lines.append(clean)
return cue_dict
def _match_text_lines(self):
"""match unique text lines with timestamps"""
self.matched = []
while self.all_text_lines:
check = self.all_text_lines[0]
matches = [i for i in self.parsed_cue_list if check in i["lines"]]
new_cue = matches[-1]
new_cue["start"] = matches[0]["start"]
for line in new_cue["lines"]:
try:
self.all_text_lines.remove(line)
except ValueError:
continue
self.matched.append(new_cue)
def _timestamp_check(self):
"""check if end timestamp is bigger than start timestamp"""
for idx, cue in enumerate(self.matched):
# this
end = int(re.sub("[^0-9]", "", cue.get("end")))
# next
try:
next_cue = self.matched[idx + 1]
except IndexError:
continue continue
start_next = int(re.sub("[^0-9]", "", next_cue.get("start"))) cue = {
if end > start_next: "start": self._ms_conv(event["tStartMs"]),
self.matched[idx]["end"] = next_cue.get("start") "end": self._ms_conv(event["tStartMs"] + event["dDurationMs"]),
"text": "".join([i.get("utf8") for i in event["segs"]]),
"idx": idx + 1,
}
self.all_cues.append(cue)
def _add_id(self): @staticmethod
"""add id to matched cues""" def _flat_auto_caption(all_events):
for idx, _ in enumerate(self.matched): """flatten autocaption segments"""
self.matched[idx]["id"] = idx + 1 flatten = []
for event in all_events:
if "segs" not in event.keys():
continue
text = "".join([i.get("utf8") for i in event.get("segs")])
if not text.strip():
continue
if flatten:
# fix overlapping retiming issue
if "dDurationMs" not in flatten[-1]:
# some events won't have a duration
print(f"failed to parse event without duration: {event}")
continue
last_end = flatten[-1]["tStartMs"] + flatten[-1]["dDurationMs"]
if event["tStartMs"] < last_end:
joined = flatten[-1]["segs"][0]["utf8"] + "\n" + text
flatten[-1]["segs"][0]["utf8"] = joined
continue
event.update({"segs": [{"utf8": text}]})
flatten.append(event)
return flatten
@staticmethod
def _ms_conv(ms):
"""convert ms to timestamp"""
hours = str((ms // (1000 * 60 * 60)) % 24).zfill(2)
minutes = str((ms // (1000 * 60)) % 60).zfill(2)
secs = str((ms // 1000) % 60).zfill(2)
millis = str(ms % 1000).zfill(3)
return f"{hours}:{minutes}:{secs}.{millis}"
def get_subtitle_str(self): def get_subtitle_str(self):
"""stitch cues and return processed new string""" """create vtt text str from cues"""
new_subtitle_str = self.header + "\n\n" subtitle_str = f"WEBVTT\nKind: captions\nLanguage: {self.lang}"
for cue in self.matched: for cue in self.all_cues:
timestamp = f"{cue.get('start')} --> {cue.get('end')}" stamp = f"{cue.get('start')} --> {cue.get('end')}"
lines = "\n".join(cue.get("lines")) cue_text = f"\n\n{cue.get('idx')}\n{stamp}\n{cue.get('text')}"
cue_text = f"{cue.get('id')}\n{timestamp}\n{lines}\n\n" subtitle_str = subtitle_str + cue_text
new_subtitle_str = new_subtitle_str + cue_text
return new_subtitle_str return subtitle_str
def create_bulk_import(self, video, source): def create_bulk_import(self, video, source):
"""process matched for es import""" """subtitle lines for es import"""
documents = self._create_documents(video, source)
bulk_list = [] bulk_list = []
channel = video.json_data.get("channel")
document = { for document in documents:
document_id = document.get("subtitle_fragment_id")
action = {"index": {"_index": "ta_subtitle", "_id": document_id}}
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(document))
bulk_list.append("\n")
query_str = "\n".join(bulk_list)
return query_str
def _create_documents(self, video, source):
"""process documents"""
documents = self._chunk_list(video.youtube_id)
channel = video.json_data.get("channel")
meta_dict = {
"youtube_id": video.youtube_id, "youtube_id": video.youtube_id,
"title": video.json_data.get("title"), "title": video.json_data.get("title"),
"subtitle_channel": channel.get("channel_name"), "subtitle_channel": channel.get("channel_name"),
@@ -257,26 +282,129 @@ class SubtitleParser:
"subtitle_source": source, "subtitle_source": source,
} }
for match in self.matched: _ = [i.update(meta_dict) for i in documents]
match_id = match.get("id")
document_id = f"{video.youtube_id}-{self.lang}-{match_id}" return documents
action = {"index": {"_index": "ta_subtitle", "_id": document_id}}
document.update( def _chunk_list(self, youtube_id):
{ """join cues for bulk import"""
"subtitle_fragment_id": document_id, chunk_list = []
"subtitle_start": match.get("start"),
"subtitle_end": match.get("end"), chunk = {}
"subtitle_index": match_id, for cue in self.all_cues:
"subtitle_line": " ".join(match.get("lines")), if chunk:
text = f"{chunk.get('subtitle_line')} {cue.get('text')}\n"
chunk["subtitle_line"] = text
else:
idx = len(chunk_list) + 1
chunk = {
"subtitle_index": idx,
"subtitle_line": cue.get("text"),
"subtitle_start": cue.get("start"),
} }
)
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(document))
bulk_list.append("\n") chunk["subtitle_fragment_id"] = f"{youtube_id}-{self.lang}-{idx}"
query_str = "\n".join(bulk_list)
return query_str if cue["idx"] % 5 == 0:
chunk["subtitle_end"] = cue.get("end")
chunk_list.append(chunk)
chunk = {}
return chunk_list
class SponsorBlock:
"""handle sponsor block integration"""
API = "https://sponsor.ajay.app/api"
def __init__(self, user_id=False):
self.user_id = user_id
self.user_agent = f"{settings.TA_UPSTREAM} {settings.TA_VERSION}"
self.last_refresh = int(datetime.now().strftime("%s"))
def get_sb_id(self):
"""get sponsorblock userid or generate if needed"""
if not self.user_id:
print("missing request user id")
raise ValueError
key = f"{self.user_id}:id_sponsorblock"
sb_id = RedisArchivist().get_message(key)
if not sb_id["status"]:
sb_id = {"status": randomizor(32)}
RedisArchivist().set_message(key, sb_id, expire=False)
return sb_id
def get_timestamps(self, youtube_id):
"""get timestamps from the API"""
url = f"{self.API}/skipSegments?videoID={youtube_id}"
headers = {"User-Agent": self.user_agent}
print(f"{youtube_id}: get sponsorblock timestamps")
response = requests.get(url, headers=headers)
if not response.ok:
print(f"{youtube_id}: sponsorblock failed: {response.text}")
sponsor_dict = {
"last_refresh": self.last_refresh,
"is_enabled": True,
"segments": [],
}
else:
all_segments = response.json()
sponsor_dict = self._get_sponsor_dict(all_segments)
return sponsor_dict
def _get_sponsor_dict(self, all_segments):
"""format and process response"""
has_unlocked = False
cleaned_segments = []
for segment in all_segments:
if not segment["locked"]:
has_unlocked = True
del segment["userID"]
del segment["description"]
cleaned_segments.append(segment)
sponsor_dict = {
"last_refresh": self.last_refresh,
"has_unlocked": has_unlocked,
"is_enabled": True,
"segments": cleaned_segments,
}
return sponsor_dict
def post_timestamps(self, youtube_id, start_time, end_time):
"""post timestamps to api"""
user_id = self.get_sb_id().get("status")
data = {
"videoID": youtube_id,
"startTime": start_time,
"endTime": end_time,
"category": "sponsor",
"userID": user_id,
"userAgent": self.user_agent,
}
url = f"{self.API}/skipSegments?videoID={youtube_id}"
print(f"post: {data}")
print(f"to: {url}")
return {"success": True}, 200
def vote_on_segment(self, uuid, vote):
"""send vote on existing segment"""
user_id = self.get_sb_id().get("status")
data = {
"UUID": uuid,
"userID": user_id,
"type": vote,
}
url = f"{self.API}/api/voteOnSponsorTime"
print(f"post: {data}")
print(f"to: {url}")
return {"success": True}, 200
class YoutubeVideo(YouTubeItem, YoutubeSubtitle): class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
@@ -286,9 +414,10 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
index_name = "ta_video" index_name = "ta_video"
yt_base = "https://www.youtube.com/watch?v=" yt_base = "https://www.youtube.com/watch?v="
def __init__(self, youtube_id): def __init__(self, youtube_id, video_overwrites=False):
super().__init__(youtube_id) super().__init__(youtube_id)
self.channel_id = False self.channel_id = False
self.video_overwrites = video_overwrites
self.es_path = f"{self.index_name}/_doc/{youtube_id}" self.es_path = f"{self.index_name}/_doc/{youtube_id}"
def build_json(self): def build_json(self):
@@ -302,12 +431,28 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
self._add_stats() self._add_stats()
self.add_file_path() self.add_file_path()
self.add_player() self.add_player()
self._check_subtitles()
if self.config["downloads"]["integrate_ryd"]: if self.config["downloads"]["integrate_ryd"]:
self._get_ryd_stats() self._get_ryd_stats()
if self._check_get_sb():
self._get_sponsorblock()
return return
def _check_get_sb(self):
"""check if need to run sponsor block"""
integrate = self.config["downloads"]["integrate_sponsorblock"]
if self.video_overwrites:
single_overwrite = self.video_overwrites.get(self.youtube_id)
if not single_overwrite:
return integrate
if "integrate_sponsorblock" in single_overwrite:
return single_overwrite.get("integrate_sponsorblock")
return integrate
def _process_youtube_meta(self): def _process_youtube_meta(self):
"""extract relevant fields from youtube""" """extract relevant fields from youtube"""
# extract # extract
@@ -316,12 +461,15 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
upload_date_time = datetime.strptime(upload_date, "%Y%m%d") upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d") published = upload_date_time.strftime("%Y-%m-%d")
last_refresh = int(datetime.now().strftime("%s")) last_refresh = int(datetime.now().strftime("%s"))
# base64_blur = ThumbManager().get_base64_blur(self.youtube_id)
base64_blur = False
# build json_data basics # build json_data basics
self.json_data = { self.json_data = {
"title": self.youtube_meta["title"], "title": self.youtube_meta["title"],
"description": self.youtube_meta["description"], "description": self.youtube_meta["description"],
"category": self.youtube_meta["categories"], "category": self.youtube_meta["categories"],
"vid_thumb_url": self.youtube_meta["thumbnail"], "vid_thumb_url": self.youtube_meta["thumbnail"],
"vid_thumb_base64": base64_blur,
"tags": self.youtube_meta["tags"], "tags": self.youtube_meta["tags"],
"published": published, "published": published,
"vid_last_refresh": last_refresh, "vid_last_refresh": last_refresh,
@@ -375,7 +523,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
channel_dir = os.path.join(self.app_conf["videos"], channel) channel_dir = os.path.join(self.app_conf["videos"], channel)
all_files = os.listdir(channel_dir) all_files = os.listdir(channel_dir)
for file in all_files: for file in all_files:
if self.youtube_id in file: if self.youtube_id in file and file.endswith(".mp4"):
vid_path = os.path.join(channel_dir, file) vid_path = os.path.join(channel_dir, file)
break break
else: else:
@@ -414,19 +562,21 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
"""delete video file, meta data""" """delete video file, meta data"""
self.get_from_es() self.get_from_es()
video_base = self.app_conf["videos"] video_base = self.app_conf["videos"]
to_del = [self.json_data.get("media_url")] media_url = self.json_data.get("media_url")
file_path = os.path.join(video_base, media_url)
all_subtitles = self.json_data.get("subtitles") try:
if all_subtitles:
to_del = to_del + [i.get("media_url") for i in all_subtitles]
for media_url in to_del:
file_path = os.path.join(video_base, media_url)
os.remove(file_path) os.remove(file_path)
except FileNotFoundError:
print(f"{self.youtube_id}: failed {media_url}, continue.")
self.del_in_es() self.del_in_es()
self.delete_subtitles() self.delete_subtitles()
def delete_subtitles(self):
"""delete indexed subtitles"""
print(f"{self.youtube_id}: delete subtitles")
YoutubeSubtitle(self).delete()
def _get_ryd_stats(self): def _get_ryd_stats(self):
"""get optional stats from returnyoutubedislikeapi.com""" """get optional stats from returnyoutubedislikeapi.com"""
try: try:
@@ -447,7 +597,13 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
return True return True
def _check_subtitles(self): def _get_sponsorblock(self):
"""get optional sponsorblock timestamps from sponsor.ajay.app"""
sponsorblock = SponsorBlock().get_timestamps(self.youtube_id)
if sponsorblock:
self.json_data["sponsorblock"] = sponsorblock
def check_subtitles(self):
"""optionally add subtitles""" """optionally add subtitles"""
handler = YoutubeSubtitle(self) handler = YoutubeSubtitle(self)
subtitles = handler.get_subtitles() subtitles = handler.get_subtitles()
@@ -455,18 +611,20 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
self.json_data["subtitles"] = subtitles self.json_data["subtitles"] = subtitles
handler.download_subtitles(relevant_subtitles=subtitles) handler.download_subtitles(relevant_subtitles=subtitles)
def delete_subtitles(self): def update_media_url(self):
"""delete indexed subtitles""" """update only media_url in es for reindex channel rename"""
data = {"query": {"term": {"youtube_id": {"value": self.youtube_id}}}} data = {"doc": {"media_url": self.json_data["media_url"]}}
_, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data=data) path = f"{self.index_name}/_update/{self.youtube_id}"
_, _ = ElasticWrap(path).post(data=data)
def index_new_video(youtube_id): def index_new_video(youtube_id, video_overwrites=False):
"""combined classes to create new video in index""" """combined classes to create new video in index"""
video = YoutubeVideo(youtube_id) video = YoutubeVideo(youtube_id, video_overwrites=video_overwrites)
video.build_json() video.build_json()
if not video.json_data: if not video.json_data:
raise ValueError("failed to get metadata for " + youtube_id) raise ValueError("failed to get metadata for " + youtube_id)
video.check_subtitles()
video.upload_to_es() video.upload_to_es()
return video.json_data return video.json_data

View File

@@ -83,33 +83,35 @@ class AppConfig:
def update_config(self, form_post): def update_config(self, form_post):
"""update config values from settings form""" """update config values from settings form"""
config = self.config updated = []
for key, value in form_post.items(): for key, value in form_post.items():
to_write = value[0] if not value and not isinstance(value, int):
if len(to_write): continue
if to_write == "0":
to_write = False
elif to_write == "1":
to_write = True
elif to_write.isdigit():
to_write = int(to_write)
config_dict, config_value = key.split("_", maxsplit=1) if value in ["0", 0]:
config[config_dict][config_value] = to_write to_write = False
elif value == "1":
to_write = True
else:
to_write = value
RedisArchivist().set_message("config", config, expire=False) config_dict, config_value = key.split("_", maxsplit=1)
self.config[config_dict][config_value] = to_write
updated.append((config_value, to_write))
RedisArchivist().set_message("config", self.config, expire=False)
return updated
@staticmethod @staticmethod
def set_user_config(form_post, user_id): def set_user_config(form_post, user_id):
"""set values in redis for user settings""" """set values in redis for user settings"""
for key, value in form_post.items(): for key, value in form_post.items():
to_write = value[0] if not value:
if len(to_write): continue
if to_write.isdigit():
to_write = int(to_write) message = {"status": value}
message = {"status": to_write} redis_key = f"{user_id}:{key}"
redis_key = f"{user_id}:{key}" RedisArchivist().set_message(redis_key, message, expire=False)
RedisArchivist().set_message(redis_key, message, expire=False)
def get_colors(self): def get_colors(self):
"""overwrite config if user has set custom values""" """overwrite config if user has set custom values"""
@@ -172,12 +174,11 @@ class ScheduleBuilder:
print("processing form, restart container for changes to take effect") print("processing form, restart container for changes to take effect")
redis_config = self.config redis_config = self.config
for key, value in form_post.items(): for key, value in form_post.items():
to_check = value[0] if key in self.SCHEDULES and value:
if key in self.SCHEDULES and to_check:
try: try:
to_write = self.value_builder(key, to_check) to_write = self.value_builder(key, value)
except ValueError: except ValueError:
print(f"failed: {key} {to_check}") print(f"failed: {key} {value}")
mess_dict = { mess_dict = {
"status": "message:setting", "status": "message:setting",
"level": "error", "level": "error",
@@ -188,8 +189,8 @@ class ScheduleBuilder:
return return
redis_config["scheduler"][key] = to_write redis_config["scheduler"][key] = to_write
if key in self.CONFIG and to_check: if key in self.CONFIG and value:
redis_config["scheduler"][key] = int(to_check) redis_config["scheduler"][key] = int(value)
RedisArchivist().set_message("config", redis_config, expire=False) RedisArchivist().set_message("config", redis_config, expire=False)
mess_dict = { mess_dict = {
"status": "message:setting", "status": "message:setting",
@@ -199,37 +200,56 @@ class ScheduleBuilder:
} }
RedisArchivist().set_message("message:setting", mess_dict) RedisArchivist().set_message("message:setting", mess_dict)
def value_builder(self, key, to_check): def value_builder(self, key, value):
"""validate single cron form entry and return cron dict""" """validate single cron form entry and return cron dict"""
print(f"change schedule for {key} to {to_check}") print(f"change schedule for {key} to {value}")
if to_check == "0": if value == "0":
# deactivate this schedule # deactivate this schedule
return False return False
if re.search(r"[\d]{1,2}\/[\d]{1,2}", to_check): if re.search(r"[\d]{1,2}\/[\d]{1,2}", value):
# number/number cron format will fail in celery # number/number cron format will fail in celery
print("number/number schedule formatting not supported") print("number/number schedule formatting not supported")
raise ValueError raise ValueError
keys = ["minute", "hour", "day_of_week"] keys = ["minute", "hour", "day_of_week"]
if to_check == "auto": if value == "auto":
# set to sensible default # set to sensible default
values = self.SCHEDULES[key].split() values = self.SCHEDULES[key].split()
else: else:
values = to_check.split() values = value.split()
if len(keys) != len(values): if len(keys) != len(values):
print(f"failed to parse {to_check} for {key}") print(f"failed to parse {value} for {key}")
raise ValueError("invalid input") raise ValueError("invalid input")
to_write = dict(zip(keys, values)) to_write = dict(zip(keys, values))
try: self._validate_cron(to_write)
int(to_write["minute"])
except ValueError as error:
print("too frequent: only number in minutes are supported")
raise ValueError("invalid input") from error
return to_write return to_write
@staticmethod
def _validate_cron(to_write):
"""validate all fields, raise value error for impossible schedule"""
all_hours = list(re.split(r"\D+", to_write["hour"]))
for hour in all_hours:
if hour.isdigit() and int(hour) > 23:
print("hour can not be greater than 23")
raise ValueError("invalid input")
all_days = list(re.split(r"\D+", to_write["day_of_week"]))
for day in all_days:
if day.isdigit() and int(day) > 6:
print("day can not be greater than 6")
raise ValueError("invalid input")
if not to_write["minute"].isdigit():
print("too frequent: only number in minutes are supported")
raise ValueError("invalid input")
if int(to_write["minute"]) > 59:
print("minutes can not be greater than 59")
raise ValueError("invalid input")
def build_schedule(self): def build_schedule(self):
"""build schedule dict as expected by app.conf.beat_schedule""" """build schedule dict as expected by app.conf.beat_schedule"""
schedule_dict = {} schedule_dict = {}

View File

@@ -3,31 +3,17 @@ Loose collection of helper functions
- don't import AppConfig class here to avoid circular imports - don't import AppConfig class here to avoid circular imports
""" """
import json import random
import re import re
import string import string
import subprocess import subprocess
import unicodedata import unicodedata
from datetime import datetime
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import requests
import yt_dlp import yt_dlp
def get_total_hits(index, es_url, es_auth, match_field):
"""get total hits from index"""
headers = {"Content-type": "application/json"}
data = {"query": {"match": {match_field: True}}}
payload = json.dumps(data)
url = f"{es_url}/{index}/_search?filter_path=hits.total"
request = requests.post(url, data=payload, headers=headers, auth=es_auth)
if not request.ok:
print(request.text)
total_json = json.loads(request.text)
total_hits = total_json["hits"]["total"]["value"]
return total_hits
def clean_string(file_name): def clean_string(file_name):
"""clean string to only asci characters""" """clean string to only asci characters"""
whitelist = "-_.() " + string.ascii_letters + string.digits whitelist = "-_.() " + string.ascii_letters + string.digits
@@ -51,6 +37,74 @@ def ignore_filelist(filelist):
return cleaned return cleaned
def randomizor(length):
"""generate random alpha numeric string"""
pool = string.digits + string.ascii_letters
return "".join(random.choice(pool) for i in range(length))
def requests_headers():
"""build header with random user agent for requests outside of yt-dlp"""
chrome_versions = (
"90.0.4430.212",
"90.0.4430.24",
"90.0.4430.70",
"90.0.4430.72",
"90.0.4430.85",
"90.0.4430.93",
"91.0.4472.101",
"91.0.4472.106",
"91.0.4472.114",
"91.0.4472.124",
"91.0.4472.164",
"91.0.4472.19",
"91.0.4472.77",
"92.0.4515.107",
"92.0.4515.115",
"92.0.4515.131",
"92.0.4515.159",
"92.0.4515.43",
"93.0.4556.0",
"93.0.4577.15",
"93.0.4577.63",
"93.0.4577.82",
"94.0.4606.41",
"94.0.4606.54",
"94.0.4606.61",
"94.0.4606.71",
"94.0.4606.81",
"94.0.4606.85",
"95.0.4638.17",
"95.0.4638.50",
"95.0.4638.54",
"95.0.4638.69",
"95.0.4638.74",
"96.0.4664.18",
"96.0.4664.45",
"96.0.4664.55",
"96.0.4664.93",
"97.0.4692.20",
)
template = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
)
return {"User-Agent": template}
def date_praser(timestamp):
"""return formatted date string"""
if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp)
elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
return datetime.strftime(date_obj, "%d %b, %Y")
class UrlListParser: class UrlListParser:
"""take a multi line string and detect valid youtube ids""" """take a multi line string and detect valid youtube ids"""
@@ -119,7 +173,7 @@ class UrlListParser:
id_type = "video" id_type = "video"
elif str_len == 24: elif str_len == 24:
id_type = "channel" id_type = "channel"
elif str_len in [34, 18]: elif str_len in [34, 18] or id_str in ["LL", "WL"]:
id_type = "playlist" id_type = "playlist"
else: else:
# unable to parse # unable to parse

View File

@@ -11,12 +11,20 @@ import redis
from home.src.ta.helper import ignore_filelist from home.src.ta.helper import ignore_filelist
class RedisArchivist: class RedisBase:
"""collection of methods to interact with redis""" """connection base for redis"""
REDIS_HOST = os.environ.get("REDIS_HOST") REDIS_HOST = os.environ.get("REDIS_HOST")
REDIS_PORT = os.environ.get("REDIS_PORT") or 6379 REDIS_PORT = os.environ.get("REDIS_PORT") or 6379
NAME_SPACE = "ta:" NAME_SPACE = "ta:"
def __init__(self):
self.conn = redis.Redis(host=self.REDIS_HOST, port=self.REDIS_PORT)
class RedisArchivist(RedisBase):
"""collection of methods to interact with redis"""
CHANNELS = [ CHANNELS = [
"download", "download",
"add", "add",
@@ -27,14 +35,9 @@ class RedisArchivist:
"setting", "setting",
] ]
def __init__(self):
self.redis_connection = redis.Redis(
host=self.REDIS_HOST, port=self.REDIS_PORT
)
def set_message(self, key, message, expire=True): def set_message(self, key, message, expire=True):
"""write new message to redis""" """write new message to redis"""
self.redis_connection.execute_command( self.conn.execute_command(
"JSON.SET", self.NAME_SPACE + key, ".", json.dumps(message) "JSON.SET", self.NAME_SPACE + key, ".", json.dumps(message)
) )
@@ -43,15 +46,11 @@ class RedisArchivist:
secs = 20 secs = 20
else: else:
secs = expire secs = expire
self.redis_connection.execute_command( self.conn.execute_command("EXPIRE", self.NAME_SPACE + key, secs)
"EXPIRE", self.NAME_SPACE + key, secs
)
def get_message(self, key): def get_message(self, key):
"""get message dict from redis""" """get message dict from redis"""
reply = self.redis_connection.execute_command( reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key)
"JSON.GET", self.NAME_SPACE + key
)
if reply: if reply:
json_str = json.loads(reply) json_str = json.loads(reply)
else: else:
@@ -61,7 +60,7 @@ class RedisArchivist:
def list_items(self, query): def list_items(self, query):
"""list all matches""" """list all matches"""
reply = self.redis_connection.execute_command( reply = self.conn.execute_command(
"KEYS", self.NAME_SPACE + query + "*" "KEYS", self.NAME_SPACE + query + "*"
) )
all_matches = [i.decode().lstrip(self.NAME_SPACE) for i in reply] all_matches = [i.decode().lstrip(self.NAME_SPACE) for i in reply]
@@ -74,14 +73,12 @@ class RedisArchivist:
def del_message(self, key): def del_message(self, key):
"""delete key from redis""" """delete key from redis"""
response = self.redis_connection.execute_command( response = self.conn.execute_command("DEL", self.NAME_SPACE + key)
"DEL", self.NAME_SPACE + key
)
return response return response
def get_lock(self, lock_key): def get_lock(self, lock_key):
"""handle lock for task management""" """handle lock for task management"""
redis_lock = self.redis_connection.lock(self.NAME_SPACE + lock_key) redis_lock = self.conn.lock(self.NAME_SPACE + lock_key)
return redis_lock return redis_lock
def get_progress(self): def get_progress(self):
@@ -89,7 +86,7 @@ class RedisArchivist:
all_messages = [] all_messages = []
for channel in self.CHANNELS: for channel in self.CHANNELS:
key = "message:" + channel key = "message:" + channel
reply = self.redis_connection.execute_command( reply = self.conn.execute_command(
"JSON.GET", self.NAME_SPACE + key "JSON.GET", self.NAME_SPACE + key
) )
if reply: if reply:
@@ -120,19 +117,12 @@ class RedisArchivist:
return mess_dict return mess_dict
class RedisQueue: class RedisQueue(RedisBase):
"""dynamically interact with the download queue in redis""" """dynamically interact with the download queue in redis"""
REDIS_HOST = os.environ.get("REDIS_HOST") def __init__(self):
REDIS_PORT = os.environ.get("REDIS_PORT") super().__init__()
NAME_SPACE = "ta:" self.key = self.NAME_SPACE + "dl_queue"
if not REDIS_PORT:
REDIS_PORT = 6379
def __init__(self, key):
self.key = self.NAME_SPACE + key
self.conn = redis.Redis(host=self.REDIS_HOST, port=self.REDIS_PORT)
def get_all(self): def get_all(self):
"""return all elements in list""" """return all elements in list"""

View File

@@ -8,8 +8,8 @@ Functionality:
import os import os
import home.apps as startup_apps
from celery import Celery, shared_task from celery import Celery, shared_task
from home.apps import StartupCheck
from home.src.download.queue import PendingList from home.src.download.queue import PendingList
from home.src.download.subscriptions import ( from home.src.download.subscriptions import (
ChannelSubscription, ChannelSubscription,
@@ -24,7 +24,6 @@ from home.src.index.filesystem import (
reindex_old_documents, reindex_old_documents,
scan_filesystem, scan_filesystem,
) )
from home.src.index.playlist import YoutubePlaylist
from home.src.ta.config import AppConfig, ScheduleBuilder from home.src.ta.config import AppConfig, ScheduleBuilder
from home.src.ta.helper import UrlListParser from home.src.ta.helper import UrlListParser
from home.src.ta.ta_redis import RedisArchivist, RedisQueue from home.src.ta.ta_redis import RedisArchivist, RedisQueue
@@ -63,9 +62,11 @@ def update_subscribed():
missing_from_playlists = playlist_handler.find_missing() missing_from_playlists = playlist_handler.find_missing()
missing = missing_from_channels + missing_from_playlists missing = missing_from_channels + missing_from_playlists
if missing: if missing:
pending_handler = PendingList() youtube_ids = [{"type": "video", "url": i} for i in missing]
all_videos_added = pending_handler.add_to_pending(missing) pending_handler = PendingList(youtube_ids=youtube_ids)
ThumbManager().download_vid(all_videos_added) pending_handler.parse_url_list()
pending_handler.add_to_pending()
else: else:
print("Did not acquire rescan lock.") print("Did not acquire rescan lock.")
@@ -86,7 +87,6 @@ def download_pending():
downloader = VideoDownloader() downloader = VideoDownloader()
downloader.add_pending() downloader.add_pending()
downloader.run_queue() downloader.run_queue()
downloader.validate_playlists()
else: else:
print("Did not acquire download lock.") print("Did not acquire download lock.")
@@ -98,7 +98,7 @@ def download_pending():
@shared_task @shared_task
def download_single(youtube_id): def download_single(youtube_id):
"""start download single video now""" """start download single video now"""
queue = RedisQueue("dl_queue") queue = RedisQueue()
queue.add_priority(youtube_id) queue.add_priority(youtube_id)
print("Added to queue with priority: " + youtube_id) print("Added to queue with priority: " + youtube_id)
# start queue if needed # start queue if needed
@@ -128,19 +128,9 @@ def download_single(youtube_id):
@shared_task @shared_task
def extrac_dl(youtube_ids): def extrac_dl(youtube_ids):
"""parse list passed and add to pending""" """parse list passed and add to pending"""
pending_handler = PendingList() pending_handler = PendingList(youtube_ids=youtube_ids)
missing_videos = pending_handler.parse_url_list(youtube_ids) pending_handler.parse_url_list()
all_videos_added = pending_handler.add_to_pending(missing_videos) pending_handler.add_to_pending()
missing_playlists = pending_handler.missing_from_playlists
thumb_handler = ThumbManager()
if missing_playlists:
new_thumbs = PlaylistSubscription().process_url_str(
missing_playlists, subscribed=False
)
thumb_handler.download_playlist(new_thumbs)
thumb_handler.download_vid(all_videos_added)
@shared_task(name="check_reindex") @shared_task(name="check_reindex")
@@ -191,7 +181,7 @@ def kill_dl(task_id):
app.control.revoke(task_id, terminate=True) app.control.revoke(task_id, terminate=True)
_ = RedisArchivist().del_message("dl_queue_id") _ = RedisArchivist().del_message("dl_queue_id")
RedisQueue("dl_queue").clear() RedisQueue().clear()
# clear cache # clear cache
cache_dir = os.path.join(CONFIG["application"]["cache_dir"], "download") cache_dir = os.path.join(CONFIG["application"]["cache_dir"], "download")
@@ -277,55 +267,12 @@ def index_channel_playlists(channel_id):
"message": f'Scanning channel "{channel.youtube_id}" in progress', "message": f'Scanning channel "{channel.youtube_id}" in progress',
} }
RedisArchivist().set_message("message:playlistscan", mess_dict) RedisArchivist().set_message("message:playlistscan", mess_dict)
all_playlists = channel.get_all_playlists() channel.index_channel_playlists()
if not all_playlists:
print(f"no playlists found for channel {channel_id}")
return
all_indexed = PendingList().get_all_indexed()
all_youtube_ids = [i["youtube_id"] for i in all_indexed]
for idx, (playlist_id, playlist_title) in enumerate(all_playlists):
# notify
mess_dict = {
"status": "message:playlistscan",
"level": "info",
"title": "Scanning channel for playlists",
"message": f"Progress: {idx + 1}/{len(all_playlists)}",
}
RedisArchivist().set_message("message:playlistscan", mess_dict)
print("add playlist: " + playlist_title)
playlist = YoutubePlaylist(playlist_id)
playlist.all_youtube_ids = all_youtube_ids
playlist.build_json()
if not playlist.json_data:
# skip if not available
continue
# don't add if no videos downloaded
downloaded = [
i
for i in playlist.json_data["playlist_entries"]
if i["downloaded"]
]
if not downloaded:
continue
playlist.upload_to_es()
playlist.add_vids_to_playlist()
if all_playlists:
playlist.get_playlist_art()
return
try: try:
app.conf.beat_schedule = ScheduleBuilder().build_schedule() app.conf.beat_schedule = ScheduleBuilder().build_schedule()
except KeyError: except KeyError:
# update path from v0.0.8 to v0.0.9 to load new defaults # update path from v0.0.8 to v0.0.9 to load new defaults
startup_apps.sync_redis_state() StartupCheck().sync_redis_state()
app.conf.beat_schedule = ScheduleBuilder().build_schedule() app.conf.beat_schedule = ScheduleBuilder().build_schedule()

View File

@@ -7,13 +7,13 @@
</div> </div>
<div class="about-section"> <div class="about-section">
<h2>Useful Links</h2> <h2>Useful Links</h2>
<p>This project is in active and constant development, take a look at the <a href="https://github.com/bbilly1/tubearchivist#roadmap" target="_blank">roadmap</a> for a overview.</p> <p>This project is in active and constant development, take a look at the <a href="https://github.com/tubearchivist/tubearchivist#roadmap" target="_blank">roadmap</a> for a overview.</p>
<p>For any questions on what a button or a function does, You can find the up-to-date user documentation on <a href="https://github.com/bbilly1/tubearchivist/wiki" target="_blank">Github</a>.</p> <p>For any questions on what a button or a function does, You can find the up-to-date user documentation on <a href="https://github.com/tubearchivist/tubearchivist/wiki" target="_blank">Github</a>.</p>
<p>All contributions are welcome: Open an <a href="https://github.com/bbilly1/tubearchivist/issues" target="_blank">issue</a> for any bugs and errors, start a <a href="https://github.com/bbilly1/tubearchivist/discussions" target="_blank">discussion</a> for anything that will require a more indepth look. The <a href="https://github.com/bbilly1/tubearchivist/blob/master/CONTRIBUTING.md" target="_blank">contributing</a> page is a good place to get started.</p> <p>All contributions are welcome: Open an <a href="https://github.com/tubearchivist/tubearchivist/issues" target="_blank">issue</a> for any bugs and errors, join us on <a href="https://discord.gg/AFwz8nE7BK" target="_blank">Discord</a> to discuss details. The <a href="https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md" target="_blank">contributing</a> page is a good place to get started.</p>
</div> </div>
<div class="about-section"> <div class="about-section">
<h2>Donate</h2> <h2>Donate</h2>
<p>Here are <a href="https://github.com/bbilly1/tubearchivist#donate" target="_blank">some links</a>, if you want to buy the developer a coffee. Thank you for your support!</p> <p>Here are <a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank">some links</a>, if you want to buy the developer a coffee. Thank you for your support!</p>
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -132,7 +132,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<div class="boxed-content"> <div class="boxed-content">
<span>© 2021 - <script type="text/javascript">document.write(new Date().getFullYear());</script> TubeArchivist v0.1.2 </span><span><a href="{% url 'about' %}">About</a> | <a href="https://github.com/bbilly1/tubearchivist" target="_blank">GitHub</a> | <a href="https://hub.docker.com/r/bbilly1/tubearchivist" target="_blank">Docker Hub</a> | <a href="https://discord.gg/AFwz8nE7BK" target="_blank">Discord</a> | <a href="https://www.reddit.com/r/TubeArchivist/">Reddit</a></span> <span>© 2021 - <script type="text/javascript">document.write(new Date().getFullYear());</script> TubeArchivist {{ version }} </span><span><a href="{% url 'about' %}">About</a> | <a href="https://github.com/tubearchivist/tubearchivist" target="_blank">GitHub</a> | <a href="https://hub.docker.com/r/bbilly1/tubearchivist" target="_blank">Docker Hub</a> | <a href="https://discord.gg/AFwz8nE7BK" target="_blank">Discord</a> | <a href="https://www.reddit.com/r/TubeArchivist/">Reddit</a></span>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -40,40 +40,40 @@
<div class="channel-list {{ view_style }}"> <div class="channel-list {{ view_style }}">
{% if results %} {% if results %}
{% for channel in results %} {% for channel in results %}
<div class="channel-item {{ view_style }}"> <div class="channel-item {{ view_style }}">
<div class="channel-banner {{ view_style }}"> <div class="channel-banner {{ view_style }}">
<a href="{% url 'channel_id' channel.source.channel_id %}"> <a href="{% url 'channel_id' channel.source.channel_id %}">
<img src="/cache/channels/{{ channel.source.channel_id }}_banner.jpg" alt="{{ channel.source.channel_id }}-banner"> <img src="/cache/channels/{{ channel.source.channel_id }}_banner.jpg" alt="{{ channel.source.channel_id }}-banner">
</a> </a>
</div>
<div class="info-box info-box-2 {{ view_style }}">
<div class="info-box-item">
<div class="round-img">
<a href="{% url 'channel_id' channel.source.channel_id %}">
<img src="/cache/channels/{{ channel.source.channel_id }}_thumb.jpg" alt="channel-thumb">
</a>
</div>
<div>
<h3><a href="{% url 'channel_id' channel.source.channel_id %}">{{ channel.source.channel_name }}</a></h3>
{% if channel.source.channel_subs >= 1000000 %}
<p>Subscribers: {{ channel.source.channel_subs|intword }}</p>
{% else %}
<p>Subscribers: {{ channel.source.channel_subs|intcomma }}</p>
{% endif %}
</div>
</div> </div>
<div class="info-box-item"> <div class="info-box info-box-2 {{ view_style }}">
<div> <div class="info-box-item">
<p>Last refreshed: {{ channel.source.channel_last_refresh }}</p> <div class="round-img">
{% if channel.source.channel_subscribed %} <a href="{% url 'channel_id' channel.source.channel_id %}">
<button class="unsubscribe" type="button" id="{{ channel.source.channel_id }}" onclick="unsubscribe(this.id)" title="Unsubscribe from {{ channel.source.channel_name }}">Unsubscribe</button> <img src="/cache/channels/{{ channel.source.channel_id }}_thumb.jpg" alt="channel-thumb">
{% else %} </a>
<button type="button" id="{{ channel.source.channel_id }}" onclick="subscribe(this.id)" title="Subscribe to {{ channel.source.channel_name }}">Subscribe</button> </div>
{% endif %} <div>
<h3><a href="{% url 'channel_id' channel.source.channel_id %}">{{ channel.source.channel_name }}</a></h3>
{% if channel.source.channel_subs >= 1000000 %}
<p>Subscribers: {{ channel.source.channel_subs|intword }}</p>
{% else %}
<p>Subscribers: {{ channel.source.channel_subs|intcomma }}</p>
{% endif %}
</div>
</div>
<div class="info-box-item">
<div>
<p>Last refreshed: {{ channel.source.channel_last_refresh }}</p>
{% if channel.source.channel_subscribed %}
<button class="unsubscribe" type="button" id="{{ channel.source.channel_id }}" onclick="unsubscribe(this.id)" title="Unsubscribe from {{ channel.source.channel_name }}">Unsubscribe</button>
{% else %}
<button type="button" id="{{ channel.source.channel_id }}" onclick="subscribe(this.id)" title="Subscribe to {{ channel.source.channel_name }}">Subscribe</button>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<h2>No channels found...</h2> <h2>No channels found...</h2>

View File

@@ -6,6 +6,7 @@
<div class="channel-banner"> <div class="channel-banner">
<a href="/channel/{{ channel_info.channel_id }}/"><img src="/cache/channels/{{ channel_info.channel_id }}_banner.jpg" alt="channel_banner"></a> <a href="/channel/{{ channel_info.channel_id }}/"><img src="/cache/channels/{{ channel_info.channel_id }}_banner.jpg" alt="channel_banner"></a>
</div> </div>
<div id="notifications" data="channel_id"></div>
<div class="info-box info-box-3"> <div class="info-box info-box-3">
<div class="info-box-item"> <div class="info-box-item">
<div class="round-img"> <div class="round-img">
@@ -52,11 +53,57 @@
<p>Total Videos archived: {{ max_hits }}</p> <p>Total Videos archived: {{ max_hits }}</p>
<p>Watched: <button title="Mark all videos from {{ channel_info.channel_name }} as watched" type="button" id="watched-button" data-id="{{ channel_info.channel_id }}" onclick="isWatchedButton(this)">Mark as watched</button></p> <p>Watched: <button title="Mark all videos from {{ channel_info.channel_name }} as watched" type="button" id="watched-button" data-id="{{ channel_info.channel_id }}" onclick="isWatchedButton(this)">Mark as watched</button></p>
{% endif %} {% endif %}
<button title="Search for playlists on YouTube for {{ channel_info.channel_name }}" type="button" id="find-playlists-button" data-id="{{ channel_info.channel_id }}" onclick="findPlaylists(this)">Find Playlists</button> <button {% if channel_info.channel_overwrites %} class="danger-button"{% endif %} onclick="showOverwrite()" title="Overwrite settings for channel {{ channel_info.channel_name }}">Configure</button>
<a href="/playlist/?search={{ channel_info.channel_id }}" title="Show all playlists belonging to {{ channel_info.channel_name }}"><button>Show Playlists</button></a> <a href="/playlist/?search={{ channel_info.channel_id }}" title="Show all playlists belonging to {{ channel_info.channel_name }}"><button>Show Playlists</button></a>
</div> </div>
</div> </div>
</div> </div>
<div id="overwrite-form" class="info-box{% if not channel_info.channel_overwrites %} hidden-overwrite{% endif %}">
<div class="info-box-item">
<form class="overwrite-form" action="/channel/{{ channel_info.channel_id }}/" method="POST">
{% csrf_token %}
<div class="overwrite-form-item">
<p>Download format: <span class="settings-current">
{% if channel_info.channel_overwrites.download_format %}
{{ channel_info.channel_overwrites.download_format }}
{% else %}
False
{% endif %}</span></p>
{{ channel_overwrite_form.download_format }}<br>
</div>
<div class="overwrite-form-item">
<p>Auto delete watched videos after x days: <span class="settings-current">
{% if channel_info.channel_overwrites.autodelete_days %}
{{ channel_info.channel_overwrites.autodelete_days }}
{% else %}
False
{% endif %}</span></p>
{{ channel_overwrite_form.autodelete_days }}<br>
</div>
<div class="overwrite-form-item">
<p>Index playlists: <span class="settings-current">
{% if channel_info.channel_overwrites.index_playlists %}
{{ channel_info.channel_overwrites.index_playlists }}
{% else %}
False
{% endif %}</span></p>
{{ channel_overwrite_form.index_playlists }}<br>
</div>
<div class="overwrite-form-item">
<p>Enable <a href="https://sponsor.ajay.app/" target="_blank">SponsorBlock</a>: <span class="settings-current">
{% if channel_info.channel_overwrites.integrate_sponsorblock %}
{{ channel_info.channel_overwrites.integrate_sponsorblock }}
{% elif channel_info.channel_overwrites.integrate_sponsorblock == False %}
Disabled
{% else %}
False
{% endif %}</span></p>
{{ channel_overwrite_form.integrate_sponsorblock }}<br>
</div>
<button type="submit">Save Channel Overwrites</button>
</form>
</div>
</div>
{% if channel_info.channel_description %} {% if channel_info.channel_description %}
<div class="info-box-item description-box"> <div class="info-box-item description-box">
<p>Description: <button onclick="textReveal()" id="text-reveal-button">Show</button></p> <p>Description: <button onclick="textReveal()" id="text-reveal-button">Show</button></p>
@@ -65,7 +112,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div id="notifications" data="channel_id"></div>
<div class="view-controls"> <div class="view-controls">
<div class="toggle"> <div class="toggle">
<span>Hide watched videos:</span> <span>Hide watched videos:</span>
@@ -111,9 +157,9 @@
<div class="video-thumb"> <div class="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb"> <img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb">
{% if video.source.player.progress %} {% if video.source.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div> <div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div>
{% else %} {% else %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div> <div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div>
{% endif %} {% endif %}
</div> </div>
<div class="video-play"> <div class="video-play">
@@ -124,9 +170,9 @@
<div class="video-desc {{ view_style }}"> <div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}"> <div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %} {% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.source.youtube_id }}" onclick="isUnwatched(this.id)" class="seen-icon" title="Mark as unwatched"> <img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.source.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %} {% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" id="{{ video.source.youtube_id }}" onclick="isWatched(this.id)" class="unseen-icon" title="Mark as watched."> <img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.source.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %} {% endif %}
<span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span> <span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span>
</div> </div>

View File

@@ -2,6 +2,46 @@
{% block content %} {% block content %}
{% load static %} {% load static %}
<div class="boxed-content"> <div class="boxed-content">
{% if continue_vids %}
<div class="title-bar">
<h1>Continue Watching</h1>
</div>
<div class="video-list {{ view_style }}">
{% for video in continue_vids %}
<div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb">
{% if video.source.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div>
{% else %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div>
{% endif %}
</div>
<div class="video-play">
<img src="{% static 'img/icon-play.svg' %}" alt="play-icon">
</div>
</div>
</a>
<div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.source.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.source.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %}
<span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span>
</div>
<div>
<a href="{% url 'channel_id' video.source.channel.channel_id %}"><h3>{{ video.source.channel.channel_name }}</h3></a>
<a class="video-more" href="{% url 'video' video.source.youtube_id %}"><h2>{{ video.source.title }}</h2></a>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="title-bar"> <div class="title-bar">
<h1>Recent Videos</h1> <h1>Recent Videos</h1>
</div> </div>
@@ -50,9 +90,9 @@
<div class="video-thumb"> <div class="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb"> <img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb">
{% if video.source.player.progress %} {% if video.source.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div> <div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div>
{% else %} {% else %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div> <div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div>
{% endif %} {% endif %}
</div> </div>
<div class="video-play"> <div class="video-play">
@@ -63,9 +103,9 @@
<div class="video-desc {{ view_style }}"> <div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}"> <div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %} {% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.source.youtube_id }}" onclick="isUnwatched(this.id)" class="seen-icon" title="Mark as unwatched"> <img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.source.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %} {% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" id="{{ video.source.youtube_id }}" onclick="isWatched(this.id)" class="unseen-icon" title="Mark as watched."> <img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.source.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %} {% endif %}
<span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span> <span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span>
</div> </div>

View File

@@ -45,7 +45,7 @@
<input type="hidden" name="next" value="{{ request.GET.next }}" /> <input type="hidden" name="next" value="{{ request.GET.next }}" />
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
<p class="login-links"><span><a href="https://github.com/bbilly1/tubearchivist" target="_blank">Github</a></span> <span><a href="https://github.com/bbilly1/tubearchivist#donate" target="_blank">Donate</a></span></p> <p class="login-links"><span><a href="https://github.com/tubearchivist/tubearchivist" target="_blank">Github</a></span> <span><a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank">Donate</a></span></p>
</div> </div>
<div class="footer-colors"> <div class="footer-colors">
<div class="col-1"></div> <div class="col-1"></div>

View File

@@ -49,8 +49,8 @@
<div class="info-box-item"> <div class="info-box-item">
<div> <div>
{% if max_hits %} {% if max_hits %}
<p>Total Videos archived: {{ max_hits }}/{{ playlist_info.playlist_entries|length }}</p> <p>Total Videos archived: {{ max_hits }}/{{ playlist_info.playlist_entries|length }}</p>
<p>Watched: <button title="Mark all videos from {{ playlist_info.playlist_name }} as watched" type="button" id="watched-button" data-id="{{ playlist_info.playlist_id }}" onclick="isWatchedButton(this)">Mark as watched</button></p> <p>Watched: <button title="Mark all videos from {{ playlist_info.playlist_name }} as watched" type="button" id="watched-button" data-id="{{ playlist_info.playlist_id }}" onclick="isWatchedButton(this)">Mark as watched</button></p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -92,9 +92,9 @@
<div class="video-thumb"> <div class="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb"> <img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb">
{% if video.source.player.progress %} {% if video.source.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div> <div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div>
{% else %} {% else %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div> <div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div>
{% endif %} {% endif %}
</div> </div>
<div class="video-play"> <div class="video-play">
@@ -105,9 +105,9 @@
<div class="video-desc {{ view_style }}"> <div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}"> <div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %} {% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.source.youtube_id }}" onclick="isUnwatched(this.id)" class="seen-icon" title="Mark as unwatched"> <img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.source.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %} {% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" id="{{ video.source.youtube_id }}" onclick="isWatched(this.id)" class="unseen-icon" title="Mark as watched."> <img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.source.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %} {% endif %}
<span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span> <span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span>
</div> </div>

View File

@@ -3,7 +3,7 @@
<div id="player" class="player-wrapper"></div> <div id="player" class="player-wrapper"></div>
<div class="boxed-content"> <div class="boxed-content">
<div class="title-bar"> <div class="title-bar">
<h1>Search</h1> <h1>Search your Archive</h1>
</div> </div>
<div class="multi-search-box"> <div class="multi-search-box">
{{ search_form }} {{ search_form }}

View File

@@ -48,7 +48,7 @@
</div> </div>
<div class="settings-item"> <div class="settings-item">
<p>Current download speed limit in KB/s: <span class="settings-current">{{ config.downloads.limit_speed }}</span></p> <p>Current download speed limit in KB/s: <span class="settings-current">{{ config.downloads.limit_speed }}</span></p>
<i>Limit download speed. 0 (zero) to deactivate, e.g. 1000 (1MB/s). Speeds are in KB/s.</i><br> <i>Limit download speed. 0 (zero) to deactivate, e.g. 1000 (1MB/s). Speeds are in KB/s. Setting takes effect on new download jobs or application restart.</i><br>
{{ app_form.downloads_limit_speed }} {{ app_form.downloads_limit_speed }}
</div> </div>
<div class="settings-item"> <div class="settings-item">
@@ -114,6 +114,19 @@
{{ app_form.downloads_subtitle_index }} {{ app_form.downloads_subtitle_index }}
</div> </div>
</div> </div>
<div class="settings-group">
<h2 id="format">Cookie</h2>
<div class="settings-item">
<p>Import YouTube cookie: <span class="settings-current">{{ config.downloads.cookie_import }}</span><br></p>
<i>Place your cookie file named <span class="settings-current">cookies.google.txt</span> in <span class="settings-current">cache/import</span> before enabling. Instructions in the <a href="https://github.com/tubearchivist/tubearchivist/wiki/Settings" target="_blank">Wiki.</a></i><br>
{{ app_form.downloads_cookie_import }}<br>
{% if config.downloads.cookie_import %}
<div id="cookieMessage">
<button onclick="handleCookieValidate()" type="button" id="cookieButton">Validate Cookie File</button>
</div>
{% endif %}
</div>
</div>
<div class="settings-group"> <div class="settings-group">
<h2 id="integrations">Integrations</h2> <h2 id="integrations">Integrations</h2>
<div class="settings-item"> <div class="settings-item">
@@ -124,10 +137,15 @@
</div> </div>
</div> </div>
<div class="settings-item"> <div class="settings-item">
<p>Integrate with <a href="https://returnyoutubedislike.com/">returnyoutubedislike.com</a> to get dislikes and average ratings back: <span class="settings-current">{{ config.downloads.integrate_ryd }}</span></p> <p>Integrate with <a href="https://returnyoutubedislike.com/" target="_blank">returnyoutubedislike.com</a> to get dislikes and average ratings back: <span class="settings-current">{{ config.downloads.integrate_ryd }}</span></p>
<i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br> <i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br>
{{ app_form.downloads_integrate_ryd }} {{ app_form.downloads_integrate_ryd }}
</div> </div>
<div class="settings-item">
<p>Integrate with <a href="https://sponsor.ajay.app/" target="_blank">SponsorBlock</a> to get sponsored timestamps: <span class="settings-current">{{ config.downloads.integrate_sponsorblock }}</span></p>
<i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br>
{{ app_form.downloads_integrate_sponsorblock }}
</div>
<div class="settings-item"> <div class="settings-item">
<p>Current Cast integration: <span class="settings-current">{{ config.application.enable_cast }}</span></p> <p>Current Cast integration: <span class="settings-current">{{ config.application.enable_cast }}</span></p>
<i>Enabling Cast will load an additional JS library from Google. HTTPS and a supported browser are required for this integration.</i><br> <i>Enabling Cast will load an additional JS library from Google. HTTPS and a supported browser are required for this integration.</i><br>
@@ -253,7 +271,7 @@
</div> </div>
<div class="settings-group"> <div class="settings-group">
<h2>Manual media files import.</h2> <h2>Manual media files import.</h2>
<p>Add files to the <span class="settings-current">cache/import</span> folder. Make sure to follow the instructions in the Github <a href="https://github.com/bbilly1/tubearchivist/wiki/Settings" target="_blank">Wiki</a>.</p> <p>Add files to the <span class="settings-current">cache/import</span> folder. Make sure to follow the instructions in the Github <a href="https://github.com/tubearchivist/tubearchivist/wiki/Settings" target="_blank">Wiki</a>.</p>
<div id="manual-import"> <div id="manual-import">
<button onclick="manualImport()">Start import</button> <button onclick="manualImport()">Start import</button>
</div> </div>
@@ -298,7 +316,7 @@
<div class="settings-group"> <div class="settings-group">
<h2>Rescan filesystem</h2> <h2>Rescan filesystem</h2>
<p><span class="danger-zone">Danger Zone</span>: This will delete the metadata of deleted videos from the filesystem.</p> <p><span class="danger-zone">Danger Zone</span>: This will delete the metadata of deleted videos from the filesystem.</p>
<p>Rescan your media folder looking for missing videos and clean up index. More infos on the Github <a href="https://github.com/bbilly1/tubearchivist/wiki/Settings" target="_blank">Wiki</a>.</p> <p>Rescan your media folder looking for missing videos and clean up index. More infos on the Github <a href="https://github.com/tubearchivist/tubearchivist/wiki/Settings" target="_blank">Wiki</a>.</p>
<div id="fs-rescan"> <div id="fs-rescan">
<button onclick="fsRescan()">Rescan filesystem</button> <button onclick="fsRescan()">Rescan filesystem</button>
</div> </div>

View File

@@ -3,6 +3,16 @@
{% load static %} {% load static %}
{% load humanize %} {% load humanize %}
<div class="video-main"></div> <div class="video-main"></div>
<div class="notifications" id="notifications"></div>
<div class="sponsorblock" id="sponsorblock">
{% if video.sponsorblock.is_enabled %}
{% if video.sponsorblock.segments|length == 0 %}
<h4>This video doesn't have any sponsor segments added. To add a segment go to <u><a href="https://www.youtube.com/watch?v={{ video.youtube_id }}">this video on YouTube</a></u> and add a segment using the <u><a href="https://sponsor.ajay.app/">SponsorBlock</a></u> extension.</h4>
{% elif video.sponsorblock.has_unlocked %}
<h4>This video has unlocked sponsor segments. Go to <u><a href="https://www.youtube.com/watch?v={{ video.youtube_id }}">this video on YouTube</a></u> and vote on the segments using the <u><a href="https://sponsor.ajay.app/">SponsorBlock</a></u> extension.</h4>
{% endif %}
{% endif %}
</div>
<div class="boxed-content"> <div class="boxed-content">
<div class="title-bar"> <div class="title-bar">
{% if cast %} {% if cast %}
@@ -32,9 +42,9 @@
<p>Last refreshed: {{ video.vid_last_refresh }}</p> <p>Last refreshed: {{ video.vid_last_refresh }}</p>
<p class="video-info-watched">Watched: <p class="video-info-watched">Watched:
{% if video.player.watched %} {% if video.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" id="{{ video.youtube_id }}" onclick="isUnwatched(this.id)" class="seen-icon" title="Mark as unwatched"> <img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %} {% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" id="{{ video.youtube_id }}" onclick="isWatched(this.id)" class="unseen-icon" title="Mark as watched."> <img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %} {% endif %}
</p> </p>
{% if video.active %} {% if video.active %}
@@ -114,6 +124,7 @@
</div> </div>
<script> <script>
var videoData = getVideoData('{{ video.youtube_id }}'); var videoData = getVideoData('{{ video.youtube_id }}');
sponsorBlock = videoData.data.sponsorblock;
var videoProgress = getVideoProgress('{{ video.youtube_id }}').position; var videoProgress = getVideoProgress('{{ video.youtube_id }}').position;
window.onload = insertVideoTag(videoData, videoProgress); window.onload = insertVideoTag(videoData, videoProgress);
</script> </script>

View File

@@ -8,17 +8,19 @@ import json
import urllib.parse import urllib.parse
from time import sleep from time import sleep
from django import forms from django.conf import settings
from django.contrib.auth import login from django.contrib.auth import login
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.views import View from django.views import View
from home.src.download.yt_cookie import CookieHandler
from home.src.es.index_setup import get_available_backups from home.src.es.index_setup import get_available_backups
from home.src.frontend.api_calls import PostData from home.src.frontend.api_calls import PostData
from home.src.frontend.forms import ( from home.src.frontend.forms import (
AddToQueueForm, AddToQueueForm,
ApplicationSettingsForm, ApplicationSettingsForm,
ChannelOverwriteForm,
CustomAuthForm, CustomAuthForm,
MultiSearchForm, MultiSearchForm,
SchedulerSettingsForm, SchedulerSettingsForm,
@@ -27,12 +29,13 @@ from home.src.frontend.forms import (
UserSettingsForm, UserSettingsForm,
) )
from home.src.frontend.searching import SearchHandler from home.src.frontend.searching import SearchHandler
from home.src.index.channel import channel_overwrites
from home.src.index.generic import Pagination from home.src.index.generic import Pagination
from home.src.index.playlist import YoutubePlaylist from home.src.index.playlist import YoutubePlaylist
from home.src.ta.config import AppConfig, ScheduleBuilder from home.src.ta.config import AppConfig, ScheduleBuilder
from home.src.ta.helper import UrlListParser from home.src.ta.helper import UrlListParser
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
from home.tasks import extrac_dl, subscribe_to from home.tasks import extrac_dl, index_channel_playlists, subscribe_to
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
@@ -109,7 +112,7 @@ class ArchivistViewConfig(View):
"""build default context for every view""" """build default context for every view"""
self.user_id = user_id self.user_id = user_id
self.user_conf = RedisArchivist() self.user_conf = RedisArchivist()
self.default_conf = AppConfig().config self.default_conf = AppConfig(self.user_id).config
self.context = { self.context = {
"colors": self.default_conf["application"]["colors"], "colors": self.default_conf["application"]["colors"],
@@ -120,6 +123,7 @@ class ArchivistViewConfig(View):
"hide_watched": self._get_hide_watched(), "hide_watched": self._get_hide_watched(),
"show_ignored_only": self._get_show_ignore_only(), "show_ignored_only": self._get_show_ignore_only(),
"show_subed_only": self._get_show_subed_only(), "show_subed_only": self._get_show_subed_only(),
"version": settings.TA_VERSION,
} }
@@ -175,14 +179,44 @@ class ArchivistResultsView(ArchivistViewConfig):
if not results or not self.context["results"]: if not results or not self.context["results"]:
return return
progress = {i["youtube_id"]: i["position"] for i in results} self.context["continue_vids"] = self.get_in_progress(results)
in_progress = {i["youtube_id"]: i["position"] for i in results}
for hit in self.context["results"]: for hit in self.context["results"]:
video = hit["source"] video = hit["source"]
if video["youtube_id"] in progress: if video["youtube_id"] in in_progress:
played_sec = progress.get(video["youtube_id"]) played_sec = in_progress.get(video["youtube_id"])
total = video["player"]["duration"] total = video["player"]["duration"]
if not total:
total = played_sec * 2
video["player"]["progress"] = 100 * (played_sec / total) video["player"]["progress"] = 100 * (played_sec / total)
def get_in_progress(self, results):
"""get all videos in progress"""
ids = [{"match": {"youtube_id": i.get("youtube_id")}} for i in results]
data = {
"size": self.default_conf["archive"]["page_size"],
"query": {"bool": {"should": ids}},
"sort": [{"published": {"order": "desc"}}],
}
search = SearchHandler(
"ta_video/_search", self.default_conf, data=data
)
videos = search.get_data()
if not videos:
return False
for video in videos:
youtube_id = video["source"]["youtube_id"]
matched = [i for i in results if i["youtube_id"] == youtube_id]
played_sec = matched[0]["position"]
total = video["source"]["player"]["duration"]
if not total:
total = matched[0].get("position") * 2
video["source"]["player"]["progress"] = 100 * (played_sec / total)
return videos
def single_lookup(self, es_path): def single_lookup(self, es_path):
"""retrieve a single item from url""" """retrieve a single item from url"""
search = SearchHandler(es_path, config=self.default_conf) search = SearchHandler(es_path, config=self.default_conf)
@@ -232,6 +266,13 @@ class HomeView(ArchivistResultsView):
def _update_view_data(self): def _update_view_data(self):
"""update view specific data dict""" """update view specific data dict"""
self.data["sort"].extend(
[
{"channel.channel_name.keyword": {"order": "asc"}},
{"title.keyword": {"order": "asc"}},
]
)
if self.context["hide_watched"]: if self.context["hide_watched"]:
self.data["query"] = {"term": {"player.watched": {"value": False}}} self.data["query"] = {"term": {"player.watched": {"value": False}}}
if self.search_get: if self.search_get:
@@ -290,8 +331,11 @@ class AboutView(View):
@staticmethod @staticmethod
def get(request): def get(request):
"""handle http get""" """handle http get"""
colors = AppConfig(request.user.id).colors context = {
context = {"title": "About", "colors": colors} "title": "About",
"colors": AppConfig(request.user.id).colors,
"version": settings.TA_VERSION,
}
return render(request, "home/about.html", context) return render(request, "home/about.html", context)
@@ -385,6 +429,7 @@ class ChannelIdView(ArchivistResultsView):
{ {
"title": "Channel: " + channel_name, "title": "Channel: " + channel_name,
"channel_info": channel_info, "channel_info": channel_info,
"channel_overwrite_form": ChannelOverwriteForm,
} }
) )
@@ -392,19 +437,34 @@ class ChannelIdView(ArchivistResultsView):
def _update_view_data(self, channel_id): def _update_view_data(self, channel_id):
"""update view specific data dict""" """update view specific data dict"""
query = { self.data["query"] = {
"bool": { "bool": {
"must": [ "must": [
{"term": {"channel.channel_id": {"value": channel_id}}} {"term": {"channel.channel_id": {"value": channel_id}}}
] ]
} }
} }
self.data["query"] = query self.data["sort"].append({"title.keyword": {"order": "asc"}})
if self.context["hide_watched"]: if self.context["hide_watched"]:
to_append = {"term": {"player.watched": {"value": False}}} to_append = {"term": {"player.watched": {"value": False}}}
self.data["query"]["bool"]["must"].append(to_append) self.data["query"]["bool"]["must"].append(to_append)
@staticmethod
def post(request, channel_id):
"""handle post request"""
print(f"handle post from {channel_id}")
channel_overwrite_form = ChannelOverwriteForm(request.POST)
if channel_overwrite_form.is_valid():
overwrites = channel_overwrite_form.cleaned_data
print(f"{channel_id}: set overwrites {overwrites}")
channel_overwrites(channel_id, overwrites=overwrites)
if overwrites.get("index_playlists") == "1":
index_channel_playlists.delay(channel_id)
sleep(1)
return redirect("channel_id", channel_id, permanent=True)
class ChannelView(ArchivistResultsView): class ChannelView(ArchivistResultsView):
"""resolves to /channel/ """resolves to /channel/
@@ -611,7 +671,7 @@ class VideoView(View):
def get(self, request, video_id): def get(self, request, video_id):
"""get single video""" """get single video"""
colors, cast = self.read_config(user_id=request.user.id) config_handler = AppConfig(request.user.id)
path = f"ta_video/_doc/{video_id}" path = f"ta_video/_doc/{video_id}"
look_up = SearchHandler(path, config=False) look_up = SearchHandler(path, config=False)
video_hit = look_up.get_data() video_hit = look_up.get_data()
@@ -633,8 +693,10 @@ class VideoView(View):
"video": video_data, "video": video_data,
"playlist_nav": playlist_nav, "playlist_nav": playlist_nav,
"title": video_title, "title": video_title,
"colors": colors, "colors": config_handler.colors,
"cast": cast, "cast": config_handler.config["application"]["enable_cast"],
"version": settings.TA_VERSION,
"config": config_handler.config,
} }
return render(request, "home/video.html", context) return render(request, "home/video.html", context)
@@ -651,14 +713,6 @@ class VideoView(View):
return all_navs return all_navs
@staticmethod
def read_config(user_id):
"""read config file"""
config_handler = AppConfig(user_id)
cast = config_handler.config["application"]["enable_cast"]
colors = config_handler.colors
return colors, cast
@staticmethod @staticmethod
def star_creator(rating): def star_creator(rating):
"""convert rating float to stars""" """convert rating float to stars"""
@@ -691,7 +745,10 @@ class SearchView(ArchivistResultsView):
all_styles = self.get_all_view_styles() all_styles = self.get_all_view_styles()
self.context.update({"all_styles": all_styles}) self.context.update({"all_styles": all_styles})
self.context.update( self.context.update(
{"search_form": MultiSearchForm(initial=all_styles)} {
"search_form": MultiSearchForm(initial=all_styles),
"version": settings.TA_VERSION,
}
) )
return render(request, "home/search.html", self.context) return render(request, "home/search.html", self.context)
@@ -723,6 +780,7 @@ class SettingsView(View):
"user_form": user_form, "user_form": user_form,
"app_form": app_form, "app_form": app_form,
"scheduler_form": scheduler_form, "scheduler_form": scheduler_form,
"version": settings.TA_VERSION,
} }
return render(request, "home/settings.html", context) return render(request, "home/settings.html", context)
@@ -734,30 +792,45 @@ class SettingsView(View):
token = Token.objects.get_or_create(user=request.user)[0] token = Token.objects.get_or_create(user=request.user)[0]
return token return token
@staticmethod def post(self, request):
def post(request):
"""handle form post to update settings""" """handle form post to update settings"""
user_form = UserSettingsForm(request.POST)
if user_form.is_valid():
user_form_post = user_form.cleaned_data
if any(user_form_post.values()):
AppConfig().set_user_config(user_form_post, request.user.id)
form_response = forms.Form(request.POST) app_form = ApplicationSettingsForm(request.POST)
if form_response.is_valid(): if app_form.is_valid():
form_post = dict(request.POST) app_form_post = app_form.cleaned_data
print(form_post) if app_form_post:
del form_post["csrfmiddlewaretoken"] print(app_form_post)
config_handler = AppConfig() updated = AppConfig().update_config(app_form_post)
if "application-settings" in form_post: self.post_process_updated(updated)
del form_post["application-settings"]
config_handler.update_config(form_post) scheduler_form = SchedulerSettingsForm(request.POST)
elif "user-settings" in form_post: if scheduler_form.is_valid():
del form_post["user-settings"] scheduler_form_post = scheduler_form.cleaned_data
config_handler.set_user_config(form_post, request.user.id) if any(scheduler_form_post.values()):
elif "scheduler-settings" in form_post: print(scheduler_form_post)
del form_post["scheduler-settings"] ScheduleBuilder().update_schedule_conf(scheduler_form_post)
print(form_post)
ScheduleBuilder().update_schedule_conf(form_post)
sleep(1) sleep(1)
return redirect("settings", permanent=True) return redirect("settings", permanent=True)
@staticmethod
def post_process_updated(updated):
"""apply changes for config"""
if not updated:
return
for config_value, updated_value in updated:
if config_value == "cookie_import":
if updated_value:
CookieHandler().import_cookie()
else:
CookieHandler().revoke()
def progress(request): def progress(request):
# pylint: disable=unused-argument # pylint: disable=unused-argument

View File

@@ -1,12 +1,12 @@
beautifulsoup4==4.10.0 beautifulsoup4==4.11.1
celery==5.2.3 celery==5.2.6
Django==4.0.2 Django==4.0.4
django-cors-headers==3.11.0 django-cors-headers==3.11.0
djangorestframework==3.13.1 djangorestframework==3.13.1
Pillow==9.0.1 Pillow==9.1.0
redis==4.1.4 redis==4.2.2
requests==2.27.1 requests==2.27.1
ryd-client==0.0.3 ryd-client==0.0.3
uWSGI==2.0.20 uWSGI==2.0.20
whitenoise==6.0.0 whitenoise==6.0.0
yt_dlp==2022.2.4 yt_dlp==2022.4.8

View File

@@ -62,6 +62,13 @@ h3 {
color: var(--accent-font-light); color: var(--accent-font-light);
} }
h4 {
font-size: 0.7em;
margin-bottom: 7px;
font-family: Sen-Regular, sans-serif;
color: var(--accent-font-light);
}
p, i, li { p, i, li {
font-family: Sen-Regular, sans-serif; font-family: Sen-Regular, sans-serif;
margin-bottom: 10px; margin-bottom: 10px;
@@ -355,6 +362,18 @@ button:hover {
height: 100vh; height: 100vh;
} }
.notifications {
text-align: center;
width: 80%;
margin: auto;
}
.sponsorblock {
text-align: center;
width: 80%;
margin: auto;
}
.video-player video, .video-player video,
.video-main video { .video-main video {
max-height: 80vh; max-height: 80vh;
@@ -370,16 +389,18 @@ button:hover {
} }
/* video list */ /* video list */
.video-list.grid { .video-list {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 1rem; grid-gap: 1rem;
margin-top: 1rem;
}
.video-list.grid {
grid-template-columns: 1fr 1fr 1fr;
} }
.video-list.list { .video-list.list {
display: grid;
grid-template-columns: unset; grid-template-columns: unset;
grid-gap: 1rem;
} }
.video-item { .video-item {
@@ -482,8 +503,7 @@ button:hover {
align-items: center; align-items: center;
} }
.unseen-icon, .watch-button,
.seen-icon,
.close-button { .close-button {
cursor: pointer; cursor: pointer;
filter: var(--img-filter); filter: var(--img-filter);
@@ -565,6 +585,28 @@ button:hover {
margin-bottom: 10px; margin-bottom: 10px;
} }
.overwrite-form {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
}
.overwrite-form button {
width: 200px;
}
.overwrite-form-item {
margin-bottom: 1rem;
}
.overwrite-form-item input {
width: 90%;
}
.hidden-overwrite {
display: none;
}
/* login */ /* login */
.login-page { .login-page {
display: flex; display: flex;
@@ -1061,7 +1103,10 @@ button:hover {
.channel-list.grid, .channel-list.grid,
.video-item.list, .video-item.list,
.playlist-list.list, .playlist-list.list,
.playlist-list.grid { .playlist-list.grid,
.info-box-2,
.info-box-3,
.overwrite-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.playlist-item.list { .playlist-item.list {
@@ -1100,10 +1145,6 @@ button:hover {
.sort select { .sort select {
margin: unset; margin: unset;
} }
.info-box-2,
.info-box-3 {
grid-template-columns: 1fr;
}
.description-box { .description-box {
display: block; display: block;
} }

View File

@@ -7,7 +7,7 @@ checkMessages()
// page map to notification status // page map to notification status
const messageTypes = { const messageTypes = {
"download": ["message:download", "message:add", "message:rescan"], "download": ["message:download", "message:add", "message:rescan", "message:playlistscan"],
"channel": ["message:subchannel"], "channel": ["message:subchannel"],
"channel_id": ["message:playlistscan"], "channel_id": ["message:playlistscan"],
"playlist": ["message:subplaylist"], "playlist": ["message:subplaylist"],

View File

@@ -8,21 +8,62 @@ function sortChange(sortValue) {
}, 500); }, 500);
} }
function isWatched(youtube_id) { // Updates video watch status when passed a video id and it's current state (ex if the video was unwatched but you want to mark it as watched you will pass "unwatched")
postVideoProgress(youtube_id, 0); // Reset video progress on watched; function updateVideoWatchStatus(input1, videoCurrentWatchStatus) {
removeProgressBar(youtube_id); if (videoCurrentWatchStatus) {
var payload = JSON.stringify({'watched': youtube_id}); videoId = input1;
sendPost(payload); } else if (input1.getAttribute("data-id")) {
var seenIcon = document.createElement('img'); videoId = input1.getAttribute("data-id");
seenIcon.setAttribute('src', "/static/img/icon-seen.svg"); videoCurrentWatchStatus = input1.getAttribute("data-status");
seenIcon.setAttribute('alt', 'seen-icon'); }
seenIcon.setAttribute('id', youtube_id);
seenIcon.setAttribute('title', "Mark as unwatched"); postVideoProgress(videoId, 0); // Reset video progress on watched/unwatched;
seenIcon.setAttribute('onclick', "isUnwatched(this.id)"); removeProgressBar(videoId);
seenIcon.classList = 'seen-icon';
document.getElementById(youtube_id).replaceWith(seenIcon); if (videoCurrentWatchStatus == "watched") {
var watchStatusIndicator = createWatchStatusIndicator(videoId, "unwatched");
var payload = JSON.stringify({'un_watched': videoId});
sendPost(payload);
} else if (videoCurrentWatchStatus == "unwatched") {
var watchStatusIndicator = createWatchStatusIndicator(videoId, "watched");
var payload = JSON.stringify({'watched': videoId});
sendPost(payload);
}
var watchButtons = document.getElementsByClassName("watch-button");
for (let i = 0; i < watchButtons.length; i++) {
if (watchButtons[i].getAttribute("data-id") == videoId) {
watchButtons[i].outerHTML = watchStatusIndicator;
}
}
} }
// Creates a watch status indicator when passed a video id and the videos watch status
function createWatchStatusIndicator(videoId, videoWatchStatus) {
if (videoWatchStatus == "watched") {
var seen = "seen";
var title = "Mark as unwatched";
} else if (videoWatchStatus == "unwatched") {
var seen = "unseen";
var title = "Mark as watched";
}
var watchStatusIndicator = `<img src="/static/img/icon-${seen}.svg" alt="${seen}-icon" data-id="${videoId}" data-status="${videoWatchStatus}" onclick="updateVideoWatchStatus(this)" class="watch-button" title="${title}">`;
return watchStatusIndicator;
}
// function isWatched(youtube_id) {
// var payload = JSON.stringify({'watched': youtube_id});
// sendPost(payload);
// var seenIcon = document.createElement('img');
// seenIcon.setAttribute('src', "/static/img/icon-seen.svg");
// seenIcon.setAttribute('alt', 'seen-icon');
// seenIcon.setAttribute('id', youtube_id);
// seenIcon.setAttribute('title', "Mark as unwatched");
// seenIcon.setAttribute('onclick', "isUnwatched(this.id)");
// seenIcon.classList = 'seen-icon';
// document.getElementById(youtube_id).replaceWith(seenIcon);
// }
// Removes the progress bar when passed a video id // Removes the progress bar when passed a video id
function removeProgressBar(videoId) { function removeProgressBar(videoId) {
setProgressBar(videoId, 0, 1); setProgressBar(videoId, 0, 1);
@@ -39,19 +80,19 @@ function isWatchedButton(button) {
}, 1000); }, 1000);
} }
function isUnwatched(youtube_id) { // function isUnwatched(youtube_id) {
postVideoProgress(youtube_id, 0); // Reset video progress on unwatched; // postVideoProgress(youtube_id, 0); // Reset video progress on unwatched;
var payload = JSON.stringify({'un_watched': youtube_id}); // var payload = JSON.stringify({'un_watched': youtube_id});
sendPost(payload); // sendPost(payload);
var unseenIcon = document.createElement('img'); // var unseenIcon = document.createElement('img');
unseenIcon.setAttribute('src', "/static/img/icon-unseen.svg"); // unseenIcon.setAttribute('src', "/static/img/icon-unseen.svg");
unseenIcon.setAttribute('alt', 'unseen-icon'); // unseenIcon.setAttribute('alt', 'unseen-icon');
unseenIcon.setAttribute('id', youtube_id); // unseenIcon.setAttribute('id', youtube_id);
unseenIcon.setAttribute('title', "Mark as watched"); // unseenIcon.setAttribute('title', "Mark as watched");
unseenIcon.setAttribute('onclick', "isWatched(this.id)"); // unseenIcon.setAttribute('onclick', "isWatched(this.id)");
unseenIcon.classList = 'unseen-icon'; // unseenIcon.classList = 'unseen-icon';
document.getElementById(youtube_id).replaceWith(unseenIcon); // document.getElementById(youtube_id).replaceWith(unseenIcon);
} // }
function unsubscribe(id_unsub) { function unsubscribe(id_unsub) {
var payload = JSON.stringify({'unsubscribe': id_unsub}); var payload = JSON.stringify({'unsubscribe': id_unsub});
@@ -151,7 +192,6 @@ function addSingle(button) {
function deleteQueue(button) { function deleteQueue(button) {
var to_delete = button.getAttribute('data-id'); var to_delete = button.getAttribute('data-id');
var payload = JSON.stringify({'deleteQueue': to_delete}); var payload = JSON.stringify({'deleteQueue': to_delete});
console.log(payload);
sendPost(payload); sendPost(payload);
setTimeout(function(){ setTimeout(function(){
location.reload(); location.reload();
@@ -228,19 +268,6 @@ function fsRescan() {
toReplace.appendChild(message); toReplace.appendChild(message);
} }
function findPlaylists(button) {
var channel_id = button.getAttribute("data-id");
var payload = JSON.stringify({'find-playlists': channel_id});
sendPost(payload);
// clear button
var message = document.createElement('p');
message.innerText = 'Scraping for playlists in progress';
document.getElementById("find-playlists-button").replaceWith(message);
setTimeout(function(){
checkMessages();
}, 500);
}
function resetToken() { function resetToken() {
var payload = JSON.stringify({'reset-token': true}); var payload = JSON.stringify({'reset-token': true});
sendPost(payload); sendPost(payload);
@@ -287,7 +314,6 @@ function deletePlaylist(button) {
"playlist-action": playlist_action "playlist-action": playlist_action
} }
}); });
console.log(payload);
sendPost(payload); sendPost(payload);
setTimeout(function(){ setTimeout(function(){
window.location.replace("/playlist/"); window.location.replace("/playlist/");
@@ -301,9 +327,33 @@ function cancelDelete() {
} }
// player // player
var sponsorBlock = [];
function createPlayer(button) { function createPlayer(button) {
var videoId = button.getAttribute('data-id'); var videoId = button.getAttribute('data-id');
var videoData = getVideoData(videoId); var videoData = getVideoData(videoId);
var sponsorBlockElements = '';
if (videoData.data.sponsorblock && videoData.data.sponsorblock.is_enabled) {
sponsorBlock = videoData.data.sponsorblock;
if (sponsorBlock.segments.length == 0) {
sponsorBlockElements = `
<div class="sponsorblock" id="sponsorblock">
<h4>This video doesn't have any sponsor segments added. To add a segment go to <u><a href="https://www.youtube.com/watch?v=${videoId}">this video on Youtube</a></u> and add a segment using the <u><a href="https://sponsor.ajay.app/">SponsorBlock</a></u> extension.</h4>
</div>
`;
} else {
if (sponsorBlock.has_unlocked) {
sponsorBlockElements = `
<div class="sponsorblock" id="sponsorblock">
<h4>This video has unlocked sponsor segments. Go to <u><a href="https://www.youtube.com/watch?v=${videoId}">this video on YouTube</a></u> and vote on the segments using the <u><a href="https://sponsor.ajay.app/">SponsorBlock</a></u> extension.</h4>
</div>
`;
}
}
} else {
sponsorBlock = null;
}
var videoProgress = getVideoProgress(videoId).position; var videoProgress = getVideoProgress(videoId).position;
var videoName = videoData.data.title; var videoName = videoData.data.title;
@@ -327,7 +377,6 @@ function createPlayer(button) {
var channelName = videoData.data.channel.channel_name; var channelName = videoData.data.channel.channel_name;
removePlayer(); removePlayer();
document.getElementById(videoId).outerHTML = ''; // Remove watch indicator from video info
// If cast integration is enabled create cast button // If cast integration is enabled create cast button
var castButton = ''; var castButton = '';
@@ -337,12 +386,11 @@ function createPlayer(button) {
// Watched indicator // Watched indicator
if (videoData.data.player.watched) { if (videoData.data.player.watched) {
var playerState = "seen"; var watchStatusIndicator = createWatchStatusIndicator(videoId, "watched");
var watchedFunction = "Unwatched";
} else { } else {
var playerState = "unseen"; var watchStatusIndicator = createWatchStatusIndicator(videoId, "unwatched");
var watchedFunction = "Watched";
} }
var playerStats = `<div class="thumb-icon player-stats"><img src="/static/img/icon-eye.svg" alt="views icon"><span>${videoViews}</span>`; var playerStats = `<div class="thumb-icon player-stats"><img src="/static/img/icon-eye.svg" alt="views icon"><span>${videoViews}</span>`;
if (videoData.data.stats.like_count) { if (videoData.data.stats.like_count) {
@@ -358,9 +406,11 @@ function createPlayer(button) {
const markup = ` const markup = `
<div class="video-player" data-id="${videoId}"> <div class="video-player" data-id="${videoId}">
${videoTag} ${videoTag}
<div class="notifications" id="notifications"></div>
${sponsorBlockElements}
<div class="player-title boxed-content"> <div class="player-title boxed-content">
<img class="close-button" src="/static/img/icon-close.svg" alt="close-icon" data="${videoId}" onclick="removePlayer()" title="Close player"> <img class="close-button" src="/static/img/icon-close.svg" alt="close-icon" data="${videoId}" onclick="removePlayer()" title="Close player">
<img src="/static/img/icon-${playerState}.svg" alt="${playerState}-icon" id="${videoId}" onclick="is${watchedFunction}(this.id)" class="${playerState}-icon" title="Mark as ${watchedFunction}"> ${watchStatusIndicator}
${castButton} ${castButton}
${playerStats} ${playerStats}
<div class="player-channel-playlist"> <div class="player-channel-playlist">
@@ -391,7 +441,11 @@ function createVideoTag(videoData, videoProgress) {
var videoSubtitles = videoData.data.subtitles; // Array of subtitles var videoSubtitles = videoData.data.subtitles; // Array of subtitles
if (typeof(videoSubtitles) != 'undefined' && videoData.config.downloads.subtitle) { if (typeof(videoSubtitles) != 'undefined' && videoData.config.downloads.subtitle) {
for (var i = 0; i < videoSubtitles.length; i++) { for (var i = 0; i < videoSubtitles.length; i++) {
subtitles += `<track label="${videoSubtitles[i].name}" kind="subtitles" srclang="${videoSubtitles[i].lang}" src="${videoSubtitles[i].media_url}">`; let label = videoSubtitles[i].name;
if (videoSubtitles[i].source == "auto") {
label += " - auto";
}
subtitles += `<track label="${label}" kind="subtitles" srclang="${videoSubtitles[i].lang}" src="${videoSubtitles[i].media_url}">`;
} }
} }
@@ -444,22 +498,45 @@ function getVideoPlayerDuration() {
function getVideoPlayerWatchStatus() { function getVideoPlayerWatchStatus() {
var videoId = getVideoPlayerVideoId(); var videoId = getVideoPlayerVideoId();
var watched = false; var watched = false;
if(document.getElementById(videoId).className != "unseen-icon") {
watched = true; var watchButtons = document.getElementsByClassName("watch-button");
for (let i = 0; i < watchButtons.length; i++) {
if (watchButtons[i].getAttribute("data-id") == videoId && watchButtons[i].getAttribute("data-status") == "watched") {
watched = true;
}
} }
return watched; return watched;
} }
// Runs on video playback, marks video as watched if video gets to 90% or higher, sends position to api // Runs on video playback, marks video as watched if video gets to 90% or higher, sends position to api, SB skipping
function onVideoProgress() { function onVideoProgress() {
var videoId = getVideoPlayerVideoId(); var videoId = getVideoPlayerVideoId();
var currentTime = getVideoPlayerCurrentTime(); var currentTime = getVideoPlayerCurrentTime();
var duration = getVideoPlayerDuration(); var duration = getVideoPlayerDuration();
var videoElement = getVideoPlayer();
var notificationsElement = document.getElementById("notifications");
if (sponsorBlock && sponsorBlock.segments) {
for(let i in sponsorBlock.segments) {
if(currentTime >= sponsorBlock.segments[i].segment[0] && currentTime <= sponsorBlock.segments[i].segment[0] + 0.3) {
videoElement.currentTime = sponsorBlock.segments[i].segment[1];
var notificationElement = document.getElementById("notification-" + sponsorBlock.segments[i].UUID);
if (!notificationElement) {
notificationsElement.innerHTML += `<h3 id="notification-${sponsorBlock.segments[i].UUID}">Skipped sponsor segment from ${formatTime(sponsorBlock.segments[i].segment[0])} to ${formatTime(sponsorBlock.segments[i].segment[1])}.</h3>`;
}
}
if(currentTime > sponsorBlock.segments[i].segment[1] + 10) {
var notificationsElementUUID = document.getElementById("notification-" + sponsorBlock.segments[i].UUID);
if(notificationsElementUUID) {
notificationsElementUUID.outerHTML = '';
}
}
}
}
if ((currentTime % 10).toFixed(1) <= 0.2) { // Check progress every 10 seconds or else progress is checked a few times a second if ((currentTime % 10).toFixed(1) <= 0.2) { // Check progress every 10 seconds or else progress is checked a few times a second
postVideoProgress(videoId, currentTime); postVideoProgress(videoId, currentTime);
if (!getVideoPlayerWatchStatus()) { // Check if video is already marked as watched if (!getVideoPlayerWatchStatus()) { // Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) { if (watchedThreshold(currentTime, duration)) {
isWatched(videoId); updateVideoWatchStatus(videoId, "unwatched");
} }
} }
} }
@@ -469,7 +546,13 @@ function onVideoProgress() {
function onVideoEnded() { function onVideoEnded() {
var videoId = getVideoPlayerVideoId(); var videoId = getVideoPlayerVideoId();
if (!getVideoPlayerWatchStatus()) { // Check if video is already marked as watched if (!getVideoPlayerWatchStatus()) { // Check if video is already marked as watched
isWatched(videoId); updateVideoWatchStatus(videoId, "unwatched");
}
for(let i in sponsorBlock.segments) {
var notificationsElementUUID = document.getElementById("notification-" + sponsorBlock.segments[i].UUID);
if(notificationsElementUUID) {
notificationsElementUUID.outerHTML = '';
}
} }
} }
@@ -509,6 +592,32 @@ function formatNumbers(number) {
return numberFormatted; return numberFormatted;
} }
// Formats times in seconds for frontend
function formatTime(time) {
var hoursUnformatted = time / 3600;
var minutesUnformatted = (time % 3600) / 60;
var secondsUnformatted = time % 60;
var hoursFormatted = Math.trunc(hoursUnformatted);
if(minutesUnformatted < 10 && hoursFormatted > 0) {
var minutesFormatted = "0" + Math.trunc(minutesUnformatted);
} else {
var minutesFormatted = Math.trunc(minutesUnformatted);
}
if(secondsUnformatted < 10) {
var secondsFormatted = "0" + Math.trunc(secondsUnformatted);
} else {
var secondsFormatted = Math.trunc(secondsUnformatted);
}
var timeUnformatted = '';
if(hoursFormatted > 0) {
timeUnformatted = hoursFormatted + ":"
}
var timeFormatted = timeUnformatted.concat(minutesFormatted, ":", secondsFormatted);
return timeFormatted;
}
// Gets video data when passed video ID // Gets video data when passed video ID
function getVideoData(videoId) { function getVideoData(videoId) {
var apiEndpoint = "/api/video/" + videoId + "/"; var apiEndpoint = "/api/video/" + videoId + "/";
@@ -566,6 +675,48 @@ function postVideoProgress(videoId, videoProgress) {
} }
} }
// Send sponsor segment when given video id and and timestamps
function postSponsorSegment(videoId, startTime, endTime) {
var apiEndpoint = "/api/video/" + videoId + "/sponsor/";
var data = {
"segment": {
"startTime": startTime,
"endTime": endTime
}
};
apiRequest(apiEndpoint, "POST", data);
}
// Send sponsor segment when given video id and and timestamps
function postSponsorSegmentVote(videoId, uuid, vote) {
var apiEndpoint = "/api/video/" + videoId + "/sponsor/";
var data = {
"vote": {
"uuid": uuid,
"yourVote": vote
}
};
apiRequest(apiEndpoint, "POST", data);
}
function handleCookieValidate() {
document.getElementById("cookieButton").remove();
var cookieMessageElement = document.getElementById("cookieMessage");
cookieMessageElement.innerHTML = `<span>Processing.</span>`;
response = postCookieValidate();
if (response.cookie_validated == true) {
cookieMessageElement.innerHTML = `<span>The cookie file is valid.</span>`;
} else {
cookieMessageElement.innerHTML = `<span class="danger-zone">Warning, the cookie file is invalid.</span>`;
}
}
// Check youtube cookie settings
function postCookieValidate() {
var apiEndpoint = "/api/cookie/";
return apiRequest(apiEndpoint, "POST");
}
// Makes api requests when passed an endpoint and method ("GET", "POST", "DELETE") // Makes api requests when passed an endpoint and method ("GET", "POST", "DELETE")
function apiRequest(apiEndpoint, method, data) { function apiRequest(apiEndpoint, method, data) {
const xhttp = new XMLHttpRequest(); const xhttp = new XMLHttpRequest();
@@ -606,11 +757,21 @@ function removePlayer() {
// Sets the progress bar when passed a video id, video progress and video duration // Sets the progress bar when passed a video id, video progress and video duration
function setProgressBar(videoId, currentTime, duration) { function setProgressBar(videoId, currentTime, duration) {
progressBar = document.getElementById("progress-" + videoId); var progressBarWidth = (currentTime / duration) * 100 + "%";
progressBarWidth = (currentTime / duration) * 100 + "%"; var progressBars = document.getElementsByClassName("video-progress-bar");
if (progressBar) { for (let i = 0; i < progressBars.length; i++) {
progressBar.style.width = progressBarWidth; if (progressBars[i].id == "progress-" + videoId) {
if (!getVideoPlayerWatchStatus()) {
progressBars[i].style.width = progressBarWidth;
} else {
progressBars[i].style.width = "0%";
}
}
} }
// progressBar = document.getElementById("progress-" + videoId);
} }
// multi search form // multi search form
@@ -679,9 +840,9 @@ function createVideo(video, viewStyle) {
const videoPublished = video.published; const videoPublished = video.published;
const videoDuration = video.player.duration_str; const videoDuration = video.player.duration_str;
if (video.player.watched) { if (video.player.watched) {
var playerState = "seen"; var watchStatusIndicator = createWatchStatusIndicator(videoId, "watched");
} else { } else {
var playerState = "unseen"; var watchStatusIndicator = createWatchStatusIndicator(videoId, "unwatched");
}; };
const channelId = video.channel.channel_id; const channelId = video.channel.channel_id;
const channelName = video.channel.channel_name; const channelName = video.channel.channel_name;
@@ -699,7 +860,7 @@ function createVideo(video, viewStyle) {
</a> </a>
<div class="video-desc ${viewStyle}"> <div class="video-desc ${viewStyle}">
<div class="video-desc-player" id="video-info-${videoId}"> <div class="video-desc-player" id="video-info-${videoId}">
<img src="/static/img/icon-${playerState}.svg" alt="${playerState}-icon" id="${videoId}" onclick="isWatched(this.id)" class="${playerState}-icon"> ${watchStatusIndicator}
<span>${videoPublished} | ${videoDuration}</span> <span>${videoPublished} | ${videoDuration}</span>
</div> </div>
<div> <div>
@@ -840,6 +1001,15 @@ function showForm() {
animate('animate-icon', 'pulse-img'); animate('animate-icon', 'pulse-img');
} }
function showOverwrite() {
var overwriteDiv = document.getElementById("overwrite-form");
if (overwriteDiv.classList.contains("hidden-overwrite")) {
overwriteDiv.classList.remove("hidden-overwrite");
} else {
overwriteDiv.classList.add("hidden-overwrite")
}
}
function animate(elementId, animationClass) { function animate(elementId, animationClass) {
var toAnimate = document.getElementById(elementId); var toAnimate = document.getElementById(elementId);
if (toAnimate.className !== animationClass) { if (toAnimate.className !== animationClass) {

View File

@@ -1,9 +0,0 @@
#!/bin/bash
# install debug and testing tools into slim container
apt update && apt install -y vim htop bmon net-tools iputils-ping procps
pip install ipython
##
exit 0