Compare commits

...

433 Commits

Author SHA1 Message Date
simon
e98d2d3590 bump footer version 2022-11-30 08:52:45 +07:00
3ster
61ccb1c47a Fix example format specifier for iOS-compatible downloads (#370) 2022-11-30 08:50:56 +07:00
simon
b4b287bda4 fix spelling 2022-11-28 21:36:24 +07:00
simon
ca8f4c9fa4 update docs for comments and similar videos 2022-11-28 21:31:49 +07:00
simon
18b8a17df1 Use snapshots for mapping backup, #build
Changes:
- Use system snapshots at startup to backup before mapping changes
- Refactor startup, index setup and backup classes
- bump yt-dlp and redis
2022-11-27 18:26:45 +07:00
simon
f192c39be8 make snapshot before mapping changes 2022-11-27 18:01:57 +07:00
simon
f1b89345e2 refactor ElasticBackup and ElasticIndex for better startup handling 2022-11-27 15:41:59 +07:00
simon
f6c702fba7 refactor and run clear_dl_cache at startup 2022-11-25 18:49:36 +07:00
simon
f619a5f72d fi none existing unset channel overwrite, #362 2022-11-25 18:18:02 +07:00
simon
f7dd77ee3e bump redis 2022-11-25 15:58:43 +07:00
simon
0f41c54406 fix linebreak for comments header 2022-11-25 15:58:35 +07:00
simon
13d1927840 clean up API logging 2022-11-24 11:11:36 +07:00
simon
d0124c1a5c update yt-dlp, fix channel entries extractor 2022-11-22 19:11:58 +07:00
simon
5ed80d983d fix deactivated comment extractor 2022-11-22 18:01:27 +07:00
simon
24d66e33a7 video page improvements, #build
Changed:
- Added similar videos
- Added video tag cloud
- Added comment reply toggle
- Added comments progress
- Fixed channel and playlist deactivate
2022-11-22 11:43:44 +07:00
simon
786caf7bc1 Merge branch 'master' into testing 2022-11-22 11:36:52 +07:00
Gabe Dunn
e4b7258f7d Add podman installation instructions to docs (#367)
* add podman instructions to installation docs

* add mention of podman differences and docs page in readme installation section

* make mention of podman instructions more efficient
2022-11-22 11:36:09 +07:00
simon
90258e75e6 add notification for comment indexing 2022-11-22 11:23:34 +07:00
simon
b48b7c7e72 fix channel deactivation, take 2 2022-11-22 10:17:44 +07:00
simon
454d520394 standard video thumb path building 2022-11-21 15:50:01 +07:00
simon
ee5f83e89f add player-wrapper to video.html 2022-11-21 15:31:54 +07:00
simon
fa518c029c frontend similar videos 2022-11-21 15:13:37 +07:00
simon
aa1d0b759e [API] implement similar video endpoint 2022-11-21 15:13:15 +07:00
simon
abfd109203 hide comment box overflow 2022-11-21 15:05:28 +07:00
simon
ebf53f6486 fix channel deactivation 2022-11-21 14:55:47 +07:00
simon
2450a4ad1d future proof arm64 instructions 2022-11-21 13:46:19 +07:00
simon
41ea0c2c58 better padding for comment reply threads 2022-11-21 10:38:30 +07:00
simon
acb24e38a6 add tag cloud to video page, short rating 2022-11-21 10:27:28 +07:00
simon
a1fbc0b436 fix linter 2022-11-21 10:05:28 +07:00
simon
e052935e2d add comment reply toggle 2022-11-21 09:55:33 +07:00
simon
4d2bd51603 Merge branch 'master' into testing 2022-11-21 07:31:46 +07:00
simon
b3d3ebe9d0 fix channel_last_refresh mapping for date math 2022-11-20 20:38:10 +07:00
simon
d05c00aac6 fix playlist deactivate for refresh 2022-11-20 16:26:54 +07:00
simon
0c8fcbd724 use mapping to fix error for temp backup index 2022-11-20 16:13:30 +07:00
Simon
7e67ed02aa fix checkbox label 2022-11-20 15:45:55 +07:00
simon
e101c4abc7 ignore unavailable index for snapshots 2022-11-20 12:33:39 +07:00
simon
6a7a84792e better feedback when skipping not yet existing index backup 2022-11-20 12:21:25 +07:00
simon
2fa907c478 Index Comments, #build
Changed:
- added comment download and index
- [API] added comment api endpoints
2022-11-18 11:36:51 +07:00
simon
cc965ac473 update TA_VERSION to unstable 2022-11-18 11:35:09 +07:00
simon
8f5311ff04 delete comments when deleting channel 2022-11-18 11:33:06 +07:00
simon
e590d456a1 delete comments when deleting video 2022-11-18 10:47:48 +07:00
simon
a5be5a719b reindex comments 2022-11-18 10:31:09 +07:00
simon
b5ceb264ec format comment like number 2022-11-18 09:51:18 +07:00
simon
31c967cc11 fix linter 2022-11-18 09:46:33 +07:00
simon
2818bd5d52 add comments to video template if available 2022-11-18 09:44:20 +07:00
simon
939cd7fb62 index comments as a DownloadPostProcess 2022-11-18 09:22:58 +07:00
simon
172ced7129 configure comments extraction 2022-11-18 08:59:04 +07:00
simon
11c122cba3 finetune comments icon position 2022-11-18 08:58:25 +07:00
simon
456bc57df3 Merge branch 'master' into testing 2022-11-14 16:34:15 +07:00
crocs
091024553b Fixed "Unseen" and "Thumb" icons (#363)
* Update channel.html

Updated wording, removed "matching" as it's not necessary.

* Update downloads.html

* Delete icon-thumb.svg

* Delete icon-eye.svg

* Add files via upload
2022-11-14 16:33:26 +07:00
simon
ad016df105 implement comment frontend 2022-11-14 16:31:49 +07:00
simon
79d134cea8 clean format comments date string 2022-11-14 11:59:59 +07:00
simon
fb046bed5b [API] implement get comments API view 2022-11-12 12:42:08 +07:00
simon
f6b6185fb2 rename ta_comment index, implement get comments from es 2022-11-12 12:40:14 +07:00
simon
5b7e3e877b implement basic comment archive 2022-11-12 11:48:48 +07:00
simon
3c5302336f Merge branch 'master' into testing 2022-11-10 10:06:42 +07:00
simon
55c9721b51 rename to *common errors*, better issue template 2022-11-10 10:05:14 +07:00
simon
fc788c586d better wording for scheduler frequency, #358 2022-11-10 10:05:14 +07:00
simon
e912cf7fb6 rename to *common errors*, better issue template 2022-11-10 09:53:49 +07:00
lamusmaser
c111050c8f Update Synology documentation to include snapshot flag details. (#361) 2022-11-10 09:38:07 +07:00
simon
0e8d3b76c1 better wording for scheduler frequency, #358 2022-11-08 08:19:09 +07:00
simon
eda56236ce bump TA_VERSION 2022-11-05 11:58:41 +07:00
simon
b4620504b8 improve manual import docs 2022-11-05 11:36:53 +07:00
simon
b751ae183a fix startup migration error missing enable_snapshot var 2022-11-05 11:17:12 +07:00
simon
0b2b8aacef add snapshot wiki link 2022-11-05 09:35:28 +07:00
simon
e21d66ce85 add video player shortcuts docs 2022-11-03 18:18:51 +07:00
simon
66b72da02a update fuzzy search docs 2022-11-03 18:08:50 +07:00
simon
e0d6ce6c7f update snapshot docs 2022-11-03 17:58:17 +07:00
simon
97e9d6f8ce add path.repo env var to es container 2022-11-03 17:34:03 +07:00
crocs
0fb2f3fcc6 Changed the wording in a few places (#357)
* Update channel.html

Updated wording, removed "matching" as it's not necessary.

* Update downloads.html
2022-11-03 17:14:55 +07:00
simon
32fc89f4e7 configure fuzzines and snapshot, #build
Changed:
- Added *fuzzy:* secondary keyword for search
- Added take snapshot now button
- Changed snapshot policy to 12pm TZ aware
- Fixed channel page size validator > 0
2022-11-02 12:29:00 +07:00
simon
10fbfadf51 bump TA_VERSION unstable 2022-11-02 12:23:34 +07:00
simon
e9c457cac2 bump django 2022-11-02 12:18:17 +07:00
simon
0f191d8a3e configure fuzziness in search 2022-11-02 11:59:31 +07:00
simon
dcf317e471 form validate channel pagesize greater than 0, #334 2022-11-02 10:30:21 +07:00
simon
749261c146 take snapshot at 12 localtime 2022-11-02 10:17:27 +07:00
simon
238c6bc080 Merge branch 'master' into testing 2022-11-02 08:42:24 +07:00
simon
476d4d4909 add create snapshot now button 2022-10-31 21:21:28 +07:00
simon
651a642e71 bump pillow 2022-10-29 21:33:16 +07:00
simon
01a8c65df9 move version_check.py to separate arg 2022-10-29 21:21:22 +07:00
simon
d69460bf98 System snapshots, #build
Changed:
- Added: Dedublicated snapshots, read docs/Settings#snapshots first
- Added: Actions for better Python and JS linting
- Changed: Clean up JS
- Changed: Use patched ffmpeg builds for arm64
- API: Added endpoints to interact with snapshots
- Fixed: mobile layout for channel filter dopdown on downloads
2022-10-29 21:10:04 +07:00
lamusmaser
f0848283bd README updated with format update (#353)
* Add update to README to include format adjustments under Getting Started.

* Include updates to the Settings page of the Wiki.
2022-10-29 21:04:49 +07:00
simon
fdc1df313b add partial snapshots docs 2022-10-29 21:03:27 +07:00
simon
b425e7d75c frontend API button to restore snapshot 2022-10-29 20:12:54 +07:00
simon
fb25b76950 fix spelling 2022-10-29 17:45:20 +07:00
simon
a18723991a implement snapshot API views 2022-10-29 17:43:38 +07:00
simon
74aa134971 fix next_exec epoch time builder 2022-10-29 17:43:14 +07:00
simon
24b02150a7 handle delete, restore snapshot return values 2022-10-29 17:27:04 +07:00
simon
f717c71c66 implement _parse_single_snapshot, slug compatible naming 2022-10-29 16:54:16 +07:00
simon
814de87768 add snapshot check at startup 2022-10-29 15:57:59 +07:00
simon
c392563ef8 use exist_ok for os.makedirs at startup 2022-10-29 15:52:33 +07:00
simon
1fbd603374 frontend implementation for snapshot integration 2022-10-29 15:49:47 +07:00
simon
749f61aba6 fix empty policy building 2022-10-29 15:32:08 +07:00
simon
59f347c135 implement snapshot startup check 2022-10-28 16:47:50 +07:00
simon
904f449d37 combine snapshot_stats for frontend 2022-10-27 18:40:27 +07:00
simon
9b69a8dc91 fix mobile view-controls break 2022-10-27 16:15:32 +07:00
Kevin Gibbons
50ea7db66c Add FAQ item about downloading vs subscribing (#352)
* Add FAQ item about downloading vs subscribing

* note download+subscribe
2022-10-27 13:44:16 +07:00
lamusmaser
e4c5529412 Update Github Actions to use deploy.sh and remove individual calls for testing functions. (#351)
* Remove individual calls for testing functions.
Call `deploy.sh validate` for linting checks.

* Increment actions versions to remove deprecation errors.
Add `requests` to `pip install` line.

* Specify Python version with `actions/setup-python` call.
2022-10-27 13:30:24 +07:00
Kevin Gibbons
b4fe9a784f add github workflow to enforce linting of JS files (#348) 2022-10-27 13:27:30 +07:00
BrianCArnold
6b2fe125d4 Add LDAP attribute mapping env variables. (#344)
* Added ability to specify LDAP attribute mapping.

Specifically made sure that LDAP auth works the same if the
new environment variables aren't set, in order to maintain
behavior for users who are already using LDAP if they don't
set the new envvars.

* Updated env var name to match the name of the parent global.

* Updated README.md to include information on new user attribute mapping environment variables.

* Added additional environment var options, and updated comment explaining why there are multiple.

* I'm not a python programmer, so these were stupid mistakes. Works now.

* Addressing lint error.

* Finished updating formatting according to black linter.

* Shortened comments to fit within line length.

* fix whitespace linting

Co-authored-by: simon <simobilleter@gmail.com>
2022-10-27 13:20:41 +07:00
simon
e7f960bf46 implement basics snapshot management 2022-10-26 23:17:55 +07:00
simon
02ed521f21 bump uwsgi 2022-10-26 23:17:03 +07:00
Kevin Gibbons
39902cb1c6 Format/slightly modernize the JS (#345)
* add basic JS tooling

* fix accidental uses of global variables

* auto-format

* add and fix a couple more standard lint rules

* remove useless return false from settimeout callbacks

* document JS contributing

* fix whitespace in package.json

* add JS stuff to codespell skiplist

* codespell take two

* update github action and add comments about duplicated logic
2022-10-26 09:43:58 +07:00
simon
700a8cb54a remove leftover ffmpeg binary 2022-10-25 12:13:11 +07:00
simon
3eacc06383 Player shortcuts, #build
Changed:
- Added player shortcuts, '?'
- Fixed playlist missing channel metadata
- Changed arm64 build to use patched ffmpeg
2022-10-25 11:25:12 +07:00
simon
f8dccfcd15 use linuxarm64 ffmpeg builds for arm64 2022-10-25 11:22:32 +07:00
simon
725f17bcd2 fix playlist missing channel metadata 2022-10-25 11:13:14 +07:00
Kevin Gibbons
ff82690d3c add keyboard shortcuts to video player (#342)
* add keyboard shortcuts to video player

* fix modal on the inline player
2022-10-24 20:11:00 +07:00
Kevin Gibbons
51f7210195 More detailed contributing steps (#343)
* More detailed contributing steps

* restore whitespace
2022-10-24 19:59:29 +07:00
Kevin Gibbons
25d2ddd08a More detailed install instructions (#340) 2022-10-24 10:38:52 +07:00
simon
4af12aee15 better indentation formatting 2022-10-23 14:14:30 +07:00
simon
ed341fbc51 add cast auth comment to wiki 2022-10-23 14:11:40 +07:00
simon
f0874b2d02 add timeout for sponsorblock api requests, handle 503 2022-10-23 12:46:10 +07:00
simon
baacd3ee39 better message for running and indexing queue 2022-10-23 12:21:27 +07:00
simon
9cd23c3666 error handeling for playlist_thumbnail extraction 2022-10-23 11:50:26 +07:00
simon
0e17e2a6cf bump TA_VERSION to v0.2.3 2022-10-23 10:55:57 +07:00
simon
2dea0aa57b bump archivist-es 2022-10-23 10:55:14 +07:00
simon
ba1c8c15c4 fix missing build-arg for local deployment 2022-10-23 10:50:16 +07:00
simon
f16915be11 clarify filter only shows when more than 1 2022-10-23 10:49:26 +07:00
simon
858d437f3f simplify local production deployment 2022-10-23 00:11:03 +07:00
simon
de30ac302a add documentation for download channel filter 2022-10-23 00:10:38 +07:00
simon
dd8597307c fix wrong python version in final image 2022-10-22 23:30:44 +07:00
simon
608403c113 Download filter select, #build
Changed:
- added download filter dropdown
- fix for UnidentifiedImageError thumbnail error
- fix for _update_by_query channel missing watched_date
- fix for chrome compatibility text reveal
2022-10-22 22:55:38 +07:00
simon
40eff8e30e fix chrome compatibility issue for description text reveal, #327 2022-10-22 22:32:57 +07:00
simon
0bba36cbc3 add watched_date for _update_by_query mark as watched, #309 2022-10-22 22:00:35 +07:00
simon
a5788117de add dropdown channel agg for download page 2022-10-22 21:23:57 +07:00
simon
3f1075d0b2 bump python version 2022-10-22 20:24:58 +07:00
simon
dea2688b49 handle UnidentifiedImageError in ThumbManager, #325 2022-10-17 19:26:01 +07:00
simon
4f1daeb18c Downloads channel filter, #build
Changed:
- Added downloads channel filter to channel pages
- API: Filter download list view by channel
- Fixed: is_live status check
2022-10-17 19:01:59 +07:00
simon
927e6fa909 create channel parameter for downloads api view 2022-10-17 18:58:21 +07:00
simon
bd7cdb3942 append query parameters to pagination 2022-10-17 18:40:20 +07:00
simon
9dfd967a32 implement downloads filter per channel 2022-10-17 13:29:21 +07:00
simon
6eee762d3a add status for sub refresh 2022-10-14 11:27:27 +07:00
simon
a8c5773f81 fix is_live status check before adding to queue 2022-10-05 16:12:58 +02:00
simon
fbb52dc93f implement basic channel query string for downloads page 2022-10-05 15:47:17 +02:00
simon
c9e936da21 bump libraries 2022-10-05 15:46:44 +02:00
simon
c825e67f69 bump django and restframework 2022-09-30 18:02:44 +02:00
simon
bcb7b9443b bump tubearchivist-es 2022-09-19 15:52:22 +07:00
simon
896d166dcf add minimal system requirements documentation 2022-09-19 15:40:51 +07:00
simon
db6840639a update TA_VERSION to v0.2.2 2022-09-19 15:21:55 +07:00
simon
6af080ab6e remove unstable mention for TA_LDAP_DISABLE_CERT_CHECK 2022-09-19 15:14:22 +07:00
simon
f28c47323f cleanup channel info.json file after import 2022-09-19 15:10:41 +07:00
simon
73cd9a11f8 Consolidate downloads page UI, #build
Changed:
- use video styling from homepage also on download page
- configure grid items on download page
- fix reindex task trying to reindexing inactive videos
- better error handling with yt-dlp, timeout and retry
2022-09-17 11:32:31 +07:00
simon
19fed77218 bump python version 2022-09-17 10:53:43 +07:00
simon
b25818dbc3 extend timeout, add retry to YtWrap 2022-09-17 10:31:51 +07:00
simon
f2a8b070f2 add log output for add to queue 2022-09-17 10:30:27 +07:00
simon
dfacc3039a fix reindex task, skip inactive in _get_unrated_vids 2022-09-14 16:43:48 +07:00
simon
2b8ec1f8cf use grid_items for videos on download page 2022-09-13 16:47:31 +07:00
simon
265a53ace3 manual import fix, #build
Changed:
- fixed manual import errors, #311 #312
- fixed channel extraction to catch all alerts
- add LDAP disable cert check
- downgrade django, fix for DJANGO_DEBUG env var
2022-09-10 11:38:30 +07:00
simon
6d22e62642 downgrade django to fix django_debug env 2022-09-10 11:22:49 +07:00
simon
17c178c8bf add 400 response for wrong TA_HOST env var 2022-09-06 23:27:05 +07:00
simon
301a734905 fix DJANGO_DEBUG set to default empty string 2022-09-06 23:26:17 +07:00
simon
1c3febc49d force create thumb folder for manual import 2022-09-06 23:13:29 +07:00
simon
a6dd927860 fix channel extraction to catch all alerts, #312 2022-09-06 23:12:49 +07:00
simon
3c92de8e24 improved logging for manual import 2022-09-06 22:42:55 +07:00
simon
e51a662da9 fix manual import splitext matching invalid extensions, #311 2022-09-06 22:27:18 +07:00
simon
a7aead728c add roadmap link to FR template 2022-09-06 21:22:30 +07:00
simon
b2bf4ecc02 bump libs 2022-09-06 21:20:29 +07:00
simon
06795a014c explicitly set DJANGO_DEBUG env var on startup 2022-09-06 21:11:18 +07:00
simon
8f2794a141 Merge branch 'master' into testing 2022-09-03 15:17:06 +07:00
DanielBatteryStapler
396b6b80cd Add TA_LDAP_DISABLE_CERT_CHECK option (#314)
* add TA_LDAP_DISABLE_CERT_CHECK option

* add unstable disclaimer next to TA_LDAP_DISABLE_CERT_CHECK
2022-09-03 15:16:09 +07:00
simon
e467beb1c7 fix manual import cleanup metadata, #331 2022-08-26 17:07:55 +07:00
simon
c70942ff90 update old buildx command for reference 2022-08-20 15:26:28 +07:00
simon
f26d02919e revert faulty django update 2022-08-20 14:14:14 +07:00
simon
e65ba08ec6 untested offline import note 2022-08-20 13:42:31 +07:00
simon
09b75211c3 update roadmap 2022-08-20 08:51:29 +07:00
simon
6c019ba112 update installation wiki links and pages 2022-08-20 07:30:19 +07:00
simon
ac79ce91da verbose curl ES connection fail output 2022-08-19 18:51:16 +07:00
simon
20ff465b94 bump TA_VERSION to v0.2.1 2022-08-19 18:28:34 +07:00
simon
7d3a5cc699 add search api to readme 2022-08-19 18:26:27 +07:00
simon
93dc38e829 bump tubearchivist-es to 8.3.3 2022-08-19 18:20:58 +07:00
simon
17ddc95eb9 remove unstable mention from LDAP instructions 2022-08-19 18:20:25 +07:00
simon
1b22e61384 New search API endpoint, #build
Changed:
- Switched search form to API
- Additional validations for RYD
- Additional log output for curl ES startup check
2022-08-19 18:00:35 +07:00
simon
e6b673832f bump yt-dlp 2022-08-19 17:53:18 +07:00
PrivateGER
57a9fff82b Add API endpoint for searching (#303)
* Add API endpoint for searching + use it in frontend

* Fix linting warnings

* Remove multisearch API call

* Avoid 301 and fix up multiline comment
2022-08-19 17:52:37 +07:00
simon
1896e982b9 codespell lint fixes 2022-08-18 18:51:11 +07:00
simon
0e7e0a0560 fail open on ryd error 2022-08-18 18:45:46 +07:00
simon
115db944c8 add curl output for failed es connection 2022-08-18 18:35:09 +07:00
simon
650ac94e68 better wording for es permission error 2022-08-17 18:48:34 +07:00
simon
155439e228 add api Table of contents 2022-08-17 17:29:04 +07:00
simon
25877cf016 Lots of bug fixes, #build
Changed:
- fixed lots of small bugs
- fixed offline import mkv thumb extractor
2022-08-16 16:35:59 +07:00
lamusmaser
15996ea959 Update Installation documentation to include Synology Docker… (#282)
* Update Installation documentation to include Synology Docker instructions.

* Typo fix and adjustments.
2022-08-16 16:34:45 +07:00
simon
1a0421a45a update Manual Media Files Import docs 2022-08-16 16:29:17 +07:00
simon
0210a97b48 archive vtt files for offline import 2022-08-16 15:42:54 +07:00
simon
a4932b163b bump ryd 2022-08-16 15:42:26 +07:00
simon
0b41d288db fix mkv cover stream extractor 2022-08-16 12:19:27 +07:00
simon
21f3ee88b9 extract vtt from mkv and mp4 for import 2022-08-15 15:18:38 +07:00
simon
7727f533ee move subtitle to separate module 2022-08-15 13:55:24 +07:00
simon
41c71fde56 bump yt-dlp 2022-08-15 13:55:02 +07:00
simon
c27e446ec6 Merge branch 'master' into testing 2022-08-14 21:56:11 +07:00
simon
3d1561de75 better wording feature request help 2022-08-14 21:55:23 +07:00
simon
3df6eceb20 add issue templates 2022-08-14 19:43:00 +07:00
simon
81aa27e802 fix video-item.list vertical positioning 2022-08-13 16:20:38 +07:00
simon
bd4710ebdc improved wording for download form, subscription size, #300 2022-08-13 15:43:23 +07:00
simon
8a1ae1ef83 add protocoll to CSRF_TRUSTED_ORIGINS 2022-08-13 14:03:17 +07:00
simon
6afe25a4af fix empty subtitle download and index, #288 2022-08-12 15:53:20 +07:00
simon
cc40f46325 improved ryd error handeling, #283 2022-08-12 14:39:22 +07:00
simon
7c3bfa94f3 handle thumbnail download base exception, #281 2022-08-12 12:58:39 +07:00
simon
25f1539816 implement backup task lock, #278 2022-08-12 12:03:09 +07:00
simon
474ab22792 handle channel name redirect in UrlListParser, #276 2022-08-12 11:53:31 +07:00
simon
257f7a0287 add GH sponsor link 2022-08-12 06:19:36 +07:00
simon
c39ce61b2c offline import, #build
Changed:
- added offline import for videos and channels
- thumbnail manager rewrite
2022-08-12 05:36:54 +07:00
simon
980b99783b add sponsor info 2022-08-12 05:35:47 +07:00
simon
ececc3cedd bump yt-dlp 2022-08-11 19:57:12 +07:00
simon
0891c49620 implement offline channel import 2022-08-11 19:57:00 +07:00
simon
c26ef012c1 raise ValueError when not matching media files 2022-08-11 11:10:58 +07:00
simon
31061c0eb0 handle offline import without info.json fail 2022-08-10 22:38:17 +07:00
simon
994f7c2443 use user provided thumb for offline import 2022-08-10 22:31:15 +07:00
simon
7029441f89 use fallback thumb when no url passed 2022-08-10 22:24:02 +07:00
simon
35f1084cc2 download thumbnail for manual import 2022-08-10 21:47:26 +07:00
simon
44473a364f fix double download of playlist art 2022-08-10 21:20:58 +07:00
simon
fb4d6b7be3 major refactor ThumbManager 2022-08-10 21:03:54 +07:00
simon
b76fa69396 WIP: rewrite ManualImport, identify and archive 2022-08-08 19:18:27 +07:00
simon
8f711d359b implement youtube_meta and media_path overwritein YoutubeVideo 2022-08-08 18:28:32 +07:00
simon
9fd6f9c58b use absolute paths for manual import 2022-08-08 15:57:42 +07:00
simon
b70cbfa0a8 convert thumbnail to jpg for manual import 2022-08-08 14:52:33 +07:00
simon
d0675368f4 bump django 2022-08-08 14:52:06 +07:00
simon
195acd3246 WPI: dump thumbnails from mp4 and mkv 2022-07-31 00:02:09 +07:00
simon
5284975aa2 merge master into testing 2022-07-30 21:38:00 +07:00
simon
13018192f3 remove default funding sources 2022-07-30 21:17:12 +07:00
simon
07f50b6619 LDAP support, #build
Changed:
- configure LDAP over environment variables
- fix reverse proxy CSRF error
2022-07-30 21:13:13 +07:00
simon
36d0f08efb add FUNDING.yml 2022-07-30 21:08:57 +07:00
DanielBatteryStapler
fd75def6c3 implement LDAP as authentication backend support (#274) 2022-07-30 21:08:12 +07:00
simon
61da0c8af3 WIP: extract thumb from mkv 2022-07-30 21:07:31 +07:00
simon
91a169fa3e add FUNDING.yml 2022-07-30 21:07:31 +07:00
DanielBatteryStapler
e1c4702398 implement LDAP as authentication backend support (#274) 2022-07-30 21:05:10 +07:00
Aron Brown
5407d35779 Update settings.py (#284)
Add CSRF_TRUSTED_ORIGINS Django config settings and set it to environment variable TA_HOST.
This should fix login issues behind reverse proxies.
2022-07-30 21:03:58 +07:00
simon
26cc7846c6 WIP: new import folder parser for offline import 2022-07-26 19:51:47 +07:00
simon
36560735f2 better wording what TA_HOST represents, #275 2022-07-23 21:58:06 +07:00
simon
41972f64ad update roadmap 2022-07-23 19:57:25 +07:00
simon
83866a0d79 clarify scope 2022-07-23 17:32:59 +07:00
simon
aa0739bfbe bump TA_VERSION to v0.2.0 2022-07-23 16:35:18 +07:00
simon
d797f47bb8 remove synology link, remove security notification 2022-07-23 15:03:41 +07:00
simon
09f3ce0109 add TA_HOST and ES8 documentation 2022-07-23 14:38:50 +07:00
simon
2ba31a3937 add search help text 2022-07-23 14:10:16 +07:00
simon
31e2fcb322 update backup section for v0.2 2022-07-23 13:41:24 +07:00
simon
e69cd1129c add result size info 2022-07-23 13:41:07 +07:00
simon
8a59b66a0f finetune full text thumb width 2022-07-23 13:28:49 +07:00
simon
dcf7658972 add kibana quick start guide 2022-07-22 18:26:12 +07:00
simon
e79d64d707 update channel wiki page for v0.2 2022-07-22 18:15:27 +07:00
simon
503a4ed822 force es8 at startup 2022-07-22 17:22:06 +07:00
simon
f0a44678ed unset video max-height max-width for fullscreen webkit, #264 2022-07-22 14:50:53 +07:00
simon
4b1ebf44c9 handle filenotfounderror for manual cookie import 2022-07-21 23:01:32 +07:00
simon
eb229440d4 load config into redis on fresh install at startup 2022-07-21 23:01:01 +07:00
simon
88b412a2e9 keyword search, #build
Changed:
- added keyword search queries
- added fulltext search
- authenticate all user created media
- reduce docker log output
2022-07-21 20:15:15 +07:00
simon
14d3a7586d validate es8 at startup 2022-07-21 20:09:06 +07:00
simon
6ec65f9fe8 add search wiki page 2022-07-21 20:00:12 +07:00
simon
0ec6558282 jump to timestamp from full search 2022-07-21 17:15:36 +07:00
simon
52d31f9cca simplify createVideo player link building 2022-07-21 15:51:33 +07:00
simon
07016f2cc5 ignore full: result without highlight 2022-07-21 15:47:29 +07:00
simon
55a73995c8 implement source and lang keywords for full text search 2022-07-21 15:39:27 +07:00
simon
a40ebc7bbc tweak with for video-item.list 2022-07-21 15:36:36 +07:00
simon
e4dd47aed0 implement fulltext search keyword 2022-07-20 21:49:32 +07:00
simon
eb5a7338ab search with and operator, increase result size 2022-07-20 18:25:39 +07:00
simon
b77d50d4ed use queryType in populateMultiSearchResults to hide irrelevant 2022-07-20 18:01:57 +07:00
simon
b520a9bfb0 boost title result scores in multi_match 2022-07-20 16:20:23 +07:00
simon
242ca19586 return query_type from SearchParser 2022-07-20 16:10:57 +07:00
simon
6a6665bdf9 implement simple, aka none keyword query 2022-07-20 15:04:10 +07:00
simon
8e8c00af40 implement playlist keyword search, fix channel subscribed keyword 2022-07-20 14:58:04 +07:00
simon
ae3cf7eb80 add channel keyword search 2022-07-20 14:48:41 +07:00
simon
0a2b6ee90a add keyword search framework, implement video search 2022-07-19 19:23:59 +07:00
simon
d065b5b90a reduce uwsgi logging to errors only 2022-07-18 17:17:20 +07:00
simon
0528220b5e authenticate media and thumb requests 2022-07-18 14:10:34 +07:00
simon
f0850522a7 Merge branch 'master' into testing 2022-07-18 11:49:42 +07:00
simon
cc2fff8bef use info_dict for message, simplify cache template, #271 2022-07-18 11:37:43 +07:00
simon
2d13011866 bump yt-dlp 2022-07-18 11:29:28 +07:00
simon
146f17bf74 shutil.move fix take 2, #build
Changed:
- use shutil.copyfile for copy_function
- fixed typo
2022-07-17 07:48:54 +07:00
simon
b406250ae2 use shutil.copyfile for shutil.move calls 2022-07-17 07:47:27 +07:00
simon
ed9237b69d fix typo 2022-07-17 07:46:52 +07:00
simon
393e00866b Desc preview, nginx fix, #build
Changed:
- Requires new env var: TA_HOST, hostname of your application
- Showing preview for description text, by @p0358
- Fix nginx user permission, by @p0358
- Refactor IndexPaginate to take loop callback
- Fix backup for large index using loop callback
- Fix deactivating missing channels on reindex
2022-07-16 22:11:29 +07:00
p0358
411b09629a Better description view for videos and playlists (#272)
* draft

* proper implementation of description line clamping

* use a JS event that's potentially faster for text expand button visibility update

* remove desc title text, move show button after desc

Co-authored-by: simon <simobilleter@gmail.com>
2022-07-16 22:06:26 +07:00
p0358
79996f6838 Fix permission/mount issues from #268 (#270)
* make nginx run as root within the container (#268)

* move file to videos path with fallback to regular copy with no metadata copying (#268)

* use `copy_function=shutil.copy` everywhere with `shutil.move`
2022-07-16 21:54:39 +07:00
simon
6bb7f80ea2 Refactor IndexPaginate
- add callback function
- implement callback for ElasticBackup
2022-07-16 17:55:18 +07:00
simon
3b17c01c6d add TA_HOST env for ALLOWED_HOSTS 2022-07-14 17:26:10 +07:00
simon
91bccfd057 deactivate non existing channels on reindex 2022-07-12 11:41:03 +07:00
simon
8cca09e0ac remove ta_json backup file, keep only es_ndjson 2022-07-07 12:34:57 +07:00
simon
1479516689 Reimagining the channel pages, #build
Changed:
- Split the channel-id page into subpages
- Channel videos, channel playlist, channel about
- Disable es startup check to test ES8
2022-07-04 21:23:50 +07:00
simon
831e59a626 bump django 2022-07-04 21:23:32 +07:00
simon
6e012383ab toggle playlist subscribed filter 2022-07-04 21:12:54 +07:00
simon
e988192343 add channel_id_about subheading 2022-07-04 20:44:12 +07:00
simon
ef3abd01b1 implement channel_id_playlist, handle channel_id_about post 2022-07-04 20:25:52 +07:00
simon
e664338845 implement basic channel_id_about view 2022-07-04 16:44:37 +07:00
simon
63a1237683 temporarily disable es version check for testing 2022-07-04 16:43:13 +07:00
simon
6d59da4c86 remove redundant latest tags 2022-07-03 18:20:30 +07:00
simon
5bc67104ea set restart policy to unless-stopped 2022-07-03 17:23:02 +07:00
simon
72ec78e011 browser extension as prefered cookie sync method 2022-07-03 16:46:45 +07:00
simon
de8b7d6b43 bump ES version 2022-07-03 16:09:09 +07:00
simon
048409bb1e bump TA_VERSION to v0.1.7 2022-07-03 16:08:30 +07:00
simon
efff4cd96d change cookie documentation for v0.1.7 2022-07-03 16:06:28 +07:00
simon
78d7947a5c a note about subtitle refresh, #260 2022-07-03 15:36:21 +07:00
simon
c132a37f03 bump dependencies 2022-07-03 15:30:46 +07:00
simon
4678a7507b bump dependencies 2022-06-30 18:44:33 +07:00
simon
e8e240c89b Startup fix, cookie improvements, #build
Changed
- lock creating superuser at startup by @dshoreman
- auto validate cookie
- API: return cookie validation state for browser extension
- Finetuning CSS mobile layout
2022-06-27 10:14:59 +07:00
simon
1857ac44ca fix tablet css breakpoints and grid spacing 2022-06-27 09:48:36 +07:00
simon
8c07932071 delete cookie:valid key when revoking 2022-06-23 19:21:13 +07:00
simon
74d74d95a1 store validation result, return in API 2022-06-23 06:48:55 +07:00
simon
b4add20d10 bump yt-dlp 2022-06-22 19:30:38 +07:00
Dave Shoreman
e9619c1242 Fix repeated superuser creations after rename/removal (#263)
* Avoid creating superuser if variables are removed

Enables the removal of `TA_USERNAME` and/or `TA_PASSWORD` as a way of
preventing an additional superuser being created when another exists.

In the old code, `ELASTIC_USER` would never error due to the default, so
this replaces the loop with one requirement on `ELASTIC_PASSWORD`.

* Use .superuser_created to prevent re-creation

Creates an empty `.superuser_created` file after successful creation,
and uses this to skip the command on future runs. If the file doesn't
exist, then `TA_USERNAME` and `TA_PASSWORD` will also be required.

* The superuser password can't be empty

...well—it *can*—but the password input of the login form requires a
value so even though the account is created, you can't actually login.

* Parse createsuperuser output for 'exists' error

If a user already exists with the same name as the superuser being
created, the command fails with "Error: That name is already taken".

It's probably safe to assume that user is the one we expect. This parses
the output, treating it as successful so it can be skipped next time.

If it hits the catch-all, something probably failed so we exit.

* Use the cache for persistence

Updates the .superuser_created file to be placed in /cache so that it
gets persisted in a volume between config tweaks and container rebuilds.

Also makes `TA_PASSWORD` required always, because it seems to be needed
by tubearchivist/config/settings.py to generate a secret key.

* Let it work outside Docker

If /cache doesn't exist (i.e. when it's not running in Docker), this
falls back to a `cache` dir in the current working directory instead.

Also renames the file to 'initsu.lock' rather than having it hidden.

* Use flexible fallback path for `celery` command
2022-06-22 18:29:46 +07:00
simon
49d33a2765 Improved Cookie handeling, #build
Changed:
- Cookie: Use IO Stream from Redis directly for cookies
- Cookie: Don't load invalid cookie
- API: Import cookie with PUT request
- Redis: Don't expire Redis messages by default
- Redis: Connections expire automatically
2022-06-21 08:14:01 +07:00
simon
ecc58f6c11 API: note about release lagging behind 2022-06-21 08:10:00 +07:00
simon
e5f43e5fe9 API: implement cookie PUT request to import cookie 2022-06-21 08:06:38 +07:00
simon
499c47c7a1 move set_cookie to separate method for reusabliity 2022-06-21 08:01:33 +07:00
simon
2cf30e1127 refactor: default set_message in RedisArchivist to True 2022-06-16 10:37:46 +07:00
simon
ebfc4a349f center title and tiles 2022-06-15 18:30:06 +07:00
simon
150c1dc273 add tiles to readme 2022-06-15 18:18:52 +07:00
simon
30abbe9be7 rewrite cookie into redis from io stream, auto validate 2022-06-15 17:54:05 +07:00
simon
40e4ef0e05 auto expire redis connections after 3600secs 2022-06-15 16:36:41 +07:00
simon
2ceb1b701a consolidate config of cookie revoke 2022-06-15 15:39:24 +07:00
simon
2ad093a9a8 handle cookiejar.LoadError dont import invalid cookie 2022-06-15 10:39:48 +07:00
simon
09a94d0df5 set relative values directly with path arg 2022-06-15 10:27:29 +07:00
simon
f7f4ca7728 API update, #build
Changed:
- API: Added Task get view
- Added is_locked method for redis lock check
- Updaded base and libs
2022-06-15 09:24:01 +07:00
simon
83a90000c6 bump base image 2022-06-15 09:23:41 +07:00
lamusmaser
bbb16bb3c2 Update TaskAPIView with GET - #257 PR (#258)
* Initial commit for GET response for TaskAPIView.

* Update for missing space - linting issue.

* Additional linting fixes.

* add is_locked method to check if lock is set

* Update to use `.is_locked` method and add decorator.

* Fix linting issue.

* Fix doubled "rescan" call for locked file.
Removed call to ".owned()" under "is_locked".

* Commenting out GET call in TaskAPI view to revert change for testing.

* Commenting is_locked function to see if Server Response 500 is stopped.

* Reassert is_locked function within RedisArchivist class.

* Create test GET responder.

* Reverting simple GET response change.

* Reapplying simple GET response for `/api/task/`.

* Reapplying change with modification to lock key.

* Documentation update for new GET calls.

* README: fix returned value as `bool` instead of `str`.

* Updating the `is_locked` key reference to "rescan" and "downloading" to fix which key is being retrieved.

* Use dictionary keys, rather than line-by-line statements.

* Fix typo in README for Task View.

Co-authored-by: simon <simobilleter@gmail.com>
2022-06-15 09:15:59 +07:00
simon
4d9ee4494f bump requests 2022-06-14 14:26:49 +07:00
simon
eeeabca8c2 bump dependencies 2022-06-07 17:09:49 +07:00
simon
bb0f3dad1a update roadmap 2022-06-04 11:19:47 +07:00
simon
9c0c263fce new docker build for build server task 2022-06-04 11:16:46 +07:00
simon
aed2d34149 Merge branch 'testing' for release v0.1.6 2022-06-04 10:15:24 +07:00
simon
3504408e70 update TA_VERSION to v0.1.6 2022-06-04 09:47:51 +07:00
simon
41b3e06f73 update docs for v0.1.6 2022-06-01 17:00:06 +07:00
simon
c41f0a7cc4 try to LOAD_TRUNCATED_IMAGES, #256 2022-06-01 09:04:55 +07:00
simon
19d9af821d grid row ui improvements, #build
Changed:
- Better styled grid row, hid controlls if not applicable
- Changed subtitle naming convention
- Timeout for search as you type
- move delete queue button to settings page
2022-05-31 16:40:54 +07:00
simon
5d262d7d48 skip cookie if no config added to YtWrap 2022-05-30 18:22:17 +07:00
simon
231ac1e012 make channel matching optional for reusability 2022-05-30 17:59:52 +07:00
simon
cbb714c235 show grid count controls only if applicable 2022-05-30 16:21:24 +07:00
simon
c93e935b9c move delete download queue button to settings page 2022-05-30 16:06:12 +07:00
simon
c6d696a91b timeout search as you type 2022-05-30 15:29:52 +07:00
simon
a9b8d34b5a adapt search width with grid_items 2022-05-30 14:52:47 +07:00
simon
25e2bdb0b7 bump celery version 2022-05-30 14:51:44 +07:00
simon
bbb0c26498 fix subtitle delete overwrite, change filename to .lang.vtt, #195 2022-05-29 11:43:25 +07:00
simon
386c456415 variable gird row items, #build
Changed:
- implemented configurable video items in grid row
- fix subtitle parser bug with missing segs key
- fix also delete video from playlist
- fix channel video extractor after refactor
2022-05-28 21:54:47 +07:00
simon
51a45bb59e implement variable grid row size for playlist_id 2022-05-28 21:48:15 +07:00
simon
0759b4f220 reset mobile variable gird row count 2022-05-28 21:41:17 +07:00
simon
9f92bf95b4 mark video as downloaded false when deleting video 2022-05-28 21:23:56 +07:00
simon
e638f8b487 skip subtitle segment without segs key, #249 2022-05-28 17:47:21 +07:00
simon
caaed252f9 specify videos page for get_last_youtube_videos of channel 2022-05-28 14:34:01 +07:00
simon
5982932457 implement variable grid row size 2022-05-28 13:57:29 +07:00
simon
9a31243b1c log message when skipping video 2022-05-28 11:11:09 +07:00
simon
88e526f5e5 refactor new YtWrap class for yt-dlp calls, #build
Changed:
- new yt-dlp base class
- new cookie class using io_stream
- MetadataFromField postprocessors
2022-05-25 16:15:38 +07:00
Clark
730ac15349 Fix #241 Use title/uploader for embedded metadata not Content ID (#252) 2022-05-25 16:13:25 +07:00
simon
0ac9243389 remove unused monitor_cache_dir method 2022-05-24 15:54:41 +07:00
simon
c39f8a9cb8 refactor use cookie io_stream 2022-05-24 15:51:58 +07:00
simon
9dd1e9a29f extract playlists with new YtBase class 2022-05-24 10:44:18 +07:00
simon
3f5104d6d8 integrate new YtWrap class in download queue and subscriptions classes 2022-05-24 10:36:39 +07:00
simon
4b00f454b7 integrate new YtWrap class in yt_dlp handler 2022-05-24 10:19:02 +07:00
simon
33f27f018f handle download error 2022-05-24 10:18:33 +07:00
simon
a7d9b4584b implement new YtBase class for index classes 2022-05-24 10:01:30 +07:00
simon
4e699621a0 obs base and handle extract error 2022-05-24 10:00:40 +07:00
Heavybullets8
2dd51c38a7 Add Truenas Scale Instructions (#251)
included two <br /> to add spacing between the guides. 
If the website results in an ssl error at the time of submitting its because the new site is still in the process of being handed over to a different registrar, and its still getting its certs from github. Should be fixed by tomorrow.
2022-05-24 08:53:34 +07:00
simon
37e6f8656a yt-dlp base class 2022-05-24 08:53:05 +07:00
simon
1fd9981273 test new build server, #build
Changed:
- updated libraries
- trigger unstable build on new buildserver
2022-05-20 20:27:30 +07:00
simon
3a2a7dac40 update dev environment details 2022-05-20 20:26:31 +07:00
simon
ef69ab2799 test new build server, #build
Changed:
- updated libraries
- trigger unstable build on new buildserver
2022-05-20 20:10:18 +07:00
simon
f49e313e2f add dl speed schedule to roadmap 2022-05-20 20:09:06 +07:00
simon
9fe6d41320 update libraries 2022-05-20 20:05:31 +07:00
simon
c0ccc267c8 remove drone 2022-05-20 19:37:42 +07:00
simon
1aef0451b7 Merge branch 'master' into testing 2022-05-20 18:31:15 +07:00
simon
e8763b87cc fix manifest docker_token 2022-05-13 11:53:34 +07:00
simon
03527b4ace add manifest pipeline 2022-05-12 21:50:37 +07:00
simon
526d75af4f add arm64 build 2022-05-12 16:33:18 +07:00
simon
7e515b52e1 add TARGETPLATFORM build_args 2022-05-12 00:20:57 +07:00
simon
3679d28ffe use docker_token instead 2022-05-11 23:39:34 +07:00
simon
ced2452d6e image_pull_secrets with new creds 2022-05-11 22:42:30 +07:00
simon
b301a89ce1 change docker login 2022-05-11 22:29:04 +07:00
simon
0749cdd8f3 add TARGETPLATFORM env var 2022-05-11 21:59:15 +07:00
simon
da251b28a4 remove dry_run 2022-05-11 21:42:16 +07:00
simon
5bccbfbd84 explicitly set platform 2022-05-11 21:37:03 +07:00
simon
1f0ef10d35 test build to other account 2022-05-11 21:26:24 +07:00
simon
ab6689c7c8 test dockerconfigjson 2022-05-11 21:17:37 +07:00
simon
ddc3ea5e72 test drone pipeline 2022-05-11 19:15:47 +07:00
simon
669f1ffe2c dest drone pipeline 2022-05-11 18:44:57 +07:00
simon
03179eea94 bump redis 2022-05-10 16:32:18 +07:00
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
ef803a157a standard api key processor for list and single views 2022-04-16 00:37:56 +07:00
92 changed files with 8858 additions and 2740 deletions

17
.eslintrc.js Normal file
View File

@@ -0,0 +1,17 @@
'use strict';
module.exports = {
extends: ['eslint:recommended', 'eslint-config-prettier'],
parserOptions: {
ecmaVersion: 2020,
},
env: {
browser: true,
},
rules: {
strict: ['error', 'global'],
'no-unused-vars': ['error', { vars: 'local' }],
eqeqeq: ['error', 'always', { null: 'ignore' }],
curly: ['error', 'multi-line'],
'no-var': 'error',
},
};

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
github: bbilly1
ko_fi: bbilly1
custom: https://paypal.me/bbilly1

55
.github/ISSUE_TEMPLATE/BUG-REPORT.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Bug Report
description: File a bug report
title: "[Bug]: "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to help improve this project!
- type: checkboxes
id: latest
attributes:
label: "I've read the documentation"
options:
- label: I'm running the latest version of Tube Archivist and have read the [release notes](https://github.com/tubearchivist/tubearchivist/releases/latest).
required: true
- label: I have read through the [wiki](https://github.com/tubearchivist/tubearchivist/wiki) and the [readme](https://github.com/tubearchivist/tubearchivist#installing-and-updating), particularly the [common errors](https://github.com/tubearchivist/tubearchivist#common-errors) section.
required: true
- type: input
id: os
attributes:
label: Operating System
description: On what operating system are you running Tube Archivist on?
placeholder: ex. Linux, Unraid, Synology...
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: Your Bug Report
value: "## Describe the bug\n\n## Steps To Reproduce\n\n## Expected behavior"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant Docker logs. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@@ -0,0 +1,37 @@
name: Feature Request
description: Create a new feature request
title: "[Feature Request]: "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to help improve this project!
- type: checkboxes
id: already
attributes:
label: Already implemented?
options:
- label: I have read through the [wiki](https://github.com/tubearchivist/tubearchivist/wiki).
required: true
- label: I understand the [scope](https://github.com/tubearchivist/tubearchivist/wiki/FAQ) of this project and am aware of the [known limitations](https://github.com/tubearchivist/tubearchivist#known-limitations) and my idea is not already on the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap).
required: true
- type: textarea
id: description
attributes:
label: Your Feature Request
value: "## Is your feature request related to a problem? Please describe.\n\n## Describe the solution you'd like\n\n## Additional context"
placeholder: Tell us what you see!
validations:
required: true
- type: checkboxes
id: help
attributes:
label: Your help is needed!
description: This project is ambitious as it is, please contribute.
options:
- label: Yes I can help with this feature request!
required: false

View File

@@ -0,0 +1,45 @@
name: Installation Help
description: There is an issue with installing or configuring Tube Archivist
title: "[Installation]: "
body:
- type: markdown
attributes:
value: |
This is most likely not the best place to ask for installation help. There is a helpful community on [Discord](https://github.com/tubearchivist/tubearchivist#connect).
- type: checkboxes
id: instructions
attributes:
label: Installation instructions
options:
- label: I have read and understand the [installation instructions](https://github.com/tubearchivist/tubearchivist#installing-and-updating).
required: true
- label: My issue is not described in the [common errors](https://github.com/tubearchivist/tubearchivist#common-errors) section.
required: true
- type: input
id: os
attributes:
label: Operating System
description: On what operating system do you want to install Tube Archivist on?
placeholder: ex. Linux, Unraid, Synology...
validations:
required: true
- type: textarea
id: description
attributes:
label: Your installation or setup question
placeholder: Describe your issue
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant Docker logs. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true

16
.github/workflows/lint_js.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: lint_js
on: [pull_request, push]
jobs:
check:
name: lint_js
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: '16'
- run: npm ci
- run: npm run lint
- run: npm run format -- --check

View File

@@ -4,16 +4,21 @@ jobs:
lint_python: lint_python:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-python@v2 - uses: actions/setup-python@v4
with:
python-version: '3.10'
# note: this logic is duplicated in the `validate` function in ./deploy.sh
# if you update this file, you should update that as well
- run: pip install --upgrade pip wheel - run: pip install --upgrade pip wheel
- run: pip install bandit black codespell flake8 flake8-bugbear - run: pip install bandit black codespell flake8 flake8-bugbear
flake8-comprehensions isort flake8-comprehensions isort requests
- run: black --check --diff --line-length 79 . - run: ./deploy.sh validate
- run: codespell # - run: black --check --diff --line-length 79 .
- run: flake8 . --count --max-complexity=10 --max-line-length=79 # - run: codespell --skip="./.git,./package.json,./package-lock.json,./node_modules"
--show-source --statistics # - run: flake8 . --count --max-complexity=10 --max-line-length=79
- run: isort --check-only --line-length 79 --profile black . # --show-source --statistics
# - run: isort --check-only --line-length 79 --profile black .
# - run: pip install -r tubearchivist/requirements.txt # - run: pip install -r tubearchivist/requirements.txt
# - run: mkdir --parents --verbose .mypy_cache # - run: mkdir --parents --verbose .mypy_cache
# - run: mypy --ignore-missing-imports --install-types --non-interactive . # - run: mypy --ignore-missing-imports --install-types --non-interactive .

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@ db.sqlite3
# vscode custom conf # vscode custom conf
.vscode .vscode
# JavaScript stuff
node_modules

View File

@@ -11,37 +11,75 @@ If the issue doesn't move forward due to a lack of response, I assume it's solve
## Wiki ## Wiki
The wiki is where all user functions are explained in detail. These pages are mirrored into the **docs** folder of the repo. This allows for pull requests and all other features like regular code. Make any changes there, and I'll sync them with the wiki tab. The wiki is where all user functions are documented in detail. These pages are mirrored into the **docs** folder of the repo. This allows for pull requests and all other features like regular code. Make any changes there, and I'll sync them with the wiki tab.
## Development Environment ## Development Environment
I have learned the hard way, that working on a dockerized application outside of docker is very error prone and in general not a good idea. So if you want to test your changes, it's best to run them in a docker testing environment. I have learned the hard way, that working on a dockerized application outside of docker is very error prone and in general not a good idea. So if you want to test your changes, it's best to run them in a docker testing environment. You might be able to run the application directly, but this document assumes you're using docker.
### Instructions
Set up docker on your development machine.
Clone this repository.
Functional changes should be made against the unstable `testing` branch, so check that branch out, then make a new branch for your work.
Edit the `docker-compose.yml` file and replace the [`image: bbilly1/tubearchivist` line](https://github.com/tubearchivist/tubearchivist/blob/4af12aee15620e330adf3624c984c3acf6d0ac8b/docker-compose.yml#L7) with `build: .`. Also make any other changes to the environment variables and so on necessary to run the application, just like you're launching the application as normal.
Run `docker compose up --build`. This will bring up the application. Kill it with `ctrl-c` or by running `docker compose down` from a new terminal window in the same directory.
Make your changes locally and re-run `docker compose up --build`. 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.
### Develop environment inside a VM
You may find it nice to run everything inside of a VM, though this is not necessary. There's a `deploy.sh` script which has some helpers for this use case. YMMV, this is what one of the developers does:
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 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. - 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 call the docker build command with `--build-arg INSTALL_DEBUG=1` to install additional useful debug tools. - 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` script 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. **Quick start**
Generate your access token in Elasitcsearch:
```bash
bin/elasticsearch-service-tokens create elastic/kibana kibana
```
Example docker compose, use same version as for Elasticsearch:
```yml
kibana:
image: docker.elastic.co/kibana/kibana:0.0.0
container_name: kibana
environment:
- "ELASTICSEARCH_HOSTS=http://archivist-es:9200"
- "ELASTICSEARCH_SERVICEACCOUNTTOKEN=<your-token-here>"
ports:
- "5601:5601"
```
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
To fix a bug or implement a feature, fork the repository and make all changes to the testing branch. When ready, create a pull request. To fix a bug or implement a feature, fork the repository and make all changes to the testing branch. When ready, create a pull request.
## Making changes to the JavaScript
The JavaScript does not require any build step; you just edit the files directly. However, there is config for eslint and prettier (a linter and formatter respectively); their use is recommended but not required. To use them, install `node`, run `npm i` from the root directory of this repository to install dependencies, then run `npm run lint` and `npm run format` to run eslint and prettier respectively.
## Releases ## Releases
There are three different docker tags: There are three different docker tags:
@@ -53,10 +91,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

@@ -3,32 +3,18 @@
# First stage to build python wheel # First stage to build python wheel
FROM python:3.10.4-slim-bullseye AS builder FROM python:3.10.8-slim-bullseye AS builder
ARG TARGETPLATFORM ARG TARGETPLATFORM
RUN apt-get update RUN apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get install -y --no-install-recommends build-essential gcc curl build-essential gcc libldap2-dev libsasl2-dev libssl-dev
# get newest patched ffmpeg and ffprobe builds for amd64 fall back to repo ffmpeg for arm64
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ] ; then \
curl -s https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest \
| grep browser_download_url \
| grep ".*master.*linux64.*tar.xz" \
| cut -d '"' -f 4 \
| xargs curl -L --output ffmpeg.tar.xz && \
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe" && \
rm ffmpeg.tar.xz \
; elif [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \
apt-get -y update && apt-get -y install --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* \
; fi
# install requirements # install requirements
COPY ./tubearchivist/requirements.txt /requirements.txt COPY ./tubearchivist/requirements.txt /requirements.txt
RUN pip install --user -r requirements.txt RUN pip install --user -r requirements.txt
# build final image # build final image
FROM python:3.10.4-slim-bullseye as tubearchivist FROM python:3.10.8-slim-bullseye as tubearchivist
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG INSTALL_DEBUG ARG INSTALL_DEBUG
@@ -37,15 +23,32 @@ ENV PYTHONUNBUFFERED 1
# copy build requirements # copy build requirements
COPY --from=builder /root/.local /root/.local COPY --from=builder /root/.local /root/.local
COPY --from=builder /usr/bin/ffmpeg /usr/bin/ffmpeg
COPY --from=builder /usr/bin/ffprobe /usr/bin/ffprobe
ENV PATH=/root/.local/bin:$PATH 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 \
nginx \ nginx \
atomicparsley \ atomicparsley \
curl && rm -rf /var/lib/apt/lists/* curl \
xz-utils && rm -rf /var/lib/apt/lists/*
# install patched ffmpeg build, default to linux64
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \
curl -s https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest \
| grep browser_download_url \
| grep ".*master.*linuxarm64.*tar.xz" \
| cut -d '"' -f 4 \
| xargs curl -L --output ffmpeg.tar.xz ; \
else \
curl -s https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest \
| grep browser_download_url \
| grep ".*master.*linux64.*tar.xz" \
| cut -d '"' -f 4 \
| xargs curl -L --output ffmpeg.tar.xz ; \
fi && \
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe" && \
rm ffmpeg.tar.xz
# install debug tools for testing environment # install debug tools for testing environment
RUN if [ "$INSTALL_DEBUG" ] ; then \ RUN if [ "$INSTALL_DEBUG" ] ; then \
@@ -55,12 +58,11 @@ RUN if [ "$INSTALL_DEBUG" ] ; then \
; fi ; fi
# make folders # make folders
RUN mkdir /cache RUN mkdir /cache /youtube /app
RUN mkdir /youtube
RUN mkdir /app
# copy config files # copy config files
COPY docker_assets/nginx.conf /etc/nginx/sites-available/default COPY docker_assets/nginx.conf /etc/nginx/sites-available/default
RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
# copy application into container # copy application into container
COPY ./tubearchivist /app COPY ./tubearchivist /app

114
README.md
View File

@@ -1,18 +1,22 @@
![Tube Archivist](assets/tube-archivist-banner.jpg?raw=true "Tube Archivist Banner") ![Tube Archivist](assets/tube-archivist-banner.jpg?raw=true "Tube Archivist Banner")
<center><h1>Your self hosted YouTube media server</h1></center> <h1 align="center">Your self hosted YouTube media server</h1>
<div align="center">
Tube Archivist has a new home: https://github.com/tubearchivist/tubearchivist <a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-docker.png" alt="tubearchivist-docker" title="Tube Archivist Docker Pulls" height="50" width="200"/></a>
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-star.png" alt="tubearchivist-github-star" title="Tube Archivist GitHub Stars" height="50" width="200"/></a>
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-forks.png" alt="tubearchivist-github-forks" title="Tube Archivist GitHub Forks" height="50" width="200"/></a>
</div>
## Table of contents: ## Table of contents:
* [Wiki](https://github.com/tubearchivist/tubearchivist/wiki) for a detailed documentation, with [FAQ](https://github.com/tubearchivist/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) * [Common Errors](#common-errors)
* [Roadmap](#roadmap) * [Roadmap](#roadmap)
* [Known limitations](#known-limitations) * [Known limitations](#known-limitations)
* [Donate](#donate) * [Donate](#donate)
@@ -52,8 +56,43 @@ Once your YouTube video collection grows, it becomes hard to search and find a s
- [Discord](https://discord.gg/AFwz8nE7BK): Connect with us on our Discord server. - [Discord](https://discord.gg/AFwz8nE7BK): Connect with us on our Discord server.
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our 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). There's dedicated user-contributed install steps under [docs/Installation.md](./docs/Installation.md) for podman, Unraid, Truenas and Synology which you can use instead of this section if you happen to be using one of those. Otherwise, continue on.
For minimal system requirements, the Tube Archivist stack needs around 2GB of available memory for a small testing setup and around 4GB of available memory for a mid to large sized installation.
Note for arm64 hosts: The Tube Archivist container is multi arch, so is Elasticsearch. RedisJSON doesn't offer arm builds, but you can use the image `bbilly1/rejson`, an unofficial rebuild for arm64.
This project requires docker. Ensure it is installed and running on your system.
Save the [docker-compose.yml](./docker-compose.yml) file from this reposity somewhere permanent on your system, keeping it named `docker-compose.yml`. You'll need to refer to it whenever starting this application.
Edit the following values from that file:
- under `tubearchivist`->`environment`:
- `HOST_UID`: your UID, if you want TubeArchivist to create files with your UID. Remove if you are OK with files being owned by the the container user.
- `HOST_GID`: as above but GID.
- `TA_HOST`: change it to the address of the machine you're running this on. This can be an IP address or a domain name.
- `TA_PASSWORD`: pick a password to use when logging in.
- `ELASTIC_PASSWORD`: pick a password for the elastic service. You won't need to type this yourself.
- `TZ`: your time zone. If you don't know yours, you can look it up [here](https://www.timezoneconverter.com/cgi-bin/findzone/findzone).
- under `archivist-es`->`environment`:
- `"ELASTIC_PASSWORD=verysecret"`: change `verysecret` to match the ELASTIC_PASSWORD you picked above.
By default Docker will store all data, including downloaded data, in its own data-root directory (which you can find by running `docker info` and looking for the "Docker Root Dir"). If you want to use other locations, you can replace the `media:`, `cache:`, `redis:`, and `es:` volume names with absolute paths; if you do, remove them from the `volumes:` list at the bottom of the file.
From a terminal, `cd` into the directory you saved the `docker-compose.yml` file in and run `docker compose up --detach`. The first time you do this it will download the appropriate images, which can take a minute.
You can follow the logs with `docker compose logs -f`. Once it's ready it will print something like `celery@1234567890ab ready`. At this point you should be able to go to `http://your-host:8000` and log in with the `TA_USER`/`TA_PASSWORD` credentials.
You can bring the application down by running `docker compose down` in the same directory.
Use the *latest* (the default) or a named semantic version tag for the docker images. 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).
## Installation Details
Tube Archivist depends on three main components split up into separate docker containers: Tube Archivist depends on three main components split up into separate docker containers:
@@ -64,6 +103,7 @@ The main Python application that displays and serves your video collection, buil
- And another volume to save application data at **/cache**. - And another volume to save application data at **/cache**.
- The environment variables `ES_URL` and `REDIS_HOST` are needed to tell Tube Archivist where Elasticsearch and Redis respectively are located. - The environment variables `ES_URL` and `REDIS_HOST` are needed to tell Tube Archivist where Elasticsearch and Redis respectively are located.
- The environment variables `HOST_UID` and `HOST_GID` allows Tube Archivist to `chown` the video files to the main host system user instead of the container user. Those two variables are optional, not setting them will disable that functionality. That might be needed if the underlying filesystem doesn't support `chown` like *NFS*. - The environment variables `HOST_UID` and `HOST_GID` allows Tube Archivist to `chown` the video files to the main host system user instead of the container user. Those two variables are optional, not setting them will disable that functionality. That might be needed if the underlying filesystem doesn't support `chown` like *NFS*.
- Set the environment variable `TA_HOST` to match with the system running Tube Archivist. This can be a domain like *example.com*, a subdomain like *ta.example.com* or an IP address like *192.168.1.20*, add without the protocol and without the port. You can add multiple hostnames separated with a space. Any wrong configurations here will result in a `Bad Request (400)` response.
- Change the environment variables `TA_USERNAME` and `TA_PASSWORD` to create the initial credentials. - Change the environment variables `TA_USERNAME` and `TA_PASSWORD` to create the initial credentials.
- `ELASTIC_PASSWORD` is for the password for Elasticsearch. The environment variable `ELASTIC_USER` is optional, should you want to change the username from the default *elastic*. - `ELASTIC_PASSWORD` is for the password for Elasticsearch. The environment variable `ELASTIC_USER` is optional, should you want to change the username from the default *elastic*.
- For the scheduler to know what time it is, set your timezone with the `TZ` environment variable, defaults to *UTC*. - For the scheduler to know what time it is, set your timezone with the `TZ` environment variable, defaults to *UTC*.
@@ -77,8 +117,25 @@ 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.
### LDAP Authentication
You can configure LDAP with the following environment variables:
- `TA_LDAP` (ex: `true`) Set to anything besides empty string to use LDAP authentication **instead** of local user authentication.
- `TA_LDAP_SERVER_URI` (ex: `ldap://ldap-server:389`) Set to the uri of your LDAP server.
- `TA_LDAP_DISABLE_CERT_CHECK` (ex: `true`) Set to anything besides empty string to disable certificate checking when connecting over LDAPS.
- `TA_LDAP_BIND_DN` (ex: `uid=search-user,ou=users,dc=your-server`) DN of the user that is able to perform searches on your LDAP account.
- `TA_LDAP_BIND_PASSWORD` (ex: `yoursecretpassword`) Password for the search user.
- `TA_LDAP_USER_ATTR_MAP_USERNAME` (default: `uid`) Bind attribute used to map LDAP user's username
- `TA_LDAP_USER_ATTR_MAP_PERSONALNAME` (default: `givenName`) Bind attribute used to match LDAP user's First Name/Personal Name.
- `TA_LDAP_USER_ATTR_MAP_SURNAME` (default: `sn`) Bind attribute used to match LDAP user's Last Name/Surname.
- `TA_LDAP_USER_ATTR_MAP_EMAIL` (default: `mail`) Bind attribute used to match LDAP user's EMail address
- `TA_LDAP_USER_BASE` (ex: `ou=users,dc=your-server`) Search base for user filter.
- `TA_LDAP_USER_FILTER` (ex: `(objectClass=user)`) Filter for valid users. Login usernames are matched using the attribute specified in `TA_LDAP_USER_ATTR_MAP_USERNAME` and should not be specified in this filter.
When LDAP authentication is enabled, django passwords (e.g. the password defined in TA_PASSWORD), will not allow you to login, only the LDAP server is used.
### Elasticsearch ### Elasticsearch
**Note**: Newest Tube Archivist depends on Elasticsearch version 7.17 to provide an automatic updatepath in the future. **Note**: Tube Archivist depends on Elasticsearch 8.
Use `bbilly1/tubearchivist-es` to automatically get the recommended version, or use the official image with the version tag in the docker-compose file. Use `bbilly1/tubearchivist-es` to automatically get the recommended version, or use the official image with the version tag in the docker-compose file.
@@ -104,14 +161,11 @@ You will see the current version number of **Tube Archivist** in the footer of t
* 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. Use `bbilly1/tubearchivist-es` to automatically get the recommended version. * 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: ### Helm charts
- **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/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").
- **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.
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.
## Potential pitfalls ## Common Errors
### vm.max_map_count ### vm.max_map_count
**Elastic Search** in Docker requires the kernel setting of the host machine `vm.max_map_count` to be set to at least 262144. **Elastic Search** in Docker requires the kernel setting of the host machine `vm.max_map_count` to be set to at least 262144.
@@ -126,20 +180,22 @@ To apply the change permanently depends on your host operating system:
- On any other platform look up in the documentation on how to pass kernel parameters. - On any other platform look up in the documentation on how to pass kernel parameters.
### Permissions for elasticsearch ### Permissions for elasticsearch
If you see a message similar to `AccessDeniedException[/usr/share/elasticsearch/data/nodes]` when initially starting elasticsearch, that means the container is not allowed to write files to the volume. If you see a message similar to `failed to obtain node locks, tried [/usr/share/elasticsearch/data]` and `maybe these locations are not writable` when initially starting elasticsearch, that probably means the container is not allowed to write files to the volume.
That's most likely the case when you run `docker-compose` as an unprivileged user. To fix that issue, shutdown the container and on your host machine run: To fix that issue, shutdown the container and on your host machine run:
``` ```
chown 1000:0 /path/to/mount/point chown 1000:0 -R /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 process 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:
``` ```
bestvideo[VCODEC=avc1]+bestaudio[ACODEC=mp4a]/mp4 bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
``` ```
2. Subscribe to some of your favorite YouTube channels on the **channels** page. 2. Subscribe to some of your favorite YouTube channels on the **channels** page.
3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists. 3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists.
@@ -153,21 +209,22 @@ We have come far, nonetheless we are not short of ideas on how to improve and ex
- [ ] 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 ([#97](https://github.com/tubearchivist/tubearchivist/issues/97)) - [ ] Implement [Apprise](https://github.com/caronc/apprise) for notifications ([#97](https://github.com/tubearchivist/tubearchivist/issues/97))
- [ ] Add passing browser cookies to yt-dlp ([#199](https://github.com/tubearchivist/tubearchivist/issues/199))
- [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220)) - [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
- [ ] Auto play or play next link ([#226](https://github.com/tubearchivist/tubearchivist/issues/226)) - [ ] Auto play or play next link ([#226](https://github.com/tubearchivist/tubearchivist/issues/226))
- [ ] 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 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)) - [ ] Download speed schedule ([#198](https://github.com/tubearchivist/tubearchivist/issues/198))
- [ ] 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)) - [ ] 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)) - [ ] Custom searchable notes to videos, channels, playlists ([#144](https://github.com/tubearchivist/tubearchivist/issues/144))
- [ ] Download video comments - [ ] Download video comments
Implemented: Implemented:
- [X] Implement complete offline media file import from json file [2022-08-20]
- [X] Filter and query in search form, search by url query [2022-07-23]
- [X] Make items in grid row configurable to use more of the screen [2022-06-04]
- [X] Add passing browser cookies to yt-dlp [2022-05-08]
- [X] Add [SponsorBlock](https://sponsor.ajay.app/) integration [2022-04-16] - [X] Add [SponsorBlock](https://sponsor.ajay.app/) integration [2022-04-16]
- [X] Implement per channel settings [2022-03-26] - [X] Implement per channel settings [2022-03-26]
- [X] Subtitle download & indexing [2022-02-13] - [X] Subtitle download & indexing [2022-02-13]
@@ -188,13 +245,22 @@ Implemented:
## Known limitations ## Known limitations
- Video files created by Tube Archivist need to be playable in your browser of choice. Not every codec is compatible with every browser and might require some testing with format selection. - Video files created by Tube Archivist need to be playable in your browser of choice. Not every codec is compatible with every browser and might require some testing with format selection.
- Every limitation of **yt-dlp** will also be present in Tube Archivist. If **yt-dlp** can't download or extract a video for any reason, Tube Archivist won't be able to either. - Every limitation of **yt-dlp** will also be present in Tube Archivist. If **yt-dlp** can't download or extract a video for any reason, Tube Archivist won't be able to either.
- For now this is meant to be run in a trusted network environment. Not everything is properly authenticated.
- There is currently no flexibility in naming of the media files. - There is currently no flexibility in naming of the media files.
## Donate ## Donate
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started. The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
Second best way to support the development is to provide for caffeinated beverages: Second best way to support the development is to provide for caffeinated beverages:
* [GitHub Sponsor](https://github.com/sponsors/bbilly1) become a sponsor here on GitHub
* [Paypal.me](https://paypal.me/bbilly1) for a one time coffee * [Paypal.me](https://paypal.me/bbilly1) for a one time coffee
* [Paypal Subscription](https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-03770005GR991451KMFGVPMQ) for a monthly coffee * [Paypal Subscription](https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-03770005GR991451KMFGVPMQ) for a monthly coffee
* [ko-fi.com](https://ko-fi.com/bbilly1) for an alternative platform * [ko-fi.com](https://ko-fi.com/bbilly1) for an alternative platform
## Sponsor
Big thank you to [Digitalocean](https://www.digitalocean.com/) for generously donating credit for the tubearchivist.com VPS and buildserver.
<p>
<a href="https://www.digitalocean.com/">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px">
</a>
</p>

View File

@@ -18,12 +18,8 @@ set -e
function sync_blackhole { function sync_blackhole {
# docker commands need sudo, only build amd64
host="blackhole.local" host="blackhole.local"
read -sp 'Password: ' remote_pw
export PASS=$remote_pw
rsync -a --progress --delete-after \ rsync -a --progress --delete-after \
--exclude ".git" \ --exclude ".git" \
--exclude ".gitignore" \ --exclude ".gitignore" \
@@ -32,8 +28,8 @@ function sync_blackhole {
--exclude "db.sqlite3" \ --exclude "db.sqlite3" \
. -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' ssh "$host" 'docker build -t bbilly1/tubearchivist --build-arg TARGETPLATFORM="linux/amd64" tubearchivist'
echo "$PASS" | ssh "$host" 'sudo -S docker-compose up -d 2>/dev/null' ssh "$host" 'docker compose up -d'
} }
@@ -69,7 +65,7 @@ function sync_test {
fi fi
ssh "$host" "docker buildx build --build-arg INSTALL_DEBUG=1 --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'
} }
@@ -86,10 +82,12 @@ function validate {
echo "run validate on $check_path" echo "run validate on $check_path"
# note: this logic is duplicated in the `./github/workflows/lint_python.yml` config
# if you update this file, you should update that as well
echo "running black" echo "running black"
black --diff --color --check -l 79 "$check_path" black --diff --color --check -l 79 "$check_path"
echo "running codespell" echo "running codespell"
codespell --skip="./.git" "$check_path" codespell --skip="./.git,./package.json,./package-lock.json,./node_modules" "$check_path"
echo "running flake8" echo "running flake8"
flake8 "$check_path" --count --max-complexity=10 --max-line-length=79 \ flake8 "$check_path" --count --max-complexity=10 --max-line-length=79 \
--show-source --statistics --show-source --statistics
@@ -144,6 +142,7 @@ function sync_unstable {
} }
# new function, sync only tag, build with build server
function sync_docker { function sync_docker {
# check things # check things
@@ -152,13 +151,40 @@ function sync_docker {
return return
fi fi
echo "latest tags:"
git tag | tail -n 5 | sort -r
printf "\ncreate new version:\n"
read -r VERSION
echo "push new tag: $VERSION?"
read -rn 1
# create release tag
echo "commits since last version:"
git log "$(git describe --tags --abbrev=0)"..HEAD --oneline
git tag -a "$VERSION" -m "new release version $VERSION"
git push origin "$VERSION"
}
# old builder, sync tag, build and push locally
function sync_docker_old {
# check things
if [[ $(git branch --show-current) != 'master' ]]; then
echo 'you are not on master, dummy!'
return
fi
if [[ $(systemctl is-active docker) != 'active' ]]; then if [[ $(systemctl is-active docker) != 'active' ]]; then
echo "starting docker" echo "starting docker"
sudo systemctl start docker sudo systemctl start docker
fi fi
echo "latest tags:" echo "latest tags:"
git tag | tail -n 10 git tag | tail -n 5 | sort -r
printf "\ncreate new version:\n" printf "\ncreate new version:\n"
read -r VERSION read -r VERSION
@@ -169,14 +195,15 @@ function sync_docker {
# start build # start build
sudo docker buildx build \ sudo docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
-t bbilly1/tubearchivist:latest \ -t bbilly1/tubearchivist \
-t bbilly1/tubearchivist:unstable \
-t bbilly1/tubearchivist:"$VERSION" --push . -t bbilly1/tubearchivist:"$VERSION" --push .
# create release tag # create release tag
echo "commits since last version:" echo "commits since last version:"
git log "$(git describe --tags --abbrev=0)"..HEAD --oneline git log "$(git describe --tags --abbrev=0)"..HEAD --oneline
git tag -a "$VERSION" -m "new release version $VERSION" git tag -a "$VERSION" -m "new release version $VERSION"
git push all "$VERSION" git push origin "$VERSION"
} }
@@ -186,18 +213,17 @@ if [[ $1 == "blackhole" ]]; then
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 == "versioncheck" ]]; then
python version_check.py
elif [[ $1 == "docker" ]]; then elif [[ $1 == "docker" ]]; then
sync_docker sync_docker
sync_unstable
elif [[ $1 == "unstable" ]]; then elif [[ $1 == "unstable" ]]; then
sync_unstable sync_unstable
elif [[ $1 == "es" ]]; then elif [[ $1 == "es" ]]; then
sync_latest_es sync_latest_es
else else
echo "valid options are: blackhole | test | validate | docker | unstable | es" echo "valid options are: blackhole | test | validate | versioncheck | docker | unstable | es"
fi fi

View File

@@ -3,8 +3,8 @@ version: '3.3'
services: services:
tubearchivist: tubearchivist:
container_name: tubearchivist container_name: tubearchivist
restart: always restart: unless-stopped
image: bbilly1/tubearchivist:latest image: bbilly1/tubearchivist
ports: ports:
- 8000:8000 - 8000:8000
volumes: volumes:
@@ -15,6 +15,7 @@ services:
- REDIS_HOST=archivist-redis # don't add protocol - REDIS_HOST=archivist-redis # don't add protocol
- HOST_UID=1000 - HOST_UID=1000
- HOST_GID=1000 - HOST_GID=1000
- TA_HOST=tubearchivist.local # set your host name
- TA_USERNAME=tubearchivist # your initial TA credentials - TA_USERNAME=tubearchivist # your initial TA credentials
- TA_PASSWORD=verysecret # your initial TA credentials - TA_PASSWORD=verysecret # your initial TA credentials
- ELASTIC_PASSWORD=verysecret # set password for Elasticsearch - ELASTIC_PASSWORD=verysecret # set password for Elasticsearch
@@ -23,9 +24,9 @@ services:
- archivist-es - archivist-es
- archivist-redis - archivist-redis
archivist-redis: archivist-redis:
image: redislabs/rejson:latest # for arm64 use bbilly1/rejson image: redislabs/rejson # for arm64 use bbilly1/rejson
container_name: archivist-redis container_name: archivist-redis
restart: always restart: unless-stopped
expose: expose:
- "6379" - "6379"
volumes: volumes:
@@ -33,14 +34,15 @@ services:
depends_on: depends_on:
- archivist-es - archivist-es
archivist-es: archivist-es:
image: bbilly1/tubearchivist-es # only for amd64, or use official es 7.17.2 image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.4.3
container_name: archivist-es container_name: archivist-es
restart: always restart: unless-stopped
environment: environment:
- "xpack.security.enabled=true"
- "ELASTIC_PASSWORD=verysecret" # matching Elasticsearch password - "ELASTIC_PASSWORD=verysecret" # matching Elasticsearch password
- "discovery.type=single-node"
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- "xpack.security.enabled=true"
- "discovery.type=single-node"
- "path.repo=/usr/share/elasticsearch/data/snapshot"
ulimits: ulimits:
memlock: memlock:
soft: -1 soft: -1

View File

@@ -3,18 +3,22 @@ server {
listen 8000; listen 8000;
location /cache/videos/ { location /cache/videos/ {
auth_request /api/ping/;
alias /cache/videos/; alias /cache/videos/;
} }
location /cache/channels/ { location /cache/channels/ {
auth_request /api/ping/;
alias /cache/channels/; alias /cache/channels/;
} }
location /cache/playlists/ { location /cache/playlists/ {
auth_request /api/ping/;
alias /cache/playlists/; alias /cache/playlists/;
} }
location /media/ { location /media/ {
auth_request /api/ping/;
alias /youtube/; alias /youtube/;
types { types {
text/vtt vtt; text/vtt vtt;

View File

@@ -5,13 +5,15 @@ if [[ -z "$ELASTIC_USER" ]]; then
export ELASTIC_USER=elastic export ELASTIC_USER=elastic
fi fi
ENV_VARS=("TA_USERNAME" "TA_PASSWORD" "ELASTIC_PASSWORD" "ELASTIC_USER") cachedir=/cache
for each in "${ENV_VARS[@]}"; do [[ -d $cachedir ]] || cachedir=.
if ! [[ -v $each ]]; then lockfile=${cachedir}/initsu.lock
echo "missing environment variable $each"
exit 1 required="Missing required environment variable"
fi [[ -f $lockfile ]] || : "${TA_USERNAME:?$required}"
done : "${TA_PASSWORD:?$required}"
: "${ELASTIC_PASSWORD:?$required}"
: "${TA_HOST:?$required}"
# 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
@@ -31,6 +33,7 @@ until curl -u "$ELASTIC_USER":"$ELASTIC_PASSWORD" "$ES_URL" -fs; do
if [[ $counter -eq 12 ]]; then if [[ $counter -eq 12 ]]; then
# fail after 2 min # fail after 2 min
echo "failed to connect to elastic search, exiting..." echo "failed to connect to elastic search, exiting..."
curl -v -u "$ELASTIC_USER":"$ELASTIC_PASSWORD" "$ES_URL"?pretty
exit 1 exit 1
fi fi
sleep 10 sleep 10
@@ -39,12 +42,27 @@ done
# start python application # start python application
python manage.py makemigrations python manage.py makemigrations
python manage.py migrate python manage.py migrate
export DJANGO_SUPERUSER_PASSWORD=$TA_PASSWORD && \
python manage.py createsuperuser --noinput --name "$TA_USERNAME" if [[ -f $lockfile ]]; then
echo -e "\e[33;1m[WARNING]\e[0m This is not the first run! Skipping" \
"superuser creation.\nTo force it, remove $lockfile"
else
export DJANGO_SUPERUSER_PASSWORD=$TA_PASSWORD
output="$(python manage.py createsuperuser --noinput --name "$TA_USERNAME" 2>&1)"
case "$output" in
*"Superuser created successfully"*)
echo "$output" && touch $lockfile ;;
*"That name is already taken."*)
echo "Superuser already exists. Creation will be skipped on next start."
touch $lockfile ;;
*) echo "$output" && exit 1
esac
fi
python manage.py collectstatic --noinput -c python manage.py collectstatic --noinput -c
nginx & nginx &
celery -A home.tasks worker --loglevel=INFO & celery -A home.tasks worker --loglevel=INFO &
celery -A home beat --loglevel=INFO \ celery -A home beat --loglevel=INFO \
-s "${BEAT_SCHEDULE_PATH:-/cache/celerybeat-schedule}" & -s "${BEAT_SCHEDULE_PATH:-${cachedir}/celerybeat-schedule}" &
uwsgi --ini uwsgi.ini uwsgi --ini uwsgi.ini

View File

@@ -6,3 +6,6 @@ vacuum = True
max-requests = 5000 max-requests = 5000
socket = :8080 socket = :8080
buffer-size = 8192 buffer-size = 8192
log-5xx = true
log-4xx = true
disable-logging = true

View File

@@ -17,16 +17,20 @@ The **Subscribe to Channels** button <img src="assets/icon-add.png?raw=true" alt
You can search your indexed channels by clicking on the search icon <img src="assets/icon-search.png?raw=true" alt="search icon" width="20px" style="margin:0 5px;">. This will open a dedicated page. You can search your indexed channels by clicking on the search icon <img src="assets/icon-search.png?raw=true" alt="search icon" width="20px" style="margin:0 5px;">. This will open a dedicated page.
## Channel Detail ## Channel Detail
Each channel will get a dedicated channel detail page accessible at `/channel/<channel-id>/` of your Tube Archivist. This page shows all the videos you have downloaded from this channel plus additional metadata. Each channel will get a dedicated channel detail page accessible at `/channel/<channel-id>/` of your Tube Archivist. This page shows all the videos you have downloaded from this channel.
- If you are subscribed to the channel, an *Unsubscribe* button will show, else the *Subscribe* button will show.
- 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 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 **Show Playlists** will go to the [playlists](Playlists) page and filter the list to only show playlists from this channel.
### Channel Customize - If you are subscribed to the channel, an *Unsubscribe* button will show, else the *Subscribe* button will show.
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. - The **Mark as Watched** button will mark all videos of this channel as watched.
- **Download Format**: Overwrite the download qualities for videos from this channel.
Additionally there is a *Channel Playlist* page, accessible at `/channel/<channel-id>/playlist/` to show all indexed playlists from this channel.
On the *Channel About* page, accessible at `/channel/<channel-id>/about/`, you can see additional metadata.
- 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 channel customize form gives options to change settings on a per channel basis. Any configurations here will overwrite your configurations from the [settings](Settings) page.
- **Download Format**: Overwrite the download quality for videos from this channel.
- **Auto Delete**: Automatically delete watched videos from this channel after selected days. - **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. - **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. - **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.
If you have any videos pending in the download queue, a *Downloads* link will show, bringing you directly to the [downloads](Downloads) page, filtering the list by the selected channel.

View File

@@ -29,15 +29,15 @@ The **Add to Download Queue** icon <img src="assets/icon-add.png?raw=true" alt="
## The Download Queue ## The Download Queue
Below the three buttons you find the download queue. New items will get added at the bottom of the queue, the next video to download once you click on **Start Download** will be the first in the list. Below the three buttons you find the download queue. New items will get added at the bottom of the queue, the next video to download once you click on **Start Download** will be the first in the list.
You can filter the download queue with the **filter** dropdown box, the filter will show once you have more than one channel in the download queue. Select the channel to filter by name, the number in parentheses indicates how many videos you have pending from this channel. Reset the filter by selecting *all* from the dropdown. This will generate links for the top 30 channels with pending videos.
Every video in the download queue has two buttons: Every video in the download queue has two buttons:
- **Ignore**: This will remove that video from the download queue and this video will not get added again, even when you **Rescan Subscriptions**. - **Ignore**: This will remove that video from the download queue and this video will not get added again, even when you **Rescan Subscriptions**.
- **Download now**: This will give priority to this video. If the download process is already running, the prioritized video will get downloaded as soon as the current video is finished. If there is no download process running, this will start downloading this single video and stop after that. - **Download now**: This will give priority to this video. If the download process is already running, the prioritized video will get downloaded as soon as the current video is finished. If there is no download process running, this will start downloading this single video and stop after that.
The button **Delete all queued** will delete all pending videos from the download queue.
You can flip the view by activating **Show Only Ignored Videos**. This will show all videos you have previously *ignored*. You can flip the view by activating **Show Only Ignored Videos**. This will show all videos you have previously *ignored*.
Every video in the ignored list has two buttons: Every video in the ignored list has two buttons:
- **Forget**: This will delete the item form the ignored list. - **Forget**: This will delete the item form the ignored list.
- **Add to Queue**: This will add the ignored video back to the download queue. - **Add to Queue**: This will add the ignored video back to the download queue.
The button **Delete all ignored** will delete all videos you have previously ignored. You can delete your download queue from the [Settings](Settings#actions) page.

View File

@@ -15,7 +15,7 @@ Defining the scope is important for the success of any project:
- A too narrow scope will make this project uninteresting and will exclude audiences that could also benefit from this project. - A too narrow scope will make this project uninteresting and will exclude audiences that could also benefit from this project.
- Not defining a scope will easily lead to misunderstandings and false hopes of where this project tries to go. - Not defining a scope will easily lead to misunderstandings and false hopes of where this project tries to go.
Of course this is subject to change, as this project continues to grow and more people contribute. Of course this is subject to change: The scope can be expanded as this project continues to grow and more people contribute.
## 2. Emby/Plex/Jellyfin/Kodi integrations ## 2. Emby/Plex/Jellyfin/Kodi integrations
Although there are similarities between these excellent projects and Tube Archivist, they have a very different use case. Trying to fit the metadata relations and database structure of a YouTube archival project into these media servers that specialize in Movies and TV shows is always going to be limiting. Although there are similarities between these excellent projects and Tube Archivist, they have a very different use case. Trying to fit the metadata relations and database structure of a YouTube archival project into these media servers that specialize in Movies and TV shows is always going to be limiting.
@@ -30,5 +30,7 @@ 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 ## 5. When I subscribe to a channel it only downloads the most recent 50 videos
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. Subscribing to a channel is a different operation from downloading it. You can [add the channel to the download queue](https://github.com/tubearchivist/tubearchivist/wiki/Downloads#add-to-download-queue) to download all past videos.
If you want to download the existing videos and also automatically download new videos, then you should both download the channel and subscribe to it.

View File

@@ -9,7 +9,8 @@ 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. * [Search](Search): Search your archive
* [Installation](Installation): 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.
@@ -28,4 +29,13 @@ Table of contents:
An empty checkbox icon <img src="assets/icon-unseen.png?raw=true" alt="unseen icon" width="20px" style="margin:0 5px;"> will show for videos you haven't marked as watched. Click on it and the icon will change to a filled checkbox <img src="assets/icon-seen.png?raw=true" alt="seen icon" width="20px" style="margin:0 5px;"> indicating it as watched - click again to revert. An empty checkbox icon <img src="assets/icon-unseen.png?raw=true" alt="unseen icon" width="20px" style="margin:0 5px;"> will show for videos you haven't marked as watched. Click on it and the icon will change to a filled checkbox <img src="assets/icon-seen.png?raw=true" alt="seen icon" width="20px" style="margin:0 5px;"> indicating it as watched - click again to revert.
When available the <img src="assets/icon-gridview.png?raw=true" alt="gridview icon" width="20px" style="margin:0 5px;"> gridview icon will display the list in a grid, the <img src="assets/icon-listview.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> listview icon will arrange the items in a list. The sort icon <img src="assets/icon-sort.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> will open additional sort options. When available the <img src="assets/icon-gridview.png?raw=true" alt="gridview icon" width="20px" style="margin:0 5px;"> gridview icon will display the list in a grid. A grid row holds 3 items by default, use the <img src="assets/icon-add.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> icon to add more or the <img src="assets/icon-substract.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> icon to remove items per row, depending on your screen size. The <img src="assets/icon-listview.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> listview icon will arrange the items in a list. The sort icon <img src="assets/icon-sort.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> will open additional sort options.
You can control the video player with the following keyboard shortcuts:
- `?`: Show help
- `m`: toggle mute
- `c`: toggle subtitles if available
- `>`: increase playback speed
- `<`: decrease playback speed
- `←` (left arrow): jump back 5 seconds
- `→` (right arrow): jump forward 5 seconds

View File

@@ -1,5 +1,40 @@
# Detailed Installation Instructions for Various Platforms # Detailed Installation Instructions for Various Platforms
## Table of Contents
- [Podman](#podman)
- [Unraid](#unraid)
- [Truenas Scale](#truenas-scale)
- [Synology](#synology)
These are beginners guides installation instructions for additional platforms generously provided by users of these platforms. When in doubt, verify the details with the [Readme](https://github.com/tubearchivist/tubearchivist#installing-and-updating). If you see any issues here while using these instructions, please contribute.
## Podman
Podman handles container hostname resolving slightly differently than docker, so you need to make a few changes to the `docker-compose.yml` to get up and running.
### Step 1: Follow the installation instructions from the [README](https://github.com/tubearchivist/tubearchivist#installing-and-updating), with a few additional changes to the `docker-compose.yml`.
Edit these additional changes to the `docker-compose.yml`:
- under `tubearchivist`->`image`:
- prefix the container name with `docker.io/` (or the url of your repo of choice).
- under `tubearchivist`->`environment`:
- `ES_URL`: change `archivist-es` to the internal IP of the computer that will be running the containers.
- `REDIS_HOST`: change `archivist-redis` to the internal IP of the computer that will be running the containers (should be the same as above).
- under `archivist-redis`->`image`:
- prefix the container name with `docker.io/` again.
- under `archivist-redis`->`expose`:
- change the whole entry from `expose: ["<PORT>"]` into `ports: ["<PORT>:<PORT>"].
- under `archivist-es`->`image`:
- prefix the container name with `docker.io/` again.
- under `archivist-es`->`expose`:
- change the whole entry from `expose: ["<PORT>"]` into `ports: ["<PORT>:<PORT>"].
### Step 2: Create service files (optional)
Since podman doesn't run as a service, it can't start containers after reboots, at least not without some help.
If you want to enable this behavior, you can follow [this example](https://techblog.jeppson.org/2020/04/create-podman-services-with-podman-compose/) to have `systemd` start up the containers with `podman-compose` when the computer boots up.
## Unraid ## 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: 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:
@@ -54,3 +89,197 @@ It's finally time to set up TubeArchivist!
### From there, you should be able to start up your containers and you're good to go! ### 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. If you're still having trouble, join us on [discord](https://discord.gg/AFwz8nE7BK) and come to the #unraid channel.
<br />
<br />
## Truenas Scale
Truenas Scale can be a bit confusing, with its k3s kubernetes implementation.
However, there is a step by step guide available for it's users here:
https://heavysetup.info/applications/tube-archivist/dataset/
- Ensure you are navigating the columns under `Tube Archivist` on the left hand side of the screen
<br />
<br />
## Synology
There are several different methods to install TubeArchivist on Synology platforms. This will focus on the available `docker` package and `docker-compose` implementations.
### Prepare Directories/Folders
Before we setup TubeArchivist, we need to setup the directories/folders. You are assumed to be logged into the Synology NAS.
#### 1. Docker Base Folder
1. Open the `File Station` utility.
2. Click on the **Create🔽** button and choose *Create New Shared Folder*.
3. **Name** the folder "Docker".
4. Add a **Description**.
5. Select the **Volume Location**.
> Note: By default, this will be where all data is stored. Change the folders as best meets your requirements.
6. Select the appropriate options from the remaining checkbox configurations.
![Synology - Create Docker Folder](assets/Synology_0.2.0_Docker-Folder-Create.png)
7. Click the **Next** button.
8. If you are going to **Encrypt** your folder, check the appropriate box and provide the Encryption Key and its confirmation.
9. Click the **Next** button.
10. On the **Advanced Settings** page, you can select the *Enable data checksum for advanced data integrity* setting. This may cause a performance impact, but will allow for potential file self-healing. **This cannot be changed later.**
> Note: This is not recommended, as we will be hosting databases within this folder.
11. If you are enabling a quota for how large the folder can get, you can select the *Enabled shared folder quota* setting and choose the maximum size this folder can grow. This can be changed later.
12. Click the **Next** button.
13. Confirm the settings, then click the **Apply** button. This will create the folder.
#### 2. TubeArchivist Base Folder
1. Open the `File Station` utility.
2. Select the "Docker" folder on the left-hand side.
3. Click on the `Create🔽` button and choose *create Folder*.
4. **Name** the folder "TubeArchivist".
#### 3. Redis Data
1. Open the `File Station` utility.
2. Select the "Docker" folder on the left-hand side.
3. Select the "TubeArchivist" folder beneath "Docker".
4. Click on the `Create🔽` button and choose *create Folder*.
5. **Name** the folder "redis".
#### 4. Elastic Search Data
1. Open the `File Station` utility.
2. Select the "Docker" folder on the left-hand side.
3. Select the "TubeArchivist" folder beneath "Docker".
4. Click on the `Create🔽` button and choose *create Folder*.
5. **Name** the folder "es".
#### 5. TubeArchivist Cache
1. Open the `File Station` utility.
2. Select the "Docker" folder on the left-hand side.
3. Select the "TubeArchivist" folder beneath "Docker".
4. Click on the `Create🔽` button and choose *create Folder*.
5. **Name** the folder "cache".
#### 6. TubeArchivist Output
1. Open the `File Station` utility.
2. Select the "Docker" folder on the left-hand side.
3. Select the "TubeArchivist" folder beneath "Docker".
4. Click on the `Create🔽` button and choose *create Folder*.
5. **Name** the folder "media".
#### 7. Confirm Folder Structure
Once all of the folders have been created, it should have a folder structure within Docker\TubeArchivist that includes "cache", "es", "media", and "redis" folders.
![Synology - Docker Folder Structure](assets/Synology_0.2.0_Docker-Folder-Structure.png)
#### 8. Change Permissions - CLI Required
> If you do not have SSH access enabled for CLI, [enable it](https://kb.synology.com/en-sg/DSM/tutorial/How_to_login_to_DSM_with_root_permission_via_SSH_Telnet) before continuing.
1. Open the SSH connection to the Synology. Login as your primary `Admin` user, or the user that was enabled for SSH access.
2. Elevate your access to `root`. Steps are provided [here](https://kb.synology.com/en-sg/DSM/tutorial/How_to_login_to_DSM_with_root_permission_via_SSH_Telnet).
3. Change directories to the **Volume** where the "Docker" folder resides.
</br>Example: `cd /volume1`
4. Change directories to the "Docker" folder.
</br>Example: `cd Docker`
5. Change directories to the "TubeArchivist" folder.
</br>Example: `cd TubeArchivist`
6. Change the owner of the "redis" folder. *If correct, this does not have an output.*
</br>Example: `chown 999:100 redis`
7. Change the owner of the "es" folder. *If correct, this does not have an output.*
</br>Example: `chown 1000:1000 es`
8. Confirm that the folders have the correct permissions.
</br>Example: `ls -hl`
![Synology - Docker Folder Permissions Command](assets/Synology_0.2.0_Docker-Folder-Permissions-Commands.png)
9. Logout from root.
</br>Example: `logout`
10. Disconnect from the SSH connection.
</br>Example: `exit`
### Docker Setup
1. Install the `Docker` Synology Package.
1. Log in to your Synology NAS.
2. Open the `Package Center` utility.
3. Search for `Docker`.
4. Click `Install`.
![Synology - Install Docker Utility](assets/Synology_0.2.0_Docker-Install.png)
2. After `Docker` is installed, open the `Docker` utility.
3. Go to the `Registry` tab.
4. Search for the following `images` and download them. Follow the recommended versions for each of the images.
- `redislabs/rejson`
![Synology - Redis Image Search](assets/Synology_0.2.0_Docker-Redis-Search.png)
- `bbilly1/tubearchivist-es`
![Synology - ElasticSearch Image Search](assets/Synology_0.2.0_Docker-ES-Search.png)
- `bbilly1/tubearchivist`
![Synology - TubeArchivist Image Search](assets/Synology_0.2.0_Docker-TA-Search.png)
5. Go to the `Image` tab. From here, create an container based on each image with the associated configurations below.
- ElasticSearch
1. Select the associated image.
2. Click the **Launch** button in the top.
3. Edit the **Container Name** to be "tubearchivist-es".
4. Click on the **Advanced Settings** button.
5. In the **Advanced Settings** tab, check the box for `Enable auto-restart`.
6. In the **Volume** tab, click the **Add Folder** button and select the "Docker/TubeArchivist/es" folder, then type in `/usr/share/elasticsearch/data` for the mount path.
7. In the **Network** tab, leave the default `bridge` Network (unless you have a specific Network design that you know how to implement).
8. In the **Port Settings** tab, replace the "Auto" entry under **Local Port** with the port that will be used to connect to ElasticSearch (default is 9200).
9. In the **Port Settings** tab, select the entryline for port 9300 and ** delete** the line. It is not needed for this container.
10. The **Links** tab does not require configuration for this container.
11. In the **Environment** tab, add in the following ElasticSearch specific environment variables that may apply.
- "discovery.type=single-node"
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- "UID=1000"
- "GID=0"
- "xpack.security.enabled=true"
- "ELASTIC_PASSWORD=verysecret"
- "path.repo=/usr/share/elasticsearch/data/snapshot"
> Do not use the default password as it is very insecure.
> Activating snapshots for backups should only be done *after* setting the `path.repo` setting.
![Synology - ElasticSearch Environment Configurations](assets/Synology_0.2.0_Docker-ES-Env-Conf.png)
12. Click on the **Apply** button.
13. Back on the **Create Container** screen, click the **Next** button.
14. Review the settings to confirm, then click the **Apply** button.
- Redis
1. Select the associated image.
2. Click the **Launch** button in the top.
3. Edit the **Container Name** to be "tubearchivist-redis".
4. Click on the **Advanced Settings** button.
5. In the **Advanced Settings** tab, check the box for `Enable auto-restart`.
6. In the **Volume** tab, click the **Add Folder** button and select the "Docker/TubeArchivist/redis" folder, then type in `/data` for the mount path.
7. In the **Network** tab, leave the default `bridge` Network (unless you have a specific Network design that you know how to implement).
8. In the **Port Settings** tab, replace the "Auto" entry under **Local Port** with the port that will be used to connect to Redis (default is 6379).
9. In the **Links** tab, select the "tubearchivist-es" container from the **Container Name** dropdown and provide it the same alias, "tubearchivist-es".
10. In the **Environment** tab, add in any Redis specific environment variables that may apply (none by default).
11. Click on the **Apply** button.
12. Back on the **Create Container** screen, click the **Next** button.
13. Review the settings to confirm, then click the **Apply** button.
- TubeArchivist
1. Select the associated image.
2. Click the **Launch** button in the top.
3. Edit the **Container Name** to be "tubearchivist".
4. Click on the **Advanced Settings** button.
5. In the **Advanced Settings** tab, check the box for `Enable auto-restart`.
6. In the **Volume** tab, click the **Add Folder** button and select the "Docker/TubeArchivist/cache" folder, then type in `/cache` for the mount path.
7. In the **Volume** tab, click the **Add Folder** button and select the "Docker/TubeArchivist/media" folder, then type in `/youtube` for the mount path.
8. In the **Network** tab, leave the default `bridge` Network (unless you have a specific Network design that you know how to implement).
9. In the **Port Settings** tab, replace the "Auto" entry under **Local Port** with the port that will be used to connect to TubeArchivist (default is 8000).
10. In the **Links** tab, select the "tubearchivist-es" container from the **Container Name** dropdown and provide it the same alias, "tubearchivist-es".
11. In the **Links** tab, select the "tubearchivist-redis" container from the **Container Name** dropdown and provide it the same alias, "tubearchivist-redis".
12. In the **Environment** tab, add in the following TubeArchivist specific environment variables that may apply. **Change the variables as-is appropriate to your use case. Follow the [README section](https://github.com/tubearchivist/tubearchivist#tube-archivist) for details on what to set each variable.**
- "TA_HOST=synology.local"
- "ES_URL=http://tubearchivist-es:9200"
- "REDIS_HOST=tubearchivist-redis"
- "HOST_UID=1000"
- "HOST_GID=1000"
- "TA_USERNAME=tubearchivist"
- "TA_PASSWORD=verysecret"
- "ELASTIC_PASSWORD=verysecret"
- "TZ=America/New_York"
> Do not use the default password as it is very insecure.
> Ensure that ELASTIC_PASSWORD matches the password used on the tubearchivist-es container.
![Synology - TubeArchivist Environment Configurations](assets/Synology_0.2.0_Docker-TA-Env-Conf.png)
13. Click on the **Apply** button.
14. Back on the **Create Container** screen, click the **Next** button.
15. Review the settings to confirm, then click the **Apply** button.
6. After the containers have been configured and started, you can go to the **Container** tab and monitor the containers.
7. To review the logs to ensure that the system has started successfully, select the "tubearchivist" container and click on the **Details** button. In the new window, go to the **Log** tab. Monitor the logs until either an error occurs or the message `celery@tubearchivist ready.` is in the logs. This may take a few minutes, especially for a first time setup.
> Note: Synology Docker presents the logs in a pagination format. If you are not seeing the logs update, check if there are additional pages.
8. After it has started, go to the location in the `TA_HOST`. This should give you the standard TubeArchivist login screen.
<!--
### Docker-Compose Setup -->
<!-- This section is a Work In Progress -->
### 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 #synology channel.

57
docs/Search.md Normal file
View File

@@ -0,0 +1,57 @@
# Search Page
Accessible at `/search/` of your **Tube Archivist**, search your archive for Videos, Channels and Playlists - or even full text search throughout your indexed subtitles.
- All your queries are case insensitive and are normalized to lowercase.
- All your queries are analyzed for the english language, this means *singular*, *plural* and word variations like *-ing*, *-ed*, *-able* etc are treated as synonyms.
- Fuzzy search is activated for all your searches by default. This can catch typos in your queries or in the matching documents with one to two letters difference, depending on the query length. You can configure fuzziness with the secondary keyword `fuzzy:`, e.g:
- `fuzzy:0` or `fuzzy:no`: Deactivate fuzzy matching.
- `fuzzy:1`: Set fuzziness to one letter difference.
- `fuzzy:2`: Set fuzziness to two letters difference.
- All text searches are ranked, meaning the better a match the higher ranked the result. Unless otherwise stated, queries with multiple words are processed with the `and` operator, meaning all words need to match so each word will narrow down the result.
- This will return 30 results per query, pagination is not implemented yet.
Just start typing to start a *simple* search or start your query with a primary keyword to search for a specific type and narrow down the result with secondary keywords. Secondary keywords can be in any order. Use *yes* or *no* for boolean values.
## Simple
Start your query without a keyword to make a simple query. This will search in *video titles*, *channel names* and *playlist titles* and will return matching videos, channels and playlists. Keyword searches will return more results in a particular category due to the fact that more fields are searched for matches.
## Video
Start your query with the primary keyword `video:` to search for videos only. This will search through the *video titles*, *tags* and *category* fields. Narrow your search down with secondary keywords:
- `channel:` search for videos matching the channel name.
- `active:` is a boolean value, to search for videos that are still active on youtube or that are not active any more.
**Example**:
- `video:learn python channel:corey shafer active:yes`: This will return all videos with the term *Learn Python* from the channel *Corey Shafer* that are still *Active* on YouTube.
- `video: channel:tom scott active:no`: Note the omitted term after the primary key, this will show all videos from the channel *Tom Scott* that are no longer active on YouTube.
## Channel
Start with the `channel:` primary keyword to search for channels matching your query. This will search through the *channel name* and *channel description* fields. Narrow your search down with secondary keywords:
- `subscribed:` is a boolean value, search for channels that you are subscribed to or not.
- `active:` is a boolean value, to search for channels that are still active on YouTube or that are no longer active.
**Example**:
- `channel:linux subscribed:yes`: Search for channels with the term *Linux* that you are subscribed to.
- `channel: active:no`: Note the omitted term after the primary key, this will return all channels that are no longer active on YouTube.
## Playlist
Start your query with the primary keyword `playlist:` to search for playlists only. This will search through the *playlist title* and *playlist description* fields. Narrow down your search with these secondary keywords:
- `subscribed`: is a boolean value, search for playlists that you are subscribed to or not.
- `active:` is a boolean value, to search for playlists that are still active on YouTube or that are no longer active.
**Example**:
- `playlist:backend engineering subscribed:yes`: Search for playlists about *Backend Engineering* that you are subscribed to.
- `playlist: active:yes subscribed:yes`: Note the omitted primary search term, this will return all playlists active on YouTube that you are subscribed to.
- `playlist:html css active:yes`: Search for playlists containing *HTML CSS* that are still active on YouTube.
## Full
Start a full text search by beginning your query with the primary keyword `full:`. This will search through your indexed Subtitles showing segments with possible matches. This will only show any results if you have activated *subtitle download and index* on the settings page. The operator for full text searches is `or` meaning when searching for multiple words not all words need to match, but additional words will change the ranking of the result, the more words match and the better they match, the higher ranked the result. The matching words will get highlighted in the text preview.
Clicking the play button on the thumbnail will open the inplace player at the timestamp from where the segment starts. Same when clicking the video title, this will open the video page and put the player at the segment timestamp. This will overwrite any previous playback position.
Narrow down your search with these secondary keywords:
- `lang`: Search for matches only within a language. Use the same two letter ISO country code as you have set on the settings page.
- `source`: Can either be *auto* to search through auto generated subtitles only or *user* to search through user uploaded subtitles only.
**Example**:
- `full:contribute to open source lang:en` search for subtitle segments matching with the words *Contribute to Open Source* in the language *en*.
- `full:flight simulator cockpit source:user` to search for the words *Flight Simulator Cockpit* from *user* uploaded subtitle segments.

View File

@@ -32,22 +32,70 @@ 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.
## Comments
- **Download and index comments**: Set your configuration for downloading and indexing comments. This takes the same values as documented in the `max_comments` section for the youtube extractor of [yt-dlp](https://github.com/yt-dlp/yt-dlp#youtube). Add without space between the four different fields: *max-comments,max-parents,max-replies,max-replies-per-thread*. Example:
- `all,100,all,30`: Get 100 max-parents and 30 max-replies-per-thread.
- `1000,all,all,50`: Get a total of 1000 comments over all, 50 replies per thread.
- **Comment sort method**: Change sort method between *top* or *new*. The default is *top*, as decided by YouTube.
- The [Refresh Metadata](#refresh-metadata) background task will get comments from your already archived videos, spreading the requests out over time.
Archiving comments is slow as only very few comments get returned per request with yt-dlp. Choose your configuration above wisely. Tube Archivist will download comments after the download queue finishes, your videos will be already available while the comments are getting downloaded.
## 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 information can be used to take over your account. Treat that data with utmost care as you would any other password or credential. *Tube Archivist* stores your cookie in Redis and will automatically append it to yt-dlp for every request.
### Auto import
Easiest way to import your cookie is to use the **Tube Archivist Companion** [browser extension](https://github.com/tubearchivist/browser-extension) for Firefox and Chrome.
### Alternative Manual 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*.
### Alternative Manual 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). - **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 *NOTE*: This feature is currently broken due to an authentication issue, see #331.
- HTTPS Requirements:
* To use the cast integration HTTPS needs to be enabled, which can be done using a reverse proxy. This is a requirement by Google as communication to the cast device is required to be encrypted, but the content itself is not. - HTTPS: To use the cast integration HTTPS needs to be enabled, which can be done using a reverse proxy. This is a requirement by Google as communication to the cast device is required to be encrypted, but the content itself is not.
- Supported Browser - Supported Browser:A supported browser is required for this integration such as Google Chrome. Other browsers, especially Chromium-based browsers, may support casting by enabling it in the settings.
* A supported browser is required for this integration such as Google Chrome. Other browsers, especially Chromium-based browsers, may support casting by enabling it in the settings. - Subtitles: Subtitles are supported however they do not work out of the box and require additional configuration. Due to requirements by Google, to use subtitles you need additional headers which will need to be configured in your reverse proxy. See this [page](https://developers.google.com/cast/docs/web_sender/advanced#cors_requirements) for the specific requirements.
- Subtitles You need the following headers: Content-Type, Accept-Encoding, and Range. Note that the last two headers, Accept-Encoding and Range, are additional headers that you may not have needed previously.
* Subtitles are supported however they do not work out of the box and require additional configuration. Due to requirements by Google, to use subtitles you need additional headers which will need to be configured in your reverse proxy. See this [page](https://developers.google.com/cast/docs/web_sender/advanced#cors_requirements) for the specific requirements. Wildcards "*" cannot be used for the Access-Control-Allow-Origin header. If the page has protected media content, it must use a domain instead of a wildcard.
> You need the following headers: Content-Type, Accept-Encoding, and Range. Note that the last two headers, Accept-Encoding and Range, are additional headers that you may not have needed previously.
> Wildcards "*" cannot be used for the Access-Control-Allow-Origin header. If the page has protected media content, it must use a domain instead of a wildcard.
## Snapshots
System snapshots will automatically make daily snapshots of the Elasticsearch index. The task will start at 12pm your local time. Snapshots are deduplicated, meaning that each snapshot will only have to backup changes since the last snapshot. The initial snapshot may be slow, but subsequent runs will be much faster. There is also a cleanup function implemented, that will remove snapshots older than 30 days.
This will make a snapshot of your metadata index only, no media files or additional configuration variables you have set on the settings page will be backed up.
Due to these improvements compared to the previous backup solution, system snapshots will replace the current backup system in a future version.
Before activating system snapshots, you'll have to add one additional environment variables to the *archivist-es* container:
```
path.repo=/usr/share/elasticsearch/data/snapshot
```
The variable `path.repo` will set the folder where the snapshots will go inside the Elasticsearch container, you can't change it, but the variable needs to be set. Rebuild the container for changes to take effect, e.g `docker compose up -d`.
- **Create snapshot now**: Will start the snapshot process now, outside of the regular daily schedule.
- **Restore**: Restore your index to that point in time.
# Scheduler Setup # Scheduler Setup
Schedule settings expect a cron like format, where the first value is minute, second is hour and third is day of the week. Day 0 is Sunday, day 1 is Monday etc. Schedule settings expect a cron like format, where the first value is minute, second is hour and third is day of the week. Day 0 is Sunday, day 1 is Monday etc.
@@ -63,7 +111,7 @@ Examples:
NOTE: NOTE:
- Changes in the scheduler settings require a container restart to take effect. - Changes in the scheduler settings require a container restart to take effect.
- Cron format as *number*/*number* are none standard cron and are not supported by the scheduler, for example **0 0/12 \*** is invalid, use **0 \*/12 \*** instead. - Cron format as *number*/*number* are none standard cron and are not supported by the scheduler, for example **0 0/12 \*** is invalid, use **0 \*/12 \*** instead.
- Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that reason * or wildcards for minutes are not supported. - Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that reason, the scheduler doesn't support schedules that trigger more than once per hour.
## Rescan Subscriptions ## Rescan Subscriptions
That's the equivalent task as run from the downloads page looking through your channel and playlist and add missing videos to the download queue. That's the equivalent task as run from the downloads page looking through your channel and playlist and add missing videos to the download queue.
@@ -72,7 +120,7 @@ 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 refresh your subtitles based on your current settings. If an item is no longer available on YouTube, this will deactivate it and exclude it from future refreshes. 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.
@@ -86,26 +134,38 @@ Create a zip file of the metadata and select **Max auto backups to keep** to aut
# Actions # Actions
Additional database functionality. Additional database functionality.
## Delete download queue
The button **Delete all queued** will delete all pending videos from the download queue. The button **Delete all ignored** will delete all videos you have previously ignored.
## Manual Media Files Import ## Manual Media Files Import
So far this depends on the video you are trying to import to be still available on YouTube to get the metadata. Add the files you'd like to import to the */cache/import* folder. Then start the process from the settings page *Manual Media Files Import*. Make sure to follow one of the two methods below. NOTE: This is inherently error prone, as there are many variables, some outside of the control of this project. Read this carefully and use at your own risk.
Add the files you'd like to import to the */cache/import* folder. Only add files, don't add subdirectories. All files you are adding, need to have the same *base name* as the media file. Then start the process from the settings page *Manual Media Files Import*.
Valid media extensions are *.mp4*, *.mkv* or *.webm*. If you have other file extensions or incompatible codecs, convert them first to mp4. **Tube Archivist** can identify the videos with one of the following methods.
### Method 1: ### Method 1:
Add a matching *.json* file with the media file. Both files need to have the same base name, for example: Add a matching *.info.json* file with the media file. Both files need to have the same base name, for example:
- For the media file: \<base-name>.mp4 - For the media file: `<base-name>.mp4`
- For the JSON file: \<base-name>.info.json - For the JSON file: `<base-name>.info.json`
- Alternate JSON file: \<base-name>.json
**Tube Archivist** then looks for the 'id' key within the JSON file to identify the video. The import process then looks for the 'id' key within the JSON file to identify the video.
### Method 2: ### Method 2:
Detect the YouTube ID from filename, this accepts the default yt-dlp naming convention for file names like: Detect the YouTube ID from filename, this accepts the default yt-dlp naming convention for file names like:
- \<base-name>[\<youtube-id>].mp4 - `<base-name>[<youtube-id>].mp4`
- The YouTube ID in square brackets at the end of the filename is the crucial part. - The YouTube ID in square brackets at the end of the filename is the crucial part.
### Offline import:
If the video you are trying to import is not available on YouTube any more, **Tube Archivist** can import the required metadata:
- The file `<base-name>.info.json` is required to extract the required information.
- Add the thumbnail as `<base-name>.<ext>`, where valid file extensions are *.jpg*, *.png* or *.webp*. If there is no thumbnail file, **Tube Archivist** will try to extract the embedded cover from the media file or will fallback to a default thumbnail.
- Add subtitles as `<base-name>.<lang>.vtt` where *lang* is the two letter ISO country code. This will archive all subtitle files you add to the import folder, independent from your configurations. Subtitles can be archived and used in the player, but they can't be indexed or made searchable due to the fact, that they have a very different structure than the subtitles as **Tube Archivist** needs them.
- For videos, where the whole channel is not available any more, you can add the `<channel-id>.info.json` file as generated by *youtube-dl/yt-dlp* to get the full metadata. Alternatively **Tube Archivist** will extract as much info as possible from the video info.json file.
### 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). - For best file transcoding quality, convert your media files with desired settings first before importing.
- 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`.
@@ -113,7 +173,7 @@ Detect the YouTube ID from filename, this accepts the default yt-dlp naming conv
This will write or overwrite all thumbnails in the media file using the downloaded thumbnail. This is only necessary if you didn't download the files with the option *Embed Thumbnail* enabled or want to make sure all media files get the newest thumbnail. Follow the docker-compose logs to monitor progress. This will write or overwrite all thumbnails in the media file using the downloaded thumbnail. This is only necessary if you didn't download the files with the option *Embed Thumbnail* enabled or want to make sure all media files get the newest thumbnail. Follow the docker-compose logs to monitor progress.
## Backup Database ## Backup Database
This will backup your metadata into a zip file. The file will get stored at *cache/backup* and will contain the necessary files to restore the Elasticsearch index formatted **nd-json** files plus a complete export of the index in a set of conventional **json** files. This will backup your metadata into a zip file. The file will get stored at *cache/backup* and will contain the necessary files to restore the Elasticsearch index formatted **nd-json** files.
BE AWARE: This will **not** backup any media files, just the metadata from the Elasticsearch. BE AWARE: This will **not** backup any media files, just the metadata from the Elasticsearch.

View File

@@ -1,11 +1,23 @@
# Video Page # Video Page
Every video downloaded gets a dedicated page accessible at `/video/<video-id>/` of your Tube Archivist.
Every video downloaded gets a dedicated page accessible at `/video/<video-id>` of your Tube Archivist.
Clicking on the channel name or the channel icon will bring you to the dedicated channel detail [page](Channels#channel-detail). Clicking on the channel name or the channel icon will bring you to the dedicated channel detail [page](Channels#channel-detail).
The button **Delete Video** will delete that video including the media file. The button **Delete Video** will delete that video including the media file.
If available, a tag cloud will show, representing the tags set by the uploader.
The video description is truncated to the first few lines, click on *show more* to expand the whole description.
## Playlist
When available, a playlist navigation will show at the bottom. Clicking on the playlist name will bring you to the dedicated [Playlist Detail](Playlists#playlist-detail) page showing all videos downloaded from that playlist. The number in square brackets indicates the position of the current video in that playlist. When available, a playlist navigation will show at the bottom. Clicking on the playlist name will bring you to the dedicated [Playlist Detail](Playlists#playlist-detail) page showing all videos downloaded from that playlist. The number in square brackets indicates the position of the current video in that playlist.
Clicking on the next or previous video name or thumbnail will bring you to that dedicated video page. Clicking on the next or previous video name or thumbnail will bring you to that dedicated video page.
## Similar Videos
Tube Archivist will show up to six similar videos in a grid. Similarity is detected from the **video title** and the **video tags**. This naturally will show some videos from the same channel, but can also return videos about the same topic from other channels.
When playing a video from the similar section with the inline player, the current video will get replaced, refresh the page to reset that or click on the video title to avoid that behavior.
## Comments
If activated on the settings page, this will show the indexed comments. Reveal the threads by clicking the *+ Replies* button. Comments with a heart symbol are favorited by the uploader, comments by the uploader are highlighted in a different color.

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

1761
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"private": true,
"scripts": {
"lint": "eslint 'tubearchivist/static/**/*.js'",
"format": "prettier --write 'tubearchivist/static/**/*.js'"
},
"devDependencies": {
"eslint": "^8.26.0",
"prettier": "^2.7.1",
"eslint-config-prettier": "^8.5.0"
},
"prettier": {
"singleQuote": true,
"arrowParens": "avoid",
"printWidth": 100
}
}

View File

@@ -1,6 +1,45 @@
# TubeArchivist API # TubeArchivist API
Documentation of available API endpoints. Documentation of available API endpoints.
**Note: This is very early alpha and will change!**
Note:
- This is very early stages and will change!
- Check the commit history to see if a documented feature is already in your release
## Table of contents
- [Authentication](#authentication)
- [Pagination](#pagination)
**Video**
- [Video List](#video-list-view)
- [Video Single](#video-item-view)
- [Video Comments](#video-comment-view)
- [Video Single Progress](#video-progress-view)
- [Video Single Sponsorblock](#sponsor-block-view) WIP
**Channel**
- [Channel List](#channel-list-view)
- [Channel Single](#channel-item-view)
- [Channel Video List](#channel-videos-view)
**Playlist**
- [Playlist List](#playlist-list-view)
- [Playlist Single](#playlist-item-view)
- [Playlist Videos List](#playlist-videos-view)
**Download queue**
- [Download Queue List](#download-queue-list-view)
- [Download Queue Single](#download-queue-item-view)
**Snapshot management**
- [Snapshot List](#snapshot-list-view)
- [Snapshot Single](#snapshot-item-view)
**Additional**
- [Login](#login-view)
- [Task](#task-view) WIP
- [Cookie](#cookie-view)
- [Search](#search-view)
- [Ping](#ping-view)
## Authentication ## Authentication
API token will get automatically created, accessible on the settings page. Token needs to be passed as an authorization header with every request. Additionally session based authentication is enabled too: When you are logged into your TubeArchivist instance, you'll have access to the api in the browser for testing. API token will get automatically created, accessible on the settings page. Token needs to be passed as an authorization header with every request. Additionally session based authentication is enabled too: When you are logged into your TubeArchivist instance, you'll have access to the api in the browser for testing.
@@ -20,23 +59,19 @@ headers = {"Authorization": "Token xxxxxxxxxx"}
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
``` ```
## Login View ## Pagination
Return token and user ID for username and password: The list views return a paginate object with the following keys:
POST /api/login - page_size: *int* current page size set in config
```json - page_from: *int* first result idx
{ - prev_pages: *array of ints* of previous pages, if available
"username": "tubearchivist", - current_page: *int* current page from query
"password": "verysecret" - max_hits: *bool* if max of 10k results is reached
} - params: *str* additional url encoded query parameters
``` - last_page: *int* of last page link
- next_pages: *array of ints* of next pages
- total_hits: *int* total results
after successful login returns 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`.
```json
{
"token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"user_id": 1
}
```
## Video List View ## Video List View
/api/video/ /api/video/
@@ -44,6 +79,9 @@ after successful login returns
## Video Item View ## Video Item View
/api/video/\<video_id>/ /api/video/\<video_id>/
## Video Comment View
/api/video/\<video_id>/comment/
## Video Progress View ## Video Progress View
/api/video/\<video_id>/progress /api/video/\<video_id>/progress
@@ -123,14 +161,24 @@ 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 ## Playlist List View
/api/playlist/ /api/playlist/
## Playlists Item View ## Playlist 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
- channel: channel-id
### Add list of videos to download queue ### Add list of videos to download queue
POST /api/download/ POST /api/download/
@@ -142,9 +190,165 @@ POST /api/download/
} }
``` ```
## Download Queue Item View ### Delete download queue items by filter
/api/download/\<video_id>/ DELETE /api/download/?filter=ignore
DELETE /api/download/?filter=pending
## Download Queue Item View
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
## Snapshot List View
GET /api/snapshot/
Return snapshot config and a list of available snapshots.
```json
{
"next_exec": epoch,
"next_exec_str": "date_str",
"expire_after": "30d",
"snapshots": []
}
```
POST /api/snapshot/
Create new snapshot now, will return immediately, task will run async in the background, will return snapshot name:
```json
{
"snapshot_name": "ta_daily_<random-id>
}
```
## Snapshot Item View
GET /api/snapshot/\<snapshot-id>/
Return metadata of a single snapshot
```json
{
"id": "ta_daily_<random-id>,
"state": "SUCCESS",
"es_version": "0.0.0",
"start_date": "date_str",
"end_date": "date_str",
"end_stamp": epoch,
"duration_s": 0
}
```
GET /api/snapshot/\<snapshot-id>/
Restore this snapshot
DELETE /api/snapshot/\<snapshot-id>/
Remove this snapshot from index
## 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
}
```
## Task View
GET /api/task/
POST /api/task/
Check if there is an ongoing task:
GET /api/task/
Returns:
```json
{
"rescan": false,
"downloading": false
}
```
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, *status* turns to `true` if cookie has been validated.
GET /api/cookie/
```json
{
"cookie_enabled": true,
"status": true,
"validated": <timestamp>,
"validated_str": "timestamp"
}
```
POST /api/cookie/
Send empty post request to validate cookie.
```json
{
"cookie_validated": true
}
```
PUT /api/cookie/
Send put request containing the cookie as a string:
```json
{
"cookie": "your-cookie-as-string"
}
```
Imports and validates cookie, returns on success:
```json
{
"cookie_import": "done",
"cookie_validated": true
}
```
Or returns status code 400 on failure:
```json
{
"cookie_import": "fail",
"cookie_validated": false
}
```
## Search View
GET /api/search/?query=\<query>
Returns search results from your query.
## Ping View ## Ping View
Validate your connection with the API Validate your connection with the API

View File

@@ -7,18 +7,22 @@ Functionality:
import urllib.parse import urllib.parse
from home.src.download.thumbnails import ThumbManager from home.src.download.thumbnails import ThumbManager
from home.src.ta.config import AppConfig
from home.src.ta.helper import date_praser from home.src.ta.helper import date_praser
class SearchProcess: class SearchProcess:
"""process search results""" """process search results"""
CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"]
def __init__(self, response): def __init__(self, response):
self.response = response self.response = response
self.processed = False self.processed = False
def process(self): def process(self):
"""dedect type and process""" """detect type and process"""
if "_source" in self.response.keys(): if "_source" in self.response.keys():
# single # single
self.processed = self._process_result(self.response) self.processed = self._process_result(self.response)
@@ -33,7 +37,7 @@ class SearchProcess:
return self.processed return self.processed
def _process_result(self, result): def _process_result(self, result):
"""dedect which type of data to process""" """detect which type of data to process"""
index = result["_index"] index = result["_index"]
processed = False processed = False
if index == "ta_video": if index == "ta_video":
@@ -42,6 +46,10 @@ class SearchProcess:
processed = self._process_channel(result["_source"]) processed = self._process_channel(result["_source"])
if index == "ta_playlist": if index == "ta_playlist":
processed = self._process_playlist(result["_source"]) processed = self._process_playlist(result["_source"])
if index == "ta_download":
processed = self._process_download(result["_source"])
if index == "ta_comment":
processed = self._process_comment(result["_source"])
return processed return processed
@@ -68,16 +76,21 @@ class SearchProcess:
media_url = urllib.parse.quote(video_dict["media_url"]) media_url = urllib.parse.quote(video_dict["media_url"])
vid_last_refresh = date_praser(video_dict["vid_last_refresh"]) vid_last_refresh = date_praser(video_dict["vid_last_refresh"])
published = date_praser(video_dict["published"]) published = date_praser(video_dict["published"])
vid_thumb_url = ThumbManager().vid_thumb_path(video_id) vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
channel = self._process_channel(video_dict["channel"]) 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( video_dict.update(
{ {
"channel": channel, "channel": channel,
"media_url": media_url, "media_url": f"/media/{media_url}",
"vid_last_refresh": vid_last_refresh, "vid_last_refresh": vid_last_refresh,
"published": published, "published": published,
"vid_thumb_url": vid_thumb_url, "vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}",
} }
) )
@@ -98,3 +111,31 @@ class SearchProcess:
) )
return dict(sorted(playlist_dict.items())) 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(video_id).vid_thumb_path()
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()))
def _process_comment(self, comment_dict):
"""run on all comments, create reply thread"""
all_comments = comment_dict["comment_comments"]
processed_comments = []
for comment in all_comments:
if comment["comment_parent"] == "root":
comment.update({"comment_replies": []})
processed_comments.append(comment)
else:
processed_comments[-1]["comment_replies"].append(comment)
return processed_comments

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)
return {"success": True}

View File

@@ -2,16 +2,25 @@
from api.views import ( from api.views import (
ChannelApiListView, ChannelApiListView,
ChannelApiVideoView,
ChannelApiView, ChannelApiView,
CookieView,
DownloadApiListView, DownloadApiListView,
DownloadApiView, DownloadApiView,
LoginApiView, LoginApiView,
PingView, PingView,
PlaylistApiListView, PlaylistApiListView,
PlaylistApiVideoView,
PlaylistApiView, PlaylistApiView,
SearchView,
SnapshotApiListView,
SnapshotApiView,
TaskApiView,
VideoApiListView, VideoApiListView,
VideoApiView, VideoApiView,
VideoCommentView,
VideoProgressView, VideoProgressView,
VideoSimilarView,
VideoSponsorView, VideoSponsorView,
) )
from django.urls import path from django.urls import path
@@ -34,6 +43,16 @@ urlpatterns = [
VideoProgressView.as_view(), VideoProgressView.as_view(),
name="api-video-progress", name="api-video-progress",
), ),
path(
"video/<slug:video_id>/comment/",
VideoCommentView.as_view(),
name="api-video-comment",
),
path(
"video/<slug:video_id>/similar/",
VideoSimilarView.as_view(),
name="api-video-similar",
),
path( path(
"video/<slug:video_id>/sponsor/", "video/<slug:video_id>/sponsor/",
VideoSponsorView.as_view(), VideoSponsorView.as_view(),
@@ -50,15 +69,25 @@ urlpatterns = [
name="api-channel", name="api-channel",
), ),
path( path(
"playlist/<slug:playlist_id>/", "channel/<slug:channel_id>/video/",
PlaylistApiView.as_view(), ChannelApiVideoView.as_view(),
name="api-playlist", name="api-channel-video",
), ),
path( path(
"playlist/", "playlist/",
PlaylistApiListView.as_view(), PlaylistApiListView.as_view(),
name="api-playlist-list", name="api-playlist-list",
), ),
path(
"playlist/<slug:playlist_id>/",
PlaylistApiView.as_view(),
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(),
@@ -69,4 +98,29 @@ urlpatterns = [
DownloadApiView.as_view(), DownloadApiView.as_view(),
name="api-download", name="api-download",
), ),
path(
"task/",
TaskApiView.as_view(),
name="api-task",
),
path(
"snapshot/",
SnapshotApiListView.as_view(),
name="api-snapshot-list",
),
path(
"snapshot/<slug:snapshot_id>/",
SnapshotApiView.as_view(),
name="api-snapshot",
),
path(
"cookie/",
CookieView.as_view(),
name="api-cookie",
),
path(
"search/",
SearchView.as_view(),
name="api-search",
),
] ]

View File

@@ -1,12 +1,17 @@
"""all API views""" """all API views"""
from api.src.search_processor import SearchProcess 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_dlp_base import CookieHandler
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.es.snapshot import ElasticSnapshot
from home.src.frontend.searching import SearchForm
from home.src.index.generic import Pagination
from home.src.index.video import SponsorBlock 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,
@@ -25,17 +30,19 @@ 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, "config": AppConfig().config} 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.pagination_handler = False
def get_document(self, document_id): def get_document(self, document_id):
"""get single document from es""" """get single document from es"""
path = f"{self.search_base}{document_id}" path = f"{self.search_base}{document_id}"
print(path)
response, status_code = ElasticWrap(path).get() response, status_code = ElasticWrap(path).get()
try: try:
self.response["data"] = SearchProcess(response).process() self.response["data"] = SearchProcess(response).process()
@@ -44,35 +51,34 @@ class ApiBaseView(APIView):
self.response["data"] = False self.response["data"] = False
self.status_code = 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() self.pagination_handler = Pagination(request)
if "media_url" in all_keys: self.data.update(
media_url = self.response["data"]["media_url"] {
self.response["data"]["media_url"] = f"/media/{media_url}" "size": self.pagination_handler.pagination["page_size"],
if "vid_thumb_url" in all_keys: "from": self.pagination_handler.pagination["page_from"],
youtube_id = self.response["data"]["youtube_id"] }
vid_thumb_url = ThumbManager().vid_thumb_path(youtube_id) )
cache_dir = self.response["config"]["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, pagination=True):
"""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"""
print(self.search_base) if pagination:
response, status_code = ElasticWrap(self.search_base).get(data=data) self.initiate_pagination(request)
es_handler = ElasticWrap(self.search_base)
response, status_code = es_handler.get(data=self.data)
self.response["data"] = SearchProcess(response).process() self.response["data"] = SearchProcess(response).process()
if self.response["data"]:
self.status_code = status_code self.status_code = status_code
else:
self.status_code = 404
if pagination:
self.pagination_handler.validate(
response["hits"]["total"]["value"]
)
self.response["paginate"] = self.pagination_handler.pagination
class VideoApiView(ApiBaseView): class VideoApiView(ApiBaseView):
@@ -86,8 +92,6 @@ class VideoApiView(ApiBaseView):
# pylint: disable=unused-argument # pylint: disable=unused-argument
"""get request""" """get request"""
self.get_document(video_id) self.get_document(video_id)
if self.response.get("data"):
self.process_keys()
return Response(self.response, status=self.status_code) return Response(self.response, status=self.status_code)
@@ -99,11 +103,9 @@ class VideoApiListView(ApiBaseView):
search_base = "ta_video/_search/" search_base = "ta_video/_search/"
def get(self, request): def get(self, request):
# pylint: disable=unused-argument
"""get request""" """get request"""
data = {"query": {"match_all": {}}} self.data.update({"sort": [{"published": {"order": "desc"}}]})
self.get_document_list(data) self.get_document_list(request)
self.get_paginate()
return Response(self.response) return Response(self.response)
@@ -132,7 +134,7 @@ class VideoProgressView(ApiBaseView):
position = request.data.get("position", 0) position = request.data.get("position", 0)
key = f"{request.user.id}:progress:{video_id}" key = f"{request.user.id}:progress:{video_id}"
message = {"position": position, "youtube_id": video_id} message = {"position": position, "youtube_id": video_id}
RedisArchivist().set_message(key, message, expire=False) RedisArchivist().set_message(key, message)
self.response = request.data self.response = request.data
return Response(self.response) return Response(self.response)
@@ -146,6 +148,46 @@ class VideoProgressView(ApiBaseView):
return Response(self.response) return Response(self.response)
class VideoCommentView(ApiBaseView):
"""resolves to /api/video/<video_id>/comment/
handle video comments
GET: return all comments from video with reply threads
"""
search_base = "ta_comment/_doc/"
def get(self, request, video_id):
"""get video comments"""
# pylint: disable=unused-argument
self.get_document(video_id)
return Response(self.response, status=self.status_code)
class VideoSimilarView(ApiBaseView):
"""resolves to /api/video/<video-id>/similar/
GET: return max 3 videos similar to this
"""
search_base = "ta_video/_search/"
def get(self, request, video_id):
"""get similar videos"""
self.data = {
"size": 6,
"query": {
"more_like_this": {
"fields": ["tags", "title"],
"like": {"_id": video_id},
"min_term_freq": 1,
"max_query_terms": 25,
}
},
}
self.get_document_list(request, pagination=False)
return Response(self.response, status=self.status_code)
class VideoSponsorView(ApiBaseView): class VideoSponsorView(ApiBaseView):
"""resolves to /api/video/<video_id>/sponsor/ """resolves to /api/video/<video_id>/sponsor/
handle sponsor block integration handle sponsor block integration
@@ -218,11 +260,11 @@ class ChannelApiListView(ApiBaseView):
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.get_document_list(data) self.data.update(
self.get_paginate() {"sort": [{"channel_name.keyword": {"order": "asc"}}]}
)
return Response(self.response) return Response(self.response)
@@ -244,6 +286,44 @@ 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
@@ -258,28 +338,33 @@ class PlaylistApiView(ApiBaseView):
return Response(self.response, status=self.status_code) return Response(self.response, status=self.status_code)
class PlaylistApiListView(ApiBaseView): class PlaylistApiVideoView(ApiBaseView):
"""resolves to /api/playlist/ """resolves to /api/playlist/<playlist_id>/video
GET: returns list of indexed playlists GET: returns list of videos in playlist
""" """
search_base = "ta_playlist/_search/" search_base = "ta_video/_search/"
def get(self, request): def get(self, request, playlist_id):
# pylint: disable=unused-argument
"""handle get request""" """handle get request"""
data = {"query": {"match_all": {}}} self.data["query"] = {
self.get_document_list(data) "term": {"playlist.keyword": {"value": playlist_id}}
self.get_paginate() }
return Response(self.response) 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
@@ -287,27 +372,68 @@ class DownloadApiView(ApiBaseView):
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.get_document_list(data) self.data.update({"sort": [{"timestamp": {"order": "asc"}}]})
self.get_paginate()
must_list = []
if query_filter:
if query_filter not in self.valid_filter:
message = f"invalid url query filder: {query_filter}"
print(message)
return Response({"message": message}, status=400)
must_list.append({"term": {"status": {"value": query_filter}}})
filter_channel = request.GET.get("channel", False)
if filter_channel:
must_list.append(
{"term": {"channel_id": {"value": filter_channel}}}
)
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
return Response(self.response) return Response(self.response)
@staticmethod @staticmethod
def post(request): def post(request):
"""add list of videos to download queue""" """add list of videos to download queue"""
print(f"request meta data: {request.META}")
data = request.data data = request.data
try: try:
to_add = data["data"] to_add = data["data"]
@@ -329,6 +455,20 @@ class DownloadApiListView(ApiBaseView):
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): class PingView(ApiBaseView):
"""resolves to /api/ping/ """resolves to /api/ping/
@@ -360,3 +500,161 @@ class LoginApiView(ObtainAuthToken):
print(f"returning token for user with id {user.pk}") print(f"returning token for user with id {user.pk}")
return Response({"token": token.key, "user_id": user.pk}) return Response({"token": token.key, "user_id": user.pk})
class TaskApiView(ApiBaseView):
"""resolves to /api/task/
GET: check if ongoing background task
POST: start a new background task
"""
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
response = {"rescan": False, "downloading": False}
for key in response.keys():
response[key] = RedisArchivist().is_locked(key)
return Response(response)
def post(self, request):
"""handle post request"""
response = TaskHandler(request.data).run_task()
return Response(response)
class SnapshotApiListView(ApiBaseView):
"""resolves to /api/snapshot/
GET: returns snashot config plus list of existing snapshots
POST: take snapshot now
"""
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
snapshots = ElasticSnapshot().get_snapshot_stats()
return Response(snapshots)
@staticmethod
def post(request):
"""take snapshot now with post request"""
# pylint: disable=unused-argument
response = ElasticSnapshot().take_snapshot_now()
return Response(response)
class SnapshotApiView(ApiBaseView):
"""resolves to /api/snapshot/<snapshot-id>/
GET: return a single snapshot
POST: restore snapshot
DELETE: delete a snapshot
"""
@staticmethod
def get(request, snapshot_id):
"""handle get request"""
# pylint: disable=unused-argument
snapshot = ElasticSnapshot().get_single_snapshot(snapshot_id)
if not snapshot:
return Response({"message": "snapshot not found"}, status=404)
return Response(snapshot)
@staticmethod
def post(request, snapshot_id):
"""restore snapshot with post request"""
# pylint: disable=unused-argument
response = ElasticSnapshot().restore_all(snapshot_id)
if not response:
message = {"message": "failed to restore snapshot"}
return Response(message, status=400)
return Response(response)
@staticmethod
def delete(request, snapshot_id):
"""delete snapshot from index"""
# pylint: disable=unused-argument
response = ElasticSnapshot().delete_single_snapshot(snapshot_id)
if not response:
message = {"message": "failed to delete snapshot"}
return Response(message, status=400)
return Response(response)
class CookieView(ApiBaseView):
"""resolves to /api/cookie/
GET: check if cookie is enabled
POST: verify validity of cookie
PUT: import cookie
"""
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
config = AppConfig().config
valid = RedisArchivist().get_message("cookie:valid")
response = {"cookie_enabled": config["downloads"]["cookie_import"]}
response.update(valid)
return Response(response)
@staticmethod
def post(request):
"""handle post request"""
# pylint: disable=unused-argument
config = AppConfig().config
validated = CookieHandler(config).validate()
return Response({"cookie_validated": validated})
@staticmethod
def put(request):
"""handle put request"""
# pylint: disable=unused-argument
config = AppConfig().config
cookie = request.data.get("cookie")
if not cookie:
message = "missing cookie key in request data"
print(message)
return Response({"message": message}, status=400)
print(f"cookie preview:\n\n{cookie[:300]}")
handler = CookieHandler(config)
handler.set_cookie(cookie)
validated = handler.validate()
if not validated:
handler.revoke()
message = {"cookie_import": "fail", "cookie_validated": validated}
print(f"cookie: {message}")
return Response({"message": message}, status=400)
message = {"cookie_import": "done", "cookie_validated": validated}
return Response(message)
class SearchView(ApiBaseView):
"""resolves to /api/search/
GET: run a search with the string in the ?query parameter
"""
@staticmethod
def get(request):
"""handle get request
search through all indexes"""
search_query = request.GET.get("query", None)
if search_query is None:
return Response(
{"message": "no search query specified"}, status=400
)
search_results = SearchForm().multi_search(search_query)
return Response(search_results)

View File

@@ -14,7 +14,9 @@ import hashlib
from os import environ, path from os import environ, path
from pathlib import Path from pathlib import Path
import ldap
from corsheaders.defaults import default_headers from corsheaders.defaults import default_headers
from django_auth_ldap.config import LDAPSearch
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -30,7 +32,14 @@ SECRET_KEY = PW_HASH.hexdigest()
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = bool(environ.get("DJANGO_DEBUG")) DEBUG = bool(environ.get("DJANGO_DEBUG"))
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = [i.strip() for i in environ.get("TA_HOST").split()]
CSRF_TRUSTED_ORIGINS = []
for host in ALLOWED_HOSTS:
if host.startswith("http://") or host.startswith("https://"):
CSRF_TRUSTED_ORIGINS.append(host)
else:
CSRF_TRUSTED_ORIGINS.append(f"http://{host}")
# Application definition # Application definition
@@ -83,6 +92,96 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
if bool(environ.get("TA_LDAP")):
# pylint: disable=global-at-module-level
global AUTH_LDAP_SERVER_URI
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
global AUTH_LDAP_BIND_DN
AUTH_LDAP_BIND_DN = environ.get("TA_LDAP_BIND_DN")
global AUTH_LDAP_BIND_PASSWORD
AUTH_LDAP_BIND_PASSWORD = environ.get("TA_LDAP_BIND_PASSWORD")
"""
Since these are new environment variables, taking the opporunity to use
more accurate env names.
Given Names are *_technically_* different from Personal names, as people
who change their names have different given names and personal names,
and they go by personal names. Additionally, "LastName" is actually
incorrect for many cultures, such as Korea, where the
family name comes first, and the personal name comes last.
But we all know people are going to try to guess at these, so still want
to include names that people will guess, hence using first/last as well.
"""
# Attribute mapping options
global AUTH_LDAP_USER_ATTR_MAP_USERNAME
AUTH_LDAP_USER_ATTR_MAP_USERNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_USERNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_UID")
or "uid"
)
global AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME
AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_PERSONALNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_FIRSTNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_GIVENNAME")
or "givenName"
)
global AUTH_LDAP_USER_ATTR_MAP_SURNAME
AUTH_LDAP_USER_ATTR_MAP_SURNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_SURNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_LASTNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_FAMILYNAME")
or "sn"
)
global AUTH_LDAP_USER_ATTR_MAP_EMAIL
AUTH_LDAP_USER_ATTR_MAP_EMAIL = (
environ.get("TA_LDAP_USER_ATTR_MAP_EMAIL")
or environ.get("TA_LDAP_USER_ATTR_MAP_MAIL")
or "mail"
)
global AUTH_LDAP_USER_BASE
AUTH_LDAP_USER_BASE = environ.get("TA_LDAP_USER_BASE")
global AUTH_LDAP_USER_FILTER
AUTH_LDAP_USER_FILTER = environ.get("TA_LDAP_USER_FILTER")
global AUTH_LDAP_USER_SEARCH
# pylint: disable=no-member
AUTH_LDAP_USER_SEARCH = LDAPSearch(
AUTH_LDAP_USER_BASE,
ldap.SCOPE_SUBTREE,
"(&("
+ AUTH_LDAP_USER_ATTR_MAP_USERNAME
+ "=%(user)s)"
+ AUTH_LDAP_USER_FILTER
+ ")",
)
global AUTH_LDAP_USER_ATTR_MAP
AUTH_LDAP_USER_ATTR_MAP = {
"username": AUTH_LDAP_USER_ATTR_MAP_USERNAME,
"first_name": AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME,
"last_name": AUTH_LDAP_USER_ATTR_MAP_SURNAME,
"email": AUTH_LDAP_USER_ATTR_MAP_EMAIL,
}
if bool(environ.get("TA_LDAP_DISABLE_CERT_CHECK")):
global AUTH_LDAP_GLOBAL_OPTIONS
AUTH_LDAP_GLOBAL_OPTIONS = {
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
}
global AUTHENTICATION_BACKENDS
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
# Database # Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases # https://docs.djangoproject.com/en/3.2/ref/settings/#databases
@@ -146,7 +245,16 @@ 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",
@@ -154,4 +262,4 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings # TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.1.4" TA_VERSION = "v0.3.0"

View File

@@ -5,16 +5,18 @@ import sys
from django.apps import AppConfig from django.apps import AppConfig
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.es.index_setup import index_check from home.src.es.index_setup import ElasitIndexWrap
from home.src.es.snapshot import ElasticSnapshot
from home.src.ta.config import AppConfig as ArchivistConfig from home.src.ta.config import AppConfig as ArchivistConfig
from home.src.ta.helper import clear_dl_cache
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
class StartupCheck: class StartupCheck:
"""checks to run at application startup""" """checks to run at application startup"""
MIN_MAJOR, MAX_MAJOR = 7, 7 MIN_MAJOR, MAX_MAJOR = 8, 8
MIN_MINOR = 17 MIN_MINOR = 0
def __init__(self): def __init__(self):
self.config_handler = ArchivistConfig() self.config_handler = ArchivistConfig()
@@ -26,9 +28,12 @@ class StartupCheck:
print("run startup checks") print("run startup checks")
self.es_version_check() self.es_version_check()
self.release_lock() self.release_lock()
index_check() ElasitIndexWrap().setup()
self.sync_redis_state() self.sync_redis_state()
self.set_redis_conf()
self.make_folders() self.make_folders()
clear_dl_cache(self.config_handler.config)
self.snapshot_check()
self.set_has_run() self.set_has_run()
def get_has_run(self): def get_has_run(self):
@@ -45,6 +50,10 @@ class StartupCheck:
print("sync redis") print("sync redis")
self.config_handler.load_new_defaults() self.config_handler.load_new_defaults()
def set_redis_conf(self):
"""set conf values for redis"""
self.redis_con.conn.config_set("timeout", 3600)
def make_folders(self): def make_folders(self):
"""make needed cache folders here so docker doesn't mess it up""" """make needed cache folders here so docker doesn't mess it up"""
folders = [ folders = [
@@ -58,10 +67,7 @@ class StartupCheck:
cache_dir = self.config_handler.config["application"]["cache_dir"] cache_dir = self.config_handler.config["application"]["cache_dir"]
for folder in folders: for folder in folders:
folder_path = os.path.join(cache_dir, folder) folder_path = os.path.join(cache_dir, folder)
try: os.makedirs(folder_path, exist_ok=True)
os.makedirs(folder_path)
except FileExistsError:
continue
def release_lock(self): def release_lock(self):
"""make sure there are no leftover locks set in redis""" """make sure there are no leftover locks set in redis"""
@@ -72,12 +78,21 @@ class StartupCheck:
"dl_queue", "dl_queue",
"dl_queue_id", "dl_queue_id",
"rescan", "rescan",
"run_backup",
] ]
for lock in all_locks: for lock in all_locks:
response = self.redis_con.del_message(lock) response = self.redis_con.del_message(lock)
if response: if response:
print("deleted leftover key from redis: " + lock) print("deleted leftover key from redis: " + lock)
def snapshot_check(self):
"""setup snapshot config, create if needed"""
app = self.config_handler.config["application"]
if not app.get("enable_snapshot"):
return
ElasticSnapshot().setup()
def is_invalid(self, version): def is_invalid(self, version):
"""return true if es version is invalid, false if ok""" """return true if es version is invalid, false if ok"""
major, minor = [int(i) for i in version.split(".")[:2]] major, minor = [int(i) for i in version.split(".")[:2]]

View File

@@ -8,7 +8,8 @@
"home": "grid", "home": "grid",
"channel": "list", "channel": "list",
"downloads": "list", "downloads": "list",
"playlist": "grid" "playlist": "grid",
"grid_items": 3
}, },
"subscriptions": { "subscriptions": {
"auto_search": false, "auto_search": false,
@@ -26,6 +27,9 @@
"subtitle": false, "subtitle": false,
"subtitle_source": false, "subtitle_source": false,
"subtitle_index": false, "subtitle_index": false,
"comment_max": false,
"comment_sort": "top",
"cookie_import": false,
"throttledratelimit": false, "throttledratelimit": false,
"integrate_ryd": false, "integrate_ryd": false,
"integrate_sponsorblock": false "integrate_sponsorblock": false
@@ -34,9 +38,9 @@
"app_root": "/app", "app_root": "/app",
"cache_dir": "/cache", "cache_dir": "/cache",
"videos": "/youtube", "videos": "/youtube",
"file_template": "%(id)s_%(title)s.mp4",
"colors": "dark", "colors": "dark",
"enable_cast": false "enable_cast": false,
"enable_snapshot": false
}, },
"scheduler": { "scheduler": {
"update_subscribed": false, "update_subscribed": false,

View File

@@ -7,14 +7,15 @@ Functionality:
import json import json
from datetime import datetime from datetime import datetime
import yt_dlp
from home.src.download.subscriptions import ( from home.src.download.subscriptions import (
ChannelSubscription, ChannelSubscription,
PlaylistSubscription, PlaylistSubscription,
) )
from home.src.download.thumbnails import ThumbManager from home.src.download.thumbnails import ThumbManager
from home.src.download.yt_dlp_base import YtWrap
from home.src.es.connect import ElasticWrap, IndexPaginate 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.helper import DurationConverter from home.src.ta.helper import DurationConverter
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
@@ -101,7 +102,7 @@ class PendingInteract:
def delete_item(self): def delete_item(self):
"""delete single item from pending""" """delete single item from pending"""
path = f"ta_download/_doc/{self.video_id}" path = f"ta_download/_doc/{self.video_id}"
_, _ = ElasticWrap(path).delete() _, _ = ElasticWrap(path).delete(refresh=True)
def delete_by_status(self): def delete_by_status(self):
"""delete all matching item by status""" """delete all matching item by status"""
@@ -119,8 +120,19 @@ class PendingInteract:
class PendingList(PendingIndex): class PendingList(PendingIndex):
"""manage the pending videos list""" """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): def __init__(self, youtube_ids=False):
super().__init__() super().__init__()
self.config = AppConfig().config
self.youtube_ids = youtube_ids self.youtube_ids = youtube_ids
self.to_skip = False self.to_skip = False
self.missing_videos = False self.missing_videos = False
@@ -138,7 +150,7 @@ class PendingList(PendingIndex):
"title": "Adding to download queue.", "title": "Adding to download queue.",
"message": "Extracting lists", "message": "Extracting lists",
} }
RedisArchivist().set_message("message:add", mess_dict) RedisArchivist().set_message("message:add", mess_dict, expire=True)
self._process_entry(entry) self._process_entry(entry)
def _process_entry(self, entry): def _process_entry(self, entry):
@@ -149,10 +161,7 @@ class PendingList(PendingIndex):
self._parse_channel(entry["url"]) self._parse_channel(entry["url"])
elif entry["type"] == "playlist": elif entry["type"] == "playlist":
self._parse_playlist(entry["url"]) self._parse_playlist(entry["url"])
new_thumbs = PlaylistSubscription().process_url_str( PlaylistSubscription().process_url_str([entry], subscribed=False)
[entry], subscribed=False
)
ThumbManager().download_playlist(new_thumbs)
else: else:
raise ValueError(f"invalid url_type: {entry}") raise ValueError(f"invalid url_type: {entry}")
@@ -160,6 +169,8 @@ class PendingList(PendingIndex):
"""add video to list""" """add video to list"""
if url not in self.missing_videos and url not in self.to_skip: if url not in self.missing_videos and url not in self.to_skip:
self.missing_videos.append(url) self.missing_videos.append(url)
else:
print(f"{url}: skipped adding already indexed video to download.")
def _parse_channel(self, url): def _parse_channel(self, url):
"""add all videos of channel to list""" """add all videos of channel to list"""
@@ -184,8 +195,8 @@ class PendingList(PendingIndex):
self.get_channels() self.get_channels()
bulk_list = [] bulk_list = []
thumb_handler = ThumbManager()
for idx, youtube_id in enumerate(self.missing_videos): for idx, youtube_id in enumerate(self.missing_videos):
print(f"{youtube_id}: add to download queue")
video_details = self.get_youtube_details(youtube_id) video_details = self.get_youtube_details(youtube_id)
if not video_details: if not video_details:
continue continue
@@ -195,10 +206,12 @@ class PendingList(PendingIndex):
bulk_list.append(json.dumps(action)) bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(video_details)) bulk_list.append(json.dumps(video_details))
thumb_needed = [(youtube_id, video_details["vid_thumb_url"])] url = video_details["vid_thumb_url"]
thumb_handler.download_vid(thumb_needed) ThumbManager(youtube_id).download_video_thumb(url)
self._notify_add(idx) self._notify_add(idx)
if bulk_list:
# add last newline # add last newline
bulk_list.append("\n") bulk_list.append("\n")
query_str = "\n".join(bulk_list) query_str = "\n".join(bulk_list)
@@ -214,30 +227,26 @@ class PendingList(PendingIndex):
"message": "Progress: " + progress, "message": "Progress: " + progress,
} }
if idx + 1 == len(self.missing_videos): if idx + 1 == len(self.missing_videos):
RedisArchivist().set_message("message:add", mess_dict, expire=4) expire = 4
else: else:
RedisArchivist().set_message("message:add", mess_dict) expire = True
RedisArchivist().set_message("message:add", mess_dict, expire=expire)
if idx + 1 % 25 == 0: if idx + 1 % 25 == 0:
print("adding to queue progress: " + progress) print("adding to queue progress: " + progress)
def get_youtube_details(self, youtube_id): def get_youtube_details(self, youtube_id):
"""get details from youtubedl for single pending video""" """get details from youtubedl for single pending video"""
obs = { vid = YtWrap(self.yt_obs, self.config).extract(youtube_id)
"default_search": "ytsearch", if not vid:
"quiet": True, return False
"check_formats": "selected",
"noplaylist": True, if vid.get("id") != youtube_id:
"writethumbnail": True, # skip premium videos with different id
"simulate": True, print(f"{youtube_id}: skipping premium video, id not matching")
}
try:
vid = yt_dlp.YoutubeDL(obs).extract_info(youtube_id)
except yt_dlp.utils.DownloadError:
print("failed to extract info for: " + youtube_id)
return False return False
# stop if video is streaming live now # stop if video is streaming live now
if vid["is_live"]: if vid["live_status"] in ["is_upcoming", "is_live"]:
return False return False
return self._parse_youtube_details(vid) return self._parse_youtube_details(vid)
@@ -259,9 +268,12 @@ class PendingList(PendingIndex):
"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")),
} }
if self.all_channels:
youtube_details.update(
{"channel_indexed": vid["channel_id"] in self.all_channels}
)
return youtube_details return youtube_details

View File

@@ -4,8 +4,9 @@ Functionality:
- handle playlist subscriptions - handle playlist subscriptions
""" """
import yt_dlp
from home.src.download import queue # partial import from home.src.download import queue # partial import
from home.src.download.thumbnails import ThumbManager
from home.src.download.yt_dlp_base import YtWrap
from home.src.es.connect import IndexPaginate from home.src.es.connect import 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
@@ -17,10 +18,7 @@ class ChannelSubscription:
"""manage the list of channels subscribed""" """manage the list of channels subscribed"""
def __init__(self): def __init__(self):
config = AppConfig().config self.config = AppConfig().config
self.es_url = config["application"]["es_url"]
self.es_auth = config["application"]["es_auth"]
self.channel_size = config["subscriptions"]["channel_size"]
@staticmethod @staticmethod
def get_channels(subscribed_only=True): def get_channels(subscribed_only=True):
@@ -39,23 +37,19 @@ class ChannelSubscription:
def get_last_youtube_videos(self, channel_id, limit=True): def get_last_youtube_videos(self, channel_id, limit=True):
"""get a list of last videos from channel""" """get a list of last videos from channel"""
url = f"https://www.youtube.com/channel/{channel_id}/videos"
obs = { obs = {
"default_search": "ytsearch",
"quiet": True,
"skip_download": True, "skip_download": True,
"extract_flat": True, "extract_flat": True,
} }
if limit: if limit:
obs["playlistend"] = self.channel_size obs["playlistend"] = self.config["subscriptions"]["channel_size"]
try: url = f"https://www.youtube.com/channel/{channel_id}/videos"
chan = yt_dlp.YoutubeDL(obs).extract_info(url, download=False) channel = YtWrap(obs, self.config).extract(url)
except yt_dlp.utils.DownloadError: if not channel:
print(f"{channel_id}: failed to extract videos, skipping.")
return False return False
last_videos = [(i["id"], i["title"]) for i in chan["entries"]] last_videos = [(i["id"], i["title"]) for i in channel["entries"]]
return last_videos return last_videos
def find_missing(self): def find_missing(self):
@@ -69,6 +63,7 @@ class ChannelSubscription:
for idx, channel in enumerate(all_channels): for idx, channel in enumerate(all_channels):
channel_id = channel["channel_id"] channel_id = channel["channel_id"]
print(f"{channel_id}: find missing videos.")
last_videos = self.get_last_youtube_videos(channel_id) last_videos = self.get_last_youtube_videos(channel_id)
if last_videos: if last_videos:
@@ -83,11 +78,13 @@ class ChannelSubscription:
"message": f"Progress: {idx + 1}/{len(all_channels)}", "message": f"Progress: {idx + 1}/{len(all_channels)}",
} }
if idx + 1 == len(all_channels): if idx + 1 == len(all_channels):
RedisArchivist().set_message( expire = 4
"message:rescan", message=message, expire=4
)
else: else:
RedisArchivist().set_message("message:rescan", message=message) expire = True
RedisArchivist().set_message(
"message:rescan", message=message, expire=expire
)
return missing_videos return missing_videos
@@ -134,11 +131,9 @@ class PlaylistSubscription:
all_indexed = IndexPaginate("ta_video", data).get_results() 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 = []
for idx, playlist in enumerate(new_playlists): for idx, playlist in enumerate(new_playlists):
url_type = playlist["type"]
playlist_id = playlist["url"] playlist_id = playlist["url"]
if not url_type == "playlist": if not playlist["type"] == "playlist":
print(f"{playlist_id} not a playlist, skipping...") print(f"{playlist_id} not a playlist, skipping...")
continue continue
@@ -149,8 +144,11 @@ class PlaylistSubscription:
playlist_h.upload_to_es() playlist_h.upload_to_es()
playlist_h.add_vids_to_playlist() playlist_h.add_vids_to_playlist()
self.channel_validate(playlist_h.json_data["playlist_channel_id"]) self.channel_validate(playlist_h.json_data["playlist_channel_id"])
thumb = playlist_h.json_data["playlist_thumbnail"]
new_thumbs.append((playlist_id, thumb)) url = playlist_h.json_data["playlist_thumbnail"]
thumb = ThumbManager(playlist_id, item_type="playlist")
thumb.download_playlist_thumb(url)
# notify # notify
message = { message = {
"status": "message:subplaylist", "status": "message:subplaylist",
@@ -159,16 +157,14 @@ class PlaylistSubscription:
"message": f"Processing {idx + 1} of {len(new_playlists)}", "message": f"Processing {idx + 1} of {len(new_playlists)}",
} }
RedisArchivist().set_message( RedisArchivist().set_message(
"message:subplaylist", message=message "message:subplaylist", message=message, expire=True
) )
return new_thumbs
@staticmethod @staticmethod
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):
@@ -213,7 +209,9 @@ class PlaylistSubscription:
"title": "Scanning playlists: Looking for new videos.", "title": "Scanning playlists: Looking for new videos.",
"message": f"Progress: {idx + 1}/{len(all_playlists)}", "message": f"Progress: {idx + 1}/{len(all_playlists)}",
} }
RedisArchivist().set_message("message:rescan", message=message) RedisArchivist().set_message(
"message:rescan", message=message, expire=True
)
for video in all_missing: for video in all_missing:
youtube_id = video["youtube_id"] youtube_id = video["youtube_id"]

View File

@@ -6,134 +6,71 @@ functionality:
import base64 import base64
import os import os
from collections import Counter
from io import BytesIO from io import BytesIO
from time import sleep from time import sleep
import requests import requests
from home.src.download import queue # partial import from home.src.download import queue # partial import
from home.src.download import subscriptions # partial import from home.src.es.connect import 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.ta_redis import RedisArchivist
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
from PIL import Image, ImageFilter from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
ImageFile.LOAD_TRUNCATED_IMAGES = True
class ThumbManager: class ThumbManagerBase:
"""handle thumbnails related functions""" """base class for thumbnail management"""
CONFIG = AppConfig().config CONFIG = AppConfig().config
MEDIA_DIR = CONFIG["application"]["videos"]
CACHE_DIR = CONFIG["application"]["cache_dir"] CACHE_DIR = CONFIG["application"]["cache_dir"]
VIDEO_DIR = os.path.join(CACHE_DIR, "videos") VIDEO_DIR = os.path.join(CACHE_DIR, "videos")
CHANNEL_DIR = os.path.join(CACHE_DIR, "channels") CHANNEL_DIR = os.path.join(CACHE_DIR, "channels")
PLAYLIST_DIR = os.path.join(CACHE_DIR, "playlists") PLAYLIST_DIR = os.path.join(CACHE_DIR, "playlists")
def get_all_thumbs(self): def __init__(self, item_id, item_type, fallback=False):
"""get all video artwork already downloaded""" self.item_id = item_id
all_thumb_folders = ignore_filelist(os.listdir(self.VIDEO_DIR)) self.item_type = item_type
all_thumbs = [] self.fallback = fallback
for folder in all_thumb_folders:
folder_path = os.path.join(self.VIDEO_DIR, folder)
if os.path.isfile(folder_path):
self.update_path(folder)
all_thumbs.append(folder_path)
continue
# raise exemption here in a future version
# raise FileExistsError("video cache dir has files inside")
all_folder_thumbs = ignore_filelist(os.listdir(folder_path)) def download_raw(self, url):
all_thumbs.extend(all_folder_thumbs) """download thumbnail for video"""
if not url:
return self.get_fallback()
return all_thumbs for i in range(3):
def update_path(self, file_name):
"""reorganize thumbnails into folders as update path from v0.0.5"""
folder_name = file_name[0].lower()
folder_path = os.path.join(self.VIDEO_DIR, folder_name)
old_file = os.path.join(self.VIDEO_DIR, file_name)
new_file = os.path.join(folder_path, file_name)
os.makedirs(folder_path, exist_ok=True)
os.rename(old_file, new_file)
def get_needed_thumbs(self, missing_only=False):
"""get a list of all missing thumbnails"""
all_thumbs = self.get_all_thumbs()
pending = queue.PendingList()
pending.get_download()
pending.get_indexed()
needed_thumbs = []
for video in pending.all_videos:
youtube_id = video["youtube_id"]
thumb_url = video["vid_thumb_url"]
if missing_only:
if youtube_id + ".jpg" not in all_thumbs:
needed_thumbs.append((youtube_id, thumb_url))
else:
needed_thumbs.append((youtube_id, thumb_url))
for video in pending.all_pending + pending.all_ignored:
youtube_id = video["youtube_id"]
thumb_url = video["vid_thumb_url"]
if missing_only:
if youtube_id + ".jpg" not in all_thumbs:
needed_thumbs.append((youtube_id, thumb_url))
else:
needed_thumbs.append((youtube_id, thumb_url))
return needed_thumbs
def get_missing_channels(self):
"""get all channel artwork"""
all_channel_art = os.listdir(self.CHANNEL_DIR)
files = [i[0:24] for i in all_channel_art]
cached_channel_ids = [k for (k, v) in Counter(files).items() if v > 1]
channel_sub = subscriptions.ChannelSubscription()
channels = channel_sub.get_channels(subscribed_only=False)
missing_channels = []
for channel in channels:
channel_id = channel["channel_id"]
if channel_id not in cached_channel_ids:
channel_banner = channel["channel_banner_url"]
channel_thumb = channel["channel_thumb_url"]
missing_channels.append(
(channel_id, channel_thumb, channel_banner)
)
return missing_channels
def get_missing_playlists(self):
"""get all missing playlist artwork"""
all_downloaded = ignore_filelist(os.listdir(self.PLAYLIST_DIR))
all_ids_downloaded = [i.replace(".jpg", "") for i in all_downloaded]
playlist_sub = subscriptions.PlaylistSubscription()
playlists = playlist_sub.get_playlists(subscribed_only=False)
missing_playlists = []
for playlist in playlists:
playlist_id = playlist["playlist_id"]
if playlist_id not in all_ids_downloaded:
playlist_thumb = playlist["playlist_thumbnail"]
missing_playlists.append((playlist_id, playlist_thumb))
return missing_playlists
def get_raw_img(self, img_url, thumb_type):
"""get raw image from youtube and handle 404"""
try: try:
response = requests.get(url, stream=True, timeout=5)
if response.ok:
try:
return Image.open(response.raw)
except UnidentifiedImageError:
print(f"failed to open thumbnail: {url}")
return self.get_fallback()
if response.status_code == 404:
return self.get_fallback()
except requests.exceptions.RequestException:
print(f"{self.item_id}: retry thumbnail download {url}")
sleep((i + 1) ** i)
return False
def get_fallback(self):
"""get fallback thumbnail if not available"""
if self.fallback:
img_raw = Image.open(self.fallback)
return img_raw
app_root = self.CONFIG["application"]["app_root"] app_root = self.CONFIG["application"]["app_root"]
except KeyError:
# lazy keyerror fix to not have to deal with a strange startup
# racing contition between the threads in HomeConfig.ready()
app_root = "/app"
default_map = { default_map = {
"video": os.path.join( "video": os.path.join(
app_root, "static/img/default-video-thumb.jpg" app_root, "static/img/default-video-thumb.jpg"
), ),
"playlist": os.path.join(
app_root, "static/img/default-video-thumb.jpg"
),
"icon": os.path.join( "icon": os.path.join(
app_root, "static/img/default-channel-icon.jpg" app_root, "static/img/default-channel-icon.jpg"
), ),
@@ -141,112 +78,138 @@ class ThumbManager:
app_root, "static/img/default-channel-banner.jpg" app_root, "static/img/default-channel-banner.jpg"
), ),
} }
if img_url:
try: img_raw = Image.open(default_map[self.item_type])
response = requests.get(img_url, stream=True)
except ConnectionError:
sleep(5)
response = requests.get(img_url, stream=True)
if not response.ok and not response.status_code == 404:
print("retry thumbnail download for " + img_url)
sleep(5)
response = requests.get(img_url, stream=True)
else:
response = False
if not response or response.status_code == 404:
# use default
img_raw = Image.open(default_map[thumb_type])
else:
# use response
img_obj = response.raw
img_raw = Image.open(img_obj)
return img_raw return img_raw
def download_vid(self, missing_thumbs, notify=True):
"""download all missing thumbnails from list""" class ThumbManager(ThumbManagerBase):
print(f"downloading {len(missing_thumbs)} thumbnails") """handle thumbnails related functions"""
for idx, (youtube_id, thumb_url) in enumerate(missing_thumbs):
folder_path = os.path.join(self.VIDEO_DIR, youtube_id[0].lower()) def __init__(self, item_id, item_type="video", fallback=False):
thumb_path = os.path.join( super().__init__(item_id, item_type, fallback=fallback)
self.CACHE_DIR, self.vid_thumb_path(youtube_id)
) def download(self, url):
"""download thumbnail"""
print(f"{self.item_id}: download {self.item_type} thumbnail")
if self.item_type == "video":
self.download_video_thumb(url)
elif self.item_type == "channel":
self.download_channel_art(url)
elif self.item_type == "playlist":
self.download_playlist_thumb(url)
def delete(self):
"""delete thumbnail file"""
print(f"{self.item_id}: delete {self.item_type} thumbnail")
if self.item_type == "video":
self.delete_video_thumb()
elif self.item_type == "channel":
self.delete_channel_thumb()
elif self.item_type == "playlist":
self.delete_playlist_thumb()
def download_video_thumb(self, url, skip_existing=False):
"""pass url for video thumbnail"""
folder_path = os.path.join(self.VIDEO_DIR, self.item_id[0].lower())
thumb_path = self.vid_thumb_path(absolute=True)
if skip_existing and os.path.exists(thumb_path):
return
os.makedirs(folder_path, exist_ok=True) os.makedirs(folder_path, exist_ok=True)
img_raw = self.get_raw_img(thumb_url, "video") img_raw = self.download_raw(url)
width, height = img_raw.size width, height = img_raw.size
if not width / height == 16 / 9: if not width / height == 16 / 9:
new_height = width / 16 * 9 new_height = width / 16 * 9
offset = (height - new_height) / 2 offset = (height - new_height) / 2
img_raw = img_raw.crop((0, offset, width, height - offset)) img_raw = img_raw.crop((0, offset, width, height - offset))
img_raw.convert("RGB").save(thumb_path) img_raw.convert("RGB").save(thumb_path)
progress = f"{idx + 1}/{len(missing_thumbs)}" def vid_thumb_path(self, absolute=False, create_folder=False):
if notify: """build expected path for video thumbnail from youtube_id"""
mess_dict = { folder_name = self.item_id[0].lower()
"status": "message:add", folder_path = os.path.join("videos", folder_name)
"level": "info", thumb_path = os.path.join(folder_path, f"{self.item_id}.jpg")
"title": "Processing Videos", if absolute:
"message": "Downloading Thumbnails, Progress: " + progress, thumb_path = os.path.join(self.CACHE_DIR, thumb_path)
}
if idx + 1 == len(missing_thumbs):
RedisArchivist().set_message(
"message:add", mess_dict, expire=4
)
else:
RedisArchivist().set_message("message:add", mess_dict)
if idx + 1 % 25 == 0: if create_folder:
print("thumbnail progress: " + progress) folder_path = os.path.join(self.CACHE_DIR, folder_path)
os.makedirs(folder_path, exist_ok=True)
def download_chan(self, missing_channels): return thumb_path
"""download needed artwork for channels"""
print(f"downloading {len(missing_channels)} channel artwork") def download_channel_art(self, urls, skip_existing=False):
for channel in missing_channels: """pass tuple of channel thumbnails"""
channel_id, channel_thumb, channel_banner = channel channel_thumb, channel_banner = urls
self._download_channel_thumb(channel_thumb, skip_existing)
self._download_channel_banner(channel_banner, skip_existing)
def _download_channel_thumb(self, channel_thumb, skip_existing):
"""download channel thumbnail"""
thumb_path = os.path.join( thumb_path = os.path.join(
self.CHANNEL_DIR, channel_id + "_thumb.jpg" self.CHANNEL_DIR, f"{self.item_id}_thumb.jpg"
) )
img_raw = self.get_raw_img(channel_thumb, "icon") self.item_type = "icon"
if skip_existing and os.path.exists(thumb_path):
return
img_raw = self.download_raw(channel_thumb)
img_raw.convert("RGB").save(thumb_path) img_raw.convert("RGB").save(thumb_path)
def _download_channel_banner(self, channel_banner, skip_existing):
"""download channel banner"""
banner_path = os.path.join( banner_path = os.path.join(
self.CHANNEL_DIR, channel_id + "_banner.jpg" self.CHANNEL_DIR, self.item_id + "_banner.jpg"
) )
img_raw = self.get_raw_img(channel_banner, "banner") self.item_type = "banner"
if skip_existing and os.path.exists(banner_path):
return
img_raw = self.download_raw(channel_banner)
img_raw.convert("RGB").save(banner_path) img_raw.convert("RGB").save(banner_path)
mess_dict = { def download_playlist_thumb(self, url, skip_existing=False):
"status": "message:download", """pass thumbnail url"""
"level": "info", thumb_path = os.path.join(self.PLAYLIST_DIR, f"{self.item_id}.jpg")
"title": "Processing Channels", if skip_existing and os.path.exists(thumb_path):
"message": "Downloading Channel Art.", return
}
RedisArchivist().set_message("message:download", mess_dict)
def download_playlist(self, missing_playlists): img_raw = self.download_raw(url)
"""download needed artwork for playlists"""
print(f"downloading {len(missing_playlists)} playlist artwork")
for playlist in missing_playlists:
playlist_id, playlist_thumb_url = playlist
thumb_path = os.path.join(self.PLAYLIST_DIR, playlist_id + ".jpg")
img_raw = self.get_raw_img(playlist_thumb_url, "video")
img_raw.convert("RGB").save(thumb_path) img_raw.convert("RGB").save(thumb_path)
mess_dict = { def delete_video_thumb(self):
"status": "message:download", """delete video thumbnail if exists"""
"level": "info", thumb_path = self.vid_thumb_path()
"title": "Processing Playlists", to_delete = os.path.join(self.CACHE_DIR, thumb_path)
"message": "Downloading Playlist Art.", if os.path.exists(to_delete):
} os.remove(to_delete)
RedisArchivist().set_message("message:download", mess_dict)
def get_base64_blur(self, youtube_id): def delete_channel_thumb(self):
"""delete all artwork of channel"""
thumb = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_thumb.jpg")
banner = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_banner.jpg")
if os.path.exists(thumb):
os.remove(thumb)
if os.path.exists(banner):
os.remove(banner)
def delete_playlist_thumb(self):
"""delete playlist thumbnail"""
thumb_path = os.path.join(self.PLAYLIST_DIR, f"{self.item_id}.jpg")
if os.path.exists(thumb_path):
os.remove(thumb_path)
def get_vid_base64_blur(self):
"""return base64 encoded placeholder""" """return base64 encoded placeholder"""
img_path = self.vid_thumb_path(youtube_id) file_path = os.path.join(self.CACHE_DIR, self.vid_thumb_path())
file_path = os.path.join(self.CACHE_DIR, img_path)
img_raw = Image.open(file_path) img_raw = Image.open(file_path)
img_raw.thumbnail((img_raw.width // 20, img_raw.height // 20)) img_raw.thumbnail((img_raw.width // 20, img_raw.height // 20))
img_blur = img_raw.filter(ImageFilter.BLUR) img_blur = img_raw.filter(ImageFilter.BLUR)
@@ -258,52 +221,122 @@ class ThumbManager:
return data_url return data_url
@staticmethod
def vid_thumb_path(youtube_id):
"""build expected path for video thumbnail from youtube_id"""
folder_name = youtube_id[0].lower()
folder_path = os.path.join("videos", folder_name)
thumb_path = os.path.join(folder_path, youtube_id + ".jpg")
return thumb_path
def delete_vid_thumb(self, youtube_id): class ValidatorCallback:
"""delete video thumbnail if exists""" """handle callback validate thumbnails page by page"""
thumb_path = self.vid_thumb_path(youtube_id)
to_delete = os.path.join(self.CACHE_DIR, thumb_path)
if os.path.exists(to_delete):
os.remove(to_delete)
def delete_chan_thumb(self, channel_id): def __init__(self, source, index_name):
"""delete all artwork of channel""" self.source = source
thumb = os.path.join(self.CHANNEL_DIR, channel_id + "_thumb.jpg") self.index_name = index_name
banner = os.path.join(self.CHANNEL_DIR, channel_id + "_banner.jpg")
if os.path.exists(thumb):
os.remove(thumb)
if os.path.exists(banner):
os.remove(banner)
def cleanup_downloaded(self): def run(self):
"""find downloaded thumbnails without video indexed""" """run the task for page"""
all_thumbs = self.get_all_thumbs() print(f"{self.index_name}: validate artwork")
all_indexed = self.get_needed_thumbs() if self.index_name == "ta_video":
all_needed_thumbs = [i[0] + ".jpg" for i in all_indexed] self._validate_videos()
for thumb in all_thumbs: elif self.index_name == "ta_channel":
if thumb not in all_needed_thumbs: self._validate_channels()
# cleanup elif self.index_name == "ta_playlist":
youtube_id = thumb.rstrip(".jpg") self._validate_playlists()
self.delete_vid_thumb(youtube_id)
def _validate_videos(self):
"""check if video thumbnails are correct"""
for video in self.source:
url = video["_source"]["vid_thumb_url"]
handler = ThumbManager(video["_source"]["youtube_id"])
handler.download_video_thumb(url, skip_existing=True)
def _validate_channels(self):
"""check if all channel artwork is there"""
for channel in self.source:
urls = (
channel["_source"]["channel_thumb_url"],
channel["_source"]["channel_banner_url"],
)
handler = ThumbManager(channel["_source"]["channel_id"])
handler.download_channel_art(urls, skip_existing=True)
def _validate_playlists(self):
"""check if all playlist artwork is there"""
for playlist in self.source:
url = playlist["_source"]["playlist_thumbnail"]
handler = ThumbManager(playlist["_source"]["playlist_id"])
handler.download_playlist_thumb(url, skip_existing=True)
class ThumbValidator:
"""validate thumbnails"""
def download_missing(self):
"""download all missing artwork"""
self.download_missing_videos()
self.download_missing_channels()
self.download_missing_playlists()
def download_missing_videos(self):
"""get all missing video thumbnails"""
data = {
"query": {"term": {"active": {"value": True}}},
"sort": [{"youtube_id": {"order": "asc"}}],
"_source": ["vid_thumb_url", "youtube_id"],
}
paginate = IndexPaginate(
"ta_video", data, size=5000, callback=ValidatorCallback
)
_ = paginate.get_results()
def download_missing_channels(self):
"""get all missing channel thumbnails"""
data = {
"query": {"term": {"channel_active": {"value": True}}},
"sort": [{"channel_id": {"order": "asc"}}],
"_source": {
"excludes": ["channel_description", "channel_overwrites"]
},
}
paginate = IndexPaginate(
"ta_channel", data, callback=ValidatorCallback
)
_ = paginate.get_results()
def download_missing_playlists(self):
"""get all missing playlist artwork"""
data = {
"query": {"term": {"playlist_active": {"value": True}}},
"sort": [{"playlist_id": {"order": "asc"}}],
"_source": ["playlist_id", "playlist_thumbnail"],
}
paginate = IndexPaginate(
"ta_playlist", data, callback=ValidatorCallback
)
_ = paginate.get_results()
class ThumbFilesystem:
"""filesystem tasks for thumbnails"""
CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"]
MEDIA_DIR = CONFIG["application"]["videos"]
VIDEO_DIR = os.path.join(CACHE_DIR, "videos")
def sync(self):
"""embed thumbnails to mediafiles"""
video_list = self.get_thumb_list()
self._embed_thumbs(video_list)
def get_thumb_list(self): def get_thumb_list(self):
"""get list of mediafiles and matching thumbnails""" """get list of mediafiles and matching thumbnails"""
pending = queue.PendingList() pending = queue.PendingList()
pending.get_download()
pending.get_indexed() pending.get_indexed()
video_list = [] video_list = []
for video in pending.all_videos: for video in pending.all_videos:
youtube_id = video["youtube_id"] video_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(
self.CACHE_DIR, self.vid_thumb_path(youtube_id) self.CACHE_DIR, ThumbManager(video_id).vid_thumb_path()
) )
video_list.append( video_list.append(
{ {
@@ -315,7 +348,7 @@ class ThumbManager:
return video_list return video_list
@staticmethod @staticmethod
def write_all_thumbs(video_list): def _embed_thumbs(video_list):
"""rewrite the thumbnail into media file""" """rewrite the thumbnail into media file"""
counter = 1 counter = 1
@@ -333,15 +366,3 @@ class ThumbManager:
if counter % 50 == 0: if counter % 50 == 0:
print(f"thumbnail write progress {counter}/{len(video_list)}") print(f"thumbnail write progress {counter}/{len(video_list)}")
counter = counter + 1 counter = counter + 1
def validate_thumbnails():
"""check if all thumbnails are there and organized correctly"""
handler = ThumbManager()
thumbs_to_download = handler.get_needed_thumbs(missing_only=True)
handler.download_vid(thumbs_to_download)
missing_channels = handler.get_missing_channels()
handler.download_chan(missing_channels)
missing_playlists = handler.get_missing_playlists()
handler.download_playlist(missing_playlists)
handler.cleanup_downloaded()

View File

@@ -0,0 +1,157 @@
"""
functionality:
- base class to make all calls to yt-dlp
- handle yt-dlp errors
"""
import os
from datetime import datetime
from http import cookiejar
from io import StringIO
import yt_dlp
from home.src.ta.ta_redis import RedisArchivist
class YtWrap:
"""wrap calls to yt"""
OBS_BASE = {
"default_search": "ytsearch",
"quiet": True,
"check_formats": "selected",
"socket_timeout": 3,
"extractor_retries": 3,
}
def __init__(self, obs_request, config=False):
self.obs_request = obs_request
self.config = config
self.build_obs()
def build_obs(self):
"""build yt-dlp obs"""
self.obs = self.OBS_BASE.copy()
self.obs.update(self.obs_request)
if self.config:
self.add_cookie()
def add_cookie(self):
"""add cookie if enabled"""
if self.config["downloads"]["cookie_import"]:
cookie_io = CookieHandler(self.config).get()
self.obs["cookiefile"] = cookie_io
def download(self, url):
"""make download request"""
with yt_dlp.YoutubeDL(self.obs) as ydl:
try:
ydl.download([url])
except yt_dlp.utils.DownloadError:
print(f"{url}: failed to download.")
return False
return True
def extract(self, url):
"""make extract request"""
try:
response = yt_dlp.YoutubeDL(self.obs).extract_info(url)
except cookiejar.LoadError:
print("cookie file is invalid")
return False
except (yt_dlp.utils.ExtractorError, yt_dlp.utils.DownloadError):
print(f"{url}: failed to get info from youtube")
return False
return response
class CookieHandler:
"""handle youtube cookie for yt-dlp"""
def __init__(self, config):
self.cookie_io = False
self.config = config
def get(self):
"""get cookie io stream"""
cookie = RedisArchivist().get_message("cookie")
self.cookie_io = StringIO(cookie)
return self.cookie_io
def import_cookie(self):
"""import cookie from file"""
cache_path = self.config["application"]["cache_dir"]
import_path = os.path.join(cache_path, "import", "cookies.google.txt")
try:
with open(import_path, encoding="utf-8") as cookie_file:
cookie = cookie_file.read()
except FileNotFoundError as err:
print(f"cookie: {import_path} file not found")
raise err
self.set_cookie(cookie)
os.remove(import_path)
print("cookie: import successful")
def set_cookie(self, cookie):
"""set cookie str and activate in cofig"""
RedisArchivist().set_message("cookie", cookie)
path = ".downloads.cookie_import"
RedisArchivist().set_message("config", True, path=path)
self.config["downloads"]["cookie_import"] = True
print("cookie: activated and stored in Redis")
@staticmethod
def revoke():
"""revoke cookie"""
RedisArchivist().del_message("cookie")
RedisArchivist().del_message("cookie:valid")
RedisArchivist().set_message(
"config", False, path=".downloads.cookie_import"
)
print("cookie: revoked")
def validate(self):
"""validate cookie using the liked videos playlist"""
print("validating cookie")
obs_request = {
"skip_download": True,
"extract_flat": True,
}
validator = YtWrap(obs_request, self.config)
response = bool(validator.extract("LL"))
self.store_validation(response)
# update in redis to avoid expiring
modified = validator.obs["cookiefile"].getvalue()
if modified:
RedisArchivist().set_message("cookie", modified)
if not response:
mess_dict = {
"status": "message:download",
"level": "error",
"title": "Cookie validation failed, exiting...",
"message": "",
}
RedisArchivist().set_message(
"message:download", mess_dict, expire=4
)
print("cookie validation failed, exiting...")
return response
@staticmethod
def store_validation(response):
"""remember last validation"""
now = datetime.now()
message = {
"status": response,
"validated": int(now.strftime("%s")),
"validated_str": now.strftime("%Y-%m-%d %H:%M"),
}
RedisArchivist().set_message("cookie:valid", message)

View File

@@ -9,13 +9,13 @@ functionality:
import os import os
import shutil import shutil
from datetime import datetime from datetime import datetime
from time import sleep
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.download.yt_dlp_base import CookieHandler, YtWrap
from home.src.es.connect import ElasticWrap, IndexPaginate 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.comments import Comments
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
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
@@ -40,6 +40,7 @@ class DownloadPostProcess:
self.auto_delete_all() self.auto_delete_all()
self.auto_delete_overwrites() self.auto_delete_overwrites()
self.validate_playlists() self.validate_playlists()
self.get_comments()
def auto_delete_all(self): def auto_delete_all(self):
"""handle auto delete""" """handle auto delete"""
@@ -126,18 +127,40 @@ class DownloadPostProcess:
+ f"{id_c + 1}/{len(self.download.channels)}" + f"{id_c + 1}/{len(self.download.channels)}"
) )
message = f"Progress: {id_p + 1}/{len(all_channel_playlist)}" message = f"Progress: {id_p + 1}/{len(all_channel_playlist)}"
key = "message:download"
mess_dict = { mess_dict = {
"status": "message:download", "status": key,
"level": "info", "level": "info",
"title": title, "title": title,
"message": message, "message": message,
} }
if id_p + 1 == len(all_channel_playlist): if id_p + 1 == len(all_channel_playlist):
RedisArchivist().set_message( expire = 4
"message:download", mess_dict, expire=4
)
else: else:
RedisArchivist().set_message("message:download", mess_dict) expire = True
RedisArchivist().set_message(key, mess_dict, expire=expire)
def get_comments(self):
"""get comments from youtube"""
if not self.download.config["downloads"]["comment_max"]:
return
total_videos = len(self.download.videos)
for idx, video_id in enumerate(self.download.videos):
comment = Comments(video_id, config=self.download.config)
comment.build_json(notify=(idx, total_videos))
comment.upload_comments()
key = "message:download"
message = {
"status": key,
"level": "info",
"title": "Download and index comments finished",
"message": f"added comments for {total_videos} videos",
}
RedisArchivist().set_message(key, message, expire=4)
class VideoDownloader: class VideoDownloader:
@@ -146,6 +169,8 @@ class VideoDownloader:
if not initiated with list, take from queue if not initiated with list, take from queue
""" """
MSG = "message:download"
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.video_overwrites = False
@@ -153,13 +178,11 @@ class VideoDownloader:
self.config = AppConfig().config self.config = AppConfig().config
self._build_obs() self._build_obs()
self.channels = set() self.channels = set()
self.videos = set()
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"""
pending = PendingList() self._setup_queue()
pending.get_download()
pending.get_channels()
self.video_overwrites = pending.video_overwrites
queue = RedisQueue() queue = RedisQueue()
@@ -172,47 +195,71 @@ class VideoDownloader:
if not youtube_id: if not youtube_id:
break break
try: success = self._dl_single_vid(youtube_id)
self._dl_single_vid(youtube_id) if not success:
except yt_dlp.utils.DownloadError:
print("failed to download " + youtube_id)
continue continue
mess_dict = {
"status": self.MSG,
"level": "info",
"title": "Indexing....",
"message": "Add video metadata to index.",
}
RedisArchivist().set_message(self.MSG, mess_dict, expire=60)
vid_dict = index_new_video( vid_dict = index_new_video(
youtube_id, video_overwrites=self.video_overwrites youtube_id, video_overwrites=self.video_overwrites
) )
self.channels.add(vid_dict["channel"]["channel_id"]) self.channels.add(vid_dict["channel"]["channel_id"])
self.videos.add(vid_dict["youtube_id"])
mess_dict = { mess_dict = {
"status": "message:download", "status": self.MSG,
"level": "info", "level": "info",
"title": "Moving....", "title": "Moving....",
"message": "Moving downloaded file to storage folder", "message": "Moving downloaded file to storage folder",
} }
RedisArchivist().set_message("message:download", mess_dict, False) RedisArchivist().set_message(self.MSG, mess_dict)
if queue.has_item():
message = "Continue with next video."
else:
message = "Download queue is finished."
self.move_to_archive(vid_dict) self.move_to_archive(vid_dict)
mess_dict = { mess_dict = {
"status": "message:download", "status": self.MSG,
"level": "info", "level": "info",
"title": "Completed", "title": "Completed",
"message": "", "message": message,
} }
RedisArchivist().set_message("message:download", mess_dict, 10) RedisArchivist().set_message(self.MSG, mess_dict, expire=10)
self._delete_from_pending(youtube_id) self._delete_from_pending(youtube_id)
# post processing # post processing
self._add_subscribed_channels() self._add_subscribed_channels()
DownloadPostProcess(self).run() DownloadPostProcess(self).run()
@staticmethod def _setup_queue(self):
def add_pending(): """setup required and validate"""
if self.config["downloads"]["cookie_import"]:
valid = CookieHandler(self.config).validate()
if not valid:
return
pending = PendingList()
pending.get_download()
pending.get_channels()
self.video_overwrites = pending.video_overwrites
def add_pending(self):
"""add pending videos to download queue""" """add pending videos to download queue"""
mess_dict = { mess_dict = {
"status": "message:download", "status": self.MSG,
"level": "info", "level": "info",
"title": "Looking for videos to download", "title": "Looking for videos to download",
"message": "Scanning your download queue.", "message": "Scanning your download queue.",
} }
RedisArchivist().set_message("message:download", mess_dict) RedisArchivist().set_message(self.MSG, mess_dict, expire=True)
pending = PendingList() pending = PendingList()
pending.get_download() pending.get_download()
to_add = [i["youtube_id"] for i in pending.all_pending] to_add = [i["youtube_id"] for i in pending.all_pending]
@@ -220,25 +267,20 @@ class VideoDownloader:
# there is nothing pending # there is nothing pending
print("download queue is empty") print("download queue is empty")
mess_dict = { mess_dict = {
"status": "message:download", "status": self.MSG,
"level": "error", "level": "error",
"title": "Download queue is empty", "title": "Download queue is empty",
"message": "Add some videos to the queue first.", "message": "Add some videos to the queue first.",
} }
RedisArchivist().set_message("message:download", mess_dict) RedisArchivist().set_message(self.MSG, mess_dict, expire=True)
return return
RedisQueue().add_list(to_add) RedisQueue().add_list(to_add)
@staticmethod def _progress_hook(self, response):
def _progress_hook(response):
"""process the progress_hooks from yt_dlp""" """process the progress_hooks from yt_dlp"""
# title title = "Downloading: " + response["info_dict"]["title"]
path = os.path.split(response["filename"])[-1][12:]
filename = os.path.splitext(os.path.splitext(path)[0])[0]
filename_clean = filename.replace("_", " ")
title = "Downloading: " + filename_clean
# message
try: try:
percent = response["_percent_str"] percent = response["_percent_str"]
size = response["_total_bytes_str"] size = response["_total_bytes_str"]
@@ -247,13 +289,14 @@ class VideoDownloader:
message = f"{percent} of {size} at {speed} - time left: {eta}" message = f"{percent} of {size} at {speed} - time left: {eta}"
except KeyError: except KeyError:
message = "processing" message = "processing"
mess_dict = { mess_dict = {
"status": "message:download", "status": self.MSG,
"level": "info", "level": "info",
"title": title, "title": title,
"message": message, "message": message,
} }
RedisArchivist().set_message("message:download", mess_dict) RedisArchivist().set_message(self.MSG, mess_dict, expire=True)
def _build_obs(self): def _build_obs(self):
"""collection to build all obs passed to yt-dlp""" """collection to build all obs passed to yt-dlp"""
@@ -266,11 +309,9 @@ class VideoDownloader:
self.obs = { self.obs = {
"default_search": "ytsearch", "default_search": "ytsearch",
"merge_output_format": "mp4", "merge_output_format": "mp4",
"restrictfilenames": True,
"outtmpl": ( "outtmpl": (
self.config["application"]["cache_dir"] self.config["application"]["cache_dir"]
+ "/download/" + "/download/%(id)s.mp4"
+ self.config["application"]["file_template"]
), ),
"progress_hooks": [self._progress_hook], "progress_hooks": [self._progress_hook],
"noprogress": True, "noprogress": True,
@@ -280,6 +321,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):
@@ -307,6 +349,17 @@ class VideoDownloader:
"add_metadata": True, "add_metadata": True,
} }
) )
postprocessors.append(
{
"key": "MetadataFromField",
"formats": [
"%(title)s:%(meta_title)s",
"%(uploader)s:%(meta_artist)s",
":(?P<album>)",
],
"when": "pre_process",
}
)
if self.config["downloads"]["add_thumbnail"]: if self.config["downloads"]["add_thumbnail"]:
postprocessors.append( postprocessors.append(
@@ -342,13 +395,7 @@ class VideoDownloader:
if youtube_id in file_name: if youtube_id in file_name:
obs["outtmpl"] = os.path.join(dl_cache, file_name) obs["outtmpl"] = os.path.join(dl_cache, file_name)
with yt_dlp.YoutubeDL(obs) as ydl: success = YtWrap(obs, self.config).download(youtube_id)
try:
ydl.download([youtube_id])
except yt_dlp.utils.DownloadError:
print("retry failed download: " + youtube_id)
sleep(10)
ydl.download([youtube_id])
if self.obs["writethumbnail"]: if self.obs["writethumbnail"]:
# webp files don't get cleaned up automatically # webp files don't get cleaned up automatically
@@ -358,6 +405,8 @@ class VideoDownloader:
file_path = os.path.join(dl_cache, file_name) file_path = os.path.join(dl_cache, file_name)
os.remove(file_path) os.remove(file_path)
return success
def move_to_archive(self, vid_dict): def move_to_archive(self, vid_dict):
"""move downloaded video from cache to archive""" """move downloaded video from cache to archive"""
videos = self.config["application"]["videos"] videos = self.config["application"]["videos"]
@@ -379,12 +428,12 @@ class VideoDownloader:
for file_str in all_cached: for file_str in all_cached:
if vid_dict["youtube_id"] in file_str: if vid_dict["youtube_id"] in file_str:
old_file = file_str old_file = file_str
old_file_path = os.path.join(cache_dir, "download", old_file) old_path = os.path.join(cache_dir, "download", old_file)
new_file_path = os.path.join(videos, vid_dict["media_url"]) new_path = os.path.join(videos, vid_dict["media_url"])
# move media file and fix permission # move media file and fix permission
shutil.move(old_file_path, new_file_path) shutil.move(old_path, new_path, copy_function=shutil.copyfile)
if host_uid and host_gid: if host_uid and host_gid:
os.chown(new_file_path, host_uid, host_gid) os.chown(new_path, host_uid, host_gid)
@staticmethod @staticmethod
def _delete_from_pending(youtube_id): def _delete_from_pending(youtube_id):

View File

@@ -0,0 +1,224 @@
"""
Functionality:
- Handle json zip file based backup
- create backup
- restore backup
"""
import json
import os
import zipfile
from datetime import datetime
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.ta.config import AppConfig
from home.src.ta.helper import get_mapping, ignore_filelist
class ElasticBackup:
"""dump index to nd-json files for later bulk import"""
def __init__(self, reason=False):
self.config = AppConfig().config
self.cache_dir = self.config["application"]["cache_dir"]
self.timestamp = datetime.now().strftime("%Y%m%d")
self.index_config = get_mapping()
self.reason = reason
def backup_all_indexes(self):
"""backup all indexes, add reason to init"""
print("backup all indexes")
if not self.reason:
raise ValueError("missing backup reason in ElasticBackup")
for index in self.index_config:
index_name = index["index_name"]
print(f"backup: export in progress for {index_name}")
if not self.index_exists(index_name):
print(f"skip backup for not yet existing index {index_name}")
continue
self.backup_index(index_name)
self.zip_it()
if self.reason == "auto":
self.rotate_backup()
@staticmethod
def backup_index(index_name):
"""export all documents of a single index"""
data = {
"query": {"match_all": {}},
"sort": [{"_doc": {"order": "desc"}}],
}
paginate = IndexPaginate(
f"ta_{index_name}", data, keep_source=True, callback=BackupCallback
)
_ = paginate.get_results()
def zip_it(self):
"""pack it up into single zip file"""
file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip"
folder = os.path.join(self.cache_dir, "backup")
to_backup = []
for file in os.listdir(folder):
if file.endswith(".json"):
to_backup.append(os.path.join(folder, file))
backup_file = os.path.join(folder, file_name)
comp = zipfile.ZIP_DEFLATED
with zipfile.ZipFile(backup_file, "w", compression=comp) as zip_f:
for backup_file in to_backup:
zip_f.write(backup_file, os.path.basename(backup_file))
# cleanup
for backup_file in to_backup:
os.remove(backup_file)
def post_bulk_restore(self, file_name):
"""send bulk to es"""
file_path = os.path.join(self.cache_dir, file_name)
with open(file_path, "r", encoding="utf-8") as f:
data = f.read()
if not data.strip():
return
_, _ = ElasticWrap("_bulk").post(data=data, ndjson=True)
def get_all_backup_files(self):
"""build all available backup files for view"""
backup_dir = os.path.join(self.cache_dir, "backup")
backup_files = os.listdir(backup_dir)
all_backup_files = ignore_filelist(backup_files)
all_available_backups = [
i
for i in all_backup_files
if i.startswith("ta_") and i.endswith(".zip")
]
all_available_backups.sort(reverse=True)
backup_dicts = []
for backup_file in all_available_backups:
file_split = backup_file.split("-")
if len(file_split) == 2:
timestamp = file_split[1].strip(".zip")
reason = False
elif len(file_split) == 3:
timestamp = file_split[1]
reason = file_split[2].strip(".zip")
to_add = {
"filename": backup_file,
"timestamp": timestamp,
"reason": reason,
}
backup_dicts.append(to_add)
return backup_dicts
def restore(self, filename):
"""
restore from backup zip file
call reset from ElasitIndexWrap first to start blank
"""
zip_content = self._unpack_zip_backup(filename)
self._restore_json_files(zip_content)
def _unpack_zip_backup(self, filename):
"""extract backup zip and return filelist"""
backup_dir = os.path.join(self.cache_dir, "backup")
file_path = os.path.join(backup_dir, filename)
with zipfile.ZipFile(file_path, "r") as z:
zip_content = z.namelist()
z.extractall(backup_dir)
return zip_content
def _restore_json_files(self, zip_content):
"""go through the unpacked files and restore"""
backup_dir = os.path.join(self.cache_dir, "backup")
for json_f in zip_content:
file_name = os.path.join(backup_dir, json_f)
if not json_f.startswith("es_") or not json_f.endswith(".json"):
os.remove(file_name)
continue
print("restoring: " + json_f)
self.post_bulk_restore(file_name)
os.remove(file_name)
@staticmethod
def index_exists(index_name):
"""check if index already exists to skip"""
_, status_code = ElasticWrap(f"ta_{index_name}").get()
exists = status_code == 200
return exists
def rotate_backup(self):
"""delete old backups if needed"""
rotate = self.config["scheduler"]["run_backup_rotate"]
if not rotate:
return
all_backup_files = self.get_all_backup_files()
auto = [i for i in all_backup_files if i["reason"] == "auto"]
if len(auto) <= rotate:
print("no backup files to rotate")
return
backup_dir = os.path.join(self.cache_dir, "backup")
all_to_delete = auto[rotate:]
for to_delete in all_to_delete:
file_path = os.path.join(backup_dir, to_delete["filename"])
print(f"remove old backup file: {file_path}")
os.remove(file_path)
class BackupCallback:
"""handle backup ndjson writer as callback for IndexPaginate"""
def __init__(self, source, index_name):
self.source = source
self.index_name = index_name
self.timestamp = datetime.now().strftime("%Y%m%d")
def run(self):
"""run the junk task"""
file_content = self._build_bulk()
self._write_es_json(file_content)
def _build_bulk(self):
"""build bulk query data from all_results"""
bulk_list = []
for document in self.source:
document_id = document["_id"]
es_index = document["_index"]
action = {"index": {"_index": es_index, "_id": document_id}}
source = document["_source"]
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(source))
# add last newline
bulk_list.append("\n")
file_content = "\n".join(bulk_list)
return file_content
def _write_es_json(self, file_content):
"""write nd-json file for es _bulk API to disk"""
cache_dir = AppConfig().config["application"]["cache_dir"]
file_name = f"es_{self.index_name.lstrip('ta_')}-{self.timestamp}.json"
file_path = os.path.join(cache_dir, "backup", file_name)
with open(file_path, "a+", encoding="utf-8") as f:
f.write(file_content)

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:
@@ -89,16 +91,22 @@ class ElasticWrap:
class IndexPaginate: class IndexPaginate:
"""use search_after to go through whole index""" """use search_after to go through whole index
kwargs:
- size: int, overwrite DEFAULT_SIZE
- keep_source: bool, keep _source key from es resutls
- callback: obj, Class with run method collback for every loop
"""
DEFAULT_SIZE = 500 DEFAULT_SIZE = 500
def __init__(self, index_name, data, size=False, keep_source=False): def __init__(self, index_name, data, **kwargs):
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 = kwargs.get("size")
self.keep_source = keep_source self.keep_source = kwargs.get("keep_source")
self.callback = kwargs.get("callback")
def get_results(self): def get_results(self):
"""get all results""" """get all results"""
@@ -120,14 +128,13 @@ class IndexPaginate:
print(self.data) print(self.data)
raise ValueError("missing sort key in data") raise ValueError("missing sort key in data")
size = self.size or self.DEFAULT_SIZE self.data["size"] = self.size or self.DEFAULT_SIZE
self.data["size"] = size
self.data["pit"] = {"id": self.pit_id, "keep_alive": "10m"} self.data["pit"] = {"id": self.pit_id, "keep_alive": "10m"}
def run_loop(self): def run_loop(self):
"""loop through results until last hit""" """loop through results until last hit"""
all_results = [] all_results = []
counter = 0
while True: while True:
response, _ = ElasticWrap("_search").get(data=self.data) response, _ = ElasticWrap("_search").get(data=self.data)
all_hits = response["hits"]["hits"] all_hits = response["hits"]["hits"]
@@ -137,10 +144,18 @@ class IndexPaginate:
source = hit source = hit
else: else:
source = hit["_source"] source = hit["_source"]
search_after = hit["sort"]
if not self.callback:
all_results.append(source) all_results.append(source)
if self.callback:
self.callback(all_hits, self.index_name).run()
if counter % 10 == 0:
print(f"{self.index_name}: processing page {counter}")
counter = counter + 1
# update search_after with last hit data # update search_after with last hit data
self.data["search_after"] = search_after self.data["search_after"] = all_hits[-1]["sort"]
else: else:
break break

View File

@@ -37,8 +37,7 @@
"type": "text" "type": "text"
}, },
"channel_last_refresh": { "channel_last_refresh": {
"type": "date", "type": "date"
"format": "epoch_second"
}, },
"channel_overwrites": { "channel_overwrites": {
"properties": { "properties": {
@@ -120,8 +119,7 @@
"type": "text" "type": "text"
}, },
"channel_last_refresh": { "channel_last_refresh": {
"type": "date", "type": "date"
"format": "epoch_second"
}, },
"channel_overwrites": { "channel_overwrites": {
"properties": { "properties": {
@@ -193,6 +191,9 @@
} }
} }
}, },
"comment_count": {
"type": "long"
},
"stats" : { "stats" : {
"properties" : { "properties" : {
"average_rating" : { "average_rating" : {
@@ -460,6 +461,75 @@
}, },
"number_of_replicas": "0" "number_of_replicas": "0"
} }
},
{
"index_name": "comment",
"expected_map": {
"youtube_id": {
"type": "keyword"
},
"comment_last_refresh": {
"type": "date"
},
"comment_channel_id": {
"type": "keyword"
},
"comment_comments": {
"properties": {
"comment_id": {
"type": "keyword"
},
"comment_text": {
"type" : "text"
},
"comment_timestamp": {
"type": "date"
},
"comment_time_text": {
"type" : "text"
},
"comment_likecount": {
"type": "long"
},
"comment_is_favorited": {
"type": "boolean"
},
"comment_author": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256,
"normalizer": "to_lower"
}
}
},
"comment_author_id": {
"type": "keyword"
},
"comment_author_thumbnail": {
"type": "keyword"
},
"comment_author_is_uploader": {
"type": "boolean"
},
"comment_parent": {
"type": "keyword"
}
}
}
},
"expected_set": {
"analysis": {
"normalizer": {
"to_lower": {
"type": "custom",
"filter": ["lowercase"]
}
}
},
"number_of_replicas": "0"
}
} }
] ]
} }

View File

@@ -5,22 +5,17 @@ functionality:
- backup and restore metadata - backup and restore metadata
""" """
import json from home.src.es.backup import ElasticBackup
import os from home.src.es.connect import ElasticWrap
import zipfile from home.src.es.snapshot import ElasticSnapshot
from datetime import datetime
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 get_mapping
class ElasticIndex: class ElasticIndex:
""" """interact with a single index"""
handle mapping and settings on elastic search for a given index
"""
def __init__(self, index_name, expected_map, expected_set): def __init__(self, index_name, expected_map=False, expected_set=False):
self.index_name = index_name self.index_name = index_name
self.expected_map = expected_map self.expected_map = expected_map
self.expected_set = expected_set self.expected_set = expected_set
@@ -61,23 +56,23 @@ class ElasticIndex:
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: if key not in now_map:
print(key_n, value_n) print(f"detected mapping change: {key_n}, {value_n}")
return True 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(f"detected mapping change: {key_n}, {value_n}")
return True return True
if not value_n == now_map[key]["properties"][key_n]: if not value_n == now_map[key]["properties"][key_n]:
print(key_n, value_n) print(f"detected mapping change: {key_n}, {value_n}")
return True return True
continue continue
# not nested # not nested
if key not in now_map.keys(): if key not in now_map.keys():
print(key, value) print(f"detected mapping change: {key}, {value}")
return True return True
if not value == now_map[key]: if not value == now_map[key]:
print(key, value) print(f"detected mapping change: {key}, {value}")
return True return True
return False return False
@@ -100,6 +95,8 @@ class ElasticIndex:
def rebuild_index(self): def rebuild_index(self):
"""rebuild with new mapping""" """rebuild with new mapping"""
print(f"applying new mappings to index ta_{self.index_name}...")
self.create_blank(for_backup=True)
self.reindex("backup") self.reindex("backup")
self.delete_index(backup=False) self.delete_index(backup=False)
self.create_blank() self.create_blank()
@@ -126,277 +123,88 @@ class ElasticIndex:
_, _ = ElasticWrap(path).delete() _, _ = ElasticWrap(path).delete()
def create_blank(self): def create_blank(self, for_backup=False):
"""apply new mapping and settings for blank new index""" """apply new mapping and settings for blank new index"""
print(f"create new blank index with name ta_{self.index_name}...")
path = f"ta_{self.index_name}"
if for_backup:
path = f"{path}_backup"
data = {} data = {}
if self.expected_set: if self.expected_set:
data.update({"settings": self.expected_set}) data.update({"settings": self.expected_set})
if self.expected_map: if self.expected_map:
data.update({"mappings": {"properties": self.expected_map}}) data.update({"mappings": {"properties": self.expected_map}})
_, _ = ElasticWrap(f"ta_{self.index_name}").put(data) _, _ = ElasticWrap(path).put(data)
class ElasticBackup: class ElasitIndexWrap:
"""dump index to nd-json files for later bulk import""" """interact with all index mapping and setup"""
def __init__(self, index_config, reason): def __init__(self):
self.config = AppConfig().config self.index_config = get_mapping()
self.cache_dir = self.config["application"]["cache_dir"] self.backup_run = False
self.index_config = index_config
self.reason = reason
self.timestamp = datetime.now().strftime("%Y%m%d")
self.backup_files = []
@staticmethod def setup(self):
def get_all_documents(index_name): """setup elastic index, run at startup"""
"""export all documents of a single index""" for index in self.index_config:
data = { index_name, expected_map, expected_set = self._config_split(index)
"query": {"match_all": {}},
"sort": [{"_doc": {"order": "desc"}}],
}
paginate = IndexPaginate(f"ta_{index_name}", data, keep_source=True)
all_results = paginate.get_results()
return all_results
@staticmethod
def build_bulk(all_results):
"""build bulk query data from all_results"""
bulk_list = []
for document in all_results:
document_id = document["_id"]
es_index = document["_index"]
action = {"index": {"_index": es_index, "_id": document_id}}
source = document["_source"]
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(source))
# add last newline
bulk_list.append("\n")
file_content = "\n".join(bulk_list)
return file_content
def write_es_json(self, file_content, index_name):
"""write nd-json file for es _bulk API to disk"""
file_name = f"es_{index_name}-{self.timestamp}.json"
file_path = os.path.join(self.cache_dir, "backup", file_name)
with open(file_path, "w", encoding="utf-8") as f:
f.write(file_content)
self.backup_files.append(file_path)
def write_ta_json(self, all_results, index_name):
"""write generic json file to disk"""
file_name = f"ta_{index_name}-{self.timestamp}.json"
file_path = os.path.join(self.cache_dir, "backup", file_name)
to_write = [i["_source"] for i in all_results]
file_content = json.dumps(to_write)
with open(file_path, "w", encoding="utf-8") as f:
f.write(file_content)
self.backup_files.append(file_path)
def zip_it(self):
"""pack it up into single zip file"""
file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip"
backup_folder = os.path.join(self.cache_dir, "backup")
backup_file = os.path.join(backup_folder, file_name)
with zipfile.ZipFile(
backup_file, "w", compression=zipfile.ZIP_DEFLATED
) as zip_f:
for backup_file in self.backup_files:
zip_f.write(backup_file, os.path.basename(backup_file))
# cleanup
for backup_file in self.backup_files:
os.remove(backup_file)
def post_bulk_restore(self, file_name):
"""send bulk to es"""
file_path = os.path.join(self.cache_dir, file_name)
with open(file_path, "r", encoding="utf-8") as f:
data = f.read()
if not data.strip():
return
_, _ = ElasticWrap("_bulk").post(data=data, ndjson=True)
def get_all_backup_files(self):
"""build all available backup files for view"""
backup_dir = os.path.join(self.cache_dir, "backup")
backup_files = os.listdir(backup_dir)
all_backup_files = ignore_filelist(backup_files)
all_available_backups = [
i
for i in all_backup_files
if i.startswith("ta_") and i.endswith(".zip")
]
all_available_backups.sort(reverse=True)
backup_dicts = []
for backup_file in all_available_backups:
file_split = backup_file.split("-")
if len(file_split) == 2:
timestamp = file_split[1].strip(".zip")
reason = False
elif len(file_split) == 3:
timestamp = file_split[1]
reason = file_split[2].strip(".zip")
to_add = {
"filename": backup_file,
"timestamp": timestamp,
"reason": reason,
}
backup_dicts.append(to_add)
return backup_dicts
def unpack_zip_backup(self, filename):
"""extract backup zip and return filelist"""
backup_dir = os.path.join(self.cache_dir, "backup")
file_path = os.path.join(backup_dir, filename)
with zipfile.ZipFile(file_path, "r") as z:
zip_content = z.namelist()
z.extractall(backup_dir)
return zip_content
def restore_json_files(self, zip_content):
"""go through the unpacked files and restore"""
backup_dir = os.path.join(self.cache_dir, "backup")
for json_f in zip_content:
file_name = os.path.join(backup_dir, json_f)
if not json_f.startswith("es_") or not json_f.endswith(".json"):
os.remove(file_name)
continue
print("restoring: " + json_f)
self.post_bulk_restore(file_name)
os.remove(file_name)
@staticmethod
def index_exists(index_name):
"""check if index already exists to skip"""
_, status_code = ElasticWrap(f"ta_{index_name}").get()
exists = status_code == 200
return exists
def rotate_backup(self):
"""delete old backups if needed"""
rotate = self.config["scheduler"]["run_backup_rotate"]
if not rotate:
return
all_backup_files = self.get_all_backup_files()
auto = [i for i in all_backup_files if i["reason"] == "auto"]
if len(auto) <= rotate:
print("no backup files to rotate")
return
backup_dir = os.path.join(self.cache_dir, "backup")
all_to_delete = auto[rotate:]
for to_delete in all_to_delete:
file_path = os.path.join(backup_dir, to_delete["filename"])
print(f"remove old backup file: {file_path}")
os.remove(file_path)
def get_mapping():
"""read index_mapping.json and get expected mapping and settings"""
with open("home/src/es/index_mapping.json", "r", encoding="utf-8") as f:
index_config = json.load(f).get("index_config")
return index_config
def index_check(force_restore=False):
"""check if all indexes are created and have correct mapping"""
backed_up = False
index_config = get_mapping()
for index in index_config:
index_name = index["index_name"]
expected_map = index["expected_map"]
expected_set = index["expected_set"]
handler = ElasticIndex(index_name, expected_map, expected_set) handler = ElasticIndex(index_name, expected_map, expected_set)
# force restore
if force_restore:
handler.delete_index(backup=False)
handler.create_blank()
continue
# create new
if not handler.exists: if not handler.exists:
print(f"create new blank index with name ta_{index_name}...")
handler.create_blank() handler.create_blank()
continue continue
# validate index
rebuild = handler.validate() rebuild = handler.validate()
if rebuild: if rebuild:
# make backup before rebuild self._check_backup()
if not backed_up:
print("running backup first")
backup_all_indexes(reason="update")
backed_up = True
print(f"applying new mappings to index ta_{index_name}...")
handler.rebuild_index() handler.rebuild_index()
continue continue
# else all good # else all good
print(f"ta_{index_name} index is created and up to date...") print(f"ta_{index_name} index is created and up to date...")
def reset(self):
"""reset all indexes to blank"""
self.delete_all()
self.create_all_blank()
def get_available_backups(): def delete_all(self):
"""return dict of available backups for settings view""" """delete all indexes"""
index_config = get_mapping() print("reset elastic index")
backup_handler = ElasticBackup(index_config, reason=False) for index in self.index_config:
all_backup_files = backup_handler.get_all_backup_files() index_name, _, _ = self._config_split(index)
return all_backup_files handler = ElasticIndex(index_name)
handler.delete_index(backup=False)
def create_all_blank(self):
"""create all blank indexes"""
print("create all new indexes in elastic from template")
for index in self.index_config:
index_name, expected_map, expected_set = self._config_split(index)
handler = ElasticIndex(index_name, expected_map, expected_set)
handler.create_blank()
def backup_all_indexes(reason): @staticmethod
"""backup all es indexes to disk""" def _config_split(index):
index_config = get_mapping() """split index config keys"""
backup_handler = ElasticBackup(index_config, reason)
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}") expected_map = index["expected_map"]
if not backup_handler.index_exists(index_name): expected_set = index["expected_set"]
continue
all_results = backup_handler.get_all_documents(index_name)
file_content = backup_handler.build_bulk(all_results)
backup_handler.write_es_json(file_content, index_name)
backup_handler.write_ta_json(all_results, index_name)
backup_handler.zip_it() return index_name, expected_map, expected_set
if reason == "auto": def _check_backup(self):
backup_handler.rotate_backup() """create backup if needed"""
if self.backup_run:
return
config = AppConfig().config
if config["application"]["enable_snapshot"]:
# take snapshot if enabled
ElasticSnapshot().take_snapshot_now(wait=True)
else:
# fallback to json backup
ElasticBackup(reason="update").backup_all_indexes()
def restore_from_backup(filename): self.backup_run = True
"""restore indexes from backup file"""
# delete
index_check(force_restore=True)
# recreate
index_config = get_mapping()
backup_handler = ElasticBackup(index_config, reason=False)
zip_content = backup_handler.unpack_zip_backup(filename)
backup_handler.restore_json_files(zip_content)

View File

@@ -0,0 +1,286 @@
"""
functionality:
- handle snapshots in ES
"""
from datetime import datetime
from os import environ
from time import sleep
from zoneinfo import ZoneInfo
from home.src.es.connect import ElasticWrap
from home.src.ta.helper import get_mapping
class ElasticSnapshot:
"""interact with snapshots on ES"""
REPO = "ta_snapshot"
REPO_SETTINGS = {
"compress": "true",
"chunk_size": "1g",
"location": "/usr/share/elasticsearch/data/snapshot",
}
POLICY = "ta_daily"
def __init__(self):
self.all_indices = self._get_all_indices()
def _get_all_indices(self):
"""return all indices names managed by TA"""
mapping = get_mapping()
all_indices = [f"ta_{i['index_name']}" for i in mapping]
return all_indices
def setup(self):
"""setup the snapshot in ES, create or update if needed"""
print("snapshot: run setup")
repo_exists = self._check_repo_exists()
if not repo_exists:
self.create_repo()
policy_exists = self._check_policy_exists()
if not policy_exists:
self.create_policy()
is_outdated = self._needs_startup_snapshot()
if is_outdated:
_ = self.take_snapshot_now()
def _check_repo_exists(self):
"""check if expected repo already exists"""
path = f"_snapshot/{self.REPO}"
response, statuscode = ElasticWrap(path).get()
if statuscode == 200:
print(f"snapshot: repo {self.REPO} already created")
matching = response[self.REPO]["settings"] == self.REPO_SETTINGS
if not matching:
print(f"snapshot: update repo settings {self.REPO_SETTINGS}")
return matching
print(f"snapshot: setup repo {self.REPO} config {self.REPO_SETTINGS}")
return False
def create_repo(self):
"""create filesystem repo"""
path = f"_snapshot/{self.REPO}"
data = {
"type": "fs",
"settings": self.REPO_SETTINGS,
}
response, statuscode = ElasticWrap(path).post(data=data)
if statuscode == 200:
print(f"snapshot: repo setup correctly: {response}")
def _check_policy_exists(self):
"""check if snapshot policy is set correctly"""
policy = self._get_policy()
expected_policy = self._build_policy_data()
if not policy:
print(f"snapshot: create policy {self.POLICY} {expected_policy}")
return False
if policy["policy"] != expected_policy:
print(f"snapshot: update policy settings {expected_policy}")
return False
print("snapshot: policy is set.")
return True
def _get_policy(self):
"""get policy from es"""
path = f"_slm/policy/{self.POLICY}"
response, statuscode = ElasticWrap(path).get()
if statuscode != 200:
return False
return response[self.POLICY]
def create_policy(self):
"""create snapshot lifetime policy"""
path = f"_slm/policy/{self.POLICY}"
data = self._build_policy_data()
response, statuscode = ElasticWrap(path).put(data)
if statuscode == 200:
print(f"snapshot: policy setup correctly: {response}")
def _build_policy_data(self):
"""build policy dict from config"""
at_12 = datetime.now().replace(hour=12, minute=0, second=0)
hour = at_12.astimezone(ZoneInfo("UTC")).hour
return {
"schedule": f"0 0 {hour} * * ?",
"name": f"<{self.POLICY}_>",
"repository": self.REPO,
"config": {
"indices": self.all_indices,
"ignore_unavailable": True,
"include_global_state": True,
},
"retention": {
"expire_after": "30d",
"min_count": 5,
"max_count": 50,
},
}
def _needs_startup_snapshot(self):
"""check if last snapshot is expired"""
snap_dicts = self._get_all_snapshots()
if not snap_dicts:
print("snapshot: create initial snapshot")
return True
last_stamp = snap_dicts[0]["end_stamp"]
now = int(datetime.now().strftime("%s"))
outdated = (now - last_stamp) / 60 / 60 > 24
if outdated:
print("snapshot: is outdated, create new now")
print("snapshot: last snapshot is up-to-date")
return outdated
def take_snapshot_now(self, wait=False):
"""execute daily snapshot now"""
path = f"_slm/policy/{self.POLICY}/_execute"
response, statuscode = ElasticWrap(path).post()
if statuscode == 200:
print(f"snapshot: executing now: {response}")
if wait:
self._wait_for_snapshot(response["snapshot_name"])
return response
def _wait_for_snapshot(self, snapshot_name):
"""return after snapshot_name completes"""
path = f"_snapshot/{self.REPO}/{snapshot_name}"
while True:
# wait for task to be created
sleep(1)
_, statuscode = ElasticWrap(path).get()
if statuscode == 200:
break
while True:
# wait for snapshot success
response, statuscode = ElasticWrap(path).get()
snapshot_state = response["snapshots"][0]["state"]
if snapshot_state == "SUCCESS":
break
print(f"snapshot: {snapshot_name} in state {snapshot_state}")
print("snapshot: wait to complete")
sleep(5)
print(f"snapshot: completed - {response}")
def get_snapshot_stats(self):
"""get snapshot info for frontend"""
snapshot_info = self._build_policy_details()
if snapshot_info:
snapshot_info.update({"snapshots": self._get_all_snapshots()})
return snapshot_info
def get_single_snapshot(self, snapshot_id):
"""get single snapshot metadata"""
path = f"_snapshot/{self.REPO}/{snapshot_id}"
response, statuscode = ElasticWrap(path).get()
if statuscode == 404:
print(f"snapshots: not found: {snapshot_id}")
return False
snapshot = response["snapshots"][0]
return self._parse_single_snapshot(snapshot)
def _get_all_snapshots(self):
"""get a list of all registered snapshots"""
path = f"_snapshot/{self.REPO}/*?sort=start_time&order=desc"
response, statuscode = ElasticWrap(path).get()
if statuscode == 404:
print("snapshots: not configured")
return False
all_snapshots = response["snapshots"]
if not all_snapshots:
print("snapshots: no snapshots found")
return False
snap_dicts = []
for snapshot in all_snapshots:
snap_dict = self._parse_single_snapshot(snapshot)
snap_dicts.append(snap_dict)
return snap_dicts
def _parse_single_snapshot(self, snapshot):
"""extract relevant metadata from single snapshot"""
snap_dict = {
"id": snapshot["snapshot"],
"state": snapshot["state"],
"es_version": snapshot["version"],
"start_date": self._date_converter(snapshot["start_time"]),
"end_date": self._date_converter(snapshot["end_time"]),
"end_stamp": snapshot["end_time_in_millis"] // 1000,
"duration_s": snapshot["duration_in_millis"] // 1000,
}
return snap_dict
def _build_policy_details(self):
"""get additional policy details"""
policy = self._get_policy()
if not policy:
return False
next_exec = policy["next_execution_millis"] // 1000
next_exec_date = datetime.fromtimestamp(next_exec)
next_exec_str = next_exec_date.strftime("%Y-%m-%d %H:%M")
expire_after = policy["policy"]["retention"]["expire_after"]
policy_metadata = {
"next_exec": next_exec,
"next_exec_str": next_exec_str,
"expire_after": expire_after,
}
return policy_metadata
@staticmethod
def _date_converter(date_utc):
"""convert datetime string"""
expected_format = "%Y-%m-%dT%H:%M:%S.%fZ"
date = datetime.strptime(date_utc, expected_format)
local_datetime = date.replace(tzinfo=ZoneInfo("localtime"))
converted = local_datetime.astimezone(ZoneInfo(environ.get("TZ")))
converted_str = converted.strftime("%Y-%m-%d %H:%M")
return converted_str
def restore_all(self, snapshot_name):
"""restore snapshot by name"""
for index in self.all_indices:
_, _ = ElasticWrap(index).delete()
path = f"_snapshot/{self.REPO}/{snapshot_name}/_restore"
data = {"indices": "*"}
response, statuscode = ElasticWrap(path).post(data=data)
if statuscode == 200:
print(f"snapshot: executing now: {response}")
return response
print(f"snapshot: failed to restore, {statuscode} {response}")
return False
def delete_single_snapshot(self, snapshot_id):
"""delete single snapshot from index"""
path = f"_snapshot/{self.REPO}/{snapshot_id}"
response, statuscode = ElasticWrap(path).delete()
if statuscode == 200:
print(f"snapshot: deleting {snapshot_id} {response}")
return response
print(f"snapshot: failed to delete, {statuscode} {response}")
return False

View File

@@ -9,7 +9,6 @@ from home.src.download.subscriptions import (
ChannelSubscription, ChannelSubscription,
PlaylistSubscription, PlaylistSubscription,
) )
from home.src.frontend.searching import SearchForm
from home.src.frontend.watched import WatchState from home.src.frontend.watched import WatchState
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
@@ -19,7 +18,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,
@@ -55,6 +53,7 @@ class PostData:
"watched": self._watched, "watched": self._watched,
"un_watched": self._un_watched, "un_watched": self._un_watched,
"change_view": self._change_view, "change_view": self._change_view,
"change_grid": self._change_grid,
"rescan_pending": self._rescan_pending, "rescan_pending": self._rescan_pending,
"ignore": self._ignore, "ignore": self._ignore,
"dl_pending": self._dl_pending, "dl_pending": self._dl_pending,
@@ -74,7 +73,6 @@ class PostData:
"db-backup": self._db_backup, "db-backup": self._db_backup,
"db-restore": self._db_restore, "db-restore": self._db_restore,
"fs-rescan": self._fs_rescan, "fs-rescan": self._fs_rescan,
"multi_search": self._multi_search,
"delete-video": self._delete_video, "delete-video": self._delete_video,
"delete-channel": self._delete_channel, "delete-channel": self._delete_channel,
"delete-playlist": self._delete_playlist, "delete-playlist": self._delete_playlist,
@@ -98,7 +96,18 @@ class PostData:
origin, new_view = self.exec_val.split(":") origin, new_view = self.exec_val.split(":")
key = f"{self.current_user}:view:{origin}" key = f"{self.current_user}:view:{origin}"
print(f"change view: {key} to {new_view}") print(f"change view: {key} to {new_view}")
RedisArchivist().set_message(key, {"status": new_view}, expire=False) RedisArchivist().set_message(key, {"status": new_view})
return {"success": True}
def _change_grid(self):
"""process change items in grid"""
grid_items = int(self.exec_val)
grid_items = max(grid_items, 3)
grid_items = min(grid_items, 7)
key = f"{self.current_user}:grid_items"
print(f"change grid items: {grid_items}")
RedisArchivist().set_message(key, {"status": grid_items})
return {"success": True} return {"success": True}
@staticmethod @staticmethod
@@ -111,7 +120,7 @@ class PostData:
def _ignore(self): def _ignore(self):
"""ignore from download queue""" """ignore from download queue"""
video_id = self.exec_val video_id = self.exec_val
print(f"ignore video {video_id}") print(f"{video_id}: ignore video from download queue")
PendingInteract(video_id=video_id, status="ignore").update_status() PendingInteract(video_id=video_id, status="ignore").update_status()
# also clear from redis queue # also clear from redis queue
RedisQueue().clear_item(video_id) RedisQueue().clear_item(video_id)
@@ -123,8 +132,8 @@ 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)
return {"success": True} return {"success": True}
def _queue_handler(self): def _queue_handler(self):
@@ -146,7 +155,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"]
@@ -167,7 +176,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}
@@ -176,11 +185,11 @@ class PostData:
sort_order = {"status": self.exec_val} sort_order = {"status": self.exec_val}
if self.exec_val in ["asc", "desc"]: if self.exec_val in ["asc", "desc"]:
RedisArchivist().set_message( RedisArchivist().set_message(
f"{self.current_user}:sort_order", sort_order, expire=False f"{self.current_user}:sort_order", sort_order
) )
else: else:
RedisArchivist().set_message( RedisArchivist().set_message(
f"{self.current_user}:sort_by", sort_order, expire=False f"{self.current_user}:sort_by", sort_order
) )
return {"success": True} return {"success": True}
@@ -189,7 +198,7 @@ class PostData:
key = f"{self.current_user}:hide_watched" key = f"{self.current_user}:hide_watched"
message = {"status": bool(int(self.exec_val))} message = {"status": bool(int(self.exec_val))}
print(f"toggle {key}: {message}") print(f"toggle {key}: {message}")
RedisArchivist().set_message(key, message, expire=False) RedisArchivist().set_message(key, message)
return {"success": True} return {"success": True}
def _show_subed_only(self): def _show_subed_only(self):
@@ -197,17 +206,17 @@ class PostData:
key = f"{self.current_user}:show_subed_only" key = f"{self.current_user}:show_subed_only"
message = {"status": bool(int(self.exec_val))} message = {"status": bool(int(self.exec_val))}
print(f"toggle {key}: {message}") print(f"toggle {key}: {message}")
RedisArchivist().set_message(key, message, expire=False) RedisArchivist().set_message(key, message)
return {"success": True} return {"success": True}
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)
RedisArchivist().set_message("dl_queue_id", task_id, expire=False) RedisArchivist().set_message("dl_queue_id", task_id)
return {"success": True} return {"success": True}
def _show_ignored_only(self): def _show_ignored_only(self):
@@ -216,23 +225,21 @@ class PostData:
key = f"{self.current_user}:show_ignored_only" key = f"{self.current_user}:show_ignored_only"
value = {"status": show_value} value = {"status": show_value}
print(f"Filter download view ignored only: {show_value}") print(f"Filter download view ignored only: {show_value}")
RedisArchivist().set_message(key, value, expire=False) RedisArchivist().set_message(key, value)
return {"success": True} return {"success": True}
def _forget_ignore(self): def _forget_ignore(self):
"""delete from ta_download index""" """delete from ta_download index"""
video_id = self.exec_val video_id = self.exec_val
print(f"forgetting from download index: {video_id}") print(f"{video_id}: forget from download")
PendingInteract(video_id=video_id).delete_item() 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"""
video_id = self.exec_val video_id = self.exec_val
print(f"add vid to dl queue: {video_id}") print(f"{video_id}: add single vid to download queue")
PendingInteract(video_id=video_id).delete_item() PendingInteract(video_id=video_id, status="pending").update_status()
video_ids = UrlListParser(video_id).process_list()
extrac_dl.delay(video_ids)
return {"success": True} return {"success": True}
def _delete_queue(self): def _delete_queue(self):
@@ -277,13 +284,6 @@ class PostData:
rescan_filesystem.delay() rescan_filesystem.delay()
return {"success": True} return {"success": True}
def _multi_search(self):
"""search through all indexes"""
search_query = self.exec_val
print("searching for: " + search_query)
search_results = SearchForm().multi_search(search_query)
return search_results
def _delete_video(self): def _delete_video(self):
"""delete media file, metadata and thumb""" """delete media file, metadata and thumb"""
youtube_id = self.exec_val youtube_id = self.exec_val

View File

@@ -74,6 +74,12 @@ class ApplicationSettingsForm(forms.Form):
("1", "enable Cast"), ("1", "enable Cast"),
] ]
SNAPSHOT_CHOICES = [
("", "-- change snapshot settings --"),
("0", "disable system snapshots"),
("1", "enable system snapshots"),
]
SUBTITLE_SOURCE_CHOICES = [ SUBTITLE_SOURCE_CHOICES = [
("", "-- change subtitle source settings"), ("", "-- change subtitle source settings"),
("user", "only download user created"), ("user", "only download user created"),
@@ -86,7 +92,21 @@ class ApplicationSettingsForm(forms.Form):
("1", "enable subtitle index"), ("1", "enable subtitle index"),
] ]
subscriptions_channel_size = forms.IntegerField(required=False) COMMENT_SORT_CHOICES = [
("", "-- change comments sort settings --"),
("top", "sort comments by top"),
("new", "sort comments by new"),
]
COOKIE_IMPORT_CHOICES = [
("", "-- change cookie settings"),
("0", "disable cookie"),
("1", "enable cookie"),
]
subscriptions_channel_size = forms.IntegerField(
required=False, min_value=1
)
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)
downloads_throttledratelimit = forms.IntegerField(required=False) downloads_throttledratelimit = forms.IntegerField(required=False)
@@ -106,6 +126,13 @@ 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_comment_max = forms.CharField(required=False)
downloads_comment_sort = forms.ChoiceField(
widget=forms.Select, choices=COMMENT_SORT_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
) )
@@ -115,6 +142,9 @@ class ApplicationSettingsForm(forms.Form):
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
) )
application_enable_snapshot = forms.ChoiceField(
widget=forms.Select, choices=SNAPSHOT_CHOICES, required=False
)
class SchedulerSettingsForm(forms.Form): class SchedulerSettingsForm(forms.Form):
@@ -150,12 +180,14 @@ class MultiSearchForm(forms.Form):
class AddToQueueForm(forms.Form): class AddToQueueForm(forms.Form):
"""text area form to add to downloads""" """text area form to add to downloads"""
HELP_TEXT = "Enter at least one video, channel or playlist id/URL here..."
vid_url = forms.CharField( vid_url = forms.CharField(
label=False, label=False,
widget=forms.Textarea( widget=forms.Textarea(
attrs={ attrs={
"rows": 4, "rows": 4,
"placeholder": "Enter Video Urls or IDs here...", "placeholder": HELP_TEXT,
} }
), ),
) )

View File

@@ -119,8 +119,8 @@ class SearchHandler:
if "vid_thumb_url" in hit_keys: if "vid_thumb_url" in hit_keys:
youtube_id = hit["source"]["youtube_id"] youtube_id = hit["source"]["youtube_id"]
thumb_path = ThumbManager().vid_thumb_path(youtube_id) thumb_path = ThumbManager(youtube_id).vid_thumb_path()
hit["source"]["vid_thumb_url"] = thumb_path hit["source"]["vid_thumb_url"] = f"/cache/{thumb_path}"
if "channel_last_refresh" in hit_keys: if "channel_last_refresh" in hit_keys:
refreshed = hit["source"]["channel_last_refresh"] refreshed = hit["source"]["channel_last_refresh"]
@@ -136,6 +136,11 @@ class SearchHandler:
date_str = datetime.strftime(date_refresh, "%d %b, %Y") date_str = datetime.strftime(date_refresh, "%d %b, %Y")
hit["source"]["channel"]["channel_last_refresh"] = date_str hit["source"]["channel"]["channel_last_refresh"] = date_str
if "subtitle_fragment_id" in hit_keys:
youtube_id = hit["source"]["youtube_id"]
thumb_path = ThumbManager(youtube_id).vid_thumb_path()
hit["source"]["vid_thumb_url"] = f"/cache/{thumb_path}"
return hit return hit
@@ -146,38 +151,12 @@ class SearchForm:
def multi_search(self, search_query): def multi_search(self, search_query):
"""searching through index""" """searching through index"""
path = "ta_video,ta_channel,ta_playlist/_search" path, query, query_type = SearchParser(search_query).run()
data = { look_up = SearchHandler(path, config=self.CONFIG, data=query)
"size": 30,
"query": {
"multi_match": {
"query": search_query,
"type": "bool_prefix",
"operator": "and",
"fuzziness": "auto",
"fields": [
"category",
"channel_description",
"channel_name._2gram",
"channel_name._3gram",
"channel_name.search_as_you_type",
"playlist_description",
"playlist_name._2gram",
"playlist_name._3gram",
"playlist_name.search_as_you_type",
"tags",
"title._2gram",
"title._3gram",
"title.search_as_you_type",
],
}
},
}
look_up = SearchHandler(path, config=self.CONFIG, data=data)
search_results = look_up.get_data() search_results = look_up.get_data()
all_results = self.build_results(search_results) all_results = self.build_results(search_results)
return {"results": all_results} return {"results": all_results, "queryType": query_type}
@staticmethod @staticmethod
def build_results(search_results): def build_results(search_results):
@@ -185,6 +164,7 @@ class SearchForm:
video_results = [] video_results = []
channel_results = [] channel_results = []
playlist_results = [] playlist_results = []
fulltext_results = []
if search_results: if search_results:
for result in search_results: for result in search_results:
if result["_index"] == "ta_video": if result["_index"] == "ta_video":
@@ -193,11 +173,346 @@ class SearchForm:
channel_results.append(result) channel_results.append(result)
elif result["_index"] == "ta_playlist": elif result["_index"] == "ta_playlist":
playlist_results.append(result) playlist_results.append(result)
elif result["_index"] == "ta_subtitle":
fulltext_results.append(result)
all_results = { all_results = {
"video_results": video_results, "video_results": video_results,
"channel_results": channel_results, "channel_results": channel_results,
"playlist_results": playlist_results, "playlist_results": playlist_results,
"fulltext_results": fulltext_results,
} }
return all_results return all_results
class SearchParser:
"""handle structured searches"""
def __init__(self, search_query):
self.query_words = search_query.lower().split()
self.query_map = {"term": [], "fuzzy": []}
self.append_to = "term"
def run(self):
"""collection, return path and query dict for es"""
print(f"query words: {self.query_words}")
query_type = self._find_map()
self._run_words()
self._delete_unset()
self._match_data_types()
path, query = QueryBuilder(self.query_map, query_type).run()
return path, query, query_type
def _find_map(self):
"""find query in keyword map"""
first_word = self.query_words[0]
key_word_map = self._get_map()
if ":" in first_word:
index_match, query_string = first_word.split(":")
if index_match in key_word_map:
self.query_map.update(key_word_map.get(index_match))
self.query_words[0] = query_string
return index_match
self.query_map.update(key_word_map.get("simple"))
print(f"query_map: {self.query_map}")
return "simple"
@staticmethod
def _get_map():
"""return map to build on"""
return {
"simple": {
"index": "ta_video,ta_channel,ta_playlist",
},
"video": {
"index": "ta_video",
"channel": [],
"active": [],
},
"channel": {
"index": "ta_channel",
"active": [],
"subscribed": [],
},
"playlist": {
"index": "ta_playlist",
"active": [],
"subscribed": [],
},
"full": {
"index": "ta_subtitle",
"lang": [],
"source": [],
},
}
def _run_words(self):
"""append word by word"""
for word in self.query_words:
if ":" in word:
keyword, search_string = word.split(":")
if keyword in self.query_map:
self.append_to = keyword
word = search_string
if word:
self.query_map[self.append_to].append(word)
def _delete_unset(self):
"""delete unset keys"""
new_query_map = {}
for key, value in self.query_map.items():
if value:
new_query_map.update({key: value})
self.query_map = new_query_map
def _match_data_types(self):
"""match values with data types"""
for key, value in self.query_map.items():
if key in ["term", "channel"]:
self.query_map[key] = " ".join(self.query_map[key])
if key in ["active", "subscribed"]:
self.query_map[key] = "yes" in value
class QueryBuilder:
"""build query for ES from form data"""
def __init__(self, query_map, query_type):
self.query_map = query_map
self.query_type = query_type
def run(self):
"""build query"""
path = self._build_path()
query = self.build_query()
print(f"es path: {path}")
print(f"query: {query}")
return path, query
def _build_path(self):
"""build es index search path"""
return f"{self.query_map.get('index')}/_search"
def build_query(self):
"""build query based on query_type"""
exec_map = {
"simple": self._build_simple,
"video": self._build_video,
"channel": self._build_channel,
"playlist": self._build_playlist,
"full": self._build_fulltext,
}
build_must_list = exec_map[self.query_type]
if self.query_type == "full":
query = build_must_list()
else:
query = {
"size": 30,
"query": {"bool": {"must": build_must_list()}},
}
return query
def _get_fuzzy(self):
"""return fuziness valuee"""
fuzzy_value = self.query_map.get("fuzzy", ["auto"])[0]
if fuzzy_value == "no":
return 0
if not fuzzy_value.isdigit():
return "auto"
if int(fuzzy_value) > 2:
return "2"
return fuzzy_value
def _build_simple(self):
"""build simple cross index query"""
must_list = []
if (term := self.query_map.get("term")) is not None:
must_list.append(
{
"multi_match": {
"query": term,
"type": "bool_prefix",
"fuzziness": self._get_fuzzy(),
"operator": "and",
"fields": [
"channel_name._2gram",
"channel_name._3gram",
"channel_name.search_as_you_type",
"playlist_name._2gram",
"playlist_name._3gram",
"playlist_name.search_as_you_type",
"title._2gram",
"title._3gram",
"title.search_as_you_type",
],
}
}
)
return must_list
def _build_video(self):
"""build video query"""
must_list = []
if (term := self.query_map.get("term")) is not None:
must_list.append(
{
"multi_match": {
"query": term,
"type": "bool_prefix",
"fuzziness": self._get_fuzzy(),
"operator": "and",
"fields": [
"title._2gram^2",
"title._3gram^2",
"title.search_as_you_type^2",
"tags",
"category",
],
}
}
)
if (active := self.query_map.get("active")) is not None:
must_list.append({"term": {"active": {"value": active}}})
if (channel := self.query_map.get("channel")) is not None:
must_list.append(
{
"multi_match": {
"query": channel,
"type": "bool_prefix",
"fuzziness": self._get_fuzzy(),
"operator": "and",
"fields": [
"channel.channel_name._2gram",
"channel.channel_name._3gram",
"channel.channel_name.search_as_you_type",
],
}
}
)
return must_list
def _build_channel(self):
"""build query for channel"""
must_list = []
if (term := self.query_map.get("term")) is not None:
must_list.append(
{
"multi_match": {
"query": term,
"type": "bool_prefix",
"fuzziness": self._get_fuzzy(),
"operator": "and",
"fields": [
"channel_description",
"channel_name._2gram^2",
"channel_name._3gram^2",
"channel_name.search_as_you_type^2",
],
}
}
)
if (active := self.query_map.get("active")) is not None:
must_list.append({"term": {"channel_active": {"value": active}}})
if (subscribed := self.query_map.get("subscribed")) is not None:
must_list.append(
{"term": {"channel_subscribed": {"value": subscribed}}}
)
return must_list
def _build_playlist(self):
"""build query for playlist"""
must_list = []
if (term := self.query_map.get("term")) is not None:
must_list.append(
{
"multi_match": {
"query": term,
"type": "bool_prefix",
"fuzziness": self._get_fuzzy(),
"operator": "and",
"fields": [
"playlist_description",
"playlist_name._2gram^2",
"playlist_name._3gram^2",
"playlist_name.search_as_you_type^2",
],
}
}
)
if (active := self.query_map.get("active")) is not None:
must_list.append({"term": {"playlist_active": {"value": active}}})
if (subscribed := self.query_map.get("subscribed")) is not None:
must_list.append(
{"term": {"playlist_subscribed": {"value": subscribed}}}
)
return must_list
def _build_fulltext(self):
"""build query for fulltext search"""
must_list = []
if (term := self.query_map.get("term")) is not None:
must_list.append(
{
"match": {
"subtitle_line": {
"query": term,
"fuzziness": self._get_fuzzy(),
}
}
}
)
if (lang := self.query_map.get("lang")) is not None:
must_list.append({"term": {"subtitle_lang": {"value": lang[0]}}})
if (source := self.query_map.get("source")) is not None:
must_list.append(
{"term": {"subtitle_source": {"value": source[0]}}}
)
query = {
"size": 30,
"_source": {"excludes": "subtitle_line"},
"query": {"bool": {"must": must_list}},
"highlight": {
"fields": {
"subtitle_line": {
"number_of_fragments": 0,
"pre_tags": ['<span class="settings-current">'],
"post_tags": ["</span>"],
}
}
},
}
return query

View File

@@ -57,6 +57,14 @@ class WatchState:
print(response) print(response)
raise ValueError("failed to mark video as watched") raise ValueError("failed to mark video as watched")
def _get_source(self):
"""build source line for update_by_query script"""
source = [
"ctx._source.player['watched'] = true",
f"ctx._source.player['watched_date'] = {self.stamp}",
]
return "; ".join(source)
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" path = "ta_video/_update_by_query"
@@ -67,7 +75,7 @@ class WatchState:
data = { data = {
"query": {"bool": {"must": must_list}}, "query": {"bool": {"must": must_list}},
"script": { "script": {
"source": "ctx._source.player['watched'] = true", "source": self._get_source(),
"lang": "painless", "lang": "painless",
}, },
} }
@@ -87,7 +95,7 @@ class WatchState:
data = { data = {
"query": {"bool": {"must": must_list}}, "query": {"bool": {"must": must_list}},
"script": { "script": {
"source": "ctx._source.player['watched'] = true", "source": self._get_source(),
"lang": "painless", "lang": "painless",
}, },
} }

View File

@@ -10,10 +10,10 @@ import re
from datetime import datetime from datetime import datetime
import requests import requests
import yt_dlp
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from home.src.download import queue # partial import 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.download.yt_dlp_base import YtWrap
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
@@ -37,6 +37,9 @@ class ChannelScraper:
"""main method to return channel dict""" """main method to return channel dict"""
self.get_soup() self.get_soup()
self._extract_yt_json() self._extract_yt_json()
if self._is_deactivated():
return False
self._parse_channel_main() self._parse_channel_main()
self._parse_channel_meta() self._parse_channel_meta()
return self.json_data return self.json_data
@@ -47,7 +50,7 @@ class ChannelScraper:
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( response = requests.get(
url, cookies=cookies, headers=requests_headers() url, cookies=cookies, headers=requests_headers(), timeout=10
) )
if response.ok: if response.ok:
channel_page = response.text channel_page = response.text
@@ -68,6 +71,17 @@ class ChannelScraper:
json_raw = script_content.rstrip(";</script>") json_raw = script_content.rstrip(";</script>")
self.yt_json = json.loads(json_raw) self.yt_json = json.loads(json_raw)
def _is_deactivated(self):
"""check if channel is deactivated"""
alerts = self.yt_json.get("alerts")
if not alerts:
return False
for alert in alerts:
alert_text = alert["alertRenderer"]["text"]["simpleText"]
print(f"{self.channel_id}: failed to extract, {alert_text}")
return True
def _parse_channel_main(self): def _parse_channel_main(self):
"""extract maintab values from scraped channel json data""" """extract maintab values from scraped channel json data"""
main_tab = self.yt_json["header"]["c4TabbedHeaderRenderer"] main_tab = self.yt_json["header"]["c4TabbedHeaderRenderer"]
@@ -153,36 +167,82 @@ class YoutubeChannel(YouTubeItem):
es_path = False es_path = False
index_name = "ta_channel" index_name = "ta_channel"
yt_base = "https://www.youtube.com/channel/" yt_base = "https://www.youtube.com/channel/"
msg = "message:playlistscan"
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 self.all_playlists = False
def build_json(self, upload=False): def build_json(self, upload=False, fallback=False):
"""get from es or from youtube""" """get from es or from youtube"""
self.get_from_es() self.get_from_es()
if self.json_data: if self.json_data:
return return
self.get_from_youtube() self.get_from_youtube(fallback)
if upload: if upload:
self.upload_to_es() self.upload_to_es()
return return
def get_from_youtube(self): def get_from_youtube(self, fallback=False):
"""use bs4 to scrape channel about page""" """use bs4 to scrape channel about page"""
self.json_data = ChannelScraper(self.youtube_id).get_json() self.json_data = ChannelScraper(self.youtube_id).get_json()
if not self.json_data and fallback:
self._video_fallback(fallback)
if not self.json_data:
return
self.get_channel_art() self.get_channel_art()
def _video_fallback(self, fallback):
"""use video metadata as fallback"""
print(f"{self.youtube_id}: fallback to video metadata")
self.json_data = {
"channel_active": False,
"channel_last_refresh": int(datetime.now().strftime("%s")),
"channel_subs": fallback.get("channel_follower_count", 0),
"channel_name": fallback["uploader"],
"channel_banner_url": False,
"channel_tvart_url": False,
"channel_id": self.youtube_id,
"channel_subscribed": False,
"channel_description": False,
"channel_thumb_url": False,
"channel_views": 0,
}
self._info_json_fallback()
def _info_json_fallback(self):
"""read channel info.json for additional metadata"""
info_json = os.path.join(
self.config["application"]["cache_dir"],
"import",
f"{self.youtube_id}.info.json",
)
if os.path.exists(info_json):
print(f"{self.youtube_id}: read info.json file")
with open(info_json, "r", encoding="utf-8") as f:
content = json.loads(f.read())
self.json_data.update(
{
"channel_subs": content.get("channel_follower_count", 0),
"channel_description": content.get("description", False),
}
)
os.remove(info_json)
def get_channel_art(self): def get_channel_art(self):
"""download channel art for new channels""" """download channel art for new channels"""
channel_id = self.youtube_id urls = (
channel_thumb = self.json_data["channel_thumb_url"] self.json_data["channel_thumb_url"],
channel_banner = self.json_data["channel_banner_url"] self.json_data["channel_banner_url"],
ThumbManager().download_chan(
[(channel_id, channel_thumb, channel_banner)]
) )
ThumbManager(self.youtube_id, item_type="channel").download(urls)
def sync_to_videos(self): def sync_to_videos(self):
"""sync new channel_dict to all videos of channel""" """sync new channel_dict to all videos of channel"""
@@ -218,6 +278,15 @@ class YoutubeChannel(YouTubeItem):
} }
_, _ = ElasticWrap("ta_video/_delete_by_query").post(data) _, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
def delete_es_comments(self):
"""delete all comments from this channel"""
data = {
"query": {
"term": {"comment_channel_id": {"value": self.youtube_id}}
}
}
_, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
def delete_playlists(self): def delete_playlists(self):
"""delete all indexed playlist from es""" """delete all indexed playlist from es"""
all_playlists = self.get_indexed_playlists() all_playlists = self.get_indexed_playlists()
@@ -244,6 +313,7 @@ class YoutubeChannel(YouTubeItem):
self.delete_playlists() self.delete_playlists()
print(f"{self.youtube_id}: delete indexed videos") print(f"{self.youtube_id}: delete indexed videos")
self.delete_es_videos() self.delete_es_videos()
self.delete_es_comments()
self.del_in_es() self.del_in_es()
def index_channel_playlists(self): def index_channel_playlists(self):
@@ -252,12 +322,12 @@ class YoutubeChannel(YouTubeItem):
self.get_from_es() self.get_from_es()
channel_name = self.json_data["channel_name"] channel_name = self.json_data["channel_name"]
mess_dict = { mess_dict = {
"status": "message:playlistscan", "status": self.msg,
"level": "info", "level": "info",
"title": "Looking for playlists", "title": "Looking for playlists",
"message": f"{channel_name}: Scanning channel in progress", "message": f"{channel_name}: Scanning channel in progress",
} }
RedisArchivist().set_message("message:playlistscan", mess_dict) RedisArchivist().set_message(self.msg, mess_dict, expire=True)
self.get_all_playlists() self.get_all_playlists()
if not self.all_playlists: if not self.all_playlists:
print(f"{self.youtube_id}: no playlists found.") print(f"{self.youtube_id}: no playlists found.")
@@ -272,12 +342,12 @@ class YoutubeChannel(YouTubeItem):
"""send notification""" """send notification"""
channel_name = self.json_data["channel_name"] channel_name = self.json_data["channel_name"]
mess_dict = { mess_dict = {
"status": "message:playlistscan", "status": self.msg,
"level": "info", "level": "info",
"title": f"{channel_name}: Scanning channel for playlists", "title": f"{channel_name}: Scanning channel for playlists",
"message": f"Progress: {idx + 1}/{len(self.all_playlists)}", "message": f"Progress: {idx + 1}/{len(self.all_playlists)}",
} }
RedisArchivist().set_message("message:playlistscan", mess_dict) RedisArchivist().set_message(self.msg, mess_dict, expire=True)
print("add playlist: " + playlist[1]) print("add playlist: " + playlist[1])
@staticmethod @staticmethod
@@ -314,12 +384,8 @@ class YoutubeChannel(YouTubeItem):
f"https://www.youtube.com/channel/{self.youtube_id}" f"https://www.youtube.com/channel/{self.youtube_id}"
+ "/playlists?view=1&sort=dd&shelf_id=0" + "/playlists?view=1&sort=dd&shelf_id=0"
) )
obs = { obs = {"skip_download": True, "extract_flat": True}
"quiet": True, playlists = YtWrap(obs, self.config).extract(url)
"skip_download": True,
"extract_flat": True,
}
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 self.all_playlists = all_entries
@@ -355,6 +421,7 @@ class YoutubeChannel(YouTubeItem):
to_write[key] = False to_write[key] = False
continue continue
if value in [0, "0"]: if value in [0, "0"]:
if key in to_write:
del to_write[key] del to_write[key]
continue continue
if value == "1": if value == "1":

View File

@@ -0,0 +1,179 @@
"""
Functionality:
- Download comments
- Index comments in ES
- Retrieve comments from ES
"""
from datetime import datetime
from home.src.download.yt_dlp_base import YtWrap
from home.src.es.connect import ElasticWrap
from home.src.ta.config import AppConfig
from home.src.ta.ta_redis import RedisArchivist
class Comments:
"""hold all comments functionality"""
def __init__(self, youtube_id, config=False):
self.youtube_id = youtube_id
self.es_path = f"ta_comment/_doc/{youtube_id}"
self.json_data = False
self.config = config
self.is_activated = False
self.comments_format = False
def build_json(self, notify=False):
"""build json document for es"""
print(f"{self.youtube_id}: get comments")
self.check_config()
if not self.is_activated:
return
self._send_notification(notify)
comments_raw, channel_id = self.get_yt_comments()
if comments_raw:
self.format_comments(comments_raw)
else:
self.comments_format = []
self.json_data = {
"youtube_id": self.youtube_id,
"comment_last_refresh": int(datetime.now().strftime("%s")),
"comment_channel_id": channel_id,
"comment_comments": self.comments_format,
}
def check_config(self):
"""read config if not attached"""
if not self.config:
self.config = AppConfig().config
self.is_activated = bool(self.config["downloads"]["comment_max"])
@staticmethod
def _send_notification(notify):
"""send notification for download post process message"""
if not notify:
return
key = "message:download"
idx, total_videos = notify
message = {
"status": key,
"level": "info",
"title": "Download and index comments",
"message": f"Progress: {idx + 1}/{total_videos}",
}
RedisArchivist().set_message(key, message)
def build_yt_obs(self):
"""
get extractor config
max-comments,max-parents,max-replies,max-replies-per-thread
"""
max_comments = self.config["downloads"]["comment_max"]
max_comments_list = [i.strip() for i in max_comments.split(",")]
comment_sort = self.config["downloads"]["comment_sort"]
yt_obs = {
"skip_download": True,
"quiet": False,
"getcomments": True,
"extractor_args": {
"youtube": {
"max_comments": max_comments_list,
"comment_sort": [comment_sort],
}
},
}
return yt_obs
def get_yt_comments(self):
"""get comments from youtube"""
yt_obs = self.build_yt_obs()
info_json = YtWrap(yt_obs).extract(self.youtube_id)
comments_raw = info_json.get("comments")
channel_id = info_json.get("channel_id")
return comments_raw, channel_id
def format_comments(self, comments_raw):
"""process comments to match format"""
comments = []
for comment in comments_raw:
cleaned_comment = self.clean_comment(comment)
comments.append(cleaned_comment)
self.comments_format = comments
def clean_comment(self, comment):
"""parse metadata from comment for indexing"""
time_text_datetime = datetime.utcfromtimestamp(comment["timestamp"])
if time_text_datetime.hour == 0 and time_text_datetime.minute == 0:
format_string = "%Y-%m-%d"
else:
format_string = "%Y-%m-%d %H:%M"
time_text = time_text_datetime.strftime(format_string)
cleaned_comment = {
"comment_id": comment["id"],
"comment_text": comment["text"].replace("\xa0", ""),
"comment_timestamp": comment["timestamp"],
"comment_time_text": time_text,
"comment_likecount": comment["like_count"],
"comment_is_favorited": comment["is_favorited"],
"comment_author": comment["author"],
"comment_author_id": comment["author_id"],
"comment_author_thumbnail": comment["author_thumbnail"],
"comment_author_is_uploader": comment["author_is_uploader"],
"comment_parent": comment["parent"],
}
return cleaned_comment
def upload_comments(self):
"""upload comments to es"""
if not self.is_activated:
return
_, _ = ElasticWrap(self.es_path).put(self.json_data)
vid_path = f"ta_video/_update/{self.youtube_id}"
data = {"doc": {"comment_count": len(self.comments_format)}}
_, _ = ElasticWrap(vid_path).post(data=data)
def delete_comments(self):
"""delete comments from es"""
print(f"{self.youtube_id}: delete comments")
_, _ = ElasticWrap(self.es_path).delete(refresh=True)
def get_es_comments(self):
"""get comments from ES"""
response, statuscode = ElasticWrap(self.es_path).get()
if statuscode == 404:
print(f"comments: not found {self.youtube_id}")
return False
return response.get("_source")
def reindex_comments(self):
"""update comments from youtube"""
self.check_config()
if not self.is_activated:
return
self.build_json()
es_comments = self.get_es_comments()
if not self.comments_format and es_comments["comment_comments"]:
# don't overwrite comments in es
return
self.delete_comments()
self.upload_comments()

View File

@@ -12,13 +12,17 @@ import shutil
import subprocess import subprocess
from home.src.download.queue import PendingList from home.src.download.queue import PendingList
from home.src.download.yt_dlp_handler import VideoDownloader from home.src.download.thumbnails import ThumbManager
from home.src.es.connect import ElasticWrap 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 YoutubeVideo, index_new_video
from home.src.ta.config import AppConfig from home.src.ta.config import AppConfig
from home.src.ta.helper import clean_string, ignore_filelist from home.src.ta.helper import clean_string, ignore_filelist
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
from PIL import Image, ImageFile
from yt_dlp.utils import ISO639Utils
ImageFile.LOAD_TRUNCATED_IMAGES = True
class FilesystemScanner: class FilesystemScanner:
@@ -157,63 +161,152 @@ class FilesystemScanner:
_, _ = ElasticWrap(path).delete() _, _ = ElasticWrap(path).delete()
class ManualImport: class ImportFolderScanner:
"""import and indexing existing video files""" """import and indexing existing video files
- identify all media files belonging to a video
- identify youtube id
- convert if needed
"""
CONFIG = AppConfig().config CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"] CACHE_DIR = CONFIG["application"]["cache_dir"]
IMPORT_DIR = os.path.join(CACHE_DIR, "import") IMPORT_DIR = os.path.join(CACHE_DIR, "import")
EXT_MAP = {
"media": [".mp4", ".mkv", ".webm"],
"metadata": [".json"],
"thumb": [".jpg", ".png", ".webp"],
"subtitle": [".vtt"],
}
def __init__(self): def __init__(self):
self.identified = self.import_folder_parser() self.to_import = False
def import_folder_parser(self): def scan(self):
"""detect files in import folder""" """scan and match media files"""
import_files = os.listdir(self.IMPORT_DIR) all_files = self.get_all_files()
to_import = ignore_filelist(import_files) self.match_files(all_files)
to_import.sort() self.process_videos()
video_files = [i for i in to_import if not i.endswith(".json")]
identified = [] return self.to_import
for file_path in video_files: def get_all_files(self):
"""get all files in /import"""
rel_paths = ignore_filelist(os.listdir(self.IMPORT_DIR))
all_files = [os.path.join(self.IMPORT_DIR, i) for i in rel_paths]
all_files.sort()
file_dict = {"video_file": file_path} return all_files
file_name, _ = os.path.splitext(file_path)
matching_json = [
i
for i in to_import
if i.startswith(file_name) and i.endswith(".json")
]
if matching_json:
json_file = matching_json[0]
youtube_id = self.extract_id_from_json(json_file)
file_dict.update({"json_file": json_file})
else:
youtube_id = self.extract_id_from_filename(file_name)
file_dict.update({"json_file": False})
file_dict.update({"youtube_id": youtube_id})
identified.append(file_dict)
return identified
@staticmethod @staticmethod
def extract_id_from_filename(file_name): def _get_template():
"""base dict for video"""
return {
"media": False,
"video_id": False,
"metadata": False,
"thumb": False,
"subtitle": [],
}
def match_files(self, all_files):
"""loop through all files, join what matches"""
self.to_import = []
current_video = self._get_template()
last_base = False
for file_path in all_files:
base_name, ext = self._detect_base_name(file_path)
key, file_path = self._detect_type(file_path, ext)
if not key or not file_path:
continue
if base_name != last_base:
if last_base:
print(f"manual import: {current_video}")
self.to_import.append(current_video)
current_video = self._get_template()
last_base = base_name
if key == "subtitle":
current_video["subtitle"].append(file_path)
else:
current_video[key] = file_path
if current_video.get("media"):
print(f"manual import: {current_video}")
self.to_import.append(current_video)
def _detect_base_name(self, file_path):
"""extract base_name and ext for matching"""
base_name_raw, ext = os.path.splitext(file_path)
base_name, ext2 = os.path.splitext(base_name_raw)
if ext2:
if ISO639Utils.short2long(ext2.strip(".")) or ext2 == ".info":
# valid secondary extension
return base_name, ext
return base_name_raw, ext
def _detect_type(self, file_path, ext):
"""detect metadata type for file"""
for key, value in self.EXT_MAP.items():
if ext in value:
return key, file_path
return False, False
def process_videos(self):
"""loop through all videos"""
for current_video in self.to_import:
if not current_video["media"]:
print(f"{current_video}: no matching media file found.")
raise ValueError
self._detect_youtube_id(current_video)
self._dump_thumb(current_video)
self._convert_thumb(current_video)
self._get_subtitles(current_video)
self._convert_video(current_video)
print(f"manual import: {current_video}")
ManualImport(current_video, self.CONFIG).run()
def _detect_youtube_id(self, current_video):
"""find video id from filename or json"""
youtube_id = self._extract_id_from_filename(current_video["media"])
if youtube_id:
current_video["video_id"] = youtube_id
return
youtube_id = self._extract_id_from_json(current_video["metadata"])
if youtube_id:
current_video["video_id"] = youtube_id
return
raise ValueError("failed to find video id")
@staticmethod
def _extract_id_from_filename(file_name):
""" """
look at the file name for the youtube id look at the file name for the youtube id
expects filename ending in [<youtube_id>].<ext> expects filename ending in [<youtube_id>].<ext>
""" """
id_search = re.search(r"\[([a-zA-Z0-9_-]{11})\]$", file_name) base_name, _ = os.path.splitext(file_name)
id_search = re.search(r"\[([a-zA-Z0-9_-]{11})\]$", base_name)
if id_search: if id_search:
youtube_id = id_search.group(1) youtube_id = id_search.group(1)
return youtube_id return youtube_id
print("failed to extract youtube id for: " + file_name) print(f"id extraction failed from filename: {file_name}")
raise Exception
def extract_id_from_json(self, json_file): return False
def _extract_id_from_json(self, json_file):
"""open json file and extract id""" """open json file and extract id"""
json_path = os.path.join(self.CACHE_DIR, "import", json_file) json_path = os.path.join(self.CACHE_DIR, "import", json_file)
with open(json_path, "r", encoding="utf-8") as f: with open(json_path, "r", encoding="utf-8") as f:
@@ -223,66 +316,276 @@ class ManualImport:
return youtube_id return youtube_id
def process_import(self): def _dump_thumb(self, current_video):
"""go through identified media files""" """extract embedded thumb before converting"""
if current_video["thumb"]:
return
all_videos_added = [] media_path = current_video["media"]
_, ext = os.path.splitext(media_path)
for media_file in self.identified: new_path = False
json_file = media_file["json_file"] if ext == ".mkv":
video_file = media_file["video_file"] idx, thumb_type = self._get_mkv_thumb_stream(media_path)
youtube_id = media_file["youtube_id"] if idx is not None:
new_path = self.dump_mpv_thumb(media_path, idx, thumb_type)
video_path = os.path.join(self.CACHE_DIR, "import", video_file) elif ext == ".mp4":
thumb_type = self.get_mp4_thumb_type(media_path)
if thumb_type:
new_path = self.dump_mp4_thumb(media_path, thumb_type)
self.move_to_cache(video_path, youtube_id) if new_path:
current_video["thumb"] = new_path
# identify and archive def _get_mkv_thumb_stream(self, media_path):
vid_dict = index_new_video(youtube_id) """get stream idx of thumbnail for mkv files"""
VideoDownloader([youtube_id]).move_to_archive(vid_dict) streams = self._get_streams(media_path)
youtube_id = vid_dict["youtube_id"] attachments = [
thumb_url = vid_dict["vid_thumb_url"] i for i in streams["streams"] if i["codec_type"] == "attachment"
all_videos_added.append((youtube_id, thumb_url)) ]
# cleanup for idx, stream in enumerate(attachments):
if os.path.exists(video_path): tags = stream["tags"]
os.remove(video_path) if "mimetype" in tags and tags["filename"].startswith("cover"):
if json_file: _, ext = os.path.splitext(tags["filename"])
json_path = os.path.join(self.CACHE_DIR, "import", json_file) return idx, ext
os.remove(json_path)
return all_videos_added return None, None
def move_to_cache(self, video_path, youtube_id): @staticmethod
"""move identified video file to cache, convert to mp4""" def dump_mpv_thumb(media_path, idx, thumb_type):
file_name = os.path.split(video_path)[-1] """write cover to disk for mkv"""
video_file, ext = os.path.splitext(file_name) _, media_ext = os.path.splitext(media_path)
new_path = f"{media_path.rstrip(media_ext)}{thumb_type}"
subprocess.run(
[
"ffmpeg",
"-v",
"quiet",
f"-dump_attachment:t:{idx}",
new_path,
"-i",
media_path,
],
check=False,
)
# make sure youtube_id is in filename return new_path
if youtube_id not in video_file:
video_file = f"{video_file}_{youtube_id}" def get_mp4_thumb_type(self, media_path):
"""detect filetype of embedded thumbnail"""
streams = self._get_streams(media_path)
for stream in streams["streams"]:
if stream["codec_name"] in ["png", "jpg"]:
return stream["codec_name"]
return False
def _convert_thumb(self, current_video):
"""convert all thumbnails to jpg"""
if not current_video["thumb"]:
return
thumb_path = current_video["thumb"]
base_path, ext = os.path.splitext(thumb_path)
if ext == ".jpg":
return
new_path = f"{base_path}.jpg"
img_raw = Image.open(thumb_path)
img_raw.convert("RGB").save(new_path)
os.remove(thumb_path)
current_video["thumb"] = new_path
def _get_subtitles(self, current_video):
"""find all subtitles in media file"""
if current_video["subtitle"]:
return
media_path = current_video["media"]
streams = self._get_streams(media_path)
base_path, ext = os.path.splitext(media_path)
if ext == ".webm":
print(f"{media_path}: subtitle extract from webm not supported")
return
for idx, stream in enumerate(streams["streams"]):
if stream["codec_type"] == "subtitle":
lang = ISO639Utils.long2short(stream["tags"]["language"])
sub_path = f"{base_path}.{lang}.vtt"
self._dump_subtitle(idx, media_path, sub_path)
current_video["subtitle"].append(sub_path)
@staticmethod
def _dump_subtitle(idx, media_path, sub_path):
"""extract subtitle from media file"""
subprocess.run(
["ffmpeg", "-i", media_path, "-map", f"0:{idx}", sub_path],
check=True,
)
@staticmethod
def _get_streams(media_path):
"""return all streams from media_path"""
streams_raw = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_streams",
"-print_format",
"json",
media_path,
],
capture_output=True,
check=True,
)
streams = json.loads(streams_raw.stdout.decode())
return streams
@staticmethod
def dump_mp4_thumb(media_path, thumb_type):
"""save cover to disk"""
_, ext = os.path.splitext(media_path)
new_path = f"{media_path.rstrip(ext)}.{thumb_type}"
# move, convert if needed
if ext == ".mp4":
new_file = video_file + ext
dest_path = os.path.join(self.CACHE_DIR, "download", new_file)
shutil.move(video_path, dest_path)
else:
print(f"processing with ffmpeg: {video_file}")
new_file = video_file + ".mp4"
dest_path = os.path.join(self.CACHE_DIR, "download", new_file)
subprocess.run( subprocess.run(
[ [
"ffmpeg", "ffmpeg",
"-i", "-i",
video_path, media_path,
dest_path, "-map",
"0:v",
"-map",
"-0:V",
"-c",
"copy",
new_path,
],
check=True,
)
return new_path
def _convert_video(self, current_video):
"""convert if needed"""
current_path = current_video["media"]
base_path, ext = os.path.splitext(current_path)
if ext == ".mp4":
return
new_path = base_path + ".mp4"
subprocess.run(
[
"ffmpeg",
"-i",
current_path,
new_path,
"-loglevel", "-loglevel",
"warning", "warning",
"-stats", "-stats",
], ],
check=True, check=True,
) )
current_video["media"] = new_path
os.remove(current_path)
class ManualImport:
"""import single identified video"""
def __init__(self, current_video, config):
self.current_video = current_video
self.config = config
def run(self):
"""run all"""
json_data = self.index_metadata()
self._move_to_archive(json_data)
self._cleanup(json_data)
def index_metadata(self):
"""get metadata from yt or json"""
video_id = self.current_video["video_id"]
video = YoutubeVideo(video_id)
video.build_json(
youtube_meta_overwrite=self._get_info_json(),
media_path=self.current_video["media"],
)
if not video.json_data:
print(f"{video_id}: manual import failed, and no metadata found.")
raise ValueError
video.check_subtitles(subtitle_files=self.current_video["subtitle"])
video.upload_to_es()
if video.offline_import and self.current_video["thumb"]:
old_path = self.current_video["thumb"]
thumbs = ThumbManager(video_id)
new_path = thumbs.vid_thumb_path(absolute=True, create_folder=True)
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
else:
url = video.json_data["vid_thumb_url"]
ThumbManager(video_id).download_video_thumb(url)
return video.json_data
def _get_info_json(self):
"""read info_json from file"""
if not self.current_video["metadata"]:
return False
with open(self.current_video["metadata"], "r", encoding="utf-8") as f:
info_json = json.loads(f.read())
return info_json
def _move_to_archive(self, json_data):
"""move identified media file to archive"""
videos = self.config["application"]["videos"]
channel, file = os.path.split(json_data["media_url"])
channel_folder = os.path.join(videos, channel)
if not os.path.exists(channel_folder):
os.makedirs(channel_folder)
old_path = self.current_video["media"]
new_path = os.path.join(channel_folder, file)
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
base_name, _ = os.path.splitext(new_path)
for old_path in self.current_video["subtitle"]:
lang = old_path.split(".")[-2]
new_path = f"{base_name}.{lang}.vtt"
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
def _cleanup(self, json_data):
"""cleanup leftover files"""
meta_data = self.current_video["metadata"]
if meta_data and os.path.exists(meta_data):
os.remove(meta_data)
thumb = self.current_video["thumb"]
if thumb and os.path.exists(thumb):
os.remove(thumb)
for subtitle_file in self.current_video["subtitle"]:
if os.path.exists(subtitle_file):
os.remove(subtitle_file)
channel_info = os.path.join(
self.config["application"]["cache_dir"],
"import",
f"{json_data['channel']['channel_id']}.info.json",
)
if os.path.exists(channel_info):
os.remove(channel_info)
def scan_filesystem(): def scan_filesystem():
@@ -310,4 +613,4 @@ def reindex_old_documents():
handler = Reindex() handler = Reindex()
handler.check_outdated() handler.check_outdated()
handler.reindex() handler.reindex()
RedisArchivist().set_message("last_reindex", handler.now, expire=False) RedisArchivist().set_message("last_reindex", handler.now)

View File

@@ -5,7 +5,7 @@ functionality:
import math import math
import yt_dlp from home.src.download.yt_dlp_base import YtWrap
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
@@ -18,38 +18,22 @@ class YouTubeItem:
index_name = False index_name = False
yt_base = False yt_base = False
yt_obs = { yt_obs = {
"quiet": True,
"default_search": "ytsearch",
"skip_download": True, "skip_download": True,
"check_formats": "selected",
"noplaylist": True, "noplaylist": True,
} }
def __init__(self, youtube_id): def __init__(self, youtube_id):
self.youtube_id = youtube_id self.youtube_id = youtube_id
self.config = False
self.app_conf = False
self.youtube_meta = False
self.json_data = False
self._get_conf()
def _get_conf(self):
"""read user conf"""
self.config = AppConfig().config self.config = AppConfig().config
self.app_conf = self.config["application"] self.app_conf = self.config["application"]
self.youtube_meta = False
self.json_data = False
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"""
print(f"{self.youtube_id}: get metadata from youtube") print(f"{self.youtube_id}: get metadata from youtube")
try: url = self.yt_base + self.youtube_id
yt_item = yt_dlp.YoutubeDL(self.yt_obs) response = YtWrap(self.yt_obs, self.config).extract(url)
response = yt_item.extract_info(self.yt_base + self.youtube_id)
except (
yt_dlp.utils.ExtractorError,
yt_dlp.utils.DownloadError,
):
print(f"{self.youtube_id}: failed to get info from youtube")
response = False
self.youtube_meta = response self.youtube_meta = response
@@ -72,16 +56,16 @@ class YouTubeItem:
"ta_channel": "channel_active", "ta_channel": "channel_active",
"ta_playlist": "playlist_active", "ta_playlist": "playlist_active",
} }
update_path = f"{self.index_name}/_update/{self.youtube_id}" path = f"{self.index_name}/_update/{self.youtube_id}?refresh=true"
data = { data = {
"script": f"ctx._source.{key_match.get(self.index_name)} = false" "script": f"ctx._source.{key_match.get(self.index_name)} = false"
} }
_, _ = ElasticWrap(update_path).post(data) _, _ = ElasticWrap(path).post(data)
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:
@@ -89,16 +73,25 @@ class Pagination:
figure out the pagination based on page size and total_hits figure out the pagination based on page size and total_hits
""" """
def __init__(self, page_get, user_id, search_get=False): def __init__(self, request):
self.user_id = user_id self.request = request
self.page_get = False
self.params = False
self.get_params()
self.page_size = self.get_page_size() self.page_size = self.get_page_size()
self.page_get = page_get
self.search_get = search_get
self.pagination = self.first_guess() self.pagination = self.first_guess()
def get_params(self):
"""process url query parameters"""
query_dict = self.request.GET.copy()
self.page_get = int(query_dict.get("page", 0))
_ = query_dict.pop("page", False)
self.params = query_dict.urlencode()
def get_page_size(self): def get_page_size(self):
"""get default or user modified page_size""" """get default or user modified page_size"""
key = f"{self.user_id}:page_size" key = f"{self.request.user.id}:page_size"
page_size = RedisArchivist().get_message(key)["status"] page_size = RedisArchivist().get_message(key)["status"]
if not page_size: if not page_size:
config = AppConfig().config config = AppConfig().config
@@ -124,9 +117,9 @@ class Pagination:
"prev_pages": prev_pages, "prev_pages": prev_pages,
"current_page": page_get, "current_page": page_get,
"max_hits": False, "max_hits": False,
"params": self.params,
} }
if self.search_get:
pagination.update({"search_get": self.search_get})
return pagination return pagination
def validate(self, total_hits): def validate(self, total_hits):
@@ -147,3 +140,4 @@ class Pagination:
] ]
self.pagination["next_pages"] = next_pages self.pagination["next_pages"] = next_pages
self.pagination["total_hits"] = total_hits

View File

@@ -19,10 +19,8 @@ class YoutubePlaylist(YouTubeItem):
es_path = False es_path = False
index_name = "ta_playlist" index_name = "ta_playlist"
yt_obs = { yt_obs = {
"default_search": "ytsearch",
"quiet": True,
"skip_download": True,
"extract_flat": True, "extract_flat": True,
"allow_playlist_files": True,
} }
yt_base = "https://www.youtube.com/playlist?list=" yt_base = "https://www.youtube.com/playlist?list="
@@ -43,21 +41,30 @@ class YoutubePlaylist(YouTubeItem):
if scrape or not self.json_data: if scrape or not self.json_data:
self.get_from_youtube() self.get_from_youtube()
if not self.youtube_meta:
self.json_data = False
return
self.process_youtube_meta() self.process_youtube_meta()
self.get_entries() self.get_entries()
self.json_data["playlist_entries"] = self.all_members self.json_data["playlist_entries"] = self.all_members
self.get_playlist_art()
self.json_data["playlist_subscribed"] = subscribed self.json_data["playlist_subscribed"] = subscribed
def process_youtube_meta(self): def process_youtube_meta(self):
"""extract relevant fields from youtube""" """extract relevant fields from youtube"""
try:
playlist_thumbnail = self.youtube_meta["thumbnails"][-1]["url"]
except IndexError:
print(f"{self.youtube_id}: thumbnail extraction failed")
playlist_thumbnail = False
self.json_data = { self.json_data = {
"playlist_id": self.youtube_id, "playlist_id": self.youtube_id,
"playlist_active": True, "playlist_active": True,
"playlist_name": self.youtube_meta["title"], "playlist_name": self.youtube_meta["title"],
"playlist_channel": self.youtube_meta["channel"], "playlist_channel": self.youtube_meta["channel"],
"playlist_channel_id": self.youtube_meta["channel_id"], "playlist_channel_id": self.youtube_meta["channel_id"],
"playlist_thumbnail": self.youtube_meta["thumbnails"][-1]["url"], "playlist_thumbnail": playlist_thumbnail,
"playlist_description": self.youtube_meta["description"] or False, "playlist_description": self.youtube_meta["description"] or False,
"playlist_last_refresh": int(datetime.now().strftime("%s")), "playlist_last_refresh": int(datetime.now().strftime("%s")),
} }
@@ -73,12 +80,12 @@ class YoutubePlaylist(YouTubeItem):
downloaded = entry["id"] in self.all_youtube_ids downloaded = entry["id"] in self.all_youtube_ids
else: else:
downloaded = False downloaded = False
if not entry["uploader"]: if not entry["channel"]:
continue continue
to_append = { to_append = {
"youtube_id": entry["id"], "youtube_id": entry["id"],
"title": entry["title"], "title": entry["title"],
"uploader": entry["uploader"], "uploader": entry["channel"],
"idx": idx, "idx": idx,
"downloaded": downloaded, "downloaded": downloaded,
} }
@@ -86,12 +93,10 @@ class YoutubePlaylist(YouTubeItem):
self.all_members = all_members self.all_members = all_members
@staticmethod def get_playlist_art(self):
def get_playlist_art():
"""download artwork of playlist""" """download artwork of playlist"""
thumbnails = ThumbManager() url = self.json_data["playlist_thumbnail"]
missing_playlists = thumbnails.get_missing_playlists() ThumbManager(self.youtube_id, item_type="playlist").download(url)
thumbnails.download_playlist(missing_playlists)
def add_vids_to_playlist(self): def add_vids_to_playlist(self):
"""sync the playlist id to videos""" """sync the playlist id to videos"""
@@ -150,17 +155,15 @@ class YoutubePlaylist(YouTubeItem):
previous_item = False previous_item = False
else: else:
previous_item = all_entries[current_idx - 1] previous_item = all_entries[current_idx - 1]
prev_thumb = ThumbManager().vid_thumb_path( prev_id = previous_item["youtube_id"]
previous_item["youtube_id"] previous_item["vid_thumb"] = ThumbManager(prev_id).vid_thumb_path()
)
previous_item["vid_thumb"] = prev_thumb
if current_idx == len(all_entries) - 1: if current_idx == len(all_entries) - 1:
next_item = False next_item = False
else: else:
next_item = all_entries[current_idx + 1] next_item = all_entries[current_idx + 1]
next_thumb = ThumbManager().vid_thumb_path(next_item["youtube_id"]) next_id = next_item["youtube_id"]
next_item["vid_thumb"] = next_thumb next_item["vid_thumb"] = ThumbManager(next_id).vid_thumb_path()
self.nav = { self.nav = {
"playlist_meta": { "playlist_meta": {

View File

@@ -4,14 +4,19 @@ functionality:
- index and update in es - index and update in es
""" """
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
from home.src.download.queue import PendingList from home.src.download.queue import PendingList
from home.src.download.thumbnails import ThumbManager from home.src.download.thumbnails import ThumbManager
from home.src.download.yt_dlp_base import CookieHandler
from home.src.download.yt_dlp_handler import VideoDownloader
from home.src.es.connect import ElasticWrap 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.comments import Comments
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
@@ -37,6 +42,13 @@ class Reindex:
self.all_channel_ids = False self.all_channel_ids = False
self.all_playlist_ids = False self.all_playlist_ids = False
def check_cookie(self):
"""validate cookie if enabled"""
if self.config["downloads"]["cookie_import"]:
valid = CookieHandler(self.config).validate()
if not valid:
return
def _get_daily(self): def _get_daily(self):
"""get daily refresh values""" """get daily refresh values"""
total_videos = self._get_total_hits("ta_video") total_videos = self._get_total_hits("ta_video")
@@ -61,14 +73,11 @@ class Reindex:
def _get_unrated_vids(self): def _get_unrated_vids(self):
"""get max 200 videos without rating if ryd integration is enabled""" """get max 200 videos without rating if ryd integration is enabled"""
data = { must_not_list = [
"size": 200, {"exists": {"field": "stats.average_rating"}},
"query": { {"term": {"active": {"value": False}}},
"bool": { ]
"must_not": [{"exists": {"field": "stats.average_rating"}}] data = {"size": 200, "query": {"bool": {"must_not": must_not_list}}}
}
},
}
response, _ = ElasticWrap("ta_video/_search").get(data=data) response, _ = ElasticWrap("ta_video/_search").get(data=data)
missing_rating = [i["_id"] for i in response["hits"]["hits"]] missing_rating = [i["_id"] for i in response["hits"]["hits"]]
@@ -139,8 +148,7 @@ class Reindex:
if integrate_ryd: if integrate_ryd:
self._get_unrated_vids() self._get_unrated_vids()
@staticmethod def _reindex_single_video(self, 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)
@@ -150,6 +158,7 @@ class Reindex:
date_downloaded = video.json_data["date_downloaded"] date_downloaded = video.json_data["date_downloaded"]
channel_dict = video.json_data["channel"] channel_dict = video.json_data["channel"]
playlist = video.json_data.get("playlist") playlist = video.json_data.get("playlist")
subtitles = video.json_data.get("subtitles")
# get new # get new
video.build_json() video.build_json()
@@ -157,7 +166,7 @@ class Reindex:
video.deactivate() video.deactivate()
return return
video.delete_subtitles() video.delete_subtitles(subtitles=subtitles)
video.check_subtitles() video.check_subtitles()
# add back # add back
@@ -169,10 +178,12 @@ class Reindex:
video.upload_to_es() video.upload_to_es()
thumb_handler = ThumbManager() thumb_handler = ThumbManager(youtube_id)
thumb_handler.delete_vid_thumb(youtube_id) thumb_handler.delete_video_thumb()
to_download = (youtube_id, video.json_data["vid_thumb_url"]) thumb_handler.download_video_thumb(video.json_data["vid_thumb_url"])
thumb_handler.download_vid([to_download], notify=False)
Comments(youtube_id, config=self.config).reindex_comments()
return return
@staticmethod @staticmethod
@@ -183,6 +194,12 @@ class Reindex:
subscribed = channel.json_data["channel_subscribed"] subscribed = channel.json_data["channel_subscribed"]
overwrites = channel.json_data.get("channel_overwrites", False) overwrites = channel.json_data.get("channel_overwrites", False)
channel.get_from_youtube() channel.get_from_youtube()
if not channel.json_data:
channel.deactivate()
channel.get_from_es()
channel.sync_to_videos()
return
channel.json_data["channel_subscribed"] = subscribed channel.json_data["channel_subscribed"] = subscribed
if overwrites: if overwrites:
channel.json_data["channel_overwrites"] = overwrites channel.json_data["channel_overwrites"] = overwrites
@@ -211,6 +228,11 @@ class Reindex:
# 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:
try:
self._reindex_single_video(youtube_id)
except FileNotFoundError:
# handle channel name change here
ChannelUrlFixer(youtube_id, self.config).run()
self._reindex_single_video(youtube_id) self._reindex_single_video(youtube_id)
if sleep_interval: if sleep_interval:
sleep(sleep_interval) sleep(sleep_interval)
@@ -231,3 +253,62 @@ class Reindex:
self._reindex_single_playlist(playlist_id, all_indexed_ids) self._reindex_single_playlist(playlist_id, all_indexed_ids)
if sleep_interval: if sleep_interval:
sleep(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_path = os.path.join(
cache_dir, "download", self.youtube_id + ".mp4"
)
shutil.move(video_path_is, new_path, copy_function=shutil.copyfile)
VideoDownloader().move_to_archive(self.video.json_data)
self.video.update_media_url()

View File

@@ -0,0 +1,321 @@
"""
functionality:
- download subtitles
- parse subtitles into it's cues
- index dubtitles
"""
import json
import os
from datetime import datetime
import requests
from home.src.es.connect import ElasticWrap
from home.src.ta.helper import requests_headers
class YoutubeSubtitle:
"""handle video subtitle functionality"""
def __init__(self, video):
self.video = video
self.languages = False
def _sub_conf_parse(self):
"""add additional conf values to self"""
languages_raw = self.video.config["downloads"]["subtitle"]
if languages_raw:
self.languages = [i.strip() for i in languages_raw.split(",")]
def get_subtitles(self):
"""check what to do"""
self._sub_conf_parse()
if not self.languages:
# no subtitles
return False
relevant_subtitles = []
for lang in self.languages:
user_sub = self._get_user_subtitles(lang)
if user_sub:
relevant_subtitles.append(user_sub)
continue
if self.video.config["downloads"]["subtitle_source"] == "auto":
auto_cap = self._get_auto_caption(lang)
if auto_cap:
relevant_subtitles.append(auto_cap)
return relevant_subtitles
def _get_auto_caption(self, lang):
"""get auto_caption subtitles"""
print(f"{self.video.youtube_id}-{lang}: get auto generated subtitles")
all_subtitles = self.video.youtube_meta.get("automatic_captions")
if not all_subtitles:
return False
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
all_formats = all_subtitles.get(lang)
if not all_formats:
return False
subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
subtitle.update(
{"lang": lang, "source": "auto", "media_url": media_url}
)
return subtitle
def _normalize_lang(self):
"""normalize country specific language keys"""
all_subtitles = self.video.youtube_meta.get("subtitles")
if not all_subtitles:
return False
all_keys = list(all_subtitles.keys())
for key in all_keys:
lang = key.split("-")[0]
old = all_subtitles.pop(key)
if lang == "live_chat":
continue
all_subtitles[lang] = old
return all_subtitles
def _get_user_subtitles(self, lang):
"""get subtitles uploaded from channel owner"""
print(f"{self.video.youtube_id}-{lang}: get user uploaded subtitles")
all_subtitles = self._normalize_lang()
if not all_subtitles:
return False
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
all_formats = all_subtitles.get(lang)
if not all_formats:
# no user subtitles found
return False
subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
subtitle.update(
{"lang": lang, "source": "user", "media_url": media_url}
)
return subtitle
def download_subtitles(self, relevant_subtitles):
"""download subtitle files to archive"""
videos_base = self.video.config["application"]["videos"]
indexed = []
for subtitle in relevant_subtitles:
dest_path = os.path.join(videos_base, subtitle["media_url"])
source = subtitle["source"]
lang = subtitle.get("lang")
response = requests.get(
subtitle["url"], headers=requests_headers()
)
if not response.ok:
print(f"{self.video.youtube_id}: failed to download subtitle")
print(response.text)
continue
parser = SubtitleParser(response.text, lang, source)
parser.process()
if not parser.all_cues:
continue
subtitle_str = parser.get_subtitle_str()
self._write_subtitle_file(dest_path, subtitle_str)
if self.video.config["downloads"]["subtitle_index"]:
query_str = parser.create_bulk_import(self.video, source)
self._index_subtitle(query_str)
indexed.append(subtitle)
return indexed
@staticmethod
def _write_subtitle_file(dest_path, subtitle_str):
"""write subtitle file to disk"""
# create folder here for first video of channel
os.makedirs(os.path.split(dest_path)[0], exist_ok=True)
with open(dest_path, "w", encoding="utf-8") as subfile:
subfile.write(subtitle_str)
@staticmethod
def _index_subtitle(query_str):
"""send subtitle to es for indexing"""
_, _ = ElasticWrap("_bulk").post(data=query_str, ndjson=True)
def delete(self, subtitles=False):
"""delete subtitles from index and filesystem"""
youtube_id = self.video.youtube_id
videos_base = self.video.config["application"]["videos"]
# delete files
if subtitles:
files = [i["media_url"] for i in subtitles]
else:
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:
"""parse subtitle str from youtube"""
def __init__(self, subtitle_str, lang, source):
self.subtitle_raw = json.loads(subtitle_str)
self.lang = lang
self.source = source
self.all_cues = False
def process(self):
"""extract relevant que data"""
self.all_cues = []
all_events = self.subtitle_raw.get("events")
if not all_events:
return
if self.source == "auto":
all_events = self._flat_auto_caption(all_events)
for idx, event in enumerate(all_events):
if "dDurationMs" not in event or "segs" not in event:
# some events won't have a duration or segs
print(f"skipping subtitle event without content: {event}")
continue
cue = {
"start": self._ms_conv(event["tStartMs"]),
"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)
@staticmethod
def _flat_auto_caption(all_events):
"""flatten autocaption segments"""
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
last = flatten[-1]
if "dDurationMs" not in last or "segs" not in last:
# some events won't have a duration or segs
print(f"skipping subtitle event without content: {event}")
continue
last_end = last["tStartMs"] + last["dDurationMs"]
if event["tStartMs"] < last_end:
joined = last["segs"][0]["utf8"] + "\n" + text
last["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):
"""create vtt text str from cues"""
subtitle_str = f"WEBVTT\nKind: captions\nLanguage: {self.lang}"
for cue in self.all_cues:
stamp = f"{cue.get('start')} --> {cue.get('end')}"
cue_text = f"\n\n{cue.get('idx')}\n{stamp}\n{cue.get('text')}"
subtitle_str = subtitle_str + cue_text
return subtitle_str
def create_bulk_import(self, video, source):
"""subtitle lines for es import"""
documents = self._create_documents(video, source)
bulk_list = []
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,
"title": video.json_data.get("title"),
"subtitle_channel": channel.get("channel_name"),
"subtitle_channel_id": channel.get("channel_id"),
"subtitle_last_refresh": int(datetime.now().strftime("%s")),
"subtitle_lang": self.lang,
"subtitle_source": source,
}
_ = [i.update(meta_dict) for i in documents]
return documents
def _chunk_list(self, youtube_id):
"""join cues for bulk import"""
chunk_list = []
chunk = {}
for cue in self.all_cues:
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"),
}
chunk["subtitle_fragment_id"] = f"{youtube_id}-{self.lang}-{idx}"
if cue["idx"] % 5 == 0:
chunk["subtitle_end"] = cue.get("end")
chunk_list.append(chunk)
chunk = {}
return chunk_list

View File

@@ -4,291 +4,22 @@ functionality:
- index and update in es - index and update in es
""" """
import json
import os import os
from datetime import datetime from datetime import datetime
import requests import requests
from django.conf import settings from django.conf import settings
from home.src.download.thumbnails import ThumbManager
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 import comments as ta_comments
from home.src.index import playlist as ta_playlist
from home.src.index.generic import YouTubeItem from home.src.index.generic import YouTubeItem
from home.src.ta.helper import ( from home.src.index.subtitle import YoutubeSubtitle
DurationConverter, from home.src.ta.helper import DurationConverter, clean_string, randomizor
clean_string,
randomizor,
requests_headers,
)
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
from ryd_client import ryd_client from ryd_client import ryd_client
class YoutubeSubtitle:
"""handle video subtitle functionality"""
def __init__(self, video):
self.video = video
self.languages = False
def _sub_conf_parse(self):
"""add additional conf values to self"""
languages_raw = self.video.config["downloads"]["subtitle"]
if languages_raw:
self.languages = [i.strip() for i in languages_raw.split(",")]
def get_subtitles(self):
"""check what to do"""
self._sub_conf_parse()
if not self.languages:
# no subtitles
return False
relevant_subtitles = []
for lang in self.languages:
user_sub = self._get_user_subtitles(lang)
if user_sub:
relevant_subtitles.append(user_sub)
continue
if self.video.config["downloads"]["subtitle_source"] == "auto":
auto_cap = self._get_auto_caption(lang)
if auto_cap:
relevant_subtitles.append(auto_cap)
return relevant_subtitles
def _get_auto_caption(self, lang):
"""get auto_caption subtitles"""
print(f"{self.video.youtube_id}-{lang}: get auto generated subtitles")
all_subtitles = self.video.youtube_meta.get("automatic_captions")
if not all_subtitles:
return False
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f"-{lang}.vtt")
all_formats = all_subtitles.get(lang)
if not all_formats:
return False
subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
subtitle.update(
{"lang": lang, "source": "auto", "media_url": media_url}
)
return subtitle
def _normalize_lang(self):
"""normalize country specific language keys"""
all_subtitles = self.video.youtube_meta.get("subtitles")
if not all_subtitles:
return False
all_keys = list(all_subtitles.keys())
for key in all_keys:
lang = key.split("-")[0]
old = all_subtitles.pop(key)
if lang == "live_chat":
continue
all_subtitles[lang] = old
return all_subtitles
def _get_user_subtitles(self, lang):
"""get subtitles uploaded from channel owner"""
print(f"{self.video.youtube_id}-{lang}: get user uploaded subtitles")
all_subtitles = self._normalize_lang()
if not all_subtitles:
return False
video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f"-{lang}.vtt")
all_formats = all_subtitles.get(lang)
if not all_formats:
# no user subtitles found
return False
subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
subtitle.update(
{"lang": lang, "source": "user", "media_url": media_url}
)
return subtitle
def download_subtitles(self, relevant_subtitles):
"""download subtitle files to archive"""
videos_base = self.video.config["application"]["videos"]
for subtitle in relevant_subtitles:
dest_path = os.path.join(videos_base, subtitle["media_url"])
source = subtitle["source"]
lang = subtitle.get("lang")
response = requests.get(
subtitle["url"], headers=requests_headers()
)
if not response.ok:
print(f"{self.video.youtube_id}: failed to download subtitle")
print(response.text)
continue
parser = SubtitleParser(response.text, lang, source)
parser.process()
subtitle_str = parser.get_subtitle_str()
self._write_subtitle_file(dest_path, subtitle_str)
if self.video.config["downloads"]["subtitle_index"]:
query_str = parser.create_bulk_import(self.video, source)
self._index_subtitle(query_str)
@staticmethod
def _write_subtitle_file(dest_path, subtitle_str):
"""write subtitle file to disk"""
# create folder here for first video of channel
os.makedirs(os.path.split(dest_path)[0], exist_ok=True)
with open(dest_path, "w", encoding="utf-8") as subfile:
subfile.write(subtitle_str)
@staticmethod
def _index_subtitle(query_str):
"""send subtitle to es for indexing"""
_, _ = ElasticWrap("_bulk").post(data=query_str, ndjson=True)
class SubtitleParser:
"""parse subtitle str from youtube"""
def __init__(self, subtitle_str, lang, source):
self.subtitle_raw = json.loads(subtitle_str)
self.lang = lang
self.source = source
self.all_cues = False
def process(self):
"""extract relevant que data"""
all_events = self.subtitle_raw.get("events")
if self.source == "auto":
all_events = self._flat_auto_caption(all_events)
self.all_cues = []
for idx, event in enumerate(all_events):
if "dDurationMs" not in event:
# some events won't have a duration
print(f"failed to parse event without duration: {event}")
continue
cue = {
"start": self._ms_conv(event["tStartMs"]),
"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)
@staticmethod
def _flat_auto_caption(all_events):
"""flatten autocaption segments"""
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
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):
"""create vtt text str from cues"""
subtitle_str = f"WEBVTT\nKind: captions\nLanguage: {self.lang}"
for cue in self.all_cues:
stamp = f"{cue.get('start')} --> {cue.get('end')}"
cue_text = f"\n\n{cue.get('idx')}\n{stamp}\n{cue.get('text')}"
subtitle_str = subtitle_str + cue_text
return subtitle_str
def create_bulk_import(self, video, source):
"""subtitle lines for es import"""
documents = self._create_documents(video, source)
bulk_list = []
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,
"title": video.json_data.get("title"),
"subtitle_channel": channel.get("channel_name"),
"subtitle_channel_id": channel.get("channel_id"),
"subtitle_last_refresh": int(datetime.now().strftime("%s")),
"subtitle_lang": self.lang,
"subtitle_source": source,
}
_ = [i.update(meta_dict) for i in documents]
return documents
def _chunk_list(self, youtube_id):
"""join cues for bulk import"""
chunk_list = []
chunk = {}
for cue in self.all_cues:
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"),
}
chunk["subtitle_fragment_id"] = f"{youtube_id}-{self.lang}-{idx}"
if cue["idx"] % 5 == 0:
chunk["subtitle_end"] = cue.get("end")
chunk_list.append(chunk)
chunk = {}
return chunk_list
class SponsorBlock: class SponsorBlock:
"""handle sponsor block integration""" """handle sponsor block integration"""
@@ -309,7 +40,7 @@ class SponsorBlock:
sb_id = RedisArchivist().get_message(key) sb_id = RedisArchivist().get_message(key)
if not sb_id["status"]: if not sb_id["status"]:
sb_id = {"status": randomizor(32)} sb_id = {"status": randomizor(32)}
RedisArchivist().set_message(key, sb_id, expire=False) RedisArchivist().set_message(key, sb_id)
return sb_id return sb_id
@@ -318,9 +49,17 @@ class SponsorBlock:
url = f"{self.API}/skipSegments?videoID={youtube_id}" url = f"{self.API}/skipSegments?videoID={youtube_id}"
headers = {"User-Agent": self.user_agent} headers = {"User-Agent": self.user_agent}
print(f"{youtube_id}: get sponsorblock timestamps") print(f"{youtube_id}: get sponsorblock timestamps")
response = requests.get(url, headers=headers) try:
response = requests.get(url, headers=headers, timeout=10)
except requests.ReadTimeout:
print(f"{youtube_id}: sponsorblock API timeout")
return False
if not response.ok: if not response.ok:
print(f"{youtube_id}: sponsorblock failed: {response.text}") print(f"{youtube_id}: sponsorblock failed: {response.status_code}")
if response.status_code == 503:
return False
sponsor_dict = { sponsor_dict = {
"last_refresh": self.last_refresh, "last_refresh": self.last_refresh,
"is_enabled": True, "is_enabled": True,
@@ -395,18 +134,23 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
self.channel_id = False self.channel_id = False
self.video_overwrites = video_overwrites 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}"
self.offline_import = False
def build_json(self): def build_json(self, youtube_meta_overwrite=False, media_path=False):
"""build json dict of video""" """build json dict of video"""
self.get_from_youtube() self.get_from_youtube()
if not self.youtube_meta: if not self.youtube_meta and not youtube_meta_overwrite:
return return
if not self.youtube_meta:
self.youtube_meta = youtube_meta_overwrite
self.offline_import = True
self._process_youtube_meta() self._process_youtube_meta()
self._add_channel() self._add_channel()
self._add_stats() self._add_stats()
self.add_file_path() self.add_file_path()
self.add_player() self.add_player(media_path)
if self.config["downloads"]["integrate_ryd"]: if self.config["downloads"]["integrate_ryd"]:
self._get_ryd_stats() self._get_ryd_stats()
@@ -437,7 +181,8 @@ 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 = 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"],
@@ -456,7 +201,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def _add_channel(self): def _add_channel(self):
"""add channel dict to video json_data""" """add channel dict to video json_data"""
channel = ta_channel.YoutubeChannel(self.channel_id) channel = ta_channel.YoutubeChannel(self.channel_id)
channel.build_json(upload=True) channel.build_json(upload=True, fallback=self.youtube_meta)
self.json_data.update({"channel": channel.json_data}) self.json_data.update({"channel": channel.json_data})
def _add_stats(self): def _add_stats(self):
@@ -464,13 +209,14 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
# likes # likes
like_count = self.youtube_meta.get("like_count", 0) like_count = self.youtube_meta.get("like_count", 0)
dislike_count = self.youtube_meta.get("dislike_count", 0) dislike_count = self.youtube_meta.get("dislike_count", 0)
average_rating = self.youtube_meta.get("average_rating", 0)
self.json_data.update( self.json_data.update(
{ {
"stats": { "stats": {
"view_count": self.youtube_meta["view_count"], "view_count": self.youtube_meta["view_count"],
"like_count": like_count, "like_count": like_count,
"dislike_count": dislike_count, "dislike_count": dislike_count,
"average_rating": self.youtube_meta["average_rating"], "average_rating": average_rating,
} }
} }
) )
@@ -487,8 +233,28 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
raise FileNotFoundError raise FileNotFoundError
def add_player(self): def add_player(self, media_path=False):
"""add player information for new videos""" """add player information for new videos"""
vid_path = self._get_vid_path(media_path)
duration_handler = DurationConverter()
duration = duration_handler.get_sec(vid_path)
duration_str = duration_handler.get_str(duration)
self.json_data.update(
{
"player": {
"watched": False,
"duration": duration,
"duration_str": duration_str,
}
}
)
def _get_vid_path(self, media_path=False):
"""get path of media file"""
if media_path:
return media_path
try: try:
# when indexing from download task # when indexing from download task
vid_path = self.build_dl_cache_path() vid_path = self.build_dl_cache_path()
@@ -504,18 +270,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
else: else:
raise FileNotFoundError("could not find video file") from err raise FileNotFoundError("could not find video file") from err
duration_handler = DurationConverter() return vid_path
duration = duration_handler.get_sec(vid_path)
duration_str = duration_handler.get_str(duration)
self.json_data.update(
{
"player": {
"watched": False,
"duration": duration,
"duration_str": duration_str,
}
}
)
def add_file_path(self): def add_file_path(self):
"""build media_url for where file will be located""" """build media_url for where file will be located"""
@@ -535,62 +290,112 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def delete_media_file(self): def delete_media_file(self):
"""delete video file, meta data""" """delete video file, meta data"""
print(f"{self.youtube_id}: delete video")
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")
all_subtitles = self.json_data.get("subtitles")
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) file_path = os.path.join(video_base, media_url)
try: try:
os.remove(file_path) os.remove(file_path)
except FileNotFoundError: except FileNotFoundError:
print(f"{self.youtube_id}: failed {media_url}, continue.") print(f"{self.youtube_id}: failed {media_url}, continue.")
self.del_in_playlists()
self.del_in_es() self.del_in_es()
self.delete_subtitles() self.delete_subtitles()
self.delete_comments()
def del_in_playlists(self):
"""remove downloaded in playlist"""
all_playlists = self.json_data.get("playlist")
if not all_playlists:
return
for playlist_id in all_playlists:
print(f"{playlist_id}: delete video {self.youtube_id}")
playlist = ta_playlist.YoutubePlaylist(playlist_id)
playlist.get_from_es()
entries = playlist.json_data["playlist_entries"]
for idx, entry in enumerate(entries):
if entry["youtube_id"] == self.youtube_id:
playlist.json_data["playlist_entries"][idx].update(
{"downloaded": False}
)
playlist.upload_to_es()
def delete_subtitles(self, subtitles=False):
"""delete indexed subtitles"""
print(f"{self.youtube_id}: delete subtitles")
YoutubeSubtitle(self).delete(subtitles=subtitles)
def delete_comments(self):
"""delete comments from es"""
comments = ta_comments.Comments(self.youtube_id, config=self.config)
comments.check_config()
if comments.is_activated:
comments.delete_comments()
def _get_ryd_stats(self): def _get_ryd_stats(self):
"""get optional stats from returnyoutubedislikeapi.com""" """get optional stats from returnyoutubedislikeapi.com"""
# pylint: disable=broad-except
try: try:
print(f"{self.youtube_id}: get ryd stats") print(f"{self.youtube_id}: get ryd stats")
result = ryd_client.get(self.youtube_id) result = ryd_client.get(self.youtube_id)
except requests.exceptions.ConnectionError: except Exception as err:
print(f"{self.youtube_id}: failed to query ryd api, skipping") print(f"{self.youtube_id}: failed to query ryd api {err}")
return False return
if result["status"] == 404: if result["status"] == 404:
return False return
dislikes = { dislikes = {
"dislike_count": result["dislikes"], "dislike_count": result.get("dislikes", 0),
"average_rating": result["rating"], "average_rating": result.get("rating", 0),
} }
self.json_data["stats"].update(dislikes) self.json_data["stats"].update(dislikes)
return True
def _get_sponsorblock(self): def _get_sponsorblock(self):
"""get optional sponsorblock timestamps from sponsor.ajay.app""" """get optional sponsorblock timestamps from sponsor.ajay.app"""
sponsorblock = SponsorBlock().get_timestamps(self.youtube_id) sponsorblock = SponsorBlock().get_timestamps(self.youtube_id)
if sponsorblock: if sponsorblock:
self.json_data["sponsorblock"] = sponsorblock self.json_data["sponsorblock"] = sponsorblock
def check_subtitles(self): def check_subtitles(self, subtitle_files=False):
"""optionally add subtitles""" """optionally add subtitles"""
if self.offline_import and subtitle_files:
indexed = self._offline_subtitles(subtitle_files)
self.json_data["subtitles"] = indexed
return
handler = YoutubeSubtitle(self) handler = YoutubeSubtitle(self)
subtitles = handler.get_subtitles() subtitles = handler.get_subtitles()
if subtitles: if subtitles:
self.json_data["subtitles"] = subtitles indexed = handler.download_subtitles(relevant_subtitles=subtitles)
handler.download_subtitles(relevant_subtitles=subtitles) self.json_data["subtitles"] = indexed
def delete_subtitles(self): def _offline_subtitles(self, subtitle_files):
"""delete indexed subtitles""" """import offline subtitles"""
path = "ta_subtitle/_delete_by_query?refresh=true" base_name, _ = os.path.splitext(self.json_data["media_url"])
data = {"query": {"term": {"youtube_id": {"value": self.youtube_id}}}} subtitles = []
for subtitle in subtitle_files:
lang = subtitle.split(".")[-2]
subtitle_media_url = f"{base_name}.{lang}.vtt"
to_add = {
"ext": "vtt",
"url": False,
"name": lang,
"lang": lang,
"source": "file",
"media_url": subtitle_media_url,
}
subtitles.append(to_add)
return subtitles
def update_media_url(self):
"""update only media_url in es for reindex channel rename"""
data = {"doc": {"media_url": self.json_data["media_url"]}}
path = f"{self.index_name}/_update/{self.youtube_id}"
_, _ = ElasticWrap(path).post(data=data) _, _ = ElasticWrap(path).post(data=data)

View File

@@ -83,6 +83,7 @@ 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"""
updated = []
for key, value in form_post.items(): for key, value in form_post.items():
if not value and not isinstance(value, int): if not value and not isinstance(value, int):
continue continue
@@ -96,8 +97,10 @@ class AppConfig:
config_dict, config_value = key.split("_", maxsplit=1) config_dict, config_value = key.split("_", maxsplit=1)
self.config[config_dict][config_value] = to_write self.config[config_dict][config_value] = to_write
updated.append((config_value, to_write))
RedisArchivist().set_message("config", self.config, expire=False) RedisArchivist().set_message("config", self.config)
return updated
@staticmethod @staticmethod
def set_user_config(form_post, user_id): def set_user_config(form_post, user_id):
@@ -108,7 +111,7 @@ class AppConfig:
message = {"status": value} message = {"status": value}
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)
def get_colors(self): def get_colors(self):
"""overwrite config if user has set custom values""" """overwrite config if user has set custom values"""
@@ -130,6 +133,8 @@ class AppConfig:
# check for customizations # check for customizations
if not redis_config: if not redis_config:
config = self.get_config()
RedisArchivist().set_message("config", config)
return return
needs_update = False needs_update = False
@@ -148,7 +153,7 @@ class AppConfig:
needs_update = True needs_update = True
if needs_update: if needs_update:
RedisArchivist().set_message("config", redis_config, expire=False) RedisArchivist().set_message("config", redis_config)
class ScheduleBuilder: class ScheduleBuilder:
@@ -162,6 +167,7 @@ class ScheduleBuilder:
"run_backup": "0 18 0", "run_backup": "0 18 0",
} }
CONFIG = ["check_reindex_days", "run_backup_rotate"] CONFIG = ["check_reindex_days", "run_backup_rotate"]
MSG = "message:setting"
def __init__(self): def __init__(self):
self.config = AppConfig().config self.config = AppConfig().config
@@ -177,25 +183,27 @@ class ScheduleBuilder:
except ValueError: except ValueError:
print(f"failed: {key} {value}") print(f"failed: {key} {value}")
mess_dict = { mess_dict = {
"status": "message:setting", "status": self.MSG,
"level": "error", "level": "error",
"title": "Scheduler update failed.", "title": "Scheduler update failed.",
"message": "Invalid schedule input", "message": "Invalid schedule input",
} }
RedisArchivist().set_message("message:setting", mess_dict) RedisArchivist().set_message(
self.MSG, mess_dict, expire=True
)
return return
redis_config["scheduler"][key] = to_write redis_config["scheduler"][key] = to_write
if key in self.CONFIG and value: if key in self.CONFIG and value:
redis_config["scheduler"][key] = int(value) redis_config["scheduler"][key] = int(value)
RedisArchivist().set_message("config", redis_config, expire=False) RedisArchivist().set_message("config", redis_config)
mess_dict = { mess_dict = {
"status": "message:setting", "status": self.MSG,
"level": "info", "level": "info",
"title": "Scheduler changed.", "title": "Scheduler changed.",
"message": "Please restart container for changes to take effect", "message": "Please restart container for changes to take effect",
} }
RedisArchivist().set_message("message:setting", mess_dict) RedisArchivist().set_message(self.MSG, mess_dict, expire=True)
def value_builder(self, key, value): def value_builder(self, key, value):
"""validate single cron form entry and return cron dict""" """validate single cron form entry and return cron dict"""

View File

@@ -3,6 +3,8 @@ 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 os
import random import random
import re import re
import string import string
@@ -11,7 +13,7 @@ import unicodedata
from datetime import datetime from datetime import datetime
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import yt_dlp from home.src.download.yt_dlp_base import YtWrap
def clean_string(file_name): def clean_string(file_name):
@@ -105,6 +107,35 @@ def date_praser(timestamp):
return datetime.strftime(date_obj, "%d %b, %Y") return datetime.strftime(date_obj, "%d %b, %Y")
def time_parser(timestamp):
"""return seconds from timestamp, false on empty"""
if not timestamp:
return False
if timestamp.isnumeric():
return int(timestamp)
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
def clear_dl_cache(config):
"""clear leftover files from dl cache"""
print("clear download cache")
cache_dir = os.path.join(config["application"]["cache_dir"], "download")
for cached in os.listdir(cache_dir):
to_delete = os.path.join(cache_dir, cached)
os.remove(to_delete)
def get_mapping():
"""read index_mapping.json and get expected mapping and settings"""
with open("home/src/es/index_mapping.json", "r", encoding="utf-8") as f:
index_config = json.load(f).get("index_config")
return index_config
class UrlListParser: class UrlListParser:
"""take a multi line string and detect valid youtube ids""" """take a multi line string and detect valid youtube ids"""
@@ -146,12 +177,12 @@ class UrlListParser:
if parsed.query: if parsed.query:
# detect from query string # detect from query string
query_parsed = parse_qs(parsed.query) query_parsed = parse_qs(parsed.query)
if "v" in query_parsed.keys(): if "v" in query_parsed:
youtube_id = query_parsed["v"][0] youtube_id = query_parsed["v"][0]
_ = self.find_valid_id(youtube_id) _ = self.find_valid_id(youtube_id)
return youtube_id, "video" return youtube_id, "video"
if "list" in query_parsed.keys(): if "list" in query_parsed:
youtube_id = query_parsed["list"][0] youtube_id = query_parsed["list"][0]
return youtube_id, "playlist" return youtube_id, "playlist"
@@ -161,19 +192,19 @@ class UrlListParser:
_ = self.find_valid_id(youtube_id) _ = self.find_valid_id(youtube_id)
return youtube_id, "channel" return youtube_id, "channel"
# dedect channel with yt_dlp # detect channel with yt_dlp
youtube_id = self.extract_channel_name(parsed.geturl()) youtube_id = self.extract_channel_name(parsed.geturl())
return youtube_id, "channel" return youtube_id, "channel"
@staticmethod @staticmethod
def find_valid_id(id_str): def find_valid_id(id_str):
"""dedect valid id from length of string""" """detect valid id from length of string"""
str_len = len(id_str) str_len = len(id_str)
if str_len == 11: if str_len == 11:
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
@@ -184,22 +215,26 @@ class UrlListParser:
@staticmethod @staticmethod
def extract_channel_name(url): def extract_channel_name(url):
"""find channel id from channel name with yt-dlp help""" """find channel id from channel name with yt-dlp help"""
obs = { obs_request = {
"default_search": "ytsearch",
"quiet": True,
"skip_download": True, "skip_download": True,
"extract_flat": True, "extract_flat": True,
"playlistend": 0, "playlistend": 0,
} }
url_info = yt_dlp.YoutubeDL(obs).extract_info(url, download=False) url_info = YtWrap(obs_request).extract(url)
try: channel_id = url_info.get("channel_id", False)
channel_id = url_info["channel_id"] if channel_id:
except KeyError as error: return channel_id
print(f"failed to extract channel id from {url}")
raise ValueError from error url = url_info.get("url", False)
if url:
# handle old channel name redirect with url path split
channel_id = urlparse(url).path.strip("/").split("/")[1]
return channel_id return channel_id
print(f"failed to extract channel id from {url}")
raise ValueError
class DurationConverter: class DurationConverter:
""" """

View File

@@ -8,7 +8,6 @@ import json
import os import os
import redis import redis
from home.src.ta.helper import ignore_filelist
class RedisBase: class RedisBase:
@@ -35,10 +34,10 @@ class RedisArchivist(RedisBase):
"setting", "setting",
] ]
def set_message(self, key, message, expire=True): def set_message(self, key, message, path=".", expire=False):
"""write new message to redis""" """write new message to redis"""
self.conn.execute_command( self.conn.execute_command(
"JSON.SET", self.NAME_SPACE + key, ".", json.dumps(message) "JSON.SET", self.NAME_SPACE + key, path, json.dumps(message)
) )
if expire: if expire:
@@ -81,6 +80,12 @@ class RedisArchivist(RedisBase):
redis_lock = self.conn.lock(self.NAME_SPACE + lock_key) redis_lock = self.conn.lock(self.NAME_SPACE + lock_key)
return redis_lock return redis_lock
def is_locked(self, lock_key):
"""check if lock is set"""
lock_name = self.NAME_SPACE + lock_key
lock_status = bool(self.conn.execute_command("GET", lock_name))
return lock_status
def get_progress(self): def get_progress(self):
"""get a list of all progress messages""" """get a list of all progress messages"""
all_messages = [] all_messages = []
@@ -95,27 +100,6 @@ class RedisArchivist(RedisBase):
return all_messages return all_messages
@staticmethod
def monitor_cache_dir(cache_dir):
"""
look at download cache dir directly as alternative progress info
"""
dl_cache = os.path.join(cache_dir, "download")
all_cache_file = os.listdir(dl_cache)
cache_file = ignore_filelist(all_cache_file)
if cache_file:
filename = cache_file[0][12:].replace("_", " ").split(".")[0]
mess_dict = {
"status": "message:download",
"level": "info",
"title": "Downloading: " + filename,
"message": "",
}
else:
return False
return mess_dict
class RedisQueue(RedisBase): class RedisQueue(RedisBase):
"""dynamically interact with the download queue in redis""" """dynamically interact with the download queue in redis"""
@@ -159,3 +143,8 @@ class RedisQueue(RedisBase):
def trim(self, size): def trim(self, size):
"""trim the queue based on settings amount""" """trim the queue based on settings amount"""
self.conn.execute_command("LTRIM", self.key, 0, size) self.conn.execute_command("LTRIM", self.key, 0, size)
def has_item(self):
"""check if queue as at least one pending item"""
result = self.conn.execute_command("LRANGE", self.key, 0, 0)
return bool(result)

View File

@@ -15,17 +15,18 @@ from home.src.download.subscriptions import (
ChannelSubscription, ChannelSubscription,
PlaylistSubscription, PlaylistSubscription,
) )
from home.src.download.thumbnails import ThumbManager, validate_thumbnails from home.src.download.thumbnails import ThumbFilesystem, ThumbValidator
from home.src.download.yt_dlp_handler import VideoDownloader from home.src.download.yt_dlp_handler import VideoDownloader
from home.src.es.index_setup import backup_all_indexes, restore_from_backup from home.src.es.backup import ElasticBackup
from home.src.es.index_setup import ElasitIndexWrap
from home.src.index.channel import YoutubeChannel from home.src.index.channel import YoutubeChannel
from home.src.index.filesystem import ( from home.src.index.filesystem import (
ManualImport, ImportFolderScanner,
reindex_old_documents, reindex_old_documents,
scan_filesystem, scan_filesystem,
) )
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, clear_dl_cache
from home.src.ta.ta_redis import RedisArchivist, RedisQueue from home.src.ta.ta_redis import RedisArchivist, RedisQueue
CONFIG = AppConfig().config CONFIG = AppConfig().config
@@ -48,7 +49,7 @@ def update_subscribed():
"title": "Rescanning channels and playlists.", "title": "Rescanning channels and playlists.",
"message": "Looking for new videos.", "message": "Looking for new videos.",
} }
RedisArchivist().set_message("message:rescan", message) RedisArchivist().set_message("message:rescan", message, expire=True)
have_lock = False have_lock = False
my_lock = RedisArchivist().get_lock("rescan") my_lock = RedisArchivist().get_lock("rescan")
@@ -108,13 +109,14 @@ def download_single(youtube_id):
try: try:
have_lock = my_lock.acquire(blocking=False) have_lock = my_lock.acquire(blocking=False)
if have_lock: if have_lock:
key = "message:download"
mess_dict = { mess_dict = {
"status": "message:download", "status": key,
"level": "info", "level": "info",
"title": "Download single video", "title": "Download single video",
"message": "processing", "message": "processing",
} }
RedisArchivist().set_message("message:download", mess_dict) RedisArchivist().set_message(key, mess_dict, expire=True)
VideoDownloader().run_queue() VideoDownloader().run_queue()
else: else:
print("Download queue already running.") print("Download queue already running.")
@@ -149,10 +151,7 @@ def run_manual_import():
try: try:
have_lock = my_lock.acquire(blocking=False) have_lock = my_lock.acquire(blocking=False)
if have_lock: if have_lock:
import_handler = ManualImport() ImportFolderScanner().scan()
if import_handler.identified:
all_videos_added = import_handler.process_import()
ThumbManager().download_vid(all_videos_added)
else: else:
print("Did not acquire lock form import.") print("Did not acquire lock form import.")
@@ -164,14 +163,26 @@ def run_manual_import():
@shared_task(name="run_backup") @shared_task(name="run_backup")
def run_backup(reason="auto"): def run_backup(reason="auto"):
"""called from settings page, dump backup to zip file""" """called from settings page, dump backup to zip file"""
backup_all_indexes(reason) have_lock = False
my_lock = RedisArchivist().get_lock("run_backup")
try:
have_lock = my_lock.acquire(blocking=False)
if have_lock:
ElasticBackup(reason=reason).backup_all_indexes()
else:
print("Did not acquire lock for backup task.")
finally:
if have_lock:
my_lock.release()
print("backup finished") print("backup finished")
@shared_task @shared_task
def run_restore_backup(filename): def run_restore_backup(filename):
"""called from settings page, dump backup to zip file""" """called from settings page, dump backup to zip file"""
restore_from_backup(filename) ElasitIndexWrap().reset()
ElasticBackup().restore(filename)
print("index restore finished") print("index restore finished")
@@ -183,11 +194,7 @@ def kill_dl(task_id):
_ = RedisArchivist().del_message("dl_queue_id") _ = RedisArchivist().del_message("dl_queue_id")
RedisQueue().clear() RedisQueue().clear()
# clear cache clear_dl_cache(CONFIG)
cache_dir = os.path.join(CONFIG["application"]["cache_dir"], "download")
for cached in os.listdir(cache_dir):
to_delete = os.path.join(cache_dir, cached)
os.remove(to_delete)
# notify # notify
mess_dict = { mess_dict = {
@@ -196,28 +203,26 @@ def kill_dl(task_id):
"title": "Canceling download process", "title": "Canceling download process",
"message": "Canceling download queue now.", "message": "Canceling download queue now.",
} }
RedisArchivist().set_message("message:download", mess_dict) RedisArchivist().set_message("message:download", mess_dict, expire=True)
@shared_task @shared_task
def rescan_filesystem(): def rescan_filesystem():
"""check the media folder for mismatches""" """check the media folder for mismatches"""
scan_filesystem() scan_filesystem()
validate_thumbnails() ThumbValidator().download_missing()
@shared_task(name="thumbnail_check") @shared_task(name="thumbnail_check")
def thumbnail_check(): def thumbnail_check():
"""validate thumbnails""" """validate thumbnails"""
validate_thumbnails() ThumbValidator().download_missing()
@shared_task @shared_task
def re_sync_thumbs(): def re_sync_thumbs():
"""sync thumbnails to mediafiles""" """sync thumbnails to mediafiles"""
handler = ThumbManager() ThumbFilesystem().sync()
video_list = handler.get_thumb_list()
handler.write_all_thumbs(video_list)
@shared_task @shared_task
@@ -228,9 +233,7 @@ def subscribe_to(url_str):
for item in to_subscribe_list: for item in to_subscribe_list:
to_sub_id = item["url"] to_sub_id = item["url"]
if item["type"] == "playlist": if item["type"] == "playlist":
new_thumbs = PlaylistSubscription().process_url_str([item]) PlaylistSubscription().process_url_str([item])
if new_thumbs:
ThumbManager().download_playlist(new_thumbs)
continue continue
if item["type"] == "video": if item["type"] == "video":
@@ -245,13 +248,14 @@ def subscribe_to(url_str):
channel_id_sub, channel_subscribed=True channel_id_sub, channel_subscribed=True
) )
# notify for channels # notify for channels
key = "message:subchannel"
message = { message = {
"status": "message:subchannel", "status": key,
"level": "info", "level": "info",
"title": "Subscribing to Channels", "title": "Subscribing to Channels",
"message": f"Processing {counter} of {len(to_subscribe_list)}", "message": f"Processing {counter} of {len(to_subscribe_list)}",
} }
RedisArchivist().set_message("message:subchannel", message=message) RedisArchivist().set_message(key, message=message, expire=True)
counter = counter + 1 counter = counter + 1
@@ -260,13 +264,14 @@ def index_channel_playlists(channel_id):
"""add all playlists of channel to index""" """add all playlists of channel to index"""
channel = YoutubeChannel(channel_id) channel = YoutubeChannel(channel_id)
# notify # notify
key = "message:playlistscan"
mess_dict = { mess_dict = {
"status": "message:playlistscan", "status": key,
"level": "info", "level": "info",
"title": "Looking for playlists", "title": "Looking for playlists",
"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(key, mess_dict, expire=True)
channel.index_channel_playlists() channel.index_channel_playlists()

View File

@@ -79,16 +79,16 @@
<div class="pagination"> <div class="pagination">
{% if pagination %} {% if pagination %}
{% if pagination.current_page > 1 %} {% if pagination.current_page > 1 %}
{% if pagination.search_get %} {% if pagination.params %}
<a class="pagination-item" href="{{ request.path }}?search={{ pagination.search_get }}">First</a> <a class="pagination-item" href="{{ request.path }}?{{ pagination.params }}">First</a>
{% else %} {% else %}
<a class="pagination-item" href="{{ request.path }}">First</a> <a class="pagination-item" href="{{ request.path }}">First</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if pagination.prev_pages %} {% if pagination.prev_pages %}
{% for page in pagination.prev_pages %} {% for page in pagination.prev_pages %}
{% if pagination.search_get %} {% if pagination.params %}
<a class="pagination-item" href="?page={{ page }}&search={{ pagination.search_get }}">{{ page }}</a> <a class="pagination-item" href="?page={{ page }}&{{ pagination.params }}">{{ page }}</a>
{% else %} {% else %}
<a class="pagination-item" href="?page={{ page }}">{{ page }}</a> <a class="pagination-item" href="?page={{ page }}">{{ page }}</a>
{% endif %} {% endif %}
@@ -100,16 +100,16 @@
{% if pagination.next_pages %} {% if pagination.next_pages %}
<span> ></span> <span> ></span>
{% for page in pagination.next_pages %} {% for page in pagination.next_pages %}
{% if pagination.search_get %} {% if pagination.params %}
<a class="pagination-item" href="?page={{ page }}&search={{ pagination.search_get }}">{{ page }}</a> <a class="pagination-item" href="?page={{ page }}&{{ pagination.params }}">{{ page }}</a>
{% else %} {% else %}
<a class="pagination-item" href="?page={{ page }}">{{ page }}</a> <a class="pagination-item" href="?page={{ page }}">{{ page }}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if pagination.last_page > 0 %} {% if pagination.last_page > 0 %}
{% if pagination.search_get %} {% if pagination.params %}
<a class="pagination-item" href="?page={{ pagination.last_page }}&search={{ pagination.search_get }}"> <a class="pagination-item" href="?page={{ pagination.last_page }}&{{ pagination.params }}">
{% if pagination.max_hits %} {% if pagination.max_hits %}
Max ({{ pagination.last_page }}) Max ({{ pagination.last_page }})
{% else %} {% else %}

View File

@@ -36,7 +36,7 @@
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="channel" data-value="list" alt="list view"> <img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="channel" data-value="list" alt="list view">
</div> </div>
</div> </div>
<h2>Total matching channels: {{ max_hits }}</h2> <h2>Total channels: {{ max_hits }}</h2>
<div class="channel-list {{ view_style }}"> <div class="channel-list {{ view_style }}">
{% if results %} {% if results %}
{% for channel in results %} {% for channel in results %}

View File

@@ -6,8 +6,16 @@
<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 class="info-box-item channel-nav">
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
{% if has_pending %}
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
{% endif %}
</div>
<div id="notifications" data="channel_id"></div> <div id="notifications" data="channel_id"></div>
<div class="info-box info-box-3"> <div class="info-box info-box-2">
<div class="info-box-item"> <div class="info-box-item">
<div class="round-img"> <div class="round-img">
<a href="{% url 'channel_id' channel_info.channel_id %}"> <a href="{% url 'channel_id' channel_info.channel_id %}">
@@ -30,89 +38,16 @@
</div> </div>
<div class="info-box-item"> <div class="info-box-item">
<div> <div>
<p>Last refreshed: {{ channel_info.channel_last_refresh }}</p>
{% if channel_info.channel_active %}
<p>Youtube: <a href="https://www.youtube.com/channel/{{ channel_info.channel_id }}" target="_blank">Active</a></p>
{% else %}
<p>Youtube: Deactivated</p>
{% endif %}
<button onclick="deleteConfirm()" id="delete-item">Delete Channel</button>
<div class="delete-confirm" id="delete-button">
<span>Delete {{ channel_info.channel_name }} including all videos? </span><button class="danger-button" onclick="deleteChannel(this)" data-id="{{ channel_info.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button>
</div>
</div>
</div>
<div class="info-box-item">
<div>
{% if channel_info.channel_views >= 1000000 %}
<p>Channel views: {{ channel_info.channel_views|intword }}</p>
{% elif channel_info.channel_views > 0 %}
<p>Channel views: {{ channel_info.channel_views|intcomma }}</p>
{% endif %}
{% if max_hits %} {% if max_hits %}
<p>Total Videos archived: {{ max_hits }}</p> <p>Total Videos: {{ 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> <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>
{% endif %} {% endif %}
<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>
</div> </div>
</div> </div>
</div> </div>
<div id="overwrite-form" class="info-box{% if not channel_info.channel_overwrites %} hidden-overwrite{% endif %}"> </div>
<div class="info-box-item"> <div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}">
<form class="overwrite-form" action="/channel/{{ channel_info.channel_id }}/" method="POST"> <div class="view-controls three">
{% 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 %}
<div class="info-box-item description-box">
<p>Description: <button onclick="textReveal()" id="text-reveal-button">Show</button></p>
<div id="text-reveal" class="description-text">
{{ channel_info.channel_description|linebreaks }}
</div>
</div>
{% endif %}
<div class="view-controls">
<div class="toggle"> <div class="toggle">
<span>Hide watched videos:</span> <span>Hide watched videos:</span>
<div class="toggleBox"> <div class="toggleBox">
@@ -141,21 +76,31 @@
</div> </div>
<div class="view-icons"> <div class="view-icons">
<img src="{% static 'img/icon-sort.svg' %}" alt="sort-icon" onclick="showForm()" id="animate-icon"> <img src="{% static 'img/icon-sort.svg' %}" alt="sort-icon" onclick="showForm()" id="animate-icon">
{% if view_style == "grid" %}
<div class="grid-count">
{% if grid_items < 7 %}
<img src="{% static 'img/icon-add.svg' %}" onclick="changeGridItems(this)" data-value="{{ grid_items|add:"1"}}" alt="grid plus row">
{% endif %}
{% if grid_items > 3 %}
<img src="{% static 'img/icon-substract.svg' %}" onclick="changeGridItems(this)" data-value="{{ grid_items|add:"-1"}}" alt="grid minus row">
{% endif %}
</div>
{% endif %}
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="grid" alt="grid view"> <img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="list" alt="list view"> <img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="list" alt="list view">
</div> </div>
</div> </div>
</div> </div>
<div id="player" class="player-wrapper"></div> <div id="player" class="player-wrapper"></div>
<div class="boxed-content"> <div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}">
<div class="video-list {{ view_style }}"> <div class="video-list {{ view_style }} {% if view_style == "grid" %}grid-{{ grid_items }}{% endif %}">
{% if results %} {% if results %}
{% for video in results %} {% for video in results %}
<div class="video-item {{ view_style }}"> <div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)"> <a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}"> <div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb"> <div class="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb"> <img src="{{ 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 %}

View File

@@ -0,0 +1,115 @@
{% extends "home/base.html" %}
{% block content %}
{% load static %}
{% load humanize %}
<div class="boxed-content">
<div class="channel-banner">
<a href="{% url 'channel_id' channel_info.channel_id %}"><img src="{{ channel_info.channel_banner_url }}" alt="channel_banner"></a>
</div>
<div class="info-box-item channel-nav">
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
{% if has_pending %}
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
{% endif %}
</div>
<div class="info-box info-box-3">
<div class="info-box-item">
<div class="round-img">
<a href="{% url 'channel_id' channel_info.channel_id %}">
<img src="{{ channel_info.channel_thumb_url }}" alt="channel-thumb">
</a>
</div>
<div>
<h3><a href="{% url 'channel_id' channel_info.channel_id %}">{{ channel_info.channel_name }}</a></h3>
{% if channel_info.channel_subs >= 1000000 %}
<p>Subscribers: {{ channel_info.channel_subs|intword }}</p>
{% else %}
<p>Subscribers: {{ channel_info.channel_subs|intcomma }}</p>
{% endif %}
</div>
</div>
<div class="info-box-item">
<div>
<p>Last refreshed: {{ channel_info.channel_last_refresh }}</p>
{% if channel_info.channel_active %}
<p>Youtube: <a href="https://www.youtube.com/channel/{{ channel_info.channel_id }}" target="_blank">Active</a></p>
{% else %}
<p>Youtube: Deactivated</p>
{% endif %}
</div>
</div>
<div class="info-box-item">
<div>
{% if channel_info.channel_views >= 1000000 %}
<p>Channel views: {{ channel_info.channel_views|intword }}</p>
{% elif channel_info.channel_views > 0 %}
<p>Channel views: {{ channel_info.channel_views|intcomma }}</p>
{% endif %}
<button onclick="deleteConfirm()" id="delete-item">Delete Channel</button>
<div class="delete-confirm" id="delete-button">
<span>Delete {{ channel_info.channel_name }} including all videos? </span><button class="danger-button" onclick="deleteChannel(this)" data-id="{{ channel_info.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button>
</div>
</div>
</div>
</div>
{% if channel_info.channel_description %}
<div class="description-box">
<p id="text-expand" class="description-text">
{{ channel_info.channel_description|linebreaksbr|urlizetrunc:50 }}
</p>
<button onclick="textExpand()" id="text-expand-button">Show more</button>
</div>
{% endif %}
<div class="description-box">
<h2>Customize {{ channel_info.channel_name }}</h2>
</div>
<div id="overwrite-form" class="info-box">
<div class="info-box-item">
<form class="overwrite-form" action="/channel/{{ channel_info.channel_id }}/about/" 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>
</div>
{% endblock content %}

View File

@@ -0,0 +1,60 @@
{% extends "home/base.html" %}
{% block content %}
{% load static %}
{% load humanize %}
<div class="boxed-content">
<div class="channel-banner">
<a href="{% url 'channel_id' channel_info.channel_id %}"><img src="{{ channel_info.channel_banner_url }}" alt="channel_banner"></a>
</div>
<div class="info-box-item channel-nav">
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
{% if has_pending %}
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
{% endif %}
</div>
<div class="view-controls">
<div class="toggle">
<span>Show subscribed only:</span>
<div class="toggleBox">
<input id="show_subed_only" onclick="toggleCheckbox(this)" type="checkbox" {% if show_subed_only %}checked{% endif %}>
{% if not show_subed_only %}
<label for="" class="ofbtn">Off</label>
{% else %}
<label for="" class="onbtn">On</label>
{% endif %}
</div>
</div>
<div class="view-icons">
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="playlist" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="playlist" data-value="list" alt="list view">
</div>
</div>
<div class="playlist-list {{ view_style }}">
{% if results %}
{% for playlist in results %}
<div class="playlist-item {{ view_style }}">
<div class="playlist-thumbnail">
<a href="{% url 'playlist_id' playlist.source.playlist_id %}">
<img src="/cache/playlists/{{ playlist.source.playlist_id }}.jpg" alt="{{ playlist.source.playlist_id }}-thumbnail">
</a>
</div>
<div class="playlist-desc {{ view_style }}">
<a href="{% url 'channel_id' playlist.source.playlist_channel_id %}"><h3>{{ playlist.source.playlist_channel }}</h3></a>
<a href="{% url 'playlist_id' playlist.source.playlist_id %}"><h2>{{ playlist.source.playlist_name }}</h2></a>
<p>Last refreshed: {{ playlist.source.playlist_last_refresh }}</p>
{% if playlist.source.playlist_subscribed %}
<button class="unsubscribe" type="button" id="{{ playlist.source.playlist_id }}" onclick="unsubscribe(this.id)" title="Unsubscribe from {{ playlist.source.playlist_name }}">Unsubscribe</button>
{% else %}
<button type="button" id="{{ playlist.source.playlist_id }}" onclick="subscribe(this.id)" title="Subscribe to {{ playlist.source.playlist_name }}">Subscribe</button>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<h2>No playlists found...</h2>
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -3,7 +3,7 @@
{% block content %} {% block content %}
<div class="boxed-content"> <div class="boxed-content">
<div class="title-bar"> <div class="title-bar">
<h1>Downloads</h1> <h1>Downloads {% if channel_filter_id %} for {{ channel_filter_name }}{% endif %}</h1>
</div> </div>
<div id="notifications" data="download"></div> <div id="notifications" data="download"></div>
<div id="downloadControl"></div> <div id="downloadControl"></div>
@@ -28,7 +28,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="view-controls"> <div class="view-controls three">
<div class="toggle"> <div class="toggle">
<span>Show only ignored videos:</span> <span>Show only ignored videos:</span>
<div class="toggleBox"> <div class="toggleBox">
@@ -41,40 +41,56 @@
</div> </div>
</div> </div>
<div class="view-icons"> <div class="view-icons">
{% if channel_agg_list|length > 1 %}
<select name="channel_filter" id="channel_filter" onchange="channelFilterDownload(this.value)">
<option value="all" {% if not channel_filter_id %}selected{% endif %}>all</option>
{% for channel in channel_agg_list %}
<option {% if channel_filter_id == channel.id %}selected{% endif %} value="{{ channel.id }}">{{ channel.name }} ({{channel.count}})</option>
{% endfor %}
</select>
{% endif %}
{% if view_style == "grid" %}
<div class="grid-count">
{% if grid_items < 7 %}
<img src="{% static 'img/icon-add.svg' %}" onclick="changeGridItems(this)" data-value="{{ grid_items|add:"1"}}" alt="grid plus row">
{% endif %}
{% if grid_items > 3 %}
<img src="{% static 'img/icon-substract.svg' %}" onclick="changeGridItems(this)" data-value="{{ grid_items|add:"-1"}}" alt="grid minus row">
{% endif %}
</div>
{% endif %}
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="downloads" data-value="grid" alt="grid view"> <img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="downloads" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="downloads" data-value="list" alt="list view"> <img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="downloads" data-value="list" alt="list view">
</div> </div>
</div> </div>
<div class="title-split"> <h3>Total videos in queue: {{ max_hits }}{% if max_hits == 10000 %}+{% endif %} {% if channel_filter_id %} - from channel <i>{{ channel_filter_name }}</i>{% endif %}</h3>
{% if show_ignored_only %} </div>
<h2>Ignored from download</h2> <div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}">
<button onclick="deleteQueue(this)" data-id="ignore" title="Delete all previously ignored videos from the queue">Delete all ignored</button> <div class="video-list {{ view_style }} {% if view_style == "grid" %}grid-{{ grid_items }}{% endif %}">
{% else %}
<h2>Download queue</h2>
<button onclick="deleteQueue(this)" data-id="pending" title="Delete all pending videos from the queue">Delete all queued</button>
{% endif %}
</div>
<h3>Total videos: {{ max_hits }}{% if max_hits == 10000 %}+{% endif %}</h3>
<div class="dl-list {{ view_style }}">
{% if results %} {% if results %}
{% for video in results %} {% for video in results %}
<div class="dl-item {{ view_style }}" id="dl-{{ video.source.youtube_id }}"> <div class="video-item {{ view_style }}" id="dl-{{ video.source.youtube_id }}">
<div class="dl-thumb {{ view_style }}"> <div class="video-thumb-wrap {{ view_style }}">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video_thumb"> <div class="video-thumb">
<img src="{{ video.source.vid_thumb_url }}" alt="video_thumb">
{% if show_ignored_only %} {% if show_ignored_only %}
<span>ignored</span> <span>ignored</span>
{% else %} {% else %}
<span>queued</span> <span>queued</span>
{% endif %} {% endif %}
</div> </div>
<div class="dl-desc {{ view_style }}"> </div>
<h3>{{ video.source.title }}</h3> <div class="video-desc {{ view_style }}">
<div>
{% if video.source.channel_indexed %} {% if video.source.channel_indexed %}
<a href="{% url 'channel_id' video.source.channel_id %}">{{ video.source.channel_name }}</a> <a href="{% url 'channel_id' video.source.channel_id %}">{{ video.source.channel_name }}</a>
{% else %} {% else %}
<span>{{ video.source.channel_name }}</span> <span>{{ video.source.channel_name }}</span>
{% endif %} {% endif %}
<h3>{{ video.source.title }}</h3>
</div>
<p>Published: {{ video.source.published }} | Duration: {{ video.source.duration }} | {{ video.source.youtube_id }}</p> <p>Published: {{ video.source.published }} | Duration: {{ video.source.duration }} | {{ video.source.youtube_id }}</p>
<div>
{% if show_ignored_only %} {% if show_ignored_only %}
<button data-id="{{ video.source.youtube_id }}" onclick="forgetIgnore(this)">Forget</button> <button data-id="{{ video.source.youtube_id }}" onclick="forgetIgnore(this)">Forget</button>
<button data-id="{{ video.source.youtube_id }}" onclick="addSingle(this)">Add to queue</button> <button data-id="{{ video.source.youtube_id }}" onclick="addSingle(this)">Add to queue</button>
@@ -84,6 +100,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,18 +1,18 @@
{% extends "home/base.html" %} {% extends "home/base.html" %}
{% block content %} {% block content %}
{% load static %} {% load static %}
<div class="boxed-content"> <div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}">
{% if continue_vids %} {% if continue_vids %}
<div class="title-bar"> <div class="title-bar">
<h1>Continue Watching</h1> <h1>Continue Watching</h1>
</div> </div>
<div class="video-list {{ view_style }}"> <div class="video-list {{ view_style }} {% if view_style == "grid" %}grid-{{ grid_items }}{% endif %}">
{% for video in continue_vids %} {% for video in continue_vids %}
<div class="video-item {{ view_style }}"> <div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)"> <a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}"> <div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb"> <div class="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb"> <img src="{{ 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 %}
@@ -45,7 +45,7 @@
<div class="title-bar"> <div class="title-bar">
<h1>Recent Videos</h1> <h1>Recent Videos</h1>
</div> </div>
<div class="view-controls"> <div class="view-controls three">
<div class="toggle"> <div class="toggle">
<span>Hide watched:</span> <span>Hide watched:</span>
<div class="toggleBox"> <div class="toggleBox">
@@ -74,21 +74,31 @@
</div> </div>
<div class="view-icons"> <div class="view-icons">
<img src="{% static 'img/icon-sort.svg' %}" alt="sort-icon" onclick="showForm()" id="animate-icon"> <img src="{% static 'img/icon-sort.svg' %}" alt="sort-icon" onclick="showForm()" id="animate-icon">
{% if view_style == "grid" %}
<div class="grid-count">
{% if grid_items < 7 %}
<img src="{% static 'img/icon-add.svg' %}" onclick="changeGridItems(this)" data-value="{{ grid_items|add:"1"}}" alt="grid plus row">
{% endif %}
{% if grid_items > 3 %}
<img src="{% static 'img/icon-substract.svg' %}" onclick="changeGridItems(this)" data-value="{{ grid_items|add:"-1"}}" alt="grid minus row">
{% endif %}
</div>
{% endif %}
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="grid" alt="grid view"> <img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="list" alt="list view"> <img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="list" alt="list view">
</div> </div>
</div> </div>
</div> </div>
<div id="player" class="player-wrapper"></div> <div id="player" class="player-wrapper"></div>
<div class="boxed-content"> <div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}">
<div class="video-list {{ view_style }}"> <div class="video-list {{ view_style }} {% if view_style == "grid" %}grid-{{ grid_items }}{% endif %}">
{% if results %} {% if results %}
{% for video in results %} {% for video in results %}
<div class="video-item {{ view_style }}"> <div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)"> <a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}"> <div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb"> <div class="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb"> <img src="{{ 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 %}

View File

@@ -56,13 +56,15 @@
</div> </div>
</div> </div>
{% if playlist_info.playlist_description %} {% if playlist_info.playlist_description %}
<div class="info-box-item description-box"> <div class="description-box">
<p>Description: <button onclick="textReveal()" id="text-reveal-button">Show</button></p> <p id="text-expand" class="description-text">
<div id="text-reveal" class="description-text"> {{ playlist_info.playlist_description|linebreaksbr|urlizetrunc:50 }}
{{ playlist_info.playlist_description|linebreaks }} </p>
</div> <button onclick="textExpand()" id="text-expand-button">Show more</button>
</div> </div>
{% endif %} {% endif %}
</div>
<div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}">
<div class="view-controls"> <div class="view-controls">
<div class="toggle"> <div class="toggle">
<span>Hide watched videos:</span> <span>Hide watched videos:</span>
@@ -76,21 +78,31 @@
</div> </div>
</div> </div>
<div class="view-icons"> <div class="view-icons">
{% if view_style == "grid" %}
<div class="grid-count">
{% if grid_items < 7 %}
<img src="{% static 'img/icon-add.svg' %}" onclick="changeGridItems(this)" data-value="{{ grid_items|add:"1"}}" alt="grid plus row">
{% endif %}
{% if grid_items > 3 %}
<img src="{% static 'img/icon-substract.svg' %}" onclick="changeGridItems(this)" data-value="{{ grid_items|add:"-1"}}" alt="grid minus row">
{% endif %}
</div>
{% endif %}
<img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="grid" alt="grid view"> <img src="{% static 'img/icon-gridview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="grid" alt="grid view">
<img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="list" alt="list view"> <img src="{% static 'img/icon-listview.svg' %}" onclick="changeView(this)" data-origin="home" data-value="list" alt="list view">
</div> </div>
</div> </div>
</div> </div>
<div id="player" class="player-wrapper"></div> <div id="player" class="player-wrapper"></div>
<div class="boxed-content"> <div class="boxed-content {% if view_style == "grid" %}boxed-{{ grid_items }}{% endif %}">
<div class="video-list {{ view_style }}"> <div class="video-list {{ view_style }} {% if view_style == "grid" %}grid-{{ grid_items }}{% endif %}">
{% if results %} {% if results %}
{% for video in results %} {% for video in results %}
<div class="video-item {{ view_style }}"> <div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)"> <a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}"> <div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb"> <div class="video-thumb">
<img src="/cache/{{ video.source.vid_thumb_url }}" alt="video-thumb"> <img src="{{ 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 %}

View File

@@ -1,16 +1,17 @@
{% extends "home/base.html" %} {% extends "home/base.html" %}
{% block content %} {% block content %}
<div id="player" class="player-wrapper"></div> <div id="player" class="player-wrapper"></div>
<div class="boxed-content"> <div class="boxed-content {% if all_styles.home == "grid" %}boxed-{{ grid_items }}{% endif %}">
<div class="title-bar"> <div class="title-bar">
<h1>Search your Archive</h1> <h1>Search your Archive</h1>
</div> </div>
<div class="multi-search-box"> <div class="multi-search-box">
{{ search_form }} {{ search_form }}
<p>Start typing or use <span class="settings-current">video:</span>, <span class="settings-current">channel:</span>, <span class="settings-current">playlist:</span> or <span class="settings-current">full:</span> keywords for advanced queries. <a href="https://github.com/tubearchivist/tubearchivist/wiki/Search" target="_blank">Learn more</a>.</p>
</div> </div>
<div class="multi-search-result"> <div class="multi-search-result">
<h2>Video Results</h2> <h2>Video Results</h2>
<div id="video-results" class="video-list {{ all_styles.home }}"> <div id="video-results" class="video-list {{ all_styles.home }} {% if all_styles.home == "grid" %}grid-{{ grid_items }}{% endif %}">
<p>No videos found.</p> <p>No videos found.</p>
</div> </div>
</div> </div>
@@ -26,5 +27,11 @@
<p>No playlists found.</p> <p>No playlists found.</p>
</div> </div>
</div> </div>
<div class="multi-search-result">
<h2>Fulltext Results</h2>
<div id="fulltext-results" class="video-list list">
<p>No fulltext results found.</p>
</div>
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -34,8 +34,8 @@
<div class="settings-group"> <div class="settings-group">
<h2 id="subscriptions">Subscriptions</h2> <h2 id="subscriptions">Subscriptions</h2>
<div class="settings-item"> <div class="settings-item">
<p>Current page size: <span class="settings-current">{{ config.subscriptions.channel_size }}</span></p> <p>YouTube page size: <span class="settings-current">{{ config.subscriptions.channel_size }}</span></p>
<i>Recent videos for channels and playlist to check when running <b>Rescan subscriptions</b>, max recommended 50.</i><br> <i>Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
{{ app_form.subscriptions_channel_size }} {{ app_form.subscriptions_channel_size }}
</div> </div>
</div> </div>
@@ -77,7 +77,7 @@
<ul> <ul>
<li><span class="settings-current">bestvideo[height<=720]+bestaudio/best[height<=720]</span>: best audio and max video height of 720p.</li> <li><span class="settings-current">bestvideo[height<=720]+bestaudio/best[height<=720]</span>: best audio and max video height of 720p.</li>
<li><span class="settings-current">bestvideo[height<=1080]+bestaudio/best[height<=1080]</span>: best audio and max video height of 1080p.</li> <li><span class="settings-current">bestvideo[height<=1080]+bestaudio/best[height<=1080]</span>: best audio and max video height of 1080p.</li>
<li><span class="settings-current">bestvideo[height<=1080][VCODEC=avc1]+bestaudio[ACODEC=mp4a]/mp4</span>: Max 1080p video height with iOS compatible video and audio codecs.</li> <li><span class="settings-current">bestvideo[height<=1080][vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4</span>: Max 1080p video height with iOS compatible video and audio codecs.</li>
<li><span class="settings-current">0</span>: deactivate and download the best quality possible as decided by yt-dlp.</li> <li><span class="settings-current">0</span>: deactivate and download the best quality possible as decided by yt-dlp.</li>
</ul> </ul>
<i>Make sure your custom format gets merged into a single file. Check out the <a href="https://github.com/yt-dlp/yt-dlp#format-selection" target="_blank">documentation</a> for valid configurations.</i><br> <i>Make sure your custom format gets merged into a single file. Check out the <a href="https://github.com/yt-dlp/yt-dlp#format-selection" target="_blank">documentation</a> for valid configurations.</i><br>
@@ -114,6 +114,38 @@
{{ app_form.downloads_subtitle_index }} {{ app_form.downloads_subtitle_index }}
</div> </div>
</div> </div>
<div class="settings-group">
<h2 id="comments">Comments</h2>
<div class="settings-item">
<p>Download and index comments: <span class="settings-current">{{ config.downloads.comment_max }}</span><br>
<i>Follow the yt-dlp max_comments documentation, <a href="https://github.com/yt-dlp/yt-dlp#youtube" target="_blank">max-comments,max-parents,max-replies,max-replies-per-thread</a>:</i><br>
<p>Example configurations:</p>
<ul>
<li><span class="settings-current">all,100,all,30</span>: Get 100 max-parents and 30 max-replies-per-thread.</li>
<li><span class="settings-current">1000,all,all,50</span>: Get a total of 1000 comments over all, 50 replies per thread.</li>
</ul>
{{ app_form.downloads_comment_max }}</p>
</div>
<div class="settings-item">
<p>Selected comment sort method: <span class="settings-current">{{ config.downloads.comment_sort }}</span><br>
<i>Select how many comments and threads to download:</i><br>
{{ app_form.downloads_comment_sort }}</p>
</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>
<p>For automatic cookie import use <b>Tube Archivist Companion</b> <a href="https://github.com/tubearchivist/browser-extension" target="_blank">browser extension</a>.</p>
<i>For manual cookie import, 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">
@@ -139,6 +171,23 @@
{{ app_form.application_enable_cast }} {{ app_form.application_enable_cast }}
</div> </div>
</div> </div>
<div class="settings-group">
<h2 id="snapshots">Snapshots</h2>
<div class="settings-item">
<p>Current system snapshot: <span class="settings-current">{{ config.application.enable_snapshot }}</span></p>
<i>Automatically create daily deduplicated snapshots of the index, stored in Elasticsearch. Read first before activating: <a target="_blank" href="https://github.com/tubearchivist/tubearchivist/wiki/Settings#snapshots">Wiki</a>.</i><br>
{{ app_form.application_enable_snapshot }}
</div>
<div>
{% if snapshots %}
<p>Create next snapshot: <span class="settings-current">{{ snapshots.next_exec_str }}</span>, snapshots expire after <span class="settings-current">{{ snapshots.expire_after }}</span>. <button onclick="createSnapshot()" id="createButton">Create snapshot now</button></p>
<br>
{% for snapshot in snapshots.snapshots %}
<p><button id="{{ snapshot.id }}" onclick="restoreSnapshot(id)">Restore</button> Snapshot created on: <span class="settings-current">{{ snapshot.start_date }}</span>, took <span class="settings-current">{{ snapshot.duration_s }}s</span> to create. State: <i>{{ snapshot.state }}</i></p>
{% endfor %}
{% endif %}
</div>
</div>
<button type="submit" name="application-settings">Update Application Configurations</button> <button type="submit" name="application-settings">Update Application Configurations</button>
</form> </form>
<div class="title-bar"> <div class="title-bar">
@@ -155,7 +204,7 @@
<p>Note:</p> <p>Note:</p>
<ul> <ul>
<li>Changes in the scheduler settings require a container restart to take effect.</li> <li>Changes in the scheduler settings require a container restart to take effect.</li>
<li>Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that reason <i>*</i> or <i>wildcard</i> for minutes is not supported.</li> <li>Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that reason, the scheduler doesn't support schedules that trigger more than once per hour.</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -256,6 +305,12 @@
<div class="title-bar"> <div class="title-bar">
<h1>Actions</h1> <h1>Actions</h1>
</div> </div>
<div class="settings-group">
<h2>Delete download queue</h2>
<p>Delete your pending or previously ignored videos from your download queue.<p>
<button onclick="deleteQueue(this)" id="ignore-button" data-id="ignore" title="Delete all previously ignored videos from the queue">Delete all ignored</button>
<button onclick="deleteQueue(this)" id="pending-button" data-id="pending" title="Delete all pending videos from the queue">Delete all queued</button>
</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/tubearchivist/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>

View File

@@ -2,7 +2,11 @@
{% block content %} {% block content %}
{% load static %} {% load static %}
{% load humanize %} {% load humanize %}
<div class="video-main"></div> <div id="player" class="player-wrapper">
<div class="video-main">
<div class="video-modal"><span class="video-modal-text"></span></div>
</div>
</div>
<div class="notifications" id="notifications"></div> <div class="notifications" id="notifications"></div>
<div class="sponsorblock" id="sponsorblock"> <div class="sponsorblock" id="sponsorblock">
{% if video.sponsorblock.is_enabled %} {% if video.sponsorblock.is_enabled %}
@@ -67,23 +71,32 @@
<p class="thumb-icon"><img class="dislike" src="{% static 'img/icon-thumb.svg' %}" alt="thumbs-down">: {{ video.stats.dislike_count|intcomma }}</p> <p class="thumb-icon"><img class="dislike" src="{% static 'img/icon-thumb.svg' %}" alt="thumbs-down">: {{ video.stats.dislike_count|intcomma }}</p>
{% endif %} {% endif %}
{% if video.stats.average_rating %} {% if video.stats.average_rating %}
<p class="rating-stars">Rating: <div class="rating-stars">
{% for star in video.stats.average_rating %} {% for star in video.stats.average_rating %}
<img src="/static/img/icon-star-{{ star }}.svg" alt="{{ star }}"> <img src="/static/img/icon-star-{{ star }}.svg" alt="{{ star }}">
{% endfor %} {% endfor %}
</p> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
{% if video.description %} {% if video.tags %}
<div class="info-box-item description-box"> <div class="description-box">
<p>Description: <button onclick="textReveal()" id="text-reveal-button">Show</button></p> <div class="video-tag-box">
<div id="text-reveal" class="description-text"> {% for tag in video.tags %}
{{ video.description|linebreaks }} <span class="video-tag">{{ tag }}</span>
{% endfor %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if video.description %}
<div class="description-box">
<p id="text-expand" class="description-text">
{{ video.description|linebreaksbr|urlizetrunc:50 }}
</p>
<button onclick="textExpand()" id="text-expand-button">Show more</button>
</div>
{% endif %}
{% if playlist_nav %} {% if playlist_nav %}
{% for playlist_item in playlist_nav %} {% for playlist_item in playlist_nav %}
<div class="playlist-wrap"> <div class="playlist-wrap">
@@ -121,11 +134,29 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
<div class="description-box">
<h3>Similar Videos</h3>
<div class="video-list grid grid-3" id="similar-videos">
</div>
<script>getSimilarVideos('{{ video.youtube_id }}')</script>
</div>
{% if video.comment_count %}
<div class="comments-section">
<h3>Comments: {{video.comment_count}}</h3>
<div id="comments-list" class="comments-list">
</div>
</div>
<script>getComments('{{ video.youtube_id }}')</script>
{% endif %}
</div> </div>
<script> <script>
var videoData = getVideoData('{{ video.youtube_id }}'); var videoData = getVideoData('{{ video.youtube_id }}');
sponsorBlock = videoData.data.sponsorblock; sponsorBlock = videoData.data.sponsorblock;
{% if position %}
var videoProgress = {{ position }}
{% else %}
var videoProgress = getVideoProgress('{{ video.youtube_id }}').position; var videoProgress = getVideoProgress('{{ video.youtube_id }}').position;
{% endif %}
window.onload = insertVideoTag(videoData, videoProgress); window.onload = insertVideoTag(videoData, videoProgress);
</script> </script>
{% endblock content %} {% endblock content %}

View File

@@ -6,6 +6,8 @@ from django.contrib.auth.views import LogoutView
from django.urls import path from django.urls import path
from home.views import ( from home.views import (
AboutView, AboutView,
ChannelIdAboutView,
ChannelIdPlaylistView,
ChannelIdView, ChannelIdView,
ChannelView, ChannelView,
DownloadView, DownloadView,
@@ -42,6 +44,16 @@ urlpatterns = [
login_required(ChannelIdView.as_view()), login_required(ChannelIdView.as_view()),
name="channel_id", name="channel_id",
), ),
path(
"channel/<slug:channel_id>/about/",
login_required(ChannelIdAboutView.as_view()),
name="channel_id_about",
),
path(
"channel/<slug:channel_id>/playlist/",
login_required(ChannelIdPlaylistView.as_view()),
name="channel_id_playlist",
),
path( path(
"video/<slug:video_id>/", "video/<slug:video_id>/",
login_required(VideoView.as_view()), login_required(VideoView.as_view()),

View File

@@ -8,13 +8,17 @@ import json
import urllib.parse import urllib.parse
from time import sleep from time import sleep
from api.src.search_processor import SearchProcess
from django.conf import settings 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.es.index_setup import get_available_backups from home.src.download.yt_dlp_base import CookieHandler
from home.src.es.backup import ElasticBackup
from home.src.es.connect import ElasticWrap
from home.src.es.snapshot import ElasticSnapshot
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,
@@ -28,11 +32,11 @@ 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.channel import YoutubeChannel, 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, time_parser
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
from home.tasks import extrac_dl, index_channel_playlists, 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
@@ -76,6 +80,15 @@ class ArchivistViewConfig(View):
return view_style return view_style
def _get_grid_items(self):
"""return items per row to show in grid view"""
grid_key = f"{self.user_id}:grid_items"
grid_items = self.user_conf.get_message(grid_key)["status"]
if not grid_items:
grid_items = self.default_conf["default_view"]["grid_items"]
return grid_items
def get_all_view_styles(self): def get_all_view_styles(self):
"""get dict of all view stiles for search form""" """get dict of all view stiles for search form"""
all_keys = ["channel", "playlist", "home"] all_keys = ["channel", "playlist", "home"]
@@ -119,6 +132,7 @@ class ArchivistViewConfig(View):
"sort_by": self._get_sort_by(), "sort_by": self._get_sort_by(),
"sort_order": self._get_sort_order(), "sort_order": self._get_sort_order(),
"view_style": self._get_view_style(), "view_style": self._get_view_style(),
"grid_items": self._get_grid_items(),
"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(),
@@ -224,14 +238,10 @@ class ArchivistResultsView(ArchivistViewConfig):
def initiate_vars(self, request): def initiate_vars(self, request):
"""search in es for vidoe hits""" """search in es for vidoe hits"""
page_get = int(request.GET.get("page", 0))
self.user_id = request.user.id self.user_id = request.user.id
self.config_builder(self.user_id) self.config_builder(self.user_id)
self.search_get = request.GET.get("search", False) self.search_get = request.GET.get("search", False)
search_encoded = self._url_encode(self.search_get) self.pagination_handler = Pagination(request)
self.pagination_handler = Pagination(
page_get=page_get, user_id=self.user_id, search_get=search_encoded
)
self.sort_by = self._sort_by_overwrite() self.sort_by = self._sort_by_overwrite()
self._initial_data() self._initial_data()
@@ -349,29 +359,80 @@ class DownloadView(ArchivistResultsView):
def get(self, request): def get(self, request):
"""handle get request""" """handle get request"""
self.initiate_vars(request) self.initiate_vars(request)
self._update_view_data() self._update_view_data(request)
self.find_results() self.find_results()
self.context.update( self.context.update(
{ {
"title": "Downloads", "title": "Downloads",
"add_form": AddToQueueForm(), "add_form": AddToQueueForm(),
"channel_agg_list": self._get_channel_agg(),
} }
) )
return render(request, "home/downloads.html", self.context) return render(request, "home/downloads.html", self.context)
def _update_view_data(self): def _update_view_data(self, request):
"""update downloads view specific data dict""" """update downloads view specific data dict"""
if self.context["show_ignored_only"]: if self.context["show_ignored_only"]:
filter_view = "ignore" filter_view = "ignore"
else: else:
filter_view = "pending" filter_view = "pending"
must_list = [{"term": {"status": {"value": filter_view}}}]
channel_filter = request.GET.get("channel", False)
if channel_filter:
must_list.append(
{"term": {"channel_id": {"value": channel_filter}}}
)
channel = YoutubeChannel(channel_filter)
channel.get_from_es()
self.context.update(
{
"channel_filter_id": channel_filter,
"channel_filter_name": channel.json_data["channel_name"],
}
)
self.data.update( self.data.update(
{ {
"query": {"term": {"status": {"value": filter_view}}}, "query": {"bool": {"must": must_list}},
"sort": [{"timestamp": {"order": "asc"}}], "sort": [{"timestamp": {"order": "asc"}}],
} }
) )
def _get_channel_agg(self):
"""get pending channel with count"""
data = {
"size": 0,
"query": {"term": {"status": {"value": "pending"}}},
"aggs": {
"channel_downloads": {
"multi_terms": {
"size": 30,
"terms": [
{"field": "channel_name.keyword"},
{"field": "channel_id"},
],
"order": {"_count": "desc"},
}
}
},
}
response, _ = ElasticWrap(self.es_search).get(data=data)
buckets = response["aggregations"]["channel_downloads"]["buckets"]
buckets_sorted = []
for i in buckets:
bucket = {
"name": i["key"][0],
"id": i["key"][1],
"count": i["doc_count"],
}
buckets_sorted.append(bucket)
return buckets_sorted
@staticmethod @staticmethod
def post(request): def post(request):
"""handle post requests""" """handle post requests"""
@@ -383,14 +444,15 @@ class DownloadView(ArchivistResultsView):
youtube_ids = UrlListParser(url_str).process_list() youtube_ids = UrlListParser(url_str).process_list()
except ValueError: except ValueError:
# failed to process # failed to process
key = "message:add"
print(f"failed to parse: {url_str}") print(f"failed to parse: {url_str}")
mess_dict = { mess_dict = {
"status": "message:add", "status": key,
"level": "error", "level": "error",
"title": "Failed to extract links.", "title": "Failed to extract links.",
"message": "Not a video, channel or playlist ID or URL", "message": "Not a video, channel or playlist ID or URL",
} }
RedisArchivist().set_message("message:add", mess_dict) RedisArchivist().set_message(key, mess_dict, expire=True)
return redirect("downloads") return redirect("downloads")
print(youtube_ids) print(youtube_ids)
@@ -400,7 +462,37 @@ class DownloadView(ArchivistResultsView):
return redirect("downloads", permanent=True) return redirect("downloads", permanent=True)
class ChannelIdView(ArchivistResultsView): class ChannelIdBaseView(ArchivistResultsView):
"""base class for all channel-id views"""
def get_channel_meta(self, channel_id):
"""get metadata for channel"""
path = f"ta_channel/_doc/{channel_id}"
response, _ = ElasticWrap(path).get()
channel_info = SearchProcess(response).process()
return channel_info
def channel_has_pending(self, channel_id):
"""check if channel has pending videos in queue"""
path = "ta_download/_search"
data = {
"size": 1,
"query": {
"bool": {
"must": [
{"term": {"status": {"value": "pending"}}},
{"term": {"channel_id": {"value": channel_id}}},
]
}
},
}
response, _ = ElasticWrap(path).get(data=data)
self.context.update({"has_pending": bool(response["hits"]["hits"])})
class ChannelIdView(ChannelIdBaseView):
"""resolves to /channel/<channel-id>/ """resolves to /channel/<channel-id>/
display single channel page from channel_id display single channel page from channel_id
""" """
@@ -414,6 +506,7 @@ class ChannelIdView(ArchivistResultsView):
self._update_view_data(channel_id) self._update_view_data(channel_id)
self.find_results() self.find_results()
self.match_progress() self.match_progress()
self.channel_has_pending(channel_id)
if self.context["results"]: if self.context["results"]:
channel_info = self.context["results"][0]["source"]["channel"] channel_info = self.context["results"][0]["source"]["channel"]
@@ -428,7 +521,6 @@ class ChannelIdView(ArchivistResultsView):
{ {
"title": "Channel: " + channel_name, "title": "Channel: " + channel_name,
"channel_info": channel_info, "channel_info": channel_info,
"channel_overwrite_form": ChannelOverwriteForm,
} }
) )
@@ -465,6 +557,87 @@ class ChannelIdView(ArchivistResultsView):
return redirect("channel_id", channel_id, permanent=True) return redirect("channel_id", channel_id, permanent=True)
class ChannelIdAboutView(ChannelIdBaseView):
"""resolves to /channel/<channel-id>/about/
show metadata, handle per channel conf
"""
view_origin = "channel"
def get(self, request, channel_id):
"""handle get request"""
self.initiate_vars(request)
self.channel_has_pending(channel_id)
path = f"ta_channel/_doc/{channel_id}"
response, _ = ElasticWrap(path).get()
channel_info = SearchProcess(response).process()
channel_name = channel_info["channel_name"]
self.context.update(
{
"title": "Channel: About " + channel_name,
"channel_info": channel_info,
"channel_overwrite_form": ChannelOverwriteForm,
}
)
return render(request, "home/channel_id_about.html", self.context)
@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_about", channel_id, permanent=True)
class ChannelIdPlaylistView(ChannelIdBaseView):
"""resolves to /channel/<channel-id>/playlist/
show all playlists of channel
"""
view_origin = "playlist"
es_search = "ta_playlist/_search"
def get(self, request, channel_id):
"""handle get request"""
self.initiate_vars(request)
self._update_view_data(channel_id)
self.find_results()
self.channel_has_pending(channel_id)
channel_info = self.get_channel_meta(channel_id)
channel_name = channel_info["channel_name"]
self.context.update(
{
"title": "Channel: Playlists " + channel_name,
"channel_info": channel_info,
}
)
return render(request, "home/channel_id_playlist.html", self.context)
def _update_view_data(self, channel_id):
"""update view specific data dict"""
self.data["sort"] = [{"playlist_name.keyword": {"order": "asc"}}]
must_list = [{"match": {"playlist_channel_id": channel_id}}]
if self.context["show_subed_only"]:
must_list.append({"match": {"playlist_subscribed": True}})
self.data["query"] = {"bool": {"must": must_list}}
class ChannelView(ArchivistResultsView): class ChannelView(ArchivistResultsView):
"""resolves to /channel/ """resolves to /channel/
handle functionality for channel overview page, subscribe to channel, handle functionality for channel overview page, subscribe to channel,
@@ -501,13 +674,14 @@ class ChannelView(ArchivistResultsView):
"""handle http post requests""" """handle http post requests"""
subscribe_form = SubscribeToChannelForm(data=request.POST) subscribe_form = SubscribeToChannelForm(data=request.POST)
if subscribe_form.is_valid(): if subscribe_form.is_valid():
key = "message:subchannel"
message = { message = {
"status": "message:subchannel", "status": key,
"level": "info", "level": "info",
"title": "Subscribing to Channels", "title": "Subscribing to Channels",
"message": "Parsing form data", "message": "Parsing form data",
} }
RedisArchivist().set_message("message:subchannel", message=message) RedisArchivist().set_message(key, message=message, expire=True)
url_str = request.POST.get("subscribe") url_str = request.POST.get("subscribe")
print(url_str) print(url_str)
subscribe_to.delay(url_str) subscribe_to.delay(url_str)
@@ -648,15 +822,14 @@ class PlaylistView(ArchivistResultsView):
if subscribe_form.is_valid(): if subscribe_form.is_valid():
url_str = request.POST.get("subscribe") url_str = request.POST.get("subscribe")
print(url_str) print(url_str)
key = "message:subplaylist"
message = { message = {
"status": "message:subplaylist", "status": key,
"level": "info", "level": "info",
"title": "Subscribing to Playlists", "title": "Subscribing to Playlists",
"message": "Parsing form data", "message": "Parsing form data",
} }
RedisArchivist().set_message( RedisArchivist().set_message(key, message=message, expire=True)
"message:subplaylist", message=message
)
subscribe_to.delay(url_str) subscribe_to.delay(url_str)
sleep(1) sleep(1)
@@ -671,6 +844,7 @@ class VideoView(View):
def get(self, request, video_id): def get(self, request, video_id):
"""get single video""" """get single video"""
config_handler = AppConfig(request.user.id) config_handler = AppConfig(request.user.id)
position = time_parser(request.GET.get("t"))
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()
@@ -696,6 +870,7 @@ class VideoView(View):
"cast": config_handler.config["application"]["enable_cast"], "cast": config_handler.config["application"]["enable_cast"],
"version": settings.TA_VERSION, "version": settings.TA_VERSION,
"config": config_handler.config, "config": config_handler.config,
"position": position,
} }
return render(request, "home/video.html", context) return render(request, "home/video.html", context)
@@ -764,10 +939,11 @@ class SettingsView(View):
config_handler = AppConfig(request.user.id) config_handler = AppConfig(request.user.id)
colors = config_handler.colors colors = config_handler.colors
available_backups = get_available_backups() available_backups = ElasticBackup().get_all_backup_files()
user_form = UserSettingsForm() user_form = UserSettingsForm()
app_form = ApplicationSettingsForm() app_form = ApplicationSettingsForm()
scheduler_form = SchedulerSettingsForm() scheduler_form = SchedulerSettingsForm()
snapshots = ElasticSnapshot().get_snapshot_stats()
token = self.get_token(request) token = self.get_token(request)
context = { context = {
@@ -779,6 +955,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,
"snapshots": snapshots,
"version": settings.TA_VERSION, "version": settings.TA_VERSION,
} }
@@ -791,21 +968,22 @@ 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) user_form = UserSettingsForm(request.POST)
config_handler = AppConfig()
if user_form.is_valid(): if user_form.is_valid():
user_form_post = user_form.cleaned_data user_form_post = user_form.cleaned_data
if any(user_form_post.values()): if any(user_form_post.values()):
AppConfig().set_user_config(user_form_post, request.user.id) config_handler.set_user_config(user_form_post, request.user.id)
app_form = ApplicationSettingsForm(request.POST) app_form = ApplicationSettingsForm(request.POST)
if app_form.is_valid(): if app_form.is_valid():
app_form_post = app_form.cleaned_data app_form_post = app_form.cleaned_data
if app_form_post: if app_form_post:
print(app_form_post) print(app_form_post)
AppConfig().update_config(app_form_post) updated = config_handler.update_config(app_form_post)
self.post_process_updated(updated, config_handler.config)
scheduler_form = SchedulerSettingsForm(request.POST) scheduler_form = SchedulerSettingsForm(request.POST)
if scheduler_form.is_valid(): if scheduler_form.is_valid():
@@ -817,6 +995,48 @@ class SettingsView(View):
sleep(1) sleep(1)
return redirect("settings", permanent=True) return redirect("settings", permanent=True)
def post_process_updated(self, updated, config):
"""apply changes for config"""
if not updated:
return
for config_value, updated_value in updated:
if config_value == "cookie_import":
self.process_cookie(config, updated_value)
if config_value == "enable_snapshot":
ElasticSnapshot().setup()
def process_cookie(self, config, updated_value):
"""import and validate cookie"""
handler = CookieHandler(config)
if updated_value:
try:
handler.import_cookie()
except FileNotFoundError:
print("cookie: import failed, file not found")
handler.revoke()
self._fail_message("Cookie file not found.")
return
valid = handler.validate()
if not valid:
handler.revoke()
self._fail_message("Failed to validate cookie file.")
else:
handler.revoke()
@staticmethod
def _fail_message(message_line):
"""notify our failure"""
key = "message:setting"
message = {
"status": key,
"level": "error",
"title": "Cookie import failed",
"message": message_line,
}
RedisArchivist().set_message(key, message=message, expire=True)
def progress(request): def progress(request):
# pylint: disable=unused-argument # pylint: disable=unused-argument

View File

@@ -1,12 +1,13 @@
beautifulsoup4==4.11.1 beautifulsoup4==4.11.1
celery==5.2.6 celery==5.2.7
Django==4.0.4 Django==4.1.3
django-cors-headers==3.11.0 django-auth-ldap==4.1.0
djangorestframework==3.13.1 django-cors-headers==3.13.0
Pillow==9.1.0 djangorestframework==3.14.0
redis==4.2.2 Pillow==9.3.0
requests==2.27.1 redis==4.3.5
ryd-client==0.0.3 requests==2.28.1
uWSGI==2.0.20 ryd-client==0.0.6
whitenoise==6.0.0 uWSGI==2.0.21
yt_dlp==2022.4.8 whitenoise==6.2.0
yt_dlp==2022.11.11

View File

@@ -1,31 +1,37 @@
'use strict';
/* global cast chrome getVideoPlayerVideoId postVideoProgress setProgressBar getVideoPlayer getVideoPlayerWatchStatus watchedThreshold isWatched getVideoData getURL getVideoPlayerCurrentTime */
function initializeCastApi() { function initializeCastApi() {
cast.framework.CastContext.getInstance().setOptions({ cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in reciver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee. receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee.
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
}); });
var player = new cast.framework.RemotePlayer(); let player = new cast.framework.RemotePlayer();
var playerController = new cast.framework.RemotePlayerController(player); let playerController = new cast.framework.RemotePlayerController(player);
// Add event listerner to check if a connection to a cast device is initiated // Add event listerner to check if a connection to a cast device is initiated
playerController.addEventListener( playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, function() { cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
castConnectionChange(player) function () {
castConnectionChange(player);
} }
); );
playerController.addEventListener( playerController.addEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED, function() { cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
castVideoProgress(player) function () {
castVideoProgress(player);
} }
); );
playerController.addEventListener( playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED, function() { cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
castVideoPaused(player) function () {
castVideoPaused(player);
} }
); );
} }
function castConnectionChange(player) { function castConnectionChange(player) {
// If cast connection is initialized start cast // If cast connection is initialized start cast
if (player.isConnected) { if (player.isConnected) {
@@ -37,14 +43,16 @@ function castConnectionChange(player) {
} }
function castVideoProgress(player) { function castVideoProgress(player) {
var videoId = getVideoPlayerVideoId(); let videoId = getVideoPlayerVideoId();
if (player.mediaInfo.contentId.includes(videoId)) { if (player.mediaInfo.contentId.includes(videoId)) {
var currentTime = player.currentTime; let currentTime = player.currentTime;
var duration = player.duration; let duration = player.duration;
if ((currentTime % 10) <= 1.0 && currentTime != 0 && duration != 0) { // Check progress every 10 seconds or else progress is checked a few times a second if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
// Check progress every 10 seconds or else progress is checked a few times a second
postVideoProgress(videoId, currentTime); postVideoProgress(videoId, currentTime);
setProgressBar(videoId, currentTime, duration); setProgressBar(videoId, currentTime, duration);
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); isWatched(videoId);
} }
@@ -54,12 +62,12 @@ function castVideoProgress(player) {
} }
function castVideoPaused(player) { function castVideoPaused(player) {
var videoId = getVideoPlayerVideoId(); let videoId = getVideoPlayerVideoId();
var currentTime = player.currentTime; let currentTime = player.currentTime;
var duration = player.duration; let duration = player.duration;
if (player.mediaInfo != null) { if (player.mediaInfo != null) {
if (player.mediaInfo.contentId.includes(videoId)) { if (player.mediaInfo.contentId.includes(videoId)) {
if (currentTime != 0 && duration != 0) { if (currentTime !== 0 && duration !== 0) {
postVideoProgress(videoId, currentTime); postVideoProgress(videoId, currentTime);
} }
} }
@@ -67,29 +75,29 @@ function castVideoPaused(player) {
} }
function castStart() { function castStart() {
var castSession = cast.framework.CastContext.getInstance().getCurrentSession(); let castSession = cast.framework.CastContext.getInstance().getCurrentSession();
// Check if there is already media playing on the cast target to prevent recasting on page reload or switching to another video page // Check if there is already media playing on the cast target to prevent recasting on page reload or switching to another video page
if (!castSession.getMediaSession()) { if (!castSession.getMediaSession()) {
var videoId = getVideoPlayerVideoId(); let videoId = getVideoPlayerVideoId();
var videoData = getVideoData(videoId); let videoData = getVideoData(videoId);
var contentId = getURL() + videoData.data.media_url; let contentId = getURL() + videoData.data.media_url;
var contentTitle = videoData.data.title; let contentTitle = videoData.data.title;
var contentImage = getURL() + videoData.data.vid_thumb_url; let contentImage = getURL() + videoData.data.vid_thumb_url;
contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded let contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
contentCurrentTime = getVideoPlayerCurrentTime(); // Get video's current position let contentCurrentTime = getVideoPlayerCurrentTime(); // Get video's current position
contentActiveSubtitle = []; let contentActiveSubtitle = [];
// Check if a subtitle is turned on. // Check if a subtitle is turned on.
for (var i = 0; i < getVideoPlayer().textTracks.length; i++) { for (let i = 0; i < getVideoPlayer().textTracks.length; i++) {
if (getVideoPlayer().textTracks[i].mode == "showing") { if (getVideoPlayer().textTracks[i].mode === 'showing') {
contentActiveSubtitle =[i + 1]; contentActiveSubtitle = [i + 1];
} }
} }
contentSubtitles = []; let contentSubtitles = [];
var videoSubtitles = videoData.data.subtitles; // Array of subtitles let 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 (let i = 0; i < videoSubtitles.length; i++) {
subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT); let subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
subtitle.trackContentId = videoSubtitles[i].media_url; subtitle.trackContentId = videoSubtitles[i].media_url;
subtitle.trackContentType = 'text/vtt'; subtitle.trackContentType = 'text/vtt';
subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES; subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
@@ -100,35 +108,36 @@ function castStart() {
} }
} }
mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type let mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type
// mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER // mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo
mediaInfo.metadata.title = contentTitle.replace("&amp;", "&"); // Set the video title mediaInfo.metadata.title = contentTitle.replace('&amp;', '&'); // Set the video title
mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
// mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle(); // mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
mediaInfo.tracks = contentSubtitles; mediaInfo.tracks = contentSubtitles;
var request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo. let request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo.
// request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support. // request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support.
request.currentTime = shiftCurrentTime(contentCurrentTime); // Set video start position based on the browser video position request.currentTime = shiftCurrentTime(contentCurrentTime); // Set video start position based on the browser video position
request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player
// request.autoplay = false; // Set content to auto play, true by default // request.autoplay = false; // Set content to auto play, true by default
castSession.loadMedia(request).then( castSession.loadMedia(request).then(
function() { function () {
castSuccessful(); castSuccessful();
}, },
function() { function (error) {
castFailed(errorCode); castFailed(error.code);
} }
); // Send request to cast device ); // Send request to cast device
} }
} }
function shiftCurrentTime(contentCurrentTime) { // Shift media back 3 seconds to prevent missing some of the content function shiftCurrentTime(contentCurrentTime) {
// Shift media back 3 seconds to prevent missing some of the content
if (contentCurrentTime > 5) { if (contentCurrentTime > 5) {
return(contentCurrentTime - 3); return contentCurrentTime - 3;
} else { } else {
return(0); return 0;
} }
} }
@@ -141,8 +150,8 @@ function castFailed(errorCode) {
console.log('Error code: ' + errorCode); console.log('Error code: ' + errorCode);
} }
window['__onGCastApiAvailable'] = function(isAvailable) { window['__onGCastApiAvailable'] = function (isAvailable) {
if (isAvailable) { if (isAvailable) {
initializeCastApi(); initializeCastApi();
} }
} };

View File

@@ -133,6 +133,18 @@ button:hover {
margin: 0 auto; margin: 0 auto;
} }
.boxed-content.boxed-4 {
max-width: 1200px;
width: 80%;
}
.boxed-content.boxed-5,
.boxed-content.boxed-6,
.boxed-content.boxed-7 {
max-width: unset;
width: 85%;
}
.round-img img { .round-img img {
border-radius: 50%; border-radius: 50%;
} }
@@ -328,9 +340,11 @@ button:hover {
margin: 15px 0; margin: 15px 0;
} }
.view-icons { .view-icons,
.grid-count {
display: flex; display: flex;
justify-content: end; justify-content: end;
align-items: center;
} }
.view-icons img { .view-icons img {
@@ -340,15 +354,29 @@ button:hover {
filter: var(--img-filter); filter: var(--img-filter);
} }
.grid-count img {
width: 15px;
margin: 5px;
cursor: pointer;
filter: var(--img-filter);
}
#hidden-form { #hidden-form {
display: none; display: none;
} }
#text-reveal { #text-reveal {
height: 0px; height: 0;
overflow: hidden; overflow: hidden;
} }
#text-expand {
overflow: hidden;
display: -webkit-inline-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
/* video player */ /* video player */
.player-wrapper { .player-wrapper {
@@ -360,6 +388,7 @@ button:hover {
display: grid; display: grid;
align-content: space-evenly; align-content: space-evenly;
height: 100vh; height: 100vh;
position: relative; /* needed for modal */
} }
.notifications { .notifications {
@@ -388,6 +417,12 @@ button:hover {
margin: 10px 10px 10px 0; margin: 10px 10px 10px 0;
} }
/* fix for safari full screen not scaling full */
video:-webkit-full-screen {
max-height: unset !important;
max-width: unset !important;
}
/* video list */ /* video list */
.video-list { .video-list {
display: grid; display: grid;
@@ -395,10 +430,26 @@ button:hover {
margin-top: 1rem; margin-top: 1rem;
} }
.video-list.grid { .video-list.grid.grid-3 {
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
} }
.video-list.grid.grid-4 {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
.video-list.grid.grid-5 {
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
}
.video-list.grid.grid-6 {
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr;
}
.video-list.grid.grid-7 {
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
}
.video-list.list { .video-list.list {
grid-template-columns: unset; grid-template-columns: unset;
} }
@@ -407,9 +458,15 @@ button:hover {
overflow: hidden; overflow: hidden;
} }
.video-item:hover .video-thumb span {
opacity: 1;
}
.video-item.list { .video-item.list {
display: grid; display: grid;
grid-template-columns: 25% auto; grid-template-columns: 26% auto;
background-color: var(--highlight-bg);
align-items: center;
} }
.video-progress-bar { .video-progress-bar {
@@ -425,6 +482,16 @@ button:hover {
position: relative; position: relative;
} }
.video-thumb span {
position: absolute;
top: 5px;
left: 5px;
background-color: var(--accent-font-light);
padding: 5px;
opacity: 0;
transition: 300ms ease-in-out;
}
.video-play img { .video-play img {
width: 40px; width: 40px;
filter: var(--img-filter); filter: var(--img-filter);
@@ -459,10 +526,9 @@ button:hover {
.video-desc.list { .video-desc.list {
padding: 10px; padding: 10px;
height: unset; height: 100%;
background-color: var(--highlight-bg);
display: flex; display: flex;
flex-wrap: wrap-reverse; flex-wrap: wrap;
align-content: center; align-content: center;
} }
@@ -553,8 +619,11 @@ button:hover {
margin-top: 1rem; margin-top: 1rem;
} }
.description-box { .description-box,
.comments-section {
margin-top: 1rem; margin-top: 1rem;
padding: 15px;
background-color: var(--highlight-bg);
} }
.info-box-3 { .info-box-3 {
@@ -677,6 +746,22 @@ button:hover {
/* video page */ /* video page */
.video-main { .video-main {
margin: 1rem 0; margin: 1rem 0;
position: relative; /* needed for modal */
}
.video-modal {
position: absolute;
z-index: 1;
top: 20%;
width: 100%;
text-align: center;
}
.video-modal-text {
background: rgba(0,0,0,.5);
color: #eeeeee;
font-size: 1.3em;
display: none;
} }
.video-main video { .video-main video {
@@ -694,10 +779,26 @@ button:hover {
margin-left: 5px; margin-left: 5px;
} }
.thumb-icon {
display: flex;
}
.video-tag-box {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.video-tag {
padding: 5px 10px;
margin: 5px;
border: 1px solid var(--accent-font-light);
}
.thumb-icon img, .thumb-icon img,
.rating-stars img { .rating-stars img {
width: 20px; width: 20px;
margin: 0; margin: 0 5px;
filter: var(--img-filter); filter: var(--img-filter);
} }
@@ -735,7 +836,49 @@ button:hover {
width: 100%; width: 100%;
} }
.comment-box {
padding-bottom: 1rem;
overflow: hidden;
}
.comment-box h3 {
line-break: anywhere;
}
.comments-replies {
display: none;
padding-left: 1rem;
border-left: 1px solid var(--accent-font-light);
margin-top: 1rem;
}
.comment-highlight {
background-color: var(--main-font);
padding: 3px;
color: var(--accent-font-dark);
font-family: Sen-bold, sans-serif;
width: fit-content;
}
.comment-meta {
display: flex;
}
.space-carrot {
margin: 0 5px;
}
.comment-like img {
width: 20px;
margin-left: 5px;
filter: var(--img-filter-error);
}
/* multi search page */ /* multi search page */
.multi-search-box {
padding-right: 20px;
}
.multi-search-box input { .multi-search-box input {
width: 100%; width: 100%;
} }
@@ -781,6 +924,18 @@ button:hover {
transform: translateX(-30%); transform: translateX(-30%);
} }
.info-box-item.channel-nav {
justify-content: center;
}
.info-box-item.channel-nav a {
padding: 0 1rem;
}
.info-box-item.channel-nav a:hover {
text-decoration: underline;
}
/* playlist overview page */ /* playlist overview page */
.playlist-list.list { .playlist-list.list {
display: grid; display: grid;
@@ -839,73 +994,6 @@ button:hover {
cursor: pointer; cursor: pointer;
} }
.dl-list.list {
display: block;
}
.dl-list.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-gap: 1rem;
}
.dl-item.list {
display: flex;
margin: 15px 0;
align-items: center;
background-color: var(--highlight-bg);
}
.dl-item.grid {
display: flex;
flex-wrap: wrap;
background-color: var(--highlight-bg);
}
.dl-check {
width: 30px;
}
.dl-item:hover > .dl-thumb span {
opacity: 1;
}
.dl-thumb {
position: relative;
}
.dl-thumb span {
position: absolute;
top: 5px;
left: 5px;
background-color: var(--accent-font-light);
padding: 5px;
opacity: 0;
transition: 300ms ease-in-out;
}
.dl-thumb.list {
width: 25%;
}
.dl-thumb.grid {
width: 100%;
}
.dl-item img {
width: 100%;
}
.dl-desc.list {
padding: 0 15px;
width: 75%;
}
.dl-desc.grid {
padding: 15px;
width: 100%;
}
.dl-control-icons { .dl-control-icons {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -1050,19 +1138,23 @@ button:hover {
} }
/* tablet */ /* tablet */
@media screen and (max-width: 1000px) { @media screen and (max-width: 1000px), screen and (max-height: 850px) {
.boxed-content { .boxed-content,
.boxed-content.boxed-4,
.boxed-content.boxed-5,
.boxed-content.boxed-6,
.boxed-content.boxed-7 {
width: 90%; width: 90%;
} }
.video-list.grid, .video-list.grid.grid-3,
.dl-list.grid, .video-list.grid.grid-4,
.video-list.grid.grid-5,
.video-list.grid.grid-6,
.video-list.grid.grid-7,
.channel-list.grid, .channel-list.grid,
.playlist-list.grid { .playlist-list.grid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.dl-thumb.list {
width: 35%;
}
.video-item.list, .video-item.list,
.playlist-item.list { .playlist-item.list {
display: grid; display: grid;
@@ -1084,6 +1176,9 @@ button:hover {
position: unset; position: unset;
transform: unset; transform: unset;
} }
.grid-count {
display: none;
}
.video-player { .video-player {
height: unset; height: unset;
padding: 20px 0 padding: 20px 0
@@ -1098,8 +1193,11 @@ button:hover {
* { * {
word-wrap: anywhere; word-wrap: anywhere;
} }
.video-list.grid, .video-list.grid.grid-3,
.dl-list.grid, .video-list.grid.grid-4,
.video-list.grid.grid-5,
.video-list.grid.grid-6,
.video-list.grid.grid-7,
.channel-list.grid, .channel-list.grid,
.video-item.list, .video-item.list,
.playlist-list.list, .playlist-list.list,
@@ -1139,6 +1237,10 @@ button:hover {
margin: 15px; margin: 15px;
text-align: center; text-align: center;
} }
.view-controls.three {
grid-template-columns: unset;
justify-content: center;
}
.sort { .sort {
display: block; display: block;
} }
@@ -1148,15 +1250,6 @@ button:hover {
.description-box { .description-box {
display: block; display: block;
} }
.dl-item {
display: block;
}
.dl-thumb {
width: 100%;
}
.dl-desc {
padding: 15px;
}
.backup-grid-row { .backup-grid-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -1,63 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) --> <!-- Generator: Adobe Illustrator 26.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
<svg id="svg1303" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" inkscape:version="0.92.4 (5da689c313, 2019-01-14)" sodipodi:docname="Icons_seen.svg"
xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 500 500"
xmlns:cc="http://creativecommons.org/ns#" style="enable-background:new 0 0 500 500;" xml:space="preserve">
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" <sodipodi:namedview bordercolor="#666666" borderopacity="1.0" id="base" inkscape:current-layer="layer1" inkscape:cx="84.208758" inkscape:cy="136.94831" inkscape:document-units="mm" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:window-height="1017" inkscape:window-maximized="1" inkscape:window-width="1920" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:zoom="1.0105705" pagecolor="#ffffff" showgrid="false" units="px">
xmlns:svg="http://www.w3.org/2000/svg" </sodipodi:namedview>
xmlns="http://www.w3.org/2000/svg" <g id="layer1" transform="translate(0,-164.70764)" inkscape:groupmode="layer" inkscape:label="Ebene 1">
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" <path id="path1091" inkscape:connector-curvature="0" d="M250,259.4c-105.2,0.1-201.5,60-250,155.4C48.5,510.1,144.9,569.9,250,570
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" c105.2-0.1,201.5-60,250-155.4C451.5,319.3,355.1,259.5,250,259.4z M230.7,287.8c-18.7,2.8-36.6,9.8-52.4,20.3
width="500" c2.8-0.5,5.7-0.7,8.6-0.7c27.8,0,50.4,22.6,50.4,50.4c0,0,0,0,0,0c0,27.8-22.6,50.4-50.4,50.4c0,0,0,0,0,0
height="500" c-27.8,0-50.4-22.6-50.4-50.4c0,0,0,0,0,0c0-1.2,0.1-2.4,0.2-3.6c-10,18.6-15.2,39.4-15.2,60.5c0,62.6,45,116,106.7,126.7
viewBox="0 0 132.29197 132.29167" c-78.3-7.6-147.6-55.2-183.9-126.6C81,342.6,151.3,294.7,230.7,287.8L230.7,287.8z M271.8,288c78.3,7.6,147.6,55.2,183.9,126.6
version="1.1" c-36.7,72.1-106.9,120-186.2,127c62.6-9.7,108.9-63.5,108.9-126.9C378.5,352.1,333.5,298.7,271.8,288L271.8,288z"/>
id="svg1303" </g>
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="Icons_seen.svg">
<defs
id="defs1297" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.0105705"
inkscape:cx="84.208758"
inkscape:cy="136.94831"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1" />
<metadata
id="metadata1300">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-164.70764)">
<path
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke"
d="M 66.145984,191.3255 A 71.797122,73.404487 0 0 0 2.5025987,230.88314 71.797122,73.404487 0 0 0 66.145984,270.38145 71.797122,73.404487 0 0 0 129.78937,230.82387 71.797122,73.404487 0 0 0 66.145984,191.3255 Z m -4.921549,7.22394 a 32.724755,32.724755 0 0 0 -13.334395,5.17759 12.828107,12.828107 0 0 1 2.180958,-0.18652 12.828107,12.828107 0 0 1 12.828141,12.82816 12.828107,12.828107 0 0 1 -12.828141,12.82822 12.828107,12.828107 0 0 1 -12.828192,-12.82822 12.828107,12.828107 0 0 1 0.04509,-0.91548 32.724755,32.724755 0 0 0 -3.86581,15.39964 32.724755,32.724755 0 0 0 27.161922,32.24981 A 59.09757,60.420619 0 0 1 13.7604,230.8774 59.09757,60.420619 0 0 1 61.224664,198.54948 Z m 10.482345,0.0563 a 59.09757,60.420619 0 0 1 46.82502,32.22523 59.09757,60.420619 0 0 1 -47.393377,32.32497 32.724755,32.724755 0 0 0 27.73169,-32.30187 32.724755,32.724755 0 0 0 -27.163333,-32.24833 z"
id="path1091"
inkscape:connector-curvature="0" />
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.5.0, 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 500 500" style="enable-background:new 0 0 500 500;" xml:space="preserve">
<path d="M499.9,159L499.9,159c0.1-1.7,0.1-3.4,0.1-5.2c0-69.5-58.6-129.7-130.9-129.7c-52.9,0-98.4,34-119,77.4h0
c-20.7-43.4-66.2-77.4-119-77.4C58.6,24.1,0,84.4,0,153.9c0,1.7,0.1,3.4,0.1,5.2h0c0,0-7.4,82.6,84.5,172.7
c41.8,41.9,88.5,81.6,165.4,144.1c76.9-62.5,123.6-102.3,165.4-144.1C507.2,241.6,499.9,159,499.9,159z"/>
</svg>

After

Width:  |  Height:  |  Size: 684 B

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="474.99609"
height="78.428696"
viewBox="0 0 125.67634 20.750926"
version="1.1"
id="svg1303"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"
sodipodi:docname="icon-add.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs1297" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.85859018"
inkscape:cx="-97.380081"
inkscape:cy="261.09215"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
showguides="true"
inkscape:guide-bbox="true"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<sodipodi:guide
position="-225.18364,87.524084"
orientation="1,0"
id="guide1072"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata1300">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-3.3077777,-220.4781)">
<path
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke"
d="m 12.291019,220.47796 c -4.9648228,-0.009 -8.9691711,3.98046 -8.9784054,8.94536 l -0.00482,2.62846 c -0.00925,4.9649 3.9804459,8.96933 8.9452674,8.97832 107.703889,0.29274 22.266017,0.0414 107.747629,0.19881 4.96484,0.009 8.96917,-3.98046 8.9784,-8.94534 l 0.005,-2.62847 c 0.009,-4.96489 -3.98037,-8.96923 -8.94525,-8.97831 -107.850822,-0.0255 -20.413204,-0.038 -107.747821,-0.19883 z"
id="rect1073"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,76 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) --> <!-- Generator: Adobe Illustrator 26.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
<svg id="svg1303" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" inkscape:version="0.92.4 (5da689c313, 2019-01-14)" sodipodi:docname="icon_thumb.svg"
xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 500 500"
xmlns:cc="http://creativecommons.org/ns#" style="enable-background:new 0 0 500 500;" xml:space="preserve">
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" <sodipodi:namedview bordercolor="#666666" borderopacity="1.0" id="base" inkscape:current-layer="layer1" inkscape:cx="35.718548" inkscape:cy="203.39339" inkscape:document-units="mm" inkscape:guide-bbox="true" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:window-height="1009" inkscape:window-maximized="1" inkscape:window-width="1920" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:zoom="1.5046797" pagecolor="#ffffff" showgrid="false" showguides="true" units="px">
xmlns:svg="http://www.w3.org/2000/svg" <sodipodi:guide id="guide816" inkscape:locked="false" orientation="0,1" position="80.99058,65.965029"></sodipodi:guide>
xmlns="http://www.w3.org/2000/svg" <sodipodi:guide id="guide818" inkscape:locked="false" orientation="1,0" position="65.965178,49.107299"></sodipodi:guide>
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" </sodipodi:namedview>
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" <g id="layer1" transform="translate(0,-164.70764)" inkscape:groupmode="layer" inkscape:label="Ebene 1">
width="500" <path id="rect1278" inkscape:connector-curvature="0" d="M285.3,164.7c-3.1-0.1-6.4,0.1-9.8,0.7c-73.7,12.2,24,119.6-61.8,180.9
height="500" c-71.7,59.8-97.8,41.1-98.5,60.7v192.6c10.8,28.5,82.3,57.6,158.4,63.8c76.1,6.2,182.5-9.9,183.6-43c0.6-30-16.5-30.7-16.5-30.7
viewBox="0 0 132.29197 132.29167" s37.5-2.3,38.7-34.8c0.6-31.9-36.3-36.4-36.3-36.4s47.3-1.3,47.3-34.2c0-33.6-36.4-36.3-47.6-36.8c7-0.4,34.5-10,34.4-43.2
version="1.1" c-0.2-34.3-52.8-31.5-147.3-37.7C340.8,302.9,348.4,166.8,285.3,164.7L285.3,164.7z M25.6,382.8c-8.9,0-16,6-16,13.4v217.5
id="svg1303" c0,7.4,7.1,13.4,16,13.4h59.8c8.9,0,16-6,16-13.4V396.2c0-7.4-7.1-13.4-16-13.4H25.6z"/>
inkscape:version="0.92.4 (5da689c313, 2019-01-14)" </g>
sodipodi:docname="icon_thumb.svg">
<defs
id="defs1297" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.5046797"
inkscape:cx="35.718548"
inkscape:cy="203.39339"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
showguides="true"
inkscape:guide-bbox="true">
<sodipodi:guide
position="80.99058,65.965029"
orientation="0,1"
id="guide816"
inkscape:locked="false" />
<sodipodi:guide
position="65.965178,49.107299"
orientation="1,0"
id="guide818"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata1300">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-164.70764)">
<path
style="opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers fill stroke"
d="m 72.35681,178.12175 c -0.545876,-0.0192 -1.121779,0.0192 -1.729242,0.12259 -12.959134,2.14891 4.226681,21.04642 -10.867262,31.83193 -12.617074,10.52505 -17.206737,7.22264 -17.332488,10.67172 v 33.88171 c 1.891308,5.00813 14.483922,10.12847 27.868234,11.21944 13.384309,1.09096 32.098988,-1.73801 32.305468,-7.56421 0.10989,-5.27572 -2.896223,-5.39662 -2.896223,-5.39662 0,0 6.589043,-0.40752 6.808883,-6.12278 0.10966,-5.60545 -6.38265,-6.40691 -6.38265,-6.40691 0,0 8.31589,-0.23607 8.31589,-6.01832 0,-5.91485 -6.39662,-6.38072 -8.37539,-6.48104 1.22776,-0.0747 6.07353,-1.75141 6.04505,-7.59582 -0.0287,-6.03384 -9.280896,-5.53597 -25.919173,-6.63279 1.919791,-11.19377 3.258461,-35.14851 -7.841097,-35.50894 z m -45.69253,38.36631 c -1.562226,0 -2.819733,1.04793 -2.819733,2.34979 v 38.25943 c 0,1.30191 1.257507,2.34983 2.819733,2.34983 h 10.526021 c 1.562231,0 2.819739,-1.04792 2.819739,-2.34983 v -38.25943 c 0,-1.30186 -1.257508,-2.34979 -2.819739,-2.34979 z"
id="rect1278"
inkscape:connector-curvature="0" />
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -3,104 +3,108 @@
* *
*/ */
checkMessages() 'use strict';
checkMessages();
// page map to notification status // page map to notification status
const messageTypes = { const messageTypes = {
"download": ["message:download", "message:add", "message:rescan", "message:playlistscan"], 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'],
"setting": ["message:setting"] setting: ['message:setting'],
} };
// start to look for messages // start to look for messages
function checkMessages() { function checkMessages() {
var notifications = document.getElementById("notifications"); let notifications = document.getElementById('notifications');
if (notifications) { if (notifications) {
var dataOrigin = notifications.getAttribute("data"); let dataOrigin = notifications.getAttribute('data');
getMessages(dataOrigin); getMessages(dataOrigin);
} }
} }
// get messages for page on timer // get messages for page on timer
function getMessages(dataOrigin) { function getMessages(dataOrigin) {
fetch('/progress/').then(response => { fetch('/progress/')
.then(response => {
return response.json(); return response.json();
}).then(responseData => { })
var messages = buildMessage(responseData, dataOrigin); .then(responseData => {
let messages = buildMessage(responseData, dataOrigin);
if (messages.length > 0) { if (messages.length > 0) {
// restart itself // restart itself
setTimeout(function() { setTimeout(function () {
getMessages(dataOrigin); getMessages(dataOrigin);
}, 3000); }, 3000);
}; }
}); });
} }
// make div for all messages, return relevant // make div for all messages, return relevant
function buildMessage(responseData, dataOrigin) { function buildMessage(responseData, dataOrigin) {
// filter relevan messages // filter relevan messages
var allMessages = responseData["messages"]; let allMessages = responseData['messages'];
var messages = allMessages.filter(function(value) { let messages = allMessages.filter(function (value) {
return messageTypes[dataOrigin].includes(value["status"]) return messageTypes[dataOrigin].includes(value['status']);
}, dataOrigin); }, dataOrigin);
// build divs // build divs
var notificationDiv = document.getElementById("notifications"); let notificationDiv = document.getElementById('notifications');
var nots = notificationDiv.childElementCount; let nots = notificationDiv.childElementCount;
notificationDiv.innerHTML = ""; notificationDiv.innerHTML = '';
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
var messageData = messages[i]; let messageData = messages[i];
var messageStatus = messageData["status"]; let messageStatus = messageData['status'];
var messageBox = document.createElement("div"); let messageBox = document.createElement('div');
var title = document.createElement("h3"); let title = document.createElement('h3');
title.innerHTML = messageData["title"]; title.innerHTML = messageData['title'];
var message = document.createElement("p"); let message = document.createElement('p');
message.innerHTML = messageData["message"]; message.innerHTML = messageData['message'];
messageBox.appendChild(title); messageBox.appendChild(title);
messageBox.appendChild(message); messageBox.appendChild(message);
messageBox.classList.add(messageData["level"], "notification"); messageBox.classList.add(messageData['level'], 'notification');
notificationDiv.appendChild(messageBox); notificationDiv.appendChild(messageBox);
if (messageStatus === "message:download") { if (messageStatus === 'message:download') {
checkDownloadIcons(); checkDownloadIcons();
}; }
}; }
// reload page when no more notifications // reload page when no more notifications
if (nots > 0 && messages.length === 0) { if (nots > 0 && messages.length === 0) {
location.reload(); location.reload();
}; }
return messages return messages;
} }
// check if download icons are needed // check if download icons are needed
function checkDownloadIcons() { function checkDownloadIcons() {
var iconBox = document.getElementById("downloadControl"); let iconBox = document.getElementById('downloadControl');
if (iconBox.childElementCount === 0) { if (iconBox.childElementCount === 0) {
var downloadIcons = buildDownloadIcons(); let downloadIcons = buildDownloadIcons();
iconBox.appendChild(downloadIcons); iconBox.appendChild(downloadIcons);
}; }
} }
// add dl control icons // add dl control icons
function buildDownloadIcons() { function buildDownloadIcons() {
var downloadIcons = document.createElement('div'); let downloadIcons = document.createElement('div');
downloadIcons.classList = 'dl-control-icons'; downloadIcons.classList = 'dl-control-icons';
// stop icon // stop icon
var stopIcon = document.createElement('img'); let stopIcon = document.createElement('img');
stopIcon.setAttribute('id', "stop-icon"); stopIcon.setAttribute('id', 'stop-icon');
stopIcon.setAttribute('title', "Stop Download Queue"); stopIcon.setAttribute('title', 'Stop Download Queue');
stopIcon.setAttribute('src', "/static/img/icon-stop.svg"); stopIcon.setAttribute('src', '/static/img/icon-stop.svg');
stopIcon.setAttribute('alt', "stop icon"); stopIcon.setAttribute('alt', 'stop icon');
stopIcon.setAttribute('onclick', 'stopQueue()'); stopIcon.setAttribute('onclick', 'stopQueue()');
// kill icon // kill icon
var killIcon = document.createElement('img'); let killIcon = document.createElement('img');
killIcon.setAttribute('id', "kill-icon"); killIcon.setAttribute('id', 'kill-icon');
killIcon.setAttribute('title', "Kill Download Queue"); killIcon.setAttribute('title', 'Kill Download Queue');
killIcon.setAttribute('src', "/static/img/icon-close.svg"); killIcon.setAttribute('src', '/static/img/icon-close.svg');
killIcon.setAttribute('alt', "kill icon"); killIcon.setAttribute('alt', 'kill icon');
killIcon.setAttribute('onclick', 'killQueue()'); killIcon.setAttribute('onclick', 'killQueue()');
// stich together // stich together
downloadIcons.appendChild(stopIcon); downloadIcons.appendChild(stopIcon);
downloadIcons.appendChild(killIcon); downloadIcons.appendChild(killIcon);
return downloadIcons return downloadIcons;
} }

File diff suppressed because it is too large Load Diff