Compare commits

..

854 Commits

Author SHA1 Message Date
Simon
11b31c493e bump archivist-es 2025-07-31 22:21:56 +07:00
Simon
d421e15405 bump TA_VERSION, remove unstable tag 2025-07-31 22:21:15 +07:00
Simon
0073b49a38 fix typo, channel tabs not tags 2025-07-31 22:16:37 +07:00
Simon
689aa60fa4 fix docs typo 2025-07-31 22:14:30 +07:00
Craig Alexander
31f612769b Disable login button while login processes is happening (#1015) 2025-07-31 21:33:21 +07:00
Craig Alexander
6b859eb436 Configure git to always use LF (#1013) 2025-07-31 21:29:58 +07:00
Simon
b0c435caf3 Merge branch 'master' into testing 2025-07-31 21:19:10 +07:00
Simon
95c0e35db7 fix mobile pagination layout wrap 2025-07-31 21:17:04 +07:00
Craig Alexander
d2bbc7c583 Fix GHSA-xffm-g5w8-qvg7 (#1014) 2025-07-23 18:47:17 +02:00
Simon
c1d1355536 Fix is_live add to queue parsing, #build 2025-07-23 21:14:18 +07:00
Simon
2f03dccf3b add unstable checkbox 2025-07-23 21:14:00 +07:00
Simon
f17b62626d fix empty is_live video response handling, #1016 2025-07-23 21:11:55 +07:00
Simon
4f46492298 Bulk clear errors, #build
Changed:
- Added bulk clear error button
- bump yt-dlp
2025-07-22 23:17:29 +07:00
Simon
7e8ced001d handle error state bulk update, add bulk clear error 2025-07-22 19:02:31 +07:00
Simon
5cee7af233 bump requirements 2025-07-22 17:36:35 +07:00
Simon
01db2df729 Channel extraction fix, #build
Changed:
- Fix for channel all pages extraction
- Fix duplicate channel playlist notification
- Ensure vid type enum match when adding to queue
2025-07-17 20:53:31 +07:00
Simon
40f6ee60d4 fix unknown vid_type query building 2025-07-17 20:51:59 +07:00
Simon
c22fe144b0 fix remove duplicate notification box in channel playlist page 2025-07-17 20:27:04 +07:00
Simon
64ac647ade ensure vid_type enum in __extract_vid_type 2025-07-17 20:26:30 +07:00
Simon
16ec1f694f remove debug 2025-07-17 12:21:31 +07:00
Simon
6192fcc350 Unkwnown type fix, page size null fix, #build
Changed:
- Fix for unknow vid_type when adding to the queue
- Fix for resetting page size
- Tests for VideoQueryBuilder
2025-07-13 17:01:25 +07:00
Simon
e19a1c6166 add tests for channel remote_query 2025-07-13 16:56:03 +07:00
Simon
f8a66ce7f0 handle none page size subscriptions 2025-07-13 16:54:13 +07:00
Simon
e6260f6919 fix unknow vid_type in queue 2025-07-13 08:40:49 +07:00
Simon
faf00bf35e fix channel_tabs sync to videos, #build 2025-07-12 23:33:22 +07:00
Simon
4245147e7e fix ta_video mapping restore, #build 2025-07-12 23:19:23 +07:00
Simon
4baaabe3f3 Track channel tabs, #build
Changed:
- Added channel_tabs to channel index
- Limit subscription refresh to available tabs
- Fix custom playlists
- Fix empty add to queue response
2025-07-12 22:53:51 +07:00
Simon
3fd061e838 serialize channel_tabs 2025-07-12 22:53:28 +07:00
Simon
ff4e41b932 fix type 2025-07-12 22:41:08 +07:00
Simon
454952d9dd fix custom playlist sortorder handling 2025-07-12 22:24:02 +07:00
Simon
2b709ce9c1 remove unused vid thumb blur 2025-07-12 18:18:22 +07:00
Simon
4d9be9853c fix _add_video empty return 2025-07-12 18:15:46 +07:00
Simon
759d034c86 add channel_tabs indexing 2025-07-12 18:06:41 +07:00
Simon
f4392f43fa remove old migrations 2025-07-12 17:27:05 +07:00
Simon
b5a79c4885 remove old template 2025-07-12 11:40:59 +07:00
Simon
d7edaa3b70 Download queue refactoring, #build
Changed:
- Added filter and search options for download queue
- Added bulk actions for download queue
- Added bulk add to download queue
- Added bulk add for subscriptions
- Added playlist reverse order, per playlist page size
- and more...
2025-07-12 10:28:36 +07:00
Simon
2808f5ba0d update wording, fix indent 2025-07-12 10:21:34 +07:00
Simon
e56059771c flatten channel_json arg 2025-07-12 09:51:31 +07:00
Simon
27bb5ff298 add to each item to pending queue in loop 2025-07-11 22:47:28 +07:00
Simon
5504c333b8 improved notification during scanning 2025-07-11 21:50:32 +07:00
Simon
ce19693a86 add per playlist page size 2025-07-11 20:10:22 +07:00
Simon
dfd86f8a80 update hooks 2025-07-11 19:56:14 +07:00
Simon
fb92387540 bump requirements 2025-07-11 19:55:05 +07:00
Simon
42a8ae2e9f add fallback thumb for queue, add ta_download to thumb validator 2025-07-11 19:53:48 +07:00
02bb52f276 feat: Added Support for ElasticSearch 9 (#1007) 2025-07-11 17:45:39 +07:00
joshrivers
5e6c94318c Configurable LDAP user promotion to superuser or staff (#1000)
* refactor: segregated ldap and fwd auth settings into imports and added variable validations

* added: LDAP users listed in configuration variables are promoted to staff or superuser
2025-07-11 17:36:00 +07:00
Craig Alexander
aefd678dca Add test notification button (#996)
* Add api to test notifications before you save

* Add button to UI

* Inspect apprise logs to get errors

* Better formatting around errors coming back from the test notification endpoint

* Use apprise's built in log capture

* Instruct the user to get error from container log instead of intercepting and parsing apprise logs

* refac move to test method on notification class

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2025-07-11 17:30:50 +07:00
Loris Leitner
624a5f9bd4 Add timestamp seeking (#989)
* Add timestamp seeking

* Remove unnecessary else

* handle setSeekToTimestamp reset in player

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2025-07-11 17:08:43 +07:00
Simon
fa7643e903 implement bulk add subscriptions in appsettings 2025-07-11 16:45:00 +07:00
Simon
05ce2a7034 refac, split pending interact to separate module 2025-07-10 22:09:13 +07:00
Simon
21f1d9cc00 update docstring 2025-07-10 21:45:09 +07:00
Simon
e6c13698bd reject progress below thresh, handle progress clean up from bulk update, #1009 2025-07-10 18:43:16 +07:00
Simon
de0dd8eeec cleanup duplicate 2025-07-10 18:25:21 +07:00
Simon
59f0c74e54 fix missing channel index for playlist 2025-07-10 17:32:27 +07:00
Simon
2868dae09d raise on playlist channel ID extraction error, #1008 2025-07-10 17:31:37 +07:00
Simon
cefa0093ba handle form hide on delete confirm 2025-07-10 17:09:11 +07:00
Simon
f51e094745 remove channel json file parsing, #1004 2025-07-10 16:57:47 +07:00
Simon
90611dbe75 add playlist sort order toggle, #171 2025-07-10 16:39:08 +07:00
Simon
bbfd3f4423 implement dynamic obs overwrite 2025-07-10 16:34:00 +07:00
Simon
59e9ee7eed complete playlist mapping 2025-07-10 15:26:15 +07:00
Simon
b91408ada2 remove unused 2025-07-10 15:02:40 +07:00
Simon
25c9fd99b1 Merge branch 'feat-flat-queue' into testing 2025-07-10 12:29:15 +07:00
Simon
29be11cf75 add error state filtering 2025-07-10 12:28:49 +07:00
Simon
ffd3bab948 handle download queue search 2025-07-10 11:29:50 +07:00
Simon
83404628e6 refact yt-dlp info extract, show errors 2025-07-10 10:58:26 +07:00
Simon
f45214714c handle API stop 2025-07-10 10:18:04 +07:00
Simon
8fbd94b120 add to queue error handling 2025-07-10 10:10:49 +07:00
Simon
569d97e2f3 handle add to queue progress 2025-07-10 10:00:50 +07:00
Simon
5da7b2a3c9 fix channel type filter, add channel playlist fallback 2025-07-10 07:54:11 +07:00
Christian Heimlich
ae40df1b6b fix: Country/language code mix-up for subtitles in search examples (#1012) 2025-07-09 18:30:47 +02:00
Simon
97bc6f225f add fast add, use toggles 2025-07-09 17:09:45 +07:00
Simon
15ec8b5ab6 refac pending list, implement flat add 2025-07-09 16:16:01 +07:00
Simon
b6d38e9319 return complete vid_entry dict from scan 2025-07-06 21:59:26 +07:00
Simon
66f37a92d3 fix priority bulk download 2025-07-06 20:55:04 +07:00
Simon
74c708baa2 fix notification url delete 2025-07-06 20:23:39 +07:00
Simon
f87309dbd1 change reset queue filtering on status filter change 2025-07-06 20:19:40 +07:00
Simon
3947653595 add bulk status update in queue 2025-07-06 20:14:56 +07:00
Simon
2a70f7ab58 move bulk delete to download actions section 2025-07-06 17:36:04 +07:00
Simon
a0f40d9970 backend bulk delete filter 2025-07-06 16:36:16 +07:00
Simon
8d9cb9261e add vid_type filter for download list view 2025-07-06 16:01:55 +07:00
MerlinScheurer
28ac0ba620 Fix video and audio streams can be undefined (#997) 2025-07-06 10:36:51 +02:00
MerlinScheurer
e88ae9e2f0 Fix show unknown codec in TableView when codec is missing 2025-07-06 10:08:58 +02:00
MerlinScheurer
79ee903f90 Update frontend dependencies 2025-07-03 00:05:14 +02:00
Simon
4a64bdea33 build improvements, #build
Changed:
- bump dependencies
- faster builds and better caching
- auto update yt-dlp
2025-07-01 11:12:34 +07:00
Simon
bc66b4bef6 add unstable tag 2025-07-01 11:08:37 +07:00
Simon
dfb984590d bump requirements 2025-07-01 11:06:49 +07:00
Craig Alexander
08681f0e33 Add option to update yt-dlp on restart (#992)
* Add option to update yt-dlp on restart

* Address pr feedback
2025-07-01 10:09:18 +07:00
Craig Alexander
ab3b83ed3f Upgrade Python to 3.11.13 (#991) 2025-07-01 09:36:23 +07:00
MerlinScheurer
28f2fbd6a7 Fix remove theater mode localstorage flag 2025-06-22 12:14:32 +02:00
MerlinScheurer
d19190bf6a Add theater mode to normal video player 2025-06-22 12:10:29 +02:00
MerlinScheurer
b14309daeb Fix do not send referrer when opening youtube, sponsorblock or returndislite links 2025-06-22 11:43:32 +02:00
Craig Alexander
990cb9aaec Move npm install into its own docker stage (#999) 2025-06-20 18:33:43 +02:00
Craig Alexander
bb4e5ecb50 Fix not found message showing as API is loading (#995) 2025-06-15 11:19:14 +02:00
MerlinScheurer
ff94c324b3 Refac extract loading indicator into its own component 2025-06-13 20:10:57 +02:00
Simon
21f0a09f5f bump TA_VERSION 2025-06-11 08:34:56 +07:00
Simon
12fc7e1663 bump requirements 2025-06-11 08:34:22 +07:00
Simon
1c60b2bb46 Merge branch 'master' into testing 2025-06-10 08:49:26 +07:00
Simon
3631fa0b2c bump yt-dlp, #build 2025-06-10 08:47:57 +07:00
Simon
dfcd46efbf add unstable tag 2025-06-10 08:38:58 +07:00
Simon
aa943567de bump bump yt-dlp 2025-06-10 08:37:48 +07:00
Simon
178c25f2f0 really no more feature requests please 2025-06-08 19:52:55 +07:00
Simon
22b53e6820 remove unstable tag 2025-06-08 12:16:48 +07:00
James Kerrane
2c2129179a Remove obsolete "version" attribute (#988)
The attribute `version` is obsolete, and docker compose recommends removing it to avoid potential confusion, so this removes the attribute.
2025-06-08 12:14:28 +07:00
Baku
ffe9444295 Update CONTRIBUTING.md (#987)
* Update CONTRIBUTING.md

Reworded the Beta Testing section for clarity and flow. Also fixed typos.

* Update CONTRIBUTING.md

Fixed additional typo
2025-06-08 12:13:59 +07:00
Simon
f8c5efb87a fix type hints 2025-06-08 11:34:05 +07:00
Simon
e62a4e0fcf fix none existing timestamp key in info json 2025-06-08 10:04:45 +07:00
Simon
b495761e9e add documentation changes section 2025-06-05 10:58:21 +07:00
Simon
b7b6ae0216 Video index rebuild, #build
Changed:
- Fixed publish date indexing and sorting
- Bump django, fixing forward auth
- Fix task command serialization
- Improved subtitle selection
- Added table view layout
2025-06-05 10:33:43 +07:00
Merlin
aa08701049 Add video details view (#956)
* Add Video details page to settings

* Add option A

* Refac remove option A

* Refac viewStyleType and viewStyleEnum

* Add viewStyle Table

* Refac remove unused code

* Refac show resolution in one cell
2025-06-05 10:19:16 +07:00
Joel Puig Rubio
436a641746 Improve subtitle selection (#875) 2025-06-05 10:13:55 +07:00
Simon
710b0ddc2d serialize task command for notification box 2025-06-05 09:55:21 +07:00
Simon
703fd63f44 bump requirements 2025-06-05 09:37:37 +07:00
Simon
f040ac6b34 mapping change, multi format date published 2025-06-04 09:46:11 +07:00
joshrivers
aebde2b993 remove dead pre-0.5.0 links (#969) 2025-06-03 23:42:42 +07:00
Simon
372275c4d5 Wide range of fixes, #build
Changed:
- Multi auth backends for LDAP
- Auto restart beat scheduler
- Use timestamp for date published
- Fix playlist videos sort
- Add ignore link on channel page
2025-06-03 23:40:54 +07:00
Baku
e68a9f9f1f Update SettingsActions.tsx (#985)
* Update SettingsActions.tsx

Fix typo ("infos" > "info")

* fix pre-commit

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2025-06-03 23:37:18 +07:00
skilletskills
b239f4bd84 gracefully handle missing appconfig by creating default settings in elasticsearch (#983)
Co-authored-by: skillet <skillet@localhost>
2025-06-03 23:29:19 +07:00
joshrivers
8a227fc9b8 Multiple Authentication Backends can be configured (#970) 2025-06-03 23:19:12 +07:00
Simon
8c59d65ee5 auto restart beat after 1h, #967 2025-06-03 23:03:13 +07:00
Simon
d196d2e4f5 index date published as iso timestamp, #902 2025-06-03 21:41:57 +07:00
Simon
62ea518e1b use check_formats only on download, not in info extract 2025-06-03 20:58:57 +07:00
Simon
a961c8f175 sort playlist videos by idx, #889 2025-06-03 20:40:58 +07:00
Simon
ec5204cd6c delay import, fix circular problems 2025-06-03 20:19:13 +07:00
Simon
37a6922718 ES check handle no shards, fail on none 404 status for appsettings default, #975 2025-06-02 23:48:45 +07:00
Simon
a82e4b1c51 add play/pause toggle shortcut key, #898 2025-06-02 23:36:37 +07:00
Simon
58b4e22df4 serialize watched_date, #913 2025-06-02 22:47:21 +07:00
krufab
efe1401518 Bugfix #934: Download a video from the queue (#935)
* Bugfix #934: Download a video from the queue

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

* Bugfix #934: Download a video from the queue

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

* Bugfix #934: Download a video from the queue

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

* Bugfix #934: Download a video from the queue

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

* Bugfix #934: Download a video from the queue

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

* Bugfix #934: Download a video from the queue

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

* Improved code

* Improved code

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

* Fixed file because of black pre-commit

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

* Fixed file because of black pre-commit

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>

---------

Signed-off-by: Fabio Kruger <10956489+krufab@users.noreply.github.com>
2025-06-02 21:55:59 +07:00
Jurrer
02b5ed6917 Add Ignored tab to Channel page (#927)
* Add Ignored tab to Channel page

* fix for Ignored button action

* use ignored parameter as bool

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2025-06-02 21:41:48 +07:00
MerlinScheurer
fd3ccbec3a Fix video player skip 5s did not work without video player focus #898 2025-05-31 11:32:33 +02:00
MerlinScheurer
717d2b3098 Update frontend dependencies 2025-05-23 18:55:29 +02:00
MerlinScheurer
068cd2e407 Add theme handling when without user context 2025-05-23 18:54:29 +02:00
Simon
ef07dc91b4 Newest yt-dlp, #build
Changed:
- Bump yt-dlp
- Fix scedule input edit
- Fix channel indexed in download queue serializing
2025-05-22 21:59:29 +07:00
Simon
514ad0d16b add unstable tag 2025-05-22 21:59:11 +07:00
Simon
72d81cc45a ensure channel_indexed field in pending downloads, #932 2025-05-22 21:39:53 +07:00
Simon
6789cc90d8 bump requirements 2025-05-22 19:29:36 +07:00
MerlinScheurer
e7f1921986 Fix schedule input fields resetting automatically 2025-05-20 20:02:28 +02:00
Simon
0ba6169524 add svg logos, #960 2025-05-15 07:42:11 +07:00
Simon
6e5fac847f bump TA_VERSION 2025-05-15 06:44:17 +07:00
Simon
0a5ce3f786 bump ES 2025-05-15 06:44:01 +07:00
Simon
0db4ae3c19 handle 401 ES connection status code 2025-05-13 07:13:21 +07:00
Simon
56f5c6bc4b Watched state improvement, #build
Changed:
- Fixed watched state date tracking
- Fixed channel fallback extraction error handling
2025-05-13 06:41:01 +07:00
Simon
008dc79ebc add channel_name fallback for failed extraction 2025-05-11 12:13:52 +07:00
Simon
e34da1687e fix channel subs null value extraction, #803 2025-05-11 10:15:58 +07:00
Simon
c5f212f520 ignore static files, docker ignore node_modules 2025-05-10 23:39:31 +07:00
Jurrer
6eb774be69 only stamp watched date to watched videos (#926) 2025-05-10 23:02:08 +07:00
Simon
f53988b826 Custom CSS, auto delete fixes, #build
Changed:
- Re-added custom CSS functionality
- Fixed playlist video type
- Fixed autodelete channel overwrite
- Fixed autodelete for watched state toggle
- newest yt-dlp
2025-05-10 22:25:15 +07:00
Simon
70b67e5f77 bump requirements 2025-05-10 22:19:12 +07:00
MerlinScheurer
25b6ca2a1a Revert "Add Video details page to settings"
This reverts commit 776481c513.
2025-05-10 15:04:03 +02:00
MerlinScheurer
776481c513 Add Video details page to settings 2025-05-10 14:09:59 +02:00
MerlinScheurer
1d727a1170 Chore update frontend dependencies 2025-05-10 10:41:39 +02:00
Simon
270e6e8320 bump requirements 2025-04-29 10:48:18 +07:00
Simon
897250da6b use palylistEntries length for total videos in playlist, #942 2025-04-29 10:23:21 +07:00
Simon
af33dbb22d skip channel overwrite auto delete, #673 2025-04-29 09:28:53 +07:00
Jurrer
32cc2c8a13 Fix #919, check if video is in watched state before auto deleting (#920)
* Fix #919, check if video is in watched state before auto deleting

* lint
2025-04-29 09:22:19 +07:00
Simon
c00368d1f2 fix playlist video grid type, #916 2025-04-29 08:57:44 +07:00
MerlinScheurer
e28a6be3c5 update frontend dependencies 2025-04-22 18:35:10 +02:00
MerlinScheurer
2b71dbca9a Update frontend dependencies 2025-04-05 13:36:26 +02:00
MerlinScheurer
b7a29897dd Fix formatting of backend\common\src\helper.py 2025-04-05 13:28:24 +02:00
MerlinScheurer
68a69185fe Add custom.css #884 2025-04-05 13:16:30 +02:00
Simon
253571b5ac Player URL fixes, #build
Changed:
- Fix player url query persistence
- Fix various search params handling
- Bumped yt-dlp
2025-04-04 06:03:01 +02:00
Simon
3cd6bad9d3 fix dependabot warnings 2025-04-04 06:02:43 +02:00
Simon
5ca295ccff add unstable TA_VERSION tag 2025-04-04 05:59:01 +02:00
Simon
8fd217e6f0 bump requirements 2025-04-04 05:58:37 +02:00
MerlinScheurer
85be8f1114 Fix block pagination hotkeys when embeddableVideoPlayer is active 2025-03-29 11:07:47 +01:00
MerlinScheurer
e254a848e0 Revert pagination preventDefault | it was eating all key events 2025-03-29 10:58:39 +01:00
MerlinScheurer
947ec803b3 Fix Download page not refetching on page change 2025-03-29 10:56:26 +01:00
MerlinScheurer
0d1bdb8904 Fix pagination not preving page navigation on arrow key navigation 2025-03-29 10:55:39 +01:00
Merlin
a2cb2d283c Fix URL params in embed player #907 2025-03-29 10:34:04 +01:00
MerlinScheurer
11afb29384 Refac change searchParam handling for subtitle and download page 2025-03-29 10:27:54 +01:00
MerlinScheurer
cbfb66b5c8 Merge branch 'master' of github.com:tubearchivist/tubearchivist into testing 2025-03-29 09:30:46 +01:00
MerlinScheurer
7b26a01063 Refac reduce notification interval from 500 to 1000 #912 2025-03-28 11:58:27 +01:00
MerlinScheurer
96b55bf9d5 Fix download page requesting pending downloads twice 2025-03-28 11:50:53 +01:00
MerlinScheurer
50a3442ca6 Add VideoThumbnail to video playlist view 2025-03-28 11:31:32 +01:00
MerlinScheurer
c407f27ecf Add VideoThumbnail to subtitleList 2025-03-28 11:29:15 +01:00
MerlinScheurer
148f9e801b Refac extract VideoThumbnail into a component 2025-03-28 11:25:54 +01:00
MerlinScheurer
5fe0afaeb7 Refac add 'waiting for backend' text to make it more clear 2025-03-28 11:12:39 +01:00
MerlinScheurer
c73a350095 Refac take searchParams from router in Base.tsx 2025-03-28 01:46:56 +01:00
MerlinScheurer
2be190aa65 Refac preemptive image fallback when url is undefined. Ticket: #911 2025-03-28 01:34:34 +01:00
MerlinScheurer
0aa5ef5b31 Add trim to subscribe textarea fields #908 2025-03-28 01:26:34 +01:00
Mason James
40bae8d73d Fix URL params in embed player
When opening/closing embedded videos, maintain existing URL parameters including pagination state. Previously, the video player would reset page to 0 and lose other query parameters.
2025-03-27 19:36:32 -04:00
Simon
8f34626a27 bump TA_VERSION 2025-03-27 21:40:00 +01:00
Alex
8978e6510a Added delete_shorts to the User Scripts section (#903) 2025-03-28 03:39:33 +07:00
Simon
04ea6254b7 fix health API endpoint url 2025-03-27 21:36:59 +01:00
Simon
f18d812eb8 delay playlist extraction 2025-03-27 21:13:13 +01:00
Simon
d69c579839 Update yt-dlp, again, #build
Changed:
- bumped yt-dlp for more nsig fixes
- bumped node dependencies
2025-03-26 07:46:07 +01:00
Simon
9fe11c3d22 bump dependencies 2025-03-26 07:45:28 +01:00
Simon
f8846836e7 Bump yt-dlp, #build
Changed:
- Bump yt-dlp fixing nsig extraction
2025-03-25 09:12:26 +01:00
Simon
3ab218d744 bump requirements 2025-03-25 09:11:12 +01:00
Simon
c6329571ae add unstable TA_VERSION 2025-03-25 09:10:52 +01:00
Simon
4ae59848b4 Various fixes, #build
Changed:
- Added better debug statements to login page
- Fixed appsettings admin read permissions
- Fixed notification types
- Fixed DISABLE_STATIC_AUTH
2025-03-24 22:01:00 +01:00
Simon
17aa693fdf fix backend tasks messages notification types 2025-03-24 21:59:35 +01:00
Simon
164f3d2a22 bump requirements 2025-03-24 21:53:37 +01:00
MerlinScheurer
512f661823 Add sourcemaps for easier debugging after build 2025-03-22 10:36:35 +01:00
MerlinScheurer
7fdb93f183 Fix formatting in frontend 2025-03-20 20:30:32 +01:00
MerlinScheurer
edcede5de6 Refac APIClient to return data, error and status as object 2025-03-20 20:30:08 +01:00
Simon
52083e6fb7 fix redis connection failure error message 2025-03-19 07:53:30 +01:00
Simon
a4824497ef handle 404 pages, #890 2025-03-15 16:58:28 +01:00
MerlinScheurer
c5f549967b Refac use file_size_unit from user config in frontend #886 2025-03-15 15:15:22 +01:00
Simon
87d4f16456 add page query param to playlist list fetching, #894 2025-03-15 14:24:17 +01:00
smantic
f0bac927fe AdminWriteOnly /appsettings/config (#892) 2025-03-15 20:13:48 +07:00
trick77
4b19294899 Call _disable_static_auth() during startup (#897) 2025-03-15 20:03:43 +07:00
Simon
9f351b5907 backend: store file_size_unit in user config, #886 2025-03-15 13:59:49 +01:00
Simon
677f85b993 add delete and ignore, #891 2025-03-15 13:50:26 +01:00
MerlinScheurer
158115d319 Fix playlist being sorted, instead of playlist sort order 2025-03-15 11:06:01 +01:00
MerlinScheurer
7abcfcc579 Add 'having issues' help text to login after 10 seconds 2025-03-14 09:08:40 +01:00
Simon
862a854e64 add health API endpoint, #887 2025-03-11 22:12:32 +07:00
MerlinScheurer
e06e1be433 Add TA_HOST example and compose with port 2025-03-10 20:26:40 +01:00
Simon
ac7ce2ce3c bump es version 2025-03-09 21:38:06 +07:00
Simon
264394a50b bump TA_VERSION 2025-03-09 21:37:18 +07:00
Simon
b3376318ac readme updates for v0.5.0 2025-03-09 21:07:00 +07:00
Simon
680279f6a2 update compose for v0.5.0 2025-03-09 21:06:06 +07:00
Simon
d4cfb783ef fix ta_config appsettings migration 2025-03-09 21:04:54 +07:00
Simon
edaf1cdd7d remove old deploy function 2025-03-09 21:03:51 +07:00
Simon
01f2b42d33 add bg_save on delete option 2025-03-09 21:03:51 +07:00
Simon
45907cad8b stop on old ENABLE_CAST var 2025-03-09 21:03:51 +07:00
MerlinScheurer
fd9ce89bb1 Fix automatic redirect when already loggedIn 2025-03-09 11:24:48 +01:00
MerlinScheurer
3e65ebfed1 Add waiting for backend handling 2025-03-09 11:04:21 +01:00
Simon
7425732920 Move cast to app settings, #build
Changed:
- Moved cast to app settings
- Removed cast env var
2025-03-08 18:37:20 +07:00
Simon
f0e932544e remove cast env var 2025-03-08 18:36:40 +07:00
Simon
7229cf1930 add cast to appsettings 2025-03-08 18:32:13 +07:00
Simon
73f7e8945c fix spelling 2025-03-08 17:44:17 +07:00
Simon
0a801e6b72 update dependencies 2025-03-08 17:40:32 +07:00
Simon
11a518889a Refresh and scroll fixes, #build
Changes:
- Fixed timing problem when scrolling the inline player into view
- Fixed refresh issue on player change
2025-03-08 17:15:55 +07:00
Simon
9c847f492a use videoId for refresh dependency 2025-03-07 10:39:14 +07:00
MerlinScheurer
0fc86407be Refac refresh video list when videoId changing 2025-03-06 23:54:12 +01:00
MerlinScheurer
838c6ae145 Refac move loading from localStorage to the beginning of the VideoPlayer 2025-03-06 23:53:23 +01:00
MerlinScheurer
b7e23db750 Fix EmbeddableVideoPlayer scrollIntoView v4 2025-03-05 18:34:57 +01:00
Simon
13098b5496 useLayoutEffect for scrollIntoView 2025-03-05 22:37:38 +07:00
Simon
139f1ff937 fix login error message display 2025-03-05 21:36:10 +07:00
MerlinScheurer
d9b5576534 Refac overwrite scrollIntoView default for consistency 2025-03-04 19:01:48 +01:00
Simon
b03b09afe8 Fix vid_type reindex, player speed #build
Changed:
- Fixed incorrect vid_type key naming for reindex
- Fixed persisting playback speed
2025-02-26 22:24:06 +07:00
Simon
70031be8b7 fix reindex vid_type key 2025-02-26 22:00:26 +07:00
MerlinScheurer
934c92421f Refac make DownloadListItem key more unique 2025-02-24 19:03:55 +01:00
MerlinScheurer
b4c6b52f32 Fix playback speed from localstorage and then modifying speed 2025-02-24 18:57:50 +01:00
Simon
329478c05d Serializer exceptions, #build
Changed:
- Fixed missing channel_tags serializer
- Fixed download error message serializer
2025-02-23 19:24:09 +07:00
Simon
ab83bd9b16 optional channel_tags key 2025-02-23 19:22:20 +07:00
Simon
0a0a59b390 add kibana token persistence instructions 2025-02-23 18:38:17 +07:00
Simon
37c8428c56 better channel_description fallback 2025-02-23 16:53:54 +07:00
Simon
affd7e6be2 add downlaod fail message serializer 2025-02-23 16:28:49 +07:00
Simon
9f0621efc8 Channel about POST, #build
Changed:
- Fixed channel about POST request response type
- Fixed modal shortcuts ignoring modifier keys
2025-02-21 23:48:40 +07:00
Simon
5751f13385 ignore keyboard events with modifier 2025-02-21 23:42:13 +07:00
Simon
cc6ebae295 clean logs 2025-02-21 23:31:45 +07:00
Simon
60724df45a fix channel about response type 2025-02-21 23:17:19 +07:00
Simon
83cfa03e11 Fix page query param, #build
Changed:
- Fix page query parameter serialization
- Bump yt-dlp
2025-02-20 21:41:11 +07:00
Simon
bbf2648f89 bump requirements 2025-02-20 21:36:49 +07:00
Simon
724d5556d4 add page serializer 2025-02-20 16:41:18 +07:00
Simon
464619cc00 Data Serializers, #build
Changed:
- Serialize all data
- API simplify item return format
- API document with swagger docs from serializers
2025-02-18 23:13:23 +07:00
Simon
eb78a3904f fix deprecated env format 2025-02-18 22:39:40 +07:00
Simon
837c4293bc fix last pagination number serializer 2025-02-18 22:37:26 +07:00
Simon
dfddf8cb4d bump node packages 2025-02-18 22:24:55 +07:00
Simon
598fa4e674 fix custom playlist endpoints 2025-02-17 20:34:44 +07:00
Simon
3085fbf348 fix DownloadAggsType 2025-02-17 20:20:47 +07:00
Simon
0ea4c87b9e use enums for validation 2025-02-17 18:43:54 +07:00
Simon
cfcb4e6f9e fix empty channel tags conditional 2025-02-17 17:53:29 +07:00
Simon
711488b471 fix similar videos response type 2025-02-17 17:45:55 +07:00
Simon
3b78a9868d serialize subtitles and playlists to empty list 2025-02-17 17:38:29 +07:00
Simon
59cc177913 handle expected empty response 2025-02-17 17:23:26 +07:00
Simon
c18090e169 always run collect static 2025-02-17 17:00:54 +07:00
MerlinScheurer
8a08e7dcca Refac move PlaylistType into loader and fix new response type 2025-02-16 13:19:21 +01:00
MerlinScheurer
a0f31a831f Fix videoType naming 2025-02-16 13:18:19 +01:00
Simon
476f663478 fix sortorder data type 2025-02-16 18:56:35 +07:00
MerlinScheurer
03279979f9 Chore update frontend dependencies 2025-02-16 12:47:14 +01:00
MerlinScheurer
b33527adc2 Fix several response types and responses and add AppSettingsStore 2025-02-16 12:42:56 +01:00
MerlinScheurer
718d379c3c Fix Video Thumbnail fallback in DownloadListItem 2025-02-16 11:54:17 +01:00
Simon
3a2701f2fa fix reindex inconsitent video_type 2025-02-16 15:34:56 +07:00
Simon
b2337669ca fix channel tags empty list serializers and migration 2025-02-16 11:03:50 +07:00
Simon
cc9cb2b578 fix task id notification serializer 2025-02-16 10:59:19 +07:00
MerlinScheurer
0f61a6272f Fix video thumbnail fallback 2025-02-15 11:42:08 +01:00
Simon
a13cfc9c33 fix video type query serializer 2025-02-14 16:52:25 +07:00
Simon
e3488ca4d8 fix query building list parameter tests 2025-02-14 16:39:07 +07:00
Simon
46c974c078 better partial config update 2025-02-14 16:31:12 +07:00
Simon
f36490f491 fix empty subtitle serializer 2025-02-14 15:51:09 +07:00
Simon
f2b1e6962d fix WatchStatsSerializer null value 2025-02-14 15:40:50 +07:00
Simon
b7eabf02ce fix login status response 2025-02-14 15:37:25 +07:00
MerlinScheurer
9a31c2d100 Refac move UserAccountType into loadUserAccount.ts 2025-02-13 20:51:14 +01:00
Simon
d96ac486e4 split between userConfig and userAccount 2025-02-13 21:43:01 +07:00
Simon
ad7af09159 fix query playlist API 2025-02-13 21:31:05 +07:00
Simon
88132fef3c add serializers for video 2025-02-12 18:59:56 +07:00
Simon
5772ab3e68 serialize user, add account endpoint 2025-02-12 17:31:59 +07:00
Simon
5498958b36 serialize tasks 2025-02-12 11:16:07 +07:00
Simon
b2b25f24ed serialize stats 2025-02-11 21:32:42 +07:00
Simon
9022817096 serialize playlists, add custom playlist endpoints 2025-02-11 19:22:39 +07:00
Simon
1035bb0947 add playlist list and item serializer 2025-02-10 22:20:54 +07:00
Simon
cec09eda8a add common view serializers 2025-02-10 21:42:46 +07:00
Simon
e6bc16693c document and serialize download views 2025-02-10 18:29:50 +07:00
Simon
6723d6152f add appsettings serializers and docs 2025-02-09 22:10:29 +07:00
Simon
df5a5cf449 add drf_spectacular, type and serialize channel app 2025-02-09 17:41:12 +07:00
Simon
93406ee0a4 add password change command 2025-02-08 00:35:54 +07:00
Simon
e351880d48 add DISABLE_STATIC_AUTH env var 2025-02-07 22:30:16 +07:00
Simon
6275e06db2 fix channel extract playlist boundry 2025-02-05 14:45:48 +07:00
Simon
7e19495c10 remove leftover migrations 2025-02-02 18:35:20 +07:00
Simon
2083828fe1 Various refresh fixes, #build
Changed:
- Fix player focus
- Various refresh fixes for player open/close
- Refresh watch state
- Add error messages schedules and login
- Show help modal
2025-02-02 17:36:33 +07:00
Simon
c71e1acf74 add error messages to schedule form 2025-02-02 17:29:08 +07:00
Simon
4d0dc27ef1 add note about failing subtitles in debug, #871 2025-02-02 16:51:14 +07:00
Simon
1e12a060ce add progress delete button 2025-02-02 16:43:59 +07:00
Simon
8f22d0d9e2 sanitize validate cookie str 2025-02-02 16:41:57 +07:00
Simon
3aa41232db fix progress inconsistency+ 2025-02-02 16:30:43 +07:00
Simon
847764e440 strip nullbyte from cookie 2025-02-02 15:50:08 +07:00
Simon
2ec81c7ac7 context manager for yt-dlp extract, stricter worker recycle 2025-02-02 15:45:40 +07:00
Simon
c7fc2666fa add lsof to debug install 2025-02-02 15:36:25 +07:00
Simon
a7d11f53a8 disable onPause for progress gt 95% 2025-02-01 23:21:21 +07:00
MerlinScheurer
a092744958 Refac skip videoProgress update when currentTime equals video duration 2025-02-01 15:05:53 +01:00
MerlinScheurer
4dafa2f427 Fix only refresh video resposne once 2025-02-01 13:53:04 +01:00
MerlinScheurer
2f1a43df55 Fix only refresh video resposne once 2025-02-01 13:48:29 +01:00
MerlinScheurer
3fcef13f14 Add help dialog to video player 2025-02-01 13:28:19 +01:00
Simon
c41872b6b7 fix subtitle URL building in search processor 2025-02-01 10:51:06 +07:00
MerlinScheurer
ccdef58e1d Add latest frontend folders to README.md 2025-01-31 18:04:02 +01:00
MerlinScheurer
5cd67ae315 Fix typo in CONTRIBUTING.md 2025-01-31 18:01:11 +01:00
MerlinScheurer
2c8fb72e47 Fix remove unused import 2025-01-31 17:58:41 +01:00
MerlinScheurer
45da47f99b Fix sponsorblock set progress onVideoEnd 2025-01-31 17:56:35 +01:00
Simon
168412724e fix continue watching none IDs, add showEmbeddedVideo to useEffect 2025-01-31 10:38:10 +07:00
MerlinScheurer
702000a493 Add video player hotkeys 2025-01-30 20:17:30 +01:00
MerlinScheurer
b836697e2a Fix missing formatNumbers on dislike count 2025-01-30 20:11:12 +01:00
MerlinScheurer
43bfa1ab93 Refac prepare updating watched state based on progress endpoint 2025-01-30 18:33:46 +01:00
MerlinScheurer
9146ecb82d Refac search reduce debounce time to 300ms and add enter key bypass 2025-01-30 18:12:57 +01:00
Simon
87b65caf8a handle is_watched state in backend 2025-01-30 22:02:48 +07:00
Simon
43bd5359ba sort download queue autostart on top 2025-01-30 20:40:43 +07:00
Simon
2a331f855c clear download queue text on submit 2025-01-30 20:29:28 +07:00
Simon
937ff5c92c debounce search 2025-01-30 20:26:20 +07:00
MerlinScheurer
d643e8c573 Fix watched state update for both player styles 2025-01-29 20:10:20 +01:00
MerlinScheurer
4a67da12f7 Fix video player flickering when changing watched state 2025-01-29 20:02:01 +01:00
MerlinScheurer
1c643bef8e Add error message to login 2025-01-29 19:02:36 +01:00
MerlinScheurer
3c06b06960 Refac hide total_hits on channels page until we added total_hits to video list and playlist pages 2025-01-29 18:32:11 +01:00
MerlinScheurer
9e4f9de119 Refac invert filter toggle text to be inline with other pages 2025-01-29 18:31:05 +01:00
MerlinScheurer
c2cd02e7bd Fix channel list having 1rem offset, compared to video list entries 2025-01-29 18:17:17 +01:00
MerlinScheurer
b78d881bf6 Fix scrollbar causing page flicker/jiggle 2025-01-29 18:07:28 +01:00
MerlinScheurer
fe4ee6a2d7 Refac jump to an empty player wrapper when opening the EmbeddableVideoPlayer 2025-01-28 17:41:01 +01:00
Simon
baf0c568a8 various improvements, #build
Changed:
- Fixed partial refresh on UI toggle
- Fixed channel extraction
- Fixed showIgnored filter on downloads agg
- Better debug messages in dev mode
- Hide 0 comment linke count
- better cookie validation, use caching
- cache channel search result
- mobile fixes
- sleep on download queue
2025-01-28 18:10:48 +07:00
Simon
4d23b7dce4 fix mobile settings CSS 2025-01-28 17:52:27 +07:00
Simon
a5e97cc4b5 cache channel url search results 2025-01-28 17:29:22 +07:00
Simon
0b393304d1 cache cookie validation 2025-01-28 14:57:10 +07:00
Simon
074311339a sleep on download queue 2025-01-28 10:30:06 +07:00
Simon
206efc784c hide 0 comment_likecount 2025-01-28 10:14:15 +07:00
Simon
870582f732 pass showIgnored filter state to channel dl aggs 2025-01-28 10:02:14 +07:00
Simon
10a4b13506 blackhole deploy fix, take 2 2025-01-28 09:45:40 +07:00
Simon
a49a1d36ff fix channel extraction metadata error handling 2025-01-28 09:45:23 +07:00
Simon
40198cd799 remove quiet in debug 2025-01-28 09:44:54 +07:00
MerlinScheurer
781dd8d2fc Fix embeddable player focus by using useRef 2025-01-27 19:04:49 +01:00
Simon
78528c4260 fix partial implemented refresh for config useEffect, #869 2025-01-27 21:17:42 +07:00
Simon
759f57aa0c fix blackhole deployment 2025-01-27 21:15:22 +07:00
Simon
05ff9df049 Appsettings migration fix, #build
Changed:
- Fixed calling appsettings migration at startup
2025-01-27 17:51:06 +07:00
Simon
190d9b3b10 fix inconsistent casing 2025-01-27 17:47:11 +07:00
Simon
5dc25b081c fix appconfig migration at startup 2025-01-27 17:46:58 +07:00
Simon
726fb6d667 Redis Socket, better port parsing, #build
Changed:
- Allow for unix:// REDIS_CON sockets
- Parse ports for CORS
2025-01-27 16:37:07 +07:00
Simon
73bd392488 parse unix connection string for redis socket 2025-01-27 16:36:19 +07:00
Simon
a68798842b Merge branch 'master' into testing 2025-01-27 08:36:15 +07:00
Simon
cce6d85d69 revert too soon commited REDIS_HOST var 2025-01-27 08:35:02 +07:00
MerlinScheurer
a4978f5ae9 fix pre-commit - black 2025-01-26 20:30:29 +01:00
MerlinScheurer
641cc361a2 Fix add port to TA_HOST parsing for cors 2025-01-26 20:22:52 +01:00
Simon
26fc8e51d4 CORS fix, #build
Migration guide: https://gist.github.com/bbilly1/7b6abc52ab689f56671bf9011879379a

Changed:
- fixed CORSE header building
- fixed startup schedule creating
- Bump TA_VERSION to unstable
2025-01-27 01:08:11 +07:00
Simon
ffeb7cd95c fix CORS headers overwrite 2025-01-27 01:04:55 +07:00
MerlinScheurer
cac61d16d6 Chore update frontend dependencies 2025-01-26 19:00:57 +01:00
Simon
3dac21c4bc remove unneded appconfig lookup 2025-01-27 00:38:35 +07:00
Simon
f0ab718be0 bump TA_VERSION 2025-01-27 00:36:19 +07:00
Simon
93d28b167c New React Frontend, #build
Migration guide: https://gist.github.com/bbilly1/7b6abc52ab689f56671bf9011879379a

Changed:
- First testing build of the new React frontend
2025-01-26 23:10:07 +07:00
Simon
a706ba601a disable blank issues 2025-01-26 23:09:50 +07:00
Simon
368a9e73d8 bump yt-dlp 2025-01-26 23:03:11 +07:00
Simon
d49d549caa extend native dev instructions 2025-01-26 21:57:12 +07:00
Simon
4bb34b280f channel about help 2025-01-26 18:43:14 +07:00
Simon
55aa360c96 add help text to appsettings page 2025-01-26 18:31:31 +07:00
Simon
d408b5bb52 add show helptext user config value 2025-01-26 16:18:45 +07:00
Simon
c633544eac ensure user config is set 2025-01-26 15:36:17 +07:00
Simon
3bbe91416d fix none key lookup 2025-01-26 14:35:24 +07:00
Simon
17a00fa361 undo 2025-01-26 14:19:43 +07:00
Simon
34203b70ae add appsettings sync at startup 2025-01-25 00:54:47 +07:00
Simon
a8336ed04d better error messages for failed index, #858 2025-01-24 21:31:59 +07:00
Simon
bde706cc5e Merge branch 'testing' into refactor-react-migration 2025-01-19 09:50:59 +07:00
Simon
0154136114 bump TA_VERSION 2025-01-19 09:39:55 +07:00
Simon
351242a7a8 Merge branch 'testing' into refactor-react-migration 2025-01-17 09:39:33 +07:00
Simon
5e5c17a4fb fix comment extraction, #build
Changed:
- Bump yt-dlp to 2025.01.15
- Fix comment extraction
2025-01-17 08:50:19 +07:00
Simon
0f8b82a1b7 bump yt-dlp, fix comment extraction 2025-01-17 08:49:17 +07:00
Simon
0a46284551 Merge branch 'testing' into refactor-react-migration 2025-01-15 21:56:06 +07:00
Simon
ff439a996f handle migration and restore 2025-01-15 21:53:54 +07:00
Simon
2f8287230f bump requirements, add migration script, #build
Changed:
- Updated requirements.txt
- Added ta_config_backup manage.py command for migration
2025-01-15 21:43:27 +07:00
Simon
58a02df987 add unstable tag 2025-01-15 21:40:17 +07:00
Simon
c8db4b5b37 add ta_config_backup manage command for migration 2025-01-15 21:39:48 +07:00
Simon
6f5d278732 bump requirements 2025-01-15 21:39:02 +07:00
Simon
61b53a655a delay config import for better startup 2025-01-13 17:35:32 +07:00
MerlinScheurer
c62a495f96 Add textbox closing after channel/playlist subscribing 2025-01-12 16:11:00 +01:00
MerlinScheurer
868564ee32 Add async notification to channels and playlists subscription text 2025-01-12 16:04:28 +01:00
MerlinScheurer
5b67d9de24 Refac delete buttons order to be in a row 2025-01-12 15:39:50 +01:00
Simon
1c656f917e fix playlist subscribe 2025-01-12 21:39:13 +07:00
MerlinScheurer
601f8aff6b Add key to appriseNotification list 2025-01-12 15:29:20 +01:00
MerlinScheurer
5668798677 Add more line breaks in code 2025-01-12 15:15:44 +01:00
MerlinScheurer
08f813a699 Add spacing around buttons in downloadListItem 2025-01-12 15:08:53 +01:00
MerlinScheurer
8526398d49 Refac untie download file and reindex on video page and add spacing 2025-01-12 14:42:12 +01:00
MerlinScheurer
851b499754 Add prepended text to ToggleConfig 2025-01-12 14:32:58 +01:00
MerlinScheurer
6549b52112 Fix playlist autoplay with multiple playlists 2025-01-12 14:27:29 +01:00
MerlinScheurer
bd58637ec6 Fix refresh after creating custom playlist 2025-01-12 13:46:51 +01:00
Simon
f85772ca14 implement create custom playlist, #733 2025-01-12 19:16:37 +07:00
Simon
87cf9fdd53 add success checkbox 2025-01-11 18:49:02 +07:00
Simon
c928ec9b97 skip empty video lookup 2025-01-11 18:49:02 +07:00
Simon
0b9ef62477 remove notifications from player 2025-01-11 18:49:02 +07:00
Simon
bb82661d7e fix redundant playlist item remove 2025-01-11 18:49:02 +07:00
Simon
cfae159240 immediate cookie write back 2025-01-11 18:49:02 +07:00
Simon
8fc935d805 rand_sleep on playlist refresh loop 2025-01-11 18:49:02 +07:00
Simon
457f6a0b87 implement po_token extractor args 2025-01-11 18:49:02 +07:00
Simon
2474a6a96b consolidate rand_sleep 2025-01-11 18:49:02 +07:00
MerlinScheurer
73396da7f6 Fix react select option selected warning, #863 2025-01-11 12:33:39 +01:00
Simon
21c127d0fa delay ldap import, take 2 2025-01-11 14:09:29 +07:00
Simon
56b9c8e921 delay LDAPSearch import 2025-01-11 12:01:35 +07:00
Simon
44035187b9 remove old cookie import, #856 2025-01-11 10:55:43 +07:00
Simon
d1a42c4b57 handle cookie through text form, #856 2025-01-10 17:09:11 +07:00
Simon
5cc40315c4 refac, move ToggleConfig to component 2025-01-10 15:28:17 +07:00
Simon
bf38cdc7f0 fix null undefined types 2025-01-09 23:59:29 +07:00
Simon
f6b5de00df refac appsettings page 2025-01-09 22:16:39 +07:00
Simon
1a6f51a122 abstract InputConfig to separate component 2025-01-08 18:05:38 +07:00
Simon
8d4bcb2f4b use synchronous playlist subscribe update, #862 2025-01-08 16:15:43 +07:00
Simon
0411b14c5c add synchronous channel subscribe frontend, #862 2025-01-08 16:06:23 +07:00
Simon
d79fbcd168 fix video button layout 2025-01-08 15:35:02 +07:00
MerlinScheurer
6b87f2d0fc Refac use Routes constant for logout 2025-01-07 19:09:07 +01:00
Simon
daf09fcc33 refac, useApiClient for loader and actions 2025-01-07 23:13:48 +07:00
Simon
d33917b541 fix playlist detail filter bar 2025-01-07 10:02:48 +07:00
Simon
09ef342de7 fix linter 2025-01-07 09:48:45 +07:00
Simon
8ca943ca7b checkou node 2025-01-07 09:29:52 +07:00
Simon
54e93c44e9 add globals 2025-01-07 09:19:52 +07:00
Simon
d6acef2b64 add eslint/js dependency 2025-01-07 09:16:43 +07:00
MerlinScheurer
5abe2d0548 Refac inline player scroll behaviour 2025-01-06 19:21:17 +01:00
MerlinScheurer
71797b5050 Fix eslint config 2025-01-06 19:04:41 +01:00
Simon
b83e16f3ab add playlist/channel detail subscribed update, #862 2025-01-06 22:37:16 +07:00
Simon
a7fb5d9939 switch to pre_commit workflow 2025-01-06 21:17:15 +07:00
Simon
bc74bf80f4 run pre-commit on all 2025-01-06 21:08:51 +07:00
Simon
cf54f6d7fc remove old node packages and config 2025-01-06 20:18:37 +07:00
Simon
c9607343e6 Feat global state (#861)
* add zustand, add auth store

* add user config store, isAdmin from store

* move ColourVariants, fix importColours

* get userConfig from store

* fix name conflict

* home filter bar from config store

* use user store for channel list

* use userConfig store for channel pages

* use userConfig for playlist pages

* fix channel video type navigation

* use userConfig update on downloads page

* fix view style in search

* handle initial user config, take 2

* modify userSettings in global state
2025-01-06 19:44:05 +07:00
MerlinScheurer
0eca242fd6 Fix run prettier on all files again 2025-01-03 18:11:20 +01:00
MerlinScheurer
bd48b17f9a Refac remove unused import 2025-01-03 18:09:13 +01:00
Simon
ec18731b61 split info box items 2025-01-03 22:41:49 +07:00
Simon
c42c6a0643 fix channel tags parsing list 2025-01-03 22:34:36 +07:00
Simon
8da027f320 add to queue one instead of bulk 2025-01-03 22:28:51 +07:00
Simon
1fa8ab65c9 comment scrape sleep 2025-01-03 22:07:04 +07:00
Simon
c4b981fb7c fix spacing 2025-01-03 21:38:11 +07:00
Simon
1177aa5699 refac channel about page settings, #852 2025-01-03 19:05:25 +07:00
Simon
1f74e02c92 fix empty playlist response, #858 2025-01-03 18:02:18 +07:00
Simon
2dbce9da4d dynamic sleep 2025-01-03 11:41:55 +07:00
Simon
490b61961a remove comment player overwrite 2025-01-03 11:21:45 +07:00
Styloy
6e3a647d6a add sleep_interval to queue.py (#832)
add sleep_interval to mitigate rate_limiting induced errors when adding videos to queue
2025-01-03 11:21:17 +07:00
Simon
be20d56d05 use view style home for channel video list, #854 2025-01-03 11:05:23 +07:00
Simon
a91f2e1b98 bump requirements 2025-01-03 11:04:03 +07:00
MerlinScheurer
e8ae261a10 Fix download notification message can be string instead of array 2025-01-01 13:37:08 +01:00
MerlinScheurer
25739a1162 Fix react error about input switching from uncontrolled to controlled 2025-01-01 13:02:29 +01:00
MerlinScheurer
d0b4f6cdbf Fix read correct view style from config on channel video page 2025-01-01 12:49:58 +01:00
MerlinScheurer
8e463a818a Fix do not store user config on page change 2025-01-01 12:49:14 +01:00
MerlinScheurer
2857f49915 Refac remove unused logout page 2025-01-01 12:22:00 +01:00
Simon
fc88ed912c fix lower case route 2025-01-01 15:30:38 +07:00
Simon
86232b8628 handle logout, #855 2025-01-01 15:26:23 +07:00
MerlinScheurer
20e3b7b7ff Refac improve inline embeddable player functionality 2024-12-27 19:31:45 +01:00
MerlinScheurer
3d43ade857 Fix Filterbar causing userconfig to be overwritten on playlist page 2024-12-27 18:23:49 +01:00
MerlinScheurer
bb67c7e1ab Fix Filterbar causing userconfig to be overwritten on page view 2024-12-27 18:13:58 +01:00
Simon
815dac618a handle empty aggs 2024-12-27 23:38:25 +07:00
MerlinScheurer
4f8eb4462c Refac split playlist subscribing input by line break and send ids in bulk 2024-12-27 17:33:01 +01:00
MerlinScheurer
e87ee7bf72 Refac split channel subscribing input by line break and send ids in bulk 2024-12-27 17:27:59 +01:00
MerlinScheurer
d6235fd272 Refac split download input by line break and send ids in bulk 2024-12-27 17:00:24 +01:00
Simon
69875c42b9 handle failed snapshot created 2024-12-27 21:52:38 +07:00
Simon
a4435fdd60 use REDIS_CON for redis connection string 2024-12-23 22:29:52 +07:00
MerlinScheurer
f25c2025e8 Merge branch 'refactor-react-migration' of github.com:tubearchivist/tubearchivist into refactor-react-migration 2024-12-23 15:58:29 +01:00
MerlinScheurer
1ca34400f0 Refac remove loadVideoProgressById 2024-12-23 15:56:56 +01:00
Simon
7dcef45b97 replace uwsgi with uvicorn 2024-12-23 21:53:27 +07:00
Simon
e5e51f1a60 add position to progress, remove get progress 2024-12-23 21:34:24 +07:00
Simon
1393d6886e use Fragment instead of div 2024-12-23 16:46:58 +07:00
Simon
c53a706a8e fix cicular import 2024-12-23 16:44:43 +07:00
Simon
29857a9e3f match video progress through API 2024-12-23 16:20:57 +07:00
Simon
058dda7de4 ensure unique key for skipped message map 2024-12-23 11:53:59 +07:00
Simon
f8a0577451 fix none type volume and speed reading 2024-12-23 11:46:38 +07:00
Simon
fbd7c19a93 remove dependency on redis json 2024-12-22 22:59:48 +07:00
Simon
7ffa6ff807 remove log 2024-12-22 22:01:28 +07:00
Merlin
5a5d47da9b Refac react frontend (#790)
* Add channel config endpoint

* Add channel aggs

* Add playlist show subscribed only toggle

* Fix refresh always on filterbar toggle

* Add loadingindicator for watchstate change

* Fix missing space in scheduling

* Add schedule request and apprisenotifcation

* Refac upgrade TypeScript target to include 2024 for Object.groupBy

* WIP: Schedule page

* WIP: Schedule page

* Add schedule management ( - notification )

* Fix missing space

* Refac show current selection in input

* Add apprise notifictation url

* Add Stream & Shorts channel pages

* Refac autotarget input on search page

* Fix input requiring 1 instead of 0

* Fix remove unused function

* Chore: npm audit fix

* Refac get channel_overwrites from channelById

* Refac remove defaultvalues form select

* Fix delay content refresh to allow the backend to update subscribed state

* Fix styling selection

* Fix lint

* Fix spelling

* Fix remove unused import

* Chore: update all dependencies - React 19 & vite 6

* Add missing property to ValidatedCookieType

* Refac fix complaints about JSX.Element, used ReactNode instead

* Refac remove unused dependency

* Refac replace react-helmet with react 19 implementation

* Fix Application Settings page

* Chore update dependencies

* Add simple playlist autoplay feature

* Refac use server provided channel images path

* Refac use server provided playlistthumbnail images path

* Add save and restore video playback speed
2024-12-22 21:59:30 +07:00
Simon
75339e479e fix API token creating 2024-12-21 10:29:34 +07:00
Simon
e18f176759 add paths for library static files 2024-12-21 09:58:41 +07:00
Simon
42f1679502 fix channel overwrite playlist refresh missing task 2024-12-21 09:57:48 +07:00
Simon
9e796019d2 better local dev setup 2024-12-21 09:57:20 +07:00
Simon
dfe8fe2ac0 Merge branch 'testing' into refactor-react-migration 2024-12-20 11:30:36 +07:00
Simon
b1112f5e6d fix numeric sort 2024-12-20 11:20:02 +07:00
Simon
1d7833cd24 bump es 2024-12-20 11:11:58 +07:00
Simon
4057e80004 bump TA_VERSION 2024-12-20 11:10:13 +07:00
Simon
7ab81c3fd4 Bump yt-dlp, fix AI dub prio, #build
Changed:
- Updated yt-dlp to ship upstream fixes
2024-12-18 09:24:08 +07:00
Simon
9fd2c01966 bump yt-dlp fix AI dub prio 2024-12-18 09:23:09 +07:00
Simon
744c6c94f1 Bump yt-dlp, fix shorts only playlist, #build
Changed:
- Ships latest yt-dlp
- Fixes playlists with shorts only extraction
2024-12-07 22:10:44 +07:00
Simon
9288f197ef add unstable tag 2024-12-07 22:08:09 +07:00
Simon
d051f8cb15 bump requirements 2024-12-07 22:07:59 +07:00
Simon
a3910a7929 fix none channel playlist entry extraction 2024-12-07 22:07:01 +07:00
Simon
cc85d8287e pin dev requirements 2024-12-07 22:06:18 +07:00
Simon
bfeaefc476 Merge branch 'testing' into refactor-react-migration 2024-11-09 11:04:38 +07:00
Simon
376fc071e1 bump TA_VERSION 2024-11-09 10:29:28 +07:00
Simon
2871955b6d fix linter 2024-11-08 14:22:36 +07:00
Simon
9b45187db4 consolidate channe overwrite endpoint 2024-11-08 14:19:37 +07:00
FunkeCoder23
29ce05b57d add live url to parser and test (#806)
Co-authored-by: funkecoder23 <funkecoder@DESKTOP-AMVOBPG>
2024-11-08 12:17:31 +07:00
Merlin
b269f6ad90 Fix download aggs refresh only on video finished (#800) 2024-11-08 12:15:56 +07:00
Merlin
c008e1bfba Fix refresh post body (#799) 2024-11-08 12:13:25 +07:00
kralverde
de24fcd207 add /api/channel/{channel-id}/about/ (#783)
* add /api/channel/{channel-id}/about/

* move testing function

* remove unused import

* fix unused import

* fix formatting

* simplify channel overwrites

* remove unused

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2024-11-08 12:06:08 +07:00
Simon
df44c02164 Merge branch 'testing' into refactor-react-migration 2024-11-08 10:56:28 +07:00
Simon
5a9b66fc90 Bump requirements, #build
Changed:
- shipping latest yt-dlp
2024-11-08 10:53:02 +07:00
Simon
ba42509e80 bump requirements 2024-11-08 10:47:22 +07:00
Simon
d661e9af25 Merge branch 'testing' into refactor-react-migration 2024-10-21 21:05:29 +07:00
Simon
2ed7f5ebc3 add links, remove sponsor 2024-10-21 21:03:37 +07:00
Simon
732c5a7f7d align styling 2024-10-21 20:57:00 +07:00
tangyjoust
b4129cb319 Tautulli Notify TubeArchivist of Plex Watched State (#827)
* Update README.md

Add `Tautulli Motify TubeArchivist of Plex Watched State`  to scripts

* Correct layout
2024-10-21 20:55:51 +07:00
Geert Stappers
1405b7b21f README.md: Added URL of yt-dlp (#811)
The README has now al link to the audio/video downloader.

Sign-Off-By:  Geert Stappers  <stappers@stappers.it>
2024-10-21 20:44:29 +07:00
Simon
7b3e8c1f9d Bump requirements, #build
Changed:
- Bumped all requirements
- Shipps newest yt-dlp
2024-10-21 19:25:21 +07:00
Simon
aed58c48e0 bump requirements 2024-10-21 19:24:25 +07:00
Simon
850c2ebf85 clearer error on url parse fail 2024-09-22 11:33:13 +02:00
Simon
b4969964fa remove debug 2024-09-22 10:54:19 +02:00
Simon
197aa70980 fix linter 2024-08-23 21:13:10 +02:00
Simon
39b995fbf1 add get views for schedules, #723 2024-08-23 21:13:00 +02:00
Merlin
37389e91f5 Refac move sponsorblock into video endpoint (#787) 2024-08-23 23:42:13 +07:00
Merlin
6d27b69799 Refac update appsettings endpoint to flatten request body json (#788) 2024-08-23 23:35:07 +07:00
Merlin
867ed999ee Add default colors to errorpage (#786) 2024-08-23 23:28:07 +07:00
Merlin
3aea299889 Fix return type on updateUserConfig (#785) 2024-08-23 23:25:28 +07:00
Simon
f05d9bd727 Merge branch 'testing' into refactor-react-migration 2024-08-13 08:50:48 +02:00
Simon
cfe94c8dac bump yt-dlp, #build
Changed:
- shipping latest yt-dlp
2024-08-13 08:44:08 +02:00
Simon
e3e56a31e5 bump yt-dlp 2024-08-13 08:42:09 +02:00
Merlin
83bb7f678b DRAFT: Add Tubearchivist Frontend React dev docker setup (#768)
* Add development docker-compose file

* Add /new/ path in nginx conf

* Add frontend production setup

* Fix lint

* Refac move prod docker compose into non suffixed file

* Fix run.sh fileendings on windows

* Fix docker file naming consistancies

* Add frontend dev setup

* Add docker compose dev command

* Refac remove docker network

* Fix potential error causes

* Chore update react-router-dom

* Add redirect to login after logout

* Refac allow basic auth for session login in api

* Fix loginresponsetype optional property

* Refac move isAdmin check into page Base

* Refac use node lts for dev container

* Refac remove old setup in readme

* Refac move getisAdmin into loader and rename

* Refac remove manual csrf cookie handing from actions and loader

* Fix post requiring csrf header & cookie

* Fix remove empty files

* Refac revert dockerfile changes

* Refac revert gitatrributes changes

* Refac revert docker changes

* Refac revert nginx changes

* Refac revert docker change

* Refac move frontend into frontend folder

* Add production steps to dockerfile

* Refac implement endpoint renaming

* Refac remove frontend dockerfile

* Add credentials include for dev env

* Fix allow cors with credentials for dev environment

* Fix images in dev mode

* Add credentials for dev mode to all loader and actions, except signin

* Revert cors config

* Revert cors config

* Fix nginx not serving /youtube/

* Fix video url missing api

* Fix media url missing api

* Add application settings page

* Add continue vids

* Add csrf to delete requests

* Refac use api/video endpoint with filter to home, channel, playlist pages

* Add channel nav request

* Add channel playlists

* Fix filterbar for playlist in channel

* Add playlist_nav to video page

* Add downloads aggs

* Refac remove basic auth

* Fix credentials include in signin

* Refac user config to user me config

* Add ApiToken get
2024-08-11 00:53:50 +07:00
Simon
4dd7ac496a return token #718 2024-08-10 19:36:33 +02:00
Simon
5fc8f4d7a5 fix pagination for empty hits return, #732 2024-08-10 19:19:00 +02:00
Simon
975fabf8d1 align post datatype with get 2024-08-10 18:55:50 +02:00
Simon
9202a90970 maybe fix cors headers 2024-08-10 18:20:49 +02:00
Simon
6aa8b8b05b session login 2024-08-10 18:19:58 +02:00
Simon
37d6a13cbf ignore csrf on login view 2024-08-10 17:18:00 +02:00
Simon
2ee037c745 fix doc string 2024-08-10 17:16:51 +02:00
Merlin
f981186a93 Add gitattributes to fix docker on windows (#782) 2024-08-10 21:42:03 +07:00
Simon
271a595058 add django app overview readme 2024-08-10 16:12:06 +02:00
Merlin
b454391d6e Add ta_update to api ping (#780)
* Add ta_update to api/ping/

* Fix lint
2024-08-10 20:46:21 +07:00
Simon
b864813a7b fix tests 2024-08-03 23:37:23 +02:00
Simon
192d518c2b Merge branch 'refactor-react-migration' into refactor-app-split 2024-08-03 22:10:42 +02:00
Simon
bf523ccb16 Merge branch 'testing' into refactor-react-migration 2024-08-03 22:09:53 +02:00
Simon
2ea4872bc7 bump TA_VERSION 2024-08-03 21:59:25 +02:00
Simon
a5b492fecd renamed django app folder to backend 2024-08-03 21:58:22 +02:00
Simon
1d07386a06 remove home app 2024-08-03 21:50:59 +02:00
Simon
852dfba8c5 add config value update hooks 2024-08-03 21:40:28 +02:00
Simon
812fc633d3 remove unused 2024-08-03 20:47:23 +02:00
Simon
9b57b534ea add download aggs 2024-08-03 01:07:11 +02:00
Simon
fc99a2b532 add channel nav 2024-08-03 00:51:02 +02:00
Simon
38d74657ec add channel aggs 2024-08-03 00:18:49 +02:00
Simon
49cfbd5796 add playlist data query building 2024-08-02 23:51:01 +02:00
Simon
032a28e330 add config to appsettings 2024-08-02 23:07:07 +02:00
Simon
224703dcd7 move AppSettings config to ES 2024-08-02 22:50:36 +02:00
Simon
44cfb15e0c add tests for video query building 2024-08-02 20:20:12 +02:00
Simon
5afbc2e0a5 fix types 2024-08-02 18:39:24 +02:00
Simon
f99e5c0081 implement video query building from url params 2024-08-02 18:31:49 +02:00
Simon
49f22bc43c add video detail playlist nav 2024-08-02 16:08:03 +02:00
Simon
af18666f09 post request notification 2024-08-02 15:35:12 +02:00
Simon
864fe325de fix doc string 2024-08-02 15:34:08 +02:00
Simon
6ae4f2d6db Merge branch 'testing' into refactor-app-split 2024-08-02 15:22:09 +02:00
Simon
8550421a9a bump yt-dlp, #build 2024-08-02 14:27:16 +02:00
Simon
aabc883d84 add unstable tag 2024-08-02 14:24:39 +02:00
Simon
90441ba678 bump requirements 2024-08-02 14:24:18 +02:00
Simon
308d5a0a61 add tests for invalid schedules 2024-08-01 17:55:17 +02:00
Simon
3d12fe7b5e create update schedule through view 2024-08-01 17:25:04 +02:00
Simon
37df4f8b5b move common testst 2024-08-01 16:00:25 +02:00
Simon
4661cd025f add home back 2024-08-01 15:59:56 +02:00
Simon
d6fc78d3b4 clean up, consolidate config.json, celery hooks 2024-07-22 22:53:45 +02:00
Simon
7d4eecf603 consolidate in common app 2024-07-22 22:37:49 +02:00
Simon
899bdc950b reset migrations, split user app 2024-07-22 21:34:03 +02:00
Simon
f368322fea move notify module to task app 2024-07-22 17:59:00 +02:00
Simon
f869d0e46a Merge branch 'testing' into refactor-react-migration 2024-07-22 17:44:22 +02:00
Simon
1db738ce89 fix roadmap 2024-07-22 17:43:29 +02:00
Simon
adcd4f9245 split stats app 2024-07-22 17:40:49 +02:00
Simon
52ac5285b9 split appsettings app 2024-07-21 12:07:06 +02:00
Simon
d621008d31 split task app, migrate scheduler instances 2024-07-20 12:16:46 +02:00
Simon
f60411c7a4 split download app 2024-07-18 23:17:45 +02:00
Simon
7af9ba364b split playlist app 2024-07-18 22:53:25 +02:00
Simon
528bc5f2ef split to channel app 2024-07-18 22:37:11 +02:00
Simon
4c5f56e191 split video app 2024-07-18 12:34:48 +02:00
Simon
c37242ff4b fix video channel icon 2024-07-18 11:59:37 +02:00
Simon
c687c0fd96 make application run outside of docker 2024-07-17 19:09:31 +02:00
kralverde
335e6f4b6f add is_superuser, is_staff, and groups to /api/login (#764)
* add is_superuser, is_staff, and groups to /api/login

* fix linting
2024-07-17 20:57:45 +07:00
Simon
b97cb9d264 disable feature requests 2024-07-17 15:13:10 +02:00
Simon
12f9517838 update roadmap 2024-07-15 20:44:04 +02:00
Simon
eba40af1ab bump ES 2024-07-15 20:37:57 +02:00
Simon
807c30caa0 bump TA_VERSION for release 2024-07-15 20:17:35 +02:00
Simon
24b57406ab Update TZ in schedules, fix version check trigger, #build
Changed:
- Bumped yt-dlp
- Added migration to update TZ in schedule when changed
- Fixed issue where version check would never trigger
2024-07-12 16:59:54 +02:00
Simon
b0b72a6e6e up ES memory default 2024-07-12 16:58:50 +02:00
Simon
93b921582d bump requirements 2024-07-12 16:58:04 +02:00
Simon
437cd72f45 update TZ in schedule when changed 2024-07-12 16:57:28 +02:00
Simon
81bf614afa fix version check not triggering 2024-07-12 14:30:39 +02:00
Simon
9b84169fab Channel page size overwrite, download retry, #build
Changed:
- Added page size overwrite per channel
- Added retry logic for download failure
- Fixed download continue from cache logic
2024-07-06 16:52:52 +02:00
Simon
381f97e6d2 add unstable tag 2024-07-06 16:51:31 +02:00
Simon
039a49387c remove workarounds for merged channel extraction failure 2024-07-06 16:50:29 +02:00
Simon
c9007431bf add channel overwrite mappings to index 2024-07-06 16:49:36 +02:00
Simon
1fcea860c8 track dl failure, implement retry, continue from cache, #752 2024-07-06 16:15:33 +02:00
Simon
963d952dfa bump requirements 2024-07-06 15:52:50 +02:00
Simon
cba6b0a33e Merge branch 'master' into testing 2024-07-01 21:30:12 +02:00
Heavybullets8
5fac26156b remove TrueNAS SCALE (#754) 2024-07-02 02:27:46 +07:00
Boo1098
9eba3e278d Add Page Size Overrides per Channel (#702)
This is a minimum viable product. Tested all 3 overrides and they
worked. The current method of resetting the override is clunk (setting
to negative number). I've also upended some of the build query in
subscriptions and haven't fully tested if that messes with things.

Moved query building into its own class

Based on bbilly1's code from their comment in #702
2024-06-08 01:13:39 +07:00
Simon
e3d5a0885b fix spelling 2024-06-06 19:27:49 +02:00
Simon
9f484eca83 skip video not part of channel, #741 2024-06-06 19:17:53 +02:00
Simon
e8691a8edb Merge branch 'master' into testing 2024-06-06 17:50:13 +02:00
Simon
08346d6dc7 bump requirements 2024-06-06 16:01:09 +02:00
Simon
64016389f7 Fix arm64 build, add TARGETPLATFORM ARG, #build 2024-05-26 23:37:26 +02:00
Simon
a3afa23bc7 bump TA_VERSION for release 2024-05-22 18:40:16 +02:00
Simon
9068e4569d add manual fix management commands 2024-05-22 18:34:18 +02:00
Simon
97b8439856 Add pytest, #build
Changed:
- Fixed running tests problem
- Added some simple unit tests
- Add to queue error handling improvement
- Fixed is_shorts from id
- Fixed logout
2024-05-22 00:05:54 +02:00
Simon
d4165327df remove apt cache 2024-05-21 22:29:55 +02:00
Simon
9fe0aa85d0 add invalid watch url 2024-05-21 22:23:07 +02:00
Simon
87e3814751 fix locked files caching 2024-05-21 22:20:47 +02:00
Simon
a201d43bfd chdir for tests, test reading mapping 2024-05-21 22:15:48 +02:00
Simon
200f5ed5a2 add apt caching, better structuring 2024-05-21 21:54:20 +02:00
Simon
7a265231bc cache pip 2024-05-21 21:26:09 +02:00
Simon
ed3183b42c remove comments 2024-05-21 21:17:45 +02:00
Simon
a1e3512bab add test_helper 2024-05-21 21:10:21 +02:00
Simon
241e76c17d cleane get_duration_str without zfill on first part 2024-05-21 21:08:24 +02:00
Simon
7111fedd5c fix redirect with is_shorts 2024-05-21 20:55:36 +02:00
Simon
40beb35f34 fix typo, better date parsing error 2024-05-21 20:54:04 +02:00
Simon
d7818871a4 clean up 2024-05-21 20:18:27 +02:00
Simon
d3103ccc30 install missing system dependencies 2024-05-21 20:17:47 +02:00
Simon
30c20b41d9 test and better handle invalid domains 2024-05-21 20:14:40 +02:00
Simon
cfa0cabd41 add unit tests workflow 2024-05-21 19:10:22 +02:00
Simon
8a2040aa26 ignore pytest cache 2024-05-21 19:08:45 +02:00
Simon
e2253fc63b switch to official yt-dlp commit 2024-05-21 19:08:29 +02:00
Simon
f9eff28da7 replace deprecated STATICFILES_STORAGE 2024-05-21 19:05:49 +02:00
Simon
873e7d1e8d add gcc to runner 2024-05-21 17:41:08 +02:00
Simon
91f9351502 limit action run on path glob 2024-05-21 16:51:06 +02:00
Simon
4a49d70b17 Merge branch 'master' into testing 2024-05-21 16:46:30 +02:00
Simon
ca1864843a fix install repo dependencies 2024-05-21 16:41:37 +02:00
Simon
819d166296 update actions version 2024-05-21 16:35:43 +02:00
Simon
d6b5521c11 fix naming 2024-05-21 16:22:59 +02:00
Simon
c9285ce3a1 bump requests 2024-05-21 13:06:40 +02:00
Simon
3e3e3ae78e add tests for urlparser 2024-05-21 13:05:40 +02:00
Simon
ae89f47072 make application testable 2024-05-21 12:18:25 +02:00
Simon
1bcb4f1f7c remove unused CONFIG init 2024-05-21 12:16:30 +02:00
Simon
758038f93d fix sync_to_videos in channel reindex 2024-05-20 13:34:45 +02:00
Simon
d6f679784e fix logout django 5 needing post request 2024-05-20 13:17:11 +02:00
Simon
c7ebbaa207 remove redundant 2024-05-19 13:32:59 +02:00
Simon
a2000b253b Update FRONTEND-MIGRATION.yml 2024-05-19 18:31:31 +07:00
Simon
6e9ae583c9 add Frontend Migration template 2024-05-19 13:24:05 +02:00
Simon
ee5d73917c Add channel art fallback, #build
Changed:
- Added additional channel art fallback extractor
2024-05-17 14:43:43 +02:00
Simon
478d05997c channel art fallback injection, #701 2024-05-16 21:55:27 +02:00
Simon
0e305f60f4 Channel playlist index fix, #build
Changed:
- Fixed channel not indexing correctly for standalone playlist
2024-05-16 19:54:08 +02:00
Simon
3c441c3a31 fix missing channel 2024-05-16 17:44:21 +02:00
Simon
228677ddfb add beta-testing info 2024-05-15 21:15:09 +02:00
Simon
dc0c82c814 New scheduler store and playlist improvements, #build
Changed:
- Changed to persistent celery beat schedule store
- Changed playlist handling and refreshing
- Changed internal queue storage to use Redis
- Changed a lot of other things...
2024-05-15 20:42:08 +02:00
Simon
34ad0ca07d add next steps 2024-05-15 20:40:29 +02:00
Simon
c82d9e2140 bump base image 2024-05-15 19:54:16 +02:00
Simon
bbf59eaca9 set first in queue with score 1 2024-05-15 19:54:05 +02:00
Simon
e42737ad9b bump req 2024-05-15 16:32:04 +02:00
Simon
1d9c274390 better reindex_type handler, fix off by one 2024-05-15 13:18:28 +02:00
Simon
2a35b42d88 add redisqueue in use docs 2024-05-15 11:56:09 +02:00
Simon
11ab314649 refact CommentList to use RedisQueue 2024-05-15 00:37:01 +02:00
Simon
d58d133baf refactor decouple DownloadPostProcess 2024-05-15 00:18:43 +02:00
Simon
d6f3fd883c skip empty add 2024-05-15 00:11:25 +02:00
Simon
8f1a5c8557 use dynamic max_score for reindex queue 2024-05-15 00:11:05 +02:00
Simon
fdc427977e fix types 2024-05-14 23:39:43 +02:00
Simon
db080e97bb better queue total management with score 2024-05-14 23:25:33 +02:00
Simon
5235af3d91 refactor download post processing to redis queue 2024-05-14 19:17:13 +02:00
Simon
6ab70c7602 better att to queue, track score 2024-05-14 18:06:27 +02:00
Simon
86d157699a better 404 handling in views 2024-05-14 17:05:47 +02:00
Simon
c176405b32 untangle channel_overwrites in video index and queue 2024-05-13 20:54:39 +02:00
Simon
6e7cb74366 clean up, remove unused 2024-05-13 19:51:25 +02:00
Simon
05cfeb9d99 fix stop iterator, better channel overwrite unpack 2024-05-13 19:37:46 +02:00
Simon
4edb5adead thumb clean up notification, call in task 2024-05-13 18:26:17 +02:00
Simon
c17627a911 clean up thumbs from cache 2024-05-12 23:00:58 +02:00
Simon
36a738d5d7 move get_channel_overwrites to helper 2024-05-12 21:11:21 +02:00
Simon
d6c4a6ea46 remove redundant playlistcheck in _process_entry 2024-05-12 21:09:48 +02:00
Simon
77ee9cfc13 use video class for channel_id extraction 2024-05-12 21:08:37 +02:00
Simon
c413811e17 fix _parse_playlist, add existing playlist to queue, #634 2024-05-12 01:02:01 +02:00
Simon
2a9769d154 simplify channel overwrite handling in posprocessing 2024-05-12 00:34:03 +02:00
Simon
d9ce9641e2 rewrite playlist postprocess, ensure refresh, #686 2024-05-11 23:59:36 +02:00
Simon
f874d402b1 implement skip_on_empty in update_playlist 2024-05-11 23:09:03 +02:00
Simon
97b6d7d606 simplify playlist reindex, use update 2024-05-11 22:34:44 +02:00
Simon
4a38636ef3 implement remove_vids_from_playlist 2024-05-11 22:34:21 +02:00
Simon
97bc03f855 split channel rescan checking with existing index, #500 2024-05-11 21:47:40 +02:00
Simon
770990c568 split playlist parsing in find_missing in playlist 2024-05-11 21:23:27 +02:00
Simon
ddc4685811 decouple playlist video id matching to individual 2024-05-11 19:16:35 +02:00
Simon
56220a94e0 workaround channel_subs, use first entry, #701 2024-05-11 11:52:50 +02:00
Simon
fd039de53d add to queue notification, #714 2024-05-11 11:10:26 +02:00
Simon
320ead0bd2 clean up startup migrations 2024-05-09 20:52:42 +02:00
Simon
e33341d30d fix playlist subscribe template bool logic, #684 2024-05-09 16:00:02 +02:00
Simon
21b79e7c8f Merge branch 'testing' into feat-delete-ignore 2024-05-09 15:27:19 +02:00
Simon
9366b8eab9 Feature beat model (#713)
* add django-celery-beat

* implement schedule migration

* fix version_check migration

* remove old schedule init

* better schedule migration

* fix task_config migration

* show task config on settings page

* fix notify url builder

* refactor celery initiation

* fix get task

* fix scheduler mig

* fix linter

* better task_config store on periodic task

* save new schedules

* fix task_config extraction from custom model

* implement auto schedule

* implement schedule delete

* refactor notifications to ES config storage

* downgrade redis

* better notification migration to ES

* add notification url handling

* fix worker start

* fix docs spelling

* don't resend form data on notification refresh

* fix type hints

* move TASK_CONFIG to separate module

* fix partial task config imports

* fix yt_obs typing

* delete schedule

* remove outdated instructions

* create initial schedules

* fix reindex days config key

* fix doc string

* unregister BeatModels
2024-05-09 20:22:36 +07:00
Simon
011073617d More experimental comment extraction, #build
Changed:
- Fix for like_count gt 1000
2024-05-07 09:28:09 +02:00
Simon
784f90b16d more experimental comment extractions 2024-05-07 09:27:55 +02:00
Simon
c1cd9bc8eb add requirements-dev 2024-05-06 19:56:14 +02:00
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
852abf254d implement delete and ignore for video, #286 2024-04-03 19:39:20 +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
500 changed files with 29245 additions and 14782 deletions

View File

@@ -5,6 +5,13 @@
# Docker
.docker
# Backend development
backend/static
backend/staticfiles
# Frontend development
frontend/node_modules
# Python
tubearchivist/__pycache__/
tubearchivist/*/__pycache__/
@@ -17,8 +24,5 @@ venv/
# Unneeded graphics
assets/*
# Unneeded docs
docs/*
# for local testing only
testing.sh
testing.sh

View File

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

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

2
.github/FUNDING.yml vendored
View File

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

View File

@@ -15,6 +15,7 @@ body:
options:
- label: I'm running the latest version of Tube Archivist and have read the [release notes](https://github.com/tubearchivist/tubearchivist/releases/latest).
required: true
- label: I'm [beta testing](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#beta-testing) and am running the latest unstable build.
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [bug report](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#bug-report) section.
required: true

View File

@@ -1,34 +1,14 @@
name: Feature Request
description: I have an idea for a great addition to this project
description: This Project currently doesn't take any new feature requests.
title: "[Feature Request]: "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to help improve this project! This project is *very* selective with accepting new feature requests. Please read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide carefully before continuing.
- type: checkboxes
id: already
id: block
attributes:
label: "I've read the documentation"
label: "This project doesn't accept any new feature requests for the foreseeable future. There is no shortage of ideas and the next development steps are clear for years to come."
options:
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [feature request](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#feature-request) section.
- label: I understand that this issue will be closed without comment.
required: true
- label: I will resist the temptation and I will not submit this issue. If I submit this, I understand I might get blocked from this repo.
required: true
- type: textarea
id: description
attributes:
label: Your Feature Request
value: "## Is your feature request related to a problem? Please describe.\n\n## Describe the solution you'd like\n\n## Additional context"
placeholder: Tell us what you see!
validations:
required: true
- type: checkboxes
id: help
attributes:
label: Your help is needed!
description: This project is ambitious as it is, please contribute.
options:
- label: Yes I will work on this in the next few days or weeks.

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

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

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

View File

@@ -1,14 +0,0 @@
name: lint_python
on: [pull_request, push]
jobs:
lint_python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- run: pip install --upgrade pip wheel
- run: pip install bandit black codespell flake8 flake8-bugbear
flake8-comprehensions isort requests
- run: ./deploy.sh validate

47
.github/workflows/pre_commit.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Lint, Test, Build, and Push Docker Image
on:
push:
branches:
- '**'
tags:
- '**'
pull_request:
branches:
- '**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '23'
- name: Install frontend dependencies
run: |
cd frontend
npm install
- name: Cache pre-commit environment
uses: actions/cache@v3
with:
path: |
~/.cache/pre-commit
key: ${{ runner.os }}-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }}
restore-keys: |
${{ runner.os }}-pre-commit-
- name: Install dependencies
run: |
pip install pre-commit
pre-commit install
- name: Run pre-commit
run: |
pre-commit run --all-files

43
.github/workflows/unit_tests.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: python_unit_tests
on:
push:
paths:
- '**/*.py'
pull_request:
paths:
- '**/*.py'
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc libldap2-dev libsasl2-dev libssl-dev
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements-dev.txt
- name: Run unit tests
run: pytest backend

8
.gitignore vendored
View File

@@ -2,11 +2,15 @@
__pycache__
.venv
# django testing db
db.sqlite3
# django testing
backend/static
backend/staticfiles
backend/.env
# vscode custom conf
.vscode
# JavaScript stuff
node_modules
.editorconfig

49
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,49 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
alias: python
files: ^backend/
args: ["--line-length=79"]
- repo: https://github.com/pycqa/isort
rev: 6.0.1
hooks:
- id: isort
name: isort (python)
alias: python
files: ^backend/
args: ["--profile", "black", "-l 79"]
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
alias: python
files: ^backend/
args: ["--max-complexity=10", "--max-line-length=79"]
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
- id: codespell
exclude: ^frontend/package-lock.json
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.30.1
hooks:
- id: eslint
name: eslint
files: \.[jt]sx?$
types: [file]
entry: npm run --prefix ./frontend lint
pass_filenames: false
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
entry: npm run --prefix ./frontend format
pass_filenames: false
exclude: '.*(\.svg|/migrations/).*'

View File

@@ -1,48 +0,0 @@
# The Inner Workings of Tube Archivist
This is a high level overview of the architecture of Tube Archivist, intended for interested contributors to find your way around quickly.
```
Tube Archivist
______________________|_____________________
| | |
------------------- --------------- -------------------
| | | | | |
| DjangoProject | | RedisJson | | ElasticSearch |
| | | | | |
------------------- --------------- -------------------
```
## DjangoProject
This is the main Python application. Django serves its data container internally with **Uwsgi** on port 8080, the interface is served with **Nginx** on the public port 8000.
Users created static files like media files and artwork as well as application artwork like logos and fonts are served directly from Nginx, while the rest of the application uses uwsgi_pass to proxy the requests to uwsgi.
Config files are located in the `docker_assets` folder. The script `run.sh` is the container `CMD` command and entry point, validating env vars, connection to ElasticSearch (ES) and will start the application.
Compared to other Django projects, this application doesn't make use of the database models, due to a lack of integration with ES. This project has its own abstractions and integrations, treating ES as a REST API.
Long running application tasks are handed off to **Celery** - using **Redis** as a broker - to run asynchronously from the main threads.
- All tasks are defined in the `home.tasks.py` module.
There are three Django apps:
- **config**: The root app, routing the main endpoints and the main `settings.py` file
- **api**: The API app with its views and functionality
- **home**: Most of the application logic, templates and views, will probably get split up further in the future.
The *home* app is split up into packages in the `src` directory:
- **download**: All download related classes, interact with yt-dlp, download artwork, handle the download queue and post processing tasks.
- **es**: All index setup and validation classes, handles mapping validations and makes mapping changes, wrapper functions to simplify interactions with Elasticsearch, backup and restore.
- **frontend**: All direct interactions with the frontend, like Django forms, searching, watched state changes, and legacy api_calls in the process of moving to the api app.
- **index**: Contains all functionality for scraping and indexing videos, channels, playlists, comments, subtitles, etc...
- **ta**: Loose collection of functions and classes, handle application config and contains redis wrapper classes.
## RedisJson
Holds the main application config json object that gets dynamically edited from the frontend, serves as a message broker for **Celery**. Redis serves as a temporary and thread safe link between Django and the frontend, storing progress messages and temporary queues for processing. Used to store locking keys for threads and execution details for tasks.
- Wrapper classes to interact with Redis are located in the `home.src.ta.ta_redis.py` module.
## ElasticSearch (ES)
Is used to store and index all metadata, functions as an application database and makes it all searchable. The mapping defines which fields are indexed as searchable text fields and which fields are used for match filtering.
- The index setup and validation is handled in the `home.src.es.index_setup.py` module.
- Wrapper classes for making requests to ES are located in the `home.src.es.connect.py` module.

View File

@@ -1,8 +1,9 @@
## Contributing to Tube Archivist
# Contributing to Tube Archivist
Welcome, and thanks for showing interest in improving Tube Archivist!
Welcome, and thanks for showing interest in improving Tube Archivist!
## Table of Content
- [Beta Testing](#beta-testing)
- [How to open an issue](#how-to-open-an-issue)
- [Bug Report](#bug-report)
- [Feature Request](#feature-request)
@@ -14,6 +15,19 @@ Welcome, and thanks for showing interest in improving Tube Archivist!
- [Development Environment](#development-environment)
---
## Beta Testing
Be the first to help test new features/improvements and provide feedback! Regular `:unstable` builds are available for early access. These are for the tinkerers and the brave. Ideally, use a testing environment first, before upgrading your main installation.
There is always something that can get missed during development. Look at the commit messages tagged with `#build` - these are the unstable builds and give a quick overview of what has changed.
- Test the features mentioned, play around, try to break it.
- Test the update path by installing the `:latest` release first, then upgrade to `:unstable` to check for any errors.
- Test the unstable build on a fresh install.
Then provide feedback - even if you don't encounter any issues! You can do this in the `#beta-testing` channel on the [Discord](https://tubearchivist.com/discord) Discord server.
This helps ensure a smooth update for the stable release. Plus you get to test things out early!
## How to open an issue
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
@@ -23,7 +37,7 @@ Please read this carefully before opening any [issue](https://github.com/tubearc
- Do respond to questions within a day or two so issues can progress. If the issue doesn't move forward due to a lack of response, we'll assume it's solved and we'll close it after some time to keep the list fresh.
**Don't**:
- Don't open *duplicates*, that includes open and closed issues.
- Don't open *duplicates*, that includes open and closed issues. Also don't post the same issue on multiple platforms, that makes it unnecessarily hard for maintainers to keep up.
- Don't open an issue for something that's already on the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), this needs your help to implement it, not another issue.
- Don't open an issue for something that's a [known limitation](https://github.com/tubearchivist/tubearchivist#known-limitations). These are *known* by definition and don't need another reminder. Some limitations may be solved in the future, maybe by you?
- Don't overwrite the *issue template*, they are there for a reason. Overwriting that shows that you don't really care about this project. It shows that you have a misunderstanding how open source collaboration works and just want to push your ideas through. Overwriting the template may result in a ban.
@@ -37,22 +51,7 @@ 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.
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.
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.
- 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.
Your request is likely going to be rejected if:
- Your idea requires multiple days worth of development time and is unrealistic to be implemented any time soon.
- There are already other ways to do what you are trying to do.
- You are trying to do something that only applies to your platform, your specific workflow or your specific setup.
- Your idea would fundamentally change how the project works or it wouldn't be able to be implemented with backwards compatibility.
- Your idea is not a good fit for this project.
This project doesn't take any new feature requests. This project doesn't lack ideas, see the currently open tasks and roadmap. New feature requests aren't helpful at this point in time. Thank you for your understanding.
### Installation Help
GitHub is most likely not the best place to ask for installation help. That's inherently individual and one on one.
@@ -66,26 +65,33 @@ 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:
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.
- Show off your progress, even if not yet complete, by creating a [draft](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests) PR first and switch it as *ready* when you are ready.
- Make sure all your code is linted and formatted correctly, see below. The automatic GH action unfortunately needs to be triggered manually by a maintainer for first time contributors, but will trigger automatically for existing contributors.
- Make sure all your code is linted and formatted correctly, see below.
### Making changes to the JavaScript
### Documentation Changes
The JavaScript does not require any build step; you just edit the files directly. However, there is config for eslint and prettier (a linter and formatter respectively); their use is recommended but not required. To use them, install `node`, run `npm i` from the root directory of this repository to install dependencies, then run `npm run lint` and `npm run format` to run eslint and prettier respectively.
All documentation is intended to represent the state of the [latest](https://github.com/tubearchivist/tubearchivist/releases/latest) release.
- If your PR with code changes also requires changes to documentation *.md files here in this repo, create a separate PR for that, so it can be merged separately at release.
- You can make the PR directly against the *master* branch.
- If your PR requires changes on the [tubearchivist/docs](https://github.com/tubearchivist/docs), make the PR over there.
- Prepare your documentation updates at the same time as the code changes, so people testing your PR can consult the prepared docs if needed.
### Code formatting and linting
To keep things clean and consistent for everybody, there is a github action setup to lint and check the changes. You can test your code locally first if you want. For example if you made changes in the **video** module, run
This project uses the excellent [pre-commit](https://github.com/pre-commit/pre-commit) library. The [pre-commit-config.yml](https://github.com/tubearchivist/tubearchivist/blob/master/.pre-commit-config.yaml) file is part of this repo.
```shell
./deploy.sh validate tubearchivist/home/src/index/video.py
```
**Quick Start**
- Run `pre-commit install` from the root of the repo.
- Next time you commit to your local git repo, the defined hooks will run.
- On first run, this will download and install the needed environments to your local machine, that can take some time. But that will be reused on sunsequent commits.
to validate your changes. If you omit the path, all the project files will get checked. This is subject to change as the codebase improves.
That is also running as a Git Hub action.
---
@@ -93,7 +99,7 @@ to validate your changes. If you omit the path, all the project files will get c
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.
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.
@@ -101,7 +107,7 @@ 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.
- 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 apply 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.
---
@@ -118,21 +124,64 @@ Some of you might have created useful scripts or API integrations around this pr
## 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.
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 there has additional instructions on how to make changes.
---
## Development Environment
I have learned the hard way, that working on a dockerized application outside of docker is very error prone and in general not a good idea. So if you want to test your changes, it's best to run them in a docker testing environment. You might be able to run the application directly, but this document assumes you're using docker.
This codebase is set up to be developed natively outside of docker as well as in a docker container. Developing outside of a docker container can be convenient, as IDE and hot reload usually works out of the box. But testing inside of a container is still essential, as there are subtle differences, especially when working with the filesystem and networking between containers.
### Instructions
Note:
- Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers in the container.
Set up docker on your development machine.
### Native Instruction
Clone this repository.
For convenience, it's recommended to still run Redis and ES in a docker container. Make sure both containers can be reachable over the network.
Functional changes should be made against the unstable `testing` branch, so check that branch out, then make a new branch for your work.
Set up your virtual environment and install the requirements defined in `requirements-dev.txt`.
There are options built in to load environment variables from a file using `load_dotenv`. Example `.env` file to place in the same folder as `manage.py`:
```
TA_HOST="localhost"
TA_USERNAME=tubearchivist
TA_PASSWORD=verysecret
TA_MEDIA_DIR="static/volume/media"
TA_CACHE_DIR="static"
TA_APP_DIR="."
REDIS_CON=redis://localhost:6379
ES_URL="http://localhost:9200"
ELASTIC_PASSWORD=verysecret
TZ=America/New_York
DJANGO_DEBUG=True
```
Then look at the container startup script `run.sh`, make sure all needed migrations and startup checks ran. To start the dev backend server from the same folder as `manage.py` run:
```bash
python manage.py runserver
```
The backend will be available on [localhost:8000/api/](localhost:8000/api/).
You'll probably also want to have a Celery worker instance running, refer to `run.sh` for that. The Beat Scheduler might not be needed.
Then from the frontend folder, install the dependencies with:
```bash
npm install
```
Then to start the frontend development server:
```bash
npm run dev
```
And the frontend should be available at [localhost:3000](localhost:3000).
### Docker Instructions
Edit the `docker-compose.yml` file and replace the [`image: bbilly1/tubearchivist` line](https://github.com/tubearchivist/tubearchivist/blob/4af12aee15620e330adf3624c984c3acf6d0ac8b/docker-compose.yml#L7) with `build: .`. Also make any other changes to the environment variables and so on necessary to run the application, just like you're launching the application as normal.
@@ -142,11 +191,11 @@ Make your changes locally and re-run `docker compose up --build`. The `Dockerfil
### Develop environment inside a VM
You may find it nice to run everything inside of a VM, though this is not necessary. There's a `deploy.sh` script which has some helpers for this use case. YMMV, this is what one of the developers does:
You may find it nice to run everything inside of a VM for complete environment snapshots and encapsulation, though this is not strictly necessary. There's a `deploy.sh` script which has some helpers for this use case:
- Clone the repo, work on it with your favorite code editor in your local filesystem. *testing* branch is where all the changes are happening, might be unstable and is WIP.
- Then I have a VM running standard Ubuntu Server LTS with docker installed. The VM keeps my projects separate and offers convenient snapshot functionality. The VM also offers ways to simulate low end environments by limiting CPU cores and memory. You can use this [Ansible Docker Ubuntu](https://github.com/bbilly1/ansible-playbooks) playbook to get started quickly. But you could also just run docker on your host system.
- I have my local DNS resolve `tubearchivist.local` to the IP of the VM for convenience. To deploy the latest changes and rebuild the application to the testing VM run:
- This assumes a standard Ubuntu Server VM with docker and docker compose already installed.
- Configure your local DNS to resolve `tubearchivist.local` to the IP of the VM.
- To deploy the latest changes and rebuild the application to the testing VM run:
```bash
./deploy.sh test
```
@@ -177,3 +226,19 @@ services:
```
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.
**Persist Token**
The token will get stored in ES in the `config` folder, and not in the `data` folder. To persist the token between ES container rebuilds, you'll need to persist the config folder as an additional volume:
1. Create the token as described above
2. While the container is running, copy the current config folder out of the container, e.g.:
```
docker cp archivist-es:/usr/share/elasticsearch/config/ volume/es_config
```
3. Then stop all containers and mount this folder into the container as an additional volume:
```yml
- ./volume/es_config:/usr/share/elasticsearch/config
```
4. Start all containers back up.
Now your token will persist between ES container rebuilds.

View File

@@ -1,60 +1,66 @@
# 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
FROM node:lts-alpine AS npm-builder
COPY frontend/package.json frontend/package-lock.json /
RUN npm i
FROM node:lts-alpine AS node-builder
# RUN npm config set registry https://registry.npmjs.org/
COPY --from=npm-builder ./node_modules /frontend/node_modules
COPY ./frontend /frontend
WORKDIR /frontend
RUN npm run build:deploy
WORKDIR /
# First stage to build python wheel
FROM python:3.11.3-slim-bullseye AS builder
ARG TARGETPLATFORM
FROM python:3.11.13-slim-bookworm AS builder
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
COPY ./backend/requirements.txt /requirements.txt
RUN pip install --user -r requirements.txt
# build final image
FROM python:3.11.3-slim-bullseye as tubearchivist
# build ffmpeg
FROM python:3.11.13-slim-bookworm AS ffmpeg-builder
ARG TARGETPLATFORM
COPY docker_assets/ffmpeg_download.py ffmpeg_download.py
RUN python ffmpeg_download.py $TARGETPLATFORM
# build final image
FROM python:3.11.13-slim-bookworm AS tubearchivist
ARG INSTALL_DEBUG
ENV PYTHONUNBUFFERED 1
ENV PYTHONUNBUFFERED=1
# copy build requirements
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
# 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 \
apt-get -y update && apt-get -y install --no-install-recommends \
vim htop bmon net-tools iputils-ping procps \
&& pip install --user ipython \
apt-get -y update && apt-get -y install --no-install-recommends \
vim htop bmon net-tools iputils-ping procps lsof \
&& pip install --user ipython pytest pytest-django \
; fi
# make folders
@@ -65,9 +71,12 @@ COPY docker_assets/nginx.conf /etc/nginx/sites-available/default
RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
# copy application into container
COPY ./tubearchivist /app
COPY ./backend /app
COPY ./docker_assets/run.sh /app
COPY ./docker_assets/uwsgi.ini /app
COPY ./docker_assets/backend_start.py /app
COPY ./docker_assets/beat_auto_spawn.sh /app
COPY --from=node-builder ./frontend/dist /app/static
# volumes
VOLUME /cache

143
README.md
View File

@@ -1,10 +1,10 @@
![Tube Archivist](assets/tube-archivist-front.jpg?raw=true "Tube Archivist Banner")
![Tube Archivist](assets/tube-archivist-front.jpg?raw=true "Tube Archivist Banner")
[*more screenshots and video*](SHOWCASE.MD)
<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>
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-forks.png" alt="tubearchivist-github-forks" title="Tube Archivist GitHub Forks" height="50" width="190"/></a>
<a href="https://hub.docker.com/r/bbilly1/tubearchivist" 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/tubearchivist/tubearchivist/stargazers" 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>
<a href="https://github.com/tubearchivist/tubearchivist/forks" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-forks.png" alt="tubearchivist-github-forks" title="Tube Archivist GitHub Forks" height="50" width="190"/></a>
<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>
@@ -23,51 +23,67 @@
------------------------
## Core functionality
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface. This includes:
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface. This includes:
* Subscribe to your favorite YouTube channels
* Download Videos using **yt-dlp**
* Download Videos using **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**
* Index and make videos searchable
* Play videos
* Keep track of viewed and unviewed videos
## Resources
- [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)
- [Jellyfin Integration](https://github.com/tubearchivist/tubearchivist-jf): Add your videos to Jellyfin.
- [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.
This project requires docker. Ensure it is installed and running on your system.
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.
This project requires docker. Ensure it is installed and running on your system.
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/), [Podman](https://docs.tubearchivist.com/installation/podman/) and [True NAS](https://docs.tubearchivist.com/installation/truenas-scale/).
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/) and [Podman](https://docs.tubearchivist.com/installation/podman/).
The instructions here should get you up and running quickly, for Docker beginners and full explanation about each environment variable, see the [docs](https://docs.tubearchivist.com/installation/docker-compose/).
Take a look at the example [docker-compose.yml](https://github.com/tubearchivist/tubearchivist/blob/master/docker-compose.yml) and configure the required environment variables.
**TubeArchivist**:
All environment variables are explained in detail in the docs [here](https://docs.tubearchivist.com/installation/env-vars/).
**TubeArchivist**:
| Environment Var | Value | |
| ----------- | ----------- | ----------- |
| TA_HOST | Server IP or hostname | Required |
| TA_HOST | Server IP or hostname `http://tubearchivist.local:8000` | Required |
| TA_USERNAME | Initial username when logging into TA | Required |
| TA_PASSWORD | Initial password when logging into TA | Required |
| ELASTIC_PASSWORD | Password for ElasticSearch | Required |
| REDIS_HOST | Hostname for Redis | Required |
| REDIS_CON | Connection string to Redis | Required |
| TZ | Set your timezone for the scheduler | Required |
| TA_PORT | Overwrite Nginx port | Optional |
| TA_UWSGI_PORT | Overwrite container internal uwsgi port | Optional |
| TA_BACKEND_PORT | Overwrite container internal backend server 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 |
| REDIS_PORT | Port that Redis runs on | Optional |
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
| ENABLE_CAST | Enable casting support | [Read more](https://docs.tubearchivist.com/configuration/cast/) |
| DJANGO_DEBUG | Return additional error messages, for debug only | |
| DISABLE_STATIC_AUTH | Remove authentication from media files, (Google Cast...) | [Read more](https://docs.tubearchivist.com/installation/env-vars/#disable_static_auth) |
| TA_AUTO_UPDATE_YTDLP | Configure TA to automatically install the latest yt-dlp on container start | Optional |
| DJANGO_DEBUG | Return additional error messages, for debug only | Optional |
| TA_LOGIN_AUTH_MODE | Configure the order of login authentication backends (Default: single) | Optional |
**ElasticSearch**
| TA_LOGIN_AUTH_MODE value | Description |
| ------------------------ | ----------- |
| single | Only use a single backend (default, or LDAP, or Forward auth, selected by TA_LDAP or TA_ENABLE_AUTH_PROXY) |
| local | Use local password database only |
| ldap | Use LDAP backend only |
| forwardauth | Use reverse proxy headers only |
| ldap_local | Use LDAP backend in addition to the local password database |
**ElasticSearch**
| Environment Var | Value | State |
| ----------- | ----------- | ----------- |
| ELASTIC_PASSWORD | Matching password `ELASTIC_PASSWORD` from TubeArchivist | Required |
@@ -75,82 +91,91 @@ 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.
Always use the *latest* (the default) or a named semantic version tag for the docker images. The *unstable* tags see [CONTRIBUTING.md#beta-testing](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#beta-testing).
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. 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.
- All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version.
- This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported. 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.
- All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated from time to time. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version.
## Getting Started
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example:
```
bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
```
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists.
4. Click on *Start download* and let **Tube Archivist** to it's thing.
4. Click on *Start download* and let **Tube Archivist** to it's thing.
5. Enjoy your archived collection!
### Port Collisions
If you have a collision on port `8000`, best solution is to use dockers *HOST_PORT* and *CONTAINER_PORT* distinction: To for example change the interface to port 9000 use `9000:8000` in your docker-compose file.
### Port Collisions
If you have a collision on port `8000`, best solution is to use dockers *HOST_PORT* and *CONTAINER_PORT* distinction: To for example change the interface to port 9000 use `9000:8000` in your docker-compose file.
For more information on port collisions, check the docs.
## Common Errors
Here is a list of common errors and their solutions.
## Common Errors
Here is a list of common errors and their solutions.
### `vm.max_map_count`
**Elastic Search** in Docker requires the kernel setting of the host machine `vm.max_map_count` to be set to at least 262144.
To temporary set the value run:
To temporary set the value run:
```
sudo sysctl -w vm.max_map_count=262144
```
To apply the change permanently depends on your host operating system:
```
To apply the change permanently depends on your host operating system:
- For example on Ubuntu Server add `vm.max_map_count = 262144` to the file `/etc/sysctl.conf`.
- On Arch based systems create a file `/etc/sysctl.d/max_map_count.conf` with the content `vm.max_map_count = 262144`.
- On any other platform look up in the documentation on how to pass kernel parameters.
- On Arch based systems create a file `/etc/sysctl.d/max_map_count.conf` with the content `vm.max_map_count = 262144`.
- On any other platform look up in the documentation on how to pass kernel parameters.
### Permissions for elasticsearch
If you see a message similar to `Unable to access 'path.repo' (/usr/share/elasticsearch/data/snapshot)` or `failed to obtain node locks, tried [/usr/share/elasticsearch/data]` and `maybe these locations are not writable` when initially starting elasticsearch, that probably means the container is not allowed to write files to the volume.
If you see a message similar to `Unable to access 'path.repo' (/usr/share/elasticsearch/data/snapshot)` or `failed to obtain node locks, tried [/usr/share/elasticsearch/data]` and `maybe these locations are not writable` when initially starting elasticsearch, that probably means the container is not allowed to write files to the volume.
To fix that issue, shutdown the container and on your host machine run:
```
chown 1000:0 -R /path/to/mount/point
```
This will match the permissions with the **UID** and **GID** of elasticsearch process within the container and should fix the issue.
This will match the permissions with the **UID** and **GID** of elasticsearch process within the container and should fix the issue.
### Disk usage
The Elasticsearch index will turn to ***read only*** if the disk usage of the container goes above 95% until the usage drops below 90% again, you will see error messages like `disk usage exceeded flood-stage watermark`.
The Elasticsearch index will turn to ***read only*** if the disk usage of the container goes above 95% until the usage drops below 90% again, you will see error messages like `disk usage exceeded flood-stage watermark`.
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.
- 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 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
- [ ] 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
- [ ] Per user videos/channel/playlists
Implemented:
- [X] Configure shorts, streams and video sizes per channel [2024-07-15]
- [X] User created playlists [2024-04-10]
- [X] User roles, aka read only user [2023-11-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]
@@ -176,12 +201,22 @@ Implemented:
- [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. 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.
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.
- Example 1
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.
- [tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State](https://github.com/tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State) Mark videos watched in Plex (through streaming not manually) through Tautulli back to TubeArchivist
- [Dhs92/delete_shorts](https://github.com/Dhs92/delete_shorts): A script to delete ALL YouTube Shorts from TubeArchivist
## Donate
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
Second best way to support the development is to provide for caffeinated beverages:
* [GitHub Sponsor](https://github.com/sponsors/bbilly1) become a sponsor here on GitHub
* [Paypal.me](https://paypal.me/bbilly1) for a one time coffee
@@ -190,6 +225,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.
* **xda-developers.com**: 5 obscure self-hosted services worth checking out - Tube Archivist - To save your essential YouTube videos, [2024-10-13][[link](https://www.xda-developers.com/obscure-self-hosted-services/)]
* **selfhosted.show**: why we're trying Tube Archivist, [2024-06-14][[link](https://selfhosted.show/125)]
* **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/)]
@@ -201,13 +238,3 @@ This is a selection of places where this project has been featured on reddit, in
* **reddit.com**: Celebrating TubeArchivist v0.1, [2022-01-09] [[link](https://www.reddit.com/r/selfhosted/comments/rzh084/celebrating_tubearchivist_v01/)]
* **linuxunplugged.com**: Pick: tubearchivist — Your self-hosted YouTube media server, [2021-09-11] [[link](https://linuxunplugged.com/425)] and [2021-10-05] [[link](https://linuxunplugged.com/426)]
* **reddit.com**: Introducing Tube Archivist, your self hosted Youtube media server, [2021-09-12] [[link](https://www.reddit.com/r/selfhosted/comments/pmj07b/introducing_tube_archivist_your_self_hosted/)]
## Sponsor
Big thank you to [Digitalocean](https://www.digitalocean.com/) for generously donating credit for the tubearchivist.com VPS and buildserver.
<p>
<a href="https://www.digitalocean.com/">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px">
</a>
</p>

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000">
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
<defs>
<style>
.st0 {
fill: #fff;
}
.st1 {
fill: #039a86;
}
.st2 {
fill: none;
}
.st3 {
clip-path: url(#clippath-1);
}
.st4 {
fill: #06131a;
}
.st5 {
clip-path: url(#clippath-3);
}
.st6 {
display: none;
}
.st7 {
clip-path: url(#clippath-2);
}
.st8 {
clip-path: url(#clippath);
}
</style>
<clipPath id="clippath">
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
</clipPath>
<clipPath id="clippath-1">
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
</clipPath>
<clipPath id="clippath-2">
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
</clipPath>
<clipPath id="clippath-3">
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
</clipPath>
</defs>
<g id="Artwork_1" class="st6">
<g class="st8">
<g class="st3">
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
<polygon class="st4" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
<rect class="st4" x="261.1" y="554.4" width="90.8" height="200.1"/>
<polygon class="st4" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
</g>
</g>
</g>
<g id="Artwork_2">
<g class="st7">
<g class="st5">
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
<polygon class="st0" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
<rect class="st0" x="261.1" y="554.4" width="90.8" height="200.1"/>
<polygon class="st0" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000">
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
<defs>
<style>
.st0 {
fill: #fff;
}
.st1 {
fill: #039a86;
}
.st2 {
fill: none;
}
.st3 {
clip-path: url(#clippath-1);
}
.st4 {
fill: #06131a;
}
.st5 {
clip-path: url(#clippath-3);
}
.st6 {
display: none;
}
.st7 {
clip-path: url(#clippath-2);
}
.st8 {
clip-path: url(#clippath);
}
</style>
<clipPath id="clippath">
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
</clipPath>
<clipPath id="clippath-1">
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
</clipPath>
<clipPath id="clippath-2">
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
</clipPath>
<clipPath id="clippath-3">
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
</clipPath>
</defs>
<g id="Artwork_1">
<g class="st8">
<g class="st3">
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
<polygon class="st4" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
<rect class="st4" x="261.1" y="554.4" width="90.8" height="200.1"/>
<polygon class="st4" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
</g>
</g>
</g>
<g id="Artwork_2" class="st6">
<g class="st7">
<g class="st5">
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
<polygon class="st0" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
<rect class="st0" x="261.1" y="554.4" width="90.8" height="200.1"/>
<polygon class="st0" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

86
backend/README.md Normal file
View File

@@ -0,0 +1,86 @@
# Django Setup
## Apps
The backend is split up into the following apps.
### config
Root Django App. Doesn't define any views.
- Has main `settings.py`
- Has main `urls.py` responsible for routing to other apps
### common
Functionality shared between apps.
Defines views on the root `/api/*` path. Has base views to inherit from.
- Connections to ES and Redis
- Searching
- URL parser
- Collection of helper functions
### appsettings
Responsible for functionality from the settings pages.
Defines views at `/api/appsettings/*`.
- Index setup
- Reindexing
- Snapshots
- Filesystem Scan
- Manual import
### channel
Responsible for Channel Indexing functionality.
Defines views at `/api/channel/*` path.
### download
Implements download functionality with yt-dlp.
Defines views at `/api/download/*`.
- Download videos
- Queue management
- Thumbnails
- Subscriptions
### playlist
Implements playlist functionality.
Defines views at `/api/playlist/*`.
- Index Playlists
- Manual Playlists
### stats
Builds aggregations views for the statistics dashboard.
Defines views at `/api/stats/*`.
### task
Defines tasks for Celery.
Defines views at `/api/task/*`.
- Has main `tasks.py` with all shared_task definitions
- Has `CustomPeriodicTask` model
- Implements apprise notifications links
- Implements schedule functionality
### user
Implements user and auth functionality.
Defines views at `/api/config/*`.
- Defines custom `Account` model
### video
Index functionality for videos.
Defines views at `/api/video/*`.
- Index videos
- Index comments
- Index/download subtitles
- Media stream parsing

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": {
@@ -50,6 +62,9 @@
}
}
},
"channel_tabs": {
"type": "keyword"
},
"channel_overwrites": {
"properties": {
"download_format": {
@@ -62,7 +77,16 @@
"type": "boolean"
},
"integrate_sponsorblock": {
"type" : "boolean"
"type": "boolean"
},
"subscriptions_channel_size": {
"type": "long"
},
"subscriptions_live_channel_size": {
"type": "long"
},
"subscriptions_shorts_channel_size": {
"type": "long"
}
}
}
@@ -144,6 +168,9 @@
}
}
},
"channel_tabs": {
"type": "keyword"
},
"channel_overwrites": {
"properties": {
"download_format": {
@@ -156,7 +183,16 @@
"type": "boolean"
},
"integrate_sponsorblock": {
"type" : "boolean"
"type": "boolean"
},
"subscriptions_channel_size": {
"type": "long"
},
"subscriptions_live_channel_size": {
"type": "long"
},
"subscriptions_shorts_channel_size": {
"type": "long"
}
}
}
@@ -209,7 +245,8 @@
"type": "keyword"
},
"published": {
"type": "date"
"type": "date",
"format": "epoch_second||strict_date_optional_time"
},
"playlist": {
"type": "text",
@@ -224,19 +261,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"
}
}
},
@@ -302,28 +357,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"
}
}
}
@@ -412,6 +467,15 @@
"playlist_description": {
"type": "text"
},
"playlist_subscribed": {
"type": "boolean"
},
"playlist_type": {
"type": "keyword"
},
"playlist_active": {
"type": "boolean"
},
"playlist_name": {
"type": "text",
"analyzer": "english",
@@ -447,6 +511,44 @@
"playlist_last_refresh": {
"type": "date",
"format": "epoch_second"
},
"playlist_sort_order": {
"type": "keyword"
},
"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": {
@@ -504,7 +606,7 @@
"format": "epoch_second"
},
"subtitle_index": {
"type" : "long"
"type": "long"
},
"subtitle_lang": {
"type": "keyword"
@@ -513,7 +615,7 @@
"type": "keyword"
},
"subtitle_line": {
"type" : "text",
"type": "text",
"analyzer": "english"
}
},
@@ -548,14 +650,14 @@
"type": "keyword"
},
"comment_text": {
"type" : "text"
"type": "text"
},
"comment_timestamp": {
"type": "date",
"format": "epoch_second"
},
"comment_time_text": {
"type" : "text"
"type": "text"
},
"comment_likecount": {
"type": "long"
@@ -601,4 +703,4 @@
}
}
]
}
}

View File

@@ -0,0 +1,139 @@
"""appsettings erializers"""
# pylint: disable=abstract-method
from common.serializers import ValidateUnknownFieldsMixin
from rest_framework import serializers
class BackupFileSerializer(serializers.Serializer):
"""serialize backup file"""
filename = serializers.CharField()
file_path = serializers.CharField()
file_size = serializers.IntegerField()
timestamp = serializers.CharField()
reason = serializers.CharField()
class AppConfigSubSerializer(
ValidateUnknownFieldsMixin, serializers.Serializer
):
"""serialize app config subscriptions"""
channel_size = serializers.IntegerField(required=False, allow_null=True)
live_channel_size = serializers.IntegerField(
required=False, allow_null=True
)
shorts_channel_size = serializers.IntegerField(
required=False, allow_null=True
)
playlist_size = serializers.IntegerField(required=False, allow_null=True)
auto_start = serializers.BooleanField(required=False)
extract_flat = serializers.BooleanField(required=False)
class AppConfigDownloadsSerializer(
ValidateUnknownFieldsMixin, serializers.Serializer
):
"""serialize app config downloads config"""
limit_speed = serializers.IntegerField(allow_null=True)
sleep_interval = serializers.IntegerField(allow_null=True)
autodelete_days = serializers.IntegerField(allow_null=True)
format = serializers.CharField(allow_null=True)
format_sort = serializers.CharField(allow_null=True)
add_metadata = serializers.BooleanField()
add_thumbnail = serializers.BooleanField()
subtitle = serializers.CharField(allow_null=True)
subtitle_source = serializers.ChoiceField(
choices=["auto", "user"], allow_null=True
)
subtitle_index = serializers.BooleanField()
comment_max = serializers.CharField(allow_null=True)
comment_sort = serializers.ChoiceField(
choices=["top", "new"], allow_null=True
)
cookie_import = serializers.BooleanField()
potoken = serializers.BooleanField()
throttledratelimit = serializers.IntegerField(allow_null=True)
extractor_lang = serializers.CharField(allow_null=True)
integrate_ryd = serializers.BooleanField()
integrate_sponsorblock = serializers.BooleanField()
class AppConfigAppSerializer(
ValidateUnknownFieldsMixin, serializers.Serializer
):
"""serialize app config"""
enable_snapshot = serializers.BooleanField()
enable_cast = serializers.BooleanField()
class AppConfigSerializer(ValidateUnknownFieldsMixin, serializers.Serializer):
"""serialize appconfig"""
subscriptions = AppConfigSubSerializer(required=False)
downloads = AppConfigDownloadsSerializer(required=False)
application = AppConfigAppSerializer(required=False)
class CookieValidationSerializer(serializers.Serializer):
"""serialize cookie validation response"""
cookie_enabled = serializers.BooleanField()
status = serializers.BooleanField(required=False)
validated = serializers.IntegerField(required=False)
validated_str = serializers.CharField(required=False)
class CookieUpdateSerializer(serializers.Serializer):
"""serialize cookie to update"""
cookie = serializers.CharField()
class PoTokenSerializer(serializers.Serializer):
"""serialize PO token"""
potoken = serializers.CharField()
class SnapshotItemSerializer(serializers.Serializer):
"""serialize snapshot response"""
id = serializers.CharField()
state = serializers.CharField()
es_version = serializers.CharField()
start_date = serializers.CharField()
end_date = serializers.CharField()
end_stamp = serializers.IntegerField()
duration_s = serializers.IntegerField()
class SnapshotListSerializer(serializers.Serializer):
"""serialize snapshot list response"""
next_exec = serializers.IntegerField()
next_exec_str = serializers.CharField()
expire_after = serializers.CharField()
snapshots = SnapshotItemSerializer(many=True)
class SnapshotCreateResponseSerializer(serializers.Serializer):
"""serialize new snapshot creating response"""
snapshot_name = serializers.CharField()
class SnapshotRestoreResponseSerializer(serializers.Serializer):
"""serialize snapshot restore response"""
accepted = serializers.BooleanField()
class TokenResponseSerializer(serializers.Serializer):
"""serialize token response"""
token = serializers.CharField()

View File

@@ -10,17 +10,20 @@ import os
import zipfile
from datetime import datetime
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.ta.config import AppConfig
from home.src.ta.helper import get_mapping, ignore_filelist
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import get_mapping, ignore_filelist
from task.models import CustomPeriodicTask
class ElasticBackup:
"""dump index to nd-json files for later bulk import"""
def __init__(self, reason=False, task=False):
self.config = AppConfig().config
self.cache_dir = self.config["application"]["cache_dir"]
INDEX_SPLIT = ["comment"]
CACHE_DIR = EnvironmentSettings.CACHE_DIR
BACKUP_DIR = os.path.join(CACHE_DIR, "backup")
def __init__(self, reason=False, task=False) -> None:
self.timestamp = datetime.now().strftime("%Y%m%d")
self.index_config = get_mapping()
self.reason = reason
@@ -51,14 +54,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 +79,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 +98,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 +109,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 +118,38 @@ 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")
else:
raise ValueError
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 +160,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)
@@ -183,7 +198,12 @@ class ElasticBackup:
def rotate_backup(self):
"""delete old backups if needed"""
rotate = self.config["scheduler"]["run_backup_rotate"]
try:
task = CustomPeriodicTask.objects.get(name="run_backup")
except CustomPeriodicTask.DoesNotExist:
return
rotate = task.task_config.get("rotate")
if not rotate:
return
@@ -194,22 +214,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 +266,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

@@ -0,0 +1,256 @@
"""
Functionality:
- read and write config
- load config variables into redis
"""
from random import randint
from time import sleep
from typing import Literal, TypedDict
import requests
from appsettings.src.snapshot import ElasticSnapshot
from common.src.es_connect import ElasticWrap
from common.src.ta_redis import RedisArchivist
from django.conf import settings
class SubscriptionsConfigType(TypedDict):
"""describes subscriptions config"""
channel_size: int
live_channel_size: int
shorts_channel_size: int
playlist_size: int
auto_start: bool
extract_flat: bool
class DownloadsConfigType(TypedDict):
"""describes downloads config"""
limit_speed: int | None
sleep_interval: int | None
autodelete_days: int | None
format: str | None
format_sort: str | None
add_metadata: bool
add_thumbnail: bool
subtitle: str | None
subtitle_source: Literal["user", "auto"] | None
subtitle_index: bool
comment_max: str | None
comment_sort: Literal["top", "new"] | None
cookie_import: bool
potoken: bool
throttledratelimit: int | None
extractor_lang: str | None
integrate_ryd: bool
integrate_sponsorblock: bool
class ApplicationConfigType(TypedDict):
"""describes application config"""
enable_snapshot: bool
enable_cast: bool
class AppConfigType(TypedDict):
"""combined app config type"""
subscriptions: SubscriptionsConfigType
downloads: DownloadsConfigType
application: ApplicationConfigType
class AppConfig:
"""handle application variables"""
ES_PATH = "ta_config/_doc/appsettings"
ES_UPDATE_PATH = "ta_config/_update/appsettings"
CONFIG_DEFAULTS: AppConfigType = {
"subscriptions": {
"channel_size": 50,
"live_channel_size": 50,
"shorts_channel_size": 50,
"playlist_size": 50,
"auto_start": False,
"extract_flat": False,
},
"downloads": {
"limit_speed": None,
"sleep_interval": 10,
"autodelete_days": None,
"format": None,
"format_sort": None,
"add_metadata": False,
"add_thumbnail": False,
"subtitle": None,
"subtitle_source": None,
"subtitle_index": False,
"comment_max": None,
"comment_sort": "top",
"cookie_import": False,
"potoken": False,
"throttledratelimit": None,
"extractor_lang": None,
"integrate_ryd": False,
"integrate_sponsorblock": False,
},
"application": {
"enable_snapshot": True,
"enable_cast": False,
},
}
def __init__(self):
self.config = self.get_config()
def get_config(self) -> AppConfigType:
"""get config from ES"""
response, status_code = ElasticWrap(self.ES_PATH).get()
if not status_code == 200:
raise ValueError(f"no config found at {self.ES_PATH}")
return response["_source"]
def update_config(self, data: dict) -> AppConfigType:
"""update single config value"""
new_config = self.config.copy()
for key, value in data.items():
if (
isinstance(value, dict)
and key in new_config
and isinstance(new_config[key], dict)
):
new_config[key].update(value)
else:
new_config[key] = value
response, status_code = ElasticWrap(self.ES_PATH).post(new_config)
if not status_code == 200:
print(response)
self.config = new_config
return new_config
def post_process_updated(self, data: dict) -> None:
"""apply hooks for some config keys"""
for config_value, updated_value in data:
if config_value == "application.enable_snapshot" and updated_value:
ElasticSnapshot().setup()
@staticmethod
def _fail_message(message_line):
"""notify our failure"""
key = "message:setting"
message = {
"status": key,
"group": "setting:application",
"level": "error",
"title": "Cookie import failed",
"messages": [message_line],
"id": "0000",
}
RedisArchivist().set_message(key, message=message, expire=True)
def sync_defaults(self):
"""sync defaults at startup, needs to be called with __new__"""
return ElasticWrap(self.ES_PATH).post(self.CONFIG_DEFAULTS)
def add_new_defaults(self) -> list[str]:
"""add new default config values to ES, called at startup"""
updated = []
for key, value in self.CONFIG_DEFAULTS.items():
if key not in self.config:
# complete new key
self.update_config({key: value})
updated.append(str({key: value}))
continue
for sub_key, sub_value in value.items(): # type: ignore
if sub_key not in self.config[key]:
# new partial key
to_update = {key: {sub_key: sub_value}}
self.update_config(to_update)
updated.append(str(to_update))
return updated
class ReleaseVersion:
"""compare local version with remote version"""
REMOTE_URL = "https://www.tubearchivist.com/api/release/latest/"
NEW_KEY = "versioncheck:new"
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) -> None:
"""check version"""
print(f"[{self.local_version}]: look for updates")
self.get_remote_version()
new_version = self._has_update()
if new_version:
message = {
"status": True,
"version": new_version,
"is_breaking": self.is_breaking,
}
RedisArchivist().set_message(self.NEW_KEY, message)
print(f"[{self.local_version}]: found new version {new_version}")
def get_local_version(self) -> str:
"""read version from local"""
return self.local_version
def get_remote_version(self) -> None:
"""read version from remote"""
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) -> str | bool:
"""check if there is an update"""
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 local_parsed == remote_parsed:
return self.remote_version
return False
@staticmethod
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) -> str | bool:
"""check if update happened in the mean time"""
message = self.get_update()
if not message:
return False
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) -> dict | None:
"""return new version dict if available"""
message = RedisArchivist().get_message_dict(self.NEW_KEY)
return message or None

View File

@@ -5,17 +5,17 @@ Functionality:
import os
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.comments import CommentList
from home.src.index.video import YoutubeVideo, index_new_video
from home.src.ta.config import AppConfig
from home.src.ta.helper import ignore_filelist
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import IndexPaginate
from common.src.helper import ignore_filelist
from video.src.comments import CommentList
from video.src.index import YoutubeVideo, index_new_video
class Scanner:
"""scan index and filesystem"""
VIDEOS: str = AppConfig().config["application"]["videos"]
VIDEOS: str = EnvironmentSettings.MEDIA_DIR
def __init__(self, task=False) -> None:
self.task = task
@@ -56,7 +56,6 @@ class Scanner:
"""apply all changes"""
self.delete()
self.index()
self.url_fix()
def delete(self) -> None:
"""delete videos from index"""
@@ -83,42 +82,12 @@ class Scanner:
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(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."]
)
comment_list = CommentList(task=self.task)
comment_list.add(video_ids=list(self.to_index))
comment_list.index()

View File

@@ -5,11 +5,11 @@ functionality:
- backup and restore metadata
"""
from home.src.es.backup import ElasticBackup
from home.src.es.connect import ElasticWrap
from home.src.es.snapshot import ElasticSnapshot
from home.src.ta.config import AppConfig
from home.src.ta.helper import get_mapping
from appsettings.src.backup import ElasticBackup
from appsettings.src.config import AppConfig
from appsettings.src.snapshot import ElasticSnapshot
from common.src.es_connect import ElasticWrap
from common.src.helper import get_mapping
class ElasticIndex:
@@ -111,6 +111,8 @@ class ElasticIndex:
elif method == "restore":
source = f"ta_{self.index_name}_backup"
destination = f"ta_{self.index_name}"
else:
raise ValueError("invalid method, expected 'backup' or 'restore'")
data = {"source": {"index": source}, "dest": {"index": destination}}
_, _ = ElasticWrap("_reindex?refresh=true").post(data=data)
@@ -199,7 +201,15 @@ class ElasitIndexWrap:
if self.backup_run:
return
config = AppConfig().config
try:
config = AppConfig().config
except ValueError:
# create defaults in ES if config not found
print("AppConfig not found, creating defaults...")
handler = AppConfig.__new__(AppConfig)
handler.sync_defaults()
config = AppConfig.CONFIG_DEFAULTS
if config["application"]["enable_snapshot"]:
# take snapshot if enabled
ElasticSnapshot().take_snapshot_now(wait=True)

View File

@@ -11,12 +11,13 @@ import re
import shutil
import subprocess
from home.src.download.thumbnails import ThumbManager
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 appsettings.src.config import AppConfig
from common.src.env_settings import EnvironmentSettings
from common.src.helper import ignore_filelist
from download.src.thumbnails import ThumbManager
from PIL import Image
from video.src.comments import CommentList
from video.src.index import YoutubeVideo
from yt_dlp.utils import ISO639Utils
@@ -27,8 +28,7 @@ class ImportFolderScanner:
- convert if needed
"""
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.
@@ -128,6 +128,7 @@ class ImportFolderScanner:
def process_videos(self):
"""loop through all videos"""
config = AppConfig().config
for idx, current_video in enumerate(self.to_import):
if not current_video["media"]:
print(f"{current_video}: no matching media file found.")
@@ -143,10 +144,12 @@ class ImportFolderScanner:
self._convert_video(current_video)
print(f"manual import: {current_video}")
ManualImport(current_video, self.CONFIG).run()
ManualImport(current_video, config).run()
video_ids = [i["video_id"] for i in self.to_import]
CommentList(video_ids, task=self.task).index()
comment_list = CommentList(task=self.task)
comment_list.add(video_ids=video_ids)
comment_list.index()
def _notify(self, idx, current_video):
"""send notification back to task"""
@@ -404,8 +407,11 @@ class ManualImport:
media_path=self.current_video["media"],
)
if not video.json_data:
print(f"{video_id}: manual import failed, and no metadata found.")
raise ValueError
message = (
f"{video_id}: manual import failed, and no metadata found."
)
print(message)
raise ValueError(message)
video.check_subtitles(subtitle_files=self.current_video["subtitle"])
video.upload_to_es()
@@ -433,9 +439,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)
@@ -472,7 +478,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

@@ -7,25 +7,36 @@ functionality:
import json
import os
from datetime import datetime
from time import sleep
from typing import Callable, TypedDict
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.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.ta_redis import RedisQueue
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
from channel.src.remote_query import get_last_channel_videos
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import rand_sleep
from common.src.ta_redis import RedisQueue
from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import CookieHandler
from playlist.src.index import YoutubePlaylist
from task.models import CustomPeriodicTask
from video.src.comments import Comments
from video.src.index import YoutubeVideo
class ReindexConfigType(TypedDict):
"""represents config type"""
index_name: str
queue_name: str
active_key: str
refresh_key: str
class ReindexBase:
"""base config class for reindex task"""
REINDEX_CONFIG = {
REINDEX_CONFIG: dict[str, ReindexConfigType] = {
"video": {
"index_name": "ta_video",
"queue_name": "reindex:ta_video",
@@ -52,25 +63,36 @@ 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):
def populate(self, all_ids, reindex_config: ReindexConfigType):
"""add all to reindex ids to redis queue"""
if not all_ids:
return
RedisQueue(queue_name=reindex_config["queue_name"]).add_list(all_ids)
self.total = None
class ReindexPopulate(ReindexBase):
"""add outdated and recent documents to reindex queue"""
INTERVAL_DEFAIULT: int = 90
def __init__(self):
super().__init__()
self.interval = self.config["scheduler"]["check_reindex_days"]
self.interval = self.INTERVAL_DEFAIULT
def add_recent(self):
def get_interval(self) -> None:
"""get reindex days interval from task"""
try:
task = CustomPeriodicTask.objects.get(name="check_reindex")
except CustomPeriodicTask.DoesNotExist:
return
task_config = task.task_config
if task_config.get("days"):
self.interval = task_config.get("days")
def add_recent(self) -> None:
"""add recent videos to refresh"""
gte = datetime.fromtimestamp(self.now - self.DAYS3).date().isoformat()
must_list = [
@@ -88,10 +110,10 @@ class ReindexPopulate(ReindexBase):
return
all_ids = [i["_source"]["youtube_id"] for i in hits]
reindex_config = self.REINDEX_CONFIG.get("video")
reindex_config: ReindexConfigType = self.REINDEX_CONFIG["video"]
self.populate(all_ids, reindex_config)
def add_outdated(self):
def add_outdated(self) -> None:
"""add outdated documents"""
for reindex_config in self.REINDEX_CONFIG.values():
total_hits = self._get_total_hits(reindex_config)
@@ -100,17 +122,19 @@ class ReindexPopulate(ReindexBase):
self.populate(all_ids, reindex_config)
@staticmethod
def _get_total_hits(reindex_config):
def _get_total_hits(reindex_config: ReindexConfigType) -> int:
"""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()
def _get_daily_should(self, total_hits):
return len(total)
def _get_daily_should(self, total_hits: int) -> int:
"""calc how many should reindex daily"""
daily_should = int((total_hits // self.interval + 1) * self.MULTIPLY)
if daily_should >= 10000:
@@ -118,11 +142,13 @@ class ReindexPopulate(ReindexBase):
return daily_should
def _get_outdated_ids(self, reindex_config, daily_should):
def _get_outdated_ids(
self, reindex_config: ReindexConfigType, daily_should: int
) -> list[str]:
"""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}}},
@@ -155,7 +181,7 @@ class ReindexManual(ReindexBase):
self.extract_videos = extract_videos
self.data = False
def extract_data(self, data):
def extract_data(self, data) -> None:
"""process data"""
self.data = data
for key, values in self.data.items():
@@ -166,7 +192,9 @@ class ReindexManual(ReindexBase):
self.process_index(reindex_config, values)
def process_index(self, index_config, values):
def process_index(
self, index_config: ReindexConfigType, values: list[str]
) -> None:
"""process values per index"""
index_name = index_config["index_name"]
if index_name == "ta_video":
@@ -176,32 +204,35 @@ class ReindexManual(ReindexBase):
elif index_name == "ta_playlist":
self._add_playlists(values)
def _add_videos(self, values):
def _add_videos(self, values: list[str]) -> None:
"""add list of videos to reindex queue"""
if not values:
return
RedisQueue("reindex:ta_video").add_list(values)
queue_name = self.REINDEX_CONFIG["video"]["queue_name"]
RedisQueue(queue_name).add_list(values)
def _add_channels(self, values):
def _add_channels(self, values: list[str]) -> None:
"""add list of channels to reindex queue"""
RedisQueue("reindex:ta_channel").add_list(values)
queue_name = self.REINDEX_CONFIG["channel"]["queue_name"]
RedisQueue(queue_name).add_list(values)
if self.extract_videos:
for channel_id in values:
all_videos = self._get_channel_videos(channel_id)
self._add_videos(all_videos)
def _add_playlists(self, values):
def _add_playlists(self, values: list[str]) -> None:
"""add list of playlists to reindex queue"""
RedisQueue("reindex:ta_playlist").add_list(values)
queue_name = self.REINDEX_CONFIG["playlist"]["queue_name"]
RedisQueue(queue_name).add_list(values)
if self.extract_videos:
for playlist_id in values:
all_videos = self._get_playlist_videos(playlist_id)
self._add_videos(all_videos)
def _get_channel_videos(self, channel_id):
def _get_channel_videos(self, channel_id: str) -> list[str]:
"""get all videos from channel"""
data = {
"query": {"term": {"channel.channel_id": {"value": channel_id}}},
@@ -210,7 +241,7 @@ class ReindexManual(ReindexBase):
all_results = IndexPaginate("ta_video", data).get_results()
return [i["youtube_id"] for i in all_results]
def _get_playlist_videos(self, playlist_id):
def _get_playlist_videos(self, playlist_id: str) -> list[str]:
"""get all videos from playlist"""
data = {
"query": {"term": {"playlist.keyword": {"value": playlist_id}}},
@@ -226,43 +257,41 @@ class Reindex(ReindexBase):
def __init__(self, task=False):
super().__init__()
self.task = task
self.all_indexed_ids = False
self.processed = {
"videos": 0,
"channels": 0,
"playlists": 0,
}
def reindex_all(self):
def reindex_all(self) -> None:
"""reindex all in queue"""
if not self.cookie_is_valid():
print("[reindex] cookie invalid, exiting...")
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
self.total = RedisQueue(index_config["queue_name"]).length()
while True:
has_next = self.reindex_index(name, index_config)
if not has_next:
break
self.reindex_type(name, index_config)
def reindex_index(self, name, index_config):
def reindex_type(self, name: str, index_config: ReindexConfigType) -> None:
"""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:
reindex = self._get_reindex_map(index_config["index_name"])
queue = RedisQueue(index_config["queue_name"])
while True:
total = queue.max_score()
youtube_id, idx = queue.get_next()
if not youtube_id or not idx or not total:
break
if self.task:
self._notify(name, index_config)
self._notify(name, total, idx)
reindex(youtube_id)
sleep_interval = self.config["downloads"].get("sleep_interval", 0)
sleep(sleep_interval)
rand_sleep(self.config)
return bool(youtube_id)
def get_reindex_map(self, index_name):
def _get_reindex_map(self, index_name: str) -> Callable:
"""return def to run for index"""
def_map = {
"ta_video": self._reindex_single_video,
@@ -270,30 +299,28 @@ class Reindex(ReindexBase):
"ta_playlist": self._reindex_single_playlist,
}
return def_map.get(index_name)
return def_map[index_name]
def _notify(self, name, index_config):
def _notify(self, name: str, total: int, idx: int) -> None:
"""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 = self.total - remaining
message = [f"Reindexing {name.title()}s {idx}/{self.total}"]
progress = idx / self.total
message = [f"Reindexing {name.title()}s {idx}/{total}"]
progress = idx / total
self.task.send_progress(message, progress=progress)
def _reindex_single_video(self, youtube_id):
def _reindex_single_video(self, youtube_id: str) -> None:
"""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
media_url = os.path.join(
self.config["application"]["videos"], es_meta["media_url"]
EnvironmentSettings.MEDIA_DIR, es_meta["media_url"]
)
video.build_json(media_path=media_url)
if not video.youtube_meta:
@@ -306,15 +333,12 @@ class Reindex(ReindexBase):
# add back
video.json_data["player"] = es_meta.get("player")
video.json_data["date_downloaded"] = es_meta.get("date_downloaded")
video.json_data["vid_type"] = es_meta.get("vid_type")
video.json_data["channel"] = es_meta.get("channel")
if es_meta.get("playlist"):
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()
@@ -323,21 +347,14 @@ class Reindex(ReindexBase):
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)
def _reindex_single_channel(self, channel_id):
def _reindex_single_channel(self, channel_id: str) -> None:
"""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
@@ -358,37 +375,28 @@ class Reindex(ReindexBase):
channel.json_data["channel_overwrites"] = overwrites
channel.upload_to_es()
ChannelFullScan(channel_id).scan()
channel.sync_to_videos()
ChannelFullScan(channel_id, self.config).scan()
self.processed["channels"] += 1
def _reindex_single_playlist(self, playlist_id):
def _reindex_single_playlist(self, playlist_id: str) -> None:
"""refresh playlist data"""
self._get_all_videos()
playlist = YoutubePlaylist(playlist_id)
playlist.get_from_es()
subscribed = playlist.json_data["playlist_subscribed"]
playlist.all_youtube_ids = self.all_indexed_ids
playlist.build_json(scrape=True)
if not playlist.json_data:
if (
not playlist.json_data
or playlist.json_data["playlist_type"] == "custom"
):
return
is_active = playlist.update_playlist()
if not is_active:
playlist.deactivate()
return
playlist.json_data["playlist_subscribed"] = subscribed
playlist.upload_to_es()
self.processed["playlists"] += 1
return
def _get_all_videos(self):
"""add all videos for playlist index validation"""
if self.all_indexed_ids:
return
handler = PendingList()
handler.get_download()
handler.get_indexed()
self.all_indexed_ids = [i["youtube_id"] for i in handler.all_videos]
def cookie_is_valid(self):
def cookie_is_valid(self) -> bool:
"""return true if cookie is enabled and valid"""
if not self.config["downloads"]["cookie_import"]:
# is not activated, continue reindex
@@ -397,7 +405,7 @@ class Reindex(ReindexBase):
valid = CookieHandler(self.config).validate()
return valid
def build_message(self):
def build_message(self) -> str:
"""build progress message"""
message = ""
for key, value in self.processed.items():
@@ -427,7 +435,7 @@ class ReindexProgress(ReindexBase):
self.request_type = request_type
self.request_id = request_id
def get_progress(self):
def get_progress(self) -> dict:
"""get progress from task"""
queue_name, request_type = self._get_queue_name()
total = self._get_total_in_queue(queue_name)
@@ -485,13 +493,11 @@ class ReindexProgress(ReindexBase):
class ChannelFullScan:
"""
update from v0.3.0 to v0.3.1
full scan of channel to fix vid_type mismatch
"""
"""full scan of channel to fix vid_type mismatch"""
def __init__(self, channel_id):
def __init__(self, channel_id, config):
self.channel_id = channel_id
self.config = config
self.to_update = False
def scan(self):
@@ -502,12 +508,14 @@ class ChannelFullScan:
self.to_update = []
for video in all_local_videos:
video_id = video["youtube_id"]
remote_match = [i for i in all_remote_videos if i[0] == video_id]
remote_match = [
i for i in all_remote_videos if i["id"] == video_id
]
if not remote_match:
print(f"{video_id}: no remote match found")
continue
expected_type = remote_match[0][-1]
expected_type = remote_match[0]["vid_type"]
if video["vid_type"] != expected_type:
self.to_update.append(
{
@@ -520,9 +528,8 @@ class ChannelFullScan:
def _get_all_remote(self):
"""get all channel videos"""
sub = ChannelSubscription()
all_remote_videos = sub.get_last_youtube_videos(
self.channel_id, limit=False
all_remote_videos = get_last_channel_videos(
self.channel_id, self.config, limit=False
)
return all_remote_videos

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 common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.helper import get_mapping
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"
@@ -150,7 +150,7 @@ class ElasticSnapshot:
if statuscode == 200:
print(f"snapshot: executing now: {response}")
if wait:
if wait and "snapshot_name" in response:
self._wait_for_snapshot(response["snapshot_name"])
return response
@@ -251,10 +251,9 @@ class ElasticSnapshot:
@staticmethod
def _date_converter(date_utc):
"""convert datetime string"""
expected_format = "%Y-%m-%dT%H:%M:%S.%fZ"
date = datetime.strptime(date_utc, expected_format)
local_datetime = date.replace(tzinfo=ZoneInfo("localtime"))
converted = local_datetime.astimezone(ZoneInfo(environ.get("TZ")))
date = datetime.strptime(date_utc, "%Y-%m-%dT%H:%M:%S.%fZ")
utc_date = date.replace(tzinfo=ZoneInfo("UTC"))
converted = utc_date.astimezone(ZoneInfo(EnvironmentSettings.TZ))
converted_str = converted.strftime("%Y-%m-%d %H:%M")
return converted_str

View File

@@ -0,0 +1,47 @@
"""all app settings API urls"""
from appsettings import views
from django.urls import path
urlpatterns = [
path(
"config/",
views.AppConfigApiView.as_view(),
name="api-config",
),
path(
"snapshot/",
views.SnapshotApiListView.as_view(),
name="api-snapshot-list",
),
path(
"snapshot/<slug:snapshot_id>/",
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(
"cookie/",
views.CookieView.as_view(),
name="api-cookie",
),
path(
"potoken/",
views.POTokenView.as_view(),
name="api-potoken",
),
path(
"token/",
views.TokenView.as_view(),
name="api-token",
),
]

View File

@@ -0,0 +1,493 @@
"""all app settings API views"""
from appsettings.serializers import (
AppConfigSerializer,
BackupFileSerializer,
CookieUpdateSerializer,
CookieValidationSerializer,
PoTokenSerializer,
SnapshotCreateResponseSerializer,
SnapshotItemSerializer,
SnapshotListSerializer,
SnapshotRestoreResponseSerializer,
TokenResponseSerializer,
)
from appsettings.src.backup import ElasticBackup
from appsettings.src.config import AppConfig
from appsettings.src.snapshot import ElasticSnapshot
from common.serializers import (
AsyncTaskResponseSerializer,
ErrorResponseSerializer,
)
from common.src.ta_redis import RedisArchivist
from common.views_base import AdminOnly, AdminWriteOnly, ApiBaseView
from django.conf import settings
from download.src.yt_dlp_base import CookieHandler, POTokenHandler
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from task.src.task_manager import TaskCommand
from task.tasks import run_restore_backup
class BackupApiListView(ApiBaseView):
"""resolves to /api/appsettings/backup/
GET: returns list of available zip backups
POST: take zip backup now
"""
permission_classes = [AdminOnly]
task_name = "run_backup"
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(BackupFileSerializer(many=True)),
},
)
def get(request):
"""get list of available backup files"""
# pylint: disable=unused-argument
backup_files = ElasticBackup().get_all_backup_files()
serializer = BackupFileSerializer(backup_files, many=True)
return Response(serializer.data)
@extend_schema(
responses={
200: OpenApiResponse(AsyncTaskResponseSerializer()),
},
)
def post(self, request):
"""start new backup file task"""
# pylint: disable=unused-argument
response = TaskCommand().start(self.task_name)
message = {
"message": "backup task started",
"task_id": response["task_id"],
}
serializer = AsyncTaskResponseSerializer(message)
return Response(serializer.data)
class BackupApiView(ApiBaseView):
"""resolves to /api/appsettings/backup/<filename>/
GET: return a single backup
POST: restore backup
DELETE: delete backup
"""
permission_classes = [AdminOnly]
task_name = "restore_backup"
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(BackupFileSerializer()),
404: OpenApiResponse(
ErrorResponseSerializer(), description="file not found"
),
}
)
def get(request, filename):
"""get single backup"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().build_backup_file_data(filename)
if not backup_file:
error = ErrorResponseSerializer({"error": "file not found"})
return Response(error.data, status=404)
serializer = BackupFileSerializer(backup_file)
return Response(serializer.data)
@extend_schema(
responses={
200: OpenApiResponse(AsyncTaskResponseSerializer()),
404: OpenApiResponse(
ErrorResponseSerializer(), description="file not found"
),
}
)
def post(self, request, filename):
"""start new task to restore backup file"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().build_backup_file_data(filename)
if not backup_file:
error = ErrorResponseSerializer({"error": "file not found"})
return Response(error.data, status=404)
task = run_restore_backup.delay(filename)
message = {
"message": "backup restore task started",
"filename": filename,
"task_id": task.id,
}
return Response(message)
@staticmethod
@extend_schema(
responses={
204: OpenApiResponse(description="file deleted"),
404: OpenApiResponse(
ErrorResponseSerializer(), description="file not found"
),
}
)
def delete(request, filename):
"""delete backup file"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().delete_file(filename)
if not backup_file:
error = ErrorResponseSerializer({"error": "file not found"})
return Response(error.data, status=404)
return Response(status=204)
class AppConfigApiView(ApiBaseView):
"""resolves to /api/appsettings/config/
GET: return app settings
POST: update app settings
"""
permission_classes = [AdminWriteOnly]
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(AppConfigSerializer()),
}
)
def get(request):
"""get app config"""
response = AppConfig().config
serializer = AppConfigSerializer(response)
return Response(serializer.data)
@staticmethod
@extend_schema(
request=AppConfigSerializer(),
responses={
200: OpenApiResponse(AppConfigSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
},
)
def post(request):
"""update config values, allows partial update"""
serializer = AppConfigSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
updated_config = AppConfig().update_config(validated_data)
updated_serializer = AppConfigSerializer(updated_config)
return Response(updated_serializer.data)
class CookieView(ApiBaseView):
"""resolves to /api/appsettings/cookie/
GET: check if cookie is enabled
POST: verify validity of cookie
PUT: import cookie
DELETE: revoke the cookie
"""
permission_classes = [AdminOnly]
@extend_schema(
responses={
200: OpenApiResponse(CookieValidationSerializer()),
}
)
def get(self, request):
"""get cookie validation status"""
# pylint: disable=unused-argument
validation = self._get_cookie_validation()
serializer = CookieValidationSerializer(validation)
return Response(serializer.data)
@extend_schema(
responses={
200: OpenApiResponse(CookieValidationSerializer()),
}
)
def post(self, request):
"""validate cookie"""
# pylint: disable=unused-argument
config = AppConfig().config
_ = CookieHandler(config).validate()
validation = self._get_cookie_validation()
serializer = CookieValidationSerializer(validation)
return Response(serializer.data)
@extend_schema(
request=CookieUpdateSerializer(),
responses={
200: OpenApiResponse(CookieValidationSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
},
)
def put(self, request):
"""handle put request"""
# pylint: disable=unused-argument
serializer = CookieUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
cookie = validated_data.get("cookie")
if not cookie:
message = "missing cookie key in request data"
print(message)
error = ErrorResponseSerializer({"error": message})
return Response(error.data, status=400)
if settings.DEBUG:
print(f"[cookie] preview:\n\n{cookie[:300]}")
config = AppConfig().config
handler = CookieHandler(config)
handler.set_cookie(cookie)
validated = handler.validate()
if not validated:
message = "[cookie]: import failed, not valid"
print(message)
error = ErrorResponseSerializer({"error": message})
handler.revoke()
return Response(error.data, status=400)
validation = self._get_cookie_validation()
serializer = CookieValidationSerializer(validation)
return Response(serializer.data)
@extend_schema(
responses={
204: OpenApiResponse(description="Cookie revoked"),
},
)
def delete(self, request):
"""delete the cookie"""
config = AppConfig().config
handler = CookieHandler(config)
handler.revoke()
return Response(status=204)
@staticmethod
def _get_cookie_validation():
"""get current cookie validation"""
config = AppConfig().config
validation = RedisArchivist().get_message_dict("cookie:valid")
is_enabled = {"cookie_enabled": config["downloads"]["cookie_import"]}
validation.update(is_enabled)
return validation
class POTokenView(ApiBaseView):
"""handle PO token"""
permission_classes = [AdminOnly]
@extend_schema(
responses={
200: OpenApiResponse(PoTokenSerializer()),
404: OpenApiResponse(
ErrorResponseSerializer(), description="PO token not found"
),
}
)
def get(self, request):
"""get PO token"""
config = AppConfig().config
potoken = POTokenHandler(config).get()
if not potoken:
error = ErrorResponseSerializer({"error": "PO token not found"})
return Response(error.data, status=404)
serializer = PoTokenSerializer(data={"potoken": potoken})
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
@extend_schema(
responses={
200: OpenApiResponse(PoTokenSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
}
)
def post(self, request):
"""Update PO token"""
serializer = PoTokenSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
if not validated_data:
error = ErrorResponseSerializer(
{"error": "missing PO token key in request data"}
)
return Response(error.data, status=400)
config = AppConfig().config
new_token = validated_data["potoken"]
POTokenHandler(config).set_token(new_token)
return Response(serializer.data)
@extend_schema(
responses={
204: OpenApiResponse(description="PO token revoked"),
},
)
def delete(self, request):
"""delete PO token"""
config = AppConfig().config
POTokenHandler(config).revoke_token()
return Response(status=204)
class SnapshotApiListView(ApiBaseView):
"""resolves to /api/appsettings/snapshot/
GET: returns snapshot config plus list of existing snapshots
POST: take snapshot now
"""
permission_classes = [AdminOnly]
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(SnapshotListSerializer()),
}
)
def get(request):
"""get available snapshots with metadata"""
# pylint: disable=unused-argument
snapshots = ElasticSnapshot().get_snapshot_stats()
serializer = SnapshotListSerializer(snapshots)
return Response(serializer.data)
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(SnapshotCreateResponseSerializer()),
}
)
def post(request):
"""take snapshot now"""
# pylint: disable=unused-argument
response = ElasticSnapshot().take_snapshot_now()
serializer = SnapshotCreateResponseSerializer(response)
return Response(serializer.data)
class SnapshotApiView(ApiBaseView):
"""resolves to /api/appsettings/snapshot/<snapshot-id>/
GET: return a single snapshot
POST: restore snapshot
DELETE: delete a snapshot
"""
permission_classes = [AdminOnly]
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(SnapshotItemSerializer()),
404: OpenApiResponse(
ErrorResponseSerializer(), description="snapshot not found"
),
}
)
def get(request, snapshot_id):
"""handle get request"""
# pylint: disable=unused-argument
snapshot = ElasticSnapshot().get_single_snapshot(snapshot_id)
if not snapshot:
error = ErrorResponseSerializer({"error": "snapshot not found"})
return Response(error.data, status=404)
serializer = SnapshotItemSerializer(snapshot)
return Response(serializer.data)
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(SnapshotRestoreResponseSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="bad request"
),
}
)
def post(request, snapshot_id):
"""restore snapshot"""
# pylint: disable=unused-argument
response = ElasticSnapshot().restore_all(snapshot_id)
if not response:
error = ErrorResponseSerializer(
{"error": "failed to restore snapshot"}
)
return Response(error.data, status=400)
serializer = SnapshotRestoreResponseSerializer(response)
return Response(serializer.data)
@staticmethod
@extend_schema(
responses={
204: OpenApiResponse(description="delete snapshot from index"),
}
)
def delete(request, snapshot_id):
"""delete snapshot from index"""
# pylint: disable=unused-argument
response = ElasticSnapshot().delete_single_snapshot(snapshot_id)
if not response:
error = ErrorResponseSerializer(
{"error": "failed to delete snapshot"}
)
return Response(error.data, status=400)
return Response(status=204)
class TokenView(ApiBaseView):
"""resolves to /api/appsettings/token/
GET: get API token
DELETE: revoke the token
"""
permission_classes = [AdminOnly]
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(TokenResponseSerializer()),
}
)
def get(request):
"""get your API token"""
token, _ = Token.objects.get_or_create(user=request.user)
serializer = TokenResponseSerializer({"token": token.key})
return Response(serializer.data)
@staticmethod
@extend_schema(
responses={
204: OpenApiResponse(description="delete token"),
}
)
def delete(request):
"""delete your API token, new will get created on next get"""
print("revoke API token")
request.user.auth_token.delete()
return Response(status=204)

View File

@@ -0,0 +1,107 @@
"""channel serializers"""
# pylint: disable=abstract-method
from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin
from rest_framework import serializers
from video.src.constants import VideoTypeEnum
class ChannelOverwriteSerializer(
ValidateUnknownFieldsMixin, serializers.Serializer
):
"""serialize channel overwrites"""
download_format = serializers.CharField(required=False, allow_null=True)
autodelete_days = serializers.IntegerField(required=False, allow_null=True)
index_playlists = serializers.BooleanField(required=False, allow_null=True)
integrate_sponsorblock = serializers.BooleanField(
required=False, allow_null=True
)
subscriptions_channel_size = serializers.IntegerField(
required=False, allow_null=True
)
subscriptions_live_channel_size = serializers.IntegerField(
required=False, allow_null=True
)
subscriptions_shorts_channel_size = serializers.IntegerField(
required=False, allow_null=True
)
class ChannelSerializer(serializers.Serializer):
"""serialize channel"""
channel_id = serializers.CharField()
channel_active = serializers.BooleanField()
channel_banner_url = serializers.CharField()
channel_thumb_url = serializers.CharField()
channel_tvart_url = serializers.CharField()
channel_description = serializers.CharField()
channel_last_refresh = serializers.CharField()
channel_name = serializers.CharField()
channel_overwrites = ChannelOverwriteSerializer(required=False)
channel_subs = serializers.IntegerField()
channel_subscribed = serializers.BooleanField()
channel_tags = serializers.ListField(
child=serializers.CharField(), required=False
)
channel_tabs = serializers.ListField(
child=serializers.ChoiceField(VideoTypeEnum.values_known())
)
channel_views = serializers.IntegerField()
_index = serializers.CharField(required=False)
_score = serializers.IntegerField(required=False)
class ChannelListSerializer(serializers.Serializer):
"""serialize channel list"""
data = ChannelSerializer(many=True)
paginate = PaginationSerializer()
class ChannelListQuerySerializer(serializers.Serializer):
"""serialize list query"""
filter = serializers.ChoiceField(choices=["subscribed"], required=False)
page = serializers.IntegerField(required=False)
class ChannelUpdateSerializer(serializers.Serializer):
"""update channel"""
channel_subscribed = serializers.BooleanField(required=False)
channel_overwrites = ChannelOverwriteSerializer(required=False)
class ChannelAggBucketSerializer(serializers.Serializer):
"""serialize channel agg bucket"""
value = serializers.IntegerField()
value_str = serializers.CharField(required=False)
class ChannelAggSerializer(serializers.Serializer):
"""serialize channel aggregation"""
total_items = ChannelAggBucketSerializer()
total_size = ChannelAggBucketSerializer()
total_duration = ChannelAggBucketSerializer()
class ChannelNavSerializer(serializers.Serializer):
"""serialize channel navigation"""
has_pending = serializers.BooleanField()
has_ignored = serializers.BooleanField()
has_playlists = serializers.BooleanField()
has_videos = serializers.BooleanField()
has_streams = serializers.BooleanField()
has_shorts = serializers.BooleanField()
class ChannelSearchQuerySerializer(serializers.Serializer):
"""serialize query parameters for searching"""
q = serializers.CharField()

View File

@@ -4,16 +4,17 @@ functionality:
- index and update in es
"""
import json
import os
from datetime import datetime
from home.src.download import queue # partial import
from home.src.download.thumbnails import ThumbManager
from home.src.download.yt_dlp_base import YtWrap
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.generic import YouTubeItem
from home.src.index.playlist import YoutubePlaylist
from channel.src.remote_query import get_last_channel_videos
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import rand_sleep
from common.src.index_generic import YouTubeItem
from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import YtWrap
from video.src.constants import VideoTypeEnum
class YoutubeChannel(YouTubeItem):
@@ -23,8 +24,8 @@ class YoutubeChannel(YouTubeItem):
index_name = "ta_channel"
yt_base = "https://www.youtube.com/channel/"
yt_obs = {
"extract_flat": True,
"allow_playlist_files": True,
"playlist_items": "0,0",
"skip_download": True,
}
def __init__(self, youtube_id, task=False):
@@ -32,10 +33,6 @@ class YoutubeChannel(YouTubeItem):
self.all_playlists = False
self.task = task
def build_yt_url(self):
"""overwrite base to use channel about page"""
return f"{self.yt_base}{self.youtube_id}/about"
def build_json(self, upload=False, fallback=False):
"""get from es or from youtube"""
self.get_from_es()
@@ -46,6 +43,10 @@ class YoutubeChannel(YouTubeItem):
if not self.youtube_meta and fallback:
self._video_fallback(fallback)
else:
if not self.youtube_meta:
message = f"{self.youtube_id}: Failed to get metadata"
raise ValueError(message)
self.process_youtube_meta()
self.get_channel_art()
@@ -55,30 +56,23 @@ class YoutubeChannel(YouTubeItem):
def process_youtube_meta(self):
"""extract relevant fields"""
self.youtube_meta["thumbnails"].reverse()
channel_subs = self.youtube_meta.get("channel_follower_count") or 0
channel_name = self.youtube_meta["uploader"] or self.youtube_meta["id"]
self.json_data = {
"channel_active": True,
"channel_description": self.youtube_meta.get("description", False),
"channel_description": self.youtube_meta.get("description", ""),
"channel_id": self.youtube_id,
"channel_last_refresh": int(datetime.now().timestamp()),
"channel_name": self.youtube_meta["uploader"],
"channel_subs": channel_subs,
"channel_name": channel_name,
"channel_subs": self.youtube_meta.get("channel_follower_count", 0),
"channel_subscribed": False,
"channel_tags": self._parse_tags(self.youtube_meta.get("tags")),
"channel_tags": self.youtube_meta.get("tags", []),
"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,
"channel_tabs": self.get_channel_tabs(),
}
def _parse_tags(self, tags):
"""parse channel tags"""
if not tags:
return False
joined = " ".join(tags)
return [i.strip() for i in joined.split('"') if i and not i == " "]
def _get_thumb_art(self):
"""extract thumb art"""
for i in self.youtube_meta["thumbnails"]:
@@ -112,6 +106,31 @@ class YoutubeChannel(YouTubeItem):
return False
def get_channel_tabs(self) -> list[str]:
"""get channel tabs"""
tabs = VideoTypeEnum.values_known()
config_cp = self.config.copy()
config_cp["subscriptions"] = {
"channel_size": 1,
"live_channel_size": 1,
"shorts_channel_size": 1,
}
tabs = []
for query_filter in VideoTypeEnum:
if query_filter == VideoTypeEnum.UNKNOWN:
continue
videos = get_last_channel_videos(
channel_id=self.youtube_id,
config=config_cp,
limit=True,
query_filter=query_filter,
)
if videos:
tabs.append(query_filter.value)
return tabs
def _video_fallback(self, fallback):
"""use video metadata as fallback"""
print(f"{self.youtube_id}: fallback to video metadata")
@@ -124,32 +143,11 @@ class YoutubeChannel(YouTubeItem):
"channel_tvart_url": False,
"channel_id": self.youtube_id,
"channel_subscribed": False,
"channel_tags": False,
"channel_description": False,
"channel_tags": [],
"channel_description": "",
"channel_thumb_url": False,
"channel_views": 0,
}
self._info_json_fallback()
def _info_json_fallback(self):
"""read channel info.json for additional metadata"""
info_json = os.path.join(
self.config["application"]["cache_dir"],
"import",
f"{self.youtube_id}.info.json",
)
if os.path.exists(info_json):
print(f"{self.youtube_id}: read info.json file")
with open(info_json, "r", encoding="utf-8") as f:
content = json.loads(f.read())
self.json_data.update(
{
"channel_subs": content.get("channel_follower_count", 0),
"channel_description": content.get("description", False),
}
)
os.remove(info_json)
def get_channel_art(self):
"""download channel art for new channels"""
@@ -165,8 +163,18 @@ class YoutubeChannel(YouTubeItem):
# add ingest pipeline
processors = []
for field, value in self.json_data.items():
line = {"set": {"field": "channel." + field, "value": value}}
if value is None:
line = {
"script": {
"lang": "painless",
"source": f"ctx['{field}'] = null;",
}
}
else:
line = {"set": {"field": "channel." + field, "value": value}}
processors.append(line)
data = {"description": self.youtube_id, "processors": processors}
ingest_path = f"_ingest/pipeline/{self.youtube_id}"
_, _ = ElasticWrap(ingest_path).put(data)
@@ -175,38 +183,15 @@ class YoutubeChannel(YouTubeItem):
update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}"
_, _ = ElasticWrap(update_path).post(data)
def get_folder_path(self):
"""get folder where media files get stored"""
folder_path = os.path.join(
self.app_conf["videos"],
self.json_data["channel_id"],
)
return folder_path
def change_subscribe(self, new_subscribe_state: bool):
"""change subscribe status"""
if not self.json_data:
self.build_json()
def delete_es_videos(self):
"""delete all channel documents from elasticsearch"""
data = {
"query": {
"term": {"channel.channel_id": {"value": self.youtube_id}}
}
}
_, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
def delete_es_comments(self):
"""delete all comments from this channel"""
data = {
"query": {
"term": {"comment_channel_id": {"value": self.youtube_id}}
}
}
_, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
def delete_playlists(self):
"""delete all indexed playlist from es"""
all_playlists = self.get_indexed_playlists()
for playlist in all_playlists:
playlist_id = playlist["playlist_id"]
YoutubePlaylist(playlist_id).delete_metadata()
self.json_data["channel_subscribed"] = new_subscribe_state
self.upload_to_es()
self.sync_to_videos()
return self.json_data
def delete_channel(self):
"""delete channel and all videos"""
@@ -215,23 +200,7 @@ class YoutubeChannel(YouTubeItem):
if not self.json_data:
raise FileNotFoundError
folder_path = self.get_folder_path()
print(f"{self.youtube_id}: delete all media files")
try:
all_videos = os.listdir(folder_path)
for video in all_videos:
video_path = os.path.join(folder_path, video)
os.remove(video_path)
os.rmdir(folder_path)
except FileNotFoundError:
print(f"no videos found for {folder_path}")
print(f"{self.youtube_id}: delete indexed playlists")
self.delete_playlists()
print(f"{self.youtube_id}: delete indexed videos")
self.delete_es_videos()
self.delete_es_comments()
self.del_in_es()
ChannelDelete(json_data=self.json_data).delete()
def index_channel_playlists(self):
"""add all playlists of channel to index"""
@@ -244,14 +213,29 @@ class YoutubeChannel(YouTubeItem):
print(f"{self.youtube_id}: no playlists found.")
return
all_youtube_ids = self.get_all_video_ids()
total = len(self.all_playlists)
for idx, playlist in enumerate(self.all_playlists):
if self.task:
self._notify_single_playlist(idx, total)
self._index_single_playlist(playlist, all_youtube_ids)
self._index_single_playlist(playlist)
print("add playlist: " + playlist[1])
rand_sleep(self.config)
def get_all_playlists(self):
"""get all playlists owned by this channel"""
url = (
f"https://www.youtube.com/channel/{self.youtube_id}"
+ "/playlists?view=1&sort=dd&shelf_id=0"
)
obs = {"skip_download": True, "extract_flat": True}
playlists, _ = YtWrap(obs, self.config).extract(url)
if not playlists:
self.all_playlists = []
return
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
self.all_playlists = all_entries
def _notify_single_playlist(self, idx, total):
"""send notification"""
@@ -263,32 +247,12 @@ class YoutubeChannel(YouTubeItem):
self.task.send_progress(message, progress=(idx + 1) / total)
@staticmethod
def _index_single_playlist(playlist, all_youtube_ids):
def _index_single_playlist(playlist):
"""add single playlist if needed"""
from playlist.src.index import YoutubePlaylist
playlist = YoutubePlaylist(playlist[0])
playlist.all_youtube_ids = all_youtube_ids
playlist.build_json()
if not playlist.json_data:
return
entries = playlist.json_data["playlist_entries"]
downloaded = [i for i in entries if i["downloaded"]]
if not downloaded:
return
playlist.upload_to_es()
playlist.add_vids_to_playlist()
playlist.get_playlist_art()
@staticmethod
def get_all_video_ids():
"""match all playlists with videos"""
handler = queue.PendingList()
handler.get_download()
handler.get_indexed()
all_youtube_ids = [i["youtube_id"] for i in handler.all_videos]
return all_youtube_ids
playlist.update_playlist(skip_on_empty=True)
def get_channel_videos(self):
"""get all videos from channel"""
@@ -301,18 +265,110 @@ class YoutubeChannel(YouTubeItem):
all_videos = IndexPaginate("ta_video", data).get_results()
return all_videos
def get_all_playlists(self):
"""get all playlists owned by this channel"""
url = (
f"https://www.youtube.com/channel/{self.youtube_id}"
+ "/playlists?view=1&sort=dd&shelf_id=0"
)
obs = {"skip_download": True, "extract_flat": True}
playlists = YtWrap(obs, self.config).extract(url)
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
self.all_playlists = all_entries
def get_overwrites(self) -> dict:
"""get all per channel overwrites"""
return self.json_data.get("channel_overwrites", {})
def get_indexed_playlists(self, active_only=False):
def set_overwrites(self, overwrites):
"""set per channel overwrites"""
valid_keys = [
"download_format",
"autodelete_days",
"index_playlists",
"integrate_sponsorblock",
"subscriptions_channel_size",
"subscriptions_live_channel_size",
"subscriptions_shorts_channel_size",
]
to_write = self.json_data.get("channel_overwrites", {})
for key, value in overwrites.items():
if key not in valid_keys:
raise ValueError(f"invalid overwrite key: {key}")
if value is None and key in to_write:
to_write.pop(key)
continue
to_write.update({key: value})
self.json_data["channel_overwrites"] = to_write
class ChannelDelete(YouTubeItem):
"""delete and cleanup"""
index_name = "ta_channel"
def __init__(self, json_data):
super().__init__(youtube_id=json_data["channel_id"])
self.json_data = json_data
def delete(self):
"""delete channel and all videos"""
folder_path = self._get_folder_path()
print(f"{self.youtube_id}: delete all media files")
try:
all_videos = os.listdir(folder_path)
for video in all_videos:
video_path = os.path.join(folder_path, video)
os.remove(video_path)
os.rmdir(folder_path)
except FileNotFoundError:
print(f"no videos found for {folder_path}")
print(f"{self.youtube_id}: delete indexed playlists")
self._delete_playlists()
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 _get_folder_path(self):
"""get folder where media files get stored"""
folder_path = os.path.join(
EnvironmentSettings.MEDIA_DIR,
self.json_data["channel_id"],
)
return folder_path
def _delete_es_videos(self):
"""delete all channel documents from elasticsearch"""
data = {
"query": {
"term": {"channel.channel_id": {"value": self.youtube_id}}
}
}
_, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
def _delete_es_comments(self):
"""delete all comments from this channel"""
data = {
"query": {
"term": {"comment_channel_id": {"value": self.youtube_id}}
}
}
_, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
def _delete_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"""
from playlist.src.index import YoutubePlaylist
all_playlists = self._get_indexed_playlists()
for playlist in all_playlists:
YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
def _get_indexed_playlists(self, active_only=False):
"""get all indexed playlists from channel"""
must_list = [
{"term": {"playlist_channel_id": {"value": self.youtube_id}}}
@@ -325,38 +381,6 @@ class YoutubeChannel(YouTubeItem):
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def get_overwrites(self):
"""get all per channel overwrites"""
return self.json_data.get("channel_overwrites", False)
def set_overwrites(self, overwrites):
"""set per channel overwrites"""
valid_keys = [
"download_format",
"autodelete_days",
"index_playlists",
"integrate_sponsorblock",
]
to_write = self.json_data.get("channel_overwrites", {})
for key, value in overwrites.items():
if key not in valid_keys:
raise ValueError(f"invalid overwrite key: {key}")
if value == "disable":
to_write[key] = False
continue
if value in [0, "0"]:
if key in to_write:
del to_write[key]
continue
if value == "1":
to_write[key] = True
continue
if value:
to_write.update({key: value})
self.json_data["channel_overwrites"] = to_write
def channel_overwrites(channel_id, overwrites):
"""collection to overwrite settings per channel"""
@@ -365,3 +389,5 @@ def channel_overwrites(channel_id, overwrites):
channel.set_overwrites(overwrites)
channel.upload_to_es()
channel.sync_to_videos()
return channel.json_data

View File

@@ -0,0 +1,97 @@
"""build channel nav"""
from common.src.es_connect import ElasticWrap
class ChannelNav:
"""get all nav items"""
def __init__(self, channel_id):
self.channel_id = channel_id
def get_nav(self):
"""build nav items"""
nav = {
"has_pending": self._get_has_pending(),
"has_ignored": self._get_has_ignored(),
"has_playlists": self._get_has_playlists(),
}
nav.update(self._get_vid_types())
return nav
def _get_vid_types(self):
"""get available vid_types in given channel"""
data = {
"size": 0,
"query": {
"term": {"channel.channel_id": {"value": self.channel_id}}
},
"aggs": {"unique_values": {"terms": {"field": "vid_type"}}},
}
response, _ = ElasticWrap("ta_video/_search").get(data)
buckets = response["aggregations"]["unique_values"]["buckets"]
type_nav = {
"has_videos": False,
"has_streams": False,
"has_shorts": False,
}
for bucket in buckets:
if bucket["key"] == "videos":
type_nav["has_videos"] = True
if bucket["key"] == "streams":
type_nav["has_streams"] = True
if bucket["key"] == "shorts":
type_nav["has_shorts"] = True
return type_nav
def _get_has_pending(self):
"""check if has pending videos in download queue"""
data = {
"size": 1,
"query": {
"bool": {
"must": [
{"term": {"status": {"value": "pending"}}},
{"term": {"channel_id": {"value": self.channel_id}}},
]
}
},
"_source": False,
}
response, _ = ElasticWrap("ta_download/_search").get(data=data)
return bool(response["hits"]["hits"])
def _get_has_ignored(self):
"""Check if there are ignored videos in the download queue"""
data = {
"size": 1,
"query": {
"bool": {
"must": [
{"term": {"status": {"value": "ignore"}}},
{"term": {"channel_id": {"value": self.channel_id}}},
]
}
},
"_source": False,
}
response, _ = ElasticWrap("ta_download/_search").get(data=data)
return bool(response["hits"]["hits"])
def _get_has_playlists(self):
"""check if channel has playlists"""
path = "ta_playlist/_search"
data = {
"size": 1,
"query": {
"term": {"playlist_channel_id": {"value": self.channel_id}}
},
"_source": False,
}
response, _ = ElasticWrap(path).get(data=data)
return bool(response["hits"]["hits"])

View File

@@ -0,0 +1,136 @@
"""build queries for video extraction from channel subscriptions"""
from download.src.yt_dlp_base import YtWrap
from video.src.constants import VideoTypeEnum
class VideoQueryBuilder:
"""Build queries for yt-dlp."""
def __init__(self, config: dict, channel_overwrites: dict | None = None):
self.config = config
self.channel_overwrites = channel_overwrites or {}
def build_queries(
self,
video_type: VideoTypeEnum | list[VideoTypeEnum] | None,
limit: bool = True,
) -> list[tuple[VideoTypeEnum, int | None]]:
"""Build queries for all or specific video type."""
query_methods = {
VideoTypeEnum.VIDEOS: self.videos_query,
VideoTypeEnum.STREAMS: self.streams_query,
VideoTypeEnum.SHORTS: self.shorts_query,
}
if video_type and video_type != VideoTypeEnum.UNKNOWN:
# build query for specific type/s
if not isinstance(video_type, list):
video_type = [video_type]
queries = []
for video_type_item in video_type:
query_method = query_methods.get(video_type_item)
if not query_method:
continue
query = query_method(limit)
if query[1] != 0:
queries.append(query)
return queries
# Build and return queries for all video types
queries = []
for build_query in query_methods.values():
query = build_query(limit)
if query[1] != 0:
queries.append(query)
return queries
def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for videos."""
return self._build_generic_query(
video_type=VideoTypeEnum.VIDEOS,
overwrite_key="subscriptions_channel_size",
config_key="channel_size",
limit=limit,
)
def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for streams."""
return self._build_generic_query(
video_type=VideoTypeEnum.STREAMS,
overwrite_key="subscriptions_live_channel_size",
config_key="live_channel_size",
limit=limit,
)
def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for shorts."""
return self._build_generic_query(
video_type=VideoTypeEnum.SHORTS,
overwrite_key="subscriptions_shorts_channel_size",
config_key="shorts_channel_size",
limit=limit,
)
def _build_generic_query(
self,
video_type: VideoTypeEnum,
overwrite_key: str,
config_key: str,
limit: bool,
) -> tuple[VideoTypeEnum, int | None]:
"""Generic query for video page scraping."""
app_config_size = self.config["subscriptions"].get(config_key)
if not limit or app_config_size is None:
# treat None as unlimited
return (video_type, None)
if (
overwrite_key in self.channel_overwrites
and self.channel_overwrites[overwrite_key] is not None
):
overwrite = self.channel_overwrites[overwrite_key]
return (video_type, overwrite)
if app_config_size:
return (video_type, app_config_size)
return (video_type, 0)
def get_last_channel_videos(
channel_id,
config,
limit=None,
query_filter=None,
channel_overwrites=None,
):
"""get a list of last videos from channel"""
query_handler = VideoQueryBuilder(config, channel_overwrites)
queries = query_handler.build_queries(query_filter)
last_videos = []
for vid_type_enum, limit_amount in queries:
obs = {
"skip_download": True,
"extract_flat": True,
}
vid_type = vid_type_enum.value
if limit is not None:
obs.update({"playlist_items": f":{limit_amount}:1"})
url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
channel_query, _ = YtWrap(obs, config).extract(url)
if not channel_query:
continue
for entry in channel_query["entries"]:
entry["vid_type"] = vid_type
last_videos.append(entry)
return last_videos

View File

@@ -0,0 +1,128 @@
"""test video query building"""
# pylint: disable=redefined-outer-name
from enum import Enum
import pytest
from channel.src.remote_query import VideoQueryBuilder
from video.src.constants import VideoTypeEnum
@pytest.fixture
def default_config():
"""from appsettings"""
return {
"subscriptions": {
"channel_size": 5,
"live_channel_size": 3,
"shorts_channel_size": 2,
}
}
@pytest.fixture
def empty_overwrites():
"""from channel overwrites"""
return {}
@pytest.fixture
def overwrites():
"""from channel overwrites"""
return {
"subscriptions_channel_size": 10,
"subscriptions_live_channel_size": 0,
"subscriptions_shorts_channel_size": None,
}
def test_build_all_queries_with_limit(default_config, empty_overwrites):
"""default, empty overwrite"""
builder = VideoQueryBuilder(default_config, empty_overwrites)
result = builder.build_queries(None, limit=True)
expected = [
(VideoTypeEnum.VIDEOS, 5),
(VideoTypeEnum.STREAMS, 3),
(VideoTypeEnum.SHORTS, 2),
]
assert result == expected
def test_build_all_queries_without_limit(default_config, empty_overwrites):
"""limit disabled"""
builder = VideoQueryBuilder(default_config, empty_overwrites)
result = builder.build_queries(None, limit=False)
expected = [
(VideoTypeEnum.VIDEOS, None),
(VideoTypeEnum.STREAMS, None),
(VideoTypeEnum.SHORTS, None),
]
assert result == expected
def test_build_specific_query(default_config, empty_overwrites):
"""single vid_type"""
builder = VideoQueryBuilder(default_config, empty_overwrites)
result = builder.build_queries(VideoTypeEnum.VIDEOS)
assert result == [(VideoTypeEnum.VIDEOS, 5)]
def test_build_multiple_queries(default_config, empty_overwrites):
"""vid_type list"""
builder = VideoQueryBuilder(default_config, empty_overwrites)
result = builder.build_queries(
[VideoTypeEnum.VIDEOS, VideoTypeEnum.SHORTS]
)
assert result == [(VideoTypeEnum.VIDEOS, 5), (VideoTypeEnum.SHORTS, 2)]
def test_build_unknown_queries(default_config, empty_overwrites):
"""vid_type unknown"""
builder = VideoQueryBuilder(default_config, empty_overwrites)
result = builder.build_queries(VideoTypeEnum.UNKNOWN)
assert result == [
(VideoTypeEnum.VIDEOS, 5),
(VideoTypeEnum.STREAMS, 3),
(VideoTypeEnum.SHORTS, 2),
]
def test_overwrite_applied(default_config, overwrites):
"""with overwrite from channel config"""
builder = VideoQueryBuilder(default_config, overwrites)
result = builder.build_queries(None, limit=True)
expected = [
(VideoTypeEnum.VIDEOS, 10), # Overwritten
# STREAMS is overwritten to 0, should be excluded
(VideoTypeEnum.SHORTS, 2), # None in overwrite, fallback to config
]
assert result == expected
def test_no_limit_ignores_config_and_overwrites(default_config, overwrites):
"""no limit single vid_type"""
builder = VideoQueryBuilder(default_config, overwrites)
result = builder.build_queries([VideoTypeEnum.STREAMS], limit=False)
assert result == [(VideoTypeEnum.STREAMS, None)]
def test_zero_query_not_included(default_config):
"""overwrite to zero to disable"""
overwrites = {"subscriptions_live_channel_size": 0}
builder = VideoQueryBuilder(default_config, overwrites)
result = builder.build_queries([VideoTypeEnum.STREAMS], limit=True)
assert not result # Should be skipped due to 0
def test_invalid_video_type_is_ignored(default_config):
"""invalid enum"""
builder = VideoQueryBuilder(default_config)
class FakeEnum(Enum):
"""invalid"""
INVALID = "invalid"
result = builder.build_queries([FakeEnum.INVALID], limit=True)
assert not result

32
backend/channel/urls.py Normal file
View File

@@ -0,0 +1,32 @@
"""all channel API urls"""
from channel import views
from django.urls import path
urlpatterns = [
path(
"",
views.ChannelApiListView.as_view(),
name="api-channel-list",
),
path(
"search/",
views.ChannelApiSearchView.as_view(),
name="api-channel-search",
),
path(
"<slug:channel_id>/",
views.ChannelApiView.as_view(),
name="api-channel",
),
path(
"<slug:channel_id>/aggs/",
views.ChannelAggsApiView.as_view(),
name="api-channel-aggs",
),
path(
"<slug:channel_id>/nav/",
views.ChannelNavApiView.as_view(),
name="api-channel-nav",
),
]

280
backend/channel/views.py Normal file
View File

@@ -0,0 +1,280 @@
"""all channel API views"""
from channel.serializers import (
ChannelAggSerializer,
ChannelListQuerySerializer,
ChannelListSerializer,
ChannelNavSerializer,
ChannelSearchQuerySerializer,
ChannelSerializer,
ChannelUpdateSerializer,
)
from channel.src.index import YoutubeChannel, channel_overwrites
from channel.src.nav import ChannelNav
from common.serializers import ErrorResponseSerializer
from common.src.urlparser import Parser
from common.views_base import AdminWriteOnly, ApiBaseView
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
extend_schema,
)
from rest_framework.response import Response
from task.tasks import index_channel_playlists, subscribe_to
class ChannelApiListView(ApiBaseView):
"""resolves to /api/channel/
GET: returns list of channels
POST: edit a list of channels
"""
search_base = "ta_channel/_search/"
valid_filter = ["subscribed"]
permission_classes = [AdminWriteOnly]
@extend_schema(
responses={
200: OpenApiResponse(ChannelListSerializer()),
},
parameters=[ChannelListQuerySerializer()],
)
def get(self, request):
"""get request"""
self.data.update(
{"sort": [{"channel_name.keyword": {"order": "asc"}}]}
)
serializer = ChannelListQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
must_list = []
query_filter = validated_data.get("filter")
if query_filter:
must_list.append({"term": {"channel_subscribed": {"value": True}}})
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
serializer = ChannelListSerializer(self.response)
return Response(serializer.data)
def post(self, request):
"""subscribe/unsubscribe to list of channels"""
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 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")
YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=False)
class ChannelApiView(ApiBaseView):
"""resolves to /api/channel/<channel_id>/
GET: returns metadata dict of channel
"""
search_base = "ta_channel/_doc/"
permission_classes = [AdminWriteOnly]
@extend_schema(
responses={
200: OpenApiResponse(ChannelSerializer()),
404: OpenApiResponse(
ErrorResponseSerializer(), description="Channel not found"
),
}
)
def get(self, request, channel_id):
# pylint: disable=unused-argument
"""get channel detail"""
self.get_document(channel_id)
if not self.response:
error = ErrorResponseSerializer({"error": "channel not found"})
return Response(error.data, status=404)
response_serializer = ChannelSerializer(self.response)
return Response(response_serializer.data, status=self.status_code)
@extend_schema(
request=ChannelUpdateSerializer(),
responses={
200: OpenApiResponse(ChannelUpdateSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
404: OpenApiResponse(
ErrorResponseSerializer(), description="Channel not found"
),
},
)
def post(self, request, channel_id):
"""modify channel"""
self.get_document(channel_id)
if not self.response:
error = ErrorResponseSerializer({"error": "channel not found"})
return Response(error.data, status=404)
serializer = ChannelUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
subscribed = validated_data.get("channel_subscribed")
if subscribed is not None:
YoutubeChannel(channel_id).change_subscribe(
new_subscribe_state=subscribed
)
overwrites = validated_data.get("channel_overwrites")
if overwrites:
channel_overwrites(channel_id, overwrites)
if overwrites.get("index_playlists"):
index_channel_playlists.delay(channel_id)
return Response(serializer.data, status=200)
@extend_schema(
responses={
204: OpenApiResponse(description="Channel deleted"),
404: OpenApiResponse(
ErrorResponseSerializer(), description="Channel not found"
),
},
)
def delete(self, request, channel_id):
# pylint: disable=unused-argument
"""delete channel"""
try:
YoutubeChannel(channel_id).delete_channel()
return Response(status=204)
except FileNotFoundError:
pass
error = ErrorResponseSerializer({"error": "channel not found"})
return Response(error.data, status=404)
class ChannelAggsApiView(ApiBaseView):
"""resolves to /api/channel/<channel_id>/aggs/
GET: get channel aggregations
"""
search_base = "ta_video/_search"
@extend_schema(
responses={
200: OpenApiResponse(ChannelAggSerializer()),
},
)
def get(self, request, channel_id):
"""get channel aggregations"""
self.data.update(
{
"query": {
"term": {"channel.channel_id": {"value": channel_id}}
},
"aggs": {
"total_items": {"value_count": {"field": "youtube_id"}},
"total_size": {"sum": {"field": "media_size"}},
"total_duration": {"sum": {"field": "player.duration"}},
},
}
)
self.get_aggs()
serializer = ChannelAggSerializer(self.response)
return Response(serializer.data)
class ChannelNavApiView(ApiBaseView):
"""resolves to /api/channel/<channel_id>/nav/
GET: get channel nav
"""
@extend_schema(
responses={
200: OpenApiResponse(ChannelNavSerializer()),
},
)
def get(self, request, channel_id):
"""get navigation"""
nav = ChannelNav(channel_id).get_nav()
serializer = ChannelNavSerializer(nav)
return Response(serializer.data)
class ChannelApiSearchView(ApiBaseView):
"""resolves to /api/channel/search/
search for channel
"""
search_base = "ta_channel/_doc/"
@extend_schema(
responses={
200: OpenApiResponse(ChannelSerializer()),
400: OpenApiResponse(description="Bad Request"),
404: OpenApiResponse(
ErrorResponseSerializer(), description="Channel not found"
),
},
parameters=[
OpenApiParameter(
name="q",
description="Search query string",
required=True,
type=str,
),
],
)
def get(self, request):
"""search for local channel ID"""
serializer = ChannelSearchQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
query = validated_data.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):
error = ErrorResponseSerializer(
{"error": f"channel not found: {query}"}
)
return Response(error.data, status=404)
if not parsed["type"] == "channel":
error = ErrorResponseSerializer({"error": "expected channel data"})
return Response(error.data, status=400)
self.get_document(parsed["url"])
serializer = ChannelSerializer(self.response)
return Response(serializer.data, status=self.status_code)

View File

@@ -0,0 +1,143 @@
"""common serializers"""
# pylint: disable=abstract-method
from rest_framework import serializers
class ValidateUnknownFieldsMixin:
"""
Mixin to validate and reject unknown fields in a serializer.
"""
def to_internal_value(self, data):
"""check expected keys"""
allowed_fields = set(self.fields.keys())
input_fields = set(data.keys())
# Find unknown fields
unknown_fields = input_fields - allowed_fields
if unknown_fields:
raise serializers.ValidationError(
{"error": f"Unknown fields: {', '.join(unknown_fields)}"}
)
return super().to_internal_value(data)
class ErrorResponseSerializer(serializers.Serializer):
"""error message"""
error = serializers.CharField()
class PaginationSerializer(serializers.Serializer):
"""serialize paginate response"""
page_size = serializers.IntegerField()
page_from = serializers.IntegerField()
prev_pages = serializers.ListField(
child=serializers.IntegerField(), allow_null=True
)
current_page = serializers.IntegerField()
max_hits = serializers.BooleanField()
params = serializers.CharField()
last_page = serializers.IntegerField()
next_pages = serializers.ListField(
child=serializers.IntegerField(), allow_null=True
)
total_hits = serializers.IntegerField()
class AsyncTaskResponseSerializer(serializers.Serializer):
"""serialize new async task"""
message = serializers.CharField(required=False)
task_id = serializers.CharField()
status = serializers.CharField(required=False)
filename = serializers.CharField(required=False)
class NotificationSerializer(serializers.Serializer):
"""serialize notification messages"""
id = serializers.CharField()
title = serializers.CharField()
group = serializers.CharField()
api_start = serializers.BooleanField()
api_stop = serializers.BooleanField()
level = serializers.ChoiceField(choices=["info", "error"])
messages = serializers.ListField(child=serializers.CharField())
progress = serializers.FloatField(required=False)
command = serializers.ChoiceField(choices=["STOP", "KILL"], required=False)
class NotificationQueryFilterSerializer(serializers.Serializer):
"""serialize notification query filter"""
filter = serializers.ChoiceField(
choices=["download", "settings", "channel"], required=False
)
class PingUpdateSerializer(serializers.Serializer):
"""serialize update notification"""
status = serializers.BooleanField()
version = serializers.CharField()
is_breaking = serializers.BooleanField()
class PingSerializer(serializers.Serializer):
"""serialize ping response"""
response = serializers.ChoiceField(choices=["pong"])
user = serializers.IntegerField()
version = serializers.CharField()
ta_update = PingUpdateSerializer(required=False)
class WatchedDataSerializer(serializers.Serializer):
"""mark as watched serializer"""
id = serializers.CharField()
is_watched = serializers.BooleanField()
class RefreshQuerySerializer(serializers.Serializer):
"""refresh query filtering"""
type = serializers.ChoiceField(
choices=["video", "channel", "playlist"], required=False
)
id = serializers.CharField(required=False)
class RefreshResponseSerializer(serializers.Serializer):
"""serialize refresh response"""
state = serializers.ChoiceField(
choices=["running", "queued", "empty", False]
)
total_queued = serializers.IntegerField()
in_queue_name = serializers.CharField(required=False)
class RefreshAddQuerySerializer(serializers.Serializer):
"""serialize add to refresh queue"""
extract_videos = serializers.BooleanField(required=False)
class RefreshAddDataSerializer(serializers.Serializer):
"""add to refresh queue serializer"""
video = serializers.ListField(
child=serializers.CharField(), required=False
)
channel = serializers.ListField(
child=serializers.CharField(), required=False
)
playlist = serializers.ListField(
child=serializers.CharField(), required=False
)

View File

@@ -0,0 +1,115 @@
"""
Functionality:
- read and write application config backed by ES
- encapsulate persistence of application properties
"""
from os import environ
try:
from dotenv import load_dotenv
print("loading local dotenv")
load_dotenv(".env")
except ModuleNotFoundError:
pass
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))
DISABLE_STATIC_AUTH: bool = bool(environ.get("DISABLE_STATIC_AUTH"))
TZ: str = str(environ.get("TZ", "UTC"))
TA_PORT: int = int(environ.get("TA_PORT", False))
TA_BACKEND_PORT: int = int(environ.get("TA_BACKEND_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_CON: str = str(environ.get("REDIS_CON"))
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 get_cache_root(self):
"""get root for web server"""
if self.CACHE_DIR.startswith("/"):
return self.CACHE_DIR
return f"/{self.CACHE_DIR}"
def get_media_root(self):
"""get root for media folder"""
if self.MEDIA_DIR.startswith("/"):
return self.MEDIA_DIR
return f"/{self.MEDIA_DIR}"
def print_generic(self):
"""print generic env vars"""
print(
f"""
HOST_UID: {self.HOST_UID}
HOST_GID: {self.HOST_GID}
TZ: {self.TZ}
DISABLE_STATIC_AUTH: {self.DISABLE_STATIC_AUTH}
TA_PORT: {self.TA_PORT}
TA_BACKEND_PORT: {self.TA_BACKEND_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_CON: {self.REDIS_CON}
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

@@ -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 common.src.env_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 | str = 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)
@@ -156,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

@@ -0,0 +1,352 @@
"""
Loose collection of helper functions
- don't import AppConfig class here to avoid circular imports
"""
import json
import os
import random
import string
import subprocess
from datetime import datetime, timezone
from time import sleep
from typing import Any
from urllib.parse import urlparse
import requests
from common.src.es_connect import IndexPaginate
def ignore_filelist(filelist: list[str]) -> list[str]:
"""ignore temp files for os.listdir sanitizer"""
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:
continue
cleaned.append(file_name)
return cleaned
def randomizor(length: int) -> str:
"""generate random alpha numeric string"""
pool: str = string.digits + string.ascii_letters
return "".join(random.choice(pool) for i in range(length))
def rand_sleep(config) -> None:
"""randomized sleep based on config"""
sleep_config = config["downloads"].get("sleep_interval")
if not sleep_config:
return
secs = random.randrange(int(sleep_config * 0.5), int(sleep_config * 1.5))
sleep(secs)
def requests_headers() -> dict[str, str]:
"""build header with random user agent for requests outside of yt-dlp"""
chrome_versions = (
"90.0.4430.212",
"90.0.4430.24",
"90.0.4430.70",
"90.0.4430.72",
"90.0.4430.85",
"90.0.4430.93",
"91.0.4472.101",
"91.0.4472.106",
"91.0.4472.114",
"91.0.4472.124",
"91.0.4472.164",
"91.0.4472.19",
"91.0.4472.77",
"92.0.4515.107",
"92.0.4515.115",
"92.0.4515.131",
"92.0.4515.159",
"92.0.4515.43",
"93.0.4556.0",
"93.0.4577.15",
"93.0.4577.63",
"93.0.4577.82",
"94.0.4606.41",
"94.0.4606.54",
"94.0.4606.61",
"94.0.4606.71",
"94.0.4606.81",
"94.0.4606.85",
"95.0.4638.17",
"95.0.4638.50",
"95.0.4638.54",
"95.0.4638.69",
"95.0.4638.74",
"96.0.4664.18",
"96.0.4664.45",
"96.0.4664.55",
"96.0.4664.93",
"97.0.4692.20",
)
template = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
)
return {"User-Agent": template}
def date_parser(timestamp: int | str | None) -> str | None:
"""return formatted date string"""
if timestamp is None:
return None
if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
date_obj = date_obj.replace(tzinfo=timezone.utc)
else:
raise TypeError(f"invalid timestamp: {timestamp}")
return date_obj.isoformat()
def time_parser(timestamp: str) -> float:
"""return seconds from timestamp, false on empty"""
if not timestamp:
return False
if timestamp.isnumeric():
return int(timestamp)
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
def clear_dl_cache(cache_dir: str) -> int:
"""clear leftover files from dl cache"""
print("clear download cache")
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(download_cache_dir, cached)
os.remove(to_delete)
return len(leftover_files)
def get_mapping() -> dict:
"""read index_mapping.json and get expected mapping and settings"""
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
index_config: dict = json.load(f).get("index_config")
return index_config
def is_shorts(youtube_id: str) -> bool:
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
cookies = {"SOCS": "CAI"}
try:
response = requests.head(
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
)
except requests.exceptions.RequestException:
# assume video on error
return False
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 | float) -> str:
"""Return a human-readable duration string from seconds."""
if not seconds:
return "NA"
seconds = int(seconds)
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}")
duration_parts[0] = duration_parts[0].lstrip("0")
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] = [
"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"):
host_clean = f"http://{host_clean}"
parsed = urlparse(host_clean)
allowed_hosts.append(f"{parsed.hostname}")
cors_url = f"{parsed.scheme}://{parsed.hostname}"
if parsed.port:
cors_url = f"{cors_url}:{parsed.port}"
csrf_trusted_origins.append(cors_url)
return allowed_hosts, csrf_trusted_origins
def get_stylesheets() -> list:
"""Get all valid stylesheets from /static/css"""
stylesheets = [
"dark.css",
"light.css",
"matrix.css",
"midnight.css",
"custom.css",
]
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"
def is_missing(
to_check: str | list[str],
index_name: str = "ta_video,ta_download",
on_key: str = "youtube_id",
) -> list[str]:
"""id or list of ids that are missing from index_name"""
if isinstance(to_check, str):
to_check = [to_check]
data = {
"query": {"terms": {on_key: to_check}},
"_source": [on_key],
}
result = IndexPaginate(index_name, data=data).get_results()
existing_ids = [i[on_key] for i in result]
dl = [i for i in to_check if i not in existing_ids]
return dl
def get_channel_overwrites() -> dict[str, dict[str, Any]]:
"""get overwrites indexed my channel_id"""
data = {
"query": {
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
},
"_source": ["channel_id", "channel_overwrites"],
}
result = IndexPaginate("ta_channel", data).get_results()
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
return overwrites
def get_channels(
subscribed_only: bool, source: list[str] | None = None
) -> list[dict]:
"""get a list of all channels"""
data = {
"sort": [{"channel_name.keyword": {"order": "asc"}}],
}
if subscribed_only:
query = {"term": {"channel_subscribed": {"value": True}}}
else:
query = {"match_all": {}}
data["query"] = query # type: ignore
if source:
data["_source"] = source # type: ignore
all_channels = IndexPaginate("ta_channel", data).get_results()
return all_channels
def get_playlists(
subscribed_only: bool, source: list[str] | None = None
) -> list[dict]:
"""get list of playlists"""
data = {
"sort": [{"playlist_channel.keyword": {"order": "desc"}}],
}
must_list = [{"term": {"playlist_active": {"value": True}}}]
if subscribed_only:
must_list.append({"term": {"playlist_subscribed": {"value": True}}})
data = {"query": {"bool": {"must": must_list}}} # type: ignore
if source:
data["_source"] = source # type: ignore
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def calc_is_watched(duration: float, position: float) -> bool:
"""considered watched based on duration position"""
if not duration or duration <= 0:
return False
if duration < 60:
threshold = 0.5
elif duration > 900:
threshold = 1 - (180 / duration)
else:
threshold = 0.9
return position >= duration * threshold

View File

@@ -5,10 +5,10 @@ functionality:
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 appsettings.src.config import AppConfig
from common.src.es_connect import ElasticWrap
from download.src.yt_dlp_base import YtWrap
from user.src.user_config import UserConfig
class YouTubeItem:
@@ -17,7 +17,7 @@ class YouTubeItem:
es_path = False
index_name = ""
yt_base = ""
yt_obs = {
yt_obs: dict[str, bool | str] = {
"skip_download": True,
"noplaylist": True,
}
@@ -26,7 +26,7 @@ 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.error = None
self.youtube_meta = False
self.json_data = False
@@ -34,17 +34,24 @@ class YouTubeItem:
"""build youtube url"""
return self.yt_base + self.youtube_id
def get_from_youtube(self):
def get_from_youtube(self, obs_overwrite: dict | None = None):
"""use yt-dlp to get meta data from youtube"""
print(f"{self.youtube_id}: get metadata from youtube")
obs_request = self.yt_obs.copy()
if self.config["downloads"]["extractor_lang"]:
langs = self.config["downloads"]["extractor_lang"]
langs_list = [i.strip() for i in langs.split(",")]
obs_request["extractor_args"] = {"youtube": {"lang": langs_list}}
obs_request["extractor_args"] = {
"youtube": {"lang": langs_list}
} # type: ignore
if obs_overwrite:
obs_request.update(obs_overwrite)
url = self.build_yt_url()
self.youtube_meta = YtWrap(obs_request, self.config).extract(url)
self.youtube_meta, self.error = YtWrap(
obs_request, self.config
).extract(url)
def get_from_es(self):
"""get indexed data from elastic search"""
@@ -100,20 +107,14 @@ 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"""
page_get = self.page_get
page_from = 0
if page_get in [0, 1]:
page_from = 0
prev_pages = False
prev_pages = None
elif page_get > 1:
page_from = (page_get - 1) * self.page_size
prev_pages = [

View File

@@ -0,0 +1,221 @@
"""
Functionality:
- processing search results for frontend
- this is duplicated code from home.src.frontend.searching.SearchHandler
"""
import urllib.parse
from common.src.env_settings import EnvironmentSettings
from common.src.helper import date_parser, get_duration_str
from common.src.ta_redis import RedisArchivist
from download.src.thumbnails import ThumbManager
class SearchProcess:
"""process search results"""
def __init__(self, response, match_video_user_progress: None | int = None):
self.response = response
self.processed = False
self.position_index = self.get_user_progress(match_video_user_progress)
def process(self):
"""detect type and process"""
if "_source" in self.response.keys():
# single
self.processed = self._process_result(self.response)
elif "hits" in self.response.keys():
# multiple
self.processed = []
all_sources = self.response["hits"]["hits"]
for result in all_sources:
self.processed.append(self._process_result(result))
return self.processed
def get_user_progress(self, match_video_user_progress) -> dict | None:
"""get user video watch progress"""
if not match_video_user_progress:
return None
query = f"{match_video_user_progress}:progress:*"
all_positions = RedisArchivist().list_items(query)
if not all_positions:
return None
pos_index = {
i["youtube_id"]: i["position"]
for i in all_positions
if not i.get("watched")
}
return pos_index
def _process_result(self, result):
"""detect which type of data to process"""
index = result["_index"]
processed = False
if index == "ta_video":
processed = self._process_video(result["_source"])
if index == "ta_channel":
processed = self._process_channel(result["_source"])
if index == "ta_playlist":
processed = self._process_playlist(result["_source"])
if index == "ta_download":
processed = self._process_download(result["_source"])
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
@staticmethod
def _process_channel(channel_dict):
"""run on single channel"""
channel_id = channel_dict["channel_id"]
cache_root = EnvironmentSettings().get_cache_root()
art_base = f"{cache_root}/channels/{channel_id}"
date_str = date_parser(channel_dict["channel_last_refresh"])
channel_dict.update(
{
"channel_last_refresh": date_str,
"channel_banner_url": f"{art_base}_banner.jpg",
"channel_thumb_url": f"{art_base}_thumb.jpg",
"channel_tvart_url": f"{art_base}_tvart.jpg",
}
)
return dict(sorted(channel_dict.items()))
def _process_video(self, video_dict):
"""run on single video dict"""
video_id = video_dict["youtube_id"]
media_url = urllib.parse.quote(video_dict["media_url"])
vid_last_refresh = date_parser(video_dict["vid_last_refresh"])
published = date_parser(video_dict["published"])
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
channel = self._process_channel(video_dict["channel"])
cache_root = EnvironmentSettings().get_cache_root()
media_root = EnvironmentSettings().get_media_root()
if "subtitles" in video_dict:
for idx, _ in enumerate(video_dict["subtitles"]):
url = video_dict["subtitles"][idx]["media_url"]
video_dict["subtitles"][idx][
"media_url"
] = f"{media_root}/{url}"
else:
video_dict["subtitles"] = []
video_dict.update(
{
"channel": channel,
"media_url": f"{media_root}/{media_url}",
"vid_last_refresh": vid_last_refresh,
"published": published,
"vid_thumb_url": f"{cache_root}/{vid_thumb_url}",
}
)
if self.position_index:
player_position = self.position_index.get(video_id)
total = video_dict["player"].get("duration")
if player_position and total:
progress = 100 * (player_position / total)
video_dict["player"].update(
{
"progress": progress,
"position": player_position,
}
)
if "playlist" not in video_dict:
video_dict["playlist"] = []
return dict(sorted(video_dict.items()))
@staticmethod
def _process_playlist(playlist_dict):
"""run on single playlist dict"""
playlist_id = playlist_dict["playlist_id"]
playlist_last_refresh = date_parser(
playlist_dict["playlist_last_refresh"]
)
cache_root = EnvironmentSettings().get_cache_root()
playlist_thumbnail = f"{cache_root}/playlists/{playlist_id}.jpg"
playlist_dict.update(
{
"playlist_thumbnail": playlist_thumbnail,
"playlist_last_refresh": playlist_last_refresh,
}
)
return dict(sorted(playlist_dict.items()))
def _process_download(self, download_dict):
"""run on single download item"""
vid_thumb_url = None
if download_dict.get("vid_thumb_url"):
video_id = download_dict["youtube_id"]
cache_root = EnvironmentSettings().get_cache_root()
relative_path = ThumbManager(video_id).vid_thumb_path()
vid_thumb_url = f"{cache_root}/{relative_path}"
download_dict.update(
{
"vid_thumb_url": vid_thumb_url,
"published": date_parser(download_dict["published"]),
}
)
return dict(sorted(download_dict.items()))
def _process_comment(self, comment_dict):
"""run on all comments, create reply thread"""
all_comments = comment_dict["comment_comments"]
processed_comments = []
for comment in all_comments:
if comment["comment_parent"] == "root":
comment.update({"comment_replies": []})
processed_comments.append(comment)
else:
processed_comments[-1]["comment_replies"].append(comment)
return processed_comments
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

@@ -6,116 +6,18 @@ Functionality:
- calculate pagination values
"""
import urllib.parse
from datetime import datetime
from home.src.download.thumbnails import ThumbManager
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
from common.src.es_connect import ElasticWrap
from common.src.search_processor import SearchProcess
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

@@ -6,20 +6,20 @@ functionality:
"""
import json
import os
import redis
from common.src.env_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.from_url(
url=EnvironmentSettings.REDIS_CON, decode_responses=True
)
class RedisArchivist(RedisBase):
@@ -38,15 +38,15 @@ class RedisArchivist(RedisBase):
def set_message(
self,
key: str,
message: dict,
path: str = ".",
message: dict | str,
expire: bool | int = False,
save: bool = False,
) -> None:
"""write new message to redis"""
self.conn.execute_command(
"JSON.SET", self.NAME_SPACE + key, path, json.dumps(message)
to_write = (
json.dumps(message) if isinstance(message, dict) else message
)
self.conn.execute_command("SET", self.NAME_SPACE + key, to_write)
if expire:
if isinstance(expire, bool):
@@ -65,8 +65,24 @@ class RedisArchivist(RedisBase):
except redis.exceptions.ResponseError:
pass
def get_message(self, key: str) -> dict:
"""get message dict from redis"""
def get_message_str(self, key: str) -> str | None:
"""get message string"""
reply = self.conn.execute_command("GET", self.NAME_SPACE + key)
return reply
def get_message_dict(self, key: str) -> dict:
"""get message dict"""
reply = self.conn.execute_command("GET", self.NAME_SPACE + key)
if not reply:
return {}
return json.loads(reply)
def get_message(self, key: str) -> dict | None:
"""
get message dict from redis
old json get message, only used for migration, to be removed later
"""
reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key)
if reply:
return json.loads(reply)
@@ -81,7 +97,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"""
@@ -89,74 +105,102 @@ class RedisArchivist(RedisBase):
if not all_matches:
return []
return [self.get_message(i) for i in all_matches]
return [self.get_message_dict(i) for i in all_matches]
def del_message(self, key: str) -> bool:
def del_message(self, key: str, save: bool = False) -> bool:
"""delete key from redis"""
response = self.conn.execute_command("DEL", self.NAME_SPACE + key)
if save:
self.bg_save()
return response
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
queue names in use:
download:channel channels during download
download:playlist:full playlists during dl for full refresh
download:playlist:quick playlists during dl for quick refresh
download:video videos during downloads
index:comment videos needing comment indexing
reindex:ta_video reindex videos
reindex:ta_channel reindex channels
reindex:ta_playlist reindex playlists
"""
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(self, to_add: str) -> None:
"""add single item to queue"""
if not to_add:
return
next_score = self._get_next_score()
self.conn.zadd(self.key, {to_add: next_score})
def add_list(self, to_add: list) -> None:
"""add list to queue"""
self.conn.execute_command("RPUSH", self.key, *to_add)
if not to_add:
return
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)
next_score = self._get_next_score()
mapping = {i[1]: next_score + i[0] for i in enumerate(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)
def max_score(self) -> int | None:
"""get max score"""
last = self.conn.zrange(self.key, -1, -1, withscores=True)
if not last:
return None
return int(last[0][1])
def _get_next_score(self) -> float:
"""get next score in queue to append"""
last = self.conn.zrange(self.key, -1, -1, withscores=True)
if not last:
return 1.0
return last[0][1] + 1
def get_next(self) -> tuple[str | None, int | None]:
"""return next element in the queue, if available"""
result = self.conn.zpopmin(self.key)
if not result:
return False
return None, None
next_element = result.decode()
return next_element
item, idx = result[0][0], int(result[0][1])
return item, idx
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):
@@ -169,7 +213,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"""
@@ -177,7 +221,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

@@ -4,21 +4,36 @@ Functionality:
- identify vid_type if possible
"""
from typing import Literal, NotRequired, TypedDict
from urllib.parse import parse_qs, urlparse
from home.src.download.yt_dlp_base import YtWrap
from home.src.index.video_constants import VideoTypeEnum
from common.src.ta_redis import RedisArchivist
from download.src.yt_dlp_base import YtWrap
from video.src.constants import VideoTypeEnum
class ParsedURLType(TypedDict):
"""represents single parsed url"""
type: Literal["video", "channel", "playlist"]
url: str
vid_type: VideoTypeEnum
limit: NotRequired[int | None]
class Parser:
"""take a multi line string and detect valid youtube ids"""
"""
take a multi line string and detect valid youtube ids
channel handle lookup is cached, can be disabled for unittests
"""
def __init__(self, url_str):
def __init__(self, url_str: str, use_cache: bool = True):
self.url_list = [i.strip() for i in url_str.split()]
self.use_cache = use_cache
def parse(self):
def parse(self) -> list[ParsedURLType]:
"""parse the list"""
ids = []
ids: list[ParsedURLType] = []
for url in self.url_list:
parsed = urlparse(url)
if parsed.netloc:
@@ -42,6 +57,10 @@ class Parser:
youtube_id = parsed.path.strip("/")
return self._validate_expected(youtube_id, "video")
if "youtube.com" not in parsed.netloc:
message = f"invalid domain: {parsed.netloc}"
raise ValueError(message)
query_parsed = parse_qs(parsed.query)
if "v" in query_parsed:
# video from v query str
@@ -63,6 +82,9 @@ class Parser:
if all_paths[0] == "channel":
return self._validate_expected(all_paths[1], "channel")
if all_paths[0] == "live":
return self._validate_expected(all_paths[1], "video")
# detect channel
channel_id = self._extract_channel_name(parsed.geturl())
return {"type": "channel", "url": channel_id}
@@ -92,25 +114,35 @@ 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}")
return {"type": item_type, "url": id_str}
@staticmethod
def _extract_channel_name(url):
"""find channel id from channel name with yt-dlp help"""
def _extract_channel_name(self, url):
"""find channel id from channel name with yt-dlp help, cache result"""
if self.use_cache:
cached = self._get_cached(url)
if cached:
return cached
obs_request = {
"check_formats": None,
"skip_download": True,
"extract_flat": True,
"playlistend": 0,
}
url_info = YtWrap(obs_request).extract(url)
url_info, error = YtWrap(obs_request).extract(url)
if not url_info:
raise ValueError(f"failed to retrieve URL: {error}")
channel_id = url_info.get("channel_id", False)
if channel_id:
if self.use_cache:
self._set_cache(url, channel_id)
return channel_id
url = url_info.get("url", False)
@@ -123,6 +155,42 @@ class Parser:
print(f"failed to extract channel id from {url}")
raise ValueError
@staticmethod
def _get_cached(url) -> str | None:
"""get cached channel ID, if available"""
path = urlparse(url).path.lstrip("/")
if not path.startswith("@"):
return None
handle = path.split("/")[0]
if not handle:
return None
cache_key = f"channel:handlesearch:{handle.lower()}"
cached = RedisArchivist().get_message_dict(cache_key)
if cached:
return cached["channel_id"]
return None
@staticmethod
def _set_cache(url, channel_id) -> None:
"""set cache"""
path = urlparse(url).path.lstrip("/")
if not path.startswith("@"):
return
handle = path.split("/")[0]
if not handle:
return
cache_key = f"channel:handlesearch:{handle.lower()}"
message = {
"channel_id": channel_id,
"handle": handle,
}
RedisArchivist().set_message(cache_key, message, expire=3600 * 24 * 7)
def _detect_vid_type(self, path):
"""try to match enum from path, needs to be serializable"""
last = path.strip("/").split("/")[-1]

View File

@@ -5,16 +5,18 @@ functionality:
from datetime import datetime
from home.src.es.connect import ElasticWrap
from home.src.ta.urlparser import Parser
from common.src.es_connect import ElasticWrap
from common.src.ta_redis import RedisArchivist
from common.src.urlparser import Parser
class WatchState:
"""handle watched checkbox for videos and channels"""
def __init__(self, youtube_id, is_watched):
def __init__(self, youtube_id: str, is_watched: bool, user_id: int):
self.youtube_id = youtube_id
self.is_watched = is_watched
self.user_id = user_id
self.stamp = int(datetime.now().timestamp())
self.pipeline = f"_ingest/pipeline/watch_{youtube_id}"
@@ -26,6 +28,11 @@ class WatchState:
self.change_vid_state()
return
if url_type == "channel":
self.reset_channel_progress()
if url_type == "playlist":
self.reset_playlist_progress()
self._add_pipeline()
path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}"
data = self._build_update_data(url_type)
@@ -41,19 +48,41 @@ class WatchState:
def change_vid_state(self):
"""change watched state of video"""
path = f"ta_video/_update/{self.youtube_id}"
data = {
"doc": {
"player": {
"watched": self.is_watched,
"watched_date": self.stamp,
}
}
}
data = {"doc": {"player": {"watched": self.is_watched}}}
if self.is_watched:
data["doc"]["player"]["watched_date"] = self.stamp
response, status_code = ElasticWrap(path).post(data=data)
key = f"{self.user_id}:progress:{self.youtube_id}"
RedisArchivist().del_message(key)
if status_code != 200:
print(response)
raise ValueError("failed to mark video as watched")
def reset_channel_progress(self):
"""reset channel progress positions"""
from channel.src.index import YoutubeChannel
videos = YoutubeChannel(self.youtube_id).get_channel_videos()
video_ids = [i["youtube_id"] for i in videos]
self._reset_list(video_ids)
def reset_playlist_progress(self):
"""reset playlist progress positions"""
from playlist.src.index import YoutubePlaylist
videos = YoutubePlaylist(self.youtube_id).get_playlist_videos()
video_ids = [i["youtube_id"] for i in videos]
self._reset_list(video_ids)
def _reset_list(self, video_ids: list[str]):
"""reset list of video ids"""
redis_con = RedisArchivist()
all_ids = redis_con.list_keys(f"{self.user_id}:progress")
for progress_id in all_ids:
video_id = progress_id.split(":")[-1]
if video_id in video_ids:
redis_con.del_message(progress_id)
def _build_update_data(self, url_type):
"""build update by query data based on url_type"""
term_key_map = {

View File

@@ -0,0 +1,11 @@
"""test configs"""
import os
import pytest
@pytest.fixture(scope="session", autouse=True)
def change_test_dir(request):
"""change directory to project folder"""
os.chdir(request.config.rootdir / "backend")

View File

@@ -0,0 +1,113 @@
"""tests for helper functions"""
import pytest
from common.src.helper import (
date_parser,
get_duration_str,
get_mapping,
is_shorts,
randomizor,
time_parser,
)
def test_randomizor_with_positive_length():
"""test randomizer"""
length = 10
result = randomizor(length)
assert len(result) == length
assert result.isalnum()
def test_date_parser_with_int():
"""unix timestamp"""
timestamp = 1621539600
expected_date = "2021-05-20T19:40:00+00:00"
assert date_parser(timestamp) == expected_date
def test_date_parser_with_str():
"""iso timestamp"""
date_str = "2021-05-21"
expected_date = "2021-05-21T00:00:00+00:00"
assert date_parser(date_str) == expected_date
def test_date_parser_with_invalid_input():
"""invalid type"""
invalid_input = [1621539600]
with pytest.raises(TypeError):
date_parser(invalid_input)
def test_date_parser_with_invalid_string_format():
"""invalid date string"""
invalid_date_str = "21/05/2021"
with pytest.raises(ValueError):
date_parser(invalid_date_str)
def test_time_parser_with_numeric_string():
"""as number"""
timestamp = "100"
expected_seconds = 100
assert time_parser(timestamp) == expected_seconds
def test_time_parser_with_hh_mm_ss_format():
"""to seconds"""
timestamp = "01:00:00"
expected_seconds = 3600.0
assert time_parser(timestamp) == expected_seconds
def test_time_parser_with_empty_string():
"""handle empty"""
timestamp = ""
assert time_parser(timestamp) is False
def test_time_parser_with_invalid_format():
"""not enough to unpack"""
timestamp = "01:00"
with pytest.raises(ValueError):
time_parser(timestamp)
def test_time_parser_with_non_numeric_input():
"""non numeric"""
timestamp = "1a:00:00"
with pytest.raises(ValueError):
time_parser(timestamp)
def test_get_mapping():
"""test mappint"""
index_config = get_mapping()
assert isinstance(index_config, list)
assert all(isinstance(i, dict) for i in index_config)
def test_is_shorts():
"""is shorts id"""
youtube_id = "YG3-Pw3rixU"
assert is_shorts(youtube_id)
def test_is_not_shorts():
"""is not shorts id"""
youtube_id = "Ogr9kbypSNg"
assert is_shorts(youtube_id) is False
def test_get_duration_str():
"""only seconds"""
assert get_duration_str(None) == "NA"
assert get_duration_str(5) == "5s"
assert get_duration_str(10) == "10s"
assert get_duration_str(500) == "8m 20s"
assert get_duration_str(1000) == "16m 40s"
assert get_duration_str(5000) == "1h 23m 20s"
assert get_duration_str(500000) == "5d 18h 53m 20s"
assert get_duration_str(5000000) == "57d 20h 53m 20s"
assert get_duration_str(50000000) == "1y 213d 16h 53m 20s"

View File

@@ -0,0 +1,145 @@
"""tests for url parser"""
import pytest
from common.src.urlparser import Parser
# video id parsing
VIDEO_URL_IN = [
"7DKv5H5Frt0",
"https://www.youtube.com/watch?v=7DKv5H5Frt0",
"https://www.youtube.com/watch?v=7DKv5H5Frt0&t=113&feature=shared",
"https://www.youtube.com/watch?v=7DKv5H5Frt0&list=PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5&index=1&pp=iAQB" # noqa: E501
"https://youtu.be/7DKv5H5Frt0",
"https://www.youtube.com/live/7DKv5H5Frt0",
]
VIDEO_OUT = [{"type": "video", "url": "7DKv5H5Frt0", "vid_type": "unknown"}]
VIDEO_TEST_CASES = [(i, VIDEO_OUT) for i in VIDEO_URL_IN]
# shorts id parsing
SHORTS_URL_IN = [
"https://www.youtube.com/shorts/YG3-Pw3rixU",
"https://youtube.com/shorts/YG3-Pw3rixU?feature=shared",
]
SHORTS_OUT = [{"type": "video", "url": "YG3-Pw3rixU", "vid_type": "shorts"}]
SHORTS_TEST_CASES = [(i, SHORTS_OUT) for i in SHORTS_URL_IN]
# channel id parsing
CHANNEL_URL_IN = [
"UCBa659QWEk1AI4Tg--mrJ2A",
"@TomScottGo",
"https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A",
"https://www.youtube.com/@TomScottGo",
]
CHANNEL_OUT = [
{
"type": "channel",
"url": "UCBa659QWEk1AI4Tg--mrJ2A",
"vid_type": "unknown",
}
]
CHANNEL_TEST_CASES = [(i, CHANNEL_OUT) for i in CHANNEL_URL_IN]
# channel vid type parsing
CHANNEL_VID_TYPES = [
(
"https://www.youtube.com/@IBRACORP/videos",
[
{
"type": "channel",
"url": "UC7aW7chIafJG6ECYAd3N5uQ",
"vid_type": "videos",
}
],
),
(
"https://www.youtube.com/@IBRACORP/shorts",
[
{
"type": "channel",
"url": "UC7aW7chIafJG6ECYAd3N5uQ",
"vid_type": "shorts",
}
],
),
(
"https://www.youtube.com/@IBRACORP/streams",
[
{
"type": "channel",
"url": "UC7aW7chIafJG6ECYAd3N5uQ",
"vid_type": "streams",
}
],
),
]
# playlist id parsing
PLAYLIST_URL_IN = [
"PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5",
"https://www.youtube.com/playlist?list=PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5",
]
PLAYLIST_OUT = [
{
"type": "playlist",
"url": "PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5",
"vid_type": "unknown",
}
]
PLAYLIST_TEST_CASES = [(i, PLAYLIST_OUT) for i in PLAYLIST_URL_IN]
# personal playlists
EXPECTED_WL = [{"type": "playlist", "url": "WL", "vid_type": "unknown"}]
EXPECTED_LL = [{"type": "playlist", "url": "LL", "vid_type": "unknown"}]
PERSONAL_PLAYLISTS_TEST_CASES = [
("WL", EXPECTED_WL),
("https://www.youtube.com/playlist?list=WL", EXPECTED_WL),
("LL", EXPECTED_LL),
("https://www.youtube.com/playlist?list=LL", EXPECTED_LL),
]
# collect tests expected to pass
PASSTING_TESTS = []
PASSTING_TESTS.extend(VIDEO_TEST_CASES)
PASSTING_TESTS.extend(SHORTS_TEST_CASES)
PASSTING_TESTS.extend(CHANNEL_TEST_CASES)
PASSTING_TESTS.extend(CHANNEL_VID_TYPES)
PASSTING_TESTS.extend(PLAYLIST_TEST_CASES)
PASSTING_TESTS.extend(PERSONAL_PLAYLISTS_TEST_CASES)
@pytest.mark.parametrize("url_str, expected_result", PASSTING_TESTS)
def test_passing_parse(url_str, expected_result):
"""test parser"""
parser = Parser(url_str, use_cache=False)
parsed = parser.parse()
assert parsed == expected_result
INVALID_IDS_ERRORS = [
"aaaaa",
"https://www.youtube.com/playlist?list=AAAA",
"https://www.youtube.com/channel/UC9-y-6csu5WGm29I7Jiwpn",
"https://www.youtube.com/watch?v=CK3_zarXkw",
]
@pytest.mark.parametrize("invalid_value", INVALID_IDS_ERRORS)
def test_invalid_ids(invalid_value):
"""test for invalid IDs"""
with pytest.raises(ValueError, match="not a valid id_str"):
parser = Parser(invalid_value, use_cache=False)
parser.parse()
INVALID_DOMAINS = [
"https://vimeo.com/32001208",
"https://peertube.tv/w/8RiJE2j2nw569FVgPNjDt7",
]
@pytest.mark.parametrize("invalid_value", INVALID_DOMAINS)
def test_invalid_domains(invalid_value):
"""raise error on none YT domains"""
parser = Parser(invalid_value, use_cache=False)
with pytest.raises(ValueError, match="invalid domain"):
parser.parse()

33
backend/common/urls.py Normal file
View File

@@ -0,0 +1,33 @@
"""all api urls"""
from common import views
from django.urls import path
urlpatterns = [
path("ping/", views.PingView.as_view(), name="ping"),
path(
"refresh/",
views.RefreshView.as_view(),
name="api-refresh",
),
path(
"watched/",
views.WatchedView.as_view(),
name="api-watched",
),
path(
"search/",
views.SearchView.as_view(),
name="api-search",
),
path(
"notification/",
views.NotificationView.as_view(),
name="api-notification",
),
path(
"health/",
views.HealthCheck.as_view(),
name="api-health",
),
]

210
backend/common/views.py Normal file
View File

@@ -0,0 +1,210 @@
"""all API views"""
from appsettings.src.config import ReleaseVersion
from appsettings.src.reindex import ReindexProgress
from common.serializers import (
AsyncTaskResponseSerializer,
ErrorResponseSerializer,
NotificationQueryFilterSerializer,
NotificationSerializer,
PingSerializer,
RefreshAddDataSerializer,
RefreshAddQuerySerializer,
RefreshQuerySerializer,
RefreshResponseSerializer,
WatchedDataSerializer,
)
from common.src.searching import SearchForm
from common.src.ta_redis import RedisArchivist
from common.src.watched import WatchState
from common.views_base import AdminOnly, ApiBaseView
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.response import Response
from rest_framework.views import APIView
from task.tasks import check_reindex
class PingView(ApiBaseView):
"""resolves to /api/ping/
GET: test your connection
"""
@staticmethod
@extend_schema(
responses={200: OpenApiResponse(PingSerializer())},
)
def get(request):
"""get pong"""
data = {
"response": "pong",
"user": request.user.id,
"version": ReleaseVersion().get_local_version(),
"ta_update": ReleaseVersion().get_update(),
}
serializer = PingSerializer(data)
return Response(serializer.data)
class RefreshView(ApiBaseView):
"""resolves to /api/refresh/
GET: get refresh progress
POST: start a manual refresh task
"""
permission_classes = [AdminOnly]
@extend_schema(
responses={
200: OpenApiResponse(RefreshResponseSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
},
parameters=[RefreshQuerySerializer()],
)
def get(self, request):
"""get refresh status"""
query_serializer = RefreshQuerySerializer(data=request.query_params)
query_serializer.is_valid(raise_exception=True)
validated_query = query_serializer.validated_data
request_type = validated_query.get("type")
request_id = validated_query.get("id")
if request_id and not request_type:
error = ErrorResponseSerializer(
{"error": "specified id also needs type"}
)
return Response(error.data, status=400)
try:
progress = ReindexProgress(
request_type=request_type, request_id=request_id
).get_progress()
except ValueError:
error = ErrorResponseSerializer({"error": "bad request"})
return Response(error.data, status=400)
response_serializer = RefreshResponseSerializer(progress)
return Response(response_serializer.data)
@extend_schema(
request=RefreshAddDataSerializer(),
responses={
200: OpenApiResponse(AsyncTaskResponseSerializer()),
},
parameters=[RefreshAddQuerySerializer()],
)
def post(self, request):
"""add to reindex queue"""
query_serializer = RefreshAddQuerySerializer(data=request.query_params)
query_serializer.is_valid(raise_exception=True)
validated_query = query_serializer.validated_data
data_serializer = RefreshAddDataSerializer(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
extract_videos = validated_query.get("extract_videos")
task = check_reindex.delay(
data=validated_data, extract_videos=extract_videos
)
message = {
"message": "reindex task started",
"task_id": task.id,
}
serializer = AsyncTaskResponseSerializer(message)
return Response(serializer.data)
class WatchedView(ApiBaseView):
"""resolves to /api/watched/
POST: change watched state of video, channel or playlist
"""
@extend_schema(
request=WatchedDataSerializer(),
responses={
200: OpenApiResponse(WatchedDataSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
},
)
def post(self, request):
"""change watched state"""
data_serializer = WatchedDataSerializer(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
youtube_id = validated_data.get("id")
is_watched = validated_data.get("is_watched")
if not youtube_id or is_watched is None:
error = ErrorResponseSerializer(
{"error": "missing id or is_watched"}
)
return Response(error.data, status=400)
WatchState(youtube_id, is_watched, request.user.id).change()
return Response(data_serializer.data)
class SearchView(ApiBaseView):
"""resolves to /api/search/
GET: run a search with the string in the ?query parameter
"""
@staticmethod
def get(request):
"""handle get request
search through all indexes"""
search_query = request.GET.get("query", None)
if search_query is None:
return Response(
{"message": "no search query specified"}, status=400
)
search_results = SearchForm().multi_search(search_query)
return Response(search_results)
class NotificationView(ApiBaseView):
"""resolves to /api/notification/
GET: returns a list of notifications
filter query to filter messages by group
"""
valid_filters = ["download", "settings", "channel"]
@extend_schema(
responses={
200: OpenApiResponse(NotificationSerializer(many=True)),
},
parameters=[NotificationQueryFilterSerializer],
)
def get(self, request):
"""get all notifications"""
query_serializer = NotificationQueryFilterSerializer(
data=request.query_params
)
query_serializer.is_valid(raise_exception=True)
validated_query = query_serializer.validated_data
filter_by = validated_query.get("filter")
query = "message"
if filter_by in self.valid_filters:
query = f"{query}:{filter_by}"
notifications = RedisArchivist().list_items(query)
response_serializer = NotificationSerializer(notifications, many=True)
return Response(response_serializer.data)
class HealthCheck(APIView):
"""health check view, no auth needed"""
def get(self, request):
"""health check, no auth needed"""
return Response("OK", status=200)

View File

@@ -0,0 +1,102 @@
"""base classes to inherit from"""
from common.src.es_connect import ElasticWrap
from common.src.index_generic import Pagination
from common.src.search_processor import SearchProcess, process_aggs
from rest_framework import permissions
from rest_framework.authentication import (
SessionAuthentication,
TokenAuthentication,
)
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 = [permissions.IsAuthenticated]
search_base = ""
data = ""
def __init__(self):
super().__init__()
self.response = {}
self.data = {"query": {"match_all": {}}}
self.status_code = False
self.context = False
self.pagination_handler = False
def get_document(self, document_id, progress_match=None):
"""get single document from es"""
path = f"{self.search_base}{document_id}"
response, status_code = ElasticWrap(path).get()
try:
self.response = SearchProcess(
response, match_video_user_progress=progress_match
).process()
except KeyError:
print(f"item not found: {document_id}")
self.status_code = status_code
def initiate_pagination(self, request):
"""set initial pagination values"""
self.pagination_handler = Pagination(request)
self.data.update(
{
"size": self.pagination_handler.pagination["page_size"],
"from": self.pagination_handler.pagination["page_from"],
}
)
def get_document_list(self, request, pagination=True, progress_match=None):
"""get a list of results"""
if pagination:
self.initiate_pagination(request)
es_handler = ElasticWrap(self.search_base)
response, status_code = es_handler.get(data=self.data)
self.response["data"] = SearchProcess(
response, match_video_user_progress=progress_match
).process()
if self.response["data"]:
self.status_code = status_code
else:
self.status_code = 404
if pagination and response.get("hits"):
self.pagination_handler.validate(
response["hits"]["total"]["value"]
)
self.response["paginate"] = self.pagination_handler.pagination
def get_aggs(self):
"""get aggs alone"""
self.data["size"] = 0
response, _ = ElasticWrap(self.search_base).get(data=self.data)
process_aggs(response)
self.response = response.get("aggregations")

View File

@@ -0,0 +1,6 @@
from os import environ
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")

View File

@@ -0,0 +1,111 @@
from os import environ
import ldap
from django_auth_ldap.config import LDAPSearch
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
AUTH_LDAP_BIND_DN = environ.get("TA_LDAP_BIND_DN")
AUTH_LDAP_BIND_PASSWORD = environ.get("TA_LDAP_BIND_PASSWORD")
"""
Given Names are *_technically_* different from Personal names, as people
who change their names have different given names and personal names,
and they go by personal names. Additionally, "LastName" is actually
incorrect for many cultures, such as Korea, where the
family name comes first, and the personal name comes last.
But we all know people are going to try to guess at these, so still want
to include names that people will guess, hence using first/last as well.
"""
AUTH_LDAP_USER_ATTR_MAP_USERNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_USERNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_UID")
or "uid"
)
AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_PERSONALNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_FIRSTNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_GIVENNAME")
or "givenName"
)
AUTH_LDAP_USER_ATTR_MAP_SURNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_SURNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_LASTNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_FAMILYNAME")
or "sn"
)
AUTH_LDAP_USER_ATTR_MAP_EMAIL = (
environ.get("TA_LDAP_USER_ATTR_MAP_EMAIL")
or environ.get("TA_LDAP_USER_ATTR_MAP_MAIL")
or "mail"
)
AUTH_LDAP_USER_BASE = environ.get("TA_LDAP_USER_BASE")
AUTH_LDAP_USER_FILTER = environ.get("TA_LDAP_USER_FILTER")
# pylint: disable=no-member
AUTH_LDAP_USER_SEARCH = LDAPSearch(
AUTH_LDAP_USER_BASE,
ldap.SCOPE_SUBTREE,
"(&("
+ AUTH_LDAP_USER_ATTR_MAP_USERNAME
+ "=%(user)s)"
+ AUTH_LDAP_USER_FILTER
+ ")",
)
AUTH_LDAP_USER_ATTR_MAP = {
"username": AUTH_LDAP_USER_ATTR_MAP_USERNAME,
"first_name": AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME,
"last_name": AUTH_LDAP_USER_ATTR_MAP_SURNAME,
"email": AUTH_LDAP_USER_ATTR_MAP_EMAIL,
}
if bool(environ.get("TA_LDAP_DISABLE_CERT_CHECK")):
# pylint: disable=global-at-module-level
global AUTH_LDAP_GLOBAL_OPTIONS
AUTH_LDAP_GLOBAL_OPTIONS = {
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
}
# Promote specific usernames to staff or superuser permission levels
_ldap_superuser_username_config = (
environ.get("TA_LDAP_PROMOTE_USERNAMES_TO_SUPERUSER") or ""
)
_ldap_superuser_usernames = []
if _ldap_superuser_username_config:
_ldap_superuser_usernames = [
u.strip() for u in _ldap_superuser_username_config.split(",")
]
_ldap_staff_username_config = (
environ.get("TA_LDAP_PROMOTE_USERNAMES_TO_STAFF") or ""
)
_ldap_staff_usernames = []
if _ldap_staff_username_config:
_ldap_staff_usernames = [
u.strip() for u in _ldap_staff_username_config.split(",")
]
if _ldap_staff_usernames or _ldap_superuser_usernames:
import django_auth_ldap.backend
def create_user(sender, user=None, ldap_user=None, **kwargs):
if user.ldap_username in _ldap_superuser_usernames and not (
user.is_superuser and user.is_staff
):
user.is_staff = True
user.is_superuser = True
user.save()
elif user.ldap_username in _ldap_staff_usernames and not user.is_staff:
user.is_staff = True
user.save()
django_auth_ldap.backend.populate_user.connect(create_user)

View File

@@ -0,0 +1,36 @@
"""change user password"""
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
User = get_user_model()
class Command(BaseCommand):
"""change password"""
help = "Change Password of user"
def add_arguments(self, parser):
parser.add_argument("username", type=str)
parser.add_argument("password", type=str)
def handle(self, *args, **kwargs):
"""entry point"""
username = kwargs["username"]
new_password = kwargs["password"]
self.stdout.write(f"Changing password for user '{username}'")
try:
user = User.objects.get(name=username)
except User.DoesNotExist as err:
message = f"Username '{username}' does not exist. "
message += "Available username(s) are:\n"
message += ", ".join([i.name for i in User.objects.all()])
raise CommandError(message) from err
user.set_password(new_password)
user.save()
self.stdout.write(
self.style.SUCCESS(f" ✓ updated password for user '{username}'")
)

View File

@@ -6,9 +6,10 @@ Functionality:
from time import sleep
import requests
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.ta_redis import RedisArchivist
from django.core.management.base import BaseCommand, CommandError
from home.src.es.connect import ElasticWrap
from home.src.ta.ta_redis import RedisArchivist
TOPIC = """
@@ -23,7 +24,7 @@ class Command(BaseCommand):
"""command framework"""
TIMEOUT = 120
MIN_MAJOR, MAX_MAJOR = 8, 8
MIN_MAJOR, MAX_MAJOR = 8, 9
MIN_MINOR = 0
# pylint: disable=no-member
@@ -57,7 +58,12 @@ class Command(BaseCommand):
message = " 🗙 Redis connection failed"
self.stdout.write(self.style.ERROR(f"{message}"))
RedisArchivist().exec("PING")
try:
redis_conn.execute_command("PING")
except Exception as err: # pylint: disable=broad-except
message = f" 🗙 {type(err).__name__}: {err}"
self.stdout.write(self.style.ERROR(f"{message}"))
sleep(60)
raise CommandError(message)
@@ -88,8 +94,17 @@ class Command(BaseCommand):
sleep(5)
continue
if status_code and status_code == 401:
sleep(5)
continue
if status_code and status_code == 200:
path = "_cluster/health?wait_for_status=yellow&timeout=60s"
path = (
"_cluster/health?"
"wait_for_status=yellow&"
"timeout=60s&"
"wait_for_active_shards=1"
)
_, _ = ElasticWrap(path).get(timeout=60)
self.stdout.write(
self.style.SUCCESS(" ✓ ES connection established")
@@ -132,7 +147,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")
@@ -142,8 +169,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

@@ -8,9 +8,11 @@ Functionality:
import os
import re
from time import sleep
from common.src.env_settings import EnvironmentSettings
from django.core.management.base import BaseCommand, CommandError
from home.models import Account
from user.models import Account
LOGO = """
@@ -59,9 +61,14 @@ EXPECTED_ENV_VARS = [
"ES_URL",
"TA_HOST",
]
UNEXPECTED_ENV_VARS = {
"TA_UWSGI_PORT": "Has been replaced with 'TA_BACKEND_PORT'",
"REDIS_HOST": "Has been replaced with 'REDIS_CON' connection string",
"REDIS_PORT": "Has been consolidated in 'REDIS_CON' connection string",
"ENABLE_CAST": "That is now a toggle in setting and DISABLE_STATIC_AUTH",
}
INST = "https://github.com/tubearchivist/tubearchivist#installing-and-updating"
NGINX = "/etc/nginx/sites-available/default"
UWSGI = "/app/uwsgi.ini"
class Command(BaseCommand):
@@ -74,13 +81,59 @@ class Command(BaseCommand):
"""run all commands"""
self.stdout.write(LOGO)
self.stdout.write(TOPIC)
self._additional_auth_vars_expectations()
self._expected_vars()
self._unexpected_vars()
self._elastic_user_overwrite()
self._ta_port_overwrite()
self._ta_uwsgi_overwrite()
self._enable_cast_overwrite()
self._ta_backend_port_overwrite()
self._disable_static_auth()
self._create_superuser()
def _additional_auth_vars_expectations(self):
"""conditionally add additional expectations for auth modes"""
ldap_required_env = [
"TA_LDAP_SERVER_URI",
"TA_LDAP_BIND_DN",
"TA_LDAP_BIND_PASSWORD",
"TA_LDAP_USER_BASE",
"TA_LDAP_USER_FILTER",
]
_login_auth_mode = (
os.environ.get("TA_LOGIN_AUTH_MODE") or "single"
).casefold()
if _login_auth_mode == "local":
UNEXPECTED_ENV_VARS["TA_LDAP"] = (
"TA_LDAP is not valid with current auth mode"
)
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
)
elif _login_auth_mode == "ldap":
EXPECTED_ENV_VARS.extend(ldap_required_env)
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
)
elif _login_auth_mode == "forwardauth":
UNEXPECTED_ENV_VARS["TA_LDAP"] = (
"TA_LDAP is not valid with current auth mode"
)
elif _login_auth_mode == "ldap_local":
EXPECTED_ENV_VARS.extend(ldap_required_env)
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
)
else:
if bool(os.environ.get("TA_LDAP")):
EXPECTED_ENV_VARS.extend(ldap_required_env)
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
)
if bool(os.environ.get("TA_ENABLE_AUTH_PROXY")):
UNEXPECTED_ENV_VARS["TA_LDAP"] = (
"TA_LDAP is not valid with current auth mode"
)
def _expected_vars(self):
"""check if expected env vars are set"""
self.stdout.write("[1] checking expected env vars")
@@ -89,25 +142,42 @@ class Command(BaseCommand):
if not env.get(var):
message = f" 🗙 expected env var {var} not set\n {INST}"
self.stdout.write(self.style.ERROR(message))
sleep(60)
raise CommandError(message)
message = " ✓ all expected env vars are set"
self.stdout.write(self.style.SUCCESS(message))
def _unexpected_vars(self):
"""check for unexpected env vars"""
self.stdout.write("[2] checking for unexpected env vars")
for var, message in UNEXPECTED_ENV_VARS.items():
if not os.environ.get(var):
continue
message = (
f" 🗙 unexpected env var {var} found\n"
f" {message} \n"
" see release notes for a list of all changes."
)
self.stdout.write(self.style.ERROR(message))
sleep(60)
raise CommandError(message)
message = " ✓ no unexpected env vars found"
self.stdout.write(self.style.SUCCESS(message))
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("[3] 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")
self.stdout.write("[4] check TA_PORT overwrite")
overwrite = EnvironmentSettings.TA_PORT
if not overwrite:
self.stdout.write(self.style.SUCCESS(" TA_PORT is not set"))
return
@@ -122,60 +192,57 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(message))
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")
def _ta_backend_port_overwrite(self):
"""set TA_BACKEND_PORT overwrite"""
self.stdout.write("[5] check TA_BACKEND_PORT overwrite")
overwrite = EnvironmentSettings.TA_BACKEND_PORT
if not overwrite:
message = " TA_UWSGI_PORT is not set"
message = " TA_BACKEND_PORT is not set"
self.stdout.write(self.style.SUCCESS(message))
return
# nginx
regex = re.compile(r"uwsgi_pass localhost:[0-9]{1,5}")
to_overwrite = f"uwsgi_pass localhost:{overwrite}"
# modify nginx conf
regex = re.compile(r"proxy_pass http://localhost:[0-9]{1,5}")
to_overwrite = f"proxy_pass http://localhost:{overwrite}"
changed = file_overwrite(NGINX, regex, to_overwrite)
# uwsgi
regex = re.compile(r"socket = :[0-9]{1,5}")
to_overwrite = f"socket = :{overwrite}"
changed = file_overwrite(UWSGI, regex, to_overwrite)
if changed:
message = f" ✓ TA_UWSGI_PORT changed to {overwrite}"
message = f" ✓ TA_BACKEND_PORT changed to {overwrite}"
else:
message = f" ✓ TA_UWSGI_PORT already set to {overwrite}"
message = f" ✓ TA_BACKEND_PORT already set to {overwrite}"
self.stdout.write(self.style.SUCCESS(message))
def _enable_cast_overwrite(self):
def _disable_static_auth(self):
"""cast workaround, remove auth for static files in nginx"""
self.stdout.write("[5] check ENABLE_CAST overwrite")
overwrite = os.environ.get("ENABLE_CAST")
self.stdout.write("[7] check DISABLE_STATIC_AUTH overwrite")
overwrite = EnvironmentSettings.DISABLE_STATIC_AUTH
if not overwrite:
self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set"))
self.stdout.write(
self.style.SUCCESS(" DISABLE_STATIC_AUTH is not set")
)
return
regex = re.compile(r"[^\S\r\n]*auth_request /api/ping/;\n")
changed = file_overwrite(NGINX, regex, "")
if changed:
message = " ✓ process nginx to enable Cast"
message = " ✓ process nginx to disable static auth"
else:
message = "Cast is already enabled in nginx"
message = " ✓ static auth is already disabled in nginx"
self.stdout.write(self.style.SUCCESS(message))
def _create_superuser(self):
"""create superuser if not exist"""
self.stdout.write("[6] create superuser")
self.stdout.write("[8] create superuser")
is_created = Account.objects.filter(is_superuser=True)
if is_created:
message = " superuser already created"
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,36 @@
"""
migration for 0.5.4 to 0.5.5
index channel_tabs for subscribed channels
"""
import time
from channel.src.index import YoutubeChannel
from common.src.helper import get_channels
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""command"""
def handle(self, *args, **kwargs):
"""handle task"""
self.stdout.write("channel tags initial index")
channels = get_channels(subscribed_only=True, source=["channel_id"])
for es_channel in channels:
channel = YoutubeChannel(es_channel["channel_id"])
channel.get_from_es()
channel_name = channel.json_data["channel_name"]
channel_tabs = channel.get_channel_tabs()
channel.json_data["channel_tabs"] = channel_tabs
channel.upload_to_es()
channel.sync_to_videos()
self.stdout.write(
self.style.SUCCESS(
f" ✓ updated '{channel_name}' tabs: {channel_tabs}"
)
)
time.sleep(5)

View File

@@ -0,0 +1,398 @@
"""
Functionality:
- Application startup
- Apply migrations
"""
import os
from datetime import datetime
from random import randint
from time import sleep
from appsettings.src.config import AppConfig, ReleaseVersion
from appsettings.src.index_setup import ElasitIndexWrap
from appsettings.src.snapshot import ElasticSnapshot
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.helper import clear_dl_cache
from common.src.ta_redis import RedisArchivist
from django.core.management.base import BaseCommand, CommandError
from django.utils import dateformat
from django_celery_beat.models import CrontabSchedule, PeriodicTasks
from task.models import CustomPeriodicTask
from task.src.config_schedule import ScheduleBuilder
from task.src.task_manager import TaskManager
from task.tasks import version_check
from video.src.constants import VideoTypeEnum
TOPIC = """
#######################
# Application Start #
#######################
"""
class Command(BaseCommand):
"""command framework"""
# pylint: disable=no-member
def handle(self, *args, **options):
"""run all commands"""
self.stdout.write(TOPIC)
self._make_folders()
self._clear_redis_keys()
self._clear_tasks()
self._clear_dl_cache()
self._version_check()
self._index_setup()
self._snapshot_check()
self._create_default_schedules()
self._update_schedule_tz()
self._init_app_config()
self._mig_fix_download_channel_indexed()
self._mig_add_default_playlist_sort()
self._mig_set_channel_tabs()
self._mig_set_video_channel_tabs()
def _make_folders(self):
"""make expected cache folders"""
self.stdout.write("[1] create expected cache folders")
folders = [
"backup",
"channels",
"download",
"import",
"playlists",
"videos",
]
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 _clear_redis_keys(self):
"""make sure there are no leftover locks or keys set in redis"""
self.stdout.write("[2] clear leftover keys in redis")
all_keys = [
"dl_queue_id",
"dl_queue",
"downloading",
"manual_import",
"reindex",
"rescan",
"run_backup",
"startup_check",
"reindex:ta_video",
"reindex:ta_channel",
"reindex:ta_playlist",
]
redis_con = RedisArchivist()
has_changed = False
for key in all_keys:
if redis_con.del_message(key):
self.stdout.write(
self.style.SUCCESS(f" ✓ cleared key {key}")
)
has_changed = True
if not has_changed:
self.stdout.write(self.style.SUCCESS(" no keys found"))
def _clear_tasks(self):
"""clear tasks and messages"""
self.stdout.write("[3] clear task leftovers")
TaskManager().fail_pending()
redis_con = RedisArchivist()
to_delete = redis_con.list_keys("message:")
if to_delete:
for key in to_delete:
redis_con.del_message(key)
self.stdout.write(
self.style.SUCCESS(f" ✓ cleared {len(to_delete)} messages")
)
def _clear_dl_cache(self):
"""clear leftover files from dl cache"""
self.stdout.write("[4] clear leftover files from dl cache")
leftover_files = clear_dl_cache(EnvironmentSettings.CACHE_DIR)
if leftover_files:
self.stdout.write(
self.style.SUCCESS(f" ✓ cleared {leftover_files} files")
)
else:
self.stdout.write(self.style.SUCCESS(" no files found"))
def _version_check(self):
"""remove new release key if updated now"""
self.stdout.write("[5] check for first run after update")
new_version = ReleaseVersion().is_updated()
if new_version:
self.stdout.write(
self.style.SUCCESS(f" ✓ update to {new_version} completed")
)
else:
self.stdout.write(self.style.SUCCESS(" no new update found"))
version_task = CustomPeriodicTask.objects.filter(name="version_check")
if not version_task.exists():
return
if not version_task.first().last_run_at:
self.style.SUCCESS(" ✓ send initial version check task")
version_check.delay()
def _index_setup(self):
"""migration: validate index mappings"""
self.stdout.write("[6] validate index mappings")
ElasitIndexWrap().setup()
def _snapshot_check(self):
"""migration setup snapshots"""
self.stdout.write("[7] setup snapshots")
ElasticSnapshot().setup()
def _create_default_schedules(self) -> None:
"""create default schedules for new installations"""
self.stdout.write("[8] create initial schedules")
init_has_run = CustomPeriodicTask.objects.filter(
name="version_check"
).exists()
if init_has_run:
self.stdout.write(
self.style.SUCCESS(
" schedule init already done, skipping..."
)
)
return
builder = ScheduleBuilder()
check_reindex = builder.get_set_task(
"check_reindex", schedule=builder.SCHEDULES["check_reindex"]
)
check_reindex.task_config.update({"days": 90})
check_reindex.last_run_at = dateformat.make_aware(datetime.now())
check_reindex.save()
self.stdout.write(
self.style.SUCCESS(
f" ✓ created new default schedule: {check_reindex}"
)
)
thumbnail_check = builder.get_set_task(
"thumbnail_check", schedule=builder.SCHEDULES["thumbnail_check"]
)
thumbnail_check.last_run_at = dateformat.make_aware(datetime.now())
thumbnail_check.save()
self.stdout.write(
self.style.SUCCESS(
f" ✓ created new default schedule: {thumbnail_check}"
)
)
daily_random = f"{randint(0, 59)} {randint(0, 23)} *"
version_check_task = builder.get_set_task(
"version_check", schedule=daily_random
)
self.stdout.write(
self.style.SUCCESS(
f" ✓ created new default schedule: {version_check_task}"
)
)
self.stdout.write(
self.style.SUCCESS(" ✓ all default schedules created")
)
def _update_schedule_tz(self) -> None:
"""update timezone for Schedule instances"""
self.stdout.write("[9] validate schedules TZ")
tz = EnvironmentSettings.TZ
to_update = CrontabSchedule.objects.exclude(timezone=tz)
if not to_update.exists():
self.stdout.write(
self.style.SUCCESS(" all schedules have correct TZ")
)
return
updated = to_update.update(timezone=tz)
self.stdout.write(
self.style.SUCCESS(f" ✓ updated {updated} schedules to {tz}.")
)
PeriodicTasks.update_changed()
def _init_app_config(self) -> None:
"""init default app config to ES"""
self.stdout.write("[10] Check AppConfig")
response, status_code = ElasticWrap("ta_config/_doc/appsettings").get()
if status_code in [200, 201]:
self.stdout.write(
self.style.SUCCESS(" skip completed appsettings init")
)
updated_defaults = AppConfig().add_new_defaults()
for new_default in updated_defaults:
self.stdout.write(
self.style.SUCCESS(f" added new default: {new_default}")
)
return
if status_code != 404:
message = " 🗙 ta_config index lookup failed"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
handler = AppConfig.__new__(AppConfig)
_, status_code = handler.sync_defaults()
self.stdout.write(
self.style.SUCCESS(" ✓ Created default appsettings.")
)
self.stdout.write(
self.style.SUCCESS(f" Status code: {status_code}")
)
def _mig_fix_download_channel_indexed(self) -> None:
"""migrate from v0.5.2 to 0.5.3, fix missing channel_indexed"""
self.stdout.write("[MIGRATION] fix incorrect video channel tags types")
path = "ta_download/_update_by_query"
data = {
"query": {
"bool": {
"must_not": [{"exists": {"field": "channel_indexed"}}]
}
},
"script": {
"source": "ctx._source.channel_indexed = false",
"lang": "painless",
},
}
response, status_code = ElasticWrap(path).post(data)
if status_code in [200, 201]:
updated = response.get("updated")
if updated:
self.stdout.write(
self.style.SUCCESS(f" ✓ fixed {updated} queued videos")
)
else:
self.stdout.write(
self.style.SUCCESS(" no queued videos to fix")
)
return
message = " 🗙 failed to fix video channel tags"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _mig_add_default_playlist_sort(self) -> None:
"""migrate from 0.5.4 to 0.5.5 set default playlist sortorder"""
self.stdout.write("[MIGRATION] set default playlist sort order")
path = "ta_playlist/_update_by_query"
data = {
"query": {
"bool": {
"must_not": [{"exists": {"field": "playlist_sort_order"}}]
}
},
"script": {
"source": "ctx._source.playlist_sort_order = 'top'",
"lang": "painless",
},
}
response, status_code = ElasticWrap(path).post(data)
if status_code in [200, 201]:
updated = response.get("updated")
if updated:
self.stdout.write(
self.style.SUCCESS(f" ✓ updated {updated} playlists")
)
else:
self.stdout.write(
self.style.SUCCESS(" no playlists need updating")
)
return
message = " 🗙 failed to set default playlist sort order"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _mig_set_channel_tabs(self) -> None:
"""migrate from 0.5.4 to 0.5.5 set initial channel tabs"""
self.stdout.write("[MIGRATION] set default channel_tabs")
path = "ta_channel/_update_by_query"
tabs = VideoTypeEnum.values_known()
data = {
"query": {
"bool": {"must_not": [{"exists": {"field": "channel_tabs"}}]}
},
"script": {
"source": f"ctx._source.channel_tabs = {tabs}",
"lang": "painless",
},
}
response, status_code = ElasticWrap(path).post(data)
if status_code in [200, 201]:
updated = response.get("updated")
if updated:
self.stdout.write(
self.style.SUCCESS(f" ✓ updated {updated} channels")
)
else:
self.stdout.write(
self.style.SUCCESS(" no channels need updating")
)
return
message = " 🗙 failed to set default channel_tabs"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _mig_set_video_channel_tabs(self) -> None:
"""migrate from 0.5.4 to 0.5.5 set initial video channel tabs"""
self.stdout.write("[MIGRATION] set default channel_tabs for videos")
path = "ta_video/_update_by_query"
tabs = VideoTypeEnum.values_known()
data = {
"query": {
"bool": {
"must_not": [{"exists": {"field": "channel.channel_tabs"}}]
}
},
"script": {
"source": f"ctx._source.channel.channel_tabs = {tabs}",
"lang": "painless",
},
}
response, status_code = ElasticWrap(path).post(data)
if status_code in [200, 201]:
updated = response.get("updated")
if updated:
self.stdout.write(
self.style.SUCCESS(f" ✓ updated {updated} videos")
)
else:
self.stdout.write(
self.style.SUCCESS(" no videos need updating")
)
return
message = " 🗙 failed to set default channel_tabs"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)

View File

@@ -0,0 +1,40 @@
"""stop on unexpected table"""
from time import sleep
from django.core.management.base import BaseCommand, CommandError
from django.db import connection
ERROR_MESSAGE = """
🗙 Database is incompatible, see latest release notes for instructions:
🗙 https://github.com/tubearchivist/tubearchivist/releases/tag/v0.5.0
"""
class Command(BaseCommand):
"""command framework"""
# pylint: disable=no-member
def handle(self, *args, **options):
"""handle"""
self.stdout.write("[MIGRATION] Confirming v0.5.0 table layout")
all_tables = self.list_tables()
for table in all_tables:
if table == "home_account":
self.stdout.write(self.style.ERROR(ERROR_MESSAGE))
sleep(60)
raise CommandError(ERROR_MESSAGE)
self.stdout.write(self.style.SUCCESS(" ✓ local DB is up-to-date."))
def list_tables(self):
"""raw list all tables"""
with connection.cursor() as cursor:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table';"
)
tables = cursor.fetchall()
return [table[0] for table in tables]

View File

@@ -14,11 +14,17 @@ import hashlib
from os import environ, path
from pathlib import Path
import ldap
from common.src.env_settings import EnvironmentSettings
from common.src.helper import ta_host_parser
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
try:
from dotenv import load_dotenv
load_dotenv(".env")
except ModuleNotFoundError:
pass
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -27,18 +33,21 @@ 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!
DEBUG = bool(environ.get("DJANGO_DEBUG"))
ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS = ta_host_parser(environ["TA_HOST"])
ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS = ta_host_parser(
environ.get("TA_HOST", "localhost")
)
CORS_ALLOWED_ORIGINS = CSRF_TRUSTED_ORIGINS
# Application definition
INSTALLED_APPS = [
"home.apps.HomeConfig",
"django_celery_beat",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
@@ -50,7 +59,16 @@ INSTALLED_APPS = [
"django.contrib.humanize",
"rest_framework",
"rest_framework.authtoken",
"api",
"drf_spectacular",
"common",
"video",
"channel",
"playlist",
"download",
"task",
"appsettings",
"stats",
"user",
"config",
]
@@ -86,101 +104,10 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application"
if bool(environ.get("TA_LDAP")):
# pylint: disable=global-at-module-level
global AUTH_LDAP_SERVER_URI
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
global AUTH_LDAP_BIND_DN
AUTH_LDAP_BIND_DN = environ.get("TA_LDAP_BIND_DN")
global AUTH_LDAP_BIND_PASSWORD
AUTH_LDAP_BIND_PASSWORD = environ.get("TA_LDAP_BIND_PASSWORD")
"""
Since these are new environment variables, taking the opporunity to use
more accurate env names.
Given Names are *_technically_* different from Personal names, as people
who change their names have different given names and personal names,
and they go by personal names. Additionally, "LastName" is actually
incorrect for many cultures, such as Korea, where the
family name comes first, and the personal name comes last.
But we all know people are going to try to guess at these, so still want
to include names that people will guess, hence using first/last as well.
"""
# Attribute mapping options
global AUTH_LDAP_USER_ATTR_MAP_USERNAME
AUTH_LDAP_USER_ATTR_MAP_USERNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_USERNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_UID")
or "uid"
)
global AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME
AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_PERSONALNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_FIRSTNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_GIVENNAME")
or "givenName"
)
global AUTH_LDAP_USER_ATTR_MAP_SURNAME
AUTH_LDAP_USER_ATTR_MAP_SURNAME = (
environ.get("TA_LDAP_USER_ATTR_MAP_SURNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_LASTNAME")
or environ.get("TA_LDAP_USER_ATTR_MAP_FAMILYNAME")
or "sn"
)
global AUTH_LDAP_USER_ATTR_MAP_EMAIL
AUTH_LDAP_USER_ATTR_MAP_EMAIL = (
environ.get("TA_LDAP_USER_ATTR_MAP_EMAIL")
or environ.get("TA_LDAP_USER_ATTR_MAP_MAIL")
or "mail"
)
global AUTH_LDAP_USER_BASE
AUTH_LDAP_USER_BASE = environ.get("TA_LDAP_USER_BASE")
global AUTH_LDAP_USER_FILTER
AUTH_LDAP_USER_FILTER = environ.get("TA_LDAP_USER_FILTER")
global AUTH_LDAP_USER_SEARCH
# pylint: disable=no-member
AUTH_LDAP_USER_SEARCH = LDAPSearch(
AUTH_LDAP_USER_BASE,
ldap.SCOPE_SUBTREE,
"(&("
+ AUTH_LDAP_USER_ATTR_MAP_USERNAME
+ "=%(user)s)"
+ AUTH_LDAP_USER_FILTER
+ ")",
)
global AUTH_LDAP_USER_ATTR_MAP
AUTH_LDAP_USER_ATTR_MAP = {
"username": AUTH_LDAP_USER_ATTR_MAP_USERNAME,
"first_name": AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME,
"last_name": AUTH_LDAP_USER_ATTR_MAP_SURNAME,
"email": AUTH_LDAP_USER_ATTR_MAP_EMAIL,
}
if bool(environ.get("TA_LDAP_DISABLE_CERT_CHECK")):
global AUTH_LDAP_GLOBAL_OPTIONS
AUTH_LDAP_GLOBAL_OPTIONS = {
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
}
global AUTHENTICATION_BACKENDS
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
# Database
# 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": {
@@ -208,14 +135,49 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
AUTH_USER_MODEL = "home.Account"
AUTH_USER_MODEL = "user.Account"
# Configure Authentication Backend Combinations
_login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold()
if _login_auth_mode == "local":
AUTHENTICATION_BACKENDS: tuple = (
"django.contrib.auth.backends.ModelBackend",
)
elif _login_auth_mode == "ldap":
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
from .ldap_settings import * # noqa: F403 F401
elif _login_auth_mode == "forwardauth":
from .fwd_auth_settings import * # noqa: F403 F401
AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.RemoteUserBackend",
)
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
elif _login_auth_mode == "ldap_local":
AUTHENTICATION_BACKENDS = (
"django_auth_ldap.backend.LDAPBackend",
"django.contrib.auth.backends.ModelBackend",
)
from .ldap_settings import * # noqa: F403 F401
else:
# If none of these cases match, AUTHENTICATION_BACKENDS is unset, which
# means the ModelBackend should be used by default
if bool(environ.get("TA_LDAP")):
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
from .ldap_settings import * # noqa: F403 F401
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
from .fwd_auth_settings import * # noqa: F403 F401
AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.RemoteUserBackend",
)
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
# 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
@@ -227,7 +189,11 @@ USE_TZ = True
STATIC_URL = "/static/"
STATICFILES_DIRS = (str(BASE_DIR.joinpath("static")),)
STATIC_ROOT = str(BASE_DIR.joinpath("staticfiles"))
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
@@ -241,13 +207,14 @@ LOGOUT_REDIRECT_URL = "/login/"
# background.js makes the request so HTTP_ORIGIN will be from extension
if environ.get("DISABLE_CORS"):
# disable cors
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_ALL_ORIGINS = True
else:
CORS_ALLOWED_ORIGIN_REGEXES = [
r"moz-extension://*",
r"chrome-extension://*",
]
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = list(default_headers) + [
@@ -256,4 +223,34 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.4.1"
TA_VERSION = "v0.5.5"
# API
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {
"TITLE": "Tube Archivist API",
"DESCRIPTION": "API documentation for Tube Archivist backend.",
"VERSION": TA_VERSION,
"SERVE_INCLUDE_SCHEMA": False,
}
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"apprise": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": True,
},
},
}

View File

@@ -13,11 +13,26 @@ 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
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns = [
path("", include("home.urls")),
path("api/", include("api.urls")),
path("api/", include("common.urls")),
path("api/video/", include("video.urls")),
path("api/channel/", include("channel.urls")),
path("api/playlist/", include("playlist.urls")),
path("api/download/", include("download.urls")),
path("api/task/", include("task.urls")),
path("api/appsettings/", include("appsettings.urls")),
path("api/stats/", include("stats.urls")),
path("api/user/", include("user.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/docs/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path("admin/", admin.site.urls),
]

View File

View File

View File

@@ -0,0 +1,124 @@
"""download serializers"""
# pylint: disable=abstract-method
from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin
from rest_framework import serializers
from video.src.constants import VideoTypeEnum
class DownloadItemSerializer(serializers.Serializer):
"""serialize download item"""
auto_start = serializers.BooleanField()
channel_id = serializers.CharField()
channel_indexed = serializers.BooleanField()
channel_name = serializers.CharField()
duration = serializers.CharField()
published = serializers.CharField(allow_null=True)
status = serializers.ChoiceField(choices=["pending", "ignore"])
timestamp = serializers.IntegerField(allow_null=True)
title = serializers.CharField()
vid_thumb_url = serializers.CharField(allow_null=True)
vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values())
youtube_id = serializers.CharField()
message = serializers.CharField(required=False)
_index = serializers.CharField(required=False)
_score = serializers.IntegerField(required=False)
class DownloadListSerializer(serializers.Serializer):
"""serialize download list"""
data = DownloadItemSerializer(many=True)
paginate = PaginationSerializer()
class DownloadListQuerySerializer(
ValidateUnknownFieldsMixin, serializers.Serializer
):
"""serialize query params for download list"""
filter = serializers.ChoiceField(
choices=["pending", "ignore"], required=False
)
vid_type = serializers.ChoiceField(
choices=VideoTypeEnum.values_known(), required=False
)
channel = serializers.CharField(required=False, help_text="channel ID")
page = serializers.IntegerField(required=False)
q = serializers.CharField(required=False, help_text="Search Query")
error = serializers.BooleanField(required=False, allow_null=True)
class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
"""serialize bulk delete download queue query string"""
filter = serializers.ChoiceField(choices=["pending", "ignore"])
channel = serializers.CharField(required=False, help_text="channel ID")
vid_type = serializers.ChoiceField(
choices=VideoTypeEnum.values_known(), required=False
)
class AddDownloadItemSerializer(serializers.Serializer):
"""serialize single item to add"""
youtube_id = serializers.CharField()
status = serializers.ChoiceField(choices=["pending", "ignore-force"])
class AddToDownloadListSerializer(serializers.Serializer):
"""serialize add to download queue data"""
data = AddDownloadItemSerializer(many=True)
class AddToDownloadQuerySerializer(serializers.Serializer):
"""add to queue query serializer"""
autostart = serializers.BooleanField(required=False)
flat = serializers.BooleanField(required=False)
class BulkUpdateDowloadQuerySerializer(serializers.Serializer):
"""serialize bulk update query"""
filter = serializers.ChoiceField(choices=["pending", "ignore", "priority"])
channel = serializers.CharField(required=False)
vid_type = serializers.ChoiceField(
choices=VideoTypeEnum.values_known(), required=False
)
error = serializers.BooleanField(required=False, allow_null=True)
class BulkUpdateDowloadDataSerializer(serializers.Serializer):
"""serialize data"""
status = serializers.ChoiceField(
choices=["pending", "ignore", "priority", "clear_error"]
)
class DownloadQueueItemUpdateSerializer(serializers.Serializer):
"""update single download queue item"""
status = serializers.ChoiceField(
choices=["pending", "ignore", "ignore-force", "priority"]
)
class DownloadAggBucketSerializer(serializers.Serializer):
"""serialize bucket"""
key = serializers.ListField(child=serializers.CharField())
key_as_string = serializers.CharField()
doc_count = serializers.IntegerField()
class DownloadAggsSerializer(serializers.Serializer):
"""serialize download channel bucket aggregations"""
doc_count_error_upper_bound = serializers.IntegerField()
sum_other_doc_count = serializers.IntegerField()
buckets = DownloadAggBucketSerializer(many=True)

View File

View File

@@ -0,0 +1,514 @@
"""
Functionality:
- handle download queue
- linked with ta_dowload index
"""
import json
from datetime import datetime
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
from channel.src.remote_query import get_last_channel_videos
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import (
get_channels,
get_duration_str,
is_shorts,
rand_sleep,
)
from common.src.urlparser import ParsedURLType
from download.src.queue_interact import PendingInteract
from download.src.thumbnails import ThumbManager
from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo
class PendingIndex:
"""base class holding all export methods"""
def __init__(self):
self.all_pending = False
self.all_ignored = False
self.all_videos = False
self.all_channels = False
self.channel_overwrites = False
self.video_overwrites = False
self.to_skip = False
def get_download(self):
"""get a list of all pending videos in ta_download"""
data = {
"query": {"match_all": {}},
"sort": [{"timestamp": {"order": "asc"}}],
}
all_results = IndexPaginate("ta_download", data).get_results()
self.all_pending = []
self.all_ignored = []
self.to_skip = []
for result in all_results:
self.to_skip.append(result["youtube_id"])
if result["status"] == "pending":
self.all_pending.append(result)
elif result["status"] == "ignore":
self.all_ignored.append(result)
def get_indexed(self):
"""get a list of all videos indexed"""
data = {
"query": {"match_all": {}},
"sort": [{"published": {"order": "desc"}}],
}
self.all_videos = IndexPaginate("ta_video", data).get_results()
for video in self.all_videos:
self.to_skip.append(video["youtube_id"])
def get_channels(self):
"""get a list of all channels indexed"""
self.all_channels = []
self.channel_overwrites = {}
channels = get_channels(subscribed_only=False)
for channel in channels:
channel_id = channel["channel_id"]
self.all_channels.append(channel_id)
if channel.get("channel_overwrites"):
self.channel_overwrites.update(
{channel_id: channel.get("channel_overwrites")}
)
self._map_overwrites()
def _map_overwrites(self):
"""map video ids to channel ids overwrites"""
self.video_overwrites = {}
for video in self.all_pending:
video_id = video["youtube_id"]
channel_id = video["channel_id"]
overwrites = self.channel_overwrites.get(channel_id, False)
if overwrites:
self.video_overwrites.update({video_id: overwrites})
class PendingList(PendingIndex):
"""manage the pending videos list"""
yt_obs = {
"noplaylist": True,
"writethumbnail": True,
"simulate": True,
"check_formats": None,
}
def __init__(
self,
youtube_ids: list[ParsedURLType],
task=None,
auto_start=False,
flat=False,
):
super().__init__()
self.config = AppConfig().config
self.youtube_ids = youtube_ids
self.task = task
self.auto_start = auto_start
self.flat = flat
self.to_skip = False
self.missing_videos: list[dict] = []
self.added = 0
def parse_url_list(self, status="pending") -> int:
"""extract youtube ids from list"""
self.get_download()
self.get_indexed()
self.get_channels()
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids, start=1):
if self.task:
self.task.send_progress(
message_lines=[f"Extracting URL {idx}/{total}"],
progress=idx / total,
)
self._process_entry(entry, idx, total)
if self.missing_videos:
self.added += self.add_to_pending(status)
self.missing_videos = []
if self.task and self.task.is_stopped():
break
rand_sleep(self.config)
return self.added
def _process_entry(self, entry: ParsedURLType, idx: int, total: int):
"""process single entry from url list"""
if entry["type"] == "video":
to_add = self._add_video(entry["url"], entry["vid_type"])
if to_add:
self.__notify_add(
item_type="video",
name=to_add["title"],
idx=idx,
total=total,
)
elif entry["type"] == "channel":
self._parse_channel(entry)
elif entry["type"] == "playlist":
self._parse_playlist(entry["url"], entry.get("limit"))
else:
raise ValueError(f"invalid url_type: {entry}")
def _add_video(self, url, vid_type) -> dict | None:
"""add video to list"""
if self.auto_start and url in set(
i["youtube_id"] for i in self.all_pending
):
PendingInteract(youtube_id=url, status="priority").update_status()
return None
if url in self.missing_videos or url in self.to_skip:
print(f"{url}: skipped adding already indexed video to download.")
return None
to_add = self._parse_video(url, vid_type)
if to_add:
self.missing_videos.append(to_add)
return to_add
def _parse_channel(self, entry):
"""parse channel"""
url = entry["url"]
vid_type = entry["vid_type"]
if isinstance(vid_type, str):
# lookup enum
vid_type = getattr(VideoTypeEnum, vid_type.upper())
limit = entry.get("limit")
video_results = get_last_channel_videos(
channel_id=url,
config=self.config,
limit=limit,
query_filter=vid_type,
)
if not video_results:
print(f"{url}: no videos to add from channel, skipping")
return
channel_handler = YoutubeChannel(url)
channel_handler.build_json(upload=False)
if not channel_handler.json_data:
print(f"{url}: channel metadata extraction failed, skipping")
return
total = len(video_results)
for idx, video_data in enumerate(video_results, start=1):
to_add = self.__parse_channel_video(
video_data, vid_type, channel_handler.json_data
)
if self.task and self.task.is_stopped():
break
if not to_add:
continue
self.missing_videos.append(to_add)
self.__notify_add(
item_type="channel",
name=channel_handler.json_data["channel_name"],
idx=idx,
total=total,
)
def __parse_channel_video(
self, video_data, vid_type, channel_json
) -> dict | None:
"""parse video of channel"""
video_id = video_data["id"]
if video_id in self.to_skip:
return None
# fallback
channel_name = channel_json["channel_name"]
channel_id = channel_json["channel_id"]
if self.flat:
if not video_data.get("channel"):
video_data["channel"] = channel_name
if not video_data.get("channel_id"):
video_data["channel_id"] = channel_id
to_add = self._parse_entry(
youtube_id=video_id,
video_data=video_data,
)
else:
to_add = self._parse_video(video_id, vid_type)
return to_add
def _parse_playlist(self, url: str, limit: int | None):
"""fast parse playlist"""
playlist = YoutubePlaylist(url)
playlist.update_playlist(limit=limit)
if not playlist.youtube_meta:
print(f"{url}: playlist metadata extraction failed, skipping")
return
video_results = playlist.youtube_meta["entries"]
total = len(video_results)
for idx, video_data in enumerate(video_results, start=1):
video_id = video_data["id"]
if video_id in self.to_skip:
continue
if self.task and self.task.is_stopped():
break
if self.flat:
if not video_data.get("channel"):
video_data["channel"] = playlist.youtube_meta["channel"]
if not video_data.get("channel_id"):
channel_id = playlist.youtube_meta["channel_id"]
video_data["channel_id"] = channel_id
to_add = self._parse_entry(video_id, video_data)
else:
to_add = self._parse_video(video_id, vid_type=None)
if not to_add:
continue
self.missing_videos.append(to_add)
self.__notify_add(
item_type="playlist",
name=playlist.json_data["playlist_name"],
idx=idx,
total=total,
)
def _parse_video(self, url: str, vid_type) -> dict | None:
"""parse video when not flat, fetch from YT"""
video = YoutubeVideo(youtube_id=url)
video.get_from_youtube()
if not video.youtube_meta:
print(f"{url}: video metadata extraction failed, skipping")
if self.task:
self.task.send_progress(
message_lines=[
"Video extraction failed.",
f"{video.error}",
],
level="error",
)
return None
video.youtube_meta["vid_type"] = vid_type
to_add = self._parse_entry(
youtube_id=url,
video_data=video.youtube_meta,
)
if not to_add:
return None
ThumbManager(item_id=url).download_video_thumb(to_add["vid_thumb_url"])
rand_sleep(self.config)
return to_add
def _parse_entry(
self,
youtube_id: str,
video_data: dict,
) -> dict | None:
"""parse entry"""
if video_data.get("id") != youtube_id:
# skip premium videos with different id or redirects
print(f"{youtube_id}: skipping redirect, id not matching")
return None
if video_data.get("live_status") in ["is_upcoming", "is_live"]:
print(f"{youtube_id}: skip is_upcoming or is_live")
return None
to_add = {
"youtube_id": video_data["id"],
"title": video_data["title"],
"vid_thumb_url": self.__extract_thumb(video_data),
"duration": get_duration_str(video_data.get("duration", 0)),
"published": self.__extract_published(video_data),
"timestamp": int(datetime.now().timestamp()),
"vid_type": self.__extract_vid_type(video_data),
"channel_name": video_data["channel"],
"channel_id": video_data["channel_id"],
"channel_indexed": video_data["channel_id"] in self.all_channels,
}
return to_add
def __extract_thumb(self, video_data) -> str | None:
"""extract thumb"""
if "thumbnail" in video_data:
return video_data["thumbnail"]
if video_data.get("thumbnails"):
return video_data["thumbnails"][-1]["url"]
return None
def __extract_published(self, video_data) -> str | int | None:
"""build published date or timestamp"""
timestamp = video_data.get("timestamp")
if timestamp:
return timestamp
upload_date = video_data.get("upload_date")
if not upload_date:
return None
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
return published
def __extract_vid_type(self, video_data) -> str:
"""build vid type"""
if (
"vid_type" in video_data
and video_data["vid_type"]
and str(video_data["vid_type"]) in VideoTypeEnum.values_known()
):
return VideoTypeEnum(video_data["vid_type"]).value
if video_data.get("live_status") == "was_live":
return VideoTypeEnum.STREAMS.value
if video_data.get("width", 0) > video_data.get("height", 0):
return VideoTypeEnum.VIDEOS.value
duration = video_data.get("duration")
if duration and isinstance(duration, int):
if duration > 3 * 60:
return VideoTypeEnum.VIDEOS.value
if is_shorts(video_data["id"]):
return VideoTypeEnum.SHORTS.value
return VideoTypeEnum.VIDEOS.value
def add_to_pending(self, status="pending") -> int:
"""add missing videos to pending list"""
total = len(self.missing_videos)
if not self.missing_videos:
self._notify_empty()
return 0
self._notify_start(total)
bulk_list = []
for video_entry in self.missing_videos:
video_entry.update(
{
"status": status,
"auto_start": self.auto_start,
}
)
video_id = video_entry["youtube_id"]
action = {"index": {"_index": "ta_download", "_id": video_id}}
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(video_entry))
# add last newline
bulk_list.append("\n")
query_str = "\n".join(bulk_list)
_, status_code = ElasticWrap("_bulk").post(query_str, ndjson=True)
if status_code != 200:
self._notify_fail(status_code)
else:
self._notify_done(total)
return len(self.missing_videos)
def __notify_add(
self, item_type: str, name: str, idx: int, total: int
) -> None:
"""notify"""
if not self.task:
return
if self.flat:
lines = [
f"Bulk extracting {item_type.title()}: '{name}'.",
f"Fast adding item {idx}/{total}.",
]
else:
lines = [
f"Full extracting {item_type.title()}: '{name}'",
f"Parsing item {idx}/{total}.",
]
self.task.send_progress(
message_lines=lines,
progress=idx / total,
)
def _notify_empty(self):
"""notify nothing to add"""
if not self.task:
return
self.task.send_progress(
message_lines=[
"Extracting videos completed.",
"No new videos found to add.",
]
)
def _notify_start(self, total):
"""send notification for adding videos to download queue"""
if not self.task:
return
self.task.send_progress(
message_lines=[
"Adding new videos to download queue.",
f"Bulk adding {total} videos",
]
)
def _notify_done(self, total):
"""send done notification"""
if not self.task:
return
self.task.send_progress(
message_lines=[
"Adding new videos to the queue completed.",
f"Added {total} videos.",
]
)
def _notify_fail(self, status_code):
"""failed to add"""
if not self.task:
return
self.task.send_progress(
message_lines=[
"Adding extracted videos failed.",
f"Status code: {status_code}",
],
level="error",
)

View File

@@ -0,0 +1,115 @@
"""interact with queue items"""
from common.src.es_connect import ElasticWrap
class PendingInteract:
"""interact with items in download queue"""
def __init__(self, youtube_id=False, status=False):
self.youtube_id = youtube_id
self.status = status
def delete_item(self):
"""delete single item from pending"""
path = f"ta_download/_doc/{self.youtube_id}"
_, _ = ElasticWrap(path).delete(refresh=True)
def delete_bulk(self, channel_id: str | None, vid_type: str | None):
"""delete all matching item by status"""
must_list = [{"term": {"status": {"value": self.status}}}]
if channel_id:
must_list.append({"term": {"channel_id": {"value": channel_id}}})
if vid_type:
must_list.append({"term": {"vid_type": {"value": vid_type}}})
data = {"query": {"bool": {"must": must_list}}}
path = "ta_download/_delete_by_query?refresh=true"
_, _ = ElasticWrap(path).post(data=data)
def update_bulk(
self,
channel_id: str | None,
vid_type: str | None,
new_status: str,
error: bool | None = None,
):
"""update status in bulk"""
must_list = [{"term": {"status": {"value": self.status}}}]
must_not_list = []
if channel_id:
must_list.append({"term": {"channel_id": {"value": channel_id}}})
if vid_type:
must_list.append({"term": {"vid_type": {"value": vid_type}}})
if error is not None:
exists = {"exists": {"field": "message"}}
if error:
must_list.append(exists) # type: ignore
else:
must_not_list.append(exists)
if new_status == "priority":
source = """
ctx._source.status = 'pending';
ctx._source.auto_start = true;
ctx._source.message = null;
"""
elif new_status == "clear_error":
source = "ctx._source.message = null"
else:
source = f"ctx._source.status = '{new_status}'"
data = {
"query": {"bool": {"must": must_list, "must_not": must_not_list}},
"script": {"source": source, "lang": "painless"},
}
path = "ta_download/_update_by_query?refresh=true"
_, _ = ElasticWrap(path).post(data)
def update_status(self):
"""update status of pending item"""
if self.status == "priority":
data = {
"doc": {
"status": "pending",
"auto_start": True,
"message": None,
}
}
else:
data = {"doc": {"status": self.status}}
path = f"ta_download/_update/{self.youtube_id}/?refresh=true"
_, _ = ElasticWrap(path).post(data=data)
def get_item(self):
"""return pending item dict"""
path = f"ta_download/_doc/{self.youtube_id}"
response, status_code = ElasticWrap(path).get()
return response["_source"], status_code
def get_channel(self):
"""
get channel metadata from queue to not depend on channel to be indexed
"""
data = {
"size": 1,
"query": {"term": {"channel_id": {"value": self.youtube_id}}},
}
response, _ = ElasticWrap("ta_download/_search").get(data=data)
hits = response["hits"]["hits"]
if not hits:
channel_name = "NA"
else:
channel_name = hits[0]["_source"].get("channel_name", "NA")
return {
"channel_id": self.youtube_id,
"channel_name": channel_name,
}

View File

@@ -0,0 +1,202 @@
"""
Functionality:
- handle channel subscriptions
- handle playlist subscriptions
"""
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
from channel.src.remote_query import VideoQueryBuilder
from common.src.helper import get_channels, get_playlists
from common.src.urlparser import ParsedURLType, Parser
from download.src.queue import PendingList
from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo
class ChannelSubscription:
"""scan subscribed channels to find missing videos to add to pending"""
def __init__(self, task=None):
self.config = AppConfig().config
self.task = task
def find_missing(self) -> int:
"""find missing videos from channel subscriptions"""
if self.task:
self.task.send_progress(["Looking up channels."])
all_channels = get_channels(
subscribed_only=True,
source=["channel_id", "channel_overwrites", "channel_tabs"],
)
if not all_channels:
return 0
all_channel_urls = self._process_channel_urls(all_channels)
if self.task:
self.task.send_progress([f"Scanning {len(all_channels)} channels"])
pending_handler = PendingList(
youtube_ids=all_channel_urls,
task=self.task,
auto_start=self.config["subscriptions"].get("auto_start", False),
flat=self.config["subscriptions"].get("extract_flat", False),
)
added = pending_handler.parse_url_list()
return added
def _process_channel_urls(self, all_channels: list[dict]):
"""process channels, build queries"""
all_channel_urls: list[ParsedURLType] = []
for channel in all_channels:
channel_tabs = channel["channel_tabs"]
if not channel_tabs:
continue
enums = [getattr(VideoTypeEnum, i.upper()) for i in channel_tabs]
queries = VideoQueryBuilder(
config=self.config,
channel_overwrites=channel.get("channel_overwrites", {}),
).build_queries(video_type=enums)
for query in queries:
all_channel_urls.append(
ParsedURLType(
type="channel",
url=channel["channel_id"],
vid_type=query[0],
limit=query[1],
)
)
return all_channel_urls
class PlaylistSubscription:
"""scan subscribed playlists for videos to add to pending"""
def __init__(self, task=None):
self.config = AppConfig().config
self.task = task
def find_missing(self) -> int:
"""find missing"""
all_playlists = get_playlists(
subscribed_only=True, source=["playlist_id"]
)
if not all_playlists:
return 0
size_limit = self.config["subscriptions"]["playlist_size"]
all_playlist_urls: list[ParsedURLType] = []
for playlist in all_playlists:
all_playlist_urls.append(
ParsedURLType(
type="playlist",
url=playlist["playlist_id"],
vid_type=VideoTypeEnum.UNKNOWN,
limit=size_limit,
)
)
pending_handler = PendingList(
youtube_ids=all_playlist_urls,
task=self.task,
auto_start=self.config["subscriptions"].get("auto_start", False),
flat=self.config["subscriptions"].get("extract_flat", False),
)
added = pending_handler.parse_url_list()
return added
class SubscriptionScanner:
"""add missing videos to queue"""
def __init__(self, task=False):
self.task = task
self.missing_videos = False
self.auto_start = AppConfig().config["subscriptions"].get("auto_start")
def scan(self):
"""scan channels and playlists"""
if self.task:
self.task.send_progress(["Rescanning channels and playlists."])
added = 0
added += ChannelSubscription(task=self.task).find_missing()
if self.task and not self.task.is_stopped():
added += PlaylistSubscription(task=self.task).find_missing()
return added
class SubscriptionHandler:
"""subscribe to channels and playlists from url_str"""
def __init__(self, url_str, task=False):
self.url_str = url_str
self.task = task
self.to_subscribe = False
def subscribe(self, expected_type=False):
"""subscribe to url_str items"""
if self.task:
self.task.send_progress(["Processing form content."])
self.to_subscribe = Parser(self.url_str).parse()
total = len(self.to_subscribe)
for idx, item in enumerate(self.to_subscribe):
if self.task:
self._notify(idx, item, total)
self.subscribe_type(item, expected_type=expected_type)
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')}"
)
playlist = YoutubePlaylist(item["url"])
playlist.change_subscribe(new_subscribe_state=True)
return
if item["type"] == "video":
# extract channel id from video
video = YoutubeVideo(item["url"])
video.get_from_youtube()
video.process_youtube_meta()
channel_id = video.channel_id
elif item["type"] == "channel":
channel_id = item["url"]
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):
"""subscribe to channel"""
YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=True)
def _notify(self, idx, item, total):
"""send notification message to redis"""
subscribe_type = item["type"].title()
message_lines = [
f"Subscribe to {subscribe_type}",
f"Progress: {idx + 1}/{total}",
]
self.task.send_progress(message_lines, progress=(idx + 1) / total)

View File

@@ -10,8 +10,9 @@ from io import BytesIO
from time import sleep
import requests
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.ta.config import AppConfig
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import is_missing
from mutagen.mp4 import MP4, MP4Cover
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
@@ -21,8 +22,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")
@@ -70,13 +70,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"
@@ -203,7 +203,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):
@@ -246,14 +257,15 @@ 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"""
print(f"{self.index_name}: validate artwork")
if self.index_name == "ta_video":
if self.index_name in ["ta_video", "ta_download"]:
self._validate_videos()
elif self.index_name == "ta_channel":
self._validate_channels()
@@ -313,9 +325,16 @@ class ThumbValidator:
},
"name": "ta_playlist",
},
{
"data": {
"query": {"term": {"status": {"value": "pending"}}},
"_source": ["youtube_id", "vid_thumb_url"],
},
"name": "ta_download",
},
]
def __init__(self, task):
def __init__(self, task=False):
self.task = task
def validate(self):
@@ -335,6 +354,89 @@ class ThumbValidator:
)
_ = paginate.get_results()
def clean_up(self):
"""clean up all thumbs"""
self._clean_up_vids()
self._clean_up_channels()
self._clean_up_playlists()
def _clean_up_vids(self):
"""clean unneeded vid thumbs"""
video_dir = os.path.join(EnvironmentSettings.CACHE_DIR, "videos")
video_folders = os.listdir(video_dir)
for video_folder in video_folders:
folder_path = os.path.join(video_dir, video_folder)
thumbs_is = {i.split(".")[0] for i in os.listdir(folder_path)}
thumbs_should = self._get_vid_thumbs_should(video_folder)
to_delete = thumbs_is - thumbs_should
for thumb in to_delete:
delete_path = os.path.join(folder_path, f"{thumb}.jpg")
os.remove(delete_path)
if to_delete:
message = (
f"[thumbs][video][{video_folder}] "
+ f"delete {len(to_delete)} unused thumbnails"
)
print(message)
if self.task:
self.task.send_progress([message])
@staticmethod
def _get_vid_thumbs_should(video_folder: str) -> set[str]:
"""get indexed"""
should_list = [
{"prefix": {"youtube_id": {"value": video_folder.lower()}}},
{"prefix": {"youtube_id": {"value": video_folder.upper()}}},
]
data = {
"query": {"bool": {"should": should_list}},
"_source": ["youtube_id"],
}
result = IndexPaginate("ta_video,ta_download", data).get_results()
thumbs_should = {i["youtube_id"] for i in result}
return thumbs_should
def _clean_up_channels(self):
"""clean unneeded channel thumbs"""
channel_dir = os.path.join(EnvironmentSettings.CACHE_DIR, "channels")
channel_art = os.listdir(channel_dir)
thumbs_is = {"_".join(i.split("_")[:-1]) for i in channel_art}
to_delete = is_missing(list(thumbs_is), "ta_channel", "channel_id")
for channel_thumb in channel_art:
if channel_thumb[:24] in to_delete:
delete_path = os.path.join(channel_dir, channel_thumb)
os.remove(delete_path)
if to_delete:
message = (
"[thumbs][channel] "
+ f"delete {len(to_delete)} unused channel art"
)
print(message)
if self.task:
self.task.send_progress([message])
def _clean_up_playlists(self):
"""clean up unneeded playlist thumbs"""
playlist_dir = os.path.join(EnvironmentSettings.CACHE_DIR, "playlists")
playlist_art = os.listdir(playlist_dir)
thumbs_is = {i.split(".")[0] for i in playlist_art}
to_delete = is_missing(list(thumbs_is), "ta_playlist", "playlist_id")
for playlist_id in to_delete:
delete_path = os.path.join(playlist_dir, f"{playlist_id}.jpg")
os.remove(delete_path)
if to_delete:
message = (
"[thumbs][playlist] "
+ f"delete {len(to_delete)} unused playlist art"
)
print(message)
if self.task:
self.task.send_progress([message])
@staticmethod
def _get_total(index_name):
"""get total documents in index"""
@@ -379,14 +481,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

@@ -0,0 +1,222 @@
"""
functionality:
- base class to make all calls to yt-dlp
- handle yt-dlp errors
"""
from datetime import datetime
from http import cookiejar
from io import StringIO
import yt_dlp
from appsettings.src.config import AppConfig
from common.src.ta_redis import RedisArchivist
from django.conf import settings
class YtWrap:
"""wrap calls to yt"""
OBS_BASE = {
"default_search": "ytsearch",
"quiet": True,
"socket_timeout": 10,
"extractor_retries": 3,
"retries": 10,
}
def __init__(self, obs_request, config=False):
self.obs_request = obs_request
self.config = config
self.build_obs()
def build_obs(self):
"""build yt-dlp obs"""
self.obs = self.OBS_BASE.copy()
self.obs.update(self.obs_request)
if self.config:
self._add_cookie()
self._add_potoken()
if getattr(settings, "DEBUG", False):
del self.obs["quiet"]
print(self.obs)
def _add_cookie(self):
"""add cookie if enabled"""
if self.config["downloads"]["cookie_import"]:
cookie_io = CookieHandler(self.config).get()
self.obs["cookiefile"] = cookie_io
def _add_potoken(self):
"""add potoken if enabled"""
if self.config["downloads"].get("potoken"):
potoken = POTokenHandler(self.config).get()
self.obs.update(
{
"extractor_args": {
"youtube": {
"po_token": [potoken],
"player-client": ["web", "default"],
},
}
}
)
def download(self, url):
"""make download request"""
self.obs.update({"check_formats": "selected"})
with yt_dlp.YoutubeDL(self.obs) as ydl:
try:
ydl.download([url])
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 False, str(err)
self._validate_cookie()
return True, True
def extract(self, url) -> tuple[dict | None, str | None]:
"""
make extract request
returns response, error
"""
with yt_dlp.YoutubeDL(self.obs) as ydl:
try:
response = ydl.extract_info(url)
except cookiejar.LoadError as err:
print(f"cookie file is invalid: {err}")
return None, str(err)
except yt_dlp.utils.ExtractorError as err:
print(f"{url}: failed to extract: {err}, continue...")
return None, str(err)
except yt_dlp.utils.DownloadError as err:
if "This channel does not have a" in str(err):
return None, None
print(f"{url}: failed to get info from youtube: {err}")
if "Temporary failure in name resolution" in str(err):
raise ConnectionError("lost the internet, abort!") from err
return None, str(err)
self._validate_cookie()
return response, None
def _validate_cookie(self):
"""check cookie and write it back for next use"""
if not self.obs.get("cookiefile"):
return
new_cookie = self.obs["cookiefile"].read()
old_cookie = RedisArchivist().get_message_str("cookie")
if new_cookie and old_cookie != new_cookie:
print("refreshed stored cookie")
RedisArchivist().set_message("cookie", new_cookie, save=True)
class CookieHandler:
"""handle youtube cookie for yt-dlp"""
def __init__(self, config):
self.cookie_io = False
self.config = config
def get(self):
"""get cookie io stream"""
cookie = RedisArchivist().get_message_str("cookie")
self.cookie_io = StringIO(cookie)
return self.cookie_io
def set_cookie(self, cookie):
"""set cookie str and activate in config"""
cookie_clean = cookie.strip("\x00")
RedisArchivist().set_message("cookie", cookie_clean, save=True)
AppConfig().update_config({"downloads": {"cookie_import": True}})
self.config["downloads"]["cookie_import"] = True
print("[cookie]: activated and stored in Redis")
@staticmethod
def revoke():
"""revoke cookie"""
RedisArchivist().del_message("cookie")
RedisArchivist().del_message("cookie:valid")
AppConfig().update_config({"downloads": {"cookie_import": False}})
print("[cookie]: revoked")
def validate(self) -> bool:
"""validate cookie using the liked videos playlist"""
validation = RedisArchivist().get_message_dict("cookie:valid")
if validation:
print("[cookie]: used cached cookie validation")
return True
print("[cookie] validating cookie")
obs_request = {
"skip_download": True,
"extract_flat": True,
}
validator = YtWrap(obs_request, self.config)
response, error = validator.extract("LL")
self.store_validation(bool(response))
# update in redis to avoid expiring
modified = validator.obs["cookiefile"].getvalue().strip("\x00")
if modified:
cookie_clean = modified.strip("\x00")
RedisArchivist().set_message("cookie", cookie_clean)
if not response:
mess_dict = {
"status": "message:download",
"level": "error",
"title": "Cookie validation failed, exiting...",
"message": error,
}
RedisArchivist().set_message(
"message:download", mess_dict, expire=4
)
print("[cookie]: validation failed, exiting...")
print(f"[cookie]: validation success: {bool(response)}")
return bool(response)
@staticmethod
def store_validation(response):
"""remember last validation"""
now = datetime.now()
message = {
"status": response,
"validated": int(now.timestamp()),
"validated_str": now.strftime("%Y-%m-%d %H:%M"),
}
RedisArchivist().set_message("cookie:valid", message, expire=3600)
class POTokenHandler:
"""handle po token"""
REDIS_KEY = "potoken"
def __init__(self, config):
self.config = config
def get(self) -> str | None:
"""get PO token"""
potoken = RedisArchivist().get_message_str(self.REDIS_KEY)
return potoken
def set_token(self, new_token: str) -> None:
"""set new PO token"""
RedisArchivist().set_message(self.REDIS_KEY, new_token)
AppConfig().update_config({"downloads": {"potoken": True}})
def revoke_token(self) -> None:
"""revoke token"""
RedisArchivist().del_message(self.REDIS_KEY)
AppConfig().update_config({"downloads": {"potoken": False}})

View File

@@ -10,198 +10,99 @@ import os
import shutil
from datetime import datetime
from home.src.download.queue import PendingList
from home.src.download.subscriptions import PlaylistSubscription
from home.src.download.yt_dlp_base import YtWrap
from home.src.es.connect import ElasticWrap, IndexPaginate
from home.src.index.channel import YoutubeChannel
from home.src.index.comments import CommentList
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 ignore_filelist
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import (
get_channel_overwrites,
get_playlists,
ignore_filelist,
rand_sleep,
)
from common.src.ta_redis import RedisQueue
from download.src.queue import PendingList
from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist
from video.src.comments import CommentList
from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo, index_new_video
class DownloadPostProcess:
"""handle task to run after download queue finishes"""
class DownloaderBase:
"""base class for shared config"""
def __init__(self, download):
self.download = download
self.now = int(datetime.now().timestamp())
self.pending = False
CACHE_DIR = EnvironmentSettings.CACHE_DIR
MEDIA_DIR = EnvironmentSettings.MEDIA_DIR
CHANNEL_QUEUE = "download:channel"
PLAYLIST_QUEUE = "download:playlist:full"
PLAYLIST_QUICK = "download:playlist:quick"
VIDEO_QUEUE = "download:video"
def run(self):
"""run all functions"""
self.pending = PendingList()
self.pending.get_download()
self.pending.get_channels()
self.pending.get_indexed()
self.auto_delete_all()
self.auto_delete_overwrites()
self.validate_playlists()
self.get_comments()
def auto_delete_all(self):
"""handle auto delete"""
autodelete_days = self.download.config["downloads"]["autodelete_days"]
if not autodelete_days:
return
print(f"auto delete older than {autodelete_days} days")
now_lte = self.now - autodelete_days * 24 * 60 * 60
data = {
"query": {"range": {"player.watched_date": {"lte": now_lte}}},
"sort": [{"player.watched_date": {"order": "asc"}}],
}
self._auto_delete_watched(data)
def auto_delete_overwrites(self):
"""handle per channel auto delete from overwrites"""
for channel_id, value in self.pending.channel_overwrites.items():
if "autodelete_days" in value:
autodelete_days = value.get("autodelete_days")
print(f"{channel_id}: delete older than {autodelete_days}d")
now_lte = self.now - autodelete_days * 24 * 60 * 60
must_list = [
{"range": {"player.watched_date": {"lte": now_lte}}},
{"term": {"channel.channel_id": {"value": channel_id}}},
]
data = {
"query": {"bool": {"must": must_list}},
"sort": [{"player.watched_date": {"order": "desc"}}],
}
self._auto_delete_watched(data)
@staticmethod
def _auto_delete_watched(data):
"""delete watched videos after x days"""
to_delete = IndexPaginate("ta_video", data).get_results()
if not to_delete:
return
for video in to_delete:
youtube_id = video["youtube_id"]
print(f"{youtube_id}: auto delete video")
YoutubeVideo(youtube_id).delete_media_file()
print("add deleted to ignore list")
vids = [{"type": "video", "url": i["youtube_id"]} for i in to_delete]
pending = PendingList(youtube_ids=vids)
pending.parse_url_list()
pending.add_to_pending(status="ignore")
def validate_playlists(self):
"""look for playlist needing to update"""
for id_c, channel_id in enumerate(self.download.channels):
channel = YoutubeChannel(channel_id, task=self.download.task)
overwrites = self.pending.channel_overwrites.get(channel_id, False)
if overwrites and overwrites.get("index_playlists"):
# validate from remote
channel.index_channel_playlists()
continue
# validate from local
playlists = channel.get_indexed_playlists(active_only=True)
all_channel_playlist = [i["playlist_id"] for i in playlists]
self._validate_channel_playlist(all_channel_playlist, id_c)
def _validate_channel_playlist(self, all_channel_playlist, id_c):
"""scan channel for playlist needing update"""
all_youtube_ids = [i["youtube_id"] for i in self.pending.all_videos]
for id_p, playlist_id in enumerate(all_channel_playlist):
playlist = YoutubePlaylist(playlist_id)
playlist.all_youtube_ids = all_youtube_ids
playlist.build_json(scrape=True)
if not playlist.json_data:
playlist.deactivate()
continue
playlist.add_vids_to_playlist()
playlist.upload_to_es()
self._notify_playlist_progress(all_channel_playlist, id_c, id_p)
def _notify_playlist_progress(self, all_channel_playlist, id_c, id_p):
"""notify to UI"""
if not self.download.task:
return
total_channel = len(self.download.channels)
total_playlist = len(all_channel_playlist)
message = [
f"Post Processing Channels: {id_c}/{total_channel}",
f"Validate Playlists {id_p + 1}/{total_playlist}",
]
progress = (id_c + 1) / total_channel
self.download.task.send_progress(message, progress=progress)
def get_comments(self):
"""get comments from youtube"""
CommentList(self.download.videos, task=self.download.task).index()
class VideoDownloader:
"""
handle the video download functionality
if not initiated with list, take from queue
"""
def __init__(self, youtube_id_list=False, task=False):
self.obs = False
self.video_overwrites = False
self.youtube_id_list = youtube_id_list
def __init__(self, task):
self.task = task
self.config = AppConfig().config
self._build_obs()
self.channels = set()
self.videos = set()
self.channel_overwrites = get_channel_overwrites()
self.now = int(datetime.now().timestamp())
def run_queue(self, auto_only=False):
class VideoDownloader(DownloaderBase):
"""handle the video download functionality"""
def __init__(self, task=False):
super().__init__(task)
self.obs = False
self._build_obs()
def run_queue(self, auto_only=False) -> tuple[int, int]:
"""setup download queue in redis loop until no more items"""
self._get_overwrites()
downloaded = 0
failed = 0
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")
if downloaded > 0:
rand_sleep(self.config)
youtube_id = video_data["youtube_id"]
channel_id = video_data["channel_id"]
print(f"{youtube_id}: Downloading video")
self._notify(video_data, "Validate download format")
success = self._dl_single_vid(youtube_id)
success = self._dl_single_vid(youtube_id, channel_id)
if not success:
failed += 1
continue
self._notify(video_data, "Add video metadata to index")
vid_dict = index_new_video(
youtube_id,
video_overwrites=self.video_overwrites,
video_type=VideoTypeEnum(video_data["vid_type"]),
)
self.channels.add(vid_dict["channel"]["channel_id"])
self.videos.add(vid_dict["youtube_id"])
self._notify(video_data, "Add video metadata to index", progress=1)
video_type = VideoTypeEnum(video_data["vid_type"])
vid_dict = index_new_video(youtube_id, video_type=video_type)
RedisQueue(self.CHANNEL_QUEUE).add(channel_id)
RedisQueue(self.VIDEO_QUEUE).add(youtube_id)
self._notify(video_data, "Move downloaded file to archive")
self.move_to_archive(vid_dict)
self._delete_from_pending(youtube_id)
downloaded += 1
# post processing
self._add_subscribed_channels()
DownloadPostProcess(self).run()
DownloadPostProcess(self.task).run()
return self.videos
return downloaded, failed
def _notify(self, video_data, message):
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"""
@@ -225,13 +126,6 @@ class VideoDownloader:
return response["hits"]["hits"][0]["_source"]
def _get_overwrites(self):
"""get channel overwrites"""
pending = PendingList()
pending.get_download()
pending.get_channels()
self.video_overwrites = pending.video_overwrites
def _progress_hook(self, response):
"""process the progress_hooks from yt_dlp"""
progress = False
@@ -262,15 +156,13 @@ 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,
"writethumbnail": False,
"noplaylist": True,
"color": "no_color",
}
def _build_obs_user(self):
@@ -325,28 +217,17 @@ class VideoDownloader:
self.obs["postprocessors"] = postprocessors
def get_format_overwrites(self, youtube_id):
"""get overwrites from single video"""
overwrites = self.video_overwrites.get(youtube_id, False)
if overwrites:
return overwrites.get("download_format", False)
def _set_overwrites(self, obs: dict, channel_id: str) -> None:
"""add overwrites to obs"""
overwrites = self.channel_overwrites.get(channel_id)
if overwrites and overwrites.get("download_format"):
obs["format"] = overwrites.get("download_format")
return False
def _dl_single_vid(self, youtube_id):
def _dl_single_vid(self, youtube_id: str, channel_id: str) -> bool:
"""download single video"""
obs = self.obs.copy()
format_overwrite = self.get_format_overwrites(youtube_id)
if format_overwrite:
obs["format"] = format_overwrite
dl_cache = self.config["application"]["cache_dir"] + "/download/"
# check if already in cache to continue from there
all_cached = ignore_filelist(os.listdir(dl_cache))
for file_name in all_cached:
if youtube_id in file_name:
obs["outtmpl"] = os.path.join(dl_cache, file_name)
self._set_overwrites(obs, channel_id)
dl_cache = os.path.join(self.CACHE_DIR, "download")
success, message = YtWrap(obs, self.config).download(youtube_id)
if not success:
@@ -370,20 +251,20 @@ class VideoDownloader:
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"]
host_uid = EnvironmentSettings.HOST_UID
host_gid = EnvironmentSettings.HOST_GID
# make folder
folder = os.path.join(videos, vid_dict["channel"]["channel_id"])
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"
cache_dir = self.config["application"]["cache_dir"]
old_path = os.path.join(cache_dir, "download", media_file)
new_path = os.path.join(videos, vid_dict["media_url"])
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:
@@ -395,18 +276,6 @@ class VideoDownloader:
path = f"ta_download/_doc/{youtube_id}?refresh=true"
_, _ = ElasticWrap(path).delete()
def _add_subscribed_channels(self):
"""add all channels subscribed to refresh"""
all_subscribed = PlaylistSubscription().get_playlists()
if not all_subscribed:
return
channel_ids = [i["playlist_channel_id"] for i in all_subscribed]
for channel_id in channel_ids:
self.channels.add(channel_id)
return
def _reset_auto(self):
"""reset autostart to defaults after queue stop"""
path = "ta_download/_update_by_query"
@@ -417,7 +286,184 @@ class VideoDownloader:
"lang": "painless",
},
}
response, _ = ElasticWrap(path, config=self.config).post(data=data)
response, _ = ElasticWrap(path).post(data=data)
updated = response.get("updated")
if updated:
print(f"[download] reset auto start on {updated} videos.")
class DownloadPostProcess(DownloaderBase):
"""handle task to run after download queue finishes"""
def run(self):
"""run all functions"""
self.auto_delete_all()
self.auto_delete_overwrites()
self.refresh_playlist()
self.match_videos()
self.get_comments()
def auto_delete_all(self):
"""handle auto delete"""
autodelete_days = self.config["downloads"]["autodelete_days"]
if not autodelete_days:
return
print(f"auto delete older than {autodelete_days} days")
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
channel_overwrite = "channel.channel_overwrites.autodelete_days"
data = {
"query": {
"bool": {
"must": [
{"range": {"player.watched_date": {"lte": now_lte}}},
{"term": {"player.watched": True}},
],
"must_not": [
{"exists": {"field": channel_overwrite}},
],
}
},
"sort": [{"player.watched_date": {"order": "asc"}}],
}
self._auto_delete_watched(data)
def auto_delete_overwrites(self):
"""handle per channel auto delete from overwrites"""
for channel_id, value in self.channel_overwrites.items():
if "autodelete_days" in value:
autodelete_days = value.get("autodelete_days")
print(f"{channel_id}: delete older than {autodelete_days}d")
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
must_list = [
{"range": {"player.watched_date": {"lte": now_lte}}},
{"term": {"channel.channel_id": {"value": channel_id}}},
{"term": {"player.watched": True}},
]
data = {
"query": {"bool": {"must": must_list}},
"sort": [{"player.watched_date": {"order": "desc"}}],
}
self._auto_delete_watched(data)
@staticmethod
def _auto_delete_watched(data):
"""delete watched videos after x days"""
to_delete = IndexPaginate("ta_video", data).get_results()
if not to_delete:
return
for video in to_delete:
youtube_id = video["youtube_id"]
print(f"{youtube_id}: auto delete video")
YoutubeVideo(youtube_id).delete_media_file()
print("add deleted to ignore list")
vids = [{"type": "video", "url": i["youtube_id"]} for i in to_delete]
PendingList(youtube_ids=vids).parse_url_list(status="ignore")
def refresh_playlist(self) -> None:
"""match videos with playlists"""
self.add_playlists_to_refresh()
queue = RedisQueue(self.PLAYLIST_QUEUE)
while True:
total = queue.max_score()
playlist_id, idx = queue.get_next()
if not playlist_id or not idx or not total:
break
playlist = YoutubePlaylist(playlist_id)
playlist.update_playlist(skip_on_empty=True)
if not self.task:
continue
channel_name = playlist.json_data["playlist_channel"]
playlist_title = playlist.json_data["playlist_name"]
message = [
f"Post Processing Playlists for: {channel_name}",
f"{playlist_title} [{idx}/{total}]",
]
progress = idx / total
self.task.send_progress(message, progress=progress)
rand_sleep(self.config)
def add_playlists_to_refresh(self) -> None:
"""add playlists to refresh"""
if self.task:
message = ["Post Processing Playlists", "Scanning for Playlists"]
self.task.send_progress(message)
self._add_playlist_sub()
self._add_channel_playlists()
self._add_video_playlists()
def _add_playlist_sub(self):
"""add subscribed playlists to refresh"""
playlists = get_playlists(subscribed_only=True, source=["playlist_id"])
to_add = [i["playlist_id"] for i in playlists]
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
def _add_channel_playlists(self):
"""add playlists from channels to refresh"""
queue = RedisQueue(self.CHANNEL_QUEUE)
while True:
channel_id, _ = queue.get_next()
if not channel_id:
break
channel = YoutubeChannel(channel_id)
channel.get_from_es()
overwrites = channel.get_overwrites()
if "index_playlists" in overwrites:
channel.get_all_playlists()
to_add = [i[0] for i in channel.all_playlists]
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
def _add_video_playlists(self):
"""add other playlists for quick sync"""
all_playlists = RedisQueue(self.PLAYLIST_QUEUE).get_all()
must_not = [{"terms": {"playlist_id": all_playlists}}]
video_ids = RedisQueue(self.VIDEO_QUEUE).get_all()
must = [{"terms": {"playlist_entries.youtube_id": video_ids}}]
data = {
"query": {"bool": {"must_not": must_not, "must": must}},
"_source": ["playlist_id"],
}
playlists = IndexPaginate("ta_playlist", data).get_results()
to_add = [i["playlist_id"] for i in playlists]
RedisQueue(self.PLAYLIST_QUICK).add_list(to_add)
def match_videos(self) -> None:
"""scan rest of indexed playlists to match videos"""
queue = RedisQueue(self.PLAYLIST_QUICK)
while True:
total = queue.max_score()
playlist_id, idx = queue.get_next()
if not playlist_id or not idx or not total:
break
playlist = YoutubePlaylist(playlist_id)
playlist.get_from_es()
playlist.add_vids_to_playlist()
playlist.remove_vids_from_playlist()
if not self.task:
continue
message = [
"Post Processing Playlists.",
f"Validate Playlists: - {idx}/{total}",
]
progress = idx / total
self.task.send_progress(message, progress=progress)
def get_comments(self):
"""get comments from youtube"""
video_queue = RedisQueue(self.VIDEO_QUEUE)
comment_list = CommentList(task=self.task)
comment_list.add(video_ids=video_queue.get_all())
video_queue.clear()
comment_list.index()

18
backend/download/urls.py Normal file
View File

@@ -0,0 +1,18 @@
"""all download API urls"""
from django.urls import path
from download import views
urlpatterns = [
path("", views.DownloadApiListView.as_view(), name="api-download-list"),
path(
"aggs/",
views.DownloadAggsApiView.as_view(),
name="api-download-aggs",
),
path(
"<slug:video_id>/",
views.DownloadApiView.as_view(),
name="api-download",
),
]

356
backend/download/views.py Normal file
View File

@@ -0,0 +1,356 @@
"""all download API views"""
from common.serializers import (
AsyncTaskResponseSerializer,
ErrorResponseSerializer,
)
from common.views_base import AdminOnly, ApiBaseView
from download.serializers import (
AddToDownloadListSerializer,
AddToDownloadQuerySerializer,
BulkUpdateDowloadDataSerializer,
BulkUpdateDowloadQuerySerializer,
DownloadAggsSerializer,
DownloadItemSerializer,
DownloadListQuerySerializer,
DownloadListQueueDeleteQuerySerializer,
DownloadListSerializer,
DownloadQueueItemUpdateSerializer,
)
from download.src.queue_interact import PendingInteract
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.response import Response
from task.tasks import download_pending, extrac_dl
class DownloadApiListView(ApiBaseView):
"""resolves to /api/download/
GET: returns latest videos in the download queue
POST: add a list of videos to download queue
DELETE: remove items based on query filter
"""
search_base = "ta_download/_search/"
valid_filter = ["pending", "ignore"]
permission_classes = [AdminOnly]
@extend_schema(
responses={
200: OpenApiResponse(DownloadListSerializer()),
},
parameters=[DownloadListQuerySerializer()],
)
def get(self, request):
"""get download queue list"""
query_filter = request.GET.get("filter", False)
self.data.update(
{
"sort": [
{"auto_start": {"order": "desc"}},
{"timestamp": {"order": "asc"}},
],
}
)
serializer = DownloadListQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
must_list = []
query_filter = validated_data.get("filter")
if query_filter:
must_list.append({"term": {"status": {"value": query_filter}}})
filter_channel = validated_data.get("channel")
if filter_channel:
must_list.append(
{"term": {"channel_id": {"value": filter_channel}}}
)
vid_type_filter = validated_data.get("vid_type")
if vid_type_filter:
must_list.append(
{"term": {"vid_type": {"value": vid_type_filter}}}
)
search_query = validated_data.get("q")
if search_query:
must_list.append({"match_phrase_prefix": {"title": search_query}})
if validated_data.get("error") is not None:
operator = "must" if validated_data["error"] else "must_not"
must_list.append(
{"bool": {operator: [{"exists": {"field": "message"}}]}}
)
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
serializer = DownloadListSerializer(self.response)
return Response(serializer.data)
@staticmethod
@extend_schema(
request=AddToDownloadListSerializer(),
parameters=[AddToDownloadQuerySerializer()],
responses={
200: OpenApiResponse(
AsyncTaskResponseSerializer(),
description="New async task started",
),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
},
)
def post(request):
"""add list of videos to download queue"""
data_serializer = AddToDownloadListSerializer(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
query_serializer = AddToDownloadQuerySerializer(
data=request.query_params
)
query_serializer.is_valid(raise_exception=True)
validated_query = query_serializer.validated_data
auto_start = validated_query.get("autostart")
flat = validated_query.get("flat", False)
print(f"auto_start: {auto_start}, flat: {flat}")
to_add = validated_data["data"]
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
url_str = " ".join(pending)
task = extrac_dl.delay(url_str, auto_start=auto_start, flat=flat)
message = {
"message": "add to queue task started",
"task_id": task.id,
}
response_serializer = AsyncTaskResponseSerializer(message)
return Response(response_serializer.data)
@staticmethod
@extend_schema(
request=BulkUpdateDowloadDataSerializer(),
parameters=[BulkUpdateDowloadQuerySerializer()],
responses={204: OpenApiResponse(description="Status updated")},
)
def patch(request):
"""bulk update status"""
data_serializer = BulkUpdateDowloadDataSerializer(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
new_status = validated_data["status"]
query_serializer = BulkUpdateDowloadQuerySerializer(
data=request.query_params
)
query_serializer.is_valid(raise_exception=True)
validated_query = query_serializer.validated_data
status_filter = validated_query.get("filter")
channel = validated_query.get("channel")
vid_type = validated_query.get("vid_type")
error = validated_query.get("error")
PendingInteract(status=status_filter).update_bulk(
channel_id=channel,
vid_type=vid_type,
new_status=new_status,
error=error,
)
if new_status == "priority":
download_pending.delay(auto_only=True)
return Response(status=204)
@extend_schema(
parameters=[DownloadListQueueDeleteQuerySerializer()],
responses={
204: OpenApiResponse(description="Download items deleted"),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
},
)
def delete(self, request):
"""bulk delete download queue items by filter"""
serializer = DownloadListQueueDeleteQuerySerializer(
data=request.query_params
)
serializer.is_valid(raise_exception=True)
validated_query = serializer.validated_data
query_filter = validated_query["filter"]
channel = validated_query.get("channel")
vid_type = validated_query.get("vid_type")
message = f"delete queue by status: {query_filter}"
if channel:
message += f" - filter by channel: {channel}"
if vid_type:
message += f" - filter by vid_type: {vid_type}"
print(message)
PendingInteract(status=query_filter).delete_bulk(
channel_id=channel, vid_type=vid_type
)
return Response(status=204)
class DownloadApiView(ApiBaseView):
"""resolves to /api/download/<video_id>/
GET: returns metadata dict of an item in the download queue
POST: update status of item to pending or ignore
DELETE: forget from download queue
"""
search_base = "ta_download/_doc/"
valid_status = ["pending", "ignore", "ignore-force", "priority"]
permission_classes = [AdminOnly]
@extend_schema(
responses={
200: OpenApiResponse(DownloadItemSerializer()),
404: OpenApiResponse(
ErrorResponseSerializer(),
description="Download item not found",
),
},
)
def get(self, request, video_id):
# pylint: disable=unused-argument
"""get download queue item"""
self.get_document(video_id)
if not self.response:
error = ErrorResponseSerializer(
{"error": "Download item not found"}
)
return Response(error.data, status=404)
response_serializer = DownloadItemSerializer(self.response)
return Response(response_serializer.data, status=self.status_code)
@extend_schema(
request=DownloadQueueItemUpdateSerializer(),
responses={
200: OpenApiResponse(
DownloadQueueItemUpdateSerializer(),
description="Download item update",
),
400: OpenApiResponse(
ErrorResponseSerializer(), description="Bad request"
),
404: OpenApiResponse(
ErrorResponseSerializer(),
description="Download item not found",
),
},
)
def post(self, request, video_id):
"""post to video to change status"""
data_serializer = DownloadQueueItemUpdateSerializer(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
item_status = validated_data["status"]
if item_status == "ignore-force":
extrac_dl.delay(video_id, status="ignore")
return Response(data_serializer.data)
_, status_code = PendingInteract(video_id).get_item()
if status_code == 404:
error = ErrorResponseSerializer(
{"error": "Download item not found"}
)
return Response(error.data, status=404)
print(f"{video_id}: change status to {item_status}")
PendingInteract(video_id, item_status).update_status()
if item_status == "priority":
download_pending.delay(auto_only=True)
return Response(data_serializer.data)
@staticmethod
@extend_schema(
responses={
204: OpenApiResponse(description="delete download item"),
404: OpenApiResponse(
ErrorResponseSerializer(),
description="Download item not found",
),
},
)
def delete(request, video_id):
# pylint: disable=unused-argument
"""delete single video from queue"""
print(f"{video_id}: delete from queue")
PendingInteract(video_id).delete_item()
return Response(status=204)
class DownloadAggsApiView(ApiBaseView):
"""resolves to /api/download/aggs/
GET: get download aggregations
"""
search_base = "ta_download/_search"
valid_filter_view = ["ignore", "pending"]
@extend_schema(
parameters=[DownloadListQueueDeleteQuerySerializer()],
responses={
200: OpenApiResponse(DownloadAggsSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="bad request"
),
},
)
def get(self, request):
"""get aggs"""
serializer = DownloadListQueueDeleteQuerySerializer(
data=request.query_params
)
serializer.is_valid(raise_exception=True)
validated_query = serializer.validated_data
filter_view = validated_query.get("filter")
if filter_view:
if filter_view not in self.valid_filter_view:
message = f"invalid filter: {filter_view}"
return Response({"message": message}, status=400)
self.data.update(
{
"query": {"term": {"status": {"value": filter_view}}},
}
)
self.data.update(
{
"aggs": {
"channel_downloads": {
"multi_terms": {
"size": 30,
"terms": [
{"field": "channel_name.keyword"},
{"field": "channel_id"},
],
"order": {"_count": "desc"},
}
}
}
}
)
self.get_aggs()
serializer = DownloadAggsSerializer(self.response["channel_downloads"])
return Response(serializer.data)

View File

View File

View File

@@ -0,0 +1,96 @@
"""playlist serializers"""
# pylint: disable=abstract-method
from common.serializers import PaginationSerializer
from rest_framework import serializers
class PlaylistEntrySerializer(serializers.Serializer):
"""serialize single playlist entry"""
youtube_id = serializers.CharField()
title = serializers.CharField()
uploader = serializers.CharField()
idx = serializers.IntegerField()
downloaded = serializers.BooleanField()
class PlaylistSerializer(serializers.Serializer):
"""serialize playlist"""
playlist_active = serializers.BooleanField()
playlist_channel = serializers.CharField()
playlist_channel_id = serializers.CharField()
playlist_description = serializers.CharField()
playlist_entries = PlaylistEntrySerializer(many=True)
playlist_id = serializers.CharField()
playlist_last_refresh = serializers.CharField()
playlist_name = serializers.CharField()
playlist_subscribed = serializers.BooleanField()
playlist_sort_order = serializers.ChoiceField(choices=["top", "bottom"])
playlist_thumbnail = serializers.CharField()
playlist_type = serializers.ChoiceField(choices=["regular", "custom"])
_index = serializers.CharField(required=False)
_score = serializers.IntegerField(required=False)
class PlaylistListSerializer(serializers.Serializer):
"""serialize list of playlists"""
data = PlaylistSerializer(many=True)
paginate = PaginationSerializer()
class PlaylistListQuerySerializer(serializers.Serializer):
"""serialize playlist list query params"""
channel = serializers.CharField(required=False)
subscribed = serializers.BooleanField(required=False)
type = serializers.ChoiceField(
choices=["regular", "custom"], required=False
)
page = serializers.IntegerField(required=False)
class PlaylistSingleAddSerializer(serializers.Serializer):
"""single item to add"""
playlist_id = serializers.CharField()
playlist_subscribed = serializers.ChoiceField(choices=[True])
class PlaylistBulkAddSerializer(serializers.Serializer):
"""bulk add playlists serializers"""
data = PlaylistSingleAddSerializer(many=True)
class PlaylistSingleUpdate(serializers.Serializer):
"""update state of single playlist"""
playlist_subscribed = serializers.BooleanField(required=False)
playlist_sort_order = serializers.ChoiceField(
choices=["top", "bottom"], required=False
)
class PlaylistListCustomPostSerializer(serializers.Serializer):
"""serialize list post custom playlist"""
playlist_name = serializers.CharField()
class PlaylistCustomPostSerializer(serializers.Serializer):
"""serialize playlist custom action"""
action = serializers.ChoiceField(
choices=["create", "remove", "up", "down", "top", "bottom"]
)
video_id = serializers.CharField()
class PlaylistDeleteQuerySerializer(serializers.Serializer):
"""serialize playlist delete query params"""
delete_videos = serializers.BooleanField(required=False)

View File

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