Compare commits

...

293 Commits

Author SHA1 Message Date
Simon e0f1828d9c
Experimental Comment fix, #build
Changed:
- Changed to use custom build to fix comment extraction
- Changed ffmpeg installer using python build script
2024-04-26 09:10:08 +02:00
Simon f5a2e624d8
add unstable tag 2024-04-26 09:07:29 +02:00
Simon dc08c83da5
custom yt-dlp build 2024-04-26 09:07:07 +02:00
Simon 33ecd73137
ffmpeg download script, separate build step 2024-04-22 17:49:26 +02:00
Simon cb6476fa8c
add jellyfin and plex plugin links 2024-04-17 22:39:24 +02:00
Simon ec64a88d1e
update roadmap 2024-04-10 21:12:58 +02:00
Simon 0c487e6339
bump TA_VERSION 2024-04-10 18:40:55 +02:00
Simon f7ad1000c7
bump es version 2024-04-10 18:40:40 +02:00
Simon aecd189d04
bump requirements 2024-04-10 18:40:30 +02:00
Simon b735a770e3
Notification improvements, #build
Changed:
- Changed more robust channel title building
- Ensure 100 progress message is sent
- Remove mig_path at startup
- Fix comment extraction fail due to redirect
- Fix duplicate notification message threads
2024-04-05 15:11:24 +02:00
Simon 5c84a2cbf8
fix getMessages getting called multiple times in parallel 2024-04-05 15:03:44 +02:00
Simon a4d062fa52
fix comment extraction player_client for redirect workaround 2024-04-05 14:40:22 +02:00
Simon 9c34bb01d9
fix spelling 2024-04-03 21:04:26 +02:00
Simon 8c38a2eb69
clarify feature requests and contributing details 2024-04-03 21:02:33 +02:00
Simon 25edff28e7 Merge branch 'master' into testing 2024-04-03 16:52:24 +02:00
lamusmaser 731f4b6111
Add additional user scripts. (#682)
* Add additional user scripts.

* Add new repo.

* clarify license

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2024-04-03 21:52:06 +07:00
Simon e512329599
remove migpath call at startup, #687 2024-04-03 16:40:16 +02:00
Simon e26b039899
bump requirements 2024-04-03 16:39:24 +02:00
Simon 8bf7f71351
ensure 100 download progress is sent 2024-04-03 16:31:36 +02:00
Simon a72be27982
more robust channel title building 2024-03-15 12:01:09 +01:00
Simon b2c1b417e5
add unstable tag 2024-03-11 21:57:03 +01:00
Simon a348b4a810
Custom user created Playlists, #build
Changed:
- Added playlist create form
- Removed autoplay
- Disable progress less than 10s
- Better cookie jar error logs
- bump yt-dlp
2024-03-11 21:30:18 +01:00
Simon bb8db53f7d
bump yt-dlp 2024-03-11 18:18:59 +01:00
Simon 2711537a4d
clarify cookie import choices, #672 2024-03-11 18:12:56 +01:00
dot-mike 45f455070d
Fix rare edge case where comment author is None. (#676)
This happens mostly for older YT profiles that has not set-up a @-handle.
2024-03-11 23:56:18 +07:00
Simon 6dcef70b8e
skip empty subtitle, #663 2024-03-11 17:50:50 +01:00
Simon c993a5de5c
bump dependencies 2024-03-10 17:38:23 +01:00
Greg 090d88c336
Feature 590 custom playlist (#620)
* add remove custom playlist

* custom playlist page, move video controls

* align to existing code patterns

* cleanup

* resolve merge conflict

* cleanup

* cleanup

* polish

* polish

* some fixes for lint

* resolve merge conflict

* bugfix on delete video/playlist/channel - preserve custom playlist but
delete corresponding videos in custom playlist

* cleanup

* ./deploy.sh validate isort fix - validate runs clean now

* sync to latest master branch

* sync to master

* updates per admin guidance. sync to master

* attempt to resolve merge conflict

* attempt to resolve merge conflict - reintroduce changes to file.

* validate playlist_type

* validate playlist custom action

* move custom id creation to view

* stricter custom playlist matching

* revert unreachable playlist delete check

* undo unneeded playlist matching

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2024-03-10 22:57:59 +07:00
Nick 0e967d721f
log cookiejar.LoadError (#669) 2024-03-10 22:35:15 +07:00
Simon c32dbf8bc8 Merge branch 'master' into testing 2024-03-10 16:34:19 +01:00
dot-mike df08a6d591
Add in user script (#680)
* remove autoplay, disable video progress less than 10s

* Update readme.md. Add in user script. Format user scripts as bulleted list

* Revert "remove autoplay, disable video progress less than 10s"

This reverts commit 8778546577.

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2024-03-10 22:32:40 +07:00
DarkFighterLuke 9339b9227e
Add base URL setup in README.md User Scripts section (#664) 2024-03-10 22:11:10 +07:00
Simon 8778546577
remove autoplay, disable video progress less than 10s 2024-02-05 21:55:05 +01:00
Simon 0ff27ebfb9
fix black linting 2024-01-27 10:26:23 +07:00
Simon 0d863ef557
bump TA_VERSION 2024-01-27 10:18:47 +07:00
Simon 56ca49d0e2
bump ES 2024-01-27 10:18:27 +07:00
Simon 27b6efcab7
Redirect and celery memory usage workaround, #build
Changed:
- Limit life span of worker to avoid building up memory usage
- Validate video ID at index, raise error on redirect
- Clean up subtitles on channel delete
2024-01-15 12:06:43 +07:00
Simon 18ba808664
bump TA_VERSION unstable 2024-01-15 12:06:03 +07:00
Simon 65738ef52c
validate expected video ID with remote ID to avoid redirect 2024-01-15 11:34:11 +07:00
Simon 4049a2a3c1
bump requirements 2024-01-15 09:23:37 +07:00
PhuriousGeorge 49659322a1
Limit worker lifespan - RAM useage mitigation (#644)
Limit worker lifespan to save our precious RAM as discussed on [Discord](https://discord.com/channels/920056098122248193/1179480913701241002/1180026088802496512)

Mitigates #500 though RAM usage can still ramp rather high before worker is culled
2024-01-15 09:12:44 +07:00
Simon 4078eb307f Merge branch 'master' into testing 2024-01-15 09:04:01 +07:00
Daniel Jue 7f056b38f4
Update README.md (#647)
* Update README.md

Added a link to a simple helper script for prioritizing downloads

* Update README.md

@Krafting Fixed a mistake I made

Co-authored-by: Krafting <36538123+Krafting@users.noreply.github.com>

---------

Co-authored-by: Krafting <36538123+Krafting@users.noreply.github.com>
2024-01-15 09:00:33 +07:00
Simon 86fe31d258
cleanup subtitles after deleting channels 2023-12-25 11:40:09 +07:00
Simon 5b26433599 Merge branch 'master' into testing 2023-12-25 11:32:56 +07:00
Simon 4d2fc5423e
fix video reindex exist check 2023-12-22 12:31:55 +07:00
Simon 94295cdbd4
add type hints to ReleaseVersion 2023-12-22 10:41:10 +07:00
Simon b84bf78974
hotfix: clear faulty version check 2023-12-22 09:57:05 +07:00
Simon 14e23a4371
handle reindex item delete during task run 2023-12-21 13:22:05 +07:00
Simon fe8f4faa10
update TA_VERSION 2023-12-21 10:32:53 +07:00
Simon ddc0b7a481
Various yt-dlp fixes, #build
Changed:
- Fix for channel about page parsing
- Fix for like_count extraction
- Fix for comment author extraction
- Refactor RedisQueue use sorted set
- Fix chrome scaling issue
- Fix autodelete
2023-12-17 11:34:46 +07:00
Simon 7eec3ece49
lock yt-dlp at commit 2023-12-17 11:28:36 +07:00
Simon 789c35e2b5
refactor RedisQueue to use sorted set 2023-12-17 11:22:26 +07:00
Simon 8870782a6e
refactor, use decode_responses in base class 2023-12-16 17:39:09 +07:00
Simon e75ffb603c
fix auto delete lte datatype, #622 2023-12-16 13:13:48 +07:00
Simon feabc87c9f
fix chrome scaling issue, #616 2023-12-16 12:44:19 +07:00
Simon 6f1a45ffb1
downgrade redis 2023-12-16 10:18:40 +07:00
Simon 098db97cba
revert channel about parsing, #614 2023-12-12 14:00:56 +07:00
Simon 597da56975
fix comment_author_is_uploader extraction 2023-12-12 13:55:41 +07:00
Simon 325bdf5cba
add unstable tag, #build 2023-12-03 15:19:18 +07:00
Simon db2f249979
Channel about parser workaround, #build
Changed:
- Use /featured endpoint for channel parsing
- Improved version check cleanup process
- Handle version check random channel building errors
2023-12-03 14:50:50 +07:00
Simon b61b8635b8
bump celery 2023-12-03 14:49:59 +07:00
Simon 5aafc21bda
use featured path to extract channel metadata, #614 2023-12-03 14:48:56 +07:00
lamusmaser 099c70a13b
Add check to determine if `sub_value` is `rand-d`, always. (#612) 2023-12-01 09:23:58 +07:00
Simon 43708ee2a3
refac _has_update parser, use tpl comparison 2023-11-22 12:46:09 +07:00
Simon cfb15c1a78
handle version check comparison over any diff 2023-11-22 10:49:04 +07:00
Simon e9a95d7ada
bump TA_VERSION 2023-11-21 12:50:53 +07:00
Simon a21a111221
rename to Videos 2023-11-20 14:56:22 +07:00
Simon 18e504faf2
fix add missining playlist_entries mappings, #605 2023-11-20 14:02:49 +07:00
Simon 9ffe2098a5
add unstable version 2023-11-19 22:10:07 +07:00
Simon 1315e836a4
Improved dashboard, reindex fix, #build
Changed:
- Added additional sort by fields
- [API] Chaned primary stats endpoints
- [API] Added separate video stats endpoints
- Added fallback for some manual import values
- Fix comment extration for members video
- Fix reindex outdated query
2023-11-19 22:02:23 +07:00
Simon 2e4289e75c
bump requirements 2023-11-19 21:37:08 +07:00
Simon 96e73a3a53
handle empty tile response 2023-11-19 21:32:11 +07:00
Simon a369be0f4a
split active videos tile, add duration 2023-11-19 21:20:42 +07:00
Simon d5676e5173
[API] remove primary endpoint, in favor of dedicated stats 2023-11-19 20:30:50 +07:00
Simon 44c4cf93e2
refactor dashboard tile building 2023-11-19 20:27:18 +07:00
Simon 02ac590caa
[API] add download stats 2023-11-19 14:42:16 +07:00
Simon a466c02304
[API] add playlist stats 2023-11-19 14:00:27 +07:00
Simon e74c26fe36
[API] add channel aggs 2023-11-19 13:48:24 +07:00
Simon b1267cba83
standard json style 2023-11-19 13:06:47 +07:00
Simon 91bb0ed9c0
[API] add video aggregation 2023-11-19 13:01:27 +07:00
Simon 4a145ee7cb
paginate to get total active docs count 2023-11-18 17:44:16 +07:00
Simon 463019ce5a
fix outdated redinex now_lte datatype 2023-11-18 17:30:31 +07:00
Simon 9a9d35cac4
explicitly define player mapping, #592 2023-11-17 09:44:10 +07:00
Simon f41ecd24c5
fix missing config for comments extraction, #596 2023-11-17 09:26:31 +07:00
crocs eced8200c1
Update settings_scheduling.html (#601)
I found more!
2023-11-17 09:23:19 +07:00
Simon 669bc6a620
fallback for view_count, refac, #581 2023-11-17 09:22:11 +07:00
lamusmaser 37df9b65c7
Add `allowed_null_keys` and its dictionary for manual imports. (#595)
* Add `allowed_null_keys` and its dictionary for manual imports.

* Fix linting for `allowed_null_keys` list.

* Add missing trailing comma for linting.

* Add missing newline that wasn't in earlier linting responses.

* Clear empty text in newlines.

* Remove newline that the linter requested because the linter now doesn't want it. ¯\_(ツ)_/¯

* Change default application from manual import to the video processing.

* Fix missing space.
2023-11-17 09:16:09 +07:00
lamusmaser 6721d01fa6
Fix `textarea` type from `shell` to `Shell`. (#594) 2023-11-17 09:12:02 +07:00
crocs 2b49af9620
Update settings.html (#599)
This was really bugging me lol
2023-11-15 11:45:08 +07:00
Derek Slenk 2f62898a10
Add new css item for web footer (#598) 2023-11-15 11:44:43 +07:00
spechter 832259ce48
Expanded sorting functionality (#589)
* - Added duration and filesize as options in sorting menu on Home and ChannelId views
- Added keys 'duration' and 'filesize' as valid parameters to sort by
- Mapped 'duration' and 'filesize' to their corresponding es keys

* Fixed spelling

* Changed formatting to comply to maximum line length.

* Locally running "deploy.sh validate" before committing

---------

Co-authored-by: spechter <spechter@spechter.net>
2023-11-15 11:06:51 +07:00
Simon b8ccce250a
bump TA_VERSION 2023-11-10 10:34:04 +07:00
Simon aa04ecff4f
bump es 2023-11-10 10:33:53 +07:00
Simon dcf97d3d24
tweak color matrix color filter 2023-11-10 09:57:18 +07:00
crocs 879ad52b32
updated icons (#588)
* icon updates

* Update icon-star-half.svg
2023-11-10 09:40:17 +07:00
Simon 0bedc3ee93
fix empty watchDetail building 2023-11-09 11:55:28 +07:00
Simon 1657c55cbe
Aggregation daily stats improvements, #build
Changed:
- [API] make daily stats TZ aware
- [API] add daily download media size
2023-11-09 10:42:02 +07:00
Simon 8b1324139d
pass time_zone to daily aggs 2023-11-09 10:34:08 +07:00
Simon 04124e3dad
add daily size download 2023-11-09 10:22:43 +07:00
Simon 9c26357f76
User conf endpoints, fix channel parser, #build
Changed:
- [API] Added endpoints to CRUD user conf vars
- [API] Added backup endpoints
- Fix channel about page parsing
- Add custom CSS files
- Remember player volume
2023-11-09 09:40:54 +07:00
extome 7133d6b441
Better CSS support (#583)
* Remove banner hardcoding

* Refactor "colors" to "stylesheet"

* Remove logo hardcoding

* Remove stylesheet hardcoding

* Add very basic static CSS scanning and a new style

* Respect environment settings

* Check if selected stylesheet still exists

* New theme and title formatting

* Revert migration change

* Code linting

* More outlines for Matrix style

* Change wording in settings

* Forgot this wording

* Add suggested changes
2023-11-09 09:33:03 +07:00
Simon 6bc0111d0a
set and get playerVolume from localStorage 2023-11-09 09:31:19 +07:00
Simon 1188e66f37
fix channel about page parsing, #587 2023-11-08 23:20:13 +07:00
Simon ef6d3e868d
bump requirements 2023-11-08 23:09:55 +07:00
Simon d677f9579e
replace old process view, use user conf api 2023-11-01 22:49:33 +07:00
Simon 0b920e87ae
[API] add user config endpoints 2023-11-01 19:07:22 +07:00
Simon 4d5aa4ad2f
validate user config values 2023-11-01 17:25:22 +07:00
Simon 4b63c2f536
simplify return message 2023-11-01 14:33:30 +07:00
Simon 31ad9424f5
remove unused db_restore 2023-11-01 14:10:45 +07:00
Simon 45f4ccfd93
fix off by one in filesystem rescan progress 2023-11-01 14:07:56 +07:00
Simon 285e2042ae
[API] add backup endpoints 2023-11-01 14:05:11 +07:00
Simon e4b7f8ce38
update roadmap 2023-11-01 11:04:21 +07:00
Simon 6892cbbc19
Read only user roles, refac env var builder, #build
Changed:
- Added view only user role
- Fixed media download URL builder
- Changed environment settings builder away from redis
- Improved dashboard
2023-11-01 09:24:21 +07:00
Simon 58ea256b44
add unstable tag 2023-11-01 09:19:18 +07:00
Merlin aa475c58aa
Refac settings dashboard (#577)
* Add padding to duration str text

* Add singular and plural to video in dailyStat

* Add code spacing for readability

* Refac Main overview in dashboard to be spaced evenly and use tables

* Refac simplify number padding

* Refac skip adding spacing rows on mobile

* Refac reorder watch progress to be in order of interest

* Fix that ther can be 0 Videos added a day

* Refac capitalize content keys
2023-11-01 08:40:41 +07:00
Simon 8247314d01
refactor admin permisson classes 2023-10-31 15:50:33 +07:00
Simon 2826ca4a43
move ES_SNAPSHOT_DIR to EnvironmentSettings 2023-10-28 15:25:57 +07:00
Simon 64ffc18da7
add debug methods for EnvironmentSettings 2023-10-28 15:16:22 +07:00
Simon 21fde5e068
remove old migrations 2023-10-28 15:03:16 +07:00
Simon ea9ed6c238
fix linter 2023-10-28 10:30:21 +07:00
Simon 8eaed07cff
remove unused renamer 2023-10-28 10:29:10 +07:00
Clark 4d111aff82
Move the startup application settings to a new class (#571)
* Move the startup application settings to a new class

* Replace settings methods with static fields

* Move Redis and ES configuration to the settings class

* Fix environment python imports

* Update envcheck to use the new settings
2023-10-28 10:27:03 +07:00
Simon 7236bea29a
add error setting rlimit to common errors 2023-10-20 15:36:59 +07:00
Simon 5165c3e34a
bump requirements 2023-10-16 16:12:28 +07:00
Simon 572b23169c
finetune limited permission user 2023-10-15 14:56:54 +07:00
Steve Ovens e1fce06f97
View only user (#539)
* Remove repo docs in favor of hosted docs (#537)

* updated base, channel, video htmls to hide elements based on if user is staff or in the group 'admin'

* added the load auth_extras

* updated auth_extras

* updated views.py to block api calls from deleting files from unprivileged users; The Templates needed to be updated to support the various group checks related to removing buttons an unprivileged user should not see

* bumped the channel templates to remove conflict

* fix linting issues

* more linting

---------

Co-authored-by: Merlin <4706504+MerlinScheurer@users.noreply.github.com>
2023-10-15 13:58:06 +07:00
Simon 446d5b7949 Merge branch 'master' into testing 2023-10-15 12:03:08 +07:00
Simon 17c0310220
bump docker compose version, #569 2023-10-13 08:32:40 +07:00
Omar Laham 1b0be84972
Remove /media/ prefix from Download File URL in video.html (#567) 2023-10-13 08:20:42 +07:00
Simon 2df68fa83c Merge branch 'testing' to master 2023-10-07 16:22:31 +07:00
Simon 4184736bee
update version 2023-10-07 09:53:32 +07:00
Simon 81a5f15600
add health check example 2023-10-07 09:49:51 +07:00
Simon 4a4a274259
bump requirements 2023-10-07 09:05:34 +07:00
Simon 0776cea7bc
fix backup for empty index 2023-10-07 09:05:17 +07:00
Simon fb853e6c73
yt-dlp requirements from commit, take 2, #build 2023-10-03 22:04:22 +07:00
Simon 57d8b060d2
fix spelling 2023-10-03 21:03:31 +07:00
Simon 6d1810946b
yt-dlp requirements from commit, #build 2023-10-03 20:51:47 +07:00
Simon 88f230c3f4
build yt-dlp from commit 2023-10-03 20:49:57 +07:00
Simon e9eddf06fb
error handling for playlist sub 2023-10-03 19:57:12 +07:00
Simon 8af7a3caf4
better playlist extract error message 2023-10-02 09:09:58 +07:00
Simon ad7f1f05b0
update roadmap 2023-10-01 16:23:26 +07:00
Simon e1fe8d1e29
Merge pull request #561 from XavierChevalier/patch-1
docs: typo in readme
2023-10-01 09:45:28 +07:00
Simon f8f01ac27f
Fix continue watching progress matching, #build 2023-10-01 09:34:56 +07:00
Simon 8e79cba7d5
fix continue watching progress matching 2023-10-01 09:34:43 +07:00
Simon 87e457401d
fix duplicated startup migration 2023-10-01 09:34:23 +07:00
Simon bb271e276c
validate backup before creating blank index 2023-10-01 09:17:55 +07:00
Simon 9967015eba
Fix none existing key migration, #build 2023-10-01 08:47:29 +07:00
Simon 3b7e4c9266
skip id_sb_id migration 2023-10-01 08:36:09 +07:00
Xavier Chevalier 1dd3fb9341
docs: typo on readme 2023-09-27 13:37:36 +02:00
Simon 120f9e468d Generic setup, additional auth, #build
Changed:
- Better biggest channel stats
- Refactor and consolidate serializer
- Score to full text search
- Move user configuration to ES
- Mark unwatched for channels and playlists
- Agnosic ES setup
- Add healthcheck endpoint
- Split json backup file
- Support forward auth
2023-09-26 09:35:31 +07:00
Simon 88f5c58b8e
add unstable tag 2023-09-26 09:23:32 +07:00
Simon 6bd06f61cf
Merge pull request #552 from ajgon/feat/forward-auth
Support for forward auth
2023-09-26 09:04:34 +07:00
Igor Rzegocki 6a83756fb4
support for auth forwarding proxy 2023-09-25 22:50:39 +02:00
Simon 515b724047
split json backup files, #406 2023-09-25 15:59:31 +07:00
Simon 77fef5de57
fix standard duration str agg 2023-09-25 14:53:12 +07:00
Simon 9d09d27fba
bump requirements 2023-09-25 14:13:26 +07:00
Simon 0e767e2f84
Merge pull request #551 from ajgon/fix/elasticsearch-ha
Support elasticsearch clusters
2023-09-25 12:28:04 +07:00
Simon 7801ed0d60
fix find_results through SearchProcess 2023-09-25 10:14:14 +07:00
Igor Rzegocki 6abec9401b
Mute urlib3 ssl warnings when SSL verification is disabled 2023-09-23 15:00:46 +02:00
Simon 1cdb9e1ad5
refactor find_results use ElasticWrap directly 2023-09-22 23:54:31 +07:00
Simon 7afeb41469
use SearchProcess for single_lookup 2023-09-22 21:51:27 +07:00
Simon bae11fe1f1
fix appconfig init 2023-09-22 21:27:38 +07:00
Simon 0cacaee213
merge testing 2023-09-22 21:22:32 +07:00
Simon dcbd8d2a55
update ES_DISABLE_VERIFY_SSL readme 2023-09-22 20:42:26 +07:00
Simon 892e81c185
refactor ElasticWrap dynamic kwargs 2023-09-22 20:35:14 +07:00
Igor Rzegocki f423ddc53a
add healthcheck endpoint (#553) 2023-09-22 15:01:47 +07:00
Igor Rzegocki b2bb7ea28e
Support wider spectrum of ElasticSearch configurations 2023-09-22 09:50:47 +02:00
Simon 38b3815a33
catch disabled old settings 2023-09-21 23:17:25 +07:00
Simon 92975a5c95
disable ta_config indexing 2023-09-21 23:16:06 +07:00
Joseph Liu a5b61bfaf6
Add "Mark Unwatched" to channels and playlists (#547) 2023-09-21 22:40:42 +07:00
Clark 85b56300b3
Move user configuration from Redis to ES (#533)
* ES Client must bootstrap itself to be the source of config

If this is not done a cyclic loop is created between the config loader and the ES client.
This lays the ground work for ES being the source of all app config.

* auto_download is not used anymore

* Add UserConfig class that encapsulates user config storage

This class will allow the rest of the code to 'not care' about how user properties are stored.
This requires the addition of a ta_users index in ES.

* Create migration task for user config transfer

* Replace getters and setters for each property

Strongly type the user configuration
Migrate missed sponsorblock ID

* Other DB settings will be another PR
2023-09-21 21:46:55 +07:00
Kevin Gibbons 8fa9e23c6e
Document how to update in the readme (#531)
* Document how to update in the readme

* Update README.md
2023-09-06 20:55:19 +07:00
Simon a7fc7902f0
refactor single video page to use SearchProcess 2023-09-04 21:43:09 +07:00
Simon 879f5ab52f
refactor match_progress to use SearchProcess 2023-09-04 21:05:29 +07:00
Simon c6458c6ec1
add score to full text search 2023-09-04 20:44:31 +07:00
Simon 47c433e7c1
refactor search form to use new SearchProcess 2023-09-04 20:22:00 +07:00
Simon dc41e5062d
refactor duration class into separate helper functions 2023-09-04 18:49:10 +07:00
Merlin 317942b7e1
Refac biggest channels to be 3 seperate tables with ordering and right align (#536)
* Add right align to numbers on biggest channels in dashboard

* Refac biggest channels to be 3 seperate tables with ordering

* Fix aggs linting
2023-09-04 17:51:06 +07:00
Merlin 65d768bf02
Fix url to settings documentation (#535) 2023-09-04 17:24:48 +07:00
Merlin 0767bbfecf
Remove repo docs in favor of hosted docs (#537) 2023-09-04 16:59:36 +07:00
Simon 78d6699c68
update roadmap 2023-09-03 13:26:20 +07:00
Simon a807d53ff8
update TA_VERSION 2023-09-03 12:05:51 +07:00
Simon fa45cf08ba
fix mobile table layout 2023-09-02 17:37:28 +07:00
Simon c3da3e23af
Split settings pages, new dashbord, RC, #build
Changed:
- Changed split settings page
- Changed reset autostart on queue stop
- Added stats dashbord
- Fixed for wrong date epoch indexing
2023-09-02 16:54:59 +07:00
Simon 5cf5b4edb7
clean log 2023-09-02 16:42:41 +07:00
Simon 0c9c88fc0b
buildStats async 2023-09-02 16:31:43 +07:00
Simon 725bba0963
add randint sleep before version check 2023-09-02 16:02:04 +07:00
Simon 76981635dc
reset autostart on queue stop, #520 2023-09-02 12:06:43 +07:00
Simon b56316932f
fix kibana yml 2023-09-02 11:52:00 +07:00
Simon 8dba2e240e
handle empty download hist stat 2023-09-02 11:47:07 +07:00
Simon 4016e81f9a
add download history stats 2023-09-02 11:22:03 +07:00
Simon 5ee37eb0cb
add biggest chanel aggs 2023-09-01 09:28:56 +07:00
Simon 4650963cc7
add watch progress tiles 2023-08-31 22:28:36 +07:00
Simon 5acc1ea718
add all duration aggs 2023-08-31 21:31:47 +07:00
Simon 505f5b73c5
add primary aggs 2023-08-31 14:34:08 +07:00
Simon d491b0b347
[API] better primary stats 2023-08-31 12:51:00 +07:00
Simon 52d6c59f3f
fix incomplete messages outside of sendprogress task method 2023-08-31 12:16:42 +07:00
Simon 4afb605736
show all messages on settings pages 2023-08-31 12:16:17 +07:00
Clark fcc1c2a648
Split the settings page (#528)
* Split the settings page

* Add a dashboard page for future use

Create a settings base to keep all the settings pages consistent

* Correct Python formatting

* Fix snapshots not showing in new view
2023-08-31 11:11:31 +07:00
Simon 4ded8988c3
add channel aggregation 2023-08-30 18:42:03 +07:00
Simon 988c2b8af7
add download video histogram stats 2023-08-30 17:42:10 +07:00
Simon 58ef8f753f
fix date epoch sec format 2023-08-30 11:54:02 +07:00
Simon 3e9f1a392a
[API] add index count and video view progress stats views 2023-08-29 15:45:55 +07:00
Simon 2563722f16
Channel Search API endpoint, #build
Changed:
- [API] Added channel search endpoint
- Added fullscreen hotkey
2023-08-26 21:17:54 +07:00
Simon fb089dd3de
add unstable footer 2023-08-26 21:17:32 +07:00
dmynerd78 983612f460
Add fullscreen hotkey to video player (#524)
* Add fullscreen hotkey

* Run prettier formatting
2023-08-26 21:15:36 +07:00
Simon d42bd612d0
handle 404 video sponsorblock, #526 2023-08-26 21:05:17 +07:00
Simon 41f6a03751
fix typing 2023-08-25 15:00:03 +07:00
Simon f1e25c9a20
[API] add channel search endpoint 2023-08-24 22:46:35 +07:00
Simon 15794ebfc8
API extensions, #build
Changed:
- [API] Added endpoints for subscription toggle
- [API] Added endpoint for playlist delete
- Trigger bgsave when storing redis config
- Validate subscribe url Type, surface errors
- ignore eaDir folder
2023-08-24 00:06:54 +07:00
Simon 68928f5cb1
ignore eaDir folder, use ignore_filelist for clear_dl_cache, #523 2023-08-24 00:02:58 +07:00
Simon a514dda1ff
[API] implement delete playlist endpoint 2023-08-23 23:54:36 +07:00
Simon 2bccb698e6
remove legacy process subscribe methods 2023-08-23 23:34:19 +07:00
Simon 076452c612
move subscribe buttons to API calls 2023-08-23 23:28:09 +07:00
Simon b005b7bcfe
[API] validate sub type, add sub/unsub channel and playlist 2023-08-23 22:28:09 +07:00
Simon a2eb42ebb9
validate subscribe url type, #299 2023-08-23 21:22:09 +07:00
Simon 33ff586af4
bump requirements 2023-08-23 21:18:20 +07:00
Simon 3803537739
trigger bgsave on importent redis set_message 2023-08-23 18:35:20 +07:00
Simon 6151da821f Merge branch 'master' into testing 2023-08-23 18:02:58 +07:00
Simon 8f7f5e0e32
add Jellyfin Integration to resources 2023-08-22 21:27:35 +07:00
Simon fa140a3047
add more notable mentions 2023-08-22 21:16:42 +07:00
Simon 419b6f02a5
add user script links 2023-08-22 20:54:38 +07:00
Simon 58818bb91c
better thumbnail final fall back handling 2023-08-22 18:12:21 +07:00
Simon b6ae225342
add Contributions beyond the scope 2023-08-11 10:39:36 +07:00
Simon 8411889db7
add type hints to filesystem scanner 2023-08-09 09:45:51 +07:00
Simon 313bbe8b49
fix wrong resolve doc string 2023-08-06 10:26:47 +07:00
Simon 691c643745
clarify know limitation 2023-08-05 12:44:01 +07:00
Simon 9e8e929bcc
update roadmap 2023-08-05 12:28:40 +07:00
Simon 2238565a94
bump TA_VERSION 2023-08-05 11:44:52 +07:00
Simon 39e9704436
bump es version 2023-08-05 10:00:34 +07:00
Simon fa43753614
better error handling in comment extraction 2023-08-05 09:53:21 +07:00
Simon 02be39b6ed
hide/reveal apprise links 2023-08-01 00:30:11 +07:00
Simon 375e1a2100
set initial comment toggle icon 2023-07-31 23:57:03 +07:00
Simon e893dc3b24
clean up workflow 2023-07-31 23:53:06 +07:00
Simon c1ea77434e Merge branch 'master' into testing 2023-07-31 23:52:13 +07:00
crocs 0e1e544fee
update imagery (#507)
* Update script.js

* Update README.md

updated banner photo

* Add files via upload

* Delete tube-archivist-banner.jpg

* Delete tube-archivist-banner.jpg.jpg

* Add files via upload

* Delete tube-archivist-banner.jpg

* Add files via upload

* Delete tube-archivist-banner.jpg

* Add files via upload

* Update README.md

* Delete tube-archivist-screenshot-channels.png

* Delete tube-archivist-screenshot-download.png

* Delete tube-archivist-screenshot-home.png

* Delete tube-archivist-screenshot-single-channel.png

* Delete tube-archivist-screenshot-video.png

* Add files via upload

* Update SHOWCASE.MD

* Delete tube-archivist-screenshot-channels.png

* Delete tube-archivist-screenshot-download.png

* Delete tube-archivist-screenshot-single-channel.png

* Delete tube-archivist-screenshot-video.png

* Add files via upload

* Update SHOWCASE.MD

* Add files via upload

* Update SHOWCASE.MD

* Add files via upload

* Delete tube-archivist-search.png

* Add files via upload

* Update SHOWCASE.MD

* Update SHOWCASE.MD
2023-07-31 23:48:46 +07:00
Simon a13cd2f7ba
catch timeout in es connection check, part 2 2023-07-31 19:59:16 +07:00
Simon befdc64772
catch timeout in es connection check 2023-07-31 09:08:54 +07:00
Simon 06f3055913
ignore venv for blackhole deployment 2023-07-30 23:22:06 +07:00
Simon ca2c5b8dfc
Add Apprise, RC, #build
Changed:

- Added hooks for Apprise notifications
- additional error handling for channel migration
- [API] standard date output to ISO
2023-07-30 12:58:46 +07:00
Simon c395a949cc
add localhost and tubearchivist to default trusted 2023-07-30 00:13:15 +07:00
Simon 4473e9c5b2
add apprise notifications 2023-07-29 23:41:54 +07:00
Simon 75a63c4828
paginate channel migration index update 2023-07-29 17:05:01 +07:00
Simon aea403a874
additional thumb dl error handling 2023-07-28 18:19:22 +07:00
Simon ab8fed14bb
handle edge cases in migration clean up 2023-07-28 17:56:28 +07:00
Simon 6f915a5733
fix channel tv art extraction 2023-07-28 16:34:39 +07:00
Simon f970ec867e
clarify subtitle language codes on settings page, #505 2023-07-28 16:10:52 +07:00
Simon ef0d490890
[API] change dates to ISO format 2023-07-28 15:56:59 +07:00
lamusmaser 865089d46d
Resolve #502 by making the extension lowercase before comparison. (#504)
* Resolve #502 with making the extension lowercase.
Add note to `EXT_MAP` reference.

* Fixed comment for linting, added more details.
2023-07-27 21:01:53 +07:00
Simon cd25eadd1c
Filesystem organization, #build
Changed:
- Channged filesystem to static ids
- Improved error handling for download process
- Lots of fixes and improvements
2023-07-25 00:08:59 +07:00
Simon d500fa5eeb
add unstable footer 2023-07-25 00:07:11 +07:00
Simon 4c681d813d
fix lint 2023-07-25 00:05:43 +07:00
Simon ddfab4a341
update packages 2023-07-25 00:04:18 +07:00
Simon 434aa97a86
static cache file path building, #498 2023-07-24 23:44:27 +07:00
Simon efde4b1142
skip subtitle if not processed yet, #496 2023-07-24 12:11:21 +07:00
Simon 6022bdd3cd
fix doc string 2023-07-24 11:27:19 +07:00
Simon 99baf64b11
update requirements 2023-07-24 10:51:37 +07:00
Simon 61b04ba5cf
channel migration take 2 2023-07-24 10:51:13 +07:00
Simon 2a60360f4a
handle empty channel migration cleanup 2023-06-28 20:07:40 +07:00
Simon 8a7cb8bc6f
bump redis 2023-06-28 20:07:17 +07:00
lamusmaser 1be80b24c2
Implement #490 - Add version API and add local_version function. (#491)
* Add version API and add local_version function.

* Minor adjustments for linting.

* Add missing newlines for linter.

* Add missing comma to `urls.py`.

* Remove `version/` endpoint.

* Remove the `VersionView`.

* Prepare `PingView` for removal of the `is_static` response.

* Remove the `is_unstable` response from `ReleaseVersion`.

* Readd missing class instantiation for first call of `ReleaseVersion`.
2023-06-28 10:50:28 +07:00
Simon 061c653bce
retry get config better startup error handling, #485 2023-06-23 00:15:07 +07:00
Simon 72a98b0473
handle missing channel_tvart_url in thumb validator, #479 2023-06-22 23:36:54 +07:00
Simon 88e199ef9c
reset reindex counter on new added to queue, #478 2023-06-22 23:29:05 +07:00
Simon 879497d25a
bump libs 2023-06-22 23:28:17 +07:00
Simon 3f1d8cf75d
add .venv 2023-06-22 23:28:06 +07:00
Simon 32721cf7ed
bump base python version 2023-06-22 23:27:48 +07:00
Simon 103409770d
temporary fix for is_favorited extraction error 2023-06-22 23:27:16 +07:00
Simon 094ccf4186
bump libs 2023-06-16 15:48:02 +07:00
Simon 247808563a
download error recovering 2023-06-16 15:47:38 +07:00
simon 5927ced485
bump libs 2023-05-27 18:26:34 +07:00
simon 6fb788b259
add delete button for unavailable video 2023-05-22 17:34:49 +07:00
simon 5e92d06f21
fix dl error retry logic, store and return error, #477 2023-05-20 21:25:50 +07:00
simon 7082718c14
add days to seconds string converter 2023-05-20 20:08:36 +07:00
simon 7e2cd6acd3
fix linter, take 2 2023-05-20 19:41:33 +07:00
simon 904d0de6aa
fix linter 2023-05-20 19:37:41 +07:00
simon 868247e6d4
add startup folder migration command 2023-05-20 19:30:40 +07:00
simon c4e2332b83
fix startup race condition cluster health 2023-05-20 19:29:32 +07:00
simon 139d20560f
remove unused channel folder refresh 2023-05-20 16:30:19 +07:00
simon 66a14cf389
remove unused clean_string 2023-05-20 16:08:43 +07:00
simon 9b30c7df6e
refacter filesystem scanner 2023-05-20 16:07:33 +07:00
simon 5334d79d0d
default query 2023-05-20 15:38:55 +07:00
simon 64984bc1b3
fix chown for mig new folder 2023-05-19 14:49:49 +07:00
simon 8ef59f5bff
delete channel path building 2023-05-18 20:32:58 +07:00
simon 9d6ab6b7b3
implement new media_url 2023-05-18 20:32:37 +07:00
simon d62b0d3f8d
implement simplified path migration 2023-05-18 17:42:15 +07:00
simon 918a04c502
allow empty data for paginate 2023-05-18 17:01:07 +07:00
simon 60f1809ed8
fix rescan without task 2023-05-17 23:24:47 +07:00
simon f848e73251
HOTFIX: default bitrate, #415 2023-05-14 08:01:15 +07:00
simon c65fbb0b60
add filesystem rescan message 2023-05-14 07:58:10 +07:00
simon 95f114d817
remove leftover print statement 2023-05-14 07:52:51 +07:00
simon 05eac1a8ca
hotfix: skip filesystem rescan 2023-05-13 23:24:35 +07:00
132 changed files with 5243 additions and 3968 deletions

View File

@ -17,8 +17,5 @@ venv/
# Unneeded graphics
assets/*
# Unneeded docs
docs/*
# for local testing only
testing.sh

View File

@ -38,6 +38,6 @@ body:
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
render: Shell
validations:
required: true

View File

@ -8,20 +8,7 @@ jobs:
- 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 bandit black codespell flake8 flake8-bugbear
flake8-comprehensions isort requests
- run: ./deploy.sh validate
# - run: black --check --diff --line-length 79 .
# - run: codespell --skip="./.git,./package.json,./package-lock.json,./node_modules"
# - run: flake8 . --count --max-complexity=10 --max-line-length=79
# --show-source --statistics
# - run: isort --check-only --line-length 79 --profile black .
# - run: pip install -r tubearchivist/requirements.txt
# - run: mkdir --parents --verbose .mypy_cache
# - run: mypy --ignore-missing-imports --install-types --non-interactive .
# - run: python3 tubearchivist/manage.py test || true
# - run: shopt -s globstar && pyupgrade --py36-plus **/*.py || true
# - run: safety check

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
# python testing cache
__pycache__
.venv
# django testing db
db.sqlite3

View File

@ -8,6 +8,8 @@ Welcome, and thanks for showing interest in improving Tube Archivist!
- [Feature Request](#feature-request)
- [Installation Help](#installation-help)
- [How to make a Pull Request](#how-to-make-a-pull-request)
- [Contributions beyond the scope](#contributions-beyond-the-scope)
- [User Scripts](#user-scripts)
- [Improve to the Documentation](#improve-to-the-documentation)
- [Development Environment](#development-environment)
---
@ -35,12 +37,12 @@ Please keep in mind:
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
### Feature Request
This project needs your help to grow further. There is no shortage of ideas, see the open [issues on GH](https://github.com/tubearchivist/tubearchivist/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) and the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), what this project lacks is contributors to implement these ideas.
This project needs your help to grow further. There is no shortage of ideas, see the open [issues on GH](https://github.com/tubearchivist/tubearchivist/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) and the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), what this project lacks is contributors interested in helping with overall improvements of the application. Focus is *not* on adding new features, but improving existing ones.
Existing ideas are easily *multiple years* worth of development effort, at least at current speed. Best and fastest way to implement your feature is to do it yourself, that's why this project is open source after all. This project is *very* selective with accepting new feature requests at this point.
Existing ideas are easily *multiple years* worth of development effort, at least at current speed. This project is *very* selective with accepting new feature requests at this point.
Good feature requests usually fall into one or more of these categories:
- You want to work on your own idea within the next few days or weeks.
- You want to work on your own small scoped idea within the next few days or weeks.
- Your idea is beneficial for a wide range of users, not just for you.
- Your idea extends the current project by building on and improving existing functionality.
- Your idea is quick and easy to implement, for an experienced as well as for a first time contributor.
@ -64,7 +66,9 @@ IMPORTANT: When receiving help, contribute back to the community by improving th
## How to make a Pull Request
Thank you for contributing and helping improve this project. This is a quick checklist to help streamline the process:
Thank you for contributing and helping improve this project. Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application.
This is a quick checklist to help streamline the process:
- For **code changes**, make your PR against the [testing branch](https://github.com/tubearchivist/tubearchivist/tree/testing). That's where all active development happens. This simplifies the later merging into *master*, minimizes any conflicts and usually allows for easy and convenient *fast-forward* merging.
- For **documentation changes**, make your PR directly against the *master* branch.
@ -87,6 +91,33 @@ to validate your changes. If you omit the path, all the project files will get c
---
## Contributions beyond the scope
As you have read the [FAQ](https://docs.tubearchivist.com/faq/) and the [known limitations](https://github.com/tubearchivist/tubearchivist#known-limitations) and have gotten an idea what this project tries to do, there will be some obvious shortcomings that stand out, that have been explicitly excluded from the scope of this project, at least for the time being.
Extending the scope of this project will only be feasible with more [regular contributors](https://github.com/tubearchivist/tubearchivist/graphs/contributors) that are willing to help improve this project in the long run. Contributors that have an overall improvement of the project in mind and not just about implementing this *one* thing.
Small minor additions, or making a PR for a documented feature request or bug, even if that was and will be your only contribution to this project, are always welcome and is *not* what this is about.
Beyond that, general rules to consider:
- Maintainability is key: It's not just about implementing something and being done with it, it's about maintaining it, fixing bugs as they occur, improving on it and supporting it in the long run.
- Others can do it better: Some problems have been solved by very talented developers. These things don't need to be reinvented again here in this project.
- Develop for the 80%: New features and additions *should* be beneficial for 80% of the users. If you are trying to solve your own problem that only applies to you, maybe that would be better to do in your own fork or if possible by a standalone implementation using the API.
- If all of that sounds too strict for you, as stated above, start becoming a regular contributor to this project.
---
## User Scripts
Some of you might have created useful scripts or API integrations around this project. Sharing is caring! Please add a link to your script to the Readme [here](https://github.com/tubearchivist/tubearchivist#user-scripts).
- Your repo should have a `LICENSE` file with one of the common open source licenses. People are expected to fork, adapt and build upon your great work.
- Your script should not modify the *official* files of Tube Archivist. E.g. your symlink script should build links *outside* of your `/youtube` folder. Or your fancy script that creates a beautiful artwork gallery should do that *outside* of the `/cache` folder. Modifying the *official* files and folders of TA are probably not supported.
- On the top of the repo you should have a mention and a link back to the Tube Archivist repo. Clearly state to **not** to open any issues on the main TA repo regarding your script.
- Example template:
- `[<user>/<repo>](https://linktoyourrepo.com)`: A short one line description.
---
## Improve to the Documentation
The documentation available at [docs.tubearchivist.com](https://docs.tubearchivist.com/) and is build from a separate repo [tubearchivist/docs](https://github.com/tubearchivist/docs). The Readme has additional instructions on how to make changes.
@ -136,14 +167,15 @@ 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"
services:
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.

View File

@ -1,20 +1,25 @@
# multi stage to build tube archivist
# first stage to build python wheel, copy into final image
# build python wheel, download and extract ffmpeg, copy into final image
# First stage to build python wheel
FROM python:3.10.9-slim-bullseye AS builder
FROM python:3.11.3-slim-bullseye AS builder
ARG TARGETPLATFORM
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
# install requirements
COPY ./tubearchivist/requirements.txt /requirements.txt
RUN pip install --user -r requirements.txt
# build ffmpeg
FROM python:3.11.3-slim-bullseye as ffmpeg-builder
COPY docker_assets/ffmpeg_download.py ffmpeg_download.py
RUN python ffmpeg_download.py $TARGETPLATFORM
# build final image
FROM python:3.10.9-slim-bullseye as tubearchivist
FROM python:3.11.3-slim-bullseye as tubearchivist
ARG TARGETPLATFORM
ARG INSTALL_DEBUG
@ -25,30 +30,15 @@ ENV PYTHONUNBUFFERED 1
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# copy ffmpeg
COPY --from=ffmpeg-builder ./ffmpeg/ffmpeg /usr/bin/ffmpeg
COPY --from=ffmpeg-builder ./ffprobe/ffprobe /usr/bin/ffprobe
# install distro packages needed
RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recommends \
nginx \
atomicparsley \
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
curl && rm -rf /var/lib/apt/lists/*
# install debug tools for testing environment
RUN if [ "$INSTALL_DEBUG" ] ; then \

View File

@ -1,6 +1,6 @@
![Tube Archivist](assets/tube-archivist-banner.jpg?raw=true "Tube Archivist Banner")
![Tube Archivist](assets/tube-archivist-front.jpg?raw=true "Tube Archivist Banner")
[*more screenshots and video*](SHOWCASE.MD)
<h1 align="center">Your self hosted YouTube media server</h1>
<div align="center">
<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="190"/></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="190"/></a>
@ -8,9 +8,6 @@
<a href="https://www.tubearchivist.com/discord" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-discord.png" alt="tubearchivist-discord" title="TA Discord Server Members" height="50" width="190"/></a>
</div>
![home screenshot](assets/tube-archivist-screenshot-home.png?raw=true "Tube Archivist Home")
[*more screenshots and video*](SHOWCASE.MD)
## Table of contents:
* [Docs](https://docs.tubearchivist.com/) with [FAQ](https://docs.tubearchivist.com/faq/), and API documentation
* [Core functionality](#core-functionality)
@ -37,7 +34,8 @@ Once your YouTube video collection grows, it becomes hard to search and find a s
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
- [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.
- [Jellyfin Plugin](https://github.com/tubearchivist/tubearchivist-jf-plugin): Add your videos to Jellyfin
- [Plex Plugin](https://github.com/tubearchivist/tubearchivist-plex): Add your videos to Plex
## Installing
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. Minimal with dual core with 4 threads, better quad core plus.
@ -60,7 +58,12 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
| TZ | Set your timezone for the scheduler | Required |
| TA_PORT | Overwrite Nginx port | Optional |
| TA_UWSGI_PORT | Overwrite container internal uwsgi port | Optional |
| TA_ENABLE_AUTH_PROXY | Enables support for forwarding auth in reverse proxies | [Read more](https://docs.tubearchivist.com/configuration/forward-auth/) |
| TA_AUTH_PROXY_USERNAME_HEADER | Header containing username to log in | Optional |
| TA_AUTH_PROXY_LOGOUT_URL | Logout URL for forwarded auth | Optional |
| ES_URL | URL That ElasticSearch runs on | Optional |
| ES_DISABLE_VERIFY_SSL | Disable ElasticSearch SSL certificate verification | Optional |
| ES_SNAPSHOT_DIR | Custom path where elastic search stores snapshots for master/data nodes | Optional |
| HOST_GID | Allow TA to own the video files instead of container user | Optional |
| HOST_UID | Allow TA to own the video files instead of container user | Optional |
| ELASTIC_USER | Change the default ElasticSearch user | Optional |
@ -79,7 +82,7 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
## Update
Always use the *latest* (the default) or a named semantic version tag for the docker images. The *unstable* tags are only for your testing environment, there might not be an update path for these testing builds.
You will see the current version number of **Tube Archivist** in the footer of the interface. There is a daily version check task querying tubearchivist.com, notifying you of any new releases in the footer. To take advantage of the latest fixes and improvements, make sure you are running the *latest and greatest*. After updating, check the footer to verify you are running the expected version.
You will see the current version number of **Tube Archivist** in the footer of the interface. There is a daily version check task querying tubearchivist.com, notifying you of any new releases in the footer. To update, you need to update the docker images, the method for which will depend on your platform. For example, if you're using `docker-compose`, run `docker-compose pull` and then restart with `docker-compose up -d`. After updating, check the footer to verify you are running the expected version.
- This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported and you might have to reset your index and configurations to update. Ideally apply new updates at least once per month.
- 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.
@ -132,28 +135,36 @@ The Elasticsearch index will turn to ***read only*** if the disk usage of the co
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.
## `error setting rlimit`
If you are seeing errors like `failed to create shim: OCI runtime create failed` and `error during container init: error setting rlimits`, this means docker can't set these limits, usually because they are set at another place or are incompatible because of other reasons. Solution is to remove the `ulimits` key from the ES container in your docker compose and start again.
This can happen if you have nested virtualizations, e.g. LXC running Docker in Proxmox.
## 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.
- 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.
- There is currently no flexibility in naming of the media files.
- There is no flexibility in naming of the media files.
## Roadmap
We have come far, nonetheless we are not short of ideas on how to improve and extend this project. Issues waiting for you to be tackled in no particular order:
- [ ] User roles
- [ ] Audio download
- [ ] Podcast mode to serve channel as mp3
- [ ] 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))
- [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
- [ ] 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))
- [ ] Multi language support
- [ ] Show total video downloaded vs total videos available in channel
- [ ] Add statistics of index
- [ ] Download speed schedule ([#198](https://github.com/tubearchivist/tubearchivist/issues/198))
- [ ] Download or 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))
- [ ] Search comments
- [ ] Search download queue
- [ ] Configure shorts, streams and video sizes per channel
Implemented:
- [X] User created playlists [2024-04-10]
- [X] Add statistics of index [2023-09-03]
- [X] Implement [Apprise](https://github.com/caronc/apprise) for notifications [2023-08-05]
- [X] Download video comments [2022-11-30]
- [X] Show similar videos on video page [2022-11-30]
- [X] Implement complete offline media file import from json file [2022-08-20]
@ -177,6 +188,19 @@ Implemented:
- [X] Backup and restore [2021-09-22]
- [X] Scan your file system to index already downloaded videos [2021-09-14]
## User Scripts
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. Make sure to check the respective repository links for detailed license information.
This is your time to shine, [read this](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#user-scripts) then open a PR to add your script here.
- [danieljue/ta_dl_page_script](https://github.com/danieljue/ta_dl_page_script): Helper browser script to prioritize a channels' videos in download queue.
- [dot-mike/ta-scripts](https://github.com/dot-mike/ta-scripts): A collection of personal scripts for managing TubeArchivist.
- [DarkFighterLuke/ta_base_url_nginx](https://gist.github.com/DarkFighterLuke/4561b6bfbf83720493dc59171c58ac36): Set base URL with Nginx when you can't use subdomains.
- [lamusmaser/ta_migration_helper](https://github.com/lamusmaser/ta_migration_helper): Advanced helper script for migration issues to TubeArchivist v0.4.4 or later.
- [lamusmaser/create_info_json](https://gist.github.com/lamusmaser/837fb58f73ea0cad784a33497932e0dd): Script to generate `.info.json` files using `ffmpeg` collecting information from downloaded videos.
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles.
## Donate
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:
@ -187,6 +211,8 @@ Second best way to support the development is to provide for caffeinated beverag
## Notable mentions
This is a selection of places where this project has been featured on reddit, in the news, blogs or any other online media, newest on top.
* **ycombinator**: Tube Archivist on Hackernews front page, [2023-07-16][[link](https://news.ycombinator.com/item?id=36744395)]
* **linux-community.de**: Tube Archivist bringt Ordnung in die Youtube-Sammlung, [German][2023-05-01][[link](https://www.linux-community.de/ausgaben/linuxuser/2023/05/tube-archivist-bringt-ordnung-in-die-youtube-sammlung/)]
* **noted.lol**: Dev Debrief, An Interview With the Developer of Tube Archivist, [2023-03-30] [[link](https://noted.lol/dev-debrief-tube-archivist/)]
* **console.substack.com**: Interview With Simon of Tube Archivist, [2023-01-29] [[link](https://console.substack.com/p/console-142#%C2%A7interview-with-simon-of-tube-archivist)]
* **reddit.com**: Tube Archivist v0.3.0 - Now Archiving Comments, [2022-12-02] [[link](https://www.reddit.com/r/selfhosted/comments/zaonzp/tube_archivist_v030_now_archiving_comments/)]

View File

@ -3,17 +3,23 @@
Video featuring Tube Archivist generously created by [IBRACORP](https://www.youtube.com/@IBRACORP).
## Screenshots
![home screenshot](assets/tube-archivist-screenshot-home.png?raw=true "Tube Archivist Home")
![login screenshot](assets/tube-archivist-login.png?raw=true "Tube Archivist Login")
*Login Page*: Secure way to access your media collection.
![home screenshot](assets/tube-archivist-home.png?raw=true "Tube Archivist Home")
*Home Page*: Your recent videos, continue watching incomplete videos.
![channels screenshot](assets/tube-archivist-screenshot-channels.png?raw=true "Tube Archivist Channels")
![channels screenshot](assets/tube-archivist-channels.png?raw=true "Tube Archivist Channels")
*All Channels*: A list of all your indexed channels, filtered by subscribed only.
![single channel screenshot](assets/tube-archivist-screenshot-single-channel.png?raw=true "Tube Archivist Single Channel")
![single channel screenshot](assets/tube-archivist-single-channel.png?raw=true "Tube Archivist Single Channel")
*Single Channel*: Single channel page with additional metadata and sub pages.
![video page screenshot](assets/tube-archivist-screenshot-video.png?raw=true "Tube Archivist Video Page")
![video page screenshot](assets/tube-archivist-video.png?raw=true "Tube Archivist Video Page")
*Video Page*: Stream your video directly from the interface.
![video page screenshot](assets/tube-archivist-screenshot-download.png?raw=true "Tube Archivist Video Page")
*Downloads Page*: Add, control and monitor your download queue.
![video page screenshot](assets/tube-archivist-download.png?raw=true "Tube Archivist Video Page")
*Downloads Page*: Add, control, and monitor your download queue.
![search page screenshot](assets/tube-archivist-search.png?raw=true "Tube Archivist Search Page")
*Search Page*. Use expressions to quickly search through your collection.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

View File

@ -25,6 +25,7 @@ function sync_blackhole {
--exclude ".gitignore" \
--exclude "**/cache" \
--exclude "**/__pycache__/" \
--exclude ".venv" \
--exclude "db.sqlite3" \
--exclude ".mypy_cache" \
. -e ssh "$host":tubearchivist
@ -49,6 +50,7 @@ function sync_test {
--exclude ".gitignore" \
--exclude "**/cache" \
--exclude "**/__pycache__/" \
--exclude ".venv" \
--exclude "db.sqlite3" \
--exclude ".mypy_cache" \
. -e ssh "$host":tubearchivist
@ -87,14 +89,14 @@ function validate {
# 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"
black --exclude "migrations/*" --diff --color --check -l 79 "$check_path"
black --force-exclude "migrations/*" --diff --color --check -l 79 "$check_path"
echo "running codespell"
codespell --skip="./.git,./package.json,./package-lock.json,./node_modules,./.mypy_cache" "$check_path"
codespell --skip="./.git,./.venv,./package.json,./package-lock.json,./node_modules,./.mypy_cache" "$check_path"
echo "running flake8"
flake8 "$check_path" --exclude "migrations" --count --max-complexity=10 \
flake8 "$check_path" --exclude "migrations,.venv" --count --max-complexity=10 \
--max-line-length=79 --show-source --statistics
echo "running isort"
isort --skip "migrations" --check-only --diff --profile black -l 79 "$check_path"
isort --skip "migrations" --skip ".venv" --check-only --diff --profile black -l 79 "$check_path"
printf " \n> all validations passed\n"
}

View File

@ -1,4 +1,4 @@
version: '3.3'
version: '3.5'
services:
tubearchivist:
@ -20,6 +20,12 @@ services:
- TA_PASSWORD=verysecret # your initial TA credentials
- ELASTIC_PASSWORD=verysecret # set password for Elasticsearch
- TZ=America/New_York # set your time zone
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 2m
timeout: 10s
retries: 3
start_period: 30s
depends_on:
- archivist-es
- archivist-redis
@ -34,7 +40,7 @@ services:
depends_on:
- archivist-es
archivist-es:
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.7.0
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.13.2
container_name: archivist-es
restart: unless-stopped
environment:

View File

@ -0,0 +1,71 @@
"""
ffmpeg link builder
copied as into build step in Dockerfile
"""
import json
import os
import sys
import tarfile
import urllib.request
from enum import Enum
API_URL = "https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest"
BINARIES = ["ffmpeg", "ffprobe"]
class PlatformFilter(Enum):
"""options"""
ARM64 = "linuxarm64"
AMD64 = "linux64"
def get_assets():
"""get all available assets from latest build"""
with urllib.request.urlopen(API_URL) as f:
all_links = json.loads(f.read().decode("utf-8"))
return all_links
def pick_url(all_links, platform):
"""pick url for platform"""
filter_by = PlatformFilter[platform.split("/")[1].upper()].value
options = [i for i in all_links["assets"] if filter_by in i["name"]]
if not options:
raise ValueError(f"no valid asset found for filter {filter_by}")
url_pick = options[0]["browser_download_url"]
return url_pick
def download_extract(url):
"""download and extract binaries"""
print("download file")
filename, _ = urllib.request.urlretrieve(url)
print("extract file")
with tarfile.open(filename, "r:xz") as tar:
for member in tar.getmembers():
member.name = os.path.basename(member.name)
if member.name in BINARIES:
print(f"extract {member.name}")
tar.extract(member, member.name)
def main():
"""entry point"""
args = sys.argv
if len(args) == 1:
platform = "linux/amd64"
else:
platform = args[1]
all_links = get_assets()
url = pick_url(all_links, platform)
download_extract(url)
if __name__ == "__main__":
main()

View File

@ -17,7 +17,7 @@ python manage.py ta_startup
# start all tasks
nginx &
celery -A home.tasks worker --loglevel=INFO &
celery -A home.tasks worker --loglevel=INFO --max-tasks-per-child 10 &
celery -A home beat --loglevel=INFO \
-s "${BEAT_SCHEDULE_PATH:-${cachedir}/celerybeat-schedule}" &
uwsgi --ini uwsgi.ini

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent Channels page is: [here](https://docs.tubearchivist.com/channels/).

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent Downloads page is: [here](https://docs.tubearchivist.com/downloads/).

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent FAQ page is: [here](https://docs.tubearchivist.com/faq/).

View File

@ -1 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent pages are located under *installation* on the left.

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent Playlist page is: [here](https://docs.tubearchivist.com/playlists/).

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent Search page is: [here](https://docs.tubearchivist.com/search/).

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent Settings page is: [here](https://docs.tubearchivist.com/settings/).

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent Users page is: [here](https://docs.tubearchivist.com/users/).

View File

@ -1,3 +0,0 @@
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
Equivalent Video page is: [here](https://docs.tubearchivist.com/video/).

1048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,351 @@
"""aggregations"""
from home.src.es.connect import ElasticWrap
from home.src.ta.helper import get_duration_str
from home.src.ta.settings import EnvironmentSettings
class AggBase:
"""base class for aggregation calls"""
path: str = ""
data: dict = {}
name: str = ""
def get(self):
"""make get call"""
response, _ = ElasticWrap(self.path).get(self.data)
print(f"[agg][{self.name}] took {response.get('took')} ms to process")
return response.get("aggregations")
def process(self):
"""implement in subclassess"""
raise NotImplementedError
class Video(AggBase):
"""get video stats"""
name = "video_stats"
path = "ta_video/_search"
data = {
"size": 0,
"aggs": {
"video_type": {
"terms": {"field": "vid_type"},
"aggs": {
"media_size": {"sum": {"field": "media_size"}},
"duration": {"sum": {"field": "player.duration"}},
},
},
"video_active": {
"terms": {"field": "active"},
"aggs": {
"media_size": {"sum": {"field": "media_size"}},
"duration": {"sum": {"field": "player.duration"}},
},
},
"video_media_size": {"sum": {"field": "media_size"}},
"video_count": {"value_count": {"field": "youtube_id"}},
"duration": {"sum": {"field": "player.duration"}},
},
}
def process(self):
"""process aggregation"""
aggregations = self.get()
duration = int(aggregations["duration"]["value"])
response = {
"doc_count": aggregations["video_count"]["value"],
"media_size": int(aggregations["video_media_size"]["value"]),
"duration": duration,
"duration_str": get_duration_str(duration),
}
for bucket in aggregations["video_type"]["buckets"]:
duration = int(bucket["duration"].get("value"))
response.update(
{
f"type_{bucket['key']}": {
"doc_count": bucket.get("doc_count"),
"media_size": int(bucket["media_size"].get("value")),
"duration": duration,
"duration_str": get_duration_str(duration),
}
}
)
for bucket in aggregations["video_active"]["buckets"]:
duration = int(bucket["duration"].get("value"))
response.update(
{
f"active_{bucket['key_as_string']}": {
"doc_count": bucket.get("doc_count"),
"media_size": int(bucket["media_size"].get("value")),
"duration": duration,
"duration_str": get_duration_str(duration),
}
}
)
return response
class Channel(AggBase):
"""get channel stats"""
name = "channel_stats"
path = "ta_channel/_search"
data = {
"size": 0,
"aggs": {
"channel_count": {"value_count": {"field": "channel_id"}},
"channel_active": {"terms": {"field": "channel_active"}},
"channel_subscribed": {"terms": {"field": "channel_subscribed"}},
},
}
def process(self):
"""process aggregation"""
aggregations = self.get()
response = {
"doc_count": aggregations["channel_count"].get("value"),
}
for bucket in aggregations["channel_active"]["buckets"]:
key = f"active_{bucket['key_as_string']}"
response.update({key: bucket.get("doc_count")})
for bucket in aggregations["channel_subscribed"]["buckets"]:
key = f"subscribed_{bucket['key_as_string']}"
response.update({key: bucket.get("doc_count")})
return response
class Playlist(AggBase):
"""get playlist stats"""
name = "playlist_stats"
path = "ta_playlist/_search"
data = {
"size": 0,
"aggs": {
"playlist_count": {"value_count": {"field": "playlist_id"}},
"playlist_active": {"terms": {"field": "playlist_active"}},
"playlist_subscribed": {"terms": {"field": "playlist_subscribed"}},
},
}
def process(self):
"""process aggregation"""
aggregations = self.get()
response = {"doc_count": aggregations["playlist_count"].get("value")}
for bucket in aggregations["playlist_active"]["buckets"]:
key = f"active_{bucket['key_as_string']}"
response.update({key: bucket.get("doc_count")})
for bucket in aggregations["playlist_subscribed"]["buckets"]:
key = f"subscribed_{bucket['key_as_string']}"
response.update({key: bucket.get("doc_count")})
return response
class Download(AggBase):
"""get downloads queue stats"""
name = "download_queue_stats"
path = "ta_download/_search"
data = {
"size": 0,
"aggs": {
"status": {"terms": {"field": "status"}},
"video_type": {
"filter": {"term": {"status": "pending"}},
"aggs": {"type_pending": {"terms": {"field": "vid_type"}}},
},
},
}
def process(self):
"""process aggregation"""
aggregations = self.get()
response = {}
for bucket in aggregations["status"]["buckets"]:
response.update({bucket["key"]: bucket.get("doc_count")})
for bucket in aggregations["video_type"]["type_pending"]["buckets"]:
key = f"pending_{bucket['key']}"
response.update({key: bucket.get("doc_count")})
return response
class WatchProgress(AggBase):
"""get watch progress"""
name = "watch_progress"
path = "ta_video/_search"
data = {
"size": 0,
"aggs": {
name: {
"terms": {"field": "player.watched"},
"aggs": {
"watch_docs": {
"filter": {"terms": {"player.watched": [True, False]}},
"aggs": {
"true_count": {"value_count": {"field": "_index"}},
"duration": {"sum": {"field": "player.duration"}},
},
},
},
},
"total_duration": {"sum": {"field": "player.duration"}},
"total_vids": {"value_count": {"field": "_index"}},
},
}
def process(self):
"""make the call"""
aggregations = self.get()
buckets = aggregations[self.name]["buckets"]
response = {}
all_duration = int(aggregations["total_duration"].get("value"))
response.update(
{
"total": {
"duration": all_duration,
"duration_str": get_duration_str(all_duration),
"items": aggregations["total_vids"].get("value"),
}
}
)
for bucket in buckets:
response.update(self._build_bucket(bucket, all_duration))
return response
@staticmethod
def _build_bucket(bucket, all_duration):
"""parse bucket"""
duration = int(bucket["watch_docs"]["duration"]["value"])
duration_str = get_duration_str(duration)
items = bucket["watch_docs"]["true_count"]["value"]
if bucket["key_as_string"] == "false":
key = "unwatched"
else:
key = "watched"
bucket_parsed = {
key: {
"duration": duration,
"duration_str": duration_str,
"progress": duration / all_duration if all_duration else 0,
"items": items,
}
}
return bucket_parsed
class DownloadHist(AggBase):
"""get downloads histogram last week"""
name = "videos_last_week"
path = "ta_video/_search"
data = {
"size": 0,
"aggs": {
name: {
"date_histogram": {
"field": "date_downloaded",
"calendar_interval": "day",
"format": "yyyy-MM-dd",
"order": {"_key": "desc"},
"time_zone": EnvironmentSettings.TZ,
},
"aggs": {
"total_videos": {"value_count": {"field": "youtube_id"}},
"media_size": {"sum": {"field": "media_size"}},
},
}
},
"query": {
"range": {
"date_downloaded": {
"gte": "now-7d/d",
"time_zone": EnvironmentSettings.TZ,
}
}
},
}
def process(self):
"""process query"""
aggregations = self.get()
buckets = aggregations[self.name]["buckets"]
response = [
{
"date": i.get("key_as_string"),
"count": i.get("doc_count"),
"media_size": i["media_size"].get("value"),
}
for i in buckets
]
return response
class BiggestChannel(AggBase):
"""get channel aggregations"""
def __init__(self, order):
self.data["aggs"][self.name]["multi_terms"]["order"] = {order: "desc"}
name = "channel_stats"
path = "ta_video/_search"
data = {
"size": 0,
"aggs": {
name: {
"multi_terms": {
"terms": [
{"field": "channel.channel_name.keyword"},
{"field": "channel.channel_id"},
],
"order": {"doc_count": "desc"},
},
"aggs": {
"doc_count": {"value_count": {"field": "_index"}},
"duration": {"sum": {"field": "player.duration"}},
"media_size": {"sum": {"field": "media_size"}},
},
},
},
}
order_choices = ["doc_count", "duration", "media_size"]
def process(self):
"""process aggregation, order_by validated in the view"""
aggregations = self.get()
buckets = aggregations[self.name]["buckets"]
response = [
{
"id": i["key"][1],
"name": i["key"][0].title(),
"doc_count": i["doc_count"]["value"],
"duration": i["duration"]["value"],
"duration_str": get_duration_str(int(i["duration"]["value"])),
"media_size": i["media_size"]["value"],
}
for i in buckets
]
return response

View File

@ -7,15 +7,14 @@ Functionality:
import urllib.parse
from home.src.download.thumbnails import ThumbManager
from home.src.ta.config import AppConfig
from home.src.ta.helper import date_praser
from home.src.ta.helper import date_praser, get_duration_str
from home.src.ta.settings import EnvironmentSettings
class SearchProcess:
"""process search results"""
CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"]
CACHE_DIR = EnvironmentSettings.CACHE_DIR
def __init__(self, response):
self.response = response
@ -50,6 +49,16 @@ class SearchProcess:
processed = self._process_download(result["_source"])
if index == "ta_comment":
processed = self._process_comment(result["_source"])
if index == "ta_subtitle":
processed = self._process_subtitle(result)
if isinstance(processed, dict):
processed.update(
{
"_index": index,
"_score": round(result.get("_score") or 0, 2),
}
)
return processed
@ -139,3 +148,29 @@ class SearchProcess:
processed_comments[-1]["comment_replies"].append(comment)
return processed_comments
def _process_subtitle(self, result):
"""take complete result dict to extract highlight"""
subtitle_dict = result["_source"]
highlight = result.get("highlight")
if highlight:
# replace lines with the highlighted markdown
subtitle_line = highlight.get("subtitle_line")[0]
subtitle_dict.update({"subtitle_line": subtitle_line})
thumb_path = ThumbManager(subtitle_dict["youtube_id"]).vid_thumb_path()
subtitle_dict.update({"vid_thumb_url": f"/cache/{thumb_path}"})
return subtitle_dict
def process_aggs(response):
"""convert aggs duration to str"""
if response.get("aggregations"):
aggs = response["aggregations"]
if "total_duration" in aggs:
duration_sec = int(aggs["total_duration"]["value"])
aggs["total_duration"].update(
{"value_str": get_duration_str(duration_sec)}
)

View File

@ -41,6 +41,11 @@ urlpatterns = [
views.ChannelApiListView.as_view(),
name="api-channel-list",
),
path(
"channel/search/",
views.ChannelApiSearchView.as_view(),
name="api-channel-search",
),
path(
"channel/<slug:channel_id>/",
views.ChannelApiView.as_view(),
@ -91,6 +96,16 @@ urlpatterns = [
views.SnapshotApiView.as_view(),
name="api-snapshot",
),
path(
"backup/",
views.BackupApiListView.as_view(),
name="api-backup-list",
),
path(
"backup/<str:filename>/",
views.BackupApiView.as_view(),
name="api-backup",
),
path(
"task-name/",
views.TaskListView.as_view(),
@ -106,6 +121,11 @@ urlpatterns = [
views.TaskIDView.as_view(),
name="api-task-id",
),
path(
"config/user/",
views.UserConfigView.as_view(),
name="api-config-user",
),
path(
"cookie/",
views.CookieView.as_view(),
@ -131,4 +151,39 @@ urlpatterns = [
views.NotificationView.as_view(),
name="api-notification",
),
path(
"stats/video/",
views.StatVideoView.as_view(),
name="api-stats-video",
),
path(
"stats/channel/",
views.StatChannelView.as_view(),
name="api-stats-channel",
),
path(
"stats/playlist/",
views.StatPlaylistView.as_view(),
name="api-stats-playlist",
),
path(
"stats/download/",
views.StatDownloadView.as_view(),
name="api-stats-download",
),
path(
"stats/watch/",
views.StatWatchProgress.as_view(),
name="api-stats-watch",
),
path(
"stats/downloadhist/",
views.StatDownloadHist.as_view(),
name="api-stats-downloadhist",
),
path(
"stats/biggestchannels/",
views.StatBiggestChannel.as_view(),
name="api-stats-biggestchannels",
),
]

View File

@ -1,49 +1,95 @@
"""all API views"""
from api.src.aggs import (
BiggestChannel,
Channel,
Download,
DownloadHist,
Playlist,
Video,
WatchProgress,
)
from api.src.search_processor import SearchProcess
from home.src.download.queue import PendingInteract
from home.src.download.subscriptions import (
ChannelSubscription,
PlaylistSubscription,
)
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.searching import SearchForm
from home.src.frontend.watched import WatchState
from home.src.index.channel import YoutubeChannel
from home.src.index.generic import Pagination
from home.src.index.playlist import YoutubePlaylist
from home.src.index.reindex import ReindexProgress
from home.src.index.video import SponsorBlock, YoutubeVideo
from home.src.ta.config import AppConfig
from home.src.ta.config import AppConfig, ReleaseVersion
from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisArchivist
from home.src.ta.task_manager import TaskCommand, TaskManager
from home.src.ta.urlparser import Parser
from home.src.ta.users import UserConfig
from home.tasks import (
BaseTask,
check_reindex,
download_pending,
extrac_dl,
run_restore_backup,
subscribe_to,
)
from rest_framework import permissions, status
from rest_framework.authentication import (
SessionAuthentication,
TokenAuthentication,
)
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
def check_admin(user):
"""check for admin permission for restricted views"""
return user.is_staff or user.groups.filter(name="admin").exists()
class AdminOnly(permissions.BasePermission):
"""allow only admin"""
def has_permission(self, request, view):
return check_admin(request.user)
class AdminWriteOnly(permissions.BasePermission):
"""allow only admin writes"""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return permissions.IsAuthenticated().has_permission(request, view)
return check_admin(request.user)
class ApiBaseView(APIView):
"""base view to inherit from"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [IsAuthenticated]
permission_classes = [permissions.IsAuthenticated]
search_base = ""
data = ""
def __init__(self):
super().__init__()
self.response = {"data": False, "config": AppConfig().config}
self.response = {
"data": False,
"config": {
"enable_cast": EnvironmentSettings.ENABLE_CAST,
"downloads": AppConfig().config["downloads"],
},
}
self.data = {"query": {"match_all": {}}}
self.status_code = False
self.context = False
@ -96,6 +142,7 @@ class VideoApiView(ApiBaseView):
"""
search_base = "ta_video/_doc/"
permission_classes = [AdminWriteOnly]
def get(self, request, video_id):
# pylint: disable=unused-argument
@ -134,7 +181,7 @@ class VideoApiListView(ApiBaseView):
class VideoProgressView(ApiBaseView):
"""resolves to /api/video/<video_id>/
"""resolves to /api/video/<video_id>/progress/
handle progress status for video
"""
@ -159,7 +206,6 @@ class VideoProgressView(ApiBaseView):
message = {"position": position, "youtube_id": video_id}
RedisArchivist().set_message(key, message)
self.response = request.data
return Response(self.response)
def delete(self, request, video_id):
@ -189,7 +235,7 @@ class VideoCommentView(ApiBaseView):
class VideoSimilarView(ApiBaseView):
"""resolves to /api/video/<video-id>/similar/
GET: return max 3 videos similar to this
GET: return max 6 videos similar to this
"""
search_base = "ta_video/_search/"
@ -223,6 +269,10 @@ class VideoSponsorView(ApiBaseView):
# pylint: disable=unused-argument
self.get_document(video_id)
if not self.response.get("data"):
message = {"message": "video not found"}
return Response(message, status=404)
sponsorblock = self.response["data"].get("sponsorblock")
return Response(sponsorblock)
@ -266,6 +316,7 @@ class ChannelApiView(ApiBaseView):
"""
search_base = "ta_channel/_doc/"
permission_classes = [AdminWriteOnly]
def get(self, request, channel_id):
# pylint: disable=unused-argument
@ -296,6 +347,7 @@ class ChannelApiListView(ApiBaseView):
search_base = "ta_channel/_search/"
valid_filter = ["subscribed"]
permission_classes = [AdminWriteOnly]
def get(self, request):
"""get request"""
@ -318,9 +370,8 @@ class ChannelApiListView(ApiBaseView):
return Response(self.response)
@staticmethod
def post(request):
"""subscribe to list of channels"""
def post(self, request):
"""subscribe/unsubscribe to list of channels"""
data = request.data
try:
to_add = data["data"]
@ -329,12 +380,58 @@ class ChannelApiListView(ApiBaseView):
print(message)
return Response({"message": message}, status=400)
pending = [i["channel_id"] for i in to_add if i["channel_subscribed"]]
url_str = " ".join(pending)
subscribe_to.delay(url_str)
pending = []
for channel_item in to_add:
channel_id = channel_item["channel_id"]
if channel_item["channel_subscribed"]:
pending.append(channel_id)
else:
self._unsubscribe(channel_id)
if pending:
url_str = " ".join(pending)
subscribe_to.delay(url_str, expected_type="channel")
return Response(data)
@staticmethod
def _unsubscribe(channel_id: str):
"""unsubscribe"""
print(f"[{channel_id}] unsubscribe from channel")
ChannelSubscription().change_subscribe(
channel_id, channel_subscribed=False
)
class ChannelApiSearchView(ApiBaseView):
"""resolves to /api/channel/search/
search for channel
"""
search_base = "ta_channel/_doc/"
def get(self, request):
"""handle get request, search with s parameter"""
query = request.GET.get("q")
if not query:
message = "missing expected q parameter"
return Response({"message": message, "data": False}, status=400)
try:
parsed = Parser(query).parse()[0]
except (ValueError, IndexError, AttributeError):
message = f"channel not found: {query}"
return Response({"message": message, "data": False}, status=404)
if not parsed["type"] == "channel":
message = "expected type channel"
return Response({"message": message, "data": False}, status=400)
self.get_document(parsed["url"])
return Response(self.response, status=self.status_code)
class ChannelApiVideoView(ApiBaseView):
"""resolves to /api/channel/<channel-id>/video
@ -364,15 +461,62 @@ class PlaylistApiListView(ApiBaseView):
"""
search_base = "ta_playlist/_search/"
permission_classes = [AdminWriteOnly]
valid_playlist_type = ["regular", "custom"]
def get(self, request):
"""handle get request"""
self.data.update(
{"sort": [{"playlist_name.keyword": {"order": "asc"}}]}
)
playlist_type = request.GET.get("playlist_type", None)
query = {"sort": [{"playlist_name.keyword": {"order": "asc"}}]}
if playlist_type is not None:
if playlist_type not in self.valid_playlist_type:
message = f"invalid playlist_type {playlist_type}"
return Response({"message": message}, status=400)
query.update(
{
"query": {
"term": {"playlist_type": {"value": playlist_type}}
},
}
)
self.data.update(query)
self.get_document_list(request)
return Response(self.response)
def post(self, request):
"""subscribe/unsubscribe to list of playlists"""
data = request.data
try:
to_add = data["data"]
except KeyError:
message = "missing expected data key"
print(message)
return Response({"message": message}, status=400)
pending = []
for playlist_item in to_add:
playlist_id = playlist_item["playlist_id"]
if playlist_item["playlist_subscribed"]:
pending.append(playlist_id)
else:
self._unsubscribe(playlist_id)
if pending:
url_str = " ".join(pending)
subscribe_to.delay(url_str, expected_type="playlist")
return Response(data)
@staticmethod
def _unsubscribe(playlist_id: str):
"""unsubscribe"""
print(f"[{playlist_id}] unsubscribe from playlist")
PlaylistSubscription().change_subscribe(
playlist_id, subscribe_status=False
)
class PlaylistApiView(ApiBaseView):
"""resolves to /api/playlist/<playlist_id>/
@ -380,6 +524,8 @@ class PlaylistApiView(ApiBaseView):
"""
search_base = "ta_playlist/_doc/"
permission_classes = [AdminWriteOnly]
valid_custom_actions = ["create", "remove", "up", "down", "top", "bottom"]
def get(self, request, playlist_id):
# pylint: disable=unused-argument
@ -387,6 +533,38 @@ class PlaylistApiView(ApiBaseView):
self.get_document(playlist_id)
return Response(self.response, status=self.status_code)
def post(self, request, playlist_id):
"""post to custom playlist to add a video to list"""
playlist = YoutubePlaylist(playlist_id)
if not playlist.is_custom_playlist():
message = f"playlist with ID {playlist_id} is not custom"
return Response({"message": message}, status=400)
action = request.data.get("action")
if action not in self.valid_custom_actions:
message = f"invalid action: {action}"
return Response({"message": message}, status=400)
video_id = request.data.get("video_id")
if action == "create":
playlist.add_video_to_playlist(video_id)
else:
hide = UserConfig(request.user.id).get_value("hide_watched")
playlist.move_video(video_id, action, hide_watched=hide)
return Response({"success": True}, status=status.HTTP_201_CREATED)
def delete(self, request, playlist_id):
"""delete playlist"""
print(f"{playlist_id}: delete playlist")
delete_videos = request.GET.get("delete-videos", False)
if delete_videos:
YoutubePlaylist(playlist_id).delete_videos_playlist()
else:
YoutubePlaylist(playlist_id).delete_metadata()
return Response({"success": True})
class PlaylistApiVideoView(ApiBaseView):
"""resolves to /api/playlist/<playlist_id>/video
@ -415,6 +593,7 @@ class DownloadApiView(ApiBaseView):
search_base = "ta_download/_doc/"
valid_status = ["pending", "ignore", "priority"]
permission_classes = [AdminOnly]
def get(self, request, video_id):
# pylint: disable=unused-argument
@ -461,6 +640,7 @@ class DownloadApiListView(ApiBaseView):
search_base = "ta_download/_search/"
valid_filter = ["pending", "ignore"]
permission_classes = [AdminOnly]
def get(self, request):
"""get request"""
@ -535,7 +715,11 @@ class PingView(ApiBaseView):
@staticmethod
def get(request):
"""get pong"""
data = {"response": "pong", "user": request.user.id}
data = {
"response": "pong",
"user": request.user.id,
"version": ReleaseVersion().get_local_version(),
}
return Response(data)
@ -561,10 +745,12 @@ class LoginApiView(ObtainAuthToken):
class SnapshotApiListView(ApiBaseView):
"""resolves to /api/snapshot/
GET: returns snashot config plus list of existing snapshots
GET: returns snapshot config plus list of existing snapshots
POST: take snapshot now
"""
permission_classes = [AdminOnly]
@staticmethod
def get(request):
"""handle get request"""
@ -589,6 +775,8 @@ class SnapshotApiView(ApiBaseView):
DELETE: delete a snapshot
"""
permission_classes = [AdminOnly]
@staticmethod
def get(request, snapshot_id):
"""handle get request"""
@ -623,11 +811,87 @@ class SnapshotApiView(ApiBaseView):
return Response(response)
class BackupApiListView(ApiBaseView):
"""resolves to /api/backup/
GET: returns list of available zip backups
POST: take zip backup now
"""
permission_classes = [AdminOnly]
task_name = "run_backup"
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
backup_files = ElasticBackup().get_all_backup_files()
return Response(backup_files)
def post(self, request):
"""handle post request"""
# pylint: disable=unused-argument
response = TaskCommand().start(self.task_name)
message = {
"message": "backup task started",
"task_id": response["task_id"],
}
return Response(message)
class BackupApiView(ApiBaseView):
"""resolves to /api/backup/<filename>/
GET: return a single backup
POST: restore backup
DELETE: delete backup
"""
permission_classes = [AdminOnly]
task_name = "restore_backup"
@staticmethod
def get(request, filename):
"""get single backup"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().build_backup_file_data(filename)
if not backup_file:
message = {"message": "file not found"}
return Response(message, status=404)
return Response(backup_file)
def post(self, request, filename):
"""restore backup file"""
# pylint: disable=unused-argument
task = run_restore_backup.delay(filename)
message = {
"message": "backup restore task started",
"filename": filename,
"task_id": task.id,
}
return Response(message)
@staticmethod
def delete(request, filename):
"""delete backup file"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().delete_file(filename)
if not backup_file:
message = {"message": "file not found"}
return Response(message, status=404)
message = {"message": f"file {filename} deleted"}
return Response(message)
class TaskListView(ApiBaseView):
"""resolves to /api/task-name/
GET: return a list of all stored task results
"""
permission_classes = [AdminOnly]
def get(self, request):
"""handle get request"""
# pylint: disable=unused-argument
@ -642,6 +906,8 @@ class TaskNameListView(ApiBaseView):
POST: start new background process
"""
permission_classes = [AdminOnly]
def get(self, request, task_name):
"""handle get request"""
# pylint: disable=unused-argument
@ -680,6 +946,7 @@ class TaskIDView(ApiBaseView):
"""
valid_commands = ["stop", "kill"]
permission_classes = [AdminOnly]
def get(self, request, task_id):
"""handle get request"""
@ -731,6 +998,8 @@ class RefreshView(ApiBaseView):
POST: start a manual refresh task
"""
permission_classes = [AdminOnly]
def get(self, request):
"""handle get request"""
request_type = request.GET.get("type")
@ -757,6 +1026,42 @@ class RefreshView(ApiBaseView):
return Response(data)
class UserConfigView(ApiBaseView):
"""resolves to /api/config/user/
GET: return current user config
POST: update user config
"""
def get(self, request):
"""get config"""
user_id = request.user.id
response = UserConfig(user_id).get_config()
response.update({"user_id": user_id})
return Response(response)
def post(self, request):
"""update config"""
user_id = request.user.id
data = request.data
user_conf = UserConfig(user_id)
for key, value in data.items():
try:
user_conf.set_value(key, value)
except ValueError as err:
message = {
"status": "Bad Request",
"message": f"failed updating {key} to '{value}', {err}",
}
return Response(message, status=400)
response = user_conf.get_config()
response.update({"user_id": user_id})
return Response(response)
class CookieView(ApiBaseView):
"""resolves to /api/cookie/
GET: check if cookie is enabled
@ -764,6 +1069,8 @@ class CookieView(ApiBaseView):
PUT: import cookie
"""
permission_classes = [AdminOnly]
@staticmethod
def get(request):
"""handle get request"""
@ -851,6 +1158,8 @@ class TokenView(ApiBaseView):
DELETE: revoke the token
"""
permission_classes = [AdminOnly]
@staticmethod
def delete(request):
print("revoke API token")
@ -874,3 +1183,94 @@ class NotificationView(ApiBaseView):
query = f"{query}:{filter_by}"
return Response(RedisArchivist().list_items(query))
class StatVideoView(ApiBaseView):
"""resolves to /api/stats/video/
GET: return video stats
"""
def get(self, request):
"""get stats"""
# pylint: disable=unused-argument
return Response(Video().process())
class StatChannelView(ApiBaseView):
"""resolves to /api/stats/channel/
GET: return channel stats
"""
def get(self, request):
"""get stats"""
# pylint: disable=unused-argument
return Response(Channel().process())
class StatPlaylistView(ApiBaseView):
"""resolves to /api/stats/playlist/
GET: return playlist stats
"""
def get(self, request):
"""get stats"""
# pylint: disable=unused-argument
return Response(Playlist().process())
class StatDownloadView(ApiBaseView):
"""resolves to /api/stats/download/
GET: return download stats
"""
def get(self, request):
"""get stats"""
# pylint: disable=unused-argument
return Response(Download().process())
class StatWatchProgress(ApiBaseView):
"""resolves to /api/stats/watchprogress/
GET: return watch/unwatch progress stats
"""
def get(self, request):
"""handle get request"""
# pylint: disable=unused-argument
return Response(WatchProgress().process())
class StatDownloadHist(ApiBaseView):
"""resolves to /api/stats/downloadhist/
GET: return download video count histogram for last days
"""
def get(self, request):
"""handle get request"""
# pylint: disable=unused-argument
return Response(DownloadHist().process())
class StatBiggestChannel(ApiBaseView):
"""resolves to /api/stats/biggestchannels/
GET: return biggest channels
param: order
"""
order_choices = ["doc_count", "duration", "media_size"]
def get(self, request):
"""handle get request"""
order = request.GET.get("order", "doc_count")
if order and order not in self.order_choices:
message = {"message": f"invalid order parameter {order}"}
return Response(message, status=400)
return Response(BiggestChannel(order).process())

View File

@ -8,6 +8,7 @@ from time import sleep
import requests
from django.core.management.base import BaseCommand, CommandError
from home.src.es.connect import ElasticWrap
from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisArchivist
TOPIC = """
@ -81,11 +82,16 @@ class Command(BaseCommand):
_, status_code = ElasticWrap("/").get(
timeout=1, print_error=False
)
except requests.exceptions.ConnectionError:
except (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
):
sleep(5)
continue
if status_code and status_code == 200:
path = "_cluster/health?wait_for_status=yellow&timeout=60s"
_, _ = ElasticWrap(path).get(timeout=60)
self.stdout.write(
self.style.SUCCESS(" ✓ ES connection established")
)
@ -116,7 +122,7 @@ class Command(BaseCommand):
return
message = (
" 🗙 ES connection failed. "
" 🗙 ES version check failed. "
+ f"Expected {self.MIN_MAJOR}.{self.MIN_MINOR} but got {version}"
)
self.stdout.write(self.style.ERROR(f"{message}"))
@ -127,7 +133,19 @@ class Command(BaseCommand):
"""check that path.repo var is set"""
self.stdout.write("[5] check ES path.repo env var")
response, _ = ElasticWrap("_nodes/_all/settings").get()
snaphost_roles = [
"data",
"data_cold",
"data_content",
"data_frozen",
"data_hot",
"data_warm",
"master",
]
for node in response["nodes"].values():
if not (set(node["roles"]) & set(snaphost_roles)):
continue
if node["settings"]["path"].get("repo"):
self.stdout.write(
self.style.SUCCESS(" ✓ path.repo env var is set")
@ -137,8 +155,9 @@ class Command(BaseCommand):
message = (
" 🗙 path.repo env var not found. "
+ "set the following env var to the ES container:\n"
+ " path.repo=/usr/share/elasticsearch/data/snapshot"
+ " path.repo="
+ EnvironmentSettings.ES_SNAPSHOT_DIR
)
self.stdout.write(self.style.ERROR(f"{message}"))
self.stdout.write(self.style.ERROR(message))
sleep(60)
raise CommandError(message)

View File

@ -11,6 +11,7 @@ import re
from django.core.management.base import BaseCommand, CommandError
from home.models import Account
from home.src.ta.settings import EnvironmentSettings
LOGO = """
@ -96,18 +97,14 @@ class Command(BaseCommand):
def _elastic_user_overwrite(self):
"""check for ELASTIC_USER overwrite"""
self.stdout.write("[2] set default ES user")
if not os.environ.get("ELASTIC_USER"):
os.environ.setdefault("ELASTIC_USER", "elastic")
env = os.environ.get("ELASTIC_USER")
self.stdout.write("[2] check ES user overwrite")
env = EnvironmentSettings.ES_USER
self.stdout.write(self.style.SUCCESS(f" ✓ ES user is set to {env}"))
def _ta_port_overwrite(self):
"""set TA_PORT overwrite for nginx"""
self.stdout.write("[3] check TA_PORT overwrite")
overwrite = os.environ.get("TA_PORT")
overwrite = EnvironmentSettings.TA_PORT
if not overwrite:
self.stdout.write(self.style.SUCCESS(" TA_PORT is not set"))
return
@ -125,7 +122,7 @@ class Command(BaseCommand):
def _ta_uwsgi_overwrite(self):
"""set TA_UWSGI_PORT overwrite"""
self.stdout.write("[4] check TA_UWSGI_PORT overwrite")
overwrite = os.environ.get("TA_UWSGI_PORT")
overwrite = EnvironmentSettings.TA_UWSGI_PORT
if not overwrite:
message = " TA_UWSGI_PORT is not set"
self.stdout.write(self.style.SUCCESS(message))
@ -151,7 +148,7 @@ class Command(BaseCommand):
def _enable_cast_overwrite(self):
"""cast workaround, remove auth for static files in nginx"""
self.stdout.write("[5] check ENABLE_CAST overwrite")
overwrite = os.environ.get("ENABLE_CAST")
overwrite = EnvironmentSettings.ENABLE_CAST
if not overwrite:
self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set"))
return
@ -174,8 +171,8 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(message))
return
name = os.environ.get("TA_USERNAME")
password = os.environ.get("TA_PASSWORD")
name = EnvironmentSettings.TA_USERNAME
password = EnvironmentSettings.TA_PASSWORD
Account.objects.create_superuser(name, password)
message = f" ✓ new superuser with name {name} created"
self.stdout.write(self.style.SUCCESS(message))

View File

@ -0,0 +1,185 @@
"""
filepath migration from v0.3.6 to v0.3.7
not getting called at startup any more, to run manually if needed:
python manage.py ta_migpath
"""
import json
import os
import shutil
from django.core.management.base import BaseCommand
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.ta.helper import ignore_filelist
from home.src.ta.settings import EnvironmentSettings
TOPIC = """
########################
# Filesystem Migration #
########################
"""
class Command(BaseCommand):
"""command framework"""
# pylint: disable=no-member
def handle(self, *args, **options):
"""run commands"""
self.stdout.write(TOPIC)
handler = FolderMigration()
to_migrate = handler.get_to_migrate()
if not to_migrate:
self.stdout.write(
self.style.SUCCESS(" no channel migration needed\n")
)
return
self.stdout.write(self.style.SUCCESS(" migrating channels"))
total_channels = handler.create_folders(to_migrate)
self.stdout.write(
self.style.SUCCESS(f" created {total_channels} channels")
)
self.stdout.write(
self.style.SUCCESS(f" migrating {len(to_migrate)} videos")
)
handler.migrate_videos(to_migrate)
self.stdout.write(self.style.SUCCESS(" update videos in index"))
handler.send_bulk()
self.stdout.write(self.style.SUCCESS(" cleanup old folders"))
handler.delete_old()
self.stdout.write(self.style.SUCCESS(" ✓ migration completed\n"))
class FolderMigration:
"""migrate video archive folder"""
def __init__(self):
self.videos = EnvironmentSettings.MEDIA_DIR
self.bulk_list = []
def get_to_migrate(self):
"""get videos to migrate"""
script = (
"doc['media_url'].value == "
+ "doc['channel.channel_id'].value + '/'"
+ " + doc['youtube_id'].value + '.mp4'"
)
data = {
"query": {"bool": {"must_not": [{"script": {"script": script}}]}},
"_source": [
"youtube_id",
"media_url",
"channel.channel_id",
"subtitles",
],
}
response = IndexPaginate("ta_video", data).get_results()
return response
def create_folders(self, to_migrate):
"""create required channel folders"""
host_uid = EnvironmentSettings.HOST_UID
host_gid = EnvironmentSettings.HOST_GID
all_channel_ids = {i["channel"]["channel_id"] for i in to_migrate}
for channel_id in all_channel_ids:
new_folder = os.path.join(self.videos, channel_id)
os.makedirs(new_folder, exist_ok=True)
if host_uid and host_gid:
os.chown(new_folder, host_uid, host_gid)
return len(all_channel_ids)
def migrate_videos(self, to_migrate):
"""migrate all videos of channel"""
total = len(to_migrate)
for idx, video in enumerate(to_migrate):
new_media_url = self._move_video_file(video)
if not new_media_url:
continue
all_subtitles = self._move_subtitles(video)
action = {
"update": {"_id": video["youtube_id"], "_index": "ta_video"}
}
source = {"doc": {"media_url": new_media_url}}
if all_subtitles:
source["doc"].update({"subtitles": all_subtitles})
self.bulk_list.append(json.dumps(action))
self.bulk_list.append(json.dumps(source))
if idx % 1000 == 0:
print(f"processing migration [{idx}/{total}]")
self.send_bulk()
def _move_video_file(self, video):
"""move video file to new location"""
old_path = os.path.join(self.videos, video["media_url"])
if not os.path.exists(old_path):
print(f"did not find expected video at {old_path}")
return False
new_media_url = os.path.join(
video["channel"]["channel_id"], video["youtube_id"] + ".mp4"
)
new_path = os.path.join(self.videos, new_media_url)
os.rename(old_path, new_path)
return new_media_url
def _move_subtitles(self, video):
"""move subtitle files to new location"""
all_subtitles = video.get("subtitles")
if not all_subtitles:
return False
for subtitle in all_subtitles:
old_path = os.path.join(self.videos, subtitle["media_url"])
if not os.path.exists(old_path):
print(f"did not find expected subtitle at {old_path}")
continue
new_media_url = os.path.join(
video["channel"]["channel_id"],
f"{video.get('youtube_id')}.{subtitle.get('lang')}.vtt",
)
new_path = os.path.join(self.videos, new_media_url)
os.rename(old_path, new_path)
subtitle["media_url"] = new_media_url
return all_subtitles
def send_bulk(self):
"""send bulk request to update index with new urls"""
if not self.bulk_list:
print("nothing to update")
return
self.bulk_list.append("\n")
path = "_bulk?refresh=true"
data = "\n".join(self.bulk_list)
response, status = ElasticWrap(path).post(data=data, ndjson=True)
if not status == 200:
print(response)
self.bulk_list = []
def delete_old(self):
"""delete old empty folders"""
all_folders = ignore_filelist(os.listdir(self.videos))
for folder in all_folders:
folder_path = os.path.join(self.videos, folder)
if not os.path.isdir(folder_path):
continue
if not ignore_filelist(os.listdir(folder_path)):
shutil.rmtree(folder_path)

View File

@ -8,15 +8,15 @@ import os
from time import sleep
from django.core.management.base import BaseCommand, CommandError
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.es.connect import ElasticWrap
from home.src.es.index_setup import ElasitIndexWrap
from home.src.es.snapshot import ElasticSnapshot
from home.src.index.filesystem import Filesystem
from home.src.index.video_streams import MediaStreamExtractor
from home.src.ta.config import AppConfig, ReleaseVersion
from home.src.ta.helper import clear_dl_cache
from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisArchivist
from home.src.ta.task_manager import TaskManager
from home.src.ta.users import UserConfig
TOPIC = """
@ -37,14 +37,15 @@ class Command(BaseCommand):
self.stdout.write(TOPIC)
self._sync_redis_state()
self._make_folders()
self._release_locks()
self._clear_redis_keys()
self._clear_tasks()
self._clear_dl_cache()
self._mig_clear_failed_versioncheck()
self._version_check()
self._mig_index_setup()
self._mig_snapshot_check()
self._mig_set_streams()
self._mig_set_autostart()
self._mig_move_users_to_es()
self._mig_custom_playlist()
def _sync_redis_state(self):
"""make sure redis gets new config.json values"""
@ -68,17 +69,17 @@ class Command(BaseCommand):
"playlists",
"videos",
]
cache_dir = AppConfig().config["application"]["cache_dir"]
cache_dir = EnvironmentSettings.CACHE_DIR
for folder in folders:
folder_path = os.path.join(cache_dir, folder)
os.makedirs(folder_path, exist_ok=True)
self.stdout.write(self.style.SUCCESS(" ✓ expected folders created"))
def _release_locks(self):
"""make sure there are no leftover locks set in redis"""
self.stdout.write("[3] clear leftover locks in redis")
all_locks = [
def _clear_redis_keys(self):
"""make sure there are no leftover locks or keys set in redis"""
self.stdout.write("[3] clear leftover keys in redis")
all_keys = [
"dl_queue_id",
"dl_queue",
"downloading",
@ -87,19 +88,22 @@ class Command(BaseCommand):
"rescan",
"run_backup",
"startup_check",
"reindex:ta_video",
"reindex:ta_channel",
"reindex:ta_playlist",
]
redis_con = RedisArchivist()
has_changed = False
for lock in all_locks:
if redis_con.del_message(lock):
for key in all_keys:
if redis_con.del_message(key):
self.stdout.write(
self.style.SUCCESS(f" ✓ cleared lock {lock}")
self.style.SUCCESS(f" ✓ cleared key {key}")
)
has_changed = True
if not has_changed:
self.stdout.write(self.style.SUCCESS(" no locks found"))
self.stdout.write(self.style.SUCCESS(" no keys found"))
def _clear_tasks(self):
"""clear tasks and messages"""
@ -118,8 +122,7 @@ class Command(BaseCommand):
def _clear_dl_cache(self):
"""clear leftover files from dl cache"""
self.stdout.write("[5] clear leftover files from dl cache")
config = AppConfig().config
leftover_files = clear_dl_cache(config)
leftover_files = clear_dl_cache(EnvironmentSettings.CACHE_DIR)
if leftover_files:
self.stdout.write(
self.style.SUCCESS(f" ✓ cleared {leftover_files} files")
@ -148,75 +151,128 @@ class Command(BaseCommand):
self.stdout.write("[MIGRATION] setup snapshots")
ElasticSnapshot().setup()
def _mig_set_streams(self):
"""migration: update from 0.3.5 to 0.3.6, set streams and media_size"""
self.stdout.write("[MIGRATION] index streams and media size")
videos = AppConfig().config["application"]["videos"]
data = {
"query": {
"bool": {"must_not": [{"exists": {"field": "streams"}}]}
},
"_source": ["media_url", "youtube_id"],
}
all_missing = IndexPaginate("ta_video", data).get_results()
if not all_missing:
self.stdout.write(" no videos need updating")
def _mig_clear_failed_versioncheck(self):
"""hotfix for v0.4.5, clearing faulty versioncheck"""
ReleaseVersion().clear_fail()
def _mig_move_users_to_es(self): # noqa: C901
"""migration: update from 0.4.1 to 0.4.2 move user config to ES"""
self.stdout.write("[MIGRATION] move user configuration to ES")
redis = RedisArchivist()
# 1: Find all users in Redis
users = {i.split(":")[0] for i in redis.list_keys("[0-9]*:")}
if not users:
self.stdout.write(" no users needed migrating to ES")
return
self.stdout.write(" start filesystem rescan")
Filesystem().process()
total = len(all_missing)
for idx, missing in enumerate(all_missing):
media_url = missing["media_url"]
youtube_id = missing["youtube_id"]
media_path = os.path.join(videos, media_url)
if not os.path.exists(media_path):
self.stdout.write(f" file not found: {media_path}")
continue
# 2: Write all Redis user settings to ES
# 3: Remove user settings from Redis
try:
for user in users:
new_conf = UserConfig(user)
media = MediaStreamExtractor(media_path)
vid_data = {
"doc": {
"streams": media.extract_metadata(),
"media_size": media.get_file_size(),
}
}
path = f"ta_video/_update/{youtube_id}"
response, status_code = ElasticWrap(path).post(data=vid_data)
if not status_code == 200:
self.stdout.errors(
f" update failed: {path}, {response}, {status_code}"
stylesheet_key = f"{user}:color"
stylesheet = redis.get_message(stylesheet_key).get("status")
if stylesheet:
new_conf.set_value("stylesheet", stylesheet)
redis.del_message(stylesheet_key)
sort_by_key = f"{user}:sort_by"
sort_by = redis.get_message(sort_by_key).get("status")
if sort_by:
new_conf.set_value("sort_by", sort_by)
redis.del_message(sort_by_key)
page_size_key = f"{user}:page_size"
page_size = redis.get_message(page_size_key).get("status")
if page_size:
new_conf.set_value("page_size", page_size)
redis.del_message(page_size_key)
sort_order_key = f"{user}:sort_order"
sort_order = redis.get_message(sort_order_key).get("status")
if sort_order:
new_conf.set_value("sort_order", sort_order)
redis.del_message(sort_order_key)
grid_items_key = f"{user}:grid_items"
grid_items = redis.get_message(grid_items_key).get("status")
if grid_items:
new_conf.set_value("grid_items", grid_items)
redis.del_message(grid_items_key)
hide_watch_key = f"{user}:hide_watched"
hide_watch = redis.get_message(hide_watch_key).get("status")
if hide_watch:
new_conf.set_value("hide_watched", hide_watch)
redis.del_message(hide_watch_key)
ignore_only_key = f"{user}:show_ignored_only"
ignore_only = redis.get_message(ignore_only_key).get("status")
if ignore_only:
new_conf.set_value("show_ignored_only", ignore_only)
redis.del_message(ignore_only_key)
subed_only_key = f"{user}:show_subed_only"
subed_only = redis.get_message(subed_only_key).get("status")
if subed_only:
new_conf.set_value("show_subed_only", subed_only)
redis.del_message(subed_only_key)
for view in ["channel", "playlist", "home", "downloads"]:
view_key = f"{user}:view:{view}"
view_style = redis.get_message(view_key).get("status")
if view_style:
new_conf.set_value(f"view_style_{view}", view_style)
redis.del_message(view_key)
self.stdout.write(
self.style.SUCCESS(
f" ✓ Settings for user '{user}' migrated to ES"
)
)
except Exception as err:
message = " 🗙 user migration to ES failed"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(self.style.ERROR(err))
sleep(60)
raise CommandError(message) from err
else:
self.stdout.write(
self.style.SUCCESS(
" ✓ Settings for all users migrated to ES"
)
)
if idx % 100 == 0:
self.stdout.write(f" progress {idx}/{total}")
def _mig_set_autostart(self):
"""migration: update from 0.3.5 to 0.3.6 set auto_start to false"""
self.stdout.write("[MIGRATION] set default download auto_start")
def _mig_custom_playlist(self):
"""migration for custom playlist"""
self.stdout.write("[MIGRATION] custom playlist")
data = {
"query": {
"bool": {"must_not": [{"exists": {"field": "auto_start"}}]}
"bool": {"must_not": [{"exists": {"field": "playlist_type"}}]}
},
"script": {"source": "ctx._source['auto_start'] = false"},
"script": {"source": "ctx._source['playlist_type'] = 'regular'"},
}
path = "ta_download/_update_by_query"
path = "ta_playlist/_update_by_query"
response, status_code = ElasticWrap(path).post(data=data)
if status_code == 200:
updated = response.get("updated", 0)
if updated:
self.stdout.write(
self.style.SUCCESS(
f"{updated} videos updated in ta_download"
f"{updated} playlist_type updated in ta_playlist"
)
)
else:
self.stdout.write(
" no videos needed updating in ta_download"
self.style.SUCCESS(
" no playlist_type needed updating in ta_playlist"
)
)
return
message = " 🗙 ta_download auto_start update failed"
message = " 🗙 ta_playlist playlist_type update failed"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)

View File

@ -17,8 +17,8 @@ from pathlib import Path
import ldap
from corsheaders.defaults import default_headers
from django_auth_ldap.config import LDAPSearch
from home.src.ta.config import AppConfig
from home.src.ta.helper import ta_host_parser
from home.src.ta.settings import EnvironmentSettings
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@ -27,7 +27,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
PW_HASH = hashlib.sha256(environ["TA_PASSWORD"].encode())
PW_HASH = hashlib.sha256(EnvironmentSettings.TA_PASSWORD.encode())
SECRET_KEY = PW_HASH.hexdigest()
# SECURITY WARNING: don't run with debug turned on in production!
@ -64,6 +64,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"home.src.ta.health.HealthCheckMiddleware",
]
ROOT_URLCONF = "config.urls"
@ -174,13 +175,12 @@ if bool(environ.get("TA_LDAP")):
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
}
global AUTHENTICATION_BACKENDS
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
CACHE_DIR = AppConfig().config["application"]["cache_dir"]
CACHE_DIR = EnvironmentSettings.CACHE_DIR
DB_PATH = path.join(CACHE_DIR, "db.sqlite3")
DATABASES = {
"default": {
@ -210,12 +210,25 @@ AUTH_PASSWORD_VALIDATORS = [
AUTH_USER_MODEL = "home.Account"
# Forward-auth authentication
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
TA_AUTH_PROXY_USERNAME_HEADER = (
environ.get("TA_AUTH_PROXY_USERNAME_HEADER") or "HTTP_REMOTE_USER"
)
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL")
MIDDLEWARE.append("home.src.ta.auth.HttpRemoteUserMiddleware")
AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.RemoteUserBackend",
)
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = environ.get("TZ") or "UTC"
TIME_ZONE = EnvironmentSettings.TZ
USE_I18N = True
USE_L10N = True
USE_TZ = True
@ -256,4 +269,4 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.3.6"
TA_VERSION = "v0.4.8-unstable"

View File

@ -13,6 +13,7 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import include, path

View File

@ -1,18 +1,5 @@
{
"archive": {
"sort_by": "published",
"sort_order": "desc",
"page_size": 12
},
"default_view": {
"home": "grid",
"channel": "list",
"downloads": "list",
"playlist": "grid",
"grid_items": 3
},
"subscriptions": {
"auto_download": false,
"channel_size": 50,
"live_channel_size": 50,
"shorts_channel_size": 50,
@ -38,17 +25,15 @@
"integrate_sponsorblock": false
},
"application": {
"app_root": "/app",
"cache_dir": "/cache",
"videos": "/youtube",
"colors": "dark",
"enable_cast": false,
"enable_snapshot": true
},
"scheduler": {
"update_subscribed": false,
"update_subscribed_notify": false,
"download_pending": false,
"download_pending_notify": false,
"check_reindex": {"minute": "0", "hour": "12", "day_of_week": "*"},
"check_reindex_notify": false,
"check_reindex_days": 90,
"thumbnail_check": {"minute": "0", "hour": "17", "day_of_week": "*"},
"run_backup": false,

View File

@ -1,4 +1,5 @@
"""custom models"""
from django.contrib.auth.models import (
AbstractBaseUser,
BaseUserManager,

View File

@ -16,9 +16,8 @@ from home.src.download.yt_dlp_base import YtWrap
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.playlist import YoutubePlaylist
from home.src.index.video_constants import VideoTypeEnum
from home.src.index.video_streams import DurationConverter
from home.src.ta.config import AppConfig
from home.src.ta.helper import is_shorts
from home.src.ta.helper import get_duration_str, is_shorts
class PendingIndex:
@ -114,7 +113,13 @@ class PendingInteract:
def update_status(self):
"""update status of pending item"""
if self.status == "priority":
data = {"doc": {"status": "pending", "auto_start": True}}
data = {
"doc": {
"status": "pending",
"auto_start": True,
"message": None,
}
}
else:
data = {"doc": {"status": self.status}}
@ -223,6 +228,11 @@ class PendingList(PendingIndex):
"""add all videos of playlist to list"""
playlist = YoutubePlaylist(url)
playlist.build_json()
if not playlist.json_data:
message = f"{playlist.youtube_id}: failed to extract metadata"
print(message)
raise ValueError(message)
video_results = playlist.json_data.get("playlist_entries")
youtube_ids = [i["youtube_id"] for i in video_results]
for video_id in youtube_ids:
@ -329,9 +339,6 @@ class PendingList(PendingIndex):
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
"""parse response"""
vid_id = vid.get("id")
duration_str = DurationConverter.get_str(vid["duration"])
if duration_str == "NA":
print(f"skip extracting duration for: {vid_id}")
published = datetime.strptime(vid["upload_date"], "%Y%m%d").strftime(
"%Y-%m-%d"
)
@ -343,7 +350,7 @@ class PendingList(PendingIndex):
"vid_thumb_url": vid["thumbnail"],
"title": vid["title"],
"channel_id": vid["channel_id"],
"duration": duration_str,
"duration": get_duration_str(vid["duration"]),
"published": published,
"timestamp": int(datetime.now().timestamp()),
# Pulling enum value out so it is serializable

View File

@ -12,7 +12,6 @@ from home.src.index.channel import YoutubeChannel
from home.src.index.playlist import YoutubePlaylist
from home.src.index.video_constants import VideoTypeEnum
from home.src.ta.config import AppConfig
from home.src.ta.ta_redis import RedisArchivist
from home.src.ta.urlparser import Parser
@ -188,6 +187,11 @@ class PlaylistSubscription:
playlist_h = YoutubePlaylist(playlist_id)
playlist_h.all_youtube_ids = all_youtube_ids
playlist_h.build_json()
if not playlist_h.json_data:
message = f"{playlist_h.youtube_id}: failed to extract data"
print(message)
raise ValueError(message)
playlist_h.json_data["playlist_subscribed"] = subscribed
playlist_h.upload_to_es()
playlist_h.add_vids_to_playlist()
@ -197,16 +201,13 @@ class PlaylistSubscription:
thumb = ThumbManager(playlist_id, item_type="playlist")
thumb.download_playlist_thumb(url)
# notify
message = {
"status": "message:subplaylist",
"level": "info",
"title": "Subscribing to Playlists",
"message": f"Processing {idx + 1} of {len(new_playlists)}",
}
RedisArchivist().set_message(
"message:subplaylist", message=message, expire=True
)
if self.task:
self.task.send_progress(
message_lines=[
f"Processing {idx + 1} of {len(new_playlists)}"
],
progress=(idx + 1) / len(new_playlists),
)
@staticmethod
def channel_validate(channel_id):
@ -332,7 +333,7 @@ class SubscriptionHandler:
self.task = task
self.to_subscribe = False
def subscribe(self):
def subscribe(self, expected_type=False):
"""subscribe to url_str items"""
if self.task:
self.task.send_progress(["Processing form content."])
@ -343,11 +344,16 @@ class SubscriptionHandler:
if self.task:
self._notify(idx, item, total)
self.subscribe_type(item)
self.subscribe_type(item, expected_type=expected_type)
def subscribe_type(self, item):
def subscribe_type(self, item, expected_type):
"""process single item"""
if item["type"] == "playlist":
if expected_type and expected_type != "playlist":
raise TypeError(
f"expected {expected_type} url but got {item.get('type')}"
)
PlaylistSubscription().process_url_str([item])
return
@ -360,6 +366,11 @@ class SubscriptionHandler:
else:
raise ValueError("failed to subscribe to: " + item["url"])
if expected_type and expected_type != "channel":
raise TypeError(
f"expected {expected_type} url but got {item.get('type')}"
)
self._subscribe(channel_id)
def _subscribe(self, channel_id):

View File

@ -11,7 +11,7 @@ from time import sleep
import requests
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.ta.config import AppConfig
from home.src.ta.settings import EnvironmentSettings
from mutagen.mp4 import MP4, MP4Cover
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
@ -21,8 +21,7 @@ ImageFile.LOAD_TRUNCATED_IMAGES = True
class ThumbManagerBase:
"""base class for thumbnail management"""
CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"]
CACHE_DIR = EnvironmentSettings.CACHE_DIR
VIDEO_DIR = os.path.join(CACHE_DIR, "videos")
CHANNEL_DIR = os.path.join(CACHE_DIR, "channels")
PLAYLIST_DIR = os.path.join(CACHE_DIR, "playlists")
@ -54,11 +53,14 @@ class ThumbManagerBase:
if response.status_code == 404:
return self.get_fallback()
except requests.exceptions.RequestException:
except (
requests.exceptions.RequestException,
requests.exceptions.ReadTimeout,
):
print(f"{self.item_id}: retry thumbnail download {url}")
sleep((i + 1) ** i)
return False
return self.get_fallback()
def get_fallback(self):
"""get fallback thumbnail if not available"""
@ -67,13 +69,13 @@ class ThumbManagerBase:
img_raw = Image.open(self.fallback)
return img_raw
app_root = self.CONFIG["application"]["app_root"]
app_root = EnvironmentSettings.APP_DIR
default_map = {
"video": os.path.join(
app_root, "static/img/default-video-thumb.jpg"
),
"playlist": os.path.join(
app_root, "static/img/default-video-thumb.jpg"
app_root, "static/img/default-playlist-thumb.jpg"
),
"icon": os.path.join(
app_root, "static/img/default-channel-icon.jpg"
@ -200,7 +202,18 @@ class ThumbManager(ThumbManagerBase):
if skip_existing and os.path.exists(thumb_path):
return
img_raw = self.download_raw(url)
img_raw = (
self.download_raw(url)
if not isinstance(url, str) or url.startswith("http")
else Image.open(os.path.join(self.CACHE_DIR, url))
)
width, height = img_raw.size
if not width / height == 16 / 9:
new_height = width / 16 * 9
offset = (height - new_height) / 2
img_raw = img_raw.crop((0, offset, width, height - offset))
img_raw = img_raw.resize((336, 189))
img_raw.convert("RGB").save(thumb_path)
def delete_video_thumb(self):
@ -243,9 +256,10 @@ class ThumbManager(ThumbManagerBase):
class ValidatorCallback:
"""handle callback validate thumbnails page by page"""
def __init__(self, source, index_name):
def __init__(self, source, index_name, counter=0):
self.source = source
self.index_name = index_name
self.counter = counter
def run(self):
"""run the task for page"""
@ -270,7 +284,7 @@ class ValidatorCallback:
urls = (
channel["_source"]["channel_thumb_url"],
channel["_source"]["channel_banner_url"],
channel["_source"]["channel_tvart_url"],
channel["_source"].get("channel_tvart_url", False),
)
handler = ThumbManager(channel["_source"]["channel_id"])
handler.download_channel_art(urls, skip_existing=True)
@ -376,14 +390,14 @@ class ThumbFilesystem:
class EmbedCallback:
"""callback class to embed thumbnails"""
CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"]
MEDIA_DIR = CONFIG["application"]["videos"]
CACHE_DIR = EnvironmentSettings.CACHE_DIR
MEDIA_DIR = EnvironmentSettings.MEDIA_DIR
FORMAT = MP4Cover.FORMAT_JPEG
def __init__(self, source, index_name):
def __init__(self, source, index_name, counter=0):
self.source = source
self.index_name = index_name
self.counter = counter
def run(self):
"""run embed"""

View File

@ -10,6 +10,7 @@ from http import cookiejar
from io import StringIO
import yt_dlp
from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisArchivist
@ -48,21 +49,33 @@ class YtWrap:
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
except yt_dlp.utils.DownloadError as err:
print(f"{url}: failed to download with message {err}")
if "Temporary failure in name resolution" in str(err):
raise ConnectionError("lost the internet, abort!") from err
return True
return False, str(err)
return True, 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")
except cookiejar.LoadError as err:
print(f"cookie file is invalid: {err}")
return False
except (yt_dlp.utils.ExtractorError, yt_dlp.utils.DownloadError):
print(f"{url}: failed to get info from youtube")
except yt_dlp.utils.ExtractorError as err:
print(f"{url}: failed to extract with message: {err}, continue...")
return False
except yt_dlp.utils.DownloadError as err:
if "This channel does not have a" in str(err):
return False
print(f"{url}: failed to get info from youtube with message {err}")
if "Temporary failure in name resolution" in str(err):
raise ConnectionError("lost the internet, abort!") from err
return False
return response
@ -74,6 +87,7 @@ class CookieHandler:
def __init__(self, config):
self.cookie_io = False
self.config = config
self.cache_dir = EnvironmentSettings.CACHE_DIR
def get(self):
"""get cookie io stream"""
@ -83,8 +97,9 @@ class CookieHandler:
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")
import_path = os.path.join(
self.cache_dir, "import", "cookies.google.txt"
)
try:
with open(import_path, encoding="utf-8") as cookie_file:
@ -99,10 +114,10 @@ class CookieHandler:
print("cookie: import successful")
def set_cookie(self, cookie):
"""set cookie str and activate in cofig"""
RedisArchivist().set_message("cookie", cookie)
"""set cookie str and activate in config"""
RedisArchivist().set_message("cookie", cookie, save=True)
path = ".downloads.cookie_import"
RedisArchivist().set_message("config", True, path=path)
RedisArchivist().set_message("config", True, path=path, save=True)
self.config["downloads"]["cookie_import"] = True
print("cookie: activated and stored in Redis")

View File

@ -20,7 +20,8 @@ from home.src.index.playlist import YoutubePlaylist
from home.src.index.video import YoutubeVideo, index_new_video
from home.src.index.video_constants import VideoTypeEnum
from home.src.ta.config import AppConfig
from home.src.ta.helper import clean_string, ignore_filelist
from home.src.ta.helper import ignore_filelist
from home.src.ta.settings import EnvironmentSettings
class DownloadPostProcess:
@ -49,7 +50,7 @@ class DownloadPostProcess:
return
print(f"auto delete older than {autodelete_days} days")
now_lte = self.now - autodelete_days * 24 * 60 * 60
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
data = {
"query": {"range": {"player.watched_date": {"lte": now_lte}}},
"sort": [{"player.watched_date": {"order": "asc"}}],
@ -62,7 +63,7 @@ class DownloadPostProcess:
if "autodelete_days" in value:
autodelete_days = value.get("autodelete_days")
print(f"{channel_id}: delete older than {autodelete_days}d")
now_lte = self.now - autodelete_days * 24 * 60 * 60
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
must_list = [
{"range": {"player.watched_date": {"lte": now_lte}}},
{"term": {"channel.channel_id": {"value": channel_id}}},
@ -153,6 +154,8 @@ class VideoDownloader:
self.youtube_id_list = youtube_id_list
self.task = task
self.config = AppConfig().config
self.cache_dir = EnvironmentSettings.CACHE_DIR
self.media_dir = EnvironmentSettings.MEDIA_DIR
self._build_obs()
self.channels = set()
self.videos = set()
@ -163,6 +166,7 @@ class VideoDownloader:
while True:
video_data = self._get_next(auto_only)
if self.task.is_stopped() or not video_data:
self._reset_auto()
break
youtube_id = video_data.get("youtube_id")
@ -173,7 +177,7 @@ class VideoDownloader:
if not success:
continue
self._notify(video_data, "Add video metadata to index")
self._notify(video_data, "Add video metadata to index", progress=1)
vid_dict = index_new_video(
youtube_id,
@ -191,24 +195,29 @@ class VideoDownloader:
self._add_subscribed_channels()
DownloadPostProcess(self).run()
def _notify(self, video_data, message):
return self.videos
def _notify(self, video_data, message, progress=False):
"""send progress notification to task"""
if not self.task:
return
typ = VideoTypeEnum(video_data["vid_type"]).value.rstrip("s").title()
title = video_data.get("title")
self.task.send_progress([f"Processing {typ}: {title}", message])
self.task.send_progress(
[f"Processing {typ}: {title}", message], progress=progress
)
def _get_next(self, auto_only):
"""get next item in queue"""
must_list = [{"term": {"status": {"value": "pending"}}}]
must_not_list = [{"exists": {"field": "message"}}]
if auto_only:
must_list.append({"term": {"auto_start": {"value": True}}})
data = {
"size": 1,
"query": {"bool": {"must": must_list}},
"query": {"bool": {"must": must_list, "must_not": must_not_list}},
"sort": [
{"auto_start": {"order": "desc"}},
{"timestamp": {"order": "asc"}},
@ -258,10 +267,7 @@ class VideoDownloader:
"""initial obs"""
self.obs = {
"merge_output_format": "mp4",
"outtmpl": (
self.config["application"]["cache_dir"]
+ "/download/%(id)s.mp4"
),
"outtmpl": (self.cache_dir + "/download/%(id)s.mp4"),
"progress_hooks": [self._progress_hook],
"noprogress": True,
"continuedl": True,
@ -336,7 +342,7 @@ class VideoDownloader:
if format_overwrite:
obs["format"] = format_overwrite
dl_cache = self.config["application"]["cache_dir"] + "/download/"
dl_cache = self.cache_dir + "/download/"
# check if already in cache to continue from there
all_cached = ignore_filelist(os.listdir(dl_cache))
@ -344,7 +350,9 @@ class VideoDownloader:
if youtube_id in file_name:
obs["outtmpl"] = os.path.join(dl_cache, file_name)
success = YtWrap(obs, self.config).download(youtube_id)
success, message = YtWrap(obs, self.config).download(youtube_id)
if not success:
self._handle_error(youtube_id, message)
if self.obs["writethumbnail"]:
# webp files don't get cleaned up automatically
@ -356,29 +364,28 @@ class VideoDownloader:
return success
@staticmethod
def _handle_error(youtube_id, message):
"""store error message"""
data = {"doc": {"message": message}}
_, _ = ElasticWrap(f"ta_download/_update/{youtube_id}").post(data=data)
def move_to_archive(self, vid_dict):
"""move downloaded video from cache to archive"""
videos = self.config["application"]["videos"]
host_uid = self.config["application"]["HOST_UID"]
host_gid = self.config["application"]["HOST_GID"]
channel_name = clean_string(vid_dict["channel"]["channel_name"])
if len(channel_name) <= 3:
# fall back to channel id
channel_name = vid_dict["channel"]["channel_id"]
# make archive folder with correct permissions
new_folder = os.path.join(videos, channel_name)
if not os.path.exists(new_folder):
os.makedirs(new_folder)
if host_uid and host_gid:
os.chown(new_folder, host_uid, host_gid)
# find real filename
cache_dir = self.config["application"]["cache_dir"]
all_cached = ignore_filelist(os.listdir(cache_dir + "/download/"))
for file_str in all_cached:
if vid_dict["youtube_id"] in file_str:
old_file = file_str
old_path = os.path.join(cache_dir, "download", old_file)
new_path = os.path.join(videos, vid_dict["media_url"])
host_uid = EnvironmentSettings.HOST_UID
host_gid = EnvironmentSettings.HOST_GID
# make folder
folder = os.path.join(
self.media_dir, vid_dict["channel"]["channel_id"]
)
if not os.path.exists(folder):
os.makedirs(folder)
if host_uid and host_gid:
os.chown(folder, host_uid, host_gid)
# move media file
media_file = vid_dict["youtube_id"] + ".mp4"
old_path = os.path.join(self.cache_dir, "download", media_file)
new_path = os.path.join(self.media_dir, vid_dict["media_url"])
# move media file and fix permission
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
if host_uid and host_gid:
@ -401,3 +408,18 @@ class VideoDownloader:
self.channels.add(channel_id)
return
def _reset_auto(self):
"""reset autostart to defaults after queue stop"""
path = "ta_download/_update_by_query"
data = {
"query": {"term": {"auto_start": {"value": True}}},
"script": {
"source": "ctx._source.auto_start = false",
"lang": "painless",
},
}
response, _ = ElasticWrap(path).post(data=data)
updated = response.get("updated")
if updated:
print(f"[download] reset auto start on {updated} videos.")

View File

@ -13,14 +13,18 @@ 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
from home.src.ta.settings import EnvironmentSettings
class ElasticBackup:
"""dump index to nd-json files for later bulk import"""
INDEX_SPLIT = ["comment"]
CACHE_DIR = EnvironmentSettings.CACHE_DIR
BACKUP_DIR = os.path.join(CACHE_DIR, "backup")
def __init__(self, reason=False, task=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
@ -51,14 +55,18 @@ class ElasticBackup:
def backup_index(self, index_name):
"""export all documents of a single index"""
paginate = IndexPaginate(
f"ta_{index_name}",
data={"query": {"match_all": {}}},
keep_source=True,
callback=BackupCallback,
task=self.task,
total=self._get_total(index_name),
)
paginate_kwargs = {
"data": {"query": {"match_all": {}}},
"keep_source": True,
"callback": BackupCallback,
"task": self.task,
"total": self._get_total(index_name),
}
if index_name in self.INDEX_SPLIT:
paginate_kwargs.update({"size": 200})
paginate = IndexPaginate(f"ta_{index_name}", **paginate_kwargs)
_ = paginate.get_results()
@staticmethod
@ -72,14 +80,13 @@ class ElasticBackup:
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):
for file in os.listdir(self.BACKUP_DIR):
if file.endswith(".json"):
to_backup.append(os.path.join(folder, file))
to_backup.append(os.path.join(self.BACKUP_DIR, file))
backup_file = os.path.join(folder, file_name)
backup_file = os.path.join(self.BACKUP_DIR, file_name)
comp = zipfile.ZIP_DEFLATED
with zipfile.ZipFile(backup_file, "w", compression=comp) as zip_f:
@ -92,7 +99,7 @@ class ElasticBackup:
def post_bulk_restore(self, file_name):
"""send bulk to es"""
file_path = os.path.join(self.cache_dir, file_name)
file_path = os.path.join(self.CACHE_DIR, file_name)
with open(file_path, "r", encoding="utf-8") as f:
data = f.read()
@ -103,9 +110,7 @@ class ElasticBackup:
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_backup_files = ignore_filelist(os.listdir(self.BACKUP_DIR))
all_available_backups = [
i
for i in all_backup_files
@ -114,24 +119,36 @@ class ElasticBackup:
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)
for filename in all_available_backups:
data = self.build_backup_file_data(filename)
backup_dicts.append(data)
return backup_dicts
def build_backup_file_data(self, filename):
"""build metadata of single backup file"""
file_path = os.path.join(self.BACKUP_DIR, filename)
if not os.path.exists(file_path):
return False
file_split = filename.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")
data = {
"filename": filename,
"file_path": file_path,
"file_size": os.path.getsize(file_path),
"timestamp": timestamp,
"reason": reason,
}
return data
def restore(self, filename):
"""
restore from backup zip file
@ -142,22 +159,19 @@ class ElasticBackup:
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)
file_path = os.path.join(self.BACKUP_DIR, filename)
with zipfile.ZipFile(file_path, "r") as z:
zip_content = z.namelist()
z.extractall(backup_dir)
z.extractall(self.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 idx, json_f in enumerate(zip_content):
self._notify_restore(idx, json_f, len(zip_content))
file_name = os.path.join(backup_dir, json_f)
file_name = os.path.join(self.BACKUP_DIR, json_f)
if not json_f.startswith("es_") or not json_f.endswith(".json"):
os.remove(file_name)
@ -194,22 +208,32 @@ class ElasticBackup:
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)
self.delete_file(to_delete["filename"])
def delete_file(self, filename):
"""delete backup file"""
file_path = os.path.join(self.BACKUP_DIR, filename)
if not os.path.exists(file_path):
print(f"backup file not found: {filename}")
return False
print(f"remove old backup file: {file_path}")
os.remove(file_path)
return file_path
class BackupCallback:
"""handle backup ndjson writer as callback for IndexPaginate"""
def __init__(self, source, index_name):
def __init__(self, source, index_name, counter=0):
self.source = source
self.index_name = index_name
self.counter = counter
self.timestamp = datetime.now().strftime("%Y%m%d")
self.cache_dir = EnvironmentSettings.CACHE_DIR
def run(self):
"""run the junk task"""
@ -236,8 +260,8 @@ class BackupCallback:
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)
index = self.index_name.lstrip("ta_")
file_name = f"es_{index}-{self.timestamp}-{self.counter}.json"
file_path = os.path.join(self.cache_dir, "backup", file_name)
with open(file_path, "a+", encoding="utf-8") as f:
f.write(file_content)

View File

@ -3,12 +3,15 @@ functionality:
- wrapper around requests to call elastic search
- reusable search_after to extract total index
"""
# pylint: disable=missing-timeout
import json
from typing import Any
import requests
from home.src.ta.config import AppConfig
import urllib3
from home.src.ta.settings import EnvironmentSettings
class ElasticWrap:
@ -16,61 +19,94 @@ class ElasticWrap:
returns response json and status code tuple
"""
def __init__(self, path, config=False):
self.url = False
self.auth = False
self.path = path
self.config = config
self._get_config()
def __init__(self, path: str):
self.url: str = f"{EnvironmentSettings.ES_URL}/{path}"
self.auth: tuple[str, str] = (
EnvironmentSettings.ES_USER,
EnvironmentSettings.ES_PASS,
)
def _get_config(self):
"""add config if not passed"""
if not self.config:
self.config = AppConfig().config
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
es_url = self.config["application"]["es_url"]
self.auth = self.config["application"]["es_auth"]
self.url = f"{es_url}/{self.path}"
def get(self, data=False, timeout=10, print_error=True):
def get(
self,
data: bool | dict = False,
timeout: int = 10,
print_error: bool = True,
) -> tuple[dict, int]:
"""get data from es"""
kwargs: dict[str, Any] = {
"auth": self.auth,
"timeout": timeout,
}
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
kwargs["verify"] = False
if data:
response = requests.get(
self.url, json=data, auth=self.auth, timeout=timeout
)
else:
response = requests.get(self.url, auth=self.auth, timeout=timeout)
kwargs["json"] = data
response = requests.get(self.url, **kwargs)
if print_error and not response.ok:
print(response.text)
return response.json(), response.status_code
def post(self, data=False, ndjson=False):
def post(
self, data: bool | dict = False, ndjson: bool = False
) -> tuple[dict, int]:
"""post data to es"""
if ndjson:
headers = {"Content-type": "application/x-ndjson"}
payload = data
else:
headers = {"Content-type": "application/json"}
payload = json.dumps(data)
if data:
response = requests.post(
self.url, data=payload, headers=headers, auth=self.auth
kwargs: dict[str, Any] = {"auth": self.auth}
if ndjson and data:
kwargs.update(
{
"headers": {"Content-type": "application/x-ndjson"},
"data": data,
}
)
else:
response = requests.post(self.url, headers=headers, auth=self.auth)
elif data:
kwargs.update(
{
"headers": {"Content-type": "application/json"},
"data": json.dumps(data),
}
)
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
kwargs["verify"] = False
response = requests.post(self.url, **kwargs)
if not response.ok:
print(response.text)
return response.json(), response.status_code
def put(self, data, refresh=False):
def put(
self,
data: bool | dict = False,
refresh: bool = False,
) -> tuple[dict, Any]:
"""put data to es"""
if refresh:
self.url = f"{self.url}/?refresh=true"
response = requests.put(f"{self.url}", json=data, auth=self.auth)
kwargs: dict[str, Any] = {
"json": data,
"auth": self.auth,
}
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
kwargs["verify"] = False
response = requests.put(self.url, **kwargs)
if not response.ok:
print(response.text)
print(data)
@ -78,14 +114,25 @@ class ElasticWrap:
return response.json(), response.status_code
def delete(self, data=False, refresh=False):
def delete(
self,
data: bool | dict = False,
refresh: bool = False,
) -> tuple[dict, Any]:
"""delete document from es"""
if refresh:
self.url = f"{self.url}/?refresh=true"
kwargs: dict[str, Any] = {"auth": self.auth}
if data:
response = requests.delete(self.url, json=data, auth=self.auth)
else:
response = requests.delete(self.url, auth=self.auth)
kwargs["json"] = data
if EnvironmentSettings.ES_DISABLE_VERIFY_SSL:
kwargs["verify"] = False
response = requests.delete(self.url, **kwargs)
if not response.ok:
print(response.text)
@ -127,6 +174,12 @@ class IndexPaginate:
def validate_data(self):
"""add pit and size to data"""
if not self.data:
self.data = {}
if "query" not in self.data.keys():
self.data.update({"query": {"match_all": {}}})
if "sort" not in self.data.keys():
self.data.update({"sort": [{"_doc": {"order": "desc"}}]})
@ -150,7 +203,9 @@ class IndexPaginate:
all_results.append(hit["_source"])
if self.kwargs.get("callback"):
self.kwargs.get("callback")(all_hits, self.index_name).run()
self.kwargs.get("callback")(
all_hits, self.index_name, counter=counter
).run()
if self.kwargs.get("task"):
print(f"{self.index_name}: processing page {counter}")

View File

@ -1,5 +1,17 @@
{
"index_config": [{
"index_name": "config",
"expected_map": {
"config": {
"type": "object",
"enabled": false
}
},
"expected_set": {
"number_of_replicas": "0"
}
},
{
"index_name": "channel",
"expected_map": {
"channel_id": {
@ -37,7 +49,8 @@
"type": "text"
},
"channel_last_refresh": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"channel_tags": {
"type": "text",
@ -61,7 +74,7 @@
"type": "boolean"
},
"integrate_sponsorblock": {
"type" : "boolean"
"type": "boolean"
}
}
}
@ -90,7 +103,8 @@
"index": false
},
"date_downloaded": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"channel": {
"properties": {
@ -129,7 +143,8 @@
"type": "text"
},
"channel_last_refresh": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"channel_tags": {
"type": "text",
@ -153,7 +168,7 @@
"type": "boolean"
},
"integrate_sponsorblock": {
"type" : "boolean"
"type": "boolean"
}
}
}
@ -196,7 +211,8 @@
}
},
"vid_last_refresh": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"youtube_id": {
"type": "keyword"
@ -220,19 +236,37 @@
"comment_count": {
"type": "long"
},
"stats" : {
"properties" : {
"average_rating" : {
"type" : "float"
"stats": {
"properties": {
"average_rating": {
"type": "float"
},
"dislike_count" : {
"type" : "long"
"dislike_count": {
"type": "long"
},
"like_count" : {
"type" : "long"
"like_count": {
"type": "long"
},
"view_count" : {
"type" : "long"
"view_count": {
"type": "long"
}
}
},
"player": {
"properties": {
"duration": {
"type": "long"
},
"duration_str": {
"type": "keyword",
"index": false
},
"watched": {
"type": "boolean"
},
"watched_date": {
"type": "date",
"format": "epoch_second"
}
}
},
@ -289,7 +323,8 @@
"sponsorblock": {
"properties": {
"last_refresh": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"has_unlocked": {
"type": "boolean"
@ -297,28 +332,28 @@
"is_enabled": {
"type": "boolean"
},
"segments" : {
"properties" : {
"UUID" : {
"segments": {
"properties": {
"UUID": {
"type": "keyword"
},
"actionType" : {
"actionType": {
"type": "keyword"
},
"category" : {
"category": {
"type": "keyword"
},
"locked" : {
"type" : "short"
"locked": {
"type": "short"
},
"segment" : {
"type" : "float"
"segment": {
"type": "float"
},
"videoDuration" : {
"type" : "float"
"videoDuration": {
"type": "float"
},
"votes" : {
"type" : "long"
"votes": {
"type": "long"
}
}
}
@ -341,7 +376,8 @@
"index_name": "download",
"expected_map": {
"timestamp": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"channel_id": {
"type": "keyword"
@ -380,6 +416,9 @@
},
"auto_start": {
"type": "boolean"
},
"message": {
"type": "text"
}
},
"expected_set": {
@ -436,7 +475,43 @@
"type": "keyword"
},
"playlist_last_refresh": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"playlist_entries": {
"properties": {
"downloaded": {
"type": "boolean"
},
"idx": {
"type": "long"
},
"title": {
"type": "text",
"analyzer": "english",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256,
"normalizer": "to_lower"
}
}
},
"uploader": {
"type": "text",
"analyzer": "english",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256,
"normalizer": "to_lower"
}
}
},
"youtube_id": {
"type": "keyword"
}
}
}
},
"expected_set": {
@ -490,10 +565,11 @@
"type": "text"
},
"subtitle_last_refresh": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"subtitle_index": {
"type" : "long"
"type": "long"
},
"subtitle_lang": {
"type": "keyword"
@ -502,7 +578,7 @@
"type": "keyword"
},
"subtitle_line": {
"type" : "text",
"type": "text",
"analyzer": "english"
}
},
@ -525,7 +601,8 @@
"type": "keyword"
},
"comment_last_refresh": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"comment_channel_id": {
"type": "keyword"
@ -536,13 +613,14 @@
"type": "keyword"
},
"comment_text": {
"type" : "text"
"type": "text"
},
"comment_timestamp": {
"type": "date"
"type": "date",
"format": "epoch_second"
},
"comment_time_text": {
"type" : "text"
"type": "text"
},
"comment_likecount": {
"type": "long"

View File

@ -4,12 +4,12 @@ functionality:
"""
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
from home.src.ta.settings import EnvironmentSettings
class ElasticSnapshot:
@ -19,7 +19,7 @@ class ElasticSnapshot:
REPO_SETTINGS = {
"compress": "true",
"chunk_size": "1g",
"location": "/usr/share/elasticsearch/data/snapshot",
"location": EnvironmentSettings.ES_SNAPSHOT_DIR,
}
POLICY = "ta_daily"
@ -254,7 +254,7 @@ class ElasticSnapshot:
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 = local_datetime.astimezone(ZoneInfo(EnvironmentSettings.TZ))
converted_str = converted.strftime("%Y-%m-%d %H:%M")
return converted_str

View File

@ -1,154 +0,0 @@
"""
Functionality:
- collection of functions and tasks from frontend
- called via user input
"""
from home.src.download.subscriptions import (
ChannelSubscription,
PlaylistSubscription,
)
from home.src.index.playlist import YoutubePlaylist
from home.src.ta.ta_redis import RedisArchivist
from home.src.ta.urlparser import Parser
from home.tasks import run_restore_backup, subscribe_to
class PostData:
"""
map frontend http post values to backend funcs
handover long running tasks to celery
"""
def __init__(self, post_dict, current_user):
self.post_dict = post_dict
self.to_exec, self.exec_val = list(post_dict.items())[0]
self.current_user = current_user
def run_task(self):
"""execute and return task result"""
to_exec = self.exec_map()
task_result = to_exec()
return task_result
def exec_map(self):
"""map dict key and return function to execute"""
exec_map = {
"change_view": self._change_view,
"change_grid": self._change_grid,
"unsubscribe": self._unsubscribe,
"subscribe": self._subscribe,
"sort_order": self._sort_order,
"hide_watched": self._hide_watched,
"show_subed_only": self._show_subed_only,
"show_ignored_only": self._show_ignored_only,
"db-restore": self._db_restore,
"delete-playlist": self._delete_playlist,
}
return exec_map[self.to_exec]
def _change_view(self):
"""process view changes in home, channel, and downloads"""
origin, new_view = self.exec_val.split(":")
key = f"{self.current_user}:view:{origin}"
print(f"change view: {key} to {new_view}")
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}
def _unsubscribe(self):
"""unsubscribe from channels or playlists"""
id_unsub = self.exec_val
print(f"{id_unsub}: unsubscribe")
to_unsub_list = Parser(id_unsub).parse()
for to_unsub in to_unsub_list:
unsub_type = to_unsub["type"]
unsub_id = to_unsub["url"]
if unsub_type == "playlist":
PlaylistSubscription().change_subscribe(
unsub_id, subscribe_status=False
)
elif unsub_type == "channel":
ChannelSubscription().change_subscribe(
unsub_id, channel_subscribed=False
)
else:
raise ValueError("failed to process " + id_unsub)
return {"success": True}
def _subscribe(self):
"""subscribe to channel or playlist, called from js buttons"""
id_sub = self.exec_val
print(f"{id_sub}: subscribe")
subscribe_to.delay(id_sub)
return {"success": True}
def _sort_order(self):
"""change the sort between published to downloaded"""
sort_order = {"status": self.exec_val}
if self.exec_val in ["asc", "desc"]:
RedisArchivist().set_message(
f"{self.current_user}:sort_order", sort_order
)
else:
RedisArchivist().set_message(
f"{self.current_user}:sort_by", sort_order
)
return {"success": True}
def _hide_watched(self):
"""toggle if to show watched vids or not"""
key = f"{self.current_user}:hide_watched"
message = {"status": bool(int(self.exec_val))}
print(f"toggle {key}: {message}")
RedisArchivist().set_message(key, message)
return {"success": True}
def _show_subed_only(self):
"""show or hide subscribed channels only on channels page"""
key = f"{self.current_user}:show_subed_only"
message = {"status": bool(int(self.exec_val))}
print(f"toggle {key}: {message}")
RedisArchivist().set_message(key, message)
return {"success": True}
def _show_ignored_only(self):
"""switch view on /downloads/ to show ignored only"""
show_value = self.exec_val
key = f"{self.current_user}:show_ignored_only"
value = {"status": show_value}
print(f"Filter download view ignored only: {show_value}")
RedisArchivist().set_message(key, value)
return {"success": True}
def _db_restore(self):
"""restore es zip from settings page"""
print("restoring index from backup zip")
filename = self.exec_val
run_restore_backup.delay(filename)
return {"success": True}
def _delete_playlist(self):
"""delete playlist, only metadata or incl all videos"""
playlist_dict = self.exec_val
playlist_id = playlist_dict["playlist-id"]
playlist_action = playlist_dict["playlist-action"]
print(f"{playlist_id}: delete playlist {playlist_action}")
if playlist_action == "metadata":
YoutubePlaylist(playlist_id).delete_metadata()
elif playlist_action == "all":
YoutubePlaylist(playlist_id).delete_videos_playlist()
return {"success": True}

View File

@ -2,9 +2,12 @@
- hold all form classes used in the views
"""
import os
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.forms.widgets import PasswordInput, TextInput
from home.src.ta.helper import get_stylesheets
class CustomAuthForm(AuthenticationForm):
@ -29,14 +32,16 @@ class CustomAuthForm(AuthenticationForm):
class UserSettingsForm(forms.Form):
"""user configurations values"""
CHOICES = [
("", "-- change color scheme --"),
("dark", "Dark"),
("light", "Light"),
]
STYLESHEET_CHOICES = [("", "-- change stylesheet --")]
STYLESHEET_CHOICES.extend(
[
(stylesheet, os.path.splitext(stylesheet)[0].title())
for stylesheet in get_stylesheets()
]
)
colors = forms.ChoiceField(
widget=forms.Select, choices=CHOICES, required=False
stylesheet = forms.ChoiceField(
widget=forms.Select, choices=STYLESHEET_CHOICES, required=False
)
page_size = forms.IntegerField(required=False)
@ -100,8 +105,8 @@ class ApplicationSettingsForm(forms.Form):
COOKIE_IMPORT_CHOICES = [
("", "-- change cookie settings"),
("0", "disable cookie"),
("1", "enable cookie"),
("0", "remove cookie"),
("1", "import cookie"),
]
subscriptions_channel_size = forms.IntegerField(
@ -157,9 +162,41 @@ class ApplicationSettingsForm(forms.Form):
class SchedulerSettingsForm(forms.Form):
"""handle scheduler settings"""
HELP_TEXT = "Add Apprise notification URLs, one per line"
update_subscribed = forms.CharField(required=False)
update_subscribed_notify = forms.CharField(
label=False,
widget=forms.Textarea(
attrs={
"rows": 2,
"placeholder": HELP_TEXT,
}
),
required=False,
)
download_pending = forms.CharField(required=False)
download_pending_notify = forms.CharField(
label=False,
widget=forms.Textarea(
attrs={
"rows": 2,
"placeholder": HELP_TEXT,
}
),
required=False,
)
check_reindex = forms.CharField(required=False)
check_reindex_notify = forms.CharField(
label=False,
widget=forms.Textarea(
attrs={
"rows": 2,
"placeholder": HELP_TEXT,
}
),
required=False,
)
check_reindex_days = forms.IntegerField(required=False)
thumbnail_check = forms.CharField(required=False)
run_backup = forms.CharField(required=False)
@ -228,6 +265,20 @@ class SubscribeToPlaylistForm(forms.Form):
)
class CreatePlaylistForm(forms.Form):
"""text area form to create a single custom playlist"""
create = forms.CharField(
label="Or create custom playlist",
widget=forms.Textarea(
attrs={
"rows": 1,
"placeholder": "Input playlist name",
}
),
)
class ChannelOverwriteForm(forms.Form):
"""custom overwrites for channel settings"""

View File

@ -6,116 +6,18 @@ Functionality:
- calculate pagination values
"""
import urllib.parse
from datetime import datetime
from home.src.download.thumbnails import ThumbManager
from api.src.search_processor import SearchProcess
from home.src.es.connect import ElasticWrap
from home.src.index.video_streams import DurationConverter
from home.src.ta.config import AppConfig
class SearchHandler:
"""search elastic search"""
def __init__(self, path, config, data=False):
self.max_hits = None
self.aggs = None
self.path = path
self.config = config
self.data = data
def get_data(self):
"""get the data"""
response, _ = ElasticWrap(self.path, config=self.config).get(self.data)
if "hits" in response.keys():
self.max_hits = response["hits"]["total"]["value"]
return_value = response["hits"]["hits"]
else:
# simulate list for single result to reuse rest of class
return_value = [response]
if not return_value:
return False
for idx, hit in enumerate(return_value):
return_value[idx] = self.hit_cleanup(hit)
if response.get("aggregations"):
self.aggs = response["aggregations"]
if "total_duration" in self.aggs:
duration_sec = self.aggs["total_duration"]["value"]
self.aggs["total_duration"].update(
{"value_str": DurationConverter().get_str(duration_sec)}
)
return return_value
@staticmethod
def hit_cleanup(hit):
"""clean up and parse data from a single hit"""
hit["source"] = hit.pop("_source")
hit_keys = hit["source"].keys()
if "media_url" in hit_keys:
parsed_url = urllib.parse.quote(hit["source"]["media_url"])
hit["source"]["media_url"] = parsed_url
if "published" in hit_keys:
published = hit["source"]["published"]
date_pub = datetime.strptime(published, "%Y-%m-%d")
date_str = datetime.strftime(date_pub, "%d %b, %Y")
hit["source"]["published"] = date_str
if "vid_last_refresh" in hit_keys:
vid_last_refresh = hit["source"]["vid_last_refresh"]
date_refresh = datetime.fromtimestamp(vid_last_refresh)
date_str = datetime.strftime(date_refresh, "%d %b, %Y")
hit["source"]["vid_last_refresh"] = date_str
if "playlist_last_refresh" in hit_keys:
playlist_last_refresh = hit["source"]["playlist_last_refresh"]
date_refresh = datetime.fromtimestamp(playlist_last_refresh)
date_str = datetime.strftime(date_refresh, "%d %b, %Y")
hit["source"]["playlist_last_refresh"] = date_str
if "vid_thumb_url" 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}"
if "channel_last_refresh" in hit_keys:
refreshed = hit["source"]["channel_last_refresh"]
date_refresh = datetime.fromtimestamp(refreshed)
date_str = datetime.strftime(date_refresh, "%d %b, %Y")
hit["source"]["channel_last_refresh"] = date_str
if "channel" in hit_keys:
channel_keys = hit["source"]["channel"].keys()
if "channel_last_refresh" in channel_keys:
refreshed = hit["source"]["channel"]["channel_last_refresh"]
date_refresh = datetime.fromtimestamp(refreshed)
date_str = datetime.strftime(date_refresh, "%d %b, %Y")
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
class SearchForm:
"""build query from search form data"""
CONFIG = AppConfig().config
def multi_search(self, search_query):
"""searching through index"""
path, query, query_type = SearchParser(search_query).run()
look_up = SearchHandler(path, config=self.CONFIG, data=query)
search_results = look_up.get_data()
response, _ = ElasticWrap(path).get(data=query)
search_results = SearchProcess(response).process()
all_results = self.build_results(search_results)
return {"results": all_results, "queryType": query_type}
@ -465,7 +367,6 @@ class QueryBuilder:
query = {
"size": 30,
"_source": {"excludes": "subtitle_line"},
"query": {"bool": {"must": must_list}},
"highlight": {
"fields": {

View File

@ -14,7 +14,7 @@ from home.src.download.yt_dlp_base import YtWrap
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.generic import YouTubeItem
from home.src.index.playlist import YoutubePlaylist
from home.src.ta.helper import clean_string
from home.src.ta.settings import EnvironmentSettings
class YoutubeChannel(YouTubeItem):
@ -23,10 +23,7 @@ class YoutubeChannel(YouTubeItem):
es_path = False
index_name = "ta_channel"
yt_base = "https://www.youtube.com/channel/"
yt_obs = {
"extract_flat": True,
"allow_playlist_files": True,
}
yt_obs = {"playlist_items": "0,0"}
def __init__(self, youtube_id, task=False):
super().__init__(youtube_id)
@ -69,7 +66,7 @@ class YoutubeChannel(YouTubeItem):
"channel_banner_url": self._get_banner_art(),
"channel_thumb_url": self._get_thumb_art(),
"channel_tvart_url": self._get_tv_art(),
"channel_views": self.youtube_meta.get("view_count", 0),
"channel_views": self.youtube_meta.get("view_count") or 0,
}
def _parse_tags(self, tags):
@ -93,8 +90,9 @@ class YoutubeChannel(YouTubeItem):
def _get_tv_art(self):
"""extract tv artwork"""
for i in self.youtube_meta["thumbnails"]:
if i.get("id") == "avatar_uncropped":
if i.get("id") == "banner_uncropped":
return i["url"]
for i in self.youtube_meta["thumbnails"]:
if not i.get("width"):
continue
if i["width"] // i["height"] < 2 and not i["width"] == i["height"]:
@ -134,7 +132,7 @@ class YoutubeChannel(YouTubeItem):
def _info_json_fallback(self):
"""read channel info.json for additional metadata"""
info_json = os.path.join(
self.config["application"]["cache_dir"],
EnvironmentSettings.CACHE_DIR,
"import",
f"{self.youtube_id}.info.json",
)
@ -177,12 +175,10 @@ class YoutubeChannel(YouTubeItem):
def get_folder_path(self):
"""get folder where media files get stored"""
channel_name = self.json_data["channel_name"]
folder_name = clean_string(channel_name)
if len(folder_name) <= 3:
# fall back to channel id
folder_name = self.json_data["channel_id"]
folder_path = os.path.join(self.app_conf["videos"], folder_name)
folder_path = os.path.join(
EnvironmentSettings.MEDIA_DIR,
self.json_data["channel_id"],
)
return folder_path
def delete_es_videos(self):
@ -203,11 +199,21 @@ class YoutubeChannel(YouTubeItem):
}
_, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
def delete_es_subtitles(self):
"""delete all subtitles from this channel"""
data = {
"query": {
"term": {"subtitle_channel_id": {"value": self.youtube_id}}
}
}
_, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data)
def delete_playlists(self):
"""delete all indexed playlist from es"""
all_playlists = self.get_indexed_playlists()
for playlist in all_playlists:
playlist_id = playlist["playlist_id"]
playlist = YoutubePlaylist(playlist_id)
YoutubePlaylist(playlist_id).delete_metadata()
def delete_channel(self):
@ -233,6 +239,7 @@ class YoutubeChannel(YouTubeItem):
print(f"{self.youtube_id}: delete indexed videos")
self.delete_es_videos()
self.delete_es_comments()
self.delete_es_subtitles()
self.del_in_es()
def index_channel_playlists(self):

View File

@ -63,10 +63,12 @@ class Comments:
"check_formats": None,
"skip_download": True,
"getcomments": True,
"ignoreerrors": True,
"extractor_args": {
"youtube": {
"max_comments": max_comments_list,
"comment_sort": [comment_sort],
"player_client": ["ios", "web"], # workaround yt-dlp #9554
}
},
}
@ -76,7 +78,7 @@ class Comments:
def get_yt_comments(self):
"""get comments from youtube"""
yt_obs = self.build_yt_obs()
info_json = YtWrap(yt_obs).extract(self.youtube_id)
info_json = YtWrap(yt_obs, config=self.config).extract(self.youtube_id)
if not info_json:
return False, False
@ -114,17 +116,22 @@ class Comments:
time_text = time_text_datetime.strftime(format_string)
if not comment.get("author"):
comment["author"] = comment.get("author_id", "Unknown")
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_likecount": comment.get("like_count", None),
"comment_is_favorited": comment.get("is_favorited", False),
"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_author_is_uploader": comment.get(
"author_is_uploader", False
),
"comment_parent": comment["parent"],
}

View File

@ -1,205 +1,124 @@
"""
Functionality:
- reindexing old documents
- syncing updated values between indexes
- scan the filesystem to delete or index
"""
import json
import os
from home.src.download.queue import PendingList
from home.src.es.connect import ElasticWrap
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.comments import CommentList
from home.src.index.video import index_new_video
from home.src.ta.config import AppConfig
from home.src.ta.helper import clean_string, ignore_filelist
from PIL import ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True
from home.src.index.video import YoutubeVideo, index_new_video
from home.src.ta.helper import ignore_filelist
from home.src.ta.settings import EnvironmentSettings
class ScannerBase:
"""scan the filesystem base class"""
class Scanner:
"""scan index and filesystem"""
CONFIG = AppConfig().config
VIDEOS = CONFIG["application"]["videos"]
VIDEOS: str = EnvironmentSettings.MEDIA_DIR
def __init__(self):
self.to_index = False
self.to_delete = False
self.mismatch = False
self.to_rename = False
def scan(self):
"""entry point, scan and compare"""
all_downloaded = self._get_all_downloaded()
all_indexed = self._get_all_indexed()
self.list_comarison(all_downloaded, all_indexed)
def _get_all_downloaded(self):
"""get a list of all video files downloaded"""
channels = os.listdir(self.VIDEOS)
all_channels = ignore_filelist(channels)
all_channels.sort()
all_downloaded = []
for channel_name in all_channels:
channel_path = os.path.join(self.VIDEOS, channel_name)
channel_files = os.listdir(channel_path)
channel_files_clean = ignore_filelist(channel_files)
all_videos = [i for i in channel_files_clean if i.endswith(".mp4")]
for video in all_videos:
youtube_id = video[9:20]
all_downloaded.append((channel_name, video, youtube_id))
return all_downloaded
@staticmethod
def _get_all_indexed():
"""get a list of all indexed videos"""
index_handler = PendingList()
index_handler.get_download()
index_handler.get_indexed()
all_indexed = []
for video in index_handler.all_videos:
youtube_id = video["youtube_id"]
media_url = video["media_url"]
published = video["published"]
title = video["title"]
all_indexed.append((youtube_id, media_url, published, title))
return all_indexed
def list_comarison(self, all_downloaded, all_indexed):
"""compare the lists to figure out what to do"""
self._find_unindexed(all_downloaded, all_indexed)
self._find_missing(all_downloaded, all_indexed)
self._find_bad_media_url(all_downloaded, all_indexed)
def _find_unindexed(self, all_downloaded, all_indexed):
"""find video files without a matching document indexed"""
all_indexed_ids = [i[0] for i in all_indexed]
self.to_index = []
for downloaded in all_downloaded:
if downloaded[2] not in all_indexed_ids:
self.to_index.append(downloaded)
def _find_missing(self, all_downloaded, all_indexed):
"""find indexed videos without matching media file"""
all_downloaded_ids = [i[2] for i in all_downloaded]
self.to_delete = []
for video in all_indexed:
youtube_id = video[0]
if youtube_id not in all_downloaded_ids:
self.to_delete.append(video)
def _find_bad_media_url(self, all_downloaded, all_indexed):
"""rename media files not matching the indexed title"""
self.mismatch = []
self.to_rename = []
for downloaded in all_downloaded:
channel, filename, downloaded_id = downloaded
# find in indexed
for indexed in all_indexed:
indexed_id, media_url, published, title = indexed
if indexed_id == downloaded_id:
# found it
pub = published.replace("-", "")
expected = f"{pub}_{indexed_id}_{clean_string(title)}.mp4"
new_url = os.path.join(channel, expected)
if expected != filename:
# file to rename
self.to_rename.append((channel, filename, expected))
if media_url != new_url:
# media_url to update in es
self.mismatch.append((indexed_id, new_url))
break
class Filesystem(ScannerBase):
"""handle scanning and fixing from filesystem"""
def __init__(self, task=False):
super().__init__()
def __init__(self, task=False) -> None:
self.task = task
self.to_delete: set[str] = set()
self.to_index: set[str] = set()
def process(self):
"""entry point"""
def scan(self) -> None:
"""scan the filesystem"""
downloaded: set[str] = self._get_downloaded()
indexed: set[str] = self._get_indexed()
self.to_index = downloaded - indexed
self.to_delete = indexed - downloaded
def _get_downloaded(self) -> set[str]:
"""get downloaded ids"""
if self.task:
self.task.send_progress(["Scanning your archive and index."])
self.scan()
self.rename_files()
self.send_mismatch_bulk()
self.delete_from_index()
self.add_missing()
self.task.send_progress(["Scan your filesystem for videos."])
def rename_files(self):
"""rename media files as identified by find_bad_media_url"""
if not self.to_rename:
return
downloaded: set = set()
channels = ignore_filelist(os.listdir(self.VIDEOS))
for channel in channels:
folder = os.path.join(self.VIDEOS, channel)
files = ignore_filelist(os.listdir(folder))
downloaded.update({i.split(".")[0] for i in files})
total = len(self.to_rename)
return downloaded
def _get_indexed(self) -> set:
"""get all indexed ids"""
if self.task:
self.task.send_progress([f"Rename {total} media files."])
for bad_filename in self.to_rename:
channel, filename, expected_filename = bad_filename
print(f"renaming [{filename}] to [{expected_filename}]")
old_path = os.path.join(self.VIDEOS, channel, filename)
new_path = os.path.join(self.VIDEOS, channel, expected_filename)
os.rename(old_path, new_path)
self.task.send_progress(["Get all videos indexed."])
def send_mismatch_bulk(self):
"""build bulk update"""
if not self.mismatch:
return
data = {"query": {"match_all": {}}, "_source": ["youtube_id"]}
response = IndexPaginate("ta_video", data).get_results()
return {i["youtube_id"] for i in response}
total = len(self.mismatch)
if self.task:
self.task.send_progress([f"Fix media urls for {total} files"])
bulk_list = []
for video_mismatch in self.mismatch:
youtube_id, media_url = video_mismatch
print(f"{youtube_id}: fixing media url {media_url}")
action = {"update": {"_id": youtube_id, "_index": "ta_video"}}
source = {"doc": {"media_url": media_url}}
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(source))
# add last newline
bulk_list.append("\n")
data = "\n".join(bulk_list)
_, _ = ElasticWrap("_bulk").post(data=data, ndjson=True)
def apply(self) -> None:
"""apply all changes"""
self.delete()
self.index()
self.url_fix()
def delete_from_index(self):
"""find indexed but deleted mediafile"""
def delete(self) -> None:
"""delete videos from index"""
if not self.to_delete:
print("nothing to delete")
return
total = len(self.to_delete)
if self.task:
self.task.send_progress([f"Clean up {total} items from index."])
for indexed in self.to_delete:
youtube_id = indexed[0]
print(f"deleting {youtube_id} from index")
path = f"ta_video/_doc/{youtube_id}"
_, _ = ElasticWrap(path).delete()
self.task.send_progress(
[f"Remove {len(self.to_delete)} videos from index."]
)
def add_missing(self):
"""add missing videos to index"""
video_ids = [i[2] for i in self.to_index]
if not video_ids:
for youtube_id in self.to_delete:
YoutubeVideo(youtube_id).delete_media_file()
def index(self) -> None:
"""index new"""
if not self.to_index:
print("nothing to index")
return
total = len(video_ids)
for idx, youtube_id in enumerate(video_ids):
total = len(self.to_index)
for idx, youtube_id in enumerate(self.to_index):
if self.task:
self.task.send_progress(
message_lines=[
f"Index missing video {youtube_id}, {idx}/{total}"
f"Index missing video {youtube_id}, {idx + 1}/{total}"
],
progress=(idx + 1) / total,
)
index_new_video(youtube_id)
CommentList(video_ids, task=self.task).index()
CommentList(self.to_index, task=self.task).index()
def url_fix(self) -> None:
"""
update path v0.3.6 to v0.3.7
fix url not matching channel-videoid pattern
"""
bool_must = (
"doc['media_url'].value == "
+ "(doc['channel.channel_id'].value + '/' + "
+ "doc['youtube_id'].value) + '.mp4'"
)
to_update = (
"ctx._source['media_url'] = "
+ "ctx._source.channel['channel_id'] + '/' + "
+ "ctx._source['youtube_id'] + '.mp4'"
)
data = {
"query": {
"bool": {
"must_not": [{"script": {"script": {"source": bool_must}}}]
}
},
"script": {"source": to_update},
}
response, _ = ElasticWrap("ta_video/_update_by_query").post(data=data)
updated = response.get("updates")
if updated:
print(f"updated {updated} bad media_url")
if self.task:
self.task.send_progress(
[f"Updated {updated} wrong media urls."]
)

View File

@ -8,7 +8,7 @@ import math
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
from home.src.ta.users import UserConfig
class YouTubeItem:
@ -26,7 +26,6 @@ class YouTubeItem:
self.youtube_id = youtube_id
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
self.config = AppConfig().config
self.app_conf = self.config["application"]
self.youtube_meta = False
self.json_data = False
@ -100,13 +99,7 @@ class Pagination:
def get_page_size(self):
"""get default or user modified page_size"""
key = f"{self.request.user.id}:page_size"
page_size = RedisArchivist().get_message(key)["status"]
if not page_size:
config = AppConfig().config
page_size = config["archive"]["page_size"]
return page_size
return UserConfig(self.request.user.id).get_value("page_size")
def first_guess(self):
"""build first guess before api call"""

View File

@ -16,6 +16,7 @@ from home.src.index.comments import CommentList
from home.src.index.video import YoutubeVideo
from home.src.ta.config import AppConfig
from home.src.ta.helper import ignore_filelist
from home.src.ta.settings import EnvironmentSettings
from PIL import Image
from yt_dlp.utils import ISO639Utils
@ -28,9 +29,12 @@ class ImportFolderScanner:
"""
CONFIG = AppConfig().config
CACHE_DIR = CONFIG["application"]["cache_dir"]
CACHE_DIR = EnvironmentSettings.CACHE_DIR
IMPORT_DIR = os.path.join(CACHE_DIR, "import")
"""All extensions should be in lowercase until better handling is in place.
Described in Issue #502.
"""
EXT_MAP = {
"media": [".mp4", ".mkv", ".webm"],
"metadata": [".json"],
@ -118,7 +122,7 @@ class ImportFolderScanner:
"""detect metadata type for file"""
for key, value in self.EXT_MAP.items():
if ext in value:
if ext.lower() in value:
return key, file_path
return False, False
@ -430,9 +434,9 @@ class ManualImport:
def _move_to_archive(self, json_data):
"""move identified media file to archive"""
videos = self.config["application"]["videos"]
host_uid = self.config["application"]["HOST_UID"]
host_gid = self.config["application"]["HOST_GID"]
videos = EnvironmentSettings.MEDIA_DIR
host_uid = EnvironmentSettings.HOST_UID
host_gid = EnvironmentSettings.HOST_GID
channel, file = os.path.split(json_data["media_url"])
channel_folder = os.path.join(videos, channel)
@ -469,7 +473,7 @@ class ManualImport:
os.remove(subtitle_file)
channel_info = os.path.join(
self.config["application"]["cache_dir"],
EnvironmentSettings.CACHE_DIR,
"import",
f"{json_data['channel']['channel_id']}.info.json",
)

View File

@ -66,6 +66,7 @@ class YoutubePlaylist(YouTubeItem):
"playlist_thumbnail": playlist_thumbnail,
"playlist_description": self.youtube_meta["description"] or False,
"playlist_last_refresh": int(datetime.now().timestamp()),
"playlist_type": "regular",
}
def get_entries(self, playlistend=False):
@ -178,6 +179,7 @@ class YoutubePlaylist(YouTubeItem):
def delete_metadata(self):
"""delete metadata for playlist"""
self.delete_videos_metadata()
script = (
"ctx._source.playlist.removeAll("
+ "Collections.singleton(params.playlist)) "
@ -195,6 +197,30 @@ class YoutubePlaylist(YouTubeItem):
_, _ = ElasticWrap("ta_video/_update_by_query").post(data)
self.del_in_es()
def is_custom_playlist(self):
self.get_from_es()
return self.json_data["playlist_type"] == "custom"
def delete_videos_metadata(self, channel_id=None):
"""delete video metadata for a specific channel"""
self.get_from_es()
playlist = self.json_data["playlist_entries"]
i = 0
while i < len(playlist):
video_id = playlist[i]["youtube_id"]
video = YoutubeVideo(video_id)
video.get_from_es()
if (
channel_id is None
or video.json_data["channel"]["channel_id"] == channel_id
):
playlist.pop(i)
self.remove_playlist_from_video(video_id)
i -= 1
i += 1
self.set_playlist_thumbnail()
self.upload_to_es()
def delete_videos_playlist(self):
"""delete playlist with all videos"""
print(f"{self.youtube_id}: delete playlist")
@ -208,3 +234,159 @@ class YoutubePlaylist(YouTubeItem):
YoutubeVideo(youtube_id).delete_media_file()
self.delete_metadata()
def create(self, name):
self.json_data = {
"playlist_id": self.youtube_id,
"playlist_active": False,
"playlist_name": name,
"playlist_last_refresh": int(datetime.now().timestamp()),
"playlist_entries": [],
"playlist_type": "custom",
"playlist_channel": None,
"playlist_channel_id": None,
"playlist_description": False,
"playlist_thumbnail": False,
"playlist_subscribed": False,
}
self.upload_to_es()
self.get_playlist_art()
return True
def add_video_to_playlist(self, video_id):
self.get_from_es()
video_metadata = self.get_video_metadata(video_id)
video_metadata["idx"] = len(self.json_data["playlist_entries"])
if not self.playlist_entries_contains(video_id):
self.json_data["playlist_entries"].append(video_metadata)
self.json_data["playlist_last_refresh"] = int(
datetime.now().timestamp()
)
self.set_playlist_thumbnail()
self.upload_to_es()
video = YoutubeVideo(video_id)
video.get_from_es()
if "playlist" not in video.json_data:
video.json_data["playlist"] = []
video.json_data["playlist"].append(self.youtube_id)
video.upload_to_es()
return True
def remove_playlist_from_video(self, video_id):
video = YoutubeVideo(video_id)
video.get_from_es()
if video.json_data is not None and "playlist" in video.json_data:
video.json_data["playlist"].remove(self.youtube_id)
video.upload_to_es()
def move_video(self, video_id, action, hide_watched=False):
self.get_from_es()
video_index = self.get_video_index(video_id)
playlist = self.json_data["playlist_entries"]
item = playlist[video_index]
playlist.pop(video_index)
if action == "remove":
self.remove_playlist_from_video(item["youtube_id"])
else:
if action == "up":
while True:
video_index = max(0, video_index - 1)
if (
not hide_watched
or video_index == 0
or (
not self.get_video_is_watched(
playlist[video_index]["youtube_id"]
)
)
):
break
elif action == "down":
while True:
video_index = min(len(playlist), video_index + 1)
if (
not hide_watched
or video_index == len(playlist)
or (
not self.get_video_is_watched(
playlist[video_index - 1]["youtube_id"]
)
)
):
break
elif action == "top":
video_index = 0
else:
video_index = len(playlist)
playlist.insert(video_index, item)
self.json_data["playlist_last_refresh"] = int(
datetime.now().timestamp()
)
for i, item in enumerate(playlist):
item["idx"] = i
self.set_playlist_thumbnail()
self.upload_to_es()
return True
def del_video(self, video_id):
playlist = self.json_data["playlist_entries"]
i = 0
while i < len(playlist):
if video_id == playlist[i]["youtube_id"]:
playlist.pop(i)
self.set_playlist_thumbnail()
i -= 1
i += 1
def get_video_index(self, video_id):
for i, child in enumerate(self.json_data["playlist_entries"]):
if child["youtube_id"] == video_id:
return i
return -1
def playlist_entries_contains(self, video_id):
return (
len(
list(
filter(
lambda x: x["youtube_id"] == video_id,
self.json_data["playlist_entries"],
)
)
)
> 0
)
def get_video_is_watched(self, video_id):
video = YoutubeVideo(video_id)
video.get_from_es()
return video.json_data["player"]["watched"]
def set_playlist_thumbnail(self):
playlist = self.json_data["playlist_entries"]
self.json_data["playlist_thumbnail"] = False
for video in playlist:
url = ThumbManager(video["youtube_id"]).vid_thumb_path()
if url is not None:
self.json_data["playlist_thumbnail"] = url
break
self.get_playlist_art()
def get_video_metadata(self, video_id):
video = YoutubeVideo(video_id)
video.get_from_es()
video_json_data = {
"youtube_id": video.json_data["youtube_id"],
"title": video.json_data["title"],
"uploader": video.json_data["channel"]["channel_name"],
"idx": 0,
"downloaded": "date_downloaded" in video.json_data
and video.json_data["date_downloaded"] > 0,
}
return video_json_data

View File

@ -6,7 +6,6 @@ functionality:
import json
import os
import shutil
from datetime import datetime
from time import sleep
@ -14,13 +13,13 @@ from home.src.download.queue import PendingList
from home.src.download.subscriptions import ChannelSubscription
from home.src.download.thumbnails import ThumbManager
from home.src.download.yt_dlp_base import CookieHandler
from home.src.download.yt_dlp_handler import VideoDownloader
from home.src.es.connect import ElasticWrap, IndexPaginate
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.video import YoutubeVideo
from home.src.ta.config import AppConfig
from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisQueue
@ -54,6 +53,7 @@ class ReindexBase:
def __init__(self):
self.config = AppConfig().config
self.now = int(datetime.now().timestamp())
self.total = None
def populate(self, all_ids, reindex_config):
"""add all to reindex ids to redis queue"""
@ -61,6 +61,7 @@ class ReindexBase:
return
RedisQueue(queue_name=reindex_config["queue_name"]).add_list(all_ids)
self.total = None
class ReindexPopulate(ReindexBase):
@ -104,11 +105,13 @@ class ReindexPopulate(ReindexBase):
"""get total hits from index"""
index_name = reindex_config["index_name"]
active_key = reindex_config["active_key"]
path = f"{index_name}/_search?filter_path=hits.total"
data = {"query": {"match": {active_key: True}}}
response, _ = ElasticWrap(path).post(data=data)
total_hits = response["hits"]["total"]["value"]
return total_hits
data = {
"query": {"term": {active_key: {"value": True}}},
"_source": False,
}
total = IndexPaginate(index_name, data, keep_source=True).get_results()
return len(total)
def _get_daily_should(self, total_hits):
"""calc how many should reindex daily"""
@ -122,7 +125,7 @@ class ReindexPopulate(ReindexBase):
"""get outdated from index_name"""
index_name = reindex_config["index_name"]
refresh_key = reindex_config["refresh_key"]
now_lte = self.now - self.interval * 24 * 60 * 60
now_lte = str(self.now - self.interval * 24 * 60 * 60)
must_list = [
{"match": {reindex_config["active_key"]: True}},
{"range": {refresh_key: {"lte": now_lte}}},
@ -227,6 +230,11 @@ class Reindex(ReindexBase):
super().__init__()
self.task = task
self.all_indexed_ids = False
self.processed = {
"videos": 0,
"channels": 0,
"playlists": 0,
}
def reindex_all(self):
"""reindex all in queue"""
@ -235,21 +243,22 @@ class Reindex(ReindexBase):
return
for name, index_config in self.REINDEX_CONFIG.items():
if not RedisQueue(index_config["queue_name"]).has_item():
if not RedisQueue(index_config["queue_name"]).length():
continue
total = RedisQueue(index_config["queue_name"]).length()
self.total = RedisQueue(index_config["queue_name"]).length()
while True:
has_next = self.reindex_index(name, index_config, total)
has_next = self.reindex_index(name, index_config)
if not has_next:
break
def reindex_index(self, name, index_config, total):
def reindex_index(self, name, index_config):
"""reindex all of a single index"""
reindex = self.get_reindex_map(index_config["index_name"])
youtube_id = RedisQueue(index_config["queue_name"]).get_next()
if youtube_id:
self._notify(name, index_config, total)
if self.task:
self._notify(name, index_config)
reindex(youtube_id)
sleep_interval = self.config["downloads"].get("sleep_interval", 0)
sleep(sleep_interval)
@ -266,32 +275,33 @@ class Reindex(ReindexBase):
return def_map.get(index_name)
def _notify(self, name, index_config, total):
def _notify(self, name, index_config):
"""send notification back to task"""
if self.total is None:
self.total = RedisQueue(index_config["queue_name"]).length()
remaining = RedisQueue(index_config["queue_name"]).length()
idx = total - remaining
message = [f"Reindexing {name.title()}s {idx}/{total}"]
progress = idx / total
idx = self.total - remaining
message = [f"Reindexing {name.title()}s {idx}/{self.total}"]
progress = idx / self.total
self.task.send_progress(message, progress=progress)
def _reindex_single_video(self, youtube_id):
"""wrapper to handle channel name changes"""
try:
self._reindex_single_video_call(youtube_id)
except FileNotFoundError:
ChannelUrlFixer(youtube_id, self.config).run()
self._reindex_single_video_call(youtube_id)
def _reindex_single_video_call(self, youtube_id):
"""refresh data for single video"""
video = YoutubeVideo(youtube_id)
# read current state
video.get_from_es()
if not video.json_data:
return
es_meta = video.json_data.copy()
# get new
video.build_json()
media_url = os.path.join(
EnvironmentSettings.MEDIA_DIR, es_meta["media_url"]
)
video.build_json(media_path=media_url)
if not video.youtube_meta:
video.deactivate()
return
@ -307,33 +317,24 @@ class Reindex(ReindexBase):
video.json_data["playlist"] = es_meta.get("playlist")
video.upload_to_es()
if es_meta.get("media_url") != video.json_data["media_url"]:
self._rename_media_file(
es_meta.get("media_url"), video.json_data["media_url"]
)
thumb_handler = ThumbManager(youtube_id)
thumb_handler.delete_video_thumb()
thumb_handler.download_video_thumb(video.json_data["vid_thumb_url"])
Comments(youtube_id, config=self.config).reindex_comments()
self.processed["videos"] += 1
return
def _rename_media_file(self, media_url_is, media_url_should):
"""handle title change"""
print(f"[reindex] fix media_url {media_url_is} to {media_url_should}")
videos = self.config["application"]["videos"]
old_path = os.path.join(videos, media_url_is)
new_path = os.path.join(videos, media_url_should)
os.rename(old_path, new_path)
@staticmethod
def _reindex_single_channel(channel_id):
def _reindex_single_channel(self, channel_id):
"""refresh channel data and sync to videos"""
# read current state
channel = YoutubeChannel(channel_id)
channel.get_from_es()
if not channel.json_data:
return
es_meta = channel.json_data.copy()
# get new
@ -355,12 +356,19 @@ class Reindex(ReindexBase):
channel.upload_to_es()
ChannelFullScan(channel_id).scan()
self.processed["channels"] += 1
def _reindex_single_playlist(self, playlist_id):
"""refresh playlist data"""
self._get_all_videos()
playlist = YoutubePlaylist(playlist_id)
playlist.get_from_es()
if (
not playlist.json_data
or playlist.json_data["playlist_type"] == "custom"
):
return
subscribed = playlist.json_data["playlist_subscribed"]
playlist.all_youtube_ids = self.all_indexed_ids
playlist.build_json(scrape=True)
@ -370,6 +378,7 @@ class Reindex(ReindexBase):
playlist.json_data["playlist_subscribed"] = subscribed
playlist.upload_to_es()
self.processed["playlists"] += 1
return
def _get_all_videos(self):
@ -391,6 +400,18 @@ class Reindex(ReindexBase):
valid = CookieHandler(self.config).validate()
return valid
def build_message(self):
"""build progress message"""
message = ""
for key, value in self.processed.items():
if value:
message = message + f"{value} {key}, "
if message:
message = f"reindexed {message.rstrip(', ')}"
return message
class ReindexProgress(ReindexBase):
"""
@ -466,65 +487,6 @@ class ReindexProgress(ReindexBase):
return state_dict
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()
class ChannelFullScan:
"""
update from v0.3.0 to v0.3.1

View File

@ -12,6 +12,7 @@ from datetime import datetime
import requests
from home.src.es.connect import ElasticWrap
from home.src.ta.helper import requests_headers
from home.src.ta.settings import EnvironmentSettings
class YoutubeSubtitle:
@ -62,7 +63,12 @@ class YoutubeSubtitle:
if not all_formats:
return False
subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
subtitle_json3 = [i for i in all_formats if i["ext"] == "json3"]
if not subtitle_json3:
print(f"{self.video.youtube_id}-{lang}: json3 not processed")
return False
subtitle = subtitle_json3[0]
subtitle.update(
{"lang": lang, "source": "auto", "media_url": media_url}
)
@ -108,7 +114,7 @@ class YoutubeSubtitle:
def download_subtitles(self, relevant_subtitles):
"""download subtitle files to archive"""
videos_base = self.video.config["application"]["videos"]
videos_base = EnvironmentSettings.MEDIA_DIR
indexed = []
for subtitle in relevant_subtitles:
dest_path = os.path.join(videos_base, subtitle["media_url"])
@ -122,6 +128,10 @@ class YoutubeSubtitle:
print(response.text)
continue
if not response.text:
print(f"{self.video.youtube_id}: skip empty subtitle")
continue
parser = SubtitleParser(response.text, lang, source)
parser.process()
if not parser.all_cues:
@ -144,8 +154,8 @@ class YoutubeSubtitle:
with open(dest_path, "w", encoding="utf-8") as subfile:
subfile.write(subtitle_str)
host_uid = self.video.config["application"]["HOST_UID"]
host_gid = self.video.config["application"]["HOST_GID"]
host_uid = EnvironmentSettings.HOST_UID
host_gid = EnvironmentSettings.HOST_GID
if host_uid and host_gid:
os.chown(dest_path, host_uid, host_gid)
@ -157,7 +167,7 @@ class YoutubeSubtitle:
def delete(self, subtitles=False):
"""delete subtitles from index and filesystem"""
youtube_id = self.video.youtube_id
videos_base = self.video.config["application"]["videos"]
videos_base = EnvironmentSettings.MEDIA_DIR
# delete files
if subtitles:
files = [i["media_url"] for i in subtitles]

View File

@ -16,12 +16,10 @@ from home.src.index import playlist as ta_playlist
from home.src.index.generic import YouTubeItem
from home.src.index.subtitle import YoutubeSubtitle
from home.src.index.video_constants import VideoTypeEnum
from home.src.index.video_streams import (
DurationConverter,
MediaStreamExtractor,
)
from home.src.ta.helper import clean_string, randomizor
from home.src.ta.ta_redis import RedisArchivist
from home.src.index.video_streams import MediaStreamExtractor
from home.src.ta.helper import get_duration_sec, get_duration_str, randomizor
from home.src.ta.settings import EnvironmentSettings
from home.src.ta.users import UserConfig
from ryd_client import ryd_client
@ -35,17 +33,16 @@ class SponsorBlock:
self.user_agent = f"{settings.TA_UPSTREAM} {settings.TA_VERSION}"
self.last_refresh = int(datetime.now().timestamp())
def get_sb_id(self):
"""get sponsorblock userid or generate if needed"""
def get_sb_id(self) -> str:
"""get sponsorblock for the userid or generate if needed"""
if not self.user_id:
print("missing request user id")
raise ValueError
raise ValueError("missing request user id")
key = f"{self.user_id}:id_sponsorblock"
sb_id = RedisArchivist().get_message(key)
if not sb_id["status"]:
sb_id = {"status": randomizor(32)}
RedisArchivist().set_message(key, sb_id)
user = UserConfig(self.user_id)
sb_id = user.get_value("sponsorblock_id")
if not sb_id:
sb_id = randomizor(32)
user.set_value("sponsorblock_id", sb_id)
return sb_id
@ -91,7 +88,7 @@ class SponsorBlock:
def post_timestamps(self, youtube_id, start_time, end_time):
"""post timestamps to api"""
user_id = self.get_sb_id().get("status")
user_id = self.get_sb_id()
data = {
"videoID": youtube_id,
"startTime": start_time,
@ -108,7 +105,7 @@ class SponsorBlock:
def vote_on_segment(self, uuid, vote):
"""send vote on existing segment"""
user_id = self.get_sb_id().get("status")
user_id = self.get_sb_id()
data = {
"UUID": uuid,
"userID": user_id,
@ -180,6 +177,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def _process_youtube_meta(self):
"""extract relevant fields from youtube"""
self._validate_id()
# extract
self.channel_id = self.youtube_meta["channel_id"]
upload_date = self.youtube_meta["upload_date"]
@ -191,11 +189,11 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
# build json_data basics
self.json_data = {
"title": self.youtube_meta["title"],
"description": self.youtube_meta["description"],
"category": self.youtube_meta["categories"],
"description": self.youtube_meta.get("description", ""),
"category": self.youtube_meta.get("categories", []),
"vid_thumb_url": self.youtube_meta["thumbnail"],
"vid_thumb_base64": base64_blur,
"tags": self.youtube_meta["tags"],
"tags": self.youtube_meta.get("tags", []),
"published": published,
"vid_last_refresh": last_refresh,
"date_downloaded": last_refresh,
@ -205,6 +203,19 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
"active": True,
}
def _validate_id(self):
"""validate expected video ID, raise value error on mismatch"""
remote_id = self.youtube_meta["id"]
if not self.youtube_id == remote_id:
# unexpected redirect
message = (
f"[reindex][{self.youtube_id}] got an unexpected redirect "
+ f"to {remote_id}, you are probably getting blocked by YT. "
"See FAQ for more details."
)
raise ValueError(message)
def _add_channel(self):
"""add channel dict to video json_data"""
channel = ta_channel.YoutubeChannel(self.channel_id)
@ -213,53 +224,50 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
def _add_stats(self):
"""add stats dicst to json_data"""
# likes
like_count = self.youtube_meta.get("like_count", 0)
dislike_count = self.youtube_meta.get("dislike_count", 0)
average_rating = self.youtube_meta.get("average_rating", 0)
self.json_data.update(
{
"stats": {
"view_count": self.youtube_meta["view_count"],
"like_count": like_count,
"dislike_count": dislike_count,
"average_rating": average_rating,
}
}
)
stats = {
"view_count": self.youtube_meta.get("view_count", 0),
"like_count": self.youtube_meta.get("like_count", 0),
"dislike_count": self.youtube_meta.get("dislike_count", 0),
"average_rating": self.youtube_meta.get("average_rating", 0),
}
self.json_data.update({"stats": stats})
def build_dl_cache_path(self):
"""find video path in dl cache"""
cache_dir = self.app_conf["cache_dir"]
cache_path = f"{cache_dir}/download/"
all_cached = os.listdir(cache_path)
for file_cached in all_cached:
if self.youtube_id in file_cached:
vid_path = os.path.join(cache_path, file_cached)
return vid_path
cache_dir = EnvironmentSettings.CACHE_DIR
video_id = self.json_data["youtube_id"]
cache_path = f"{cache_dir}/download/{video_id}.mp4"
if os.path.exists(cache_path):
return cache_path
channel_path = os.path.join(
EnvironmentSettings.MEDIA_DIR,
self.json_data["channel"]["channel_id"],
f"{video_id}.mp4",
)
if os.path.exists(channel_path):
return channel_path
raise FileNotFoundError
def add_player(self, media_path=False):
"""add player information for new videos"""
vid_path = self._get_vid_path(media_path)
vid_path = media_path or self.build_dl_cache_path()
duration = get_duration_sec(vid_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,
"duration_str": get_duration_str(duration),
}
}
)
def add_streams(self, media_path=False):
"""add stream metadata"""
vid_path = self._get_vid_path(media_path)
vid_path = media_path or self.build_dl_cache_path()
media = MediaStreamExtractor(vid_path)
self.json_data.update(
{
@ -268,43 +276,12 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
}
)
def _get_vid_path(self, media_path=False):
"""get path of media file"""
if media_path:
return media_path
try:
# when indexing from download task
vid_path = self.build_dl_cache_path()
except FileNotFoundError as err:
# when reindexing needs to handle title rename
channel = os.path.split(self.json_data["media_url"])[0]
channel_dir = os.path.join(self.app_conf["videos"], channel)
all_files = os.listdir(channel_dir)
for file in all_files:
if self.youtube_id in file and file.endswith(".mp4"):
vid_path = os.path.join(channel_dir, file)
break
else:
raise FileNotFoundError("could not find video file") from err
return vid_path
def add_file_path(self):
"""build media_url for where file will be located"""
channel_name = self.json_data["channel"]["channel_name"]
clean_channel_name = clean_string(channel_name)
if len(clean_channel_name) <= 3:
# fall back to channel id
clean_channel_name = self.json_data["channel"]["channel_id"]
timestamp = self.json_data["published"].replace("-", "")
youtube_id = self.json_data["youtube_id"]
title = self.json_data["title"]
clean_title = clean_string(title)
filename = f"{timestamp}_{youtube_id}_{clean_title}.mp4"
media_url = os.path.join(clean_channel_name, filename)
self.json_data["media_url"] = media_url
self.json_data["media_url"] = os.path.join(
self.json_data["channel"]["channel_id"],
self.json_data["youtube_id"] + ".mp4",
)
def delete_media_file(self):
"""delete video file, meta data"""
@ -313,7 +290,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
if not self.json_data:
raise FileNotFoundError
video_base = self.app_conf["videos"]
video_base = EnvironmentSettings.MEDIA_DIR
media_url = self.json_data.get("media_url")
file_path = os.path.join(video_base, media_url)
try:
@ -342,6 +319,8 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
playlist.json_data["playlist_entries"][idx].update(
{"downloaded": False}
)
if playlist.json_data["playlist_type"] == "custom":
playlist.del_video(self.youtube_id)
playlist.upload_to_es()
def delete_subtitles(self, subtitles=False):

View File

@ -5,57 +5,6 @@ import subprocess
from os import stat
class DurationConverter:
"""
using ffmpeg to get and parse duration from filepath
"""
@staticmethod
def get_sec(file_path):
"""read duration from file"""
duration = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file_path,
],
capture_output=True,
check=True,
)
duration_raw = duration.stdout.decode().strip()
if duration_raw == "N/A":
return 0
duration_sec = int(float(duration_raw))
return duration_sec
@staticmethod
def get_str(duration_sec):
"""takes duration in sec and returns clean string"""
if not duration_sec:
# failed to extract
return "NA"
hours = int(duration_sec // 3600)
minutes = int((duration_sec - (hours * 3600)) // 60)
secs = int(duration_sec - (hours * 3600) - (minutes * 60))
duration_str = str()
if hours:
duration_str = str(hours).zfill(2) + ":"
if minutes:
duration_str = duration_str + str(minutes).zfill(2) + ":"
else:
duration_str = duration_str + "00:"
duration_str = duration_str + str(secs).zfill(2)
return duration_str
class MediaStreamExtractor:
"""extract stream metadata"""
@ -122,8 +71,8 @@ class MediaStreamExtractor:
{
"type": "audio",
"index": stream["index"],
"codec": stream["codec_name"],
"bitrate": int(stream["bit_rate"]),
"codec": stream.get("codec_name", "undefined"),
"bitrate": int(stream.get("bit_rate", 0)),
}
)

View File

@ -0,0 +1,10 @@
from django.conf import settings
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware
class HttpRemoteUserMiddleware(PersistentRemoteUserMiddleware):
"""This class allows authentication via HTTP_REMOTE_USER which is set for
example by certain SSO applications.
"""
header = settings.TA_AUTH_PROXY_USERNAME_HEADER

View File

@ -5,9 +5,9 @@ Functionality:
"""
import json
import os
import re
from random import randint
from time import sleep
import requests
from celery.schedules import crontab
@ -16,12 +16,10 @@ from home.src.ta.ta_redis import RedisArchivist
class AppConfig:
"""handle user settings and application variables"""
"""handle application variables"""
def __init__(self, user_id=False):
self.user_id = user_id
def __init__(self):
self.config = self.get_config()
self.colors = self.get_colors()
def get_config(self):
"""get config from default file or redis if changed"""
@ -29,13 +27,6 @@ class AppConfig:
if not config:
config = self.get_config_file()
if self.user_id:
key = f"{self.user_id}:page_size"
page_size = RedisArchivist().get_message(key)["status"]
if page_size:
config["archive"]["page_size"] = page_size
config["application"].update(self.get_config_env())
return config
def get_config_file(self):
@ -43,35 +34,24 @@ class AppConfig:
with open("home/config.json", "r", encoding="utf-8") as f:
config_file = json.load(f)
config_file["application"].update(self.get_config_env())
return config_file
@staticmethod
def get_config_env():
"""read environment application variables"""
es_pass = os.environ.get("ELASTIC_PASSWORD")
es_user = os.environ.get("ELASTIC_USER", default="elastic")
application = {
"REDIS_HOST": os.environ.get("REDIS_HOST"),
"es_url": os.environ.get("ES_URL"),
"es_auth": (es_user, es_pass),
"HOST_UID": int(os.environ.get("HOST_UID", False)),
"HOST_GID": int(os.environ.get("HOST_GID", False)),
"enable_cast": bool(os.environ.get("ENABLE_CAST")),
}
return application
@staticmethod
def get_config_redis():
"""read config json set from redis to overwrite defaults"""
config = RedisArchivist().get_message("config")
if not list(config.values())[0]:
return False
for i in range(10):
try:
config = RedisArchivist().get_message("config")
if not list(config.values())[0]:
return False
return config
return config
except Exception: # pylint: disable=broad-except
print(f"... Redis connection failed, retry [{i}/10]")
sleep(3)
raise ConnectionError("failed to connect to redis")
def update_config(self, form_post):
"""update config values from settings form"""
@ -91,33 +71,9 @@ class AppConfig:
self.config[config_dict][config_value] = to_write
updated.append((config_value, to_write))
RedisArchivist().set_message("config", self.config)
RedisArchivist().set_message("config", self.config, save=True)
return updated
@staticmethod
def set_user_config(form_post, user_id):
"""set values in redis for user settings"""
for key, value in form_post.items():
if not value:
continue
message = {"status": value}
redis_key = f"{user_id}:{key}"
RedisArchivist().set_message(redis_key, message)
def get_colors(self):
"""overwrite config if user has set custom values"""
colors = False
if self.user_id:
col_dict = RedisArchivist().get_message(f"{self.user_id}:colors")
colors = col_dict["status"]
if not colors:
colors = self.config["application"]["colors"]
self.config["application"]["colors"] = colors
return colors
@staticmethod
def _build_rand_daily():
"""build random daily schedule per installation"""
@ -150,7 +106,10 @@ class AppConfig:
# missing nested values
for sub_key, sub_value in value.items():
if sub_key not in redis_config[key].keys():
if (
sub_key not in redis_config[key].keys()
or sub_value == "rand-d"
):
if sub_value == "rand-d":
sub_value = self._build_rand_daily()
@ -175,6 +134,11 @@ class ScheduleBuilder:
"version_check": "0 11 *",
}
CONFIG = ["check_reindex_days", "run_backup_rotate"]
NOTIFY = [
"update_subscribed_notify",
"download_pending_notify",
"check_reindex_notify",
]
MSG = "message:setting"
def __init__(self):
@ -191,10 +155,11 @@ class ScheduleBuilder:
except ValueError:
print(f"failed: {key} {value}")
mess_dict = {
"status": self.MSG,
"group": "setting:schedule",
"level": "error",
"title": "Scheduler update failed.",
"message": "Invalid schedule input",
"messages": ["Invalid schedule input"],
"id": "0000",
}
RedisArchivist().set_message(
self.MSG, mess_dict, expire=True
@ -204,12 +169,20 @@ class ScheduleBuilder:
redis_config["scheduler"][key] = to_write
if key in self.CONFIG and value:
redis_config["scheduler"][key] = int(value)
RedisArchivist().set_message("config", redis_config)
if key in self.NOTIFY and value:
if value == "0":
to_write = False
else:
to_write = value
redis_config["scheduler"][key] = to_write
RedisArchivist().set_message("config", redis_config, save=True)
mess_dict = {
"status": self.MSG,
"group": "setting:schedule",
"level": "info",
"title": "Scheduler changed.",
"message": "Please restart container for changes to take effect",
"messages": ["Restart container for changes to take effect"],
"id": "0000",
}
RedisArchivist().set_message(self.MSG, mess_dict, expire=True)
@ -296,68 +269,83 @@ class ReleaseVersion:
REMOTE_URL = "https://www.tubearchivist.com/api/release/latest/"
NEW_KEY = "versioncheck:new"
def __init__(self):
self.local_version = self._parse_version(settings.TA_VERSION)
self.is_unstable = settings.TA_VERSION.endswith("-unstable")
self.remote_version = False
self.is_breaking = False
self.response = False
def __init__(self) -> None:
self.local_version: str = settings.TA_VERSION
self.is_unstable: bool = settings.TA_VERSION.endswith("-unstable")
self.remote_version: str = ""
self.is_breaking: bool = False
def check(self):
def check(self) -> None:
"""check version"""
print(f"[{self.local_version}]: look for updates")
self.get_remote_version()
new_version, is_breaking = self._has_update()
new_version = self._has_update()
if new_version:
message = {
"status": True,
"version": new_version,
"is_breaking": is_breaking,
"is_breaking": self.is_breaking,
}
RedisArchivist().set_message(self.NEW_KEY, message)
print(f"[{self.local_version}]: found new version {new_version}")
def get_remote_version(self):
def get_local_version(self) -> str:
"""read version from local"""
return self.local_version
def get_remote_version(self) -> None:
"""read version from remote"""
self.response = requests.get(self.REMOTE_URL, timeout=20).json()
remote_version_str = self.response["release_version"]
self.remote_version = self._parse_version(remote_version_str)
self.is_breaking = self.response["breaking_changes"]
sleep(randint(0, 60))
response = requests.get(self.REMOTE_URL, timeout=20).json()
self.remote_version = response["release_version"]
self.is_breaking = response["breaking_changes"]
def _has_update(self):
def _has_update(self) -> str | bool:
"""check if there is an update"""
for idx, number in enumerate(self.local_version):
is_newer = self.remote_version[idx] > number
if is_newer:
return self.response["release_version"], self.is_breaking
remote_parsed = self._parse_version(self.remote_version)
local_parsed = self._parse_version(self.local_version)
if remote_parsed > local_parsed:
return self.remote_version
if self.is_unstable and self.local_version == self.remote_version:
return self.response["release_version"], self.is_breaking
if self.is_unstable and local_parsed == remote_parsed:
return self.remote_version
return False, False
return False
@staticmethod
def _parse_version(version):
def _parse_version(version) -> tuple[int, ...]:
"""return version parts"""
clean = version.rstrip("-unstable").lstrip("v")
return tuple((int(i) for i in clean.split(".")))
def is_updated(self):
def is_updated(self) -> str | bool:
"""check if update happened in the mean time"""
message = self.get_update()
if not message:
return False
if self._parse_version(message.get("version")) == self.local_version:
local_parsed = self._parse_version(self.local_version)
message_parsed = self._parse_version(message.get("version"))
if local_parsed >= message_parsed:
RedisArchivist().del_message(self.NEW_KEY)
return settings.TA_VERSION
return False
def get_update(self):
def get_update(self) -> dict:
"""return new version dict if available"""
message = RedisArchivist().get_message(self.NEW_KEY)
if not message.get("status"):
return False
return {}
return message
def clear_fail(self) -> None:
"""clear key, catch previous error in v0.4.5"""
message = self.get_update()
if not message:
return
if isinstance(message.get("version"), list):
RedisArchivist().del_message(self.NEW_KEY)

View File

@ -0,0 +1,11 @@
from django.http import HttpResponse
class HealthCheckMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path == "/health":
return HttpResponse("ok")
return self.get_response(request)

View File

@ -6,28 +6,23 @@ Loose collection of helper functions
import json
import os
import random
import re
import string
import unicodedata
import subprocess
from datetime import datetime
from urllib.parse import urlparse
import requests
def clean_string(file_name: str) -> str:
"""clean string to only asci characters"""
whitelist = "-_.() " + string.ascii_letters + string.digits
normalized = unicodedata.normalize("NFKD", file_name)
ascii_only = normalized.encode("ASCII", "ignore").decode().strip()
white_listed: str = "".join(c for c in ascii_only if c in whitelist)
cleaned: str = re.sub(r"[ ]{2,}", " ", white_listed)
return cleaned
from home.src.ta.settings import EnvironmentSettings
def ignore_filelist(filelist: list[str]) -> list[str]:
"""ignore temp files for os.listdir sanitizer"""
to_ignore = ["Icon\r\r", "Temporary Items", "Network Trash Folder"]
to_ignore = [
"@eaDir",
"Icon\r\r",
"Network Trash Folder",
"Temporary Items",
]
cleaned: list[str] = []
for file_name in filelist:
if file_name.startswith(".") or file_name in to_ignore:
@ -103,7 +98,7 @@ def date_praser(timestamp: int | str) -> str:
elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
return datetime.strftime(date_obj, "%d %b, %Y")
return date_obj.date().isoformat()
def time_parser(timestamp: str) -> float:
@ -118,13 +113,13 @@ def time_parser(timestamp: str) -> float:
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
def clear_dl_cache(config: dict) -> int:
def clear_dl_cache(cache_dir: str) -> int:
"""clear leftover files from dl cache"""
print("clear download cache")
cache_dir = os.path.join(config["application"]["cache_dir"], "download")
leftover_files = os.listdir(cache_dir)
download_cache_dir = os.path.join(cache_dir, "download")
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
for cached in leftover_files:
to_delete = os.path.join(cache_dir, cached)
to_delete = os.path.join(download_cache_dir, cached)
os.remove(to_delete)
return len(leftover_files)
@ -148,10 +143,57 @@ def is_shorts(youtube_id: str) -> bool:
return response.status_code == 200
def get_duration_sec(file_path: str) -> int:
"""get duration of media file from file path"""
duration = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file_path,
],
capture_output=True,
check=True,
)
duration_raw = duration.stdout.decode().strip()
if duration_raw == "N/A":
return 0
duration_sec = int(float(duration_raw))
return duration_sec
def get_duration_str(seconds: int) -> str:
"""Return a human-readable duration string from seconds."""
if not seconds:
return "NA"
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = []
for unit_label, unit_seconds in units:
if seconds >= unit_seconds:
unit_count, seconds = divmod(seconds, unit_seconds)
duration_parts.append(f"{unit_count:02}{unit_label}")
return " ".join(duration_parts)
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
allowed_hosts: list[str] = []
csrf_trusted_origins: list[str] = []
allowed_hosts: list[str] = [
"localhost",
"tubearchivist",
]
csrf_trusted_origins: list[str] = [
"http://localhost",
"http://tubearchivist",
]
for host in ta_host.split():
host_clean = host.strip()
if not host_clean.startswith("http"):
@ -162,3 +204,21 @@ def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
return allowed_hosts, csrf_trusted_origins
def get_stylesheets():
"""Get all valid stylesheets from /static/css"""
app_root = EnvironmentSettings.APP_DIR
stylesheets = os.listdir(os.path.join(app_root, "static/css"))
stylesheets.remove("style.css")
stylesheets.sort()
stylesheets = list(filter(lambda x: x.endswith(".css"), stylesheets))
return stylesheets
def check_stylesheet(stylesheet: str):
"""Check if a stylesheet exists. Return dark.css as a fallback"""
if stylesheet in get_stylesheets():
return stylesheet
return "dark.css"

View File

@ -0,0 +1,55 @@
"""send notifications using apprise"""
import apprise
from home.src.ta.config import AppConfig
from home.src.ta.task_manager import TaskManager
class Notifications:
"""notification handler"""
def __init__(self, name: str, task_id: str, task_title: str):
self.name: str = name
self.task_id: str = task_id
self.task_title: str = task_title
def send(self) -> None:
"""send notifications"""
apobj = apprise.Apprise()
hooks: str | None = self.get_url()
if not hooks:
return
hook_list: list[str] = self.parse_hooks(hooks=hooks)
title, body = self.build_message()
if not body:
return
for hook in hook_list:
apobj.add(hook)
apobj.notify(body=body, title=title)
def get_url(self) -> str | None:
"""get apprise urls for task"""
config = AppConfig().config
hooks: str = config["scheduler"].get(f"{self.name}_notify")
return hooks
def parse_hooks(self, hooks: str) -> list[str]:
"""create list of hooks"""
hook_list: list[str] = [i.strip() for i in hooks.split()]
return hook_list
def build_message(self) -> tuple[str, str | None]:
"""build message to send notification"""
task = TaskManager().get_task(self.task_id)
status = task.get("status")
title: str = f"[TA] {self.task_title} process ended with {status}"
body: str | None = task.get("result")
return title, body

View File

@ -0,0 +1,95 @@
"""
Functionality:
- read and write application config backed by ES
- encapsulate persistence of application properties
"""
from os import environ
class EnvironmentSettings:
"""
Handle settings for the application that are driven from the environment.
These will not change when the user is using the application.
These settings are only provided only on startup.
"""
HOST_UID: int = int(environ.get("HOST_UID", False))
HOST_GID: int = int(environ.get("HOST_GID", False))
ENABLE_CAST: bool = bool(environ.get("ENABLE_CAST"))
TZ: str = str(environ.get("TZ", "UTC"))
TA_PORT: int = int(environ.get("TA_PORT", False))
TA_UWSGI_PORT: int = int(environ.get("TA_UWSGI_PORT", False))
TA_USERNAME: str = str(environ.get("TA_USERNAME"))
TA_PASSWORD: str = str(environ.get("TA_PASSWORD"))
# Application Paths
MEDIA_DIR: str = str(environ.get("TA_MEDIA_DIR", "/youtube"))
APP_DIR: str = str(environ.get("TA_APP_DIR", "/app"))
CACHE_DIR: str = str(environ.get("TA_CACHE_DIR", "/cache"))
# Redis
REDIS_HOST: str = str(environ.get("REDIS_HOST"))
REDIS_PORT: int = int(environ.get("REDIS_PORT", 6379))
REDIS_NAME_SPACE: str = str(environ.get("REDIS_NAME_SPACE", "ta:"))
# ElasticSearch
ES_URL: str = str(environ.get("ES_URL"))
ES_PASS: str = str(environ.get("ELASTIC_PASSWORD"))
ES_USER: str = str(environ.get("ELASTIC_USER", "elastic"))
ES_SNAPSHOT_DIR: str = str(
environ.get(
"ES_SNAPSHOT_DIR", "/usr/share/elasticsearch/data/snapshot"
)
)
ES_DISABLE_VERIFY_SSL: bool = bool(environ.get("ES_DISABLE_VERIFY_SSL"))
def print_generic(self):
"""print generic env vars"""
print(
f"""
HOST_UID: {self.HOST_UID}
HOST_GID: {self.HOST_GID}
TZ: {self.TZ}
ENABLE_CAST: {self.ENABLE_CAST}
TA_PORT: {self.TA_PORT}
TA_UWSGI_PORT: {self.TA_UWSGI_PORT}
TA_USERNAME: {self.TA_USERNAME}
TA_PASSWORD: *****"""
)
def print_paths(self):
"""debug paths set"""
print(
f"""
MEDIA_DIR: {self.MEDIA_DIR}
APP_DIR: {self.APP_DIR}
CACHE_DIR: {self.CACHE_DIR}"""
)
def print_redis_conf(self):
"""debug redis conf paths"""
print(
f"""
REDIS_HOST: {self.REDIS_HOST}
REDIS_PORT: {self.REDIS_PORT}
REDIS_NAME_SPACE: {self.REDIS_NAME_SPACE}"""
)
def print_es_paths(self):
"""debug es conf"""
print(
f"""
ES_URL: {self.ES_URL}
ES_PASS: *****
ES_USER: {self.ES_USER}
ES_SNAPSHOT_DIR: {self.ES_SNAPSHOT_DIR}
ES_DISABLE_VERIFY_SSL: {self.ES_DISABLE_VERIFY_SSL}"""
)
def print_all(self):
"""print all"""
self.print_generic()
self.print_paths()
self.print_redis_conf()
self.print_es_paths()

View File

@ -6,20 +6,22 @@ functionality:
"""
import json
import os
import redis
from home.src.ta.settings import EnvironmentSettings
class RedisBase:
"""connection base for redis"""
REDIS_HOST: str = str(os.environ.get("REDIS_HOST"))
REDIS_PORT: int = int(os.environ.get("REDIS_PORT") or 6379)
NAME_SPACE: str = "ta:"
NAME_SPACE: str = EnvironmentSettings.REDIS_NAME_SPACE
def __init__(self):
self.conn = redis.Redis(host=self.REDIS_HOST, port=self.REDIS_PORT)
self.conn = redis.Redis(
host=EnvironmentSettings.REDIS_HOST,
port=EnvironmentSettings.REDIS_PORT,
decode_responses=True,
)
class RedisArchivist(RedisBase):
@ -41,6 +43,7 @@ class RedisArchivist(RedisBase):
message: dict,
path: str = ".",
expire: bool | int = False,
save: bool = False,
) -> None:
"""write new message to redis"""
self.conn.execute_command(
@ -54,6 +57,16 @@ class RedisArchivist(RedisBase):
secs = expire
self.conn.execute_command("EXPIRE", self.NAME_SPACE + key, secs)
if save:
self.bg_save()
def bg_save(self) -> None:
"""save to aof"""
try:
self.conn.bgsave()
except redis.exceptions.ResponseError:
pass
def get_message(self, key: str) -> dict:
"""get message dict from redis"""
reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key)
@ -70,7 +83,7 @@ class RedisArchivist(RedisBase):
if not reply:
return []
return [i.decode().lstrip(self.NAME_SPACE) for i in reply]
return [i.lstrip(self.NAME_SPACE) for i in reply]
def list_items(self, query: str) -> list:
"""list all matches"""
@ -87,65 +100,49 @@ class RedisArchivist(RedisBase):
class RedisQueue(RedisBase):
"""dynamically interact with queues in redis"""
"""
dynamically interact with queues in redis using sorted set
- low score number is first in queue
- add new items with high score number
"""
def __init__(self, queue_name: str):
super().__init__()
self.key = f"{self.NAME_SPACE}{queue_name}"
def get_all(self):
def get_all(self) -> list[str]:
"""return all elements in list"""
result = self.conn.execute_command("LRANGE", self.key, 0, -1)
all_elements = [i.decode() for i in result]
return all_elements
result = self.conn.zrange(self.key, 0, -1)
return result
def length(self) -> int:
"""return total elements in list"""
return self.conn.execute_command("LLEN", self.key)
return self.conn.zcard(self.key)
def in_queue(self, element) -> str | bool:
"""check if element is in list"""
result = self.conn.execute_command("LPOS", self.key, element)
result = self.conn.zrank(self.key, element)
if result is not None:
return "in_queue"
return False
def add_list(self, to_add):
def add_list(self, to_add: list) -> None:
"""add list to queue"""
self.conn.execute_command("RPUSH", self.key, *to_add)
def add_priority(self, to_add: str) -> None:
"""add single video to front of queue"""
item: str = json.dumps(to_add)
self.clear_item(item)
self.conn.execute_command("LPUSH", self.key, item)
mapping = {i: "+inf" for i in to_add}
self.conn.zadd(self.key, mapping)
def get_next(self) -> str | bool:
"""return next element in the queue, False if none"""
result = self.conn.execute_command("LPOP", self.key)
"""return next element in the queue, if available"""
result = self.conn.zpopmin(self.key)
if not result:
return False
next_element = result.decode()
return next_element
return result[0][0]
def clear(self) -> None:
"""delete list from redis"""
self.conn.execute_command("DEL", self.key)
def clear_item(self, to_clear: str) -> None:
"""remove single item from list if it's there"""
self.conn.execute_command("LREM", self.key, 0, to_clear)
def trim(self, size: int) -> None:
"""trim the queue based on settings amount"""
self.conn.execute_command("LTRIM", self.key, 0, size)
def has_item(self) -> bool:
"""check if queue as at least one pending item"""
result = self.conn.execute_command("LRANGE", self.key, 0, 0)
return bool(result)
self.conn.delete(self.key)
class TaskRedis(RedisBase):
@ -158,7 +155,7 @@ class TaskRedis(RedisBase):
def get_all(self) -> list:
"""return all tasks"""
all_keys = self.conn.execute_command("KEYS", f"{self.BASE}*")
return [i.decode().replace(self.BASE, "") for i in all_keys]
return [i.replace(self.BASE, "") for i in all_keys]
def get_single(self, task_id: str) -> dict:
"""return content of single task"""
@ -166,7 +163,7 @@ class TaskRedis(RedisBase):
if not result:
return {}
return json.loads(result.decode())
return json.loads(result)
def set_key(
self, task_id: str, message: dict, expire: bool | int = False

View File

@ -92,7 +92,7 @@ class Parser:
item_type = "video"
elif len_id_str == 24:
item_type = "channel"
elif len_id_str in (34, 26, 18):
elif len_id_str in (34, 26, 18) or id_str.startswith("TA_playlist_"):
item_type = "playlist"
else:
raise ValueError(f"not a valid id_str: {id_str}")

View File

@ -0,0 +1,142 @@
"""
Functionality:
- read and write user config backed by ES
- encapsulate persistence of user properties
"""
from typing import TypedDict
from home.src.es.connect import ElasticWrap
from home.src.ta.helper import get_stylesheets
class UserConfigType(TypedDict, total=False):
"""describes the user configuration"""
stylesheet: str
page_size: int
sort_by: str
sort_order: str
view_style_home: str
view_style_channel: str
view_style_downloads: str
view_style_playlist: str
grid_items: int
hide_watched: bool
show_ignored_only: bool
show_subed_only: bool
sponsorblock_id: str
class UserConfig:
"""Handle settings for an individual user"""
_DEFAULT_USER_SETTINGS = UserConfigType(
stylesheet="dark.css",
page_size=12,
sort_by="published",
sort_order="desc",
view_style_home="grid",
view_style_channel="list",
view_style_downloads="list",
view_style_playlist="grid",
grid_items=3,
hide_watched=False,
show_ignored_only=False,
show_subed_only=False,
sponsorblock_id=None,
)
VALID_STYLESHEETS = get_stylesheets()
VALID_VIEW_STYLE = ["grid", "list"]
VALID_SORT_ORDER = ["asc", "desc"]
VALID_SORT_BY = [
"published",
"downloaded",
"views",
"likes",
"duration",
"filesize",
]
VALID_GRID_ITEMS = range(3, 8)
def __init__(self, user_id: str):
self._user_id: str = user_id
self._config: UserConfigType = self.get_config()
def get_value(self, key: str):
"""Get the given key from the users configuration
Throws a KeyError if the requested Key is not a permitted value"""
if key not in self._DEFAULT_USER_SETTINGS:
raise KeyError(f"Unable to read config for unknown key '{key}'")
return self._config.get(key) or self._DEFAULT_USER_SETTINGS.get(key)
def set_value(self, key: str, value: str | bool | int):
"""Set or replace a configuration value for the user"""
self._validate(key, value)
old = self.get_value(key)
self._config[key] = value
# Upsert this property (creating a record if not exists)
es_payload = {"doc": {"config": {key: value}}, "doc_as_upsert": True}
es_document_path = f"ta_config/_update/user_{self._user_id}"
response, status = ElasticWrap(es_document_path).post(es_payload)
if status < 200 or status > 299:
raise ValueError(f"Failed storing user value {status}: {response}")
print(f"User {self._user_id} value '{key}' change: {old} -> {value}")
def _validate(self, key, value):
"""validate key and value"""
if not self._user_id:
raise ValueError("Unable to persist config for null user_id")
if key not in self._DEFAULT_USER_SETTINGS:
raise KeyError(
f"Unable to persist config for an unknown key '{key}'"
)
valid_values = {
"stylesheet": self.VALID_STYLESHEETS,
"sort_by": self.VALID_SORT_BY,
"sort_order": self.VALID_SORT_ORDER,
"view_style_home": self.VALID_VIEW_STYLE,
"view_style_channel": self.VALID_VIEW_STYLE,
"view_style_download": self.VALID_VIEW_STYLE,
"view_style_playlist": self.VALID_VIEW_STYLE,
"grid_items": self.VALID_GRID_ITEMS,
"page_size": int,
"hide_watched": bool,
"show_ignored_only": bool,
"show_subed_only": bool,
}
validation_value = valid_values.get(key)
if isinstance(validation_value, (list, range)):
if value not in validation_value:
raise ValueError(f"Invalid value for {key}: {value}")
elif validation_value == int:
if not isinstance(value, int):
raise ValueError(f"Invalid value for {key}: {value}")
elif validation_value == bool:
if not isinstance(value, bool):
raise ValueError(f"Invalid value for {key}: {value}")
def get_config(self) -> UserConfigType:
"""get config from ES or load from the application defaults"""
if not self._user_id:
# this is for a non logged-in user so use all the defaults
return {}
# Does this user have configuration stored in ES
es_document_path = f"ta_config/_doc/user_{self._user_id}"
response, status = ElasticWrap(es_document_path).get(print_error=False)
if status == 200 and "_source" in response.keys():
source = response.get("_source")
if "config" in source.keys():
return source.get("config")
# There is no config in ES
return {}

View File

@ -19,17 +19,19 @@ from home.src.download.yt_dlp_handler import VideoDownloader
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.filesystem import Filesystem
from home.src.index.filesystem import Scanner
from home.src.index.manual import ImportFolderScanner
from home.src.index.reindex import Reindex, ReindexManual, ReindexPopulate
from home.src.ta.config import AppConfig, ReleaseVersion, ScheduleBuilder
from home.src.ta.notify import Notifications
from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisArchivist
from home.src.ta.task_manager import TaskManager
from home.src.ta.urlparser import Parser
CONFIG = AppConfig().config
REDIS_HOST = os.environ.get("REDIS_HOST")
REDIS_PORT = os.environ.get("REDIS_PORT") or 6379
REDIS_HOST = EnvironmentSettings.REDIS_HOST
REDIS_PORT = EnvironmentSettings.REDIS_PORT
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery(
@ -38,9 +40,11 @@ app = Celery(
backend=f"redis://{REDIS_HOST}:{REDIS_PORT}",
result_extended=True,
)
app.config_from_object("django.conf:settings", namespace="ta:")
app.config_from_object(
"django.conf:settings", namespace=EnvironmentSettings.REDIS_NAME_SPACE
)
app.autodiscover_tasks()
app.conf.timezone = os.environ.get("TZ") or "UTC"
app.conf.timezone = EnvironmentSettings.TZ
class BaseTask(Task):
@ -113,7 +117,7 @@ class BaseTask(Task):
"""callback for task failure"""
print(f"{task_id} Failed callback")
message, key = self._build_message(level="error")
message.update({"messages": ["Task failed"]})
message.update({"messages": [f"Task failed: {exc}"]})
RedisArchivist().set_message(key, message, expire=20)
def on_success(self, retval, task_id, args, kwargs):
@ -130,6 +134,12 @@ class BaseTask(Task):
message.update({"messages": ["New task received."]})
RedisArchivist().set_message(key, message)
def after_return(self, status, retval, task_id, args, kwargs, einfo):
"""callback after task returns"""
print(f"{task_id} return callback")
task_title = self.TASK_CONFIG.get(self.name).get("title")
Notifications(self.name, task_id, task_title).send()
def send_progress(self, message_lines, progress=False, title=False):
"""send progress message"""
message, key = self._build_message()
@ -169,7 +179,7 @@ def update_subscribed(self):
if manager.is_pending(self):
print(f"[task][{self.name}] rescan already running")
self.send_progress("Rescan already in progress.")
return
return None
manager.init(self)
handler = SubscriptionScanner(task=self)
@ -178,6 +188,10 @@ def update_subscribed(self):
if missing_videos:
print(missing_videos)
extrac_dl.delay(missing_videos, auto_start=auto_start)
message = f"Found {len(missing_videos)} videos to add to the queue."
return message
return None
@shared_task(name="download_pending", bind=True, base=BaseTask)
@ -187,10 +201,16 @@ def download_pending(self, auto_only=False):
if manager.is_pending(self):
print(f"[task][{self.name}] download queue already running")
self.send_progress("Download Queue is already running.")
return
return None
manager.init(self)
VideoDownloader(task=self).run_queue(auto_only=auto_only)
downloader = VideoDownloader(task=self)
videos_downloaded = downloader.run_queue(auto_only=auto_only)
if videos_downloaded:
return f"downloaded {len(videos_downloaded)} videos."
return None
@shared_task(name="extract_download", bind=True, base=BaseTask)
@ -235,7 +255,10 @@ def check_reindex(self, data=False, extract_videos=False):
self.send_progress("Add outdated documents to the reindex Queue.")
populate.add_outdated()
Reindex(task=self).reindex_all()
handler = Reindex(task=self)
handler.reindex_all()
return handler.build_message()
@shared_task(bind=True, name="manual_import", base=BaseTask)
@ -271,7 +294,7 @@ def run_restore_backup(self, filename):
if manager.is_pending(self):
print(f"[task][{self.name}] restore is already running")
self.send_progress("Restore is already running.")
return
return None
manager.init(self)
self.send_progress(["Reset your Index"])
@ -279,6 +302,8 @@ def run_restore_backup(self, filename):
ElasticBackup(task=self).restore(filename)
print("index restore finished")
return f"backup restore completed: {filename}"
@shared_task(bind=True, name="rescan_filesystem", base=BaseTask)
def rescan_filesystem(self):
@ -290,7 +315,9 @@ def rescan_filesystem(self):
return
manager.init(self)
Filesystem(task=self).process()
handler = Scanner(task=self)
handler.scan()
handler.apply()
ThumbValidator(task=self).validate()
@ -321,9 +348,12 @@ def re_sync_thumbs(self):
@shared_task(bind=True, name="subscribe_to", base=BaseTask)
def subscribe_to(self, url_str):
"""take a list of urls to subscribe to"""
SubscriptionHandler(url_str, task=self).subscribe()
def subscribe_to(self, url_str: str, expected_type: str | bool = False):
"""
take a list of urls to subscribe to
optionally validate expected_type channel / playlist
"""
SubscriptionHandler(url_str, task=self).subscribe(expected_type)
@shared_task(bind=True, name="index_playlists", base=BaseTask)

View File

@ -1,4 +1,5 @@
{% load static %}
{% load auth_extras %}
<!DOCTYPE html>
<html lang="en">
<head>
@ -22,11 +23,7 @@
{% else %}
<title>TubeArchivist</title>
{% endif %}
{% if colors == "dark" %}
<link rel="stylesheet" href="{% static 'css/dark.css' %}">
{% else %}
<link rel="stylesheet" href="{% static 'css/light.css' %}">
{% endif %}
<link rel="stylesheet" href="{% static 'css/' %}{{ stylesheet }}">
<script type="text/javascript" src="{% static 'script.js' %}"></script>
{% if cast %}
<script type="text/javascript" src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
@ -36,16 +33,9 @@
<body>
<div class="main-content">
<div class="boxed-content">
<div class="top-banner">
<a href="{% url 'home' %}">
{% if colors == 'dark' %}
<img src="{% static 'img/banner-tube-archivist-dark.png' %}" alt="tube-archivist-banner">
{% endif %}
{% if colors == 'light' %}
<img src="{% static 'img/banner-tube-archivist-light.png' %}" alt="tube-archivist-banner">
{% endif %}
</a>
</div>
<a href="{% url 'home' %}">
<div class="top-banner"></div>
</a>
<div class="top-nav">
<div class="nav-items">
<a href="{% url 'home' %}">
@ -57,9 +47,11 @@
<a href="{% url 'playlist' %}">
<div class="nav-item">playlists</div>
</a>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<a href="{% url 'downloads' %}">
<div class="nav-item">downloads</div>
</a>
{% endif %}
</div>
<div class="nav-icons">
<a href="{% url 'search' %}">

View File

@ -0,0 +1,20 @@
{# Base file for all of the settings pages to ensure a common menu #}
{% extends "home/base.html" %}
{% load static %}
{% load auth_extras %}
{% block content %}
<div class="boxed-content">
<div class="info-box-item child-page-nav">
<a href="{% url 'settings' %}"><h3>Dashboard</h3></a>
<a href="{% url 'settings_user' %}"><h3>User</h3></a>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<a href="{% url 'settings_application' %}"><h3>Application</h3></a>
<a href="{% url 'settings_scheduling' %}"><h3>Scheduling</h3></a>
<a href="{% url 'settings_actions' %}"><h3>Actions</h3></a>
{% endif %}
</div>
<div id="notifications" data=""></div>
{% block settings_content %}{% endblock %}
</div>
<script type="text/javascript" src="{% static 'progress.js' %}"></script>
{% endblock content %}

View File

@ -2,11 +2,13 @@
{% load static %}
{% load humanize %}
{% block content %}
{% load auth_extras %}
<div class="boxed-content">
<div class="title-split">
<div class="title-bar">
<h1>Channels</h1>
</div>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<div class="title-split-form">
<img id="animate-icon" onclick="showForm()" src="{% static 'img/icon-add.svg' %}" alt="add-icon" title="Subscribe to Channels">
<div class="show-form">
@ -17,6 +19,7 @@
</form>
</div>
</div>
{% endif %}
</div>
<div id="notifications" data="subscription"></div>
<div class="view-controls">
@ -42,33 +45,33 @@
{% for channel in results %}
<div class="channel-item {{ view_style }}">
<div class="channel-banner {{ view_style }}">
<a href="{% url 'channel_id' channel.source.channel_id %}">
<img src="/cache/channels/{{ channel.source.channel_id }}_banner.jpg" alt="{{ channel.source.channel_id }}-banner">
<a href="{% url 'channel_id' channel.channel_id %}">
<img src="/cache/channels/{{ channel.channel_id }}_banner.jpg" alt="{{ channel.channel_id }}-banner">
</a>
</div>
<div class="info-box info-box-2 {{ view_style }}">
<div class="info-box-item">
<div class="round-img">
<a href="{% url 'channel_id' channel.source.channel_id %}">
<img src="/cache/channels/{{ channel.source.channel_id }}_thumb.jpg" alt="channel-thumb">
<a href="{% url 'channel_id' channel.channel_id %}">
<img src="/cache/channels/{{ channel.channel_id }}_thumb.jpg" alt="channel-thumb">
</a>
</div>
<div>
<h3><a href="{% url 'channel_id' channel.source.channel_id %}">{{ channel.source.channel_name }}</a></h3>
{% if channel.source.channel_subs >= 1000000 %}
<p>Subscribers: {{ channel.source.channel_subs|intword }}</p>
<h3><a href="{% url 'channel_id' channel.channel_id %}">{{ channel.channel_name }}</a></h3>
{% if channel.channel_subs >= 1000000 %}
<p>Subscribers: {{ channel.channel_subs|intword }}</p>
{% else %}
<p>Subscribers: {{ channel.source.channel_subs|intcomma }}</p>
<p>Subscribers: {{ channel.channel_subs|intcomma }}</p>
{% endif %}
</div>
</div>
<div class="info-box-item">
<div>
<p>Last refreshed: {{ channel.source.channel_last_refresh }}</p>
{% if channel.source.channel_subscribed %}
<button class="unsubscribe" type="button" id="{{ channel.source.channel_id }}" onclick="unsubscribe(this.id)" title="Unsubscribe from {{ channel.source.channel_name }}">Unsubscribe</button>
<p>Last refreshed: {{ channel.channel_last_refresh }}</p>
{% if channel.channel_subscribed %}
<button class="unsubscribe" type="button" data-type="channel" data-subscribe="" data-id="{{ channel.channel_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ channel.channel_name }}">Unsubscribe</button>
{% else %}
<button type="button" id="{{ channel.source.channel_id }}" onclick="subscribe(this.id)" title="Subscribe to {{ channel.source.channel_name }}">Subscribe</button>
<button type="button" data-type="channel" data-subscribe="true" data-id="{{ channel.channel_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ channel.channel_name }}">Subscribe</button>
{% endif %}
</div>
</div>

View File

@ -2,11 +2,13 @@
{% block content %}
{% load static %}
{% load humanize %}
{% load auth_extras %}
<div class="boxed-content">
<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>
</div>
<div class="info-box-item channel-nav">
<div class="info-box-item child-page-nav">
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
{% if has_streams %}
<a href="{% url 'channel_id_live' channel_info.channel_id %}"><h3>Streams</h3></a>
@ -19,7 +21,9 @@
{% endif %}
<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>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
{% endif %}
{% endif %}
</div>
<div id="notifications" data="channel reindex"></div>
@ -38,16 +42,21 @@
<p>Subscribers: {{ channel_info.channel_subs|intcomma }}</p>
{% endif %}
{% if channel_info.channel_subscribed %}
<button class="unsubscribe" type="button" id="{{ channel_info.channel_id }}" onclick="unsubscribe(this.id)" title="Unsubscribe from {{ channel_info.channel_name }}">Unsubscribe</button>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<button class="unsubscribe" type="button" data-type="channel" data-subscribe="" data-id="{{ channel_info.channel_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ channel_info.channel_name }}">Unsubscribe</button>
{% endif %}
{% else %}
<button type="button" id="{{ channel_info.channel_id }}" onclick="subscribe(this.id)" title="Subscribe to {{ channel_info.channel_name }}">Subscribe</button>
<button type="button" data-type="channel" data-subscribe="true" data-id="{{ channel_info.channel_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ channel_info.channel_name }}">Subscribe</button>
{% endif %}
</div>
</div>
<div class="info-box-item">
{% if aggs %}
<p>{{ aggs.total_items.value }} videos <span class="space-carrot">|</span> {{ aggs.total_duration.value_str }} playback <span class="space-carrot">|</span> Total size {{ aggs.total_size.value|filesizeformat }}</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>
<div class="button-box">
<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>
<button title="Mark all videos from {{ channel_info.channel_name }} as unwatched" type="button" id="unwatched-button" data-id="{{ channel_info.channel_id }}" onclick="isUnwatchedButton(this)">Mark as unwatched</button>
</div>
{% endif %}
</div>
</div>
@ -68,13 +77,15 @@
<div class="sort">
<div id="hidden-form">
<span>Sort by:</span>
<select name="sort" id="sort" onchange="sortChange(this.value)">
<select name="sort_by" id="sort" onchange="sortChange(this)">
<option value="published" {% if sort_by == "published" %}selected{% endif %}>date published</option>
<option value="downloaded" {% if sort_by == "downloaded" %}selected{% endif %}>date downloaded</option>
<option value="views" {% if sort_by == "views" %}selected{% endif %}>views</option>
<option value="likes" {% if sort_by == "likes" %}selected{% endif %}>likes</option>
<option value="duration" {% if sort_by == "duration" %}selected{% endif %}>duration</option>
<option value="filesize" {% if sort_by == "filesize" %}selected{% endif %}>file size</option>
</select>
<select name="sord-order" id="sort-order" onchange="sortChange(this.value)">
<select name="sort_order" id="sort-order" onchange="sortChange(this)">
<option value="asc" {% if sort_order == "asc" %}selected{% endif %}>asc</option>
<option value="desc" {% if sort_order == "desc" %}selected{% endif %}>desc</option>
</select>
@ -103,14 +114,14 @@
{% if results %}
{% for video in results %}
<div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<a href="#player" data-id="{{ video.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb">
<img src="{{ video.source.vid_thumb_url }}" alt="video-thumb">
{% if video.source.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div>
<img src="{{ video.vid_thumb_url }}" alt="video-thumb">
{% if video.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.youtube_id }}" style="width: {{video.player.progress}}%;"></div>
{% else %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div>
<div class="video-progress-bar" id="progress-{{ video.youtube_id }}" style="width: 0%;"></div>
{% endif %}
</div>
<div class="video-play">
@ -119,16 +130,16 @@
</div>
</a>
<div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.source.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
<div class="video-desc-player" id="video-info-{{ video.youtube_id }}">
{% if video.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.source.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %}
<span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span>
<span>{{ video.published }} | {{ video.player.duration_str }}</span>
</div>
<div>
<a class="video-more" href="{% url 'video' video.source.youtube_id %}"><h2>{{ video.source.title }}</h2></a>
<a class="video-more" href="{% url 'video' video.youtube_id %}"><h2>{{ video.title }}</h2></a>
</div>
</div>
</div>

View File

@ -2,11 +2,12 @@
{% block content %}
{% load static %}
{% load humanize %}
{% load auth_extras %}
<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">
<div class="info-box-item child-page-nav">
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
{% if has_streams %}
<a href="{% url 'channel_id_live' channel_info.channel_id %}"><h3>Streams</h3></a>
@ -19,7 +20,9 @@
{% endif %}
<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>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
{% endif %}
{% endif %}
</div>
<div id="notifications" data="channel reindex"></div>
@ -56,19 +59,21 @@
{% elif channel_info.channel_views > 0 %}
<p>Channel views: {{ channel_info.channel_views|intcomma }}</p>
{% endif %}
<div class="button-box">
<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>
{% if reindex %}
<p>Reindex scheduled</p>
{% else %}
<div id="reindex-button" class="button-box">
<button data-id="{{ channel_info.channel_id }}" data-type="channel" onclick="reindex(this)" title="Reindex Channel {{ channel_info.channel_name }}">Reindex</button>
<button data-id="{{ channel_info.channel_id }}" data-type="channel" data-extract-videos="true" onclick="reindex(this)" title="Reindex Videos of {{ channel_info.channel_name }}">Reindex Videos</button>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<div class="button-box">
<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>
{% if reindex %}
<p>Reindex scheduled</p>
{% else %}
<div id="reindex-button" class="button-box">
<button data-id="{{ channel_info.channel_id }}" data-type="channel" onclick="reindex(this)" title="Reindex Channel {{ channel_info.channel_name }}">Reindex</button>
<button data-id="{{ channel_info.channel_id }}" data-type="channel" data-extract-videos="true" onclick="reindex(this)" title="Reindex Videos of {{ channel_info.channel_name }}">Reindex Videos</button>
</div>
{% endif %}
{% endif %}
</div>
</div>
@ -90,53 +95,55 @@
</div>
</div>
{% endif %}
<div id="overwrite-form" class="info-box">
<div class="info-box-item">
<h2>Customize {{ channel_info.channel_name }}</h2>
<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>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<div id="overwrite-form" class="info-box">
<div class="info-box-item">
<h2>Customize {{ channel_info.channel_name }}</h2>
<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>
{% endif %}
</div>
<script type="text/javascript" src="{% static 'progress.js' %}"></script>
{% endblock content %}

View File

@ -2,11 +2,12 @@
{% block content %}
{% load static %}
{% load humanize %}
{% load auth_extras %}
<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">
<div class="info-box-item child-page-nav">
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
{% if has_streams %}
<a href="{% url 'channel_id_live' channel_info.channel_id %}"><h3>Streams</h3></a>
@ -19,7 +20,9 @@
{% endif %}
<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>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
{% endif %}
{% endif %}
</div>
<div id="notifications" data="channel reindex"></div>
@ -45,18 +48,18 @@
{% 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 href="{% url 'playlist_id' playlist.playlist_id %}">
<img src="/cache/playlists/{{ playlist.playlist_id }}.jpg" alt="{{ playlist.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>
<a href="{% url 'channel_id' playlist.playlist_channel_id %}"><h3>{{ playlist.playlist_channel }}</h3></a>
<a href="{% url 'playlist_id' playlist.playlist_id %}"><h2>{{ playlist.playlist_name }}</h2></a>
<p>Last refreshed: {{ playlist.playlist_last_refresh }}</p>
{% if playlist.playlist_subscribed and request.user|has_group:"admin" or request.user.is_staff %}
<button class="unsubscribe" type="button" data-type="playlist" data-subscribe="" data-id="{{ playlist.playlist_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ playlist.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>
<button type="button" data-type="playlist" data-subscribe="true" data-id="{{ playlist.playlist_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ playlist.playlist_name }}">Subscribe</button>
{% endif %}
</div>
</div>

View File

@ -70,18 +70,18 @@
<div class="video-list {{ view_style }} {% if view_style == "grid" %}grid-{{ grid_items }}{% endif %}">
{% if results %}
{% for video in results %}
<div class="video-item {{ view_style }}" id="dl-{{ video.source.youtube_id }}">
<div class="video-item {{ view_style }}" id="dl-{{ video.youtube_id }}">
<div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb">
<img src="{{ video.source.vid_thumb_url }}" alt="video_thumb">
<img src="{{ video.vid_thumb_url }}" alt="video_thumb">
<div class="video-tags">
{% if show_ignored_only %}
<span>ignored</span>
{% else %}
<span>queued</span>
{% endif %}
<span>{{ video.source.vid_type }}</span>
{% if video.source.auto_start %}
<span>{{ video.vid_type }}</span>
{% if video.auto_start %}
<span>auto</span>
{% endif %}
</div>
@ -89,21 +89,27 @@
</div>
<div class="video-desc {{ view_style }}">
<div>
{% if video.source.channel_indexed %}
<a href="{% url 'channel_id' video.source.channel_id %}">{{ video.source.channel_name }}</a>
{% if video.channel_indexed %}
<a href="{% url 'channel_id' video.channel_id %}">{{ video.channel_name }}</a>
{% else %}
<span>{{ video.source.channel_name }}</span>
<span>{{ video.channel_name }}</span>
{% endif %}
<a href="https://www.youtube.com/watch?v={{ video.source.youtube_id }}" target="_blank"><h3>{{ video.source.title }}</h3></a>
<a href="https://www.youtube.com/watch?v={{ video.youtube_id }}" target="_blank"><h3>{{ video.title }}</h3></a>
</div>
<p>Published: {{ video.source.published }} | Duration: {{ video.source.duration }} | {{ video.source.youtube_id }}</p>
<p>Published: {{ video.published }} | Duration: {{ video.duration }} | {{ video.youtube_id }}</p>
{% if video.message %}
<p class="danger-zone">{{ video.message }}</p>
{% endif %}
<div>
{% if show_ignored_only %}
<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.youtube_id }}" onclick="forgetIgnore(this)">Forget</button>
<button data-id="{{ video.youtube_id }}" onclick="addSingle(this)">Add to queue</button>
{% else %}
<button data-id="{{ video.source.youtube_id }}" onclick="toIgnore(this)">Ignore</button>
<button id="{{ video.source.youtube_id }}" data-id="{{ video.source.youtube_id }}" onclick="downloadNow(this)">Download now</button>
<button data-id="{{ video.youtube_id }}" onclick="toIgnore(this)">Ignore</button>
<button id="{{ video.youtube_id }}" data-id="{{ video.youtube_id }}" onclick="downloadNow(this)">Download now</button>
{% endif %}
{% if video.message %}
<button class="danger-button" data-id="{{ video.youtube_id }}" onclick="forgetIgnore(this)">Delete</button>
{% endif %}
</div>
</div>

View File

@ -9,14 +9,14 @@
<div class="video-list {{ view_style }} {% if view_style == "grid" %}grid-{{ grid_items }}{% endif %}">
{% for video in continue_vids %}
<div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<a href="#player" data-id="{{ video.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb">
<img src="{{ video.source.vid_thumb_url }}" alt="video-thumb">
{% if video.source.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div>
<img src="{{ video.vid_thumb_url }}" alt="video-thumb">
{% if video.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.youtube_id }}" style="width: {{video.player.progress}}%;"></div>
{% else %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div>
<div class="video-progress-bar" id="progress-{{ video.youtube_id }}" style="width: 0%;"></div>
{% endif %}
</div>
<div class="video-play">
@ -25,17 +25,17 @@
</div>
</a>
<div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.source.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
<div class="video-desc-player" id="video-info-{{ video.youtube_id }}">
{% if video.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.source.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %}
<span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span>
<span>{{ video.published }} | {{ video.player.duration_str }}</span>
</div>
<div>
<a href="{% url 'channel_id' video.source.channel.channel_id %}"><h3>{{ video.source.channel.channel_name }}</h3></a>
<a class="video-more" href="{% url 'video' video.source.youtube_id %}"><h2>{{ video.source.title }}</h2></a>
<a href="{% url 'channel_id' video.channel.channel_id %}"><h3>{{ video.channel.channel_name }}</h3></a>
<a class="video-more" href="{% url 'video' video.youtube_id %}"><h2>{{ video.title }}</h2></a>
</div>
</div>
</div>
@ -60,13 +60,15 @@
<div class="sort">
<div id="hidden-form">
<span>Sort by:</span>
<select name="sort" id="sort" onchange="sortChange(this.value)">
<select name="sort_by" id="sort" onchange="sortChange(this)">
<option value="published" {% if sort_by == "published" %}selected{% endif %}>date published</option>
<option value="downloaded" {% if sort_by == "downloaded" %}selected{% endif %}>date downloaded</option>
<option value="views" {% if sort_by == "views" %}selected{% endif %}>views</option>
<option value="likes" {% if sort_by == "likes" %}selected{% endif %}>likes</option>
<option value="duration" {% if sort_by == "duration" %}selected{% endif %}>duration</option>
<option value="filesize" {% if sort_by == "filesize" %}selected{% endif %}>file size</option>
</select>
<select name="sord-order" id="sort-order" onchange="sortChange(this.value)">
<select name="sort_order" id="sort-order" onchange="sortChange(this)">
<option value="asc" {% if sort_order == "asc" %}selected{% endif %}>asc</option>
<option value="desc" {% if sort_order == "desc" %}selected{% endif %}>desc</option>
</select>
@ -95,14 +97,14 @@
{% if results %}
{% for video in results %}
<div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<a href="#player" data-id="{{ video.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb">
<img src="{{ video.source.vid_thumb_url }}" alt="video-thumb">
{% if video.source.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div>
<img src="{{ video.vid_thumb_url }}" alt="video-thumb">
{% if video.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.youtube_id }}" style="width: {{video.player.progress}}%;"></div>
{% else %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div>
<div class="video-progress-bar" id="progress-{{ video.youtube_id }}" style="width: 0%;"></div>
{% endif %}
</div>
<div class="video-play">
@ -111,17 +113,17 @@
</div>
</a>
<div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.source.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
<div class="video-desc-player" id="video-info-{{ video.youtube_id }}">
{% if video.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.source.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %}
<span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span>
<span>{{ video.published }} | {{ video.player.duration_str }}</span>
</div>
<div>
<a href="{% url 'channel_id' video.source.channel.channel_id %}"><h3>{{ video.source.channel.channel_name }}</h3></a>
<a class="video-more" href="{% url 'video' video.source.youtube_id %}"><h2>{{ video.source.title }}</h2></a>
<a href="{% url 'channel_id' video.channel.channel_id %}"><h3>{{ video.channel.channel_name }}</h3></a>
<a class="video-more" href="{% url 'video' video.youtube_id %}"><h2>{{ video.title }}</h2></a>
</div>
</div>
</div>

View File

@ -18,20 +18,11 @@
<meta name="msapplication-TileColor" content="#01202e">
<meta name="msapplication-config" content="{% static 'favicon/browserconfig.xml' %}">
<meta name="theme-color" content="#01202e">
{% if colors == "dark" %}
<link rel="stylesheet" href="{% static 'css/dark.css' %}">
{% else %}
<link rel="stylesheet" href="{% static 'css/light.css' %}">
{% endif %}
<link rel="stylesheet" href="{% static 'css/' %}{{ stylesheet }}">
</head>
<body>
<div class="boxed-content login-page">
{% if colors == 'dark' %}
<img src="{% static 'img/logo-tube-archivist-dark.png' %}" alt="tube-archivist-logo">
{% endif %}
{% if colors == 'light' %}
<img src="{% static 'img/logo-tube-archivist-light.png' %}" alt="tube-archivist-banner">
{% endif %}
<img alt="tube-archivist-logo">
<h1>Tube Archivist</h1>
<h2>Your Self Hosted YouTube Media Server</h2>
{% if form_error %}

View File

@ -1,21 +1,32 @@
{% extends "home/base.html" %}
{% load static %}
{% block content %}
{% load auth_extras %}
<div class="boxed-content">
<div class="title-split">
<div class="title-bar">
<h1>Playlists</h1>
</div>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<div class="title-split-form">
<img id="animate-icon" onclick="showForm()" src="{% static 'img/icon-add.svg' %}" alt="add-icon" title="Subscribe to Playlists">
<img id="animate-icon" onclick="showForm();showForm('hidden-form2')" src="{% static 'img/icon-add.svg' %}" alt="add-icon" title="Subscribe to Playlists">
<div class="show-form">
<form id="hidden-form" action="/playlist/" method="post">
{% csrf_token %}
{{ subscribe_form }}
<button type="submit">Subscribe</button>
</form>
<form id="hidden-form2" action="/playlist/" method="post">
{% csrf_token %}
{{ create_form }}
<button type="submit">Create</button>
</form>
</div>
</div>
{% endif %}
</div>
<div id="notifications" data="subscription"></div>
<div class="view-controls">
@ -40,19 +51,23 @@
{% 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 href="{% url 'playlist_id' playlist.playlist_id %}">
<img src="/cache/playlists/{{ playlist.playlist_id }}.jpg" alt="{{ playlist.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>
{% if playlist.playlist_type != "custom" %}
<a href="{% url 'channel_id' playlist.playlist_channel_id %}"><h3>{{ playlist.playlist_channel }}</h3></a>
{% endif %}
<a href="{% url 'playlist_id' playlist.playlist_id %}"><h2>{{ playlist.playlist_name }}</h2></a>
<p>Last refreshed: {{ playlist.playlist_last_refresh }}</p>
{% if playlist.playlist_type != "custom" %}
{% if playlist.playlist_subscribed %}
<button class="unsubscribe" type="button" data-type="playlist" data-subscribe="" data-id="{{ playlist.playlist_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ playlist.playlist_name }}">Unsubscribe</button>
{% else %}
<button type="button" data-type="playlist" data-subscribe="true" data-id="{{ playlist.playlist_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ playlist.playlist_name }}">Subscribe</button>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}

View File

@ -2,46 +2,55 @@
{% load static %}
{% load humanize %}
{% block content %}
{% load auth_extras %}
<div class="boxed-content">
<div class="title-bar">
<h1>{{ playlist_info.playlist_name }}</h1>
</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="/cache/channels/{{ channel_info.channel_id }}_thumb.jpg" 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 %}
<span>Subscribers: {{ channel_info.channel_subs|intword }}</span>
{% else %}
<span>Subscribers: {{ channel_info.channel_subs|intcomma }}</span>
{% endif %}
</div>
</div>
{% if playlist_info.playlist_type != "custom" %}
<div class="info-box-item">
<div class="round-img">
<a href="{% url 'channel_id' channel_info.channel_id %}">
<img src="/cache/channels/{{ channel_info.channel_id }}_thumb.jpg" 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 %}
<span>Subscribers: {{ channel_info.channel_subs|intword }}</span>
{% else %}
<span>Subscribers: {{ channel_info.channel_subs|intcomma }}</span>
{% endif %}
</div>
</div>
{% endif %}
<div class="info-box-item">
<div>
<p>Last refreshed: {{ playlist_info.playlist_last_refresh }}</p>
<p>Playlist:
{% if playlist_info.playlist_subscribed %}
<button class="unsubscribe" type="button" id="{{ playlist_info.playlist_id }}" onclick="unsubscribe(this.id)" title="Unsubscribe from {{ playlist_info.playlist_name }}">Unsubscribe</button>
{% else %}
<button type="button" id="{{ playlist_info.playlist_id }}" onclick="subscribe(this.id)" title="Subscribe to {{ playlist_info.playlist_name }}">Subscribe</button>
{% endif %}
</p>
{% if playlist_info.playlist_active %}
<p>Youtube: <a href="https://www.youtube.com/playlist?list={{ playlist_info.playlist_id }}" target="_blank">Active</a></p>
{% else %}
<p>Youtube: Deactivated</p>
{% if playlist_info.playlist_type != "custom" %}
<p>Playlist:
{% if playlist_info.playlist_subscribed %}
{% if request.user|has_group:"admin" or request.user.is_staff %}
<button class="unsubscribe" type="button" data-type="playlist" data-subscribe="" data-id="{{ playlist_info.playlist_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ playlist_info.playlist_name }}">Unsubscribe</button>
{% endif %}
{% else %}
<button type="button" data-type="playlist" data-subscribe="true" data-id="{{ playlist_info.playlist_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ playlist_info.playlist_name }}">Subscribe</button>
{% endif %}
</p>
{% if playlist_info.playlist_active %}
<p>Youtube: <a href="https://www.youtube.com/playlist?list={{ playlist_info.playlist_id }}" target="_blank">Active</a></p>
{% else %}
<p>Youtube: Deactivated</p>
{% endif %}
{% endif %}
<button onclick="deleteConfirm()" id="delete-item">Delete Playlist</button>
<div class="delete-confirm" id="delete-button">
<span>Delete {{ playlist_info.playlist_name }}?</span>
<button onclick="deletePlaylist(this)" data-action="metadata" data-id="{{ playlist_info.playlist_id }}">Delete metadata</button>
<button onclick="deletePlaylist(this)" data-action="all" class="danger-button" data-id="{{ playlist_info.playlist_id }}">Delete all</button><br>
<button onclick="deletePlaylist(this)" data-action="" data-id="{{ playlist_info.playlist_id }}">Delete metadata</button>
<button onclick="deletePlaylist(this)" data-action="delete-videos" class="danger-button" data-id="{{ playlist_info.playlist_id }}">Delete all</button><br>
<button onclick="cancelDelete()">Cancel</button>
</div>
</div>
@ -50,13 +59,18 @@
<div>
{% if max_hits %}
<p>Total Videos archived: {{ max_hits }}/{{ playlist_info.playlist_entries|length }}</p>
<p>Watched: <button title="Mark all videos from {{ playlist_info.playlist_name }} as watched" type="button" id="watched-button" data-id="{{ playlist_info.playlist_id }}" onclick="isWatchedButton(this)">Mark as watched</button></p>
<div id="watched-button" class="button-box">
<button title="Mark all videos from {{ playlist_info.playlist_name }} as watched" type="button" id="watched-button" data-id="{{ playlist_info.playlist_id }}" onclick="isWatchedButton(this)">Mark as watched</button>
<button title="Mark all videos from {{ playlist_info.playlist_name }} as unwatched" type="button" id="unwatched-button" data-id="{{ playlist_info.playlist_id }}" onclick="isUnwatchedButton(this)">Mark as unwatched</button>
</div>
{% endif %}
{% if reindex %}
<p>Reindex scheduled</p>
{% else %}
<div id="reindex-button" class="button-box">
<button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" onclick="reindex(this)" title="Reindex Playlist {{ playlist_info.playlist_name }}">Reindex</button>
{% if playlist_info.playlist_type != "custom" %}
<button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" onclick="reindex(this)" title="Reindex Playlist {{ playlist_info.playlist_name }}">Reindex</button>
{% endif %}
<button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" data-extract-videos="true" onclick="reindex(this)" title="Reindex Videos of {{ playlist_info.playlist_name }}">Reindex Videos</button>
</div>
{% endif %}
@ -107,14 +121,14 @@
{% if results %}
{% for video in results %}
<div class="video-item {{ view_style }}">
<a href="#player" data-id="{{ video.source.youtube_id }}" onclick="createPlayer(this)">
<a href="#player" data-id="{{ video.youtube_id }}" onclick="createPlayer(this)">
<div class="video-thumb-wrap {{ view_style }}">
<div class="video-thumb">
<img src="{{ video.source.vid_thumb_url }}" alt="video-thumb">
{% if video.source.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: {{video.source.player.progress}}%;"></div>
<img src="{{ video.vid_thumb_url }}" alt="video-thumb">
{% if video.player.progress %}
<div class="video-progress-bar" id="progress-{{ video.youtube_id }}" style="width: {{video.player.progress}}%;"></div>
{% else %}
<div class="video-progress-bar" id="progress-{{ video.source.youtube_id }}" style="width: 0%;"></div>
<div class="video-progress-bar" id="progress-{{ video.youtube_id }}" style="width: 0%;"></div>
{% endif %}
</div>
<div class="video-play">
@ -123,23 +137,43 @@
</div>
</a>
<div class="video-desc {{ view_style }}">
<div class="video-desc-player" id="video-info-{{ video.source.youtube_id }}">
{% if video.source.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.source.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
<div class="video-desc-player" id="video-info-{{ video.youtube_id }}">
{% if video.player.watched %}
<img src="{% static 'img/icon-seen.svg' %}" alt="seen-icon" data-id="{{ video.youtube_id }}" data-status="watched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as unwatched">
{% else %}
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.source.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
<img src="{% static 'img/icon-unseen.svg' %}" alt="unseen-icon" data-id="{{ video.youtube_id }}" data-status="unwatched" onclick="updateVideoWatchStatus(this)" class="watch-button" title="Mark as watched">
{% endif %}
<span>{{ video.source.published }} | {{ video.source.player.duration_str }}</span>
<span>{{ video.published }} | {{ video.player.duration_str }}</span>
</div>
<div>
<a class="video-more" href="{% url 'video' video.source.youtube_id %}"><h2>{{ video.source.title }}</h2></a>
<div class="video-desc-details">
<div>
{% if playlist_info.playlist_type == "custom" %}
<a href="{% url 'channel_id' video.channel.channel_id %}"><h3>{{ video.channel.channel_name }}</h3></a>
{% endif %}
<a class="video-more" href="{% url 'video' video.youtube_id %}"><h2>{{ video.title }}</h2></a>
</div>
{% if playlist_info.playlist_type == "custom" %}
{% if pagination %}
{% if pagination.last_page > 0 %}
<img id="{{ video.youtube_id }}-button" src="{% static 'img/icon-dot-menu.svg' %}" alt="dot-menu-icon" data-id="{{ video.youtube_id }}" data-context="video" onclick="showCustomPlaylistMenu(this,'{{playlist_info.playlist_id}}',{{pagination.current_page}},{{pagination.last_page}})" class="dot-button" title="More actions">
{% else %}
<img id="{{ video.youtube_id }}-button" src="{% static 'img/icon-dot-menu.svg' %}" alt="dot-menu-icon" data-id="{{ video.youtube_id }}" data-context="video" onclick="showCustomPlaylistMenu(this,'{{playlist_info.playlist_id}}',{{pagination.current_page}},{{pagination.current_page}})" class="dot-button" title="More actions">
{% endif %}
{% else %}
<img id="{{ video.youtube_id }}-button" src="{% static 'img/icon-dot-menu.svg' %}" alt="dot-menu-icon" data-id="{{ video.youtube_id }}" data-context="video" onclick="showCustomPlaylistMenu(this,'{{playlist_info.playlist_id}}',0,0)" class="dot-button" title="More actions">
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<h2>No videos found...</h2>
<p>Try going to the <a href="{% url 'downloads' %}">downloads page</a> to start the scan and download tasks.</p>
{% if playlist_info.playlist_type == "custom" %}
<p>Try going to the <a href="{% url 'home' %}">home page</a> to add videos to this playlist.</p>
{% else %}
<p>Try going to the <a href="{% url 'downloads' %}">downloads page</a> to start the scan and download tasks.</p>
{% endif %}
{% endif %}
</div>
</div>

View File

@ -1,405 +1,80 @@
{% extends "home/base.html" %}
{% extends "home/base_settings.html" %}
{% load static %}
{% block content %}
<div class="boxed-content">
<div id="notifications" data="setting reindex"></div>
<div class="title-bar">
<h1>User Configurations</h1>
</div>
<form action="/settings/" method="POST" name="user-update">
{% csrf_token %}
<div class="settings-group">
<h2>Color scheme</h2>
<div class="settings-item">
<p>Current color scheme: <span class="settings-current">{{ config.application.colors }}</span></p>
<i>Select your preferred color scheme between dark and light mode.</i><br>
{{ user_form.colors }}
</div>
</div>
<div class="settings-group">
<h2>Archive View</h2>
<div class="settings-item">
<p>Current page size: <span class="settings-current">{{ config.archive.page_size }}</span></p>
<i>Result of videos showing in archive page</i><br>
{{ user_form.page_size }}
</div>
</div>
<button type="submit" name="user-settings">Update User Configurations</button>
</form>
<div class="title-bar">
<h1>Application Configurations</h1>
</div>
<form action="/settings/" method="POST" name="application-update">
{% csrf_token %}
<div class="settings-group">
<h2 id="subscriptions">Subscriptions</h2>
<p>Disable shorts or streams by setting their page size to 0 (zero).</p>
<div class="settings-item">
<p>YouTube page size: <span class="settings-current">{{ config.subscriptions.channel_size }}</span></p>
<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 }}
</div>
<div class="settings-item">
<p>YouTube Live page size: <span class="settings-current">{{ config.subscriptions.live_channel_size }}</span></p>
<i>Live Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
{{ app_form.subscriptions_live_channel_size }}
</div>
<div class="settings-item">
<p>YouTube Shorts page size: <span class="settings-current">{{ config.subscriptions.shorts_channel_size }}</span></p>
<i>Shorts Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
{{ app_form.subscriptions_shorts_channel_size }}
</div>
<div class="settings-item">
<p>Auto start download from your subscriptions: <span class="settings-current">{{ config.subscriptions.auto_start}}</span></p>
<i>Enable this will automatically start and prioritize videos from your subscriptions.</i><br>
{{ app_form.subscriptions_auto_start }}
</div>
</div>
<div class="settings-group">
<h2 id="downloads">Downloads</h2>
<div class="settings-item">
<p>Current download speed limit in KB/s: <span class="settings-current">{{ config.downloads.limit_speed }}</span></p>
<i>Limit download speed. 0 (zero) to deactivate, e.g. 1000 (1MB/s). Speeds are in KB/s. Setting takes effect on new download jobs or application restart.</i><br>
{{ app_form.downloads_limit_speed }}
</div>
<div class="settings-item">
<p>Current throttled rate limit in KB/s: <span class="settings-current">{{ config.downloads.throttledratelimit }}</span></p>
<i>Download will restart if speeds drop below specified amount. 0 (zero) to deactivate, e.g. 100. Speeds are in KB/s.</i><br>
{{ app_form.downloads_throttledratelimit }}
</div>
<div class="settings-item">
<p>Current scraping sleep interval: <span class="settings-current">{{ config.downloads.sleep_interval }}</p>
<i>Seconds to sleep between calls to YouTube. Might be necessary to avoid throttling. Recommended 3.</i><br>
{{ app_form.downloads_sleep_interval }}
</div>
<div class="settings-item">
<p><span class="danger-zone">Danger Zone</span>: Current auto delete watched videos: <span class="settings-current">{{ config.downloads.autodelete_days }}</span></p>
<i>Auto delete watched videos after x days, 0 (zero) to deactivate:</i><br>
{{ app_form.downloads_autodelete_days }}
</div>
</div>
<div class="settings-group">
<h2 id="format">Download Format</h2>
<div class="settings-item">
<p>Limit video and audio quality format for yt-dlp.<br>
Currently: <span class="settings-current">{{ config.downloads.format }}</span>
</p>
<p>Example configurations:</p>
<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<=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">0</span>: deactivate and download the best quality possible as decided by yt-dlp.</li>
</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>
{{ app_form.downloads_format }}
<br>
</div>
<div class="settings-item">
<p>Force sort order to have precedence over all yt-dlp fields.<br>
Currently: <span class="settings-current">{{ config.downloads.format_sort }}</span>
</p>
<p>Example configurations:</p>
<ul>
<li><span class="settings-current">res,codec:av1</span>: prefer AV1 over all other video codecs.</li>
<li><span class="settings-current">0</span>: deactivate and keep the default as decided by yt-dlp.</li>
</ul>
<i>Not all codecs are supported by all browsers. The default value ensures best compatibility. Check out the <a href="https://github.com/yt-dlp/yt-dlp#sorting-formats" target="_blank">documentation</a> for valid configurations.</i><br>
{{ app_form.downloads_format_sort }}
<br>
</div>
<div class="settings-item">
<p>Prefer translated metadata language: <span class="settings-current">{{ config.downloads.extractor_lang }}</span></p>
<i>This will change the language this video gets indexed as. That will only be available if the uploader provides translations. Add as two letter ISO language code, check the <a href="https://github.com/yt-dlp/yt-dlp#youtube" target="_blank">documentation</a> which languages are available.</i><br>
{{ app_form.downloads_extractor_lang}}
</div>
<div class="settings-item">
<p>Current metadata embed setting: <span class="settings-current">{{ config.downloads.add_metadata }}</span></p>
<i>Metadata is not embedded into the downloaded files by default.</i><br>
{{ app_form.downloads_add_metadata }}
</div>
<div class="settings-item">
<p>Current thumbnail embed setting: <span class="settings-current">{{ config.downloads.add_thumbnail }}</span></p>
<i>Embed thumbnail into the mediafile.</i><br>
{{ app_form.downloads_add_thumbnail }}
</div>
</div>
<div class="settings-group">
<h2 id="format">Subtitles</h2>
<div class="settings-item">
<p>Subtitles download setting: <span class="settings-current">{{ config.downloads.subtitle }}</span><br>
<i>Choose which subtitles to download, add comma separated two letter language ISO code,<br>
e.g. <span class="settings-current">en, de</span></i><br>
{{ app_form.downloads_subtitle }}</p>
</div>
<div class="settings-item">
<p>Subtitle source settings: <span class="settings-current">{{ config.downloads.subtitle_source }}</span></p>
<i>Download only user generated, or also less accurate auto generated subtitles.</i><br>
{{ app_form.downloads_subtitle_source }}
</div>
<div class="settings-item">
<p>Index and make subtitles searchable: <span class="settings-current">{{ config.downloads.subtitle_index }}</span></p>
<i>Store subtitle lines in Elasticsearch. Not recommended for low-end hardware.</i><br>
{{ app_form.downloads_subtitle_index }}
</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://docs.tubearchivist.com/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">
<h2 id="integrations">Integrations</h2>
<div class="settings-item">
<p>API token: <button type="button" onclick="textReveal()" id="text-reveal-button">Show</button></p>
<div id="text-reveal" class="description-text">
<p>{{ api_token }}</p>
<button class="danger-button" type="button" onclick="resetToken()">Revoke</button>
</div>
</div>
<div class="settings-item">
<p>Integrate with <a href="https://returnyoutubedislike.com/" target="_blank">returnyoutubedislike.com</a> to get dislikes and average ratings back: <span class="settings-current">{{ config.downloads.integrate_ryd }}</span></p>
<i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br>
{{ app_form.downloads_integrate_ryd }}
</div>
<div class="settings-item">
<p>Integrate with <a href="https://sponsor.ajay.app/" target="_blank">SponsorBlock</a> to get sponsored timestamps: <span class="settings-current">{{ config.downloads.integrate_sponsorblock }}</span></p>
<i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br>
{{ app_form.downloads_integrate_sponsorblock }}
</div>
</div>
<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://docs.tubearchivist.com/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>
</form>
<div class="title-bar">
<h1>Scheduler Setup</h1>
<div class="settings-group">
<p>Schedule settings expect a cron like format, where the first value is minute, second is hour and third is day of the week.</p>
<p>Examples:</p>
<ul>
<li><span class="settings-current">0 15 *</span>: Run task every day at 15:00 in the afternoon.</li>
<li><span class="settings-current">30 8 */2</span>: Run task every second day of the week (Sun, Tue, Thu, Sat) at 08:30 in the morning.</li>
<li><span class="settings-current">auto</span>: Sensible default.</li>
<li><span class="settings-current">0</span>: (zero), deactivate that task.</li>
</ul>
<p>Note:</p>
<ul>
<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, the scheduler doesn't support schedules that trigger more than once per hour.</li>
</ul>
</div>
</div>
<form action="/settings/" method="POST" name="scheduler-update">
{% csrf_token %}
<div class="settings-group">
<h2>Rescan Subscriptions</h2>
<div class="settings-item">
<p>Current rescan schedule: <span class="settings-current">
{% if config.scheduler.update_subscribed %}
{% for key, value in config.scheduler.update_subscribed.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Become a sponsor and join <a href="https://members.tubearchivist.com/" target="_blank">members.tubearchivist.com</a> to get access to <span class="settings-current">real time</span> notifications for new videos uploaded by your favorite channels.</p>
<p>Periodically rescan your subscriptions:</p>
{{ scheduler_form.update_subscribed }}
</div>
</div>
<div class="settings-group">
<h2>Start download</h2>
<div class="settings-item">
<p>Current Download schedule: <span class="settings-current">
{% if config.scheduler.download_pending %}
{% for key, value in config.scheduler.download_pending.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Automatic video download schedule:</p>
{{ scheduler_form.download_pending }}
</div>
</div>
<div class="settings-group">
<h2>Refresh Metadata</h2>
<div class="settings-item">
<p>Current Metadata refresh schedule: <span class="settings-current">
{% if config.scheduler.check_reindex %}
{% for key, value in config.scheduler.check_reindex.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Daily schedule to refresh metadata from YouTube:</p>
{{ scheduler_form.check_reindex }}
</div>
<div class="settings-item">
<p>Current refresh for metadata older than x days: <span class="settings-current">{{ config.scheduler.check_reindex_days }}</span></p>
<p>Refresh older than x days, recommended 90:</p>
{{ scheduler_form.check_reindex_days }}
</div>
</div>
<div class="settings-group">
<h2>Thumbnail check</h2>
<div class="settings-item">
<p>Current thumbnail check schedule: <span class="settings-current">
{% if config.scheduler.thumbnail_check %}
{% for key, value in config.scheduler.thumbnail_check.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Periodically check and cleanup thumbnails:</p>
{{ scheduler_form.thumbnail_check }}
</div>
</div>
<div class="settings-group">
<h2>ZIP file index backup</h2>
<div class="settings-item">
<p><i>Zip file backups are very slow for large archives and consistency is not guaranteed, use snapshots instead. Make sure no other tasks are running when creating a Zip file backup.</i></p>
<p>Current index backup schedule: <span class="settings-current">
{% if config.scheduler.run_backup %}
{% for key, value in config.scheduler.run_backup.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Automatically backup metadata to a zip file:</p>
{{ scheduler_form.run_backup }}
</div>
<div class="settings-item">
<p>Current backup files to keep: <span class="settings-current">{{ config.scheduler.run_backup_rotate }}</span></p>
<p>Max auto backups to keep:</p>
{{ scheduler_form.run_backup_rotate }}
</div>
</div>
<button type="submit" name="scheduler-settings">Update Scheduler Settings</button>
</form>
<div class="title-bar">
<h1>Actions</h1>
</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">
<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://docs.tubearchivist.com/settings/" target="_blank">Wiki</a>.</p>
<div id="manual-import">
<button onclick="manualImport()">Start import</button>
</div>
</div>
<div class="settings-group">
<h2>Embed thumbnails into media file.</h2>
<p>Set extracted youtube thumbnail as cover art of the media file.</p>
<div id="re-embed">
<button onclick="reEmbed()">Start process</button>
</div>
</div>
<div class="settings-group">
<h2>ZIP file index backup</h2>
<p>Export your database to a zip file stored at <span class="settings-current">cache/backup</span>.</p>
<p><i>Zip file backups are very slow for large archives and consistency is not guaranteed, use snapshots instead. Make sure no other tasks are running when creating a Zip file backup.</i></p>
<div id="db-backup">
<button onclick="dbBackup()">Start backup</button>
</div>
</div>
<div class="settings-group">
<h2>Restore from backup</h2>
<p><span class="danger-zone">Danger Zone</span>: This will replace your existing index with the backup.</p>
<p>Restore from available backup files from <span class="settings-current">cache/backup</span>.</p>
{% if available_backups %}
<div class="backup-grid-row">
<span></span>
<span>Timestamp</span>
<span>Source</span>
<span>Filename</span>
</div>
{% for backup in available_backups %}
<div class="backup-grid-row" id="{{ backup.filename }}">
<button onclick="dbRestore(this)" data-id="{{ backup.filename }}">Restore</button>
<span>{{ backup.timestamp }}</span>
<span>{{ backup.reason }}</span>
<span>{{ backup.filename }}</span>
</div>
{% endfor %}
{% else %}
<p>No backups found.</p>
{% endif %}
</div>
<div class="settings-group">
<h2>Rescan filesystem</h2>
<p><span class="danger-zone">Danger Zone</span>: This will delete the metadata of deleted videos from the filesystem.</p>
<p>Rescan your media folder looking for missing videos and clean up index. More infos on the Github <a href="https://docs.tubearchivist.com/settings/" target="_blank">Wiki</a>.</p>
<div id="fs-rescan">
<button onclick="fsRescan()">Rescan filesystem</button>
</div>
</div>
{% if request.user.is_superuser %}
<div class="title-bar">
<h1>Users</h1>
</div>
<div class="settings-group">
<h2>User Management</h2>
<p>Access the admin interface for basic user management functionality like adding and deleting users, changing passwords and more.</p>
<a href="/admin/"><button>Admin Interface</button></a>
</div>
{% endif %}
{% block settings_content %}
<div class="title-bar">
<h1>Your Archive</h1>
</div>
<script type="text/javascript" src="{% static 'progress.js' %}"></script>
{% endblock content %}
<div class="settings-item">
<h2>Overview</h2>
<div id="activeBox" class="info-box info-box-3">
<p id="loading">Loading...</p>
</div>
</div>
<div class="settings-item">
<h2>Video Type</h2>
<div id="videoTypeBox" class="info-box info-box-3">
<p id="loading">Loading...</p>
</div>
</div>
<div class="settings-item">
<h2>Application</h2>
<div id="secondaryBox" class="info-box info-box-3">
<p id="loading">Loading...</p>
</div>
</div>
<div class="settings-item">
<h2>Watch Progress</h2>
<div id="watchBox" class="info-box info-box-2">
<p id="loading">Loading...</p>
</div>
</div>
<div class="settings-item">
<h2>Download History</h2>
<div id="downHistBox" class="info-box info-box-4">
<p id="loading">Loading...</p>
</div>
</div>
<div class="settings-item">
<h2>Biggest Channels</h2>
<div class="info-box info-box-3">
<div class="info-box-item">
<table class="agg-channel-table">
<thead>
<tr>
<th>Name</th>
<th class="agg-channel-right-align">Videos</th>
</tr>
</thead>
<tbody id="biggestChannelTableVideos"></tbody>
</table>
</div>
<div class="info-box-item">
<table class="agg-channel-table">
<thead>
<tr>
<th>Name</th>
<th class="agg-channel-right-align">Duration</th>
</tr>
</thead>
<tbody id="biggestChannelTableDuration"></tbody>
</table>
</div>
<div class="info-box-item">
<table class="agg-channel-table">
<thead>
<tr>
<th>Name</th>
<th class="agg-channel-right-align">Media Size</th>
</tr>
</thead>
<tbody id="biggestChannelTableMediaSize"></tbody>
</table>
</div>
</div>
</div>
<script type="text/javascript" src="{% static 'stats.js' %}"></script>
{% endblock settings_content %}

View File

@ -0,0 +1,66 @@
{% extends "home/base_settings.html" %}
{% load static %}
{% block settings_content %}
<div class="title-bar">
<h1>Actions</h1>
</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">
<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://docs.tubearchivist.com/settings/actions/#manual-media-files-import" target="_blank">Wiki</a>.</p>
<div id="manual-import">
<button onclick="manualImport()">Start import</button>
</div>
</div>
<div class="settings-group">
<h2>Embed thumbnails into media file.</h2>
<p>Set extracted youtube thumbnail as cover art of the media file.</p>
<div id="re-embed">
<button onclick="reEmbed()">Start process</button>
</div>
</div>
<div class="settings-group">
<h2>ZIP file index backup</h2>
<p>Export your database to a zip file stored at <span class="settings-current">cache/backup</span>.</p>
<p><i>Zip file backups are very slow for large archives and consistency is not guaranteed, use snapshots instead. Make sure no other tasks are running when creating a Zip file backup.</i></p>
<div id="db-backup">
<button onclick="dbBackup()">Start backup</button>
</div>
</div>
<div class="settings-group">
<h2>Restore from backup</h2>
<p><span class="danger-zone">Danger Zone</span>: This will replace your existing index with the backup.</p>
<p>Restore from available backup files from <span class="settings-current">cache/backup</span>.</p>
{% if available_backups %}
<div class="backup-grid-row">
<span></span>
<span>Timestamp</span>
<span>Source</span>
<span>Filename</span>
</div>
{% for backup in available_backups %}
<div class="backup-grid-row" id="{{ backup.filename }}">
<button onclick="dbRestore(this)" data-id="{{ backup.filename }}">Restore</button>
<span>{{ backup.timestamp }}</span>
<span>{{ backup.reason }}</span>
<span>{{ backup.filename }}</span>
</div>
{% endfor %}
{% else %}
<p>No backups found.</p>
{% endif %}
</div>
<div class="settings-group">
<h2>Rescan filesystem</h2>
<p><span class="danger-zone">Danger Zone</span>: This will delete the metadata of deleted videos from the filesystem.</p>
<p>Rescan your media folder looking for missing videos and clean up index. More infos on the Github <a href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem" target="_blank">Wiki</a>.</p>
<div id="fs-rescan">
<button onclick="fsRescan()">Rescan filesystem</button>
</div>
</div>
{% endblock settings_content %}

View File

@ -0,0 +1,192 @@
{% extends "home/base_settings.html" %}
{% load static %}
{% block settings_content %}
<div class="title-bar">
<h1>Application Configurations</h1>
</div>
<form action="{% url 'settings_application' %}" method="POST" name="application-update">
{% csrf_token %}
<div class="settings-group">
<h2 id="subscriptions">Subscriptions</h2>
<p>Disable shorts or streams by setting their page size to 0 (zero).</p>
<div class="settings-item">
<p>YouTube page size: <span class="settings-current">{{ config.subscriptions.channel_size }}</span></p>
<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 }}
</div>
<div class="settings-item">
<p>YouTube Live page size: <span class="settings-current">{{ config.subscriptions.live_channel_size }}</span></p>
<i>Live Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
{{ app_form.subscriptions_live_channel_size }}
</div>
<div class="settings-item">
<p>YouTube Shorts page size: <span class="settings-current">{{ config.subscriptions.shorts_channel_size }}</span></p>
<i>Shorts Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
{{ app_form.subscriptions_shorts_channel_size }}
</div>
<div class="settings-item">
<p>Auto start download from your subscriptions: <span class="settings-current">{{ config.subscriptions.auto_start}}</span></p>
<i>Enable this will automatically start and prioritize videos from your subscriptions.</i><br>
{{ app_form.subscriptions_auto_start }}
</div>
</div>
<div class="settings-group">
<h2 id="downloads">Downloads</h2>
<div class="settings-item">
<p>Current download speed limit in KB/s: <span class="settings-current">{{ config.downloads.limit_speed }}</span></p>
<i>Limit download speed. 0 (zero) to deactivate, e.g. 1000 (1MB/s). Speeds are in KB/s. Setting takes effect on new download jobs or application restart.</i><br>
{{ app_form.downloads_limit_speed }}
</div>
<div class="settings-item">
<p>Current throttled rate limit in KB/s: <span class="settings-current">{{ config.downloads.throttledratelimit }}</span></p>
<i>Download will restart if speeds drop below specified amount. 0 (zero) to deactivate, e.g. 100. Speeds are in KB/s.</i><br>
{{ app_form.downloads_throttledratelimit }}
</div>
<div class="settings-item">
<p>Current scraping sleep interval: <span class="settings-current">{{ config.downloads.sleep_interval }}</p>
<i>Seconds to sleep between calls to YouTube. Might be necessary to avoid throttling. Recommended 3.</i><br>
{{ app_form.downloads_sleep_interval }}
</div>
<div class="settings-item">
<p><span class="danger-zone">Danger Zone</span>: Current auto delete watched videos: <span class="settings-current">{{ config.downloads.autodelete_days }}</span></p>
<i>Auto delete watched videos after x days, 0 (zero) to deactivate:</i><br>
{{ app_form.downloads_autodelete_days }}
</div>
</div>
<div class="settings-group">
<h2 id="format">Download Format</h2>
<div class="settings-item">
<p>Limit video and audio quality format for yt-dlp.<br>
Currently: <span class="settings-current">{{ config.downloads.format }}</span>
</p>
<p>Example configurations:</p>
<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<=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">0</span>: deactivate and download the best quality possible as decided by yt-dlp.</li>
</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>
{{ app_form.downloads_format }}
<br>
</div>
<div class="settings-item">
<p>Force sort order to have precedence over all yt-dlp fields.<br>
Currently: <span class="settings-current">{{ config.downloads.format_sort }}</span>
</p>
<p>Example configurations:</p>
<ul>
<li><span class="settings-current">res,codec:av1</span>: prefer AV1 over all other video codecs.</li>
<li><span class="settings-current">0</span>: deactivate and keep the default as decided by yt-dlp.</li>
</ul>
<i>Not all codecs are supported by all browsers. The default value ensures best compatibility. Check out the <a href="https://github.com/yt-dlp/yt-dlp#sorting-formats" target="_blank">documentation</a> for valid configurations.</i><br>
{{ app_form.downloads_format_sort }}
<br>
</div>
<div class="settings-item">
<p>Prefer translated metadata language: <span class="settings-current">{{ config.downloads.extractor_lang }}</span></p>
<i>This will change the language this video gets indexed as. That will only be available if the uploader provides translations. Add as two letter ISO language code, check the <a href="https://github.com/yt-dlp/yt-dlp#youtube" target="_blank">documentation</a> which languages are available.</i><br>
{{ app_form.downloads_extractor_lang}}
</div>
<div class="settings-item">
<p>Current metadata embed setting: <span class="settings-current">{{ config.downloads.add_metadata }}</span></p>
<i>Metadata is not embedded into the downloaded files by default.</i><br>
{{ app_form.downloads_add_metadata }}
</div>
<div class="settings-item">
<p>Current thumbnail embed setting: <span class="settings-current">{{ config.downloads.add_thumbnail }}</span></p>
<i>Embed thumbnail into the mediafile.</i><br>
{{ app_form.downloads_add_thumbnail }}
</div>
</div>
<div class="settings-group">
<h2 id="format">Subtitles</h2>
<div class="settings-item">
<p>Subtitles download setting: <span class="settings-current">{{ config.downloads.subtitle }}</span><br>
<i>Choose which subtitles to download, add comma separated language codes,<br>
e.g. <span class="settings-current">en, de, zh-Hans</span></i><br>
{{ app_form.downloads_subtitle }}</p>
</div>
<div class="settings-item">
<p>Subtitle source settings: <span class="settings-current">{{ config.downloads.subtitle_source }}</span></p>
<i>Download only user generated, or also less accurate auto generated subtitles.</i><br>
{{ app_form.downloads_subtitle_source }}
</div>
<div class="settings-item">
<p>Index and make subtitles searchable: <span class="settings-current">{{ config.downloads.subtitle_index }}</span></p>
<i>Store subtitle lines in Elasticsearch. Not recommended for low-end hardware.</i><br>
{{ app_form.downloads_subtitle_index }}
</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://docs.tubearchivist.com/settings/application/#cookie" 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">
<h2 id="integrations">Integrations</h2>
<div class="settings-item">
<p>API token: <button type="button" onclick="textReveal(this)" id="text-reveal-button">Show</button></p>
<div id="text-reveal" class="description-text">
<p>{{ api_token }}</p>
<button class="danger-button" type="button" onclick="resetToken()">Revoke</button>
</div>
</div>
<div class="settings-item">
<p>Integrate with <a href="https://returnyoutubedislike.com/" target="_blank">returnyoutubedislike.com</a> to get dislikes and average ratings back: <span class="settings-current">{{ config.downloads.integrate_ryd }}</span></p>
<i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br>
{{ app_form.downloads_integrate_ryd }}
</div>
<div class="settings-item">
<p>Integrate with <a href="https://sponsor.ajay.app/" target="_blank">SponsorBlock</a> to get sponsored timestamps: <span class="settings-current">{{ config.downloads.integrate_sponsorblock }}</span></p>
<i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br>
{{ app_form.downloads_integrate_sponsorblock }}
</div>
</div>
<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://docs.tubearchivist.com/settings/application/#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>
</form>
{% endblock settings_content %}

View File

@ -0,0 +1,154 @@
{% extends "home/base_settings.html" %}
{% load static %}
{% block settings_content %}
<div class="title-bar">
<h1>Scheduler Setup</h1>
<div class="settings-group">
<p>Schedule settings expect a cron like format, where the first value is minute, second is hour and third is day of the week.</p>
<p>Examples:</p>
<ul>
<li><span class="settings-current">0 15 *</span>: Run task every day at 15:00 in the afternoon.</li>
<li><span class="settings-current">30 8 */2</span>: Run task every second day of the week (Sun, Tue, Thu, Sat) at 08:30 in the morning.</li>
<li><span class="settings-current">auto</span>: Sensible default.</li>
<li><span class="settings-current">0</span>: (zero), deactivate that task.</li>
</ul>
<p>Note:</p>
<ul>
<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, the scheduler doesn't support schedules that trigger more than once per hour.</li>
</ul>
</div>
</div>
<form action="{% url 'settings_scheduling' %}" method="POST" name="scheduler-update">
{% csrf_token %}
<div class="settings-group">
<h2>Rescan Subscriptions</h2>
<div class="settings-item">
<p>Current rescan schedule: <span class="settings-current">
{% if config.scheduler.update_subscribed %}
{% for key, value in config.scheduler.update_subscribed.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Become a sponsor and join <a href="https://members.tubearchivist.com/" target="_blank">members.tubearchivist.com</a> to get access to <span class="settings-current">real time</span> notifications for new videos uploaded by your favorite channels.</p>
<p>Periodically rescan your subscriptions:</p>
{{ scheduler_form.update_subscribed }}
</div>
<div class="settings-item">
<p>Send notification on task completed:</p>
{% if config.scheduler.update_subscribed_notify %}
<p><button type="button" onclick="textReveal(this)" id="text-reveal-button">Show</button> stored notification links</p>
<div id="text-reveal" class="description-text">
<p>{{ config.scheduler.update_subscribed_notify|linebreaks }}</p>
</div>
{% else %}
<p>Current notification urls: <span class="settings-current">{{ config.scheduler.update_subscribed_notify }}</span></p>
{% endif %}
{{ scheduler_form.update_subscribed_notify }}
</div>
</div>
<div class="settings-group">
<h2>Start Download</h2>
<div class="settings-item">
<p>Current Download schedule: <span class="settings-current">
{% if config.scheduler.download_pending %}
{% for key, value in config.scheduler.download_pending.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Automatic video download schedule:</p>
{{ scheduler_form.download_pending }}
</div>
<div class="settings-item">
<p>Send notification on task completed:</p>
{% if config.scheduler.download_pending_notify %}
<p><button type="button" onclick="textReveal(this)" id="text-reveal-button">Show</button> stored notification links</p>
<div id="text-reveal" class="description-text">
<p>{{ config.scheduler.download_pending_notify|linebreaks }}</p>
</div>
{% else %}
<p>Current notification urls: <span class="settings-current">{{ config.scheduler.download_pending_notify }}</span></p>
{% endif %}
{{ scheduler_form.download_pending_notify }}
</div>
</div>
<div class="settings-group">
<h2>Refresh Metadata</h2>
<div class="settings-item">
<p>Current Metadata refresh schedule: <span class="settings-current">
{% if config.scheduler.check_reindex %}
{% for key, value in config.scheduler.check_reindex.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Daily schedule to refresh metadata from YouTube:</p>
{{ scheduler_form.check_reindex }}
</div>
<div class="settings-item">
<p>Current refresh for metadata older than x days: <span class="settings-current">{{ config.scheduler.check_reindex_days }}</span></p>
<p>Refresh older than x days, recommended 90:</p>
{{ scheduler_form.check_reindex_days }}
</div>
<div class="settings-item">
<p>Send notification on task completed:</p>
{% if config.scheduler.check_reindex_notify %}
<p><button type="button" onclick="textReveal(this)" id="text-reveal-button">Show</button> stored notification links</p>
<div id="text-reveal" class="description-text">
<p>{{ config.scheduler.check_reindex_notify|linebreaks }}</p>
</div>
{% else %}
<p>Current notification urls: <span class="settings-current">{{ config.scheduler.check_reindex_notify }}</span></p>
{% endif %}
{{ scheduler_form.check_reindex_notify }}
</div>
</div>
<div class="settings-group">
<h2>Thumbnail Check</h2>
<div class="settings-item">
<p>Current thumbnail check schedule: <span class="settings-current">
{% if config.scheduler.thumbnail_check %}
{% for key, value in config.scheduler.thumbnail_check.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Periodically check and cleanup thumbnails:</p>
{{ scheduler_form.thumbnail_check }}
</div>
</div>
<div class="settings-group">
<h2>ZIP file index backup</h2>
<div class="settings-item">
<p><i>Zip file backups are very slow for large archives and consistency is not guaranteed, use snapshots instead. Make sure no other tasks are running when creating a Zip file backup.</i></p>
<p>Current index backup schedule: <span class="settings-current">
{% if config.scheduler.run_backup %}
{% for key, value in config.scheduler.run_backup.items %}
{{ value }}
{% endfor %}
{% else %}
False
{% endif %}
</span></p>
<p>Automatically backup metadata to a zip file:</p>
{{ scheduler_form.run_backup }}
</div>
<div class="settings-item">
<p>Current backup files to keep: <span class="settings-current">{{ config.scheduler.run_backup_rotate }}</span></p>
<p>Max auto backups to keep:</p>
{{ scheduler_form.run_backup_rotate }}
</div>
</div>
<button type="submit" name="scheduler-settings">Update Scheduler Settings</button>
</form>
{% endblock settings_content %}

View File

@ -0,0 +1,37 @@
{% extends "home/base_settings.html" %}
{% load static %}
{% block settings_content %}
<div class="title-bar">
<h1>User Configurations</h1>
</div>
<form action="{% url 'settings_user' %}" method="POST" name="user-update">
{% csrf_token %}
<div class="settings-group">
<h2>Stylesheet</h2>
<div class="settings-item">
<p>Current stylesheet: <span class="settings-current">{{ stylesheet }}</span></p>
<i>Select your preferred stylesheet.</i><br>
{{ user_form.stylesheet }}
</div>
</div>
<div class="settings-group">
<h2>Archive View</h2>
<div class="settings-item">
<p>Current page size: <span class="settings-current">{{ page_size }}</span></p>
<i>Result of videos showing in archive page</i><br>
{{ user_form.page_size }}
</div>
</div>
<button type="submit" name="user-settings">Update User Configurations</button>
</form>
{% if request.user.is_superuser %}
<div class="title-bar">
<h1>Users</h1>
</div>
<div class="settings-group">
<h2>User Management</h2>
<p>Access the admin interface for basic user management functionality like adding and deleting users, changing passwords and more.</p>
<a href="/admin/"><button>Admin Interface</button></a>
</div>
{% endif %}
{% endblock settings_content %}

View File

@ -2,6 +2,7 @@
{% block content %}
{% load static %}
{% load humanize %}
{% load auth_extras %}
<div id="player" class="player-wrapper">
<div class="video-main">
<div class="video-modal"><span class="video-modal-text"></span></div>
@ -81,15 +82,20 @@
{% if reindex %}
<p>Reindex scheduled</p>
{% else %}
{% if request.user|has_group:"admin" or request.user.is_staff %}
<div id="reindex-button" class="button-box">
<button data-id="{{ video.youtube_id }}" data-type="video" onclick="reindex(this)" title="Reindex {{ video.title }}">Reindex</button>
</div>
{% endif %}
{% endif %}
<a download="" href="/media/{{ video.media_url }}"><button id="download-item">Download File</button></a>
<a download="" href="{{ video.media_url }}"><button id="download-item">Download File</button></a>
{% if request.user|has_group:"admin" or request.user.is_staff %}
<button onclick="deleteConfirm()" id="delete-item">Delete Video</button>
<div class="delete-confirm" id="delete-button">
<span>Are you sure? </span><button class="danger-button" onclick="deleteVideo(this)" data-id="{{ video.youtube_id }}" data-redirect = "{{ video.channel.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button>
</div>
{% endif %}
<button id="{{ video.youtube_id }}-button" data-id="{{ video.youtube_id }}" data-context="video" onclick="showAddToPlaylistMenu(this)">Add To Playlist</button>
</div>
</div>
<div class="info-box-item">

View File

@ -0,0 +1,8 @@
from django import template
register = template.Library()
@register.filter(name="has_group")
def has_group(user, group_name):
return user.groups.filter(name=group_name).exists()

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