Compare commits

...

177 Commits

Author SHA1 Message Date
Simon
005d2d1cf5 Separate watched filter, #build
Changed:
- Split watched state filter by home, channel videos and playlist videos
- Bumped yt-dlp
2025-08-23 12:33:31 +07:00
Simon
bc3463bdd8 add unstable tag 2025-08-23 12:32:39 +07:00
Simon
39c66019eb bump yt-dlp 2025-08-23 12:18:51 +07:00
Simon
a9264e348f watched filter split by channel and playlist 2025-08-23 12:17:55 +07:00
Simon
ba44ab252e bump TA_VERSION 2025-08-21 17:27:02 +07:00
Simon
b6e95c6125 fix unittest workflow 2025-08-21 17:10:36 +07:00
Simon
90e3a5c634 don't open yt-dlp issues here 2025-08-21 17:08:09 +07:00
Simon
3fe26fa6a9 add membership handling endpoints 2025-08-21 17:00:54 +07:00
Simon
dc6276803d Updated yt-dlp, #build 2025-08-20 15:49:35 +07:00
Simon
64a05d561f moved dev requirements to root 2025-08-20 15:48:50 +07:00
Simon
4766f703f2 bump requirements 2025-08-20 15:47:19 +07:00
João Ferreira Batista
d1da9f2f02 Fixes error message presented in the settins scheduling frontend (#1035)
When you submit an incorrect cron (for example ``0 5``) the frontend doesn't
surface the error message that the API returns, but only the default generic
one. This fix makes it surface when the api returns the message in the error
attribute and not in the message attribute.
2025-08-20 15:34:42 +07:00
MerlinScheurer
d2a6bdb18c Add no-store cache header on index.html in nginx 2025-08-19 23:09:09 +02:00
Simon
9e6509f7d1 bump TA_VERSION for release 2025-08-18 08:56:28 +07:00
arisenfromtheashes
3d48b38c62 Update README.md (#1031) 2025-08-18 08:54:03 +07:00
Simon
08e8381124 Bulk select, #build
Changed:
- Added bulk select to table view
2025-08-14 19:41:24 +07:00
Simon
7f944a4805 add select all checkbox to table view 2025-08-14 19:40:18 +07:00
Simon
2ab64b565f Bulk redownload, #build
Changed:
- Added bulk selection framework
- Added bulk redownload
- Make filters nullable
- Align filter UI/UX
2025-08-14 17:23:31 +07:00
Simon
d9a3d7f12f add playlist subscribed filter, align filter UIUX 2025-08-14 17:13:15 +07:00
Simon
41c9415b3f fix view-icon grid 2025-08-14 16:17:53 +07:00
Simon
c6d0e02f16 fix playlist incomplete index, slice in queue, #942 2025-08-14 15:51:38 +07:00
MerlinScheurer
be278fe5f6 Fix multi select svg - remove weird svg prefix 2025-08-13 18:17:03 +02:00
Simon
3cf9b5ed40 add multi select, add redownload 2025-08-13 14:21:33 +07:00
Simon
9c5e4b599f add filter toggle icon 2025-08-12 14:27:43 +07:00
Simon
f2da730aa2 add video filter by height 2025-08-12 13:58:14 +07:00
Simon
445ef8ad05 update mweb player-client for potoken 2025-08-11 21:15:30 +07:00
Simon
5804fc09e6 add vid_type_filter to home and playlist views 2025-08-11 20:46:16 +07:00
Simon
d8e5ee7e04 make watched filter nullable dropdown 2025-08-11 19:55:27 +07:00
Simon
59331b2b40 remove indexing from ta_config index 2025-08-11 17:28:25 +07:00
Simon
a925660078 fix classname type 2025-08-11 17:03:44 +07:00
Simon
7c28175e9b add missing sponsorblock description field 2025-08-11 16:41:09 +07:00
Simon
8161ee6c74 explain develop branch 2025-08-11 15:35:44 +07:00
Simon
26423e11c8 Update dependencies, #build
Changed:
- Update yt-dlp to latest release for upstream fixes
- Fix videos delete from playlist
- Index all sponsorblock categories
- Better missing items and loading indicator
2025-08-11 15:12:33 +07:00
Atinoda
bcb216d862 Get all sponsorblock segment types (testing) (#1021)
* Pull all sponsorblock segment types

* Respect segment skip on `segment.actionType`

* Lint with `prettier`

* Lint with `black`
2025-08-11 15:06:12 +07:00
Craig Alexander
dc26354020 Unify not found messaging (#1020)
* Move all not found messages into list components

* match the original style

* Use loading indicators
2025-08-11 14:18:33 +07:00
Simon
507c77fdbb fix add to ignore from existing video, #1023 2025-08-11 13:52:59 +07:00
Simon
1a4539c9ba bump requirements 2025-08-11 13:52:59 +07:00
MerlinScheurer
9606eb44c7 Update frontend dependencies 2025-08-10 11:00:22 +02:00
Simon
b618a7c282 fix delete_videos param for delete playlist, #1019 2025-08-08 13:06:36 +07:00
Simon
aa58f03323 update roadmap 2025-08-01 00:00:46 +07:00
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
146 changed files with 5964 additions and 3864 deletions

2
.gitattributes vendored
View File

@@ -1 +1 @@
docker_assets\run.sh eol=lf * text=auto eol=lf

View File

@@ -15,7 +15,8 @@ body:
options: options:
- label: I'm running the latest version of Tube Archivist and have read the [release notes](https://github.com/tubearchivist/tubearchivist/releases/latest). - 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 required: true
- 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. - 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. I've double checked that I don't open a yt-dlp issue here.
required: true required: true
- type: input - type: input

View File

@@ -10,3 +10,5 @@ body:
options: options:
- label: I understand that this issue will be closed without comment. - label: I understand that this issue will be closed without comment.
required: true 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

View File

@@ -1,23 +0,0 @@
name: Frontend Migration
description: Tracking our new React based frontend
title: "[Frontend Migration]: "
labels: ["react migration"]
body:
- type: dropdown
id: domain
attributes:
label: Domain
options:
- Frontend
- Backend
- Combined
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
placeholder: Organizing our React frontend migration
validations:
required: true

View File

@@ -37,7 +37,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r backend/requirements-dev.txt pip install -r requirements-dev.txt
- name: Run unit tests - name: Run unit tests
run: pytest backend run: pytest backend

2
.gitignore vendored
View File

@@ -12,3 +12,5 @@ backend/.env
# JavaScript stuff # JavaScript stuff
node_modules node_modules
.editorconfig

View File

@@ -19,7 +19,7 @@ repos:
files: ^backend/ files: ^backend/
args: ["--profile", "black", "-l 79"] args: ["--profile", "black", "-l 79"]
- repo: https://github.com/pycqa/flake8 - repo: https://github.com/pycqa/flake8
rev: 7.1.2 rev: 7.3.0
hooks: hooks:
- id: flake8 - id: flake8
alias: python alias: python
@@ -31,7 +31,7 @@ repos:
- id: codespell - id: codespell
exclude: ^frontend/package-lock.json exclude: ^frontend/package-lock.json
- repo: https://github.com/pre-commit/mirrors-eslint - repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.22.0 rev: v9.30.1
hooks: hooks:
- id: eslint - id: eslint
name: eslint name: eslint

View File

@@ -16,20 +16,20 @@ Welcome, and thanks for showing interest in improving Tube Archivist!
--- ---
## Beta Testing ## Beta Testing
Be the first to help test new features and improvements and provide feedback! There are regular `:unstable` builds for easy access. That's for the tinkerers and the breave. Ideally use a testing environment first, before a release be the first to install it on your main system. 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 what has changed. 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 features mentioned, play around, try to break it.
- Test the update path by installing the `:latest` release first, the upgrade to `:unstable` to check for any errors. - 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. - Test the unstable build on a fresh install.
Then provide feedback, if there is a problem but also if there is no problem. Reach out on [Discord](https://tubearchivist.com/discord) in the `#beta-testing` channel with your findings. 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 will help with a smooth update for the regular release. Plus you get to test things out early! This helps ensure a smooth update for the stable release. Plus you get to test things out early!
## How to open an issue ## How to open an issue
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub. Make sure you read [Next Steps](#next-steps) above. Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
**Do**: **Do**:
- Do provide details and context, this matters a lot and makes it easier for people to help. - Do provide details and context, this matters a lot and makes it easier for people to help.
@@ -37,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. - 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**:
- 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 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 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. - 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.
@@ -46,6 +46,7 @@ Please read this carefully before opening any [issue](https://github.com/tubearc
Bug reports are highly welcome! This project has improved a lot due to your help by providing feedback when something doesn't work as expected. The developers can't possibly cover all edge cases in an ever changing environment like YouTube and yt-dlp. Bug reports are highly welcome! This project has improved a lot due to your help by providing feedback when something doesn't work as expected. The developers can't possibly cover all edge cases in an ever changing environment like YouTube and yt-dlp.
Please keep in mind: Please keep in mind:
- Don't report bugs from yt-dlp here. There is a [dedicated repo](https://github.com/yt-dlp/yt-dlp/issues) for that. Make sure to check for duplicates before opening a new issue there.
- Docker logs are the easiest way to understand what's happening when something goes wrong, *always* provide the logs upfront. - Docker logs are the easiest way to understand what's happening when something goes wrong, *always* provide the logs upfront.
- Set the environment variable `DJANGO_DEBUG=True` to Tube Archivist and reproduce the bug for a better log output. Don't forget to remove that variable again after. - Set the environment variable `DJANGO_DEBUG=True` to Tube Archivist and reproduce the bug for a better log output. Don't forget to remove that variable again after.
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*. - A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
@@ -65,16 +66,21 @@ IMPORTANT: When receiving help, contribute back to the community by improving th
## How to make a Pull Request ## How to make a Pull Request
Make sure you read [Next Steps](#next-steps) above. Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application.
Thank you for contributing and helping improve this project. Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application.
This is a quick checklist to help streamline the process: 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. - NEW: Make your PR against the [develop branch](https://github.com/tubearchivist/tubearchivist/tree/develop). 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. - 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.
### Documentation Changes
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.
- 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 ### Code formatting and linting
@@ -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. - 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. - 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. - If all of that sounds too strict for you, as stated above, start becoming a regular contributor to this project.
--- ---
@@ -127,7 +133,7 @@ The documentation available at [docs.tubearchivist.com](https://docs.tubearchivi
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. 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.
Note: 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. - 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.
### Native Instruction ### Native Instruction
@@ -177,12 +183,6 @@ And the frontend should be available at [localhost:3000](localhost:3000).
### Docker Instructions ### Docker Instructions
Set up docker on your development machine.
Clone this repository.
Functional changes should be made against the unstable `testing` branch, so check that branch out, then make a new branch for your work.
Edit the `docker-compose.yml` file and replace the [`image: bbilly1/tubearchivist` line](https://github.com/tubearchivist/tubearchivist/blob/4af12aee15620e330adf3624c984c3acf6d0ac8b/docker-compose.yml#L7) with `build: .`. Also make any other changes to the environment variables and so on necessary to run the application, just like you're launching the application as normal. Edit the `docker-compose.yml` file and replace the [`image: bbilly1/tubearchivist` line](https://github.com/tubearchivist/tubearchivist/blob/4af12aee15620e330adf3624c984c3acf6d0ac8b/docker-compose.yml#L7) with `build: .`. Also make any other changes to the environment variables and so on necessary to run the application, just like you're launching the application as normal.
Run `docker compose up --build`. This will bring up the application. Kill it with `ctrl-c` or by running `docker compose down` from a new terminal window in the same directory. Run `docker compose up --build`. This will bring up the application. Kill it with `ctrl-c` or by running `docker compose down` from a new terminal window in the same directory.

View File

@@ -1,20 +1,24 @@
# multi stage to build tube archivist # multi stage to build tube archivist
# build python wheel, download and extract ffmpeg, 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 FROM node:lts-alpine AS node-builder
# RUN npm config set registry https://registry.npmjs.org/ # RUN npm config set registry https://registry.npmjs.org/
COPY --from=npm-builder ./node_modules /frontend/node_modules
COPY ./frontend /frontend COPY ./frontend /frontend
WORKDIR /frontend WORKDIR /frontend
RUN npm i
RUN npm run build:deploy RUN npm run build:deploy
WORKDIR / WORKDIR /
# First stage to build python wheel # First stage to build python wheel
FROM python:3.11.8-slim-bookworm AS builder FROM python:3.11.13-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
@@ -24,7 +28,7 @@ COPY ./backend/requirements.txt /requirements.txt
RUN pip install --user -r requirements.txt RUN pip install --user -r requirements.txt
# build ffmpeg # build ffmpeg
FROM python:3.11.8-slim-bookworm AS ffmpeg-builder FROM python:3.11.13-slim-bookworm AS ffmpeg-builder
ARG TARGETPLATFORM ARG TARGETPLATFORM
@@ -32,7 +36,7 @@ COPY docker_assets/ffmpeg_download.py ffmpeg_download.py
RUN python ffmpeg_download.py $TARGETPLATFORM RUN python ffmpeg_download.py $TARGETPLATFORM
# build final image # build final image
FROM python:3.11.8-slim-bookworm AS tubearchivist FROM python:3.11.13-slim-bookworm AS tubearchivist
ARG INSTALL_DEBUG ARG INSTALL_DEBUG
@@ -54,9 +58,9 @@ RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recomm
# install debug tools for testing environment # install debug tools for testing environment
RUN if [ "$INSTALL_DEBUG" ] ; then \ RUN if [ "$INSTALL_DEBUG" ] ; then \
apt-get -y update && apt-get -y install --no-install-recommends \ apt-get -y update && apt-get -y install --no-install-recommends \
vim htop bmon net-tools iputils-ping procps lsof \ vim htop bmon net-tools iputils-ping procps lsof \
&& pip install --user ipython pytest pytest-django \ && pip install --user ipython pytest pytest-django \
; fi ; fi
# make folders # make folders
@@ -70,6 +74,7 @@ RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
COPY ./backend /app COPY ./backend /app
COPY ./docker_assets/run.sh /app COPY ./docker_assets/run.sh /app
COPY ./docker_assets/backend_start.py /app COPY ./docker_assets/backend_start.py /app
COPY ./docker_assets/beat_auto_spawn.sh /app
COPY --from=node-builder ./frontend/dist /app/static COPY --from=node-builder ./frontend/dist /app/static

View File

@@ -71,7 +71,17 @@ All environment variables are explained in detail in the docs [here](https://doc
| ELASTIC_USER | Change the default ElasticSearch user | Optional | | ELASTIC_USER | Change the default ElasticSearch user | Optional |
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) | | TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
| DISABLE_STATIC_AUTH | Remove authentication from media files, (Google Cast...) | [Read more](https://docs.tubearchivist.com/installation/env-vars/#disable_static_auth) | | 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 | | DJANGO_DEBUG | Return additional error messages, for debug only | Optional |
| TA_LOGIN_AUTH_MODE | Configure the order of login authentication backends (Default: single) | Optional |
| 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** **ElasticSearch**
| Environment Var | Value | State | | Environment Var | Value | State |
@@ -81,13 +91,13 @@ All environment variables are explained in detail in the docs [here](https://doc
## Update ## 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 update, you need to update the docker images, the method for which will depend on your platform. For example, if you're using `docker-compose`, run `docker-compose pull` and then restart with `docker-compose up -d`. After updating, check the footer to verify you are running the expected version. 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. - 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. - There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there.
- All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version. - All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated 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 ## Getting Started
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example: 1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example:
@@ -158,10 +168,10 @@ We have come far, nonetheless we are not short of ideas on how to improve and ex
- [ ] Download or Ignore videos by keyword ([#163](https://github.com/tubearchivist/tubearchivist/issues/163)) - [ ] 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)) - [ ] Custom searchable notes to videos, channels, playlists ([#144](https://github.com/tubearchivist/tubearchivist/issues/144))
- [ ] Search comments - [ ] Search comments
- [ ] Search download queue
- [ ] Per user videos/channel/playlists - [ ] Per user videos/channel/playlists
Implemented: Implemented:
- [X] Search download queue [2025-07-31]
- [X] Configure shorts, streams and video sizes per channel [2024-07-15] - [X] Configure shorts, streams and video sizes per channel [2024-07-15]
- [X] User created playlists [2024-04-10] - [X] User created playlists [2024-04-10]
- [X] User roles, aka read only user [2023-11-10] - [X] User roles, aka read only user [2023-11-10]
@@ -204,6 +214,7 @@ This is your time to shine, [read this](https://github.com/tubearchivist/tubearc
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles. - [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 - [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 - [Dhs92/delete_shorts](https://github.com/Dhs92/delete_shorts): A script to delete ALL YouTube Shorts from TubeArchivist
- [arisenfromtheashes/TA_DVR](https://github.com/arisenfromtheashes/TA_DVR): Scripts to assist in using Tube Archivist like a DVR
## Donate ## Donate
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started. The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.

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

View File

@@ -1,12 +1,7 @@
{ {
"index_config": [{ "index_config": [{
"index_name": "config", "index_name": "config",
"expected_map": { "expected_map": {},
"config": {
"type": "object",
"enabled": false
}
},
"expected_set": { "expected_set": {
"number_of_replicas": "0" "number_of_replicas": "0"
} }
@@ -62,6 +57,9 @@
} }
} }
}, },
"channel_tabs": {
"type": "keyword"
},
"channel_overwrites": { "channel_overwrites": {
"properties": { "properties": {
"download_format": { "download_format": {
@@ -165,6 +163,9 @@
} }
} }
}, },
"channel_tabs": {
"type": "keyword"
},
"channel_overwrites": { "channel_overwrites": {
"properties": { "properties": {
"download_format": { "download_format": {
@@ -239,7 +240,8 @@
"type": "keyword" "type": "keyword"
}, },
"published": { "published": {
"type": "date" "type": "date",
"format": "epoch_second||strict_date_optional_time"
}, },
"playlist": { "playlist": {
"type": "text", "type": "text",
@@ -361,6 +363,15 @@
"category": { "category": {
"type": "keyword" "type": "keyword"
}, },
"description": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"locked": { "locked": {
"type": "short" "type": "short"
}, },
@@ -460,6 +471,15 @@
"playlist_description": { "playlist_description": {
"type": "text" "type": "text"
}, },
"playlist_subscribed": {
"type": "boolean"
},
"playlist_type": {
"type": "keyword"
},
"playlist_active": {
"type": "boolean"
},
"playlist_name": { "playlist_name": {
"type": "text", "type": "text",
"analyzer": "english", "analyzer": "english",
@@ -496,6 +516,9 @@
"type": "date", "type": "date",
"format": "epoch_second" "format": "epoch_second"
}, },
"playlist_sort_order": {
"type": "keyword"
},
"playlist_entries": { "playlist_entries": {
"properties": { "properties": {
"downloaded": { "downloaded": {

View File

@@ -21,10 +21,16 @@ class AppConfigSubSerializer(
): ):
"""serialize app config subscriptions""" """serialize app config subscriptions"""
channel_size = serializers.IntegerField(required=False) channel_size = serializers.IntegerField(required=False, allow_null=True)
live_channel_size = serializers.IntegerField(required=False) live_channel_size = serializers.IntegerField(
shorts_channel_size = serializers.IntegerField(required=False) 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) auto_start = serializers.BooleanField(required=False)
extract_flat = serializers.BooleanField(required=False)
class AppConfigDownloadsSerializer( class AppConfigDownloadsSerializer(
@@ -130,4 +136,4 @@ class SnapshotRestoreResponseSerializer(serializers.Serializer):
class TokenResponseSerializer(serializers.Serializer): class TokenResponseSerializer(serializers.Serializer):
"""serialize token response""" """serialize token response"""
token = serializers.CharField() token = serializers.CharField(allow_null=True)

View File

@@ -0,0 +1,31 @@
"""membership platform serializers"""
# pylint: disable=abstract-method
from rest_framework import serializers
class MembershipUserSerializer(serializers.Serializer):
"""serialize user"""
id = serializers.IntegerField()
username = serializers.CharField()
class SponsortierSerializer(serializers.Serializer):
"""serialize sponsor tier"""
tier_id = serializers.IntegerField()
name = serializers.CharField()
description = serializers.CharField()
max_subs = serializers.IntegerField()
class MembershipProfileSerializer(serializers.Serializer):
"""serialize membership profile"""
id = serializers.IntegerField()
user = MembershipUserSerializer()
sponsor_tier = SponsortierSerializer()
subscription_count = serializers.IntegerField()
subscription_is_max = serializers.BooleanField()

View File

@@ -153,7 +153,7 @@ class ElasticBackup:
def restore(self, filename): def restore(self, filename):
""" """
restore from backup zip file restore from backup zip file
call reset from ElasitIndexWrap first to start blank call reset from ElasticIndexWrap first to start blank
""" """
zip_content = self._unpack_zip_backup(filename) zip_content = self._unpack_zip_backup(filename)
self._restore_json_files(zip_content) self._restore_json_files(zip_content)

View File

@@ -21,7 +21,9 @@ class SubscriptionsConfigType(TypedDict):
channel_size: int channel_size: int
live_channel_size: int live_channel_size: int
shorts_channel_size: int shorts_channel_size: int
playlist_size: int
auto_start: bool auto_start: bool
extract_flat: bool
class DownloadsConfigType(TypedDict): class DownloadsConfigType(TypedDict):
@@ -72,7 +74,9 @@ class AppConfig:
"channel_size": 50, "channel_size": 50,
"live_channel_size": 50, "live_channel_size": 50,
"shorts_channel_size": 50, "shorts_channel_size": 50,
"playlist_size": 50,
"auto_start": False, "auto_start": False,
"extract_flat": False,
}, },
"downloads": { "downloads": {
"limit_speed": None, "limit_speed": None,

View File

@@ -35,7 +35,7 @@ class ElasticIndex:
returns True when rebuild is needed returns True when rebuild is needed
""" """
if self.expected_map: if self.expected_map or self.expected_map == {}:
rebuild = self.validate_mappings() rebuild = self.validate_mappings()
if rebuild: if rebuild:
return rebuild return rebuild
@@ -49,7 +49,7 @@ class ElasticIndex:
def validate_mappings(self): def validate_mappings(self):
"""check if all mappings are as expected""" """check if all mappings are as expected"""
now_map = self.details["mappings"]["properties"] now_map = self.details["mappings"].get("properties", {})
for key, value in self.expected_map.items(): for key, value in self.expected_map.items():
# nested # nested
@@ -75,6 +75,10 @@ class ElasticIndex:
print(f"detected mapping change: {key}, {value}") print(f"detected mapping change: {key}, {value}")
return True return True
# simple doc store
if self.expected_map == {} and now_map != self.expected_map:
return True
return False return False
def validate_settings(self): def validate_settings(self):
@@ -135,13 +139,16 @@ class ElasticIndex:
data = {} data = {}
if self.expected_set: if self.expected_set:
data.update({"settings": self.expected_set}) data.update({"settings": self.expected_set})
if self.expected_map: if self.expected_map or self.expected_map == {}:
data.update({"mappings": {"properties": self.expected_map}}) data.update({"mappings": {"properties": self.expected_map}})
if self.index_name == "config":
# no indexing for config
data["mappings"]["dynamic"] = False
_, _ = ElasticWrap(path).put(data) _, _ = ElasticWrap(path).put(data)
class ElasitIndexWrap: class ElasticIndexWrap:
"""interact with all index mapping and setup""" """interact with all index mapping and setup"""
def __init__(self): def __init__(self):
@@ -201,7 +208,15 @@ class ElasitIndexWrap:
if self.backup_run: if self.backup_run:
return 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"]: if config["application"]["enable_snapshot"]:
# take snapshot if enabled # take snapshot if enabled
ElasticSnapshot().take_snapshot_now(wait=True) ElasticSnapshot().take_snapshot_now(wait=True)

View File

@@ -0,0 +1,89 @@
"""
interact with members.tubearchivist.com
code related to sponsor perks
"""
from os import environ
import requests
from appsettings.src.config import AppConfig
from common.src.helper import get_channels
from common.src.ta_redis import RedisArchivist
class Membership:
"""membership"""
BASE_URL = environ.get("MB_URL", "https://members.tubearchivist.com")
REDIS_KEY = "MB:KEY"
def __init__(self):
self.config = AppConfig().config
def get_profile(self):
"""get profile"""
response = requests.get(
f"{self.BASE_URL}/api/profile/me/",
headers=self._get_headers(),
timeout=30,
)
return response
def _get_headers(self):
"""get headers with api key"""
token = RedisArchivist().get_message_dict(self.REDIS_KEY)
if not token:
raise ValueError("expected MB_API_KEY")
token_str = token["token"]
return {"Authorization": f"Token {token_str}"}
def sync_subs(self):
"""sync subscriptions, works if within max limits"""
to_sync = self._get_to_sync()
response = requests.post(
f"{self.BASE_URL}/api/profile/subscription/?delete=true",
headers=self._get_headers(),
json=to_sync,
timeout=30,
)
return response
def _get_to_sync(self):
"""get channels to sync"""
to_sync = []
subscribed = get_channels(subscribed_only=True)
for channel in subscribed:
overwrites = channel.get("channel_overwrites", {})
to_sync.append(
{
"channel_id": channel["channel_id"],
"notify_videos": self._notify_videos(overwrites),
"notify_streams": self._notify_streams(overwrites),
"notify_shorts": self._notify_shorts(overwrites),
}
)
return to_sync
def _notify_videos(self, overwrites: dict) -> bool:
"""notify videos"""
if overwrites.get("subscriptions_channel_size") == 0:
return False
return self.config["subscriptions"].get("channel_size") != 0
def _notify_streams(self, overwrites: dict) -> bool:
"""notify streams"""
if overwrites.get("subscriptions_live_channel_size") == 0:
return False
return self.config["subscriptions"].get("live_channel_size") != 0
def _notify_shorts(self, overwrites: dict) -> bool:
"""notify shorts"""
if overwrites.get("subscriptions_shorts_channel_size") == 0:
return False
return self.config["subscriptions"].get("shorts_channel_size") != 0

View File

@@ -11,11 +11,11 @@ from typing import Callable, TypedDict
from appsettings.src.config import AppConfig from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel 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.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import rand_sleep from common.src.helper import rand_sleep
from common.src.ta_redis import RedisQueue from common.src.ta_redis import RedisQueue
from download.src.subscriptions import ChannelSubscription
from download.src.thumbnails import ThumbManager from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import CookieHandler from download.src.yt_dlp_base import CookieHandler
from playlist.src.index import YoutubePlaylist from playlist.src.index import YoutubePlaylist
@@ -376,7 +376,7 @@ class Reindex(ReindexBase):
channel.upload_to_es() channel.upload_to_es()
channel.sync_to_videos() channel.sync_to_videos()
ChannelFullScan(channel_id).scan() ChannelFullScan(channel_id, self.config).scan()
self.processed["channels"] += 1 self.processed["channels"] += 1
def _reindex_single_playlist(self, playlist_id: str) -> None: def _reindex_single_playlist(self, playlist_id: str) -> None:
@@ -493,13 +493,11 @@ class ReindexProgress(ReindexBase):
class ChannelFullScan: class ChannelFullScan:
""" """full scan of channel to fix vid_type mismatch"""
update from v0.3.0 to v0.3.1
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.channel_id = channel_id
self.config = config
self.to_update = False self.to_update = False
def scan(self): def scan(self):
@@ -510,12 +508,14 @@ class ChannelFullScan:
self.to_update = [] self.to_update = []
for video in all_local_videos: for video in all_local_videos:
video_id = video["youtube_id"] 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: if not remote_match:
print(f"{video_id}: no remote match found") print(f"{video_id}: no remote match found")
continue continue
expected_type = remote_match[0][-1] expected_type = remote_match[0]["vid_type"]
if video["vid_type"] != expected_type: if video["vid_type"] != expected_type:
self.to_update.append( self.to_update.append(
{ {
@@ -528,9 +528,8 @@ class ChannelFullScan:
def _get_all_remote(self): def _get_all_remote(self):
"""get all channel videos""" """get all channel videos"""
sub = ChannelSubscription() all_remote_videos = get_last_channel_videos(
all_remote_videos = sub.get_last_youtube_videos( self.channel_id, self.config, limit=False
self.channel_id, limit=False
) )
return all_remote_videos return all_remote_videos

View File

@@ -1,6 +1,6 @@
"""all app settings API urls""" """all app settings API urls"""
from appsettings import views from appsettings import views, views_mb
from django.urls import path from django.urls import path
urlpatterns = [ urlpatterns = [
@@ -44,4 +44,19 @@ urlpatterns = [
views.TokenView.as_view(), views.TokenView.as_view(),
name="api-token", name="api-token",
), ),
path(
"membership/profile/",
views_mb.MembershipProfileView.as_view(),
name="api-membership-profile",
),
path(
"membership/sync/",
views_mb.MembershipSubscriptionSync.as_view(),
name="api-membership-sync",
),
path(
"membership/token/",
views_mb.MembershipToken.as_view(),
name="api-membership-token",
),
] ]

View File

@@ -0,0 +1,122 @@
"""membership platform views"""
from json import JSONDecodeError
from appsettings.serializers import TokenResponseSerializer
from appsettings.serializers_mb import MembershipProfileSerializer
from appsettings.src.membership import Membership
from common.serializers import ErrorResponseSerializer
from common.src.ta_redis import RedisArchivist
from common.views_base import AdminOnly, ApiBaseView
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.response import Response
class MembershipProfileView(ApiBaseView):
"""resolves to /api/appsettings/membership/profile/
GET: get profile status
"""
permission_classes = [AdminOnly]
@staticmethod
@extend_schema(
responses={
200: OpenApiResponse(MembershipProfileSerializer()),
400: OpenApiResponse(
ErrorResponseSerializer(), description="bad request"
),
}
)
def get(request):
"""get profile"""
try:
profile_response = Membership().get_profile()
except ValueError as error:
error = ErrorResponseSerializer({"message": str(error)})
return Response(error.data, status=400)
try:
response_json = profile_response.json()
except JSONDecodeError:
code = profile_response.status_code
message = f"Connection to remote server failed: {code}"
error_message = {"message": message}
return Response(error_message, status=400)
if profile_response.status_code == 403:
message = response_json.get("detail", "undefined error")
error_message = {"message": message}
return Response(error_message, status=400)
serializer = MembershipProfileSerializer(data=response_json)
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
class MembershipSubscriptionSync(ApiBaseView):
"""resolves to /api/appsettings/membership/sync/
POST: trigger sync task
"""
permission_classes = [AdminOnly]
@staticmethod
def post(request):
"""post request"""
response = Membership().sync_subs()
if not response.ok:
try:
response_json = response.json()
message = response_json.get("detail", "undefined error")
except JSONDecodeError:
code = response.status_code
message = f"Connection to remote server failed: {code}"
error_message = {"message": message}
return Response(error_message, status=400)
return Response(status=204)
class MembershipToken(ApiBaseView):
"""resolves to /api/appsettings/membership/token/
GET: get masked token
POST: add token
DELETE: delete token
"""
permission_classes = [AdminOnly]
REDIS_KEY = "MB:KEY"
def get(self, request):
"""get token"""
token = RedisArchivist().get_message_dict(self.REDIS_KEY)
if token:
serializer = TokenResponseSerializer(data=token)
serializer.is_valid(raise_exception=True)
data = serializer.data
else:
data = {"token": None}
return Response(data)
def post(self, request):
"""add token"""
serializer = TokenResponseSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
RedisArchivist().set_message(
self.REDIS_KEY, message=serializer.data, save=True
)
return Response(serializer.data)
def delete(self, request):
"""delete token"""
RedisArchivist().del_message(self.REDIS_KEY)
return Response(status=204)

View File

@@ -4,6 +4,7 @@
from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin
from rest_framework import serializers from rest_framework import serializers
from video.src.constants import VideoTypeEnum
class ChannelOverwriteSerializer( class ChannelOverwriteSerializer(
@@ -45,6 +46,9 @@ class ChannelSerializer(serializers.Serializer):
channel_tags = serializers.ListField( channel_tags = serializers.ListField(
child=serializers.CharField(), required=False child=serializers.CharField(), required=False
) )
channel_tabs = serializers.ListField(
child=serializers.ChoiceField(VideoTypeEnum.values_known())
)
channel_views = serializers.IntegerField() channel_views = serializers.IntegerField()
_index = serializers.CharField(required=False) _index = serializers.CharField(required=False)
_score = serializers.IntegerField(required=False) _score = serializers.IntegerField(required=False)
@@ -60,7 +64,9 @@ class ChannelListSerializer(serializers.Serializer):
class ChannelListQuerySerializer(serializers.Serializer): class ChannelListQuerySerializer(serializers.Serializer):
"""serialize list query""" """serialize list query"""
filter = serializers.ChoiceField(choices=["subscribed"], required=False) filter = serializers.ChoiceField(
choices=["subscribed", "unsubscribed"], required=False
)
page = serializers.IntegerField(required=False) page = serializers.IntegerField(required=False)
@@ -90,6 +96,7 @@ class ChannelNavSerializer(serializers.Serializer):
"""serialize channel navigation""" """serialize channel navigation"""
has_pending = serializers.BooleanField() has_pending = serializers.BooleanField()
has_ignored = serializers.BooleanField()
has_playlists = serializers.BooleanField() has_playlists = serializers.BooleanField()
has_videos = serializers.BooleanField() has_videos = serializers.BooleanField()
has_streams = serializers.BooleanField() has_streams = serializers.BooleanField()

View File

@@ -4,17 +4,17 @@ functionality:
- index and update in es - index and update in es
""" """
import json
import os import os
from datetime import datetime from datetime import datetime
from channel.src.remote_query import get_last_channel_videos
from common.src.env_settings import EnvironmentSettings from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import rand_sleep from common.src.helper import rand_sleep
from common.src.index_generic import YouTubeItem from common.src.index_generic import YouTubeItem
from download.src.thumbnails import ThumbManager from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import YtWrap from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist from video.src.constants import VideoTypeEnum
class YoutubeChannel(YouTubeItem): class YoutubeChannel(YouTubeItem):
@@ -70,6 +70,7 @@ class YoutubeChannel(YouTubeItem):
"channel_thumb_url": self._get_thumb_art(), "channel_thumb_url": self._get_thumb_art(),
"channel_tvart_url": self._get_tv_art(), "channel_tvart_url": self._get_tv_art(),
"channel_views": self.youtube_meta.get("view_count") or 0, "channel_views": self.youtube_meta.get("view_count") or 0,
"channel_tabs": self.get_channel_tabs(),
} }
def _get_thumb_art(self): def _get_thumb_art(self):
@@ -105,6 +106,31 @@ class YoutubeChannel(YouTubeItem):
return False 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): def _video_fallback(self, fallback):
"""use video metadata as fallback""" """use video metadata as fallback"""
print(f"{self.youtube_id}: fallback to video metadata") print(f"{self.youtube_id}: fallback to video metadata")
@@ -122,27 +148,6 @@ class YoutubeChannel(YouTubeItem):
"channel_thumb_url": False, "channel_thumb_url": False,
"channel_views": 0, "channel_views": 0,
} }
self._info_json_fallback()
def _info_json_fallback(self):
"""read channel info.json for additional metadata"""
info_json = os.path.join(
EnvironmentSettings.CACHE_DIR,
"import",
f"{self.youtube_id}.info.json",
)
if os.path.exists(info_json):
print(f"{self.youtube_id}: read info.json file")
with open(info_json, "r", encoding="utf-8") as f:
content = json.loads(f.read())
self.json_data.update(
{
"channel_subs": content.get("channel_follower_count", 0),
"channel_description": content.get("description", False),
}
)
os.remove(info_json)
def get_channel_art(self): def get_channel_art(self):
"""download channel art for new channels""" """download channel art for new channels"""
@@ -178,46 +183,15 @@ class YoutubeChannel(YouTubeItem):
update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}" update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}"
_, _ = ElasticWrap(update_path).post(data) _, _ = ElasticWrap(update_path).post(data)
def get_folder_path(self): def change_subscribe(self, new_subscribe_state: bool):
"""get folder where media files get stored""" """change subscribe status"""
folder_path = os.path.join( if not self.json_data:
EnvironmentSettings.MEDIA_DIR, self.build_json()
self.json_data["channel_id"],
)
return folder_path
def delete_es_videos(self): self.json_data["channel_subscribed"] = new_subscribe_state
"""delete all channel documents from elasticsearch""" self.upload_to_es()
data = { self.sync_to_videos()
"query": { return self.json_data
"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"""
all_playlists = self.get_indexed_playlists()
for playlist in all_playlists:
YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
def delete_channel(self): def delete_channel(self):
"""delete channel and all videos""" """delete channel and all videos"""
@@ -226,24 +200,7 @@ class YoutubeChannel(YouTubeItem):
if not self.json_data: if not self.json_data:
raise FileNotFoundError raise FileNotFoundError
folder_path = self.get_folder_path() ChannelDelete(json_data=self.json_data).delete()
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 index_channel_playlists(self): def index_channel_playlists(self):
"""add all playlists of channel to index""" """add all playlists of channel to index"""
@@ -265,6 +222,21 @@ class YoutubeChannel(YouTubeItem):
print("add playlist: " + playlist[1]) print("add playlist: " + playlist[1])
rand_sleep(self.config) 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): def _notify_single_playlist(self, idx, total):
"""send notification""" """send notification"""
channel_name = self.json_data["channel_name"] channel_name = self.json_data["channel_name"]
@@ -277,6 +249,8 @@ class YoutubeChannel(YouTubeItem):
@staticmethod @staticmethod
def _index_single_playlist(playlist): def _index_single_playlist(playlist):
"""add single playlist if needed""" """add single playlist if needed"""
from playlist.src.index import YoutubePlaylist
playlist = YoutubePlaylist(playlist[0]) playlist = YoutubePlaylist(playlist[0])
playlist.update_playlist(skip_on_empty=True) playlist.update_playlist(skip_on_empty=True)
@@ -291,34 +265,6 @@ class YoutubeChannel(YouTubeItem):
all_videos = IndexPaginate("ta_video", data).get_results() all_videos = IndexPaginate("ta_video", data).get_results()
return all_videos 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)
if not playlists:
self.all_playlists = []
return
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
self.all_playlists = all_entries
def get_indexed_playlists(self, active_only=False):
"""get all indexed playlists from channel"""
must_list = [
{"term": {"playlist_channel_id": {"value": self.youtube_id}}}
]
if active_only:
must_list.append({"term": {"playlist_active": {"value": True}}})
data = {"query": {"bool": {"must": must_list}}}
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def get_overwrites(self) -> dict: def get_overwrites(self) -> dict:
"""get all per channel overwrites""" """get all per channel overwrites"""
return self.json_data.get("channel_overwrites", {}) return self.json_data.get("channel_overwrites", {})
@@ -349,6 +295,93 @@ class YoutubeChannel(YouTubeItem):
self.json_data["channel_overwrites"] = to_write 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}}}
]
if active_only:
must_list.append({"term": {"playlist_active": {"value": True}}})
data = {"query": {"bool": {"must": must_list}}}
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def channel_overwrites(channel_id, overwrites): def channel_overwrites(channel_id, overwrites):
"""collection to overwrite settings per channel""" """collection to overwrite settings per channel"""
channel = YoutubeChannel(channel_id) channel = YoutubeChannel(channel_id)

View File

@@ -13,6 +13,7 @@ class ChannelNav:
"""build nav items""" """build nav items"""
nav = { nav = {
"has_pending": self._get_has_pending(), "has_pending": self._get_has_pending(),
"has_ignored": self._get_has_ignored(),
"has_playlists": self._get_has_playlists(), "has_playlists": self._get_has_playlists(),
} }
nav.update(self._get_vid_types()) nav.update(self._get_vid_types())
@@ -63,6 +64,24 @@ class ChannelNav:
return bool(response["hits"]["hits"]) 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): def _get_has_playlists(self):
"""check if channel has playlists""" """check if channel has playlists"""
path = "ta_playlist/_search" path = "ta_playlist/_search"

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

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

View File

@@ -14,7 +14,6 @@ from channel.src.nav import ChannelNav
from common.serializers import ErrorResponseSerializer from common.serializers import ErrorResponseSerializer
from common.src.urlparser import Parser from common.src.urlparser import Parser
from common.views_base import AdminWriteOnly, ApiBaseView from common.views_base import AdminWriteOnly, ApiBaseView
from download.src.subscriptions import ChannelSubscription
from drf_spectacular.utils import ( from drf_spectacular.utils import (
OpenApiParameter, OpenApiParameter,
OpenApiResponse, OpenApiResponse,
@@ -52,8 +51,11 @@ class ChannelApiListView(ApiBaseView):
must_list = [] must_list = []
query_filter = validated_data.get("filter") query_filter = validated_data.get("filter")
if query_filter: if query_filter is not None:
must_list.append({"term": {"channel_subscribed": {"value": True}}}) channel_subscribed = query_filter == "subscribed"
must_list.append(
{"term": {"channel_subscribed": {"value": channel_subscribed}}}
)
self.data["query"] = {"bool": {"must": must_list}} self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request) self.get_document_list(request)
@@ -89,9 +91,7 @@ class ChannelApiListView(ApiBaseView):
def _unsubscribe(channel_id: str): def _unsubscribe(channel_id: str):
"""unsubscribe""" """unsubscribe"""
print(f"[{channel_id}] unsubscribe from channel") print(f"[{channel_id}] unsubscribe from channel")
ChannelSubscription().change_subscribe( YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=False)
channel_id, channel_subscribed=False
)
class ChannelApiView(ApiBaseView): class ChannelApiView(ApiBaseView):
@@ -146,7 +146,9 @@ class ChannelApiView(ApiBaseView):
subscribed = validated_data.get("channel_subscribed") subscribed = validated_data.get("channel_subscribed")
if subscribed is not None: if subscribed is not None:
ChannelSubscription().change_subscribe(channel_id, subscribed) YoutubeChannel(channel_id).change_subscribe(
new_subscribe_state=subscribed
)
overwrites = validated_data.get("channel_overwrites") overwrites = validated_data.get("channel_overwrites")
if overwrites: if overwrites:

View File

@@ -69,6 +69,7 @@ class NotificationSerializer(serializers.Serializer):
level = serializers.ChoiceField(choices=["info", "error"]) level = serializers.ChoiceField(choices=["info", "error"])
messages = serializers.ListField(child=serializers.CharField()) messages = serializers.ListField(child=serializers.CharField())
progress = serializers.FloatField(required=False) progress = serializers.FloatField(required=False)
command = serializers.ChoiceField(choices=["STOP", "KILL"], required=False)
class NotificationQueryFilterSerializer(serializers.Serializer): class NotificationQueryFilterSerializer(serializers.Serializer):

View File

@@ -56,7 +56,7 @@ class ElasticWrap:
return response.json(), response.status_code return response.json(), response.status_code
def post( def post(
self, data: bool | dict = False, ndjson: bool = False self, data: bool | dict | str = False, ndjson: bool = False
) -> tuple[dict, int]: ) -> tuple[dict, int]:
"""post data to es""" """post data to es"""

View File

@@ -8,7 +8,7 @@ import os
import random import random
import string import string
import subprocess import subprocess
from datetime import datetime from datetime import datetime, timezone
from time import sleep from time import sleep
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -103,16 +103,20 @@ def requests_headers() -> dict[str, str]:
return {"User-Agent": template} return {"User-Agent": template}
def date_parser(timestamp: int | str) -> str: def date_parser(timestamp: int | str | None) -> str | None:
"""return formatted date string""" """return formatted date string"""
if timestamp is None:
return None
if isinstance(timestamp, int): if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp) date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str): elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d") date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
date_obj = date_obj.replace(tzinfo=timezone.utc)
else: else:
raise TypeError(f"invalid timestamp: {timestamp}") raise TypeError(f"invalid timestamp: {timestamp}")
return date_obj.date().isoformat() return date_obj.isoformat()
def time_parser(timestamp: str) -> float: def time_parser(timestamp: str) -> float:
@@ -151,9 +155,13 @@ def is_shorts(youtube_id: str) -> bool:
"""check if youtube_id is a shorts video, bot not it it's not a shorts""" """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}" shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
cookies = {"SOCS": "CAI"} cookies = {"SOCS": "CAI"}
response = requests.head( try:
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10 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 return response.status_code == 200
@@ -183,11 +191,12 @@ def get_duration_sec(file_path: str) -> int:
return duration_sec return duration_sec
def get_duration_str(seconds: int) -> str: def get_duration_str(seconds: int | float) -> str:
"""Return a human-readable duration string from seconds.""" """Return a human-readable duration string from seconds."""
if not seconds: if not seconds:
return "NA" return "NA"
seconds = int(seconds)
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)] units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = [] duration_parts = []
@@ -283,6 +292,50 @@ def get_channel_overwrites() -> dict[str, dict[str, Any]]:
return overwrites 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: def calc_is_watched(duration: float, position: float) -> bool:
"""considered watched based on duration position""" """considered watched based on duration position"""

View File

@@ -26,6 +26,7 @@ class YouTubeItem:
self.youtube_id = youtube_id self.youtube_id = youtube_id
self.es_path = f"{self.index_name}/_doc/{youtube_id}" self.es_path = f"{self.index_name}/_doc/{youtube_id}"
self.config = AppConfig().config self.config = AppConfig().config
self.error = None
self.youtube_meta = False self.youtube_meta = False
self.json_data = False self.json_data = False
@@ -33,17 +34,24 @@ class YouTubeItem:
"""build youtube url""" """build youtube url"""
return self.yt_base + self.youtube_id 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""" """use yt-dlp to get meta data from youtube"""
print(f"{self.youtube_id}: get metadata from youtube") print(f"{self.youtube_id}: get metadata from youtube")
obs_request = self.yt_obs.copy() obs_request = self.yt_obs.copy()
if self.config["downloads"]["extractor_lang"]: if self.config["downloads"]["extractor_lang"]:
langs = self.config["downloads"]["extractor_lang"] langs = self.config["downloads"]["extractor_lang"]
langs_list = [i.strip() for i in langs.split(",")] 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() 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): def get_from_es(self):
"""get indexed data from elastic search""" """get indexed data from elastic search"""

View File

@@ -165,15 +165,17 @@ class SearchProcess:
def _process_download(self, download_dict): def _process_download(self, download_dict):
"""run on single download item""" """run on single download item"""
video_id = download_dict["youtube_id"] vid_thumb_url = None
cache_root = EnvironmentSettings().get_cache_root() if download_dict.get("vid_thumb_url"):
vid_thumb_url = ThumbManager(video_id).vid_thumb_path() video_id = download_dict["youtube_id"]
published = date_parser(download_dict["published"]) 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( download_dict.update(
{ {
"vid_thumb_url": f"{cache_root}/{vid_thumb_url}", "vid_thumb_url": vid_thumb_url,
"published": published, "published": date_parser(download_dict["published"]),
} }
) )
return dict(sorted(download_dict.items())) return dict(sorted(download_dict.items()))

View File

@@ -4,6 +4,7 @@ Functionality:
- identify vid_type if possible - identify vid_type if possible
""" """
from typing import Literal, NotRequired, TypedDict
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from common.src.ta_redis import RedisArchivist from common.src.ta_redis import RedisArchivist
@@ -11,19 +12,28 @@ from download.src.yt_dlp_base import YtWrap
from video.src.constants import VideoTypeEnum 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: 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 channel handle lookup is cached, can be disabled for unittests
""" """
def __init__(self, url_str, use_cache=True): def __init__(self, url_str: str, use_cache: bool = True):
self.url_list = [i.strip() for i in url_str.split()] self.url_list = [i.strip() for i in url_str.split()]
self.use_cache = use_cache self.use_cache = use_cache
def parse(self): def parse(self) -> list[ParsedURLType]:
"""parse the list""" """parse the list"""
ids = [] ids: list[ParsedURLType] = []
for url in self.url_list: for url in self.url_list:
parsed = urlparse(url) parsed = urlparse(url)
if parsed.netloc: if parsed.netloc:
@@ -124,9 +134,9 @@ class Parser:
"extract_flat": True, "extract_flat": True,
"playlistend": 0, "playlistend": 0,
} }
url_info = YtWrap(obs_request).extract(url) url_info, error = YtWrap(obs_request).extract(url)
if not url_info: if not url_info:
raise ValueError(f"failed to retrieve content from URL: {url}") raise ValueError(f"failed to retrieve URL: {error}")
channel_id = url_info.get("channel_id", False) channel_id = url_info.get("channel_id", False)
if channel_id: if channel_id:

View File

@@ -28,6 +28,11 @@ class WatchState:
self.change_vid_state() self.change_vid_state()
return return
if url_type == "channel":
self.reset_channel_progress()
if url_type == "playlist":
self.reset_playlist_progress()
self._add_pipeline() self._add_pipeline()
path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}" path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}"
data = self._build_update_data(url_type) data = self._build_update_data(url_type)
@@ -53,6 +58,31 @@ class WatchState:
print(response) print(response)
raise ValueError("failed to mark video as watched") 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): def _build_update_data(self, url_type):
"""build update by query data based on url_type""" """build update by query data based on url_type"""
term_key_map = { term_key_map = {

View File

@@ -22,14 +22,14 @@ def test_randomizor_with_positive_length():
def test_date_parser_with_int(): def test_date_parser_with_int():
"""unix timestamp""" """unix timestamp"""
timestamp = 1621539600 timestamp = 1621539600
expected_date = "2021-05-20" expected_date = "2021-05-20T19:40:00+00:00"
assert date_parser(timestamp) == expected_date assert date_parser(timestamp) == expected_date
def test_date_parser_with_str(): def test_date_parser_with_str():
"""iso timestamp""" """iso timestamp"""
date_str = "2021-05-21" date_str = "2021-05-21"
expected_date = "2021-05-21" expected_date = "2021-05-21T00:00:00+00:00"
assert date_parser(date_str) == expected_date assert date_parser(date_str) == expected_date

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

@@ -1,76 +0,0 @@
"""backup config for sqlite reset and restore"""
import json
from pathlib import Path
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from home.models import CustomPeriodicTask
from home.src.ta.settings import EnvironmentSettings
from rest_framework.authtoken.models import Token
User = get_user_model()
class Command(BaseCommand):
"""export"""
help = "Exports all users and their auth tokens to a JSON file"
FILE = Path(EnvironmentSettings.CACHE_DIR) / "backup" / "migration.json"
def handle(self, *args, **kwargs):
"""entry point"""
data = {
"user_data": self.get_users(),
"schedule_data": self.get_schedules(),
}
with open(self.FILE, "w", encoding="utf-8") as json_file:
json_file.write(json.dumps(data))
def get_users(self):
"""get users"""
users = User.objects.all()
user_data = []
for user in users:
user_info = {
"username": user.name,
"is_staff": user.is_staff,
"is_superuser": user.is_superuser,
"password": user.password,
"tokens": [],
}
try:
token = Token.objects.get(user=user)
user_info["tokens"] = [token.key]
except Token.DoesNotExist:
user_info["tokens"] = []
user_data.append(user_info)
return user_data
def get_schedules(self):
"""get schedules"""
all_schedules = CustomPeriodicTask.objects.all()
schedule_data = []
for schedule in all_schedules:
schedule_info = {
"name": schedule.name,
"crontab": {
"minute": schedule.crontab.minute,
"hour": schedule.crontab.hour,
"day_of_week": schedule.crontab.day_of_week,
},
}
schedule_data.append(schedule_info)
return schedule_data

View File

@@ -1,89 +0,0 @@
"""restore config from backup"""
import json
from pathlib import Path
from common.src.env_settings import EnvironmentSettings
from django.core.management.base import BaseCommand
from django_celery_beat.models import CrontabSchedule
from rest_framework.authtoken.models import Token
from task.models import CustomPeriodicTask
from task.src.task_config import TASK_CONFIG
from user.models import Account
class Command(BaseCommand):
"""export"""
help = "Exports all users and their auth tokens to a JSON file"
FILE = Path(EnvironmentSettings.CACHE_DIR) / "backup" / "migration.json"
def handle(self, *args, **options):
"""handle"""
self.stdout.write("restore users and schedules")
data = self.get_config()
self.restore_users(data["user_data"])
self.restore_schedules(data["schedule_data"])
self.stdout.write(
self.style.SUCCESS(
" ✓ restore completed. Please restart the container."
)
)
def get_config(self) -> dict:
"""get config from backup"""
with open(self.FILE, "r", encoding="utf-8") as json_file:
data = json.loads(json_file.read())
self.stdout.write(
self.style.SUCCESS(f" ✓ json file found: {self.FILE}")
)
return data
def restore_users(self, user_data: list[dict]) -> None:
"""restore users from config"""
self.stdout.write("delete existing users")
Account.objects.all().delete()
self.stdout.write("recreate users")
for user_info in user_data:
user = Account.objects.create(
name=user_info["username"],
is_staff=user_info["is_staff"],
is_superuser=user_info["is_superuser"],
password=user_info["password"],
)
for token in user_info["tokens"]:
Token.objects.create(user=user, key=token)
self.stdout.write(
self.style.SUCCESS(
f" ✓ recreated user with name: {user_info['username']}"
)
)
def restore_schedules(self, schedule_data: list[dict]) -> None:
"""restore schedules"""
self.stdout.write("delete existing schedules")
CustomPeriodicTask.objects.all().delete()
self.stdout.write("recreate schedules")
for schedule in schedule_data:
task_name = schedule["name"]
description = TASK_CONFIG[task_name].get("title")
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=schedule["crontab"]["minute"],
hour=schedule["crontab"]["hour"],
day_of_week=schedule["crontab"]["day_of_week"],
timezone=EnvironmentSettings.TZ,
)
task = CustomPeriodicTask.objects.create(
name=task_name,
task=task_name,
description=description,
crontab=crontab,
)
self.stdout.write(
self.style.SUCCESS(f" ✓ recreated schedule: {task}")
)

View File

@@ -24,7 +24,7 @@ class Command(BaseCommand):
"""command framework""" """command framework"""
TIMEOUT = 120 TIMEOUT = 120
MIN_MAJOR, MAX_MAJOR = 8, 8 MIN_MAJOR, MAX_MAJOR = 8, 9
MIN_MINOR = 0 MIN_MINOR = 0
# pylint: disable=no-member # pylint: disable=no-member
@@ -99,7 +99,12 @@ class Command(BaseCommand):
continue continue
if status_code and status_code == 200: 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) _, _ = ElasticWrap(path).get(timeout=60)
self.stdout.write( self.stdout.write(
self.style.SUCCESS(" ✓ ES connection established") self.style.SUCCESS(" ✓ ES connection established")

View File

@@ -81,6 +81,7 @@ class Command(BaseCommand):
"""run all commands""" """run all commands"""
self.stdout.write(LOGO) self.stdout.write(LOGO)
self.stdout.write(TOPIC) self.stdout.write(TOPIC)
self._additional_auth_vars_expectations()
self._expected_vars() self._expected_vars()
self._unexpected_vars() self._unexpected_vars()
self._elastic_user_overwrite() self._elastic_user_overwrite()
@@ -89,6 +90,50 @@ class Command(BaseCommand):
self._disable_static_auth() self._disable_static_auth()
self._create_superuser() 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): def _expected_vars(self):
"""check if expected env vars are set""" """check if expected env vars are set"""
self.stdout.write("[1] checking expected env vars") self.stdout.write("[1] checking expected env vars")

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

@@ -10,7 +10,7 @@ from random import randint
from time import sleep from time import sleep
from appsettings.src.config import AppConfig, ReleaseVersion from appsettings.src.config import AppConfig, ReleaseVersion
from appsettings.src.index_setup import ElasitIndexWrap from appsettings.src.index_setup import ElasticIndexWrap
from appsettings.src.snapshot import ElasticSnapshot from appsettings.src.snapshot import ElasticSnapshot
from common.src.env_settings import EnvironmentSettings from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap from common.src.es_connect import ElasticWrap
@@ -19,11 +19,11 @@ from common.src.ta_redis import RedisArchivist
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils import dateformat from django.utils import dateformat
from django_celery_beat.models import CrontabSchedule, PeriodicTasks from django_celery_beat.models import CrontabSchedule, PeriodicTasks
from redis.exceptions import ResponseError
from task.models import CustomPeriodicTask from task.models import CustomPeriodicTask
from task.src.config_schedule import ScheduleBuilder from task.src.config_schedule import ScheduleBuilder
from task.src.task_manager import TaskManager from task.src.task_manager import TaskManager
from task.tasks import version_check from task.tasks import version_check
from video.src.constants import VideoTypeEnum
TOPIC = """ TOPIC = """
@@ -49,12 +49,13 @@ class Command(BaseCommand):
self._version_check() self._version_check()
self._index_setup() self._index_setup()
self._snapshot_check() self._snapshot_check()
self._mig_app_settings()
self._create_default_schedules() self._create_default_schedules()
self._update_schedule_tz() self._update_schedule_tz()
self._init_app_config() self._init_app_config()
self._mig_channel_tags() self._mig_fix_download_channel_indexed()
self._mig_video_channel_tags() self._mig_add_default_playlist_sort()
self._mig_set_channel_tabs()
self._mig_set_video_channel_tabs()
def _make_folders(self): def _make_folders(self):
"""make expected cache folders""" """make expected cache folders"""
@@ -150,46 +151,13 @@ class Command(BaseCommand):
def _index_setup(self): def _index_setup(self):
"""migration: validate index mappings""" """migration: validate index mappings"""
self.stdout.write("[6] validate index mappings") self.stdout.write("[6] validate index mappings")
ElasitIndexWrap().setup() ElasticIndexWrap().setup()
def _snapshot_check(self): def _snapshot_check(self):
"""migration setup snapshots""" """migration setup snapshots"""
self.stdout.write("[7] setup snapshots") self.stdout.write("[7] setup snapshots")
ElasticSnapshot().setup() ElasticSnapshot().setup()
def _mig_app_settings(self) -> None:
"""update from v0.4.13 to v0.5.0, migrate application settings"""
self.stdout.write("[MIGRATION] move appconfig to ES")
try:
config = RedisArchivist().get_message("config")
except ResponseError:
self.stdout.write(
self.style.SUCCESS(" Redis does not support JSON decoding")
)
return
if not config or config == {"status": False}:
self.stdout.write(
self.style.SUCCESS(" no config values to migrate")
)
return
path = "ta_config/_doc/appsettings"
response, status_code = ElasticWrap(path).post(config)
if status_code in [200, 201]:
self.stdout.write(
self.style.SUCCESS(" ✓ migrated appconfig to ES")
)
RedisArchivist().del_message("config", save=True)
return
message = " 🗙 failed to migrate app config"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _create_default_schedules(self) -> None: def _create_default_schedules(self) -> None:
"""create default schedules for new installations""" """create default schedules for new installations"""
self.stdout.write("[8] create initial schedules") self.stdout.write("[8] create initial schedules")
@@ -262,7 +230,7 @@ class Command(BaseCommand):
def _init_app_config(self) -> None: def _init_app_config(self) -> None:
"""init default app config to ES""" """init default app config to ES"""
self.stdout.write("[10] Check AppConfig") self.stdout.write("[10] Check AppConfig")
_, status_code = ElasticWrap("ta_config/_doc/appsettings").get() response, status_code = ElasticWrap("ta_config/_doc/appsettings").get()
if status_code in [200, 201]: if status_code in [200, 201]:
self.stdout.write( self.stdout.write(
self.style.SUCCESS(" skip completed appsettings init") self.style.SUCCESS(" skip completed appsettings init")
@@ -275,6 +243,13 @@ class Command(BaseCommand):
return 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) handler = AppConfig.__new__(AppConfig)
_, status_code = handler.sync_defaults() _, status_code = handler.sync_defaults()
self.stdout.write( self.stdout.write(
@@ -284,44 +259,18 @@ class Command(BaseCommand):
self.style.SUCCESS(f" Status code: {status_code}") self.style.SUCCESS(f" Status code: {status_code}")
) )
def _mig_channel_tags(self) -> None: def _mig_fix_download_channel_indexed(self) -> None:
"""update from v0.4.13 to v0.5.0, migrate incorrect data types""" """migrate from v0.5.2 to 0.5.3, fix missing channel_indexed"""
self.stdout.write("[MIGRATION] fix incorrect channel tags types")
path = "ta_channel/_update_by_query"
data = {
"query": {"match": {"channel_tags": False}},
"script": {
"source": "ctx._source.channel_tags = []",
"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} channel tags")
)
else:
self.stdout.write(
self.style.SUCCESS(" no channel tags needed fixing")
)
return
message = " 🗙 failed to fix channel tags"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _mig_video_channel_tags(self) -> None:
"""update from v0.4.13 to v0.5.0, migrate incorrect data types"""
self.stdout.write("[MIGRATION] fix incorrect video channel tags types") self.stdout.write("[MIGRATION] fix incorrect video channel tags types")
path = "ta_video/_update_by_query" path = "ta_download/_update_by_query"
data = { data = {
"query": {"match": {"channel.channel_tags": False}}, "query": {
"bool": {
"must_not": [{"exists": {"field": "channel_indexed"}}]
}
},
"script": { "script": {
"source": "ctx._source.channel.channel_tags = []", "source": "ctx._source.channel_indexed = false",
"lang": "painless", "lang": "painless",
}, },
} }
@@ -330,15 +279,11 @@ class Command(BaseCommand):
updated = response.get("updated") updated = response.get("updated")
if updated: if updated:
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(f" ✓ fixed {updated} queued videos")
f" ✓ fixed {updated} video channel tags"
)
) )
else: else:
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(" no queued videos to fix")
" no video channel tags needed fixing"
)
) )
return return
@@ -347,3 +292,107 @@ class Command(BaseCommand):
self.stdout.write(response) self.stdout.write(response)
sleep(60) sleep(60)
raise CommandError(message) 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

@@ -104,99 +104,6 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
if bool(environ.get("TA_LDAP")):
# pylint: disable=global-at-module-level
import ldap
from django_auth_ldap.config import LDAPSearch
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,
}
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
# Database # Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases # https://docs.djangoproject.com/en/3.2/ref/settings/#databases
@@ -230,19 +137,41 @@ AUTH_PASSWORD_VALIDATORS = [
AUTH_USER_MODEL = "user.Account" AUTH_USER_MODEL = "user.Account"
# Forward-auth authentication # Configure Authentication Backend Combinations
if bool(environ.get("TA_ENABLE_AUTH_PROXY")): _login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold()
TA_AUTH_PROXY_USERNAME_HEADER = ( if _login_auth_mode == "local":
environ.get("TA_AUTH_PROXY_USERNAME_HEADER") or "HTTP_REMOTE_USER" AUTHENTICATION_BACKENDS: tuple = (
"django.contrib.auth.backends.ModelBackend",
) )
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL") elif _login_auth_mode == "ldap":
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware") from .ldap_settings import * # noqa: F403 F401
elif _login_auth_mode == "forwardauth":
from .fwd_auth_settings import * # noqa: F403 F401
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.RemoteUserBackend", "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 # Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/ # https://docs.djangoproject.com/en/3.2/topics/i18n/
@@ -294,7 +223,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings # TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.5.2" TA_VERSION = "v0.5.8-unstable"
# API # API
REST_FRAMEWORK = { REST_FRAMEWORK = {
@@ -307,3 +236,21 @@ SPECTACULAR_SETTINGS = {
"VERSION": TA_VERSION, "VERSION": TA_VERSION,
"SERVE_INCLUDE_SCHEMA": False, "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

@@ -15,11 +15,11 @@ class DownloadItemSerializer(serializers.Serializer):
channel_indexed = serializers.BooleanField() channel_indexed = serializers.BooleanField()
channel_name = serializers.CharField() channel_name = serializers.CharField()
duration = serializers.CharField() duration = serializers.CharField()
published = serializers.CharField() published = serializers.CharField(allow_null=True)
status = serializers.ChoiceField(choices=["pending", "ignore"]) status = serializers.ChoiceField(choices=["pending", "ignore"])
timestamp = serializers.IntegerField() timestamp = serializers.IntegerField(allow_null=True)
title = serializers.CharField() title = serializers.CharField()
vid_thumb_url = serializers.CharField() vid_thumb_url = serializers.CharField(allow_null=True)
vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values()) vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values())
youtube_id = serializers.CharField() youtube_id = serializers.CharField()
message = serializers.CharField(required=False) message = serializers.CharField(required=False)
@@ -42,14 +42,23 @@ class DownloadListQuerySerializer(
filter = serializers.ChoiceField( filter = serializers.ChoiceField(
choices=["pending", "ignore"], required=False choices=["pending", "ignore"], required=False
) )
vid_type = serializers.ChoiceField(
choices=VideoTypeEnum.values_known(), required=False
)
channel = serializers.CharField(required=False, help_text="channel ID") channel = serializers.CharField(required=False, help_text="channel ID")
page = serializers.IntegerField(required=False) 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): class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
"""serialize bulk delete download queue query string""" """serialize bulk delete download queue query string"""
filter = serializers.ChoiceField(choices=["pending", "ignore"]) 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): class AddDownloadItemSerializer(serializers.Serializer):
@@ -69,6 +78,27 @@ class AddToDownloadQuerySerializer(serializers.Serializer):
"""add to queue query serializer""" """add to queue query serializer"""
autostart = serializers.BooleanField(required=False) autostart = serializers.BooleanField(required=False)
flat = serializers.BooleanField(required=False)
force = 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): class DownloadQueueItemUpdateSerializer(serializers.Serializer):

View File

@@ -4,16 +4,25 @@ Functionality:
- linked with ta_dowload index - linked with ta_dowload index
""" """
import json
from datetime import datetime from datetime import datetime
from appsettings.src.config import AppConfig 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.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import get_duration_str, is_shorts, rand_sleep from common.src.helper import (
from download.src.subscriptions import ChannelSubscription 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 download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo
class PendingIndex: class PendingIndex:
@@ -61,11 +70,7 @@ class PendingIndex:
"""get a list of all channels indexed""" """get a list of all channels indexed"""
self.all_channels = [] self.all_channels = []
self.channel_overwrites = {} self.channel_overwrites = {}
data = { channels = get_channels(subscribed_only=False)
"query": {"match_all": {}},
"sort": [{"channel_id": {"order": "asc"}}],
}
channels = IndexPaginate("ta_channel", data).get_results()
for channel in channels: for channel in channels:
channel_id = channel["channel_id"] channel_id = channel["channel_id"]
@@ -88,67 +93,6 @@ class PendingIndex:
self.video_overwrites.update({video_id: overwrites}) self.video_overwrites.update({video_id: overwrites})
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_by_status(self):
"""delete all matching item by status"""
data = {"query": {"term": {"status": {"value": self.status}}}}
path = "ta_download/_delete_by_query"
_, _ = ElasticWrap(path).post(data=data)
def update_status(self):
"""update status 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,
}
class PendingList(PendingIndex): class PendingList(PendingIndex):
"""manage the pending videos list""" """manage the pending videos list"""
@@ -159,120 +103,390 @@ class PendingList(PendingIndex):
"check_formats": None, "check_formats": None,
} }
def __init__(self, youtube_ids=False, task=False): def __init__(
self,
youtube_ids: list[ParsedURLType],
task=None,
auto_start=False,
flat=False,
force=False,
):
super().__init__() super().__init__()
self.config = AppConfig().config self.config = AppConfig().config
self.youtube_ids = youtube_ids self.youtube_ids = youtube_ids
self.task = task self.task = task
self.auto_start = auto_start
self.flat = flat
self.force = force
self.to_skip = False self.to_skip = False
self.missing_videos = False self.missing_videos: list[dict] = []
self.added = 0
def parse_url_list(self): def parse_url_list(self, status="pending") -> int:
"""extract youtube ids from list""" """extract youtube ids from list"""
self.missing_videos = []
self.get_download() self.get_download()
self.get_indexed() self.get_indexed()
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids):
self._process_entry(entry)
if not self.task:
continue
self.task.send_progress(
message_lines=[f"Extracting items {idx + 1}/{total}"],
progress=(idx + 1) / total,
)
def _process_entry(self, entry):
"""process single entry from url list"""
vid_type = self._get_vid_type(entry)
if entry["type"] == "video":
self._add_video(entry["url"], vid_type)
elif entry["type"] == "channel":
self._parse_channel(entry["url"], vid_type)
elif entry["type"] == "playlist":
self._parse_playlist(entry["url"])
else:
raise ValueError(f"invalid url_type: {entry}")
@staticmethod
def _get_vid_type(entry):
"""add vid type enum if available"""
vid_type_str = entry.get("vid_type")
if not vid_type_str:
return VideoTypeEnum.UNKNOWN
return VideoTypeEnum(vid_type_str)
def _add_video(self, url, vid_type):
"""add video to list"""
if url not in self.missing_videos and url not in self.to_skip:
self.missing_videos.append((url, vid_type))
else:
print(f"{url}: skipped adding already indexed video to download.")
def _parse_channel(self, url, vid_type):
"""add all videos of channel to list"""
video_results = ChannelSubscription().get_last_youtube_videos(
url, limit=False, query_filter=vid_type
)
for video_id, _, vid_type in video_results:
self._add_video(video_id, vid_type)
def _parse_playlist(self, url):
"""add all videos of playlist to list"""
playlist = YoutubePlaylist(url)
is_active = playlist.update_playlist()
if not is_active:
message = f"{playlist.youtube_id}: failed to extract metadata"
print(message)
raise ValueError(message)
entries = playlist.json_data["playlist_entries"]
to_add = [i["youtube_id"] for i in entries if not i["downloaded"]]
if not to_add:
return
for video_id in to_add:
# match vid_type later
self._add_video(video_id, VideoTypeEnum.UNKNOWN)
def add_to_pending(self, status="pending", auto_start=False):
"""add missing videos to pending list"""
self.get_channels() 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 = []
total = len(self.missing_videos)
videos_added = []
for idx, (youtube_id, vid_type) in enumerate(self.missing_videos):
if self.task and self.task.is_stopped(): if self.task and self.task.is_stopped():
break break
print(f"{youtube_id}: [{idx + 1}/{total}]: add to queue") rand_sleep(self.config)
self._notify_add(idx, total)
video_details = self.get_youtube_details(youtube_id, vid_type) return self.added
if not video_details:
rand_sleep(self.config) 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 not self.force and (
url in self.missing_videos or url in self.to_skip
):
print(f"{url}: skipped adding already indexed video to download.")
return None
if self.force and url in self.all_ignored or url in self.all_pending:
print(f"{url}: skipped adding force video already in queue.")
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 continue
video_details.update( self.missing_videos.append(to_add)
{ self.__notify_add(
"status": status, item_type="channel",
"auto_start": auto_start, name=channel_handler.json_data["channel_name"],
} idx=idx,
total=total,
) )
url = video_details["vid_thumb_url"] def __parse_channel_video(
ThumbManager(youtube_id).download_video_thumb(url) self, video_data, vid_type, channel_json
es_url = f"ta_download/_doc/{youtube_id}" ) -> dict | None:
_, _ = ElasticWrap(es_url).put(video_details) """parse video of channel"""
videos_added.append(youtube_id) video_id = video_data["id"]
if video_id in self.to_skip:
return None
if idx != total: # fallback
rand_sleep(self.config) channel_name = channel_json["channel_name"]
channel_id = channel_json["channel_id"]
return videos_added if self.flat:
if not video_data.get("channel"):
video_data["channel"] = channel_name
def _notify_add(self, idx, total): 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()
if not playlist.youtube_meta:
print(f"{url}: playlist metadata extraction failed, skipping")
return
video_results = playlist.youtube_meta["entries"]
if limit:
video_results = video_results[:limit]
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""" """send notification for adding videos to download queue"""
if not self.task: if not self.task:
return return
@@ -280,75 +494,31 @@ class PendingList(PendingIndex):
self.task.send_progress( self.task.send_progress(
message_lines=[ message_lines=[
"Adding new videos to download queue.", "Adding new videos to download queue.",
f"Extracting items {idx + 1}/{total}", 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}",
], ],
progress=(idx + 1) / total, level="error",
) )
def get_youtube_details(self, youtube_id, vid_type=VideoTypeEnum.VIDEOS):
"""get details from youtubedl for single pending video"""
vid = YtWrap(self.yt_obs, self.config).extract(youtube_id)
if not vid:
return False
if vid.get("id") != youtube_id:
# skip premium videos with different id
print(f"{youtube_id}: skipping premium video, id not matching")
return False
# stop if video is streaming live now
if vid["live_status"] in ["is_upcoming", "is_live"]:
print(f"{youtube_id}: skip is_upcoming or is_live")
return False
if vid["live_status"] == "was_live":
vid_type = VideoTypeEnum.STREAMS
else:
if self._check_shorts(vid):
vid_type = VideoTypeEnum.SHORTS
else:
vid_type = VideoTypeEnum.VIDEOS
if not vid.get("channel"):
print(f"{youtube_id}: skip video not part of channel")
return False
return self._parse_youtube_details(vid, vid_type)
@staticmethod
def _check_shorts(vid):
"""check if vid is shorts video"""
if vid["width"] > vid["height"]:
return False
duration = vid.get("duration")
if duration and isinstance(duration, int):
if duration > 3 * 60:
return False
return is_shorts(vid["id"])
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
"""parse response"""
vid_id = vid.get("id")
published = datetime.strptime(vid["upload_date"], "%Y%m%d").strftime(
"%Y-%m-%d"
)
# build dict
youtube_details = {
"youtube_id": vid_id,
"channel_name": vid["channel"],
"vid_thumb_url": vid["thumbnail"],
"title": vid["title"],
"channel_id": vid["channel_id"],
"duration": get_duration_str(vid["duration"]),
"published": published,
"timestamp": int(datetime.now().timestamp()),
# Pulling enum value out so it is serializable
"vid_type": vid_type.value,
}
if self.all_channels:
youtube_details.update(
{"channel_indexed": vid["channel_id"] in self.all_channels}
)
return youtube_details

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

@@ -6,324 +6,114 @@ Functionality:
from appsettings.src.config import AppConfig from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel from channel.src.index import YoutubeChannel
from common.src.es_connect import IndexPaginate from channel.src.remote_query import VideoQueryBuilder
from common.src.helper import is_missing, rand_sleep from common.src.helper import get_channels, get_playlists
from common.src.urlparser import Parser from common.src.urlparser import ParsedURLType, Parser
from download.src.thumbnails import ThumbManager from download.src.queue import PendingList
from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo from video.src.index import YoutubeVideo
class ChannelSubscription: class ChannelSubscription:
"""manage the list of channels subscribed""" """scan subscribed channels to find missing videos to add to pending"""
def __init__(self, task=False): def __init__(self, task=None):
self.config = AppConfig().config self.config = AppConfig().config
self.task = task self.task = task
@staticmethod def find_missing(self) -> int:
def get_channels(subscribed_only=True): """find missing videos from channel subscriptions"""
"""get a list of all channels subscribed to""" if self.task:
data = { self.task.send_progress(["Looking up channels."])
"sort": [{"channel_name.keyword": {"order": "asc"}}],
}
if subscribed_only:
data["query"] = {"term": {"channel_subscribed": {"value": True}}}
else:
data["query"] = {"match_all": {}}
all_channels = IndexPaginate("ta_channel", data).get_results() all_channels = get_channels(
subscribed_only=True,
return all_channels source=["channel_id", "channel_overwrites", "channel_tabs"],
)
def get_last_youtube_videos(
self,
channel_id,
limit=True,
query_filter=None,
channel_overwrites=None,
):
"""get a list of last videos from channel"""
query_handler = VideoQueryBuilder(self.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:
obs["playlistend"] = limit_amount
url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
channel_query = YtWrap(obs, self.config).extract(url)
if not channel_query:
continue
last_videos.extend(
[
(i["id"], i["title"], vid_type)
for i in channel_query["entries"]
]
)
return last_videos
def find_missing(self):
"""add missing videos from subscribed channels to pending"""
all_channels = self.get_channels()
if not all_channels: if not all_channels:
return False return 0
missing_videos = [] all_channel_urls = self._process_channel_urls(all_channels)
total = len(all_channels) if self.task:
for idx, channel in enumerate(all_channels): self.task.send_progress([f"Scanning {len(all_channels)} channels"])
channel_id = channel["channel_id"]
print(f"{channel_id}: find missing videos.")
last_videos = self.get_last_youtube_videos(
channel_id,
channel_overwrites=channel.get("channel_overwrites"),
)
if last_videos: pending_handler = PendingList(
ids_to_add = is_missing([i[0] for i in last_videos]) youtube_ids=all_channel_urls,
for video_id, _, vid_type in last_videos: task=self.task,
if video_id in ids_to_add: auto_start=self.config["subscriptions"].get("auto_start", False),
missing_videos.append((video_id, vid_type)) flat=self.config["subscriptions"].get("extract_flat", False),
)
added = pending_handler.parse_url_list()
if not self.task: 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 continue
if self.task.is_stopped(): enums = [getattr(VideoTypeEnum, i.upper()) for i in channel_tabs]
self.task.send_progress(["Received Stop signal."]) queries = VideoQueryBuilder(
break config=self.config,
channel_overwrites=channel.get("channel_overwrites", {}),
).build_queries(video_type=enums)
self.task.send_progress( for query in queries:
message_lines=[f"Scanning Channel {idx + 1}/{total}"], all_channel_urls.append(
progress=(idx + 1) / total, ParsedURLType(
) type="channel",
rand_sleep(self.config) url=channel["channel_id"],
vid_type=query[0],
limit=query[1],
)
)
return missing_videos return all_channel_urls
@staticmethod
def change_subscribe(channel_id, channel_subscribed):
"""subscribe or unsubscribe from channel and update"""
channel = YoutubeChannel(channel_id)
channel.build_json()
channel.json_data["channel_subscribed"] = channel_subscribed
channel.upload_to_es()
channel.sync_to_videos()
return channel.json_data
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 | 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:
# build query for specific type
query_method = query_methods.get(video_type)
if query_method:
query = query_method(limit)
if query[1] != 0:
return [query]
return []
# 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."""
if not limit:
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 overwrite := self.config["subscriptions"].get(config_key):
return (video_type, overwrite)
return (video_type, 0)
class PlaylistSubscription: class PlaylistSubscription:
"""manage the playlist download functionality""" """scan subscribed playlists for videos to add to pending"""
def __init__(self, task=False): def __init__(self, task=None):
self.config = AppConfig().config self.config = AppConfig().config
self.task = task self.task = task
@staticmethod def find_missing(self) -> int:
def get_playlists(subscribed_only=True): """find missing"""
"""get a list of all active playlists""" all_playlists = get_playlists(
data = { subscribed_only=True, source=["playlist_id"]
"sort": [{"playlist_channel.keyword": {"order": "desc"}}], )
}
data["query"] = {
"bool": {"must": [{"term": {"playlist_active": {"value": True}}}]}
}
if subscribed_only:
data["query"]["bool"]["must"].append(
{"term": {"playlist_subscribed": {"value": True}}}
)
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def process_url_str(self, new_playlists, subscribed=True):
"""process playlist subscribe form url_str"""
for idx, playlist in enumerate(new_playlists):
playlist_id = playlist["url"]
if not playlist["type"] == "playlist":
print(f"{playlist_id} not a playlist, skipping...")
continue
playlist_h = YoutubePlaylist(playlist_id)
playlist_h.build_json()
if not playlist_h.json_data:
message = f"{playlist_h.youtube_id}: failed to extract data"
print(message)
raise ValueError(message)
playlist_h.json_data["playlist_subscribed"] = subscribed
playlist_h.upload_to_es()
playlist_h.add_vids_to_playlist()
self.channel_validate(playlist_h.json_data["playlist_channel_id"])
url = playlist_h.json_data["playlist_thumbnail"]
thumb = ThumbManager(playlist_id, item_type="playlist")
thumb.download_playlist_thumb(url)
if self.task:
self.task.send_progress(
message_lines=[
f"Processing {idx + 1} of {len(new_playlists)}"
],
progress=(idx + 1) / len(new_playlists),
)
@staticmethod
def channel_validate(channel_id):
"""make sure channel of playlist is there"""
channel = YoutubeChannel(channel_id)
channel.build_json(upload=True)
@staticmethod
def change_subscribe(playlist_id, subscribe_status):
"""change the subscribe status of a playlist"""
playlist = YoutubePlaylist(playlist_id)
playlist.build_json()
playlist.json_data["playlist_subscribed"] = subscribe_status
playlist.upload_to_es()
return playlist.json_data
def find_missing(self):
"""find videos in subscribed playlists not downloaded yet"""
all_playlists = [i["playlist_id"] for i in self.get_playlists()]
if not all_playlists: if not all_playlists:
return False return 0
missing_videos = [] size_limit = self.config["subscriptions"]["playlist_size"]
total = len(all_playlists) all_playlist_urls: list[ParsedURLType] = []
for idx, playlist_id in enumerate(all_playlists): for playlist in all_playlists:
playlist = YoutubePlaylist(playlist_id) all_playlist_urls.append(
is_active = playlist.update_playlist() ParsedURLType(
if not is_active: type="playlist",
playlist.deactivate() url=playlist["playlist_id"],
continue vid_type=VideoTypeEnum.UNKNOWN,
limit=size_limit,
playlist_entries = playlist.json_data["playlist_entries"] )
size_limit = self.config["subscriptions"]["channel_size"]
if size_limit:
del playlist_entries[size_limit:]
to_check = [
i["youtube_id"]
for i in playlist_entries
if i["downloaded"] is False
]
needs_downloading = is_missing(to_check)
missing_videos.extend(needs_downloading)
if not self.task:
continue
if self.task.is_stopped():
self.task.send_progress(["Received Stop signal."])
break
self.task.send_progress(
message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
progress=(idx + 1) / total,
) )
rand_sleep(self.config)
return missing_videos 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: class SubscriptionScanner:
@@ -339,40 +129,12 @@ class SubscriptionScanner:
if self.task: if self.task:
self.task.send_progress(["Rescanning channels and playlists."]) self.task.send_progress(["Rescanning channels and playlists."])
self.missing_videos = [] added = 0
self.scan_channels() added += ChannelSubscription(task=self.task).find_missing()
if self.task and not self.task.is_stopped(): if self.task and not self.task.is_stopped():
self.scan_playlists() added += PlaylistSubscription(task=self.task).find_missing()
return self.missing_videos return added
def scan_channels(self):
"""get missing from channels"""
channel_handler = ChannelSubscription(task=self.task)
missing = channel_handler.find_missing()
if not missing:
return
for vid_id, vid_type in missing:
self.missing_videos.append(
{"type": "video", "vid_type": vid_type, "url": vid_id}
)
def scan_playlists(self):
"""get missing from playlists"""
playlist_handler = PlaylistSubscription(task=self.task)
missing = playlist_handler.find_missing()
if not missing:
return
for i in missing:
self.missing_videos.append(
{
"type": "video",
"vid_type": VideoTypeEnum.VIDEOS.value,
"url": i,
}
)
class SubscriptionHandler: class SubscriptionHandler:
@@ -404,7 +166,8 @@ class SubscriptionHandler:
f"expected {expected_type} url but got {item.get('type')}" f"expected {expected_type} url but got {item.get('type')}"
) )
PlaylistSubscription().process_url_str([item]) playlist = YoutubePlaylist(item["url"])
playlist.change_subscribe(new_subscribe_state=True)
return return
if item["type"] == "video": if item["type"] == "video":
@@ -427,9 +190,7 @@ class SubscriptionHandler:
def _subscribe(self, channel_id): def _subscribe(self, channel_id):
"""subscribe to channel""" """subscribe to channel"""
_ = ChannelSubscription().change_subscribe( YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=True)
channel_id, channel_subscribed=True
)
def _notify(self, idx, item, total): def _notify(self, idx, item, total):
"""send notification message to redis""" """send notification message to redis"""

View File

@@ -265,7 +265,7 @@ class ValidatorCallback:
def run(self): def run(self):
"""run the task for page""" """run the task for page"""
print(f"{self.index_name}: validate artwork") 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() self._validate_videos()
elif self.index_name == "ta_channel": elif self.index_name == "ta_channel":
self._validate_channels() self._validate_channels()
@@ -325,6 +325,13 @@ class ThumbValidator:
}, },
"name": "ta_playlist", "name": "ta_playlist",
}, },
{
"data": {
"query": {"term": {"status": {"value": "pending"}}},
"_source": ["youtube_id", "vid_thumb_url"],
},
"name": "ta_download",
},
] ]
def __init__(self, task=False): def __init__(self, task=False):

View File

@@ -20,7 +20,6 @@ class YtWrap:
OBS_BASE = { OBS_BASE = {
"default_search": "ytsearch", "default_search": "ytsearch",
"quiet": True, "quiet": True,
"check_formats": "selected",
"socket_timeout": 10, "socket_timeout": 10,
"extractor_retries": 3, "extractor_retries": 3,
"retries": 10, "retries": 10,
@@ -58,7 +57,7 @@ class YtWrap:
"extractor_args": { "extractor_args": {
"youtube": { "youtube": {
"po_token": [potoken], "po_token": [potoken],
"player-client": ["web", "default"], "player-client": ["mweb", "default"],
}, },
} }
} }
@@ -66,6 +65,7 @@ class YtWrap:
def download(self, url): def download(self, url):
"""make download request""" """make download request"""
self.obs.update({"check_formats": "selected"})
with yt_dlp.YoutubeDL(self.obs) as ydl: with yt_dlp.YoutubeDL(self.obs) as ydl:
try: try:
ydl.download([url]) ydl.download([url])
@@ -80,30 +80,33 @@ class YtWrap:
return True, True return True, True
def extract(self, url): def extract(self, url) -> tuple[dict | None, str | None]:
"""make extract request""" """
make extract request
returns response, error
"""
with yt_dlp.YoutubeDL(self.obs) as ydl: with yt_dlp.YoutubeDL(self.obs) as ydl:
try: try:
response = ydl.extract_info(url) response = ydl.extract_info(url)
except cookiejar.LoadError as err: except cookiejar.LoadError as err:
print(f"cookie file is invalid: {err}") print(f"cookie file is invalid: {err}")
return False return None, str(err)
except yt_dlp.utils.ExtractorError as err: except yt_dlp.utils.ExtractorError as err:
print(f"{url}: failed to extract: {err}, continue...") print(f"{url}: failed to extract: {err}, continue...")
return False return None, str(err)
except yt_dlp.utils.DownloadError as err: except yt_dlp.utils.DownloadError as err:
if "This channel does not have a" in str(err): if "This channel does not have a" in str(err):
return False return None, None
print(f"{url}: failed to get info from youtube: {err}") print(f"{url}: failed to get info from youtube: {err}")
if "Temporary failure in name resolution" in str(err): if "Temporary failure in name resolution" in str(err):
raise ConnectionError("lost the internet, abort!") from err raise ConnectionError("lost the internet, abort!") from err
return False return None, str(err)
self._validate_cookie() self._validate_cookie()
return response return response, None
def _validate_cookie(self): def _validate_cookie(self):
"""check cookie and write it back for next use""" """check cookie and write it back for next use"""
@@ -146,7 +149,7 @@ class CookieHandler:
AppConfig().update_config({"downloads": {"cookie_import": False}}) AppConfig().update_config({"downloads": {"cookie_import": False}})
print("[cookie]: revoked") print("[cookie]: revoked")
def validate(self): def validate(self) -> bool:
"""validate cookie using the liked videos playlist""" """validate cookie using the liked videos playlist"""
validation = RedisArchivist().get_message_dict("cookie:valid") validation = RedisArchivist().get_message_dict("cookie:valid")
if validation: if validation:
@@ -159,8 +162,8 @@ class CookieHandler:
"extract_flat": True, "extract_flat": True,
} }
validator = YtWrap(obs_request, self.config) validator = YtWrap(obs_request, self.config)
response = bool(validator.extract("LL")) response, error = validator.extract("LL")
self.store_validation(response) self.store_validation(bool(response))
# update in redis to avoid expiring # update in redis to avoid expiring
modified = validator.obs["cookiefile"].getvalue().strip("\x00") modified = validator.obs["cookiefile"].getvalue().strip("\x00")
@@ -173,15 +176,15 @@ class CookieHandler:
"status": "message:download", "status": "message:download",
"level": "error", "level": "error",
"title": "Cookie validation failed, exiting...", "title": "Cookie validation failed, exiting...",
"message": "", "message": error,
} }
RedisArchivist().set_message( RedisArchivist().set_message(
"message:download", mess_dict, expire=4 "message:download", mess_dict, expire=4
) )
print("[cookie]: validation failed, exiting...") print("[cookie]: validation failed, exiting...")
print(f"[cookie]: validation success: {response}") print(f"[cookie]: validation success: {bool(response)}")
return response return bool(response)
@staticmethod @staticmethod
def store_validation(response): def store_validation(response):

View File

@@ -16,12 +16,13 @@ from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import ( from common.src.helper import (
get_channel_overwrites, get_channel_overwrites,
get_playlists,
ignore_filelist, ignore_filelist,
rand_sleep, rand_sleep,
) )
from common.src.ta_redis import RedisQueue from common.src.ta_redis import RedisQueue
from common.src.urlparser import ParsedURLType
from download.src.queue import PendingList from download.src.queue import PendingList
from download.src.subscriptions import PlaylistSubscription
from download.src.yt_dlp_base import YtWrap from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist from playlist.src.index import YoutubePlaylist
from video.src.comments import CommentList from video.src.comments import CommentList
@@ -347,7 +348,7 @@ class DownloadPostProcess(DownloaderBase):
self._auto_delete_watched(data) self._auto_delete_watched(data)
@staticmethod @staticmethod
def _auto_delete_watched(data): def _auto_delete_watched(data) -> None:
"""delete watched videos after x days""" """delete watched videos after x days"""
to_delete = IndexPaginate("ta_video", data).get_results() to_delete = IndexPaginate("ta_video", data).get_results()
if not to_delete: if not to_delete:
@@ -359,10 +360,20 @@ class DownloadPostProcess(DownloaderBase):
YoutubeVideo(youtube_id).delete_media_file() YoutubeVideo(youtube_id).delete_media_file()
print("add deleted to ignore list") print("add deleted to ignore list")
vids = [{"type": "video", "url": i["youtube_id"]} for i in to_delete]
pending = PendingList(youtube_ids=vids) parsed_ids: list[ParsedURLType] = []
pending.parse_url_list()
_ = pending.add_to_pending(status="ignore") for video_item in to_delete:
vid_type = getattr(VideoTypeEnum, video_item["vid_type"].upper())
parsed_ids.append(
{
"type": "video",
"url": video_item["youtube_id"],
"vid_type": vid_type,
}
)
PendingList(youtube_ids=parsed_ids).parse_url_list(status="ignore")
def refresh_playlist(self) -> None: def refresh_playlist(self) -> None:
"""match videos with playlists""" """match videos with playlists"""
@@ -403,8 +414,8 @@ class DownloadPostProcess(DownloaderBase):
def _add_playlist_sub(self): def _add_playlist_sub(self):
"""add subscribed playlists to refresh""" """add subscribed playlists to refresh"""
subs = PlaylistSubscription().get_playlists() playlists = get_playlists(subscribed_only=True, source=["playlist_id"])
to_add = [i["playlist_id"] for i in subs] to_add = [i["playlist_id"] for i in playlists]
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add) RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
def _add_channel_playlists(self): def _add_channel_playlists(self):

View File

@@ -8,6 +8,8 @@ from common.views_base import AdminOnly, ApiBaseView
from download.serializers import ( from download.serializers import (
AddToDownloadListSerializer, AddToDownloadListSerializer,
AddToDownloadQuerySerializer, AddToDownloadQuerySerializer,
BulkUpdateDowloadDataSerializer,
BulkUpdateDowloadQuerySerializer,
DownloadAggsSerializer, DownloadAggsSerializer,
DownloadItemSerializer, DownloadItemSerializer,
DownloadListQuerySerializer, DownloadListQuerySerializer,
@@ -15,7 +17,7 @@ from download.serializers import (
DownloadListSerializer, DownloadListSerializer,
DownloadQueueItemUpdateSerializer, DownloadQueueItemUpdateSerializer,
) )
from download.src.queue import PendingInteract from download.src.queue_interact import PendingInteract
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.response import Response from rest_framework.response import Response
from task.tasks import download_pending, extrac_dl from task.tasks import download_pending, extrac_dl
@@ -65,6 +67,22 @@ class DownloadApiListView(ApiBaseView):
{"term": {"channel_id": {"value": filter_channel}}} {"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.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request) self.get_document_list(request)
@@ -99,12 +117,16 @@ class DownloadApiListView(ApiBaseView):
validated_query = query_serializer.validated_data validated_query = query_serializer.validated_data
auto_start = validated_query.get("autostart") auto_start = validated_query.get("autostart")
print(f"auto_start: {auto_start}") flat = validated_query.get("flat", False)
force = validated_query.get("force", False)
print(f"auto_start: {auto_start}, flat: {flat}, force: {force}")
to_add = validated_data["data"] to_add = validated_data["data"]
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"] pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
url_str = " ".join(pending) url_str = " ".join(pending)
task = extrac_dl.delay(url_str, auto_start=auto_start) task = extrac_dl.delay(
url_str, auto_start=auto_start, flat=flat, force=force
)
message = { message = {
"message": "add to queue task started", "message": "add to queue task started",
@@ -114,6 +136,39 @@ class DownloadApiListView(ApiBaseView):
return Response(response_serializer.data) 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")
PendingInteract(status=status_filter).update_bulk(
channel_id=validated_query.get("channel"),
vid_type=validated_query.get("vid_type"),
new_status=validated_data["status"],
error=validated_query.get("error"),
)
if new_status == "priority":
download_pending.delay(auto_only=True)
return Response(status=204)
@extend_schema( @extend_schema(
parameters=[DownloadListQueueDeleteQuerySerializer()], parameters=[DownloadListQueueDeleteQuerySerializer()],
responses={ responses={
@@ -132,9 +187,18 @@ class DownloadApiListView(ApiBaseView):
validated_query = serializer.validated_data validated_query = serializer.validated_data
query_filter = validated_query["filter"] 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}" 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) print(message)
PendingInteract(status=query_filter).delete_by_status() PendingInteract(status=query_filter).delete_bulk(
channel_id=channel, vid_type=vid_type
)
return Response(status=204) return Response(status=204)

View File

@@ -28,6 +28,7 @@ class PlaylistSerializer(serializers.Serializer):
playlist_last_refresh = serializers.CharField() playlist_last_refresh = serializers.CharField()
playlist_name = serializers.CharField() playlist_name = serializers.CharField()
playlist_subscribed = serializers.BooleanField() playlist_subscribed = serializers.BooleanField()
playlist_sort_order = serializers.ChoiceField(choices=["top", "bottom"])
playlist_thumbnail = serializers.CharField() playlist_thumbnail = serializers.CharField()
playlist_type = serializers.ChoiceField(choices=["regular", "custom"]) playlist_type = serializers.ChoiceField(choices=["regular", "custom"])
_index = serializers.CharField(required=False) _index = serializers.CharField(required=False)
@@ -45,7 +46,7 @@ class PlaylistListQuerySerializer(serializers.Serializer):
"""serialize playlist list query params""" """serialize playlist list query params"""
channel = serializers.CharField(required=False) channel = serializers.CharField(required=False)
subscribed = serializers.BooleanField(required=False) subscribed = serializers.BooleanField(required=False, allow_null=True)
type = serializers.ChoiceField( type = serializers.ChoiceField(
choices=["regular", "custom"], required=False choices=["regular", "custom"], required=False
) )
@@ -68,7 +69,10 @@ class PlaylistBulkAddSerializer(serializers.Serializer):
class PlaylistSingleUpdate(serializers.Serializer): class PlaylistSingleUpdate(serializers.Serializer):
"""update state of single playlist""" """update state of single playlist"""
playlist_subscribed = serializers.BooleanField() playlist_subscribed = serializers.BooleanField(required=False)
playlist_sort_order = serializers.ChoiceField(
choices=["top", "bottom"], required=False
)
class PlaylistListCustomPostSerializer(serializers.Serializer): class PlaylistListCustomPostSerializer(serializers.Serializer):

View File

@@ -7,7 +7,6 @@ functionality:
import json import json
from datetime import datetime from datetime import datetime
from channel.src import index as channel
from common.src.env_settings import EnvironmentSettings from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.index_generic import YouTubeItem from common.src.index_generic import YouTubeItem
@@ -36,11 +35,18 @@ class YoutubePlaylist(YouTubeItem):
self.get_from_es() self.get_from_es()
if self.json_data: if self.json_data:
subscribed = self.json_data.get("playlist_subscribed") subscribed = self.json_data.get("playlist_subscribed")
playlist_sort_order = self.json_data.get("playlist_sort_order")
else: else:
subscribed = False subscribed = False
playlist_sort_order = "top"
sort_order = 1 if playlist_sort_order == "top" else -1
playlist_items = f"::{sort_order}"
if scrape or not self.json_data: if scrape or not self.json_data:
self.get_from_youtube() self.get_from_youtube(
obs_overwrite={"playlist_items": playlist_items}
)
if not self.youtube_meta: if not self.youtube_meta:
self.json_data = False self.json_data = False
return return
@@ -49,8 +55,13 @@ class YoutubePlaylist(YouTubeItem):
self._ensure_channel() self._ensure_channel()
ids_found = self.get_local_vids() ids_found = self.get_local_vids()
self.get_entries(ids_found) self.get_entries(ids_found)
self.json_data["playlist_entries"] = self.all_members self.json_data.update(
self.json_data["playlist_subscribed"] = subscribed {
"playlist_entries": self.all_members,
"playlist_subscribed": subscribed,
"playlist_sort_order": playlist_sort_order,
}
)
def process_youtube_meta(self): def process_youtube_meta(self):
"""extract relevant fields from youtube""" """extract relevant fields from youtube"""
@@ -60,6 +71,9 @@ class YoutubePlaylist(YouTubeItem):
print(f"{self.youtube_id}: thumbnail extraction failed") print(f"{self.youtube_id}: thumbnail extraction failed")
playlist_thumbnail = False playlist_thumbnail = False
if not self.youtube_meta.get("channel_id"):
raise ValueError("Failed to extract Channel ID for Playlist")
self.json_data = { self.json_data = {
"playlist_id": self.youtube_id, "playlist_id": self.youtube_id,
"playlist_active": True, "playlist_active": True,
@@ -74,10 +88,24 @@ class YoutubePlaylist(YouTubeItem):
def _ensure_channel(self): def _ensure_channel(self):
"""make sure channel is indexed""" """make sure channel is indexed"""
from channel.src.index import YoutubeChannel
channel_id = self.json_data["playlist_channel_id"] channel_id = self.json_data["playlist_channel_id"]
channel_handler = channel.YoutubeChannel(channel_id) channel_handler = YoutubeChannel(channel_id)
channel_handler.build_json(upload=True) channel_handler.build_json(upload=True)
def get_playlist_videos(self):
"""get all playlist videos"""
data = {
"query": {
"term": {"playlist.keyword": {"value": self.youtube_id}}
},
"_source": ["youtube_id"],
}
result = IndexPaginate("ta_video", data).get_results()
return result
def get_local_vids(self) -> list[str]: def get_local_vids(self) -> list[str]:
"""get local video ids from youtube entries""" """get local video ids from youtube entries"""
entries = self.youtube_meta["entries"] entries = self.youtube_meta["entries"]
@@ -110,6 +138,13 @@ class YoutubePlaylist(YouTubeItem):
url = self.json_data["playlist_thumbnail"] url = self.json_data["playlist_thumbnail"]
ThumbManager(self.youtube_id, item_type="playlist").download(url) ThumbManager(self.youtube_id, item_type="playlist").download(url)
def change_subscribe(self, new_subscribe_state: bool):
"""change subscribe status"""
self.build_json()
self.json_data["playlist_subscribed"] = new_subscribe_state
self.upload_to_es()
return self.json_data
def add_vids_to_playlist(self): def add_vids_to_playlist(self):
"""sync the playlist id to videos""" """sync the playlist id to videos"""
script = ( script = (
@@ -143,14 +178,7 @@ class YoutubePlaylist(YouTubeItem):
def remove_vids_from_playlist(self): def remove_vids_from_playlist(self):
"""remove playlist ids from videos if needed""" """remove playlist ids from videos if needed"""
needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]] needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]]
data = { result = self.get_playlist_videos()
"query": {"match": {"playlist": self.youtube_id}},
"_source": ["youtube_id"],
}
data = {
"query": {"term": {"playlist.keyword": {"value": self.youtube_id}}}
}
result = IndexPaginate("ta_video", data).get_results()
to_remove = [ to_remove = [
i["youtube_id"] for i in result if i["youtube_id"] not in needed i["youtube_id"] for i in result if i["youtube_id"] not in needed
] ]
@@ -189,6 +217,15 @@ class YoutubePlaylist(YouTubeItem):
self.get_playlist_art() self.get_playlist_art()
return True return True
def change_sort_order(self, new_sort_order):
"""update sort order of playlist"""
playlist = YoutubePlaylist(self.youtube_id)
playlist.build_json()
playlist.json_data["playlist_sort_order"] = new_sort_order
playlist.upload_to_es()
return playlist.json_data
def build_nav(self, youtube_id): def build_nav(self, youtube_id):
"""find next and previous in playlist of a given youtube_id""" """find next and previous in playlist of a given youtube_id"""
cache_root = EnvironmentSettings().get_cache_root() cache_root = EnvironmentSettings().get_cache_root()
@@ -287,6 +324,7 @@ class YoutubePlaylist(YouTubeItem):
self.delete_metadata() self.delete_metadata()
def create(self, name): def create(self, name):
"""create custom playlist"""
self.json_data = { self.json_data = {
"playlist_id": self.youtube_id, "playlist_id": self.youtube_id,
"playlist_active": False, "playlist_active": False,
@@ -299,6 +337,7 @@ class YoutubePlaylist(YouTubeItem):
"playlist_description": False, "playlist_description": False,
"playlist_thumbnail": False, "playlist_thumbnail": False,
"playlist_subscribed": False, "playlist_subscribed": False,
"playlist_sort_order": "top",
} }
self.upload_to_es() self.upload_to_es()
self.get_playlist_art() self.get_playlist_art()

View File

@@ -26,7 +26,7 @@ class QueryBuilder:
must_list.append({"match": {"playlist_channel_id": channel}}) must_list.append({"match": {"playlist_channel_id": channel}})
subscribed = self.request_params.get("subscribed") subscribed = self.request_params.get("subscribed")
if subscribed: if subscribed is not None:
must_list.append({"match": {"playlist_subscribed": subscribed}}) must_list.append({"match": {"playlist_subscribed": subscribed}})
playlist_type = self.request_params.get("type") playlist_type = self.request_params.get("type")
@@ -45,7 +45,7 @@ class QueryBuilder:
type_parsed = getattr(PlaylistTypesEnum, playlist_type.upper()).value type_parsed = getattr(PlaylistTypesEnum, playlist_type.upper()).value
return {"match": {"playlist_type.keyword": type_parsed}} return {"match": {"playlist_type": type_parsed}}
def parse_sort(self) -> dict: def parse_sort(self) -> dict:
"""return sort""" """return sort"""

View File

@@ -27,4 +27,4 @@ def test_parse_type():
qb.parse_type("invalid") qb.parse_type("invalid")
result = qb.parse_type("custom") result = qb.parse_type("custom")
assert result == {"match": {"playlist_type.keyword": "custom"}} assert result == {"match": {"playlist_type": "custom"}}

View File

@@ -7,7 +7,6 @@ from common.serializers import (
ErrorResponseSerializer, ErrorResponseSerializer,
) )
from common.views_base import AdminWriteOnly, ApiBaseView from common.views_base import AdminWriteOnly, ApiBaseView
from download.src.subscriptions import PlaylistSubscription
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from playlist.serializers import ( from playlist.serializers import (
PlaylistBulkAddSerializer, PlaylistBulkAddSerializer,
@@ -240,9 +239,31 @@ class PlaylistApiView(ApiBaseView):
error = ErrorResponseSerializer({"error": "playlist not found"}) error = ErrorResponseSerializer({"error": "playlist not found"})
return Response(error.data, status=404) return Response(error.data, status=404)
subscribed = validated_data["playlist_subscribed"] if self.response["playlist_type"] == "custom":
playlist_sub = PlaylistSubscription() error = ErrorResponseSerializer(
json_data = playlist_sub.change_subscribe(playlist_id, subscribed) {"error": f"playlist with ID {playlist_id} is custom"}
)
return Response(error.data, status=400)
subscribed = validated_data.get("playlist_subscribed")
sort_order = validated_data.get("playlist_sort_order")
json_data = None
if subscribed is not None:
json_data = YoutubePlaylist(playlist_id).change_subscribe(
new_subscribe_state=subscribed
)
if sort_order:
json_data = YoutubePlaylist(playlist_id).change_sort_order(
new_sort_order=sort_order
)
if not json_data:
error = ErrorResponseSerializer(
{"error": "expect playlist_subscribed or playlist_sort_order"}
)
return Response(error.data, status=400)
response_serializer = PlaylistSerializer(json_data) response_serializer = PlaylistSerializer(json_data)
return Response(response_serializer.data) return Response(response_serializer.data)

View File

@@ -1,10 +0,0 @@
-r requirements.txt
ipython==9.2.0
pre-commit==4.2.0
pylint-django==2.6.1
pylint==3.3.7
pytest-django==4.11.1
pytest==8.3.5
python-dotenv==1.1.0
requirementscheck==0.0.6
types-requests==2.32.0.20250328

View File

@@ -1,15 +1,15 @@
apprise==1.9.3 apprise==1.9.4
celery==5.5.2 celery==5.5.3
django-auth-ldap==5.2.0 django-auth-ldap==5.2.0
django-celery-beat==2.8.0 django-celery-beat==2.8.1
django-cors-headers==4.7.0 django-cors-headers==4.7.0
Django==5.2.1 Django==5.2.5
djangorestframework==3.16.0 djangorestframework==3.16.1
drf-spectacular==0.28.0 drf-spectacular==0.28.0
Pillow==11.2.1 Pillow==11.3.0
redis==6.0.0 redis==6.4.0
requests==2.32.3 requests==2.32.5
ryd-client==0.0.6 ryd-client==0.0.6
uvicorn==0.34.2 uvicorn==0.35.0
whitenoise==6.9.0 whitenoise==6.9.0
yt-dlp[default]==2025.4.30 yt-dlp[default]==2025.8.22

View File

@@ -91,3 +91,12 @@ class TaskNotificationPostSerializer(serializers.Serializer):
task_name = serializers.ChoiceField(choices=list(TASK_CONFIG)) task_name = serializers.ChoiceField(choices=list(TASK_CONFIG))
url = serializers.CharField(required=False) url = serializers.CharField(required=False)
class TaskNotificationTestSerializer(serializers.Serializer):
"""serialize task notification test POST"""
url = serializers.CharField()
task_name = serializers.ChoiceField(
choices=list(TASK_CONFIG), required=False
)

View File

@@ -32,6 +32,38 @@ class Notifications:
apobj.notify(body=body, title=title) apobj.notify(body=body, title=title)
def test(self, url) -> tuple[bool, str]:
"""send test notification"""
try:
apobj = apprise.Apprise()
if not apobj.add(url):
success = False
message = f"Invalid notification URL format: {url}"
return success, message
title = f"[TA] {self.task_name} process ended with SUCCESS"
body = "This is a test notification. Task completed successfully."
result = apobj.notify(body=body, title=title)
if result:
success = True
message = "Test notification sent successfully"
return success, message
success = False
message = (
"Notification failed. "
"Please check container logs for more information."
)
return success, message
except Exception as err: # pylint: disable=broad-exception-caught
success = False
message = f"Notification error: {str(err)}"
return success, message
def _build_message( def _build_message(
self, task_id: str, task_title: str self, task_id: str, task_title: str
) -> tuple[str, str | None]: ) -> tuple[str, str | None]:

View File

@@ -9,14 +9,14 @@ Functionality:
from appsettings.src.backup import ElasticBackup from appsettings.src.backup import ElasticBackup
from appsettings.src.config import ReleaseVersion from appsettings.src.config import ReleaseVersion
from appsettings.src.filesystem import Scanner from appsettings.src.filesystem import Scanner
from appsettings.src.index_setup import ElasitIndexWrap from appsettings.src.index_setup import ElasticIndexWrap
from appsettings.src.manual import ImportFolderScanner from appsettings.src.manual import ImportFolderScanner
from appsettings.src.reindex import Reindex, ReindexManual, ReindexPopulate from appsettings.src.reindex import Reindex, ReindexManual, ReindexPopulate
from celery import Task, shared_task from celery import Task, shared_task
from celery.exceptions import Retry from celery.exceptions import Retry
from channel.src.index import YoutubeChannel from channel.src.index import YoutubeChannel
from common.src.ta_redis import RedisArchivist from common.src.ta_redis import RedisArchivist
from common.src.urlparser import Parser from common.src.urlparser import ParsedURLType, Parser
from download.src.queue import PendingList from download.src.queue import PendingList
from download.src.subscriptions import SubscriptionHandler, SubscriptionScanner from download.src.subscriptions import SubscriptionHandler, SubscriptionScanner
from download.src.thumbnails import ThumbFilesystem, ThumbValidator from download.src.thumbnails import ThumbFilesystem, ThumbValidator
@@ -39,10 +39,10 @@ class BaseTask(Task):
RedisArchivist().set_message(key, message, expire=20) RedisArchivist().set_message(key, message, expire=20)
def on_success(self, retval, task_id, args, kwargs): def on_success(self, retval, task_id, args, kwargs):
"""callback task completed successfully""" """callback task completed"""
print(f"{task_id} success callback") print(f"{task_id} success callback")
message, key = self._build_message() message, key = self._build_message()
message.update({"messages": ["Task completed successfully"]}) message.update({"messages": ["Task completed"]})
RedisArchivist().set_message(key, message, expire=5) RedisArchivist().set_message(key, message, expire=5)
def before_start(self, task_id, args, kwargs): def before_start(self, task_id, args, kwargs):
@@ -58,9 +58,11 @@ class BaseTask(Task):
task_title = TASK_CONFIG.get(self.name).get("title") task_title = TASK_CONFIG.get(self.name).get("title")
Notifications(self.name).send(task_id, task_title) Notifications(self.name).send(task_id, task_title)
def send_progress(self, message_lines, progress=False, title=False): def send_progress(
self, message_lines, progress=False, title=False, level="info"
):
"""send progress message""" """send progress message"""
message, key = self._build_message() message, key = self._build_message(level=level)
message.update( message.update(
{ {
"messages": message_lines, "messages": message_lines,
@@ -79,7 +81,7 @@ class BaseTask(Task):
message.update({"level": level, "id": task_id}) message.update({"level": level, "id": task_id})
task_result = TaskManager().get_task(task_id) task_result = TaskManager().get_task(task_id)
if task_result: if task_result:
command = task_result.get("command", False) command = task_result.get("command", None)
message.update({"command": command}) message.update({"command": command})
key = f"message:{message.get('group')}:{task_id.split('-')[0]}" key = f"message:{message.get('group')}:{task_id.split('-')[0]}"
@@ -101,13 +103,13 @@ def update_subscribed(self):
manager.init(self) manager.init(self)
handler = SubscriptionScanner(task=self) handler = SubscriptionScanner(task=self)
missing_videos = handler.scan() added = handler.scan()
auto_start = handler.auto_start auto_start = handler.auto_start
if missing_videos: if added:
print(missing_videos) if auto_start:
extrac_dl.delay(missing_videos, auto_start=auto_start) download_pending.delay(auto_only=True)
message = f"Found {len(missing_videos)} videos to add to the queue."
return message return f"Found {added} videos to add to the queue."
return None return None
@@ -147,7 +149,14 @@ def download_pending(self, auto_only=False):
@shared_task(name="extract_download", bind=True, base=BaseTask) @shared_task(name="extract_download", bind=True, base=BaseTask)
def extrac_dl(self, youtube_ids, auto_start=False, status="pending"): def extrac_dl(
self,
youtube_ids: str | list[ParsedURLType],
auto_start: bool = False,
flat: bool = False,
force: bool = False,
status: str = "pending",
) -> str | None:
"""parse list passed and add to pending""" """parse list passed and add to pending"""
TaskManager().init(self) TaskManager().init(self)
if isinstance(youtube_ids, str): if isinstance(youtube_ids, str):
@@ -155,17 +164,20 @@ def extrac_dl(self, youtube_ids, auto_start=False, status="pending"):
else: else:
to_add = youtube_ids to_add = youtube_ids
pending_handler = PendingList(youtube_ids=to_add, task=self) pending_handler = PendingList(
pending_handler.parse_url_list() youtube_ids=to_add,
videos_added = pending_handler.add_to_pending( task=self,
status=status, auto_start=auto_start auto_start=auto_start,
flat=flat,
force=force,
) )
videos_added = pending_handler.parse_url_list(status=status)
if auto_start: if auto_start:
download_pending.delay(auto_only=True) download_pending.delay(auto_only=True)
if videos_added: if videos_added:
return f"added {len(videos_added)} Videos to Queue" return f"added {videos_added} Videos to Queue"
return None return None
@@ -239,7 +251,7 @@ def run_restore_backup(self, filename):
manager.init(self) manager.init(self)
self.send_progress(["Reset your Index"]) self.send_progress(["Reset your Index"])
ElasitIndexWrap().reset() ElasticIndexWrap().reset()
ElasticBackup(task=self).restore(filename) ElasticBackup(task=self).restore(filename)
print("index restore finished") print("index restore finished")
@@ -259,7 +271,7 @@ def rescan_filesystem(self):
handler = Scanner(task=self) handler = Scanner(task=self)
handler.scan() handler.scan()
handler.apply() handler.apply()
ThumbValidator(task=self).validate() thumbnail_check.delay()
@shared_task(bind=True, name="thumbnail_check", base=BaseTask) @shared_task(bind=True, name="thumbnail_check", base=BaseTask)

View File

@@ -34,4 +34,9 @@ urlpatterns = [
views.ScheduleNotification.as_view(), views.ScheduleNotification.as_view(),
name="api-schedule-notification", name="api-schedule-notification",
), ),
path(
"notification/test/",
views.NotificationTestView.as_view(),
name="api-schedule-notification-test",
),
] ]

View File

@@ -15,6 +15,7 @@ from task.serializers import (
TaskIDDataSerializer, TaskIDDataSerializer,
TaskNotificationPostSerializer, TaskNotificationPostSerializer,
TaskNotificationSerializer, TaskNotificationSerializer,
TaskNotificationTestSerializer,
TaskResultSerializer, TaskResultSerializer,
) )
from task.src.config_schedule import CrontabValidator, ScheduleBuilder from task.src.config_schedule import CrontabValidator, ScheduleBuilder
@@ -343,3 +344,34 @@ class ScheduleNotification(ApiBaseView):
Notifications(task_name).remove_task() Notifications(task_name).remove_task()
return Response(status=204) return Response(status=204)
class NotificationTestView(ApiBaseView):
"""resolves to /api/task/notification/test/
POST: test notification url
"""
@extend_schema(
request=TaskNotificationTestSerializer(),
responses={
200: OpenApiResponse(description="test notification sent"),
400: OpenApiResponse(
ErrorResponseSerializer(), description="bad request"
),
},
)
def post(self, request):
"""test notification"""
data_serializer = TaskNotificationTestSerializer(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
url = validated_data["url"]
task_name = validated_data.get("task_name", "manual_test")
success, message = Notifications(task_name).test(url)
status = 200 if success else 400
return Response(
{"success": success, "message": message}, status=status
)

View File

@@ -5,7 +5,7 @@
from common.src.helper import get_stylesheets from common.src.helper import get_stylesheets
from rest_framework import serializers from rest_framework import serializers
from user.models import Account from user.models import Account
from video.src.constants import OrderEnum, SortEnum from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum
class AccountSerializer(serializers.ModelSerializer): class AccountSerializer(serializers.ModelSerializer):
@@ -31,15 +31,23 @@ class UserMeConfigSerializer(serializers.Serializer):
page_size = serializers.IntegerField() page_size = serializers.IntegerField()
sort_by = serializers.ChoiceField(choices=SortEnum.names()) sort_by = serializers.ChoiceField(choices=SortEnum.names())
sort_order = serializers.ChoiceField(choices=OrderEnum.values()) sort_order = serializers.ChoiceField(choices=OrderEnum.values())
view_style_home = serializers.ChoiceField(choices=["grid", "list"]) view_style_home = serializers.ChoiceField(
choices=["grid", "list", "table"]
)
view_style_channel = serializers.ChoiceField(choices=["grid", "list"]) view_style_channel = serializers.ChoiceField(choices=["grid", "list"])
view_style_downloads = serializers.ChoiceField(choices=["grid", "list"]) view_style_downloads = serializers.ChoiceField(choices=["grid", "list"])
view_style_playlist = serializers.ChoiceField(choices=["grid", "list"]) view_style_playlist = serializers.ChoiceField(choices=["grid", "list"])
vid_type_filter = serializers.ChoiceField(
choices=VideoTypeEnum.values_known(), allow_null=True
)
grid_items = serializers.IntegerField(max_value=7, min_value=3) grid_items = serializers.IntegerField(max_value=7, min_value=3)
hide_watched = serializers.BooleanField() hide_watched = serializers.BooleanField(allow_null=True)
hide_watched_channel = serializers.BooleanField(allow_null=True)
hide_watched_playlist = serializers.BooleanField(allow_null=True)
file_size_unit = serializers.ChoiceField(choices=["binary", "metric"]) file_size_unit = serializers.ChoiceField(choices=["binary", "metric"])
show_ignored_only = serializers.BooleanField() show_ignored_only = serializers.BooleanField()
show_subed_only = serializers.BooleanField() show_subed_only = serializers.BooleanField(allow_null=True)
show_subed_only_playlists = serializers.BooleanField(allow_null=True)
show_help_text = serializers.BooleanField() show_help_text = serializers.BooleanField()

View File

@@ -20,11 +20,15 @@ class UserConfigType(TypedDict, total=False):
view_style_channel: str view_style_channel: str
view_style_downloads: str view_style_downloads: str
view_style_playlist: str view_style_playlist: str
vid_type_filter: str | None
grid_items: int grid_items: int
hide_watched: bool hide_watched: bool | None
hide_watched_channel: bool | None
hide_watched_playlist: bool | None
file_size_unit: str file_size_unit: str
show_ignored_only: bool show_ignored_only: bool
show_subed_only: bool show_subed_only: bool | None
show_subed_only_playlists: bool | None
show_help_text: bool show_help_text: bool
@@ -43,11 +47,15 @@ class UserConfig:
view_style_channel="list", view_style_channel="list",
view_style_downloads="list", view_style_downloads="list",
view_style_playlist="grid", view_style_playlist="grid",
vid_type_filter=None,
grid_items=3, grid_items=3,
hide_watched=False, hide_watched=False,
hide_watched_channel=None,
hide_watched_playlist=None,
file_size_unit="binary", file_size_unit="binary",
show_ignored_only=False, show_ignored_only=False,
show_subed_only=False, show_subed_only=None,
show_subed_only_playlists=None,
show_help_text=True, show_help_text=True,
) )

View File

@@ -12,6 +12,7 @@ class PlayerSerializer(serializers.Serializer):
"""serialize player""" """serialize player"""
watched = serializers.BooleanField() watched = serializers.BooleanField()
watched_date = serializers.IntegerField(required=False)
duration = serializers.IntegerField() duration = serializers.IntegerField()
duration_str = serializers.CharField() duration_str = serializers.CharField()
progress = serializers.FloatField(required=False) progress = serializers.FloatField(required=False)
@@ -111,7 +112,7 @@ class VideoListQuerySerializer(serializers.Serializer):
playlist = serializers.CharField(required=False) playlist = serializers.CharField(required=False)
channel = serializers.CharField(required=False) channel = serializers.CharField(required=False)
watch = serializers.ChoiceField( watch = serializers.ChoiceField(
choices=WatchedEnum.values(), required=False choices=WatchedEnum.values(), required=False, allow_null=True
) )
sort = serializers.ChoiceField(choices=SortEnum.names(), required=False) sort = serializers.ChoiceField(choices=SortEnum.names(), required=False)
order = serializers.ChoiceField(choices=OrderEnum.values(), required=False) order = serializers.ChoiceField(choices=OrderEnum.values(), required=False)
@@ -119,6 +120,7 @@ class VideoListQuerySerializer(serializers.Serializer):
choices=VideoTypeEnum.values_known(), required=False choices=VideoTypeEnum.values_known(), required=False
) )
page = serializers.IntegerField(required=False) page = serializers.IntegerField(required=False)
height = serializers.IntegerField(required=False)
class CommentThreadItemSerializer(serializers.Serializer): class CommentThreadItemSerializer(serializers.Serializer):

View File

@@ -79,7 +79,9 @@ class Comments:
def get_yt_comments(self): def get_yt_comments(self):
"""get comments from youtube""" """get comments from youtube"""
yt_obs = self.build_yt_obs() yt_obs = self.build_yt_obs()
info_json = YtWrap(yt_obs, config=self.config).extract(self.youtube_id) info_json, _ = YtWrap(yt_obs, config=self.config).extract(
self.youtube_id
)
if not info_json: if not info_json:
return False, False return False, False

View File

@@ -11,6 +11,9 @@ class VideoTypeEnum(enum.Enum):
SHORTS = "shorts" SHORTS = "shorts"
UNKNOWN = "unknown" UNKNOWN = "unknown"
def __str__(self):
return self.value
@classmethod @classmethod
def values(cls) -> list[str]: def values(cls) -> list[str]:
"""value list""" """value list"""
@@ -31,6 +34,8 @@ class SortEnum(enum.Enum):
LIKES = "stats.like_count" LIKES = "stats.like_count"
DURATION = "player.duration" DURATION = "player.duration"
MEDIASIZE = "media_size" MEDIASIZE = "media_size"
WIDTH = "streams.width"
HEIGHT = "streams.height"
@classmethod @classmethod
def values(cls) -> list[str]: def values(cls) -> list[str]:

View File

@@ -10,10 +10,10 @@ from datetime import datetime
import requests import requests
from channel.src import index as ta_channel from channel.src import index as ta_channel
from common.src.env_settings import EnvironmentSettings from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.helper import get_duration_sec, get_duration_str, randomizor from common.src.helper import get_duration_sec, get_duration_str, randomizor
from common.src.index_generic import YouTubeItem from common.src.index_generic import YouTubeItem
from django.conf import settings from django.conf import settings
from download.src.thumbnails import ThumbManager
from playlist.src import index as ta_playlist from playlist.src import index as ta_playlist
from ryd_client import ryd_client from ryd_client import ryd_client
from user.src.user_config import UserConfig from user.src.user_config import UserConfig
@@ -48,11 +48,28 @@ class SponsorBlock:
def get_timestamps(self, youtube_id): def get_timestamps(self, youtube_id):
"""get timestamps from the API""" """get timestamps from the API"""
url = f"{self.API}/skipSegments?videoID={youtube_id}" url = f"{self.API}/skipSegments"
headers = {"User-Agent": self.user_agent} headers = {"User-Agent": self.user_agent}
categories = [
"sponsor",
"selfpromo",
"interaction",
"intro",
"outro",
"preview",
"music_offtopic",
"poi_highlight",
"filler",
]
params = {
"videoID": youtube_id,
"category": categories,
}
print(f"{youtube_id}: get sponsorblock timestamps") print(f"{youtube_id}: get sponsorblock timestamps")
try: try:
response = requests.get(url, headers=headers, timeout=10) response = requests.get(
url, headers=headers, params=params, timeout=10
)
except (requests.ReadTimeout, requests.ConnectionError) as err: except (requests.ReadTimeout, requests.ConnectionError) as err:
print(f"{youtube_id}: sponsorblock API error: {str(err)}") print(f"{youtube_id}: sponsorblock API error: {str(err)}")
return False return False
@@ -75,9 +92,17 @@ class SponsorBlock:
def _get_sponsor_dict(self, all_segments): def _get_sponsor_dict(self, all_segments):
"""format and process response""" """format and process response"""
_ = [i.pop("description", None) for i in all_segments]
has_unlocked = not any(i.get("locked") for i in all_segments) has_unlocked = not any(i.get("locked") for i in all_segments)
# Set only sponsor to skip (retain legacy behaviour)
categories_skip = ["sponsor"]
for segment in all_segments:
segment_category = segment["category"]
if segment_category in categories_skip:
segment["actionType"] = "skip"
else:
segment["actionType"] = "none"
sponsor_dict = { sponsor_dict = {
"last_refresh": self.last_refresh, "last_refresh": self.last_refresh,
"has_unlocked": has_unlocked, "has_unlocked": has_unlocked,
@@ -173,21 +198,15 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
self._validate_id() self._validate_id()
# extract # extract
self.channel_id = self.youtube_meta["channel_id"] self.channel_id = self.youtube_meta["channel_id"]
upload_date = self.youtube_meta["upload_date"]
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
last_refresh = int(datetime.now().timestamp()) last_refresh = int(datetime.now().timestamp())
# base64_blur = ThumbManager().get_base64_blur(self.youtube_id)
base64_blur = False
# build json_data basics # build json_data basics
self.json_data = { self.json_data = {
"title": self.youtube_meta["title"], "title": self.youtube_meta["title"],
"description": self.youtube_meta.get("description", ""), "description": self.youtube_meta.get("description", ""),
"category": self.youtube_meta.get("categories", []), "category": self.youtube_meta.get("categories", []),
"vid_thumb_url": self.youtube_meta["thumbnail"], "vid_thumb_url": self.youtube_meta["thumbnail"],
"vid_thumb_base64": base64_blur,
"tags": self.youtube_meta.get("tags", []), "tags": self.youtube_meta.get("tags", []),
"published": published, "published": self._build_published(),
"vid_last_refresh": last_refresh, "vid_last_refresh": last_refresh,
"date_downloaded": last_refresh, "date_downloaded": last_refresh,
"youtube_id": self.youtube_id, "youtube_id": self.youtube_id,
@@ -196,6 +215,18 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
"active": True, "active": True,
} }
def _build_published(self):
"""build published date or timestamp"""
timestamp = self.youtube_meta.get("timestamp")
if timestamp:
return timestamp
upload_date = self.youtube_meta["upload_date"]
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
return published
def _validate_id(self): def _validate_id(self):
"""validate expected video ID, raise value error on mismatch""" """validate expected video ID, raise value error on mismatch"""
remote_id = self.youtube_meta["id"] remote_id = self.youtube_meta["id"]
@@ -385,12 +416,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
return subtitles return subtitles
def update_media_url(self):
"""update only media_url in es for reindex channel rename"""
data = {"doc": {"media_url": self.json_data["media_url"]}}
path = f"{self.index_name}/_update/{self.youtube_id}"
_, _ = ElasticWrap(path).post(data=data)
def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS): def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
"""combined classes to create new video in index""" """combined classes to create new video in index"""
@@ -400,5 +425,8 @@ def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
raise ValueError("failed to get metadata for " + youtube_id) raise ValueError("failed to get metadata for " + youtube_id)
video.check_subtitles() video.check_subtitles()
url = video.json_data["vid_thumb_url"]
ThumbManager(item_id=video.youtube_id).download_video_thumb(url=url)
video.upload_to_es() video.upload_to_es()
return video.json_data return video.json_data

View File

@@ -1,6 +1,7 @@
"""build query for video fetching""" """build query for video fetching"""
from common.src.ta_redis import RedisArchivist from common.src.ta_redis import RedisArchivist
from playlist.src.index import YoutubePlaylist
from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum
@@ -34,7 +35,7 @@ class QueryBuilder:
must_list.append({"match": {"playlist.keyword": playlist}}) must_list.append({"match": {"playlist.keyword": playlist}})
watch = self.request_params.get("watch") watch = self.request_params.get("watch")
if watch: if watch is not None:
watch_must_list = self.parse_watch(watch) watch_must_list = self.parse_watch(watch)
must_list.append(watch_must_list) must_list.append(watch_must_list)
@@ -43,6 +44,11 @@ class QueryBuilder:
type_list_list = self.parse_type(video_type) type_list_list = self.parse_type(video_type)
must_list.append(type_list_list) must_list.append(type_list_list)
height = self.request_params.get("height")
if height:
height_must = self.parse_height(height)
must_list.append(height_must)
query = {"bool": {"must": must_list}} query = {"bool": {"must": must_list}}
return query return query
@@ -82,8 +88,18 @@ class QueryBuilder:
return {"match": {"vid_type": vid_type}} return {"match": {"vid_type": vid_type}}
def parse_height(self, height: str):
"""parse height to int"""
return {"term": {"streams.height": {"value": height}}}
def parse_sort(self) -> dict | None: def parse_sort(self) -> dict | None:
"""build sort key""" """build sort key"""
playlist = self.request_params.get("playlist")
if playlist:
# overwrite sort based on idx in playlist
return self._get_playlist_sort(playlist_id=playlist)
sort = self.request_params.get("sort") sort = self.request_params.get("sort")
if not sort: if not sort:
return None return None
@@ -100,3 +116,39 @@ class QueryBuilder:
order_by = getattr(OrderEnum, order.upper()).value order_by = getattr(OrderEnum, order.upper()).value
return {"sort": [{sort_field: {"order": order_by}}]} return {"sort": [{sort_field: {"order": order_by}}]}
def _get_playlist_sort(self, playlist_id: str):
"""get sort for playlist"""
playlist = YoutubePlaylist(playlist_id)
playlist.get_from_es()
if not playlist.json_data:
raise ValueError(f"playlist {playlist_id} not found")
sort_score = {
i["youtube_id"]: i["idx"]
for i in playlist.json_data["playlist_entries"]
if i["downloaded"]
}
script = (
"if(params.scores.containsKey(doc['youtube_id'].value)) "
+ "{return params.scores[doc['youtube_id'].value];} "
+ "return 100000;"
)
sort = {
"sort": [
{
"_script": {
"type": "number",
"script": {
"lang": "painless",
"source": script,
"params": {"scores": sort_score},
},
"order": "asc",
}
}
],
}
return sort

View File

@@ -7,12 +7,15 @@ functionality:
import json import json
import os import os
import re
from datetime import datetime from datetime import datetime
from operator import itemgetter
import requests import requests
from common.src.env_settings import EnvironmentSettings from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap from common.src.es_connect import ElasticWrap
from common.src.helper import requests_headers from common.src.helper import rand_sleep, requests_headers
from yt_dlp.utils import orderedSet_from_options
class YoutubeSubtitle: class YoutubeSubtitle:
@@ -35,85 +38,67 @@ class YoutubeSubtitle:
# no subtitles # no subtitles
return False return False
relevant_subtitles = [] available_subtitles = self._get_all_subtitles("user")
for lang in self.languages: if self.video.config["downloads"]["subtitle_source"] == "auto":
user_sub = self._get_user_subtitles(lang) for lang, auto_cap in self._get_all_subtitles("auto").items():
if user_sub: if lang not in available_subtitles:
relevant_subtitles.append(user_sub) available_subtitles[lang] = auto_cap
continue
if self.video.config["downloads"]["subtitle_source"] == "auto": all_sub_langs = tuple(available_subtitles.keys())
auto_cap = self._get_auto_caption(lang) relevant_subtitles = False
if auto_cap: try:
relevant_subtitles.append(auto_cap) relevant_subtitles = [
available_subtitles[lang]
for lang in orderedSet_from_options(
self.languages, {"all": all_sub_langs}, use_regex=True
)
]
except re.error as e:
raise ValueError(f"wrong regex in subtitle config: {e.pattern}")
return relevant_subtitles return relevant_subtitles
def _get_auto_caption(self, lang): def _get_all_subtitles(self, source):
"""get auto_caption subtitles""" """get video subtitles or automatic captions"""
print(f"{self.video.youtube_id}-{lang}: get auto generated subtitles") print(f"{self.video.youtube_id}: get {source} subtitles")
all_subtitles = self.video.youtube_meta.get("automatic_captions") youtube_meta_keys = {"user": "subtitles", "auto": "automatic_captions"}
if not (youtube_meta_key := youtube_meta_keys.get(source, None)):
raise ValueError(f"unknown subtitles source: {source}")
all_subtitles = self.video.youtube_meta.get(youtube_meta_key)
if not all_subtitles: if not all_subtitles:
return False return {}
video_media_url = self.video.json_data["media_url"] candidate_subtitles = {}
media_url = video_media_url.replace(".mp4", f".{lang}.vtt") for lang, all_formats in all_subtitles.items():
all_formats = all_subtitles.get(lang)
if not all_formats:
return False
subtitle_json3 = [i for i in all_formats if i["ext"] == "json3"]
if not subtitle_json3:
print(f"{self.video.youtube_id}-{lang}: json3 not processed")
return False
subtitle = subtitle_json3[0]
subtitle.update(
{"lang": lang, "source": "auto", "media_url": media_url}
)
return subtitle
def _normalize_lang(self):
"""normalize country specific language keys"""
all_subtitles = self.video.youtube_meta.get("subtitles")
if not all_subtitles:
return False
all_keys = list(all_subtitles.keys())
for key in all_keys:
lang = key.split("-")[0]
old = all_subtitles.pop(key)
if lang == "live_chat": if lang == "live_chat":
# not supported yet
continue continue
all_subtitles[lang] = old
return all_subtitles video_media_url = self.video.json_data["media_url"]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
if not all_formats:
# no subtitles found
continue
def _get_user_subtitles(self, lang): subtitle_json3 = [i for i in all_formats if i["ext"] == "json3"]
"""get subtitles uploaded from channel owner""" if not subtitle_json3:
print(f"{self.video.youtube_id}-{lang}: get user uploaded subtitles") print(f"{self.video.youtube_id}-{lang}: json3 not processed")
all_subtitles = self._normalize_lang() continue
if not all_subtitles:
return False
video_media_url = self.video.json_data["media_url"] subtitle = subtitle_json3[0]
media_url = video_media_url.replace(".mp4", f".{lang}.vtt") subtitle.update(
all_formats = all_subtitles.get(lang) {"lang": lang, "source": source, "media_url": media_url}
if not all_formats: )
# no user subtitles found candidate_subtitles[lang] = subtitle
return False
subtitle = [i for i in all_formats if i["ext"] == "json3"][0] return candidate_subtitles
subtitle.update(
{"lang": lang, "source": "user", "media_url": media_url}
)
return subtitle
def download_subtitles(self, relevant_subtitles): def download_subtitles(self, relevant_subtitles):
"""download subtitle files to archive""" """download subtitle files to archive"""
subtitle_list = ", ".join(map(itemgetter("lang"), relevant_subtitles))
print(
f"{self.video.youtube_id}: downloading subtitles: {subtitle_list}"
)
videos_base = EnvironmentSettings.MEDIA_DIR videos_base = EnvironmentSettings.MEDIA_DIR
indexed = [] indexed = []
for subtitle in relevant_subtitles: for subtitle in relevant_subtitles:
@@ -124,17 +109,21 @@ class YoutubeSubtitle:
subtitle["url"], headers=requests_headers(), timeout=30 subtitle["url"], headers=requests_headers(), timeout=30
) )
if not response.ok: if not response.ok:
print(f"{self.video.youtube_id}: failed to download subtitle") subtitle_key = f"{self.video.youtube_id}-{lang}"
print(f"{subtitle_key}: failed to download subtitle")
print(response.text) print(response.text)
rand_sleep(self.video.config)
continue continue
if not response.text: if not response.text:
print(f"{self.video.youtube_id}: skip empty subtitle") print(f"{subtitle_key}: skip empty subtitle")
rand_sleep(self.video.config)
continue continue
parser = SubtitleParser(response.text, lang, source) parser = SubtitleParser(response.text, lang, source)
parser.process() parser.process()
if not parser.all_cues: if not parser.all_cues:
rand_sleep(self.video.config)
continue continue
subtitle_str = parser.get_subtitle_str() subtitle_str = parser.get_subtitle_str()
@@ -144,6 +133,7 @@ class YoutubeSubtitle:
self._index_subtitle(query_str) self._index_subtitle(query_str)
indexed.append(subtitle) indexed.append(subtitle)
rand_sleep(self.video.config)
return indexed return indexed

View File

@@ -16,7 +16,6 @@ def test_build_data():
qb = QueryBuilder( qb = QueryBuilder(
user_id=1, user_id=1,
channel="test_channel", channel="test_channel",
playlist="test_playlist",
watch="watched", watch="watched",
type="videos", type="videos",
sort="published", sort="published",

View File

@@ -31,6 +31,7 @@ class VideoApiListView(ApiBaseView):
- sort:enum=published|downloaded|views|likes|duration|filesize - sort:enum=published|downloaded|views|likes|duration|filesize
- order:enum=asc|desc - order:enum=asc|desc
- type:enum=videos|streams|shorts - type:enum=videos|streams|shorts
- height:int=px
""" """
search_base = "ta_video/_search/" search_base = "ta_video/_search/"
@@ -227,7 +228,8 @@ class VideoProgressView(ApiBaseView):
expire = False expire = False
current_progress.update({"watched": watched}) current_progress.update({"watched": watched})
redis_con.set_message(key, current_progress, expire=expire) if position > 5:
redis_con.set_message(key, current_progress, expire=expire)
response_serializer = PlayerSerializer(current_progress) response_serializer = PlayerSerializer(current_progress)

View File

@@ -1,5 +1,3 @@
version: '3.5'
services: services:
tubearchivist: tubearchivist:
container_name: tubearchivist container_name: tubearchivist
@@ -40,7 +38,7 @@ services:
depends_on: depends_on:
- archivist-es - archivist-es
archivist-es: archivist-es:
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.18.0 image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.18.2
container_name: archivist-es container_name: archivist-es
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -0,0 +1,32 @@
#!/bin/bash
# auto restart beat scheduler
# https://github.com/celery/django-celery-beat/issues/894
if [[ -n "$DJANGO_DEBUG" ]]; then
LOGLEVEL="DEBUG"
else
LOGLEVEL="INFO"
fi
COMMAND="celery -A task beat --loglevel=$LOGLEVEL --scheduler django_celery_beat.schedulers:DatabaseScheduler"
TIMEOUT=3600
while true; do
echo "Starting process beat scheduler"
$COMMAND &
PID=$!
sleep $TIMEOUT
# Kill the process if still running
if kill -0 $PID 2>/dev/null; then
echo "Killing beat process after $TIMEOUT seconds"
kill $PID
# Wait a bit to allow graceful shutdown, then force kill if needed
sleep 10
kill -9 $PID 2>/dev/null
fi
echo "Restarting beat..."
done

View File

@@ -50,7 +50,17 @@ server {
root /app/static; root /app/static;
index index.html; index index.html;
location ~* .(?:css|js)$ {
try_files $uri $uri/ /index.html =404;
}
location = /index.html {
add_header Cache-Control 'no-store';
expires 0;
}
location / { location / {
add_header Cache-Control 'no-store';
try_files $uri $uri/ /index.html =404; try_files $uri $uri/ /index.html =404;
} }
} }

View File

@@ -3,6 +3,21 @@
set -e set -e
if [[ -n "$DJANGO_DEBUG" ]]; then
LOGLEVEL="DEBUG"
else
LOGLEVEL="INFO"
fi
# update yt-dlp if needed
if [[ "${TA_AUTO_UPDATE_YTDLP,,}" =~ ^(release|nightly)$ ]]; then
echo "Updating yt-dlp..."
preflag=$([[ "${TA_AUTO_UPDATE_YTDLP,,}" == "nightly" ]] && echo "--pre" || echo "")
python -m pip install --target=/root/.local/bin --upgrade $preflag "yt-dlp[default]" || {
echo "yt-dlp update failed"
}
fi
# stop on pending manual migration # stop on pending manual migration
python manage.py ta_stop_on_error python manage.py ta_stop_on_error
@@ -18,10 +33,11 @@ python manage.py ta_startup
# start all tasks # start all tasks
nginx & nginx &
celery -A task.celery worker \ celery -A task.celery worker \
--loglevel=INFO \ --loglevel=$LOGLEVEL \
--concurrency 4 \ --concurrency 4 \
--max-tasks-per-child 5 \ --max-tasks-per-child 5 \
--max-memory-per-child 150000 & --max-memory-per-child 150000 &
celery -A task beat --loglevel=INFO \
--scheduler django_celery_beat.schedulers:DatabaseScheduler & ./beat_auto_spawn.sh &
python backend_start.py python backend_start.py

File diff suppressed because it is too large Load Diff

View File

@@ -11,27 +11,27 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.2.5", "dompurify": "^3.2.6",
"react": "^19.1.0", "react": "^19.1.1",
"react-dom": "^19.1.0", "react-dom": "^19.1.1",
"react-router-dom": "^7.6.0", "react-router-dom": "^7.8.0",
"zustand": "^5.0.4" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.3", "@types/react": "^19.1.9",
"@types/react-dom": "^19.1.3", "@types/react-dom": "^19.1.7",
"@typescript-eslint/eslint-plugin": "^8.32.0", "@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.32.0", "@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-swc": "^4.0.0",
"eslint": "^9.26.0", "eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0", "globals": "^16.3.0",
"prettier": "3.5.3", "prettier": "3.6.2",
"typescript": "^5.8.3", "typescript": "^5.9.2",
"typescript-eslint": "^8.32.0", "typescript-eslint": "^8.39.0",
"vite": ">=6.3.5", "vite": ">=7.1.1",
"vite-plugin-checker": "^0.9.3" "vite-plugin-checker": "^0.10.2"
} }
} }

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="131 -131 512 512"
style="enable-background:new 131 -131 512 512;"
xml:space="preserve"
sodipodi:docname="icon-filter.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.6130873"
inkscape:cx="122.12606"
inkscape:cy="215.73537"
inkscape:window-width="2532"
inkscape:window-height="1379"
inkscape:window-x="1932"
inkscape:window-y="45"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" />
<g
id="XMLID_2_"
transform="matrix(0.71904421,0,0,0.71904421,108.73394,35.132323)">
<path
id="XMLID_4_"
d="m 640.9,-116.5 c 3.7,10.2 2.8,18.6 -4.7,25.1 L 456.6,87.4 v 270 c 0,10.2 -4.7,17.7 -14,21.4 -2.8,0.9 -6.5,1.9 -9.3,1.9 -6.5,0 -12.1,-1.9 -16.8,-6.5 L 323.4,281 c -4.7,-4.7 -6.5,-10.2 -6.5,-16.8 V 87.4 L 138.2,-91.4 c -7.4,-7.4 -9.3,-15.8 -4.7,-25.1 3.7,-9.3 11.2,-14 21.4,-14 h 464.5 c 10.3,-0.9 16.8,3.8 21.5,14 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 512 512"
version="1.1"
id="svg1"
docname="icon-multi-select.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.0722656"
inkscape:cx="255.75872"
inkscape:cy="255.75872"
inkscape:window-width="2560"
inkscape:window-height="1407"
inkscape:window-x="1920"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<!--! Font Awesome Free 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. -->
<path
d="m 55.494816,230.93685 c 0,-27.64778 22.43935,-50.12629 50.126294,-50.12629 h 50.1263 v 100.25259 c 0,41.51084 32.9737,75.18944 75.18944,75.18944 h 100.25259 v 50.1263 c 0,27.64778 -22.47851,50.12629 -50.12629,50.12629 H 105.62111 c -27.686944,0 -50.126294,-22.47851 -50.126294,-50.12629 z M 230.93685,331.18944 c -27.64778,0 -50.12629,-22.47851 -50.12629,-50.12629 V 105.62111 c 0,-27.686944 22.47851,-50.126294 50.12629,-50.126294 h 175.44204 c 27.64778,0 50.12629,22.43935 50.12629,50.126294 v 175.44204 c 0,27.64778 -22.47851,50.12629 -50.12629,50.12629 z"
id="path1"
style="stroke-width:0.783223" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" width="960pt" height="960pt" viewBox="0 0 960 960" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0,960)scale(.075,.075)">
<path id="path1" d="M 740 -10481 c -77 24 -152 94 -170 160 -6 23 -10 376 -10 946 l 0 910 24 50 c 28 59 66 97 121 122 38 17 268 18 5620 21 3923 1 5595 -1 5632 -8 70 -15 124 -56 157 -121 l 27 -54 0 -920 c 0 -637 -4 -932 -11 -958 -17 -58 -84 -122 -150 -141 -49 -15 -544 -16 -5635 -15 -3069 0 -5591 4 -5605 8 z M 9615 -7400 c -11 4 -31 20 -45 35 l -25 27 -3 889 c -3 978 -5 935 57 972 27 16 117 17 1253 17 1321 0 1252 3 1293 -54 20 -27 20 -43 20 -919 0 -860 -1 -893 -19 -920 -41 -60 37 -57 -1293 -56 -670 0 -1227 4 -1238 9 z M 3658 -7384 c -61 32 -58 -10 -58 959 0 973 -3 928 60 960 25 13 178 15 1250 15 1072 0 1225 -2 1250 -15 63 -32 60 13 60 -960 0 -973 3 -928 -60 -960 -25 -13 -178 -15 -1252 -15 -1062 1 -1227 3 -1250 16 z M 6551 -7374 c -17 14 -35 42 -41 62 -6 24 -10 339 -10 892 0 826 1 856 20 898 35 78 -60 73 1315 70 l 1225 -3 32 -33 33 -32 3 -895 c 2 -788 0 -899 -13 -925 -33 -64 48 -60 -1304 -60 l -1229 0 -31 26 z M 616 -7369 c -59 46 -56 0 -56 951 0 976 -4 924 70 960 33 17 113 18 1250 18 1187 0 1216 -1 1247 -20 66 -40 63 12 63 -955 0 -791 -2 -880 -16 -911 -33 -68 54 -64 -1302 -64 l -1229 0 -27 21 z M 9615 -4530 c -11 4 -31 20 -45 35 l -25 27 -3 901 c -2 891 -2 902 18 935 40 65 -32 62 1296 62 1335 0 1255 4 1292 -64 16 -29 17 -101 17 -921 0 -832 -1 -892 -18 -922 -36 -67 50 -63 -1294 -62 -670 0 -1227 4 -1238 9 z M 3664 -4514 c -68 33 -64 -26 -64 973 l 0 901 34 38 34 37 1242 0 1242 0 34 -37 34 -38 0 -900 c 0 -989 3 -942 -60 -975 -25 -13 -178 -15 -1247 -15 -1064 0 -1222 2 -1249 16 z M 6551 -4504 c -17 14 -35 42 -41 62 -14 51 -14 1733 0 1784 6 20 24 48 41 62 l 31 26 1237 0 c 1360 0 1263 5 1296 -60 23 -44 23 -1796 0 -1840 -33 -64 47 -60 -1304 -60 l -1229 0 -31 26 z M 615 -4498 c -57 44 -55 13 -55 963 0 844 1 877 19 913 11 21 31 43 46 50 20 9 323 12 1260 12 l 1233 0 31 -30 c 17 -17 33 -45 36 -63 3 -18 4 -429 3 -915 l -3 -884 -37 -34 -38 -34 -1234 0 -1233 0 -28 22 z " />
</g>
</svg>

View File

@@ -6,10 +6,10 @@ type AppriseTaskNameType =
| 'download_pending' | 'download_pending'
| 'check_reindex'; | 'check_reindex';
const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType) => { const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => {
return APIClient('/api/task/notification/', { return APIClient('/api/task/notification/', {
method: 'DELETE', method: 'DELETE',
body: { task_name: taskName }, body: { task_name: taskName, url: url },
}); });
}; };

View File

@@ -2,9 +2,15 @@ import APIClient from '../../functions/APIClient';
type FilterType = 'ignore' | 'pending'; type FilterType = 'ignore' | 'pending';
const deleteDownloadQueueByFilter = async (filter: FilterType) => { const deleteDownloadQueueByFilter = async (
filter: FilterType,
channel: string | null,
vid_type: string | null,
) => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (filter) searchParams.append('filter', filter); if (filter) searchParams.append('filter', filter);
if (channel) searchParams.append('channel', channel);
if (vid_type) searchParams.append('vid_type', vid_type);
return APIClient(`/api/download/?${searchParams.toString()}`, { return APIClient(`/api/download/?${searchParams.toString()}`, {
method: 'DELETE', method: 'DELETE',

View File

@@ -3,7 +3,7 @@ import APIClient from '../../functions/APIClient';
const deletePlaylist = async (playlistId: string, allVideos = false) => { const deletePlaylist = async (playlistId: string, allVideos = false) => {
let params = ''; let params = '';
if (allVideos) { if (allVideos) {
params = '?delete-videos=true'; params = '?delete_videos=true';
} }
return APIClient(`/api/playlist/${playlistId}/${params}`, { return APIClient(`/api/playlist/${playlistId}/${params}`, {

View File

@@ -0,0 +1,16 @@
import APIClient from '../../functions/APIClient';
import { AppriseTaskNameType } from './createAppriseNotificationUrl';
export type TestNotificationResponseType = {
success: boolean;
message: string;
};
const testAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => {
return APIClient<TestNotificationResponseType>('/api/task/notification/test/', {
method: 'POST',
body: { task_name: taskName, url },
});
};
export default testAppriseNotificationUrl;

View File

@@ -1,11 +1,18 @@
import APIClient from '../../functions/APIClient'; import APIClient from '../../functions/APIClient';
const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) => { type UpdateDownloadQueueType = {
youtubeIdStrings: string;
autostart?: boolean;
flat?: boolean;
force?: boolean;
};
const updateDownloadQueue = async (params: UpdateDownloadQueueType) => {
const urls = []; const urls = [];
const containsMultiple = youtubeIdStrings.includes('\n'); const containsMultiple = params.youtubeIdStrings.includes('\n');
if (containsMultiple) { if (containsMultiple) {
const youtubeIds = youtubeIdStrings.split('\n'); const youtubeIds = params.youtubeIdStrings.split('\n');
youtubeIds.forEach(youtubeId => { youtubeIds.forEach(youtubeId => {
if (youtubeId.trim()) { if (youtubeId.trim()) {
@@ -13,15 +20,16 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean)
} }
}); });
} else { } else {
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' }); urls.push({ youtube_id: params.youtubeIdStrings, status: 'pending' });
} }
let params = ''; const searchParams = new URLSearchParams();
if (autostart) { if (params.autostart === true) searchParams.append('autostart', 'true');
params = '?autostart=true'; if (params.flat === true) searchParams.append('flat', 'true');
} if (params.force === true) searchParams.append('force', 'true');
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient(`/api/download/${params}`, { return APIClient(endpoint, {
method: 'POST', method: 'POST',
body: { data: [...urls] }, body: { data: [...urls] },
}); });

View File

@@ -0,0 +1,25 @@
import APIClient from '../../functions/APIClient';
type FilterType = 'ignore' | 'pending';
export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority' | 'clear_error';
const updateDownloadQueueByFilter = async (
filter: FilterType,
channel: string | null,
vid_type: string | null,
error: string | null,
status: DownloadQueueStatus,
) => {
const searchParams = new URLSearchParams();
if (filter) searchParams.append('filter', filter);
if (channel) searchParams.append('channel', channel);
if (vid_type) searchParams.append('vid_type', vid_type);
if (error) searchParams.append('error', error);
return APIClient(`/api/download/?${searchParams.toString()}`, {
method: 'PATCH',
body: { status: status },
});
};
export default updateDownloadQueueByFilter;

View File

@@ -0,0 +1,10 @@
import APIClient from '../../functions/APIClient';
const updatePlaylistSortOrder = async (playlistId: string, newSortOrder: 'top' | 'bottom') => {
return APIClient(`/api/playlist/${playlistId}/`, {
method: 'POST',
body: { playlist_sort_order: newSortOrder },
});
};
export default updatePlaylistSortOrder;

View File

@@ -1,5 +1,6 @@
import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home'; import { ViewStylesType } from '../../configuration/constants/ViewStyle';
import APIClient from '../../functions/APIClient'; import APIClient from '../../functions/APIClient';
import { SortByType, SortOrderType, VideoTypes } from '../loader/loadVideoListByPage';
export type ColourVariants = export type ColourVariants =
| 'dark.css' | 'dark.css'
@@ -8,6 +9,14 @@ export type ColourVariants =
| 'midnight.css' | 'midnight.css'
| 'custom.css'; | 'custom.css';
export const ColourConstant = {
Dark: 'dark.css',
Light: 'light.css',
Matrix: 'matrix.css',
Midnight: 'midnight.css',
Custom: 'custom.css',
};
export const FileSizeUnits = { export const FileSizeUnits = {
Binary: 'binary', Binary: 'binary',
Metric: 'metric', Metric: 'metric',
@@ -18,15 +27,19 @@ export type UserConfigType = {
page_size: number; page_size: number;
sort_by: SortByType; sort_by: SortByType;
sort_order: SortOrderType; sort_order: SortOrderType;
view_style_home: ViewLayoutType; view_style_home: ViewStylesType;
view_style_channel: ViewLayoutType; view_style_channel: ViewStylesType;
view_style_downloads: ViewLayoutType; view_style_downloads: ViewStylesType;
view_style_playlist: ViewLayoutType; view_style_playlist: ViewStylesType;
vid_type_filter: VideoTypes | null;
grid_items: number; grid_items: number;
hide_watched: boolean; hide_watched: boolean | null;
hide_watched_channel: boolean | null;
hide_watched_playlist: boolean | null;
file_size_unit: 'binary' | 'metric'; file_size_unit: 'binary' | 'metric';
show_ignored_only: boolean; show_ignored_only: boolean;
show_subed_only: boolean; show_subed_only: boolean | null;
show_subed_only_playlists: boolean | null;
show_help_text: boolean; show_help_text: boolean;
}; };

View File

@@ -5,7 +5,9 @@ export type AppSettingsConfigType = {
channel_size: number | null; channel_size: number | null;
live_channel_size: number | null; live_channel_size: number | null;
shorts_channel_size: number | null; shorts_channel_size: number | null;
playlist_size: number | null;
auto_start: boolean; auto_start: boolean;
extract_flat: boolean;
}; };
downloads: { downloads: {
limit_speed: number | null; limit_speed: number | null;

View File

@@ -9,11 +9,13 @@ export type ChannelsListResponse = {
config?: ConfigType; config?: ConfigType;
}; };
const loadChannelList = async (page: number, showSubscribed: boolean) => { const loadChannelList = async (page: number, showSubscribed: boolean | null) => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString()); if (page) searchParams.append('page', page.toString());
if (showSubscribed) searchParams.append('filter', 'subscribed'); if (showSubscribed !== null) {
searchParams.append('filter', showSubscribed ? 'subscribed' : 'unsubscribed');
}
const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;

View File

@@ -5,6 +5,7 @@ export type ChannelNavResponseType = {
has_shorts: boolean; has_shorts: boolean;
has_playlists: boolean; has_playlists: boolean;
has_pending: boolean; has_pending: boolean;
has_ignored: boolean;
}; };
const loadChannelNav = async (youtubeChannelId: string) => { const loadChannelNav = async (youtubeChannelId: string) => {

View File

@@ -1,11 +1,21 @@
import APIClient from '../../functions/APIClient'; import APIClient from '../../functions/APIClient';
import { DownloadResponseType } from '../../pages/Download'; import { DownloadResponseType } from '../../pages/Download';
const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => { const loadDownloadQueue = async (
page: number,
channelId: string | null,
vid_type: string | null,
errorFilterFromUrl: string | null,
showIgnored: boolean,
search: string,
) => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString()); if (page) searchParams.append('page', page.toString());
if (channelId) searchParams.append('channel', channelId); if (channelId) searchParams.append('channel', channelId);
if (vid_type) searchParams.append('vid_type', vid_type);
if (search) searchParams.append('q', encodeURIComponent(search));
if (errorFilterFromUrl !== null) searchParams.append('error', errorFilterFromUrl);
searchParams.append('filter', showIgnored ? 'ignore' : 'pending'); searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;

View File

@@ -14,6 +14,7 @@ export type PlaylistType = {
playlist_channel_id: string; playlist_channel_id: string;
playlist_description: string; playlist_description: string;
playlist_entries: PlaylistEntryType[]; playlist_entries: PlaylistEntryType[];
playlist_sort_order: 'top' | 'bottom';
playlist_id: string; playlist_id: string;
playlist_last_refresh: string; playlist_last_refresh: string;
playlist_name: string; playlist_name: string;

View File

@@ -12,7 +12,7 @@ type PlaylistCategoryType = 'regular' | 'custom';
type LoadPlaylistListProps = { type LoadPlaylistListProps = {
channel?: string; channel?: string;
page?: number | undefined; page?: number | undefined;
subscribed?: boolean; subscribed?: boolean | null;
type?: PlaylistCategoryType; type?: PlaylistCategoryType;
}; };
@@ -21,7 +21,8 @@ const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylis
if (channel) searchParams.append('channel', channel); if (channel) searchParams.append('channel', channel);
if (page) searchParams.append('page', page.toString()); if (page) searchParams.append('page', page.toString());
if (subscribed) searchParams.append('subscribed', subscribed.toString()); if (subscribed !== undefined && subscribed !== null)
searchParams.append('subscribed', subscribed.toString());
if (type) searchParams.append('type', type); if (type) searchParams.append('type', type);
const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;

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