Compare commits

..

134 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
129 changed files with 5062 additions and 2729 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

@@ -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

@@ -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,11 +66,11 @@ IMPORTANT: When receiving help, contribute back to the community by improving th
## How to make a Pull Request ## How to make a Pull Request
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. 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.
- 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. - Make sure all your code is linted and formatted correctly, see below.
@@ -78,7 +79,6 @@ This is a quick checklist to help streamline the process:
All documentation is intended to represent the state of the [latest](https://github.com/tubearchivist/tubearchivist/releases/latest) release. 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 with code changes also requires changes to documentation *.md files here in this repo, create a separate PR for that, so it can be merged separately at release.
- You can make the PR directly against the *master* branch.
- If your PR requires changes on the [tubearchivist/docs](https://github.com/tubearchivist/docs), make the PR over there. - 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. - Prepare your documentation updates at the same time as the code changes, so people testing your PR can consult the prepared docs if needed.
@@ -107,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.
--- ---
@@ -133,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
@@ -183,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.
@@ -212,7 +206,7 @@ You may find it nice to run everything inside of a VM for complete environment s
### Working with Elasticsearch ### Working with Elasticsearch
Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries. Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries.
**Quick start** **Quick start**
Generate your access token in Elasitcsearch: Generate your access token in Elasitcsearch:
```bash ```bash
bin/elasticsearch-service-tokens create elastic/kibana kibana bin/elasticsearch-service-tokens create elastic/kibana kibana
@@ -233,7 +227,7 @@ services:
If you want to run queries on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it. If you want to run queries on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it.
**Persist Token** **Persist Token**
The token will get stored in ES in the `config` folder, and not in the `data` folder. To persist the token between ES container rebuilds, you'll need to persist the config folder as an additional volume: The token will get stored in ES in the `config` folder, and not in the `data` folder. To persist the token between ES container rebuilds, you'll need to persist the config folder as an additional volume:
1. Create the token as described above 1. Create the token as described above

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

View File

@@ -71,6 +71,7 @@ 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 | Configure the order of login authentication backends (Default: single) | Optional |
@@ -90,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:
@@ -167,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]
@@ -213,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

@@ -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": {
@@ -362,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"
}, },
@@ -461,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",
@@ -497,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):

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)

View File

@@ -4,16 +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 video.src.constants import VideoTypeEnum
class YoutubeChannel(YouTubeItem): class YoutubeChannel(YouTubeItem):
@@ -69,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):
@@ -104,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")
@@ -121,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"""
@@ -177,48 +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"""
from playlist.src.index import YoutubePlaylist
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"""
@@ -227,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"""
@@ -266,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"]
@@ -294,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", {})
@@ -352,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

@@ -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

@@ -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

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

View File

@@ -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

@@ -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

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,13 +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_video_channel_tags()
self._mig_fix_download_channel_indexed() self._mig_fix_download_channel_indexed()
self._mig_add_default_playlist_sort()
self._mig_set_channel_tabs()
self._mig_set_video_channel_tabs()
def _make_folders(self): def _make_folders(self):
"""make expected cache folders""" """make expected cache folders"""
@@ -151,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")
@@ -292,70 +259,6 @@ 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:
"""update from v0.4.13 to v0.5.0, migrate incorrect data types"""
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")
path = "ta_video/_update_by_query"
data = {
"query": {"match": {"channel.channel_tags": False}},
"script": {
"source": "ctx._source.channel.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} video channel tags"
)
)
else:
self.stdout.write(
self.style.SUCCESS(
" no video channel tags needed fixing"
)
)
return
message = " 🗙 failed to fix video channel tags"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _mig_fix_download_channel_indexed(self) -> None: def _mig_fix_download_channel_indexed(self) -> None:
"""migrate from v0.5.2 to 0.5.3, fix missing channel_indexed""" """migrate from v0.5.2 to 0.5.3, fix missing channel_indexed"""
self.stdout.write("[MIGRATION] fix incorrect video channel tags types") self.stdout.write("[MIGRATION] fix incorrect video channel tags types")
@@ -389,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,98 +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,
}
# Database # Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases # https://docs.djangoproject.com/en/3.2/ref/settings/#databases
@@ -229,16 +137,6 @@ AUTH_PASSWORD_VALIDATORS = [
AUTH_USER_MODEL = "user.Account" AUTH_USER_MODEL = "user.Account"
# Forward-auth authentication
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
TA_AUTH_PROXY_USERNAME_HEADER = (
environ.get("TA_AUTH_PROXY_USERNAME_HEADER") or "HTTP_REMOTE_USER"
)
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL")
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
# Configure Authentication Backend Combinations # Configure Authentication Backend Combinations
_login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold() _login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold()
if _login_auth_mode == "local": if _login_auth_mode == "local":
@@ -247,24 +145,33 @@ if _login_auth_mode == "local":
) )
elif _login_auth_mode == "ldap": elif _login_auth_mode == "ldap":
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",) AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
from .ldap_settings import * # noqa: F403 F401
elif _login_auth_mode == "forwardauth": 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": elif _login_auth_mode == "ldap_local":
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (
"django_auth_ldap.backend.LDAPBackend", "django_auth_ldap.backend.LDAPBackend",
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
) )
from .ldap_settings import * # noqa: F403 F401
else: else:
# If none of these cases match, AUTHENTICATION_BACKENDS is unset, which # If none of these cases match, AUTHENTICATION_BACKENDS is unset, which
# means the ModelBackend should be used by default # means the ModelBackend should be used by default
if bool(environ.get("TA_LDAP")): if bool(environ.get("TA_LDAP")):
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",) AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
from .ldap_settings import * # noqa: F403 F401
if bool(environ.get("TA_ENABLE_AUTH_PROXY")): if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
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")
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/ # https://docs.djangoproject.com/en/3.2/topics/i18n/
@@ -316,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.4" TA_VERSION = "v0.5.8-unstable"
# API # API
REST_FRAMEWORK = { REST_FRAMEWORK = {
@@ -329,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,126 +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, auto_start=False): 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, auto_start=auto_start)
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, auto_start=False):
"""process single entry from url list"""
vid_type = self._get_vid_type(entry)
if entry["type"] == "video":
self._add_video(entry["url"], vid_type, auto_start=auto_start)
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, auto_start=False):
"""add video to list"""
if auto_start and url in set(
i["youtube_id"] for i in self.all_pending
):
PendingInteract(youtube_id=url, status="priority").update_status()
return
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
@@ -286,82 +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",
], ]
progress=(idx + 1) / total,
) )
def get_youtube_details(self, youtube_id, vid_type=VideoTypeEnum.VIDEOS): def _notify_done(self, total):
"""get details from youtubedl for single pending video""" """send done notification"""
vid = YtWrap(self.yt_obs, self.config).extract(youtube_id) if not self.task:
if not vid: return
return False
if vid.get("id") != youtube_id: self.task.send_progress(
# skip premium videos with different id message_lines=[
print(f"{youtube_id}: skipping premium video, id not matching") "Adding new videos to the queue completed.",
return False f"Added {total} videos.",
# 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": def _notify_fail(self, status_code):
vid_type = VideoTypeEnum.STREAMS """failed to add"""
else: if not self.task:
if self._check_shorts(vid): return
vid_type = VideoTypeEnum.SHORTS
else:
vid_type = VideoTypeEnum.VIDEOS
if not vid.get("channel"): self.task.send_progress(
print(f"{youtube_id}: skip video not part of channel") message_lines=[
return False "Adding extracted videos failed.",
f"Status code: {status_code}",
return self._parse_youtube_details(vid, vid_type) ],
level="error",
@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")
# 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": self._build_published(vid),
"timestamp": int(datetime.now().timestamp()),
"vid_type": vid_type.value,
"channel_indexed": vid["channel_id"] in self.all_channels,
}
return youtube_details
@staticmethod
def _build_published(vid):
"""build published date or timestamp"""
timestamp = vid["timestamp"]
if timestamp:
return timestamp
upload_date = vid["upload_date"]
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
return published

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

@@ -57,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"],
}, },
} }
} }
@@ -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

@@ -35,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
@@ -48,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"""
@@ -59,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,
@@ -79,6 +94,18 @@ class YoutubePlaylist(YouTubeItem):
channel_handler = 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"]
@@ -111,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 = (
@@ -144,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
] ]
@@ -190,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()
@@ -288,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,
@@ -300,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.3.0
pre-commit==4.2.0
pylint-django==2.6.1
pylint==3.3.7
pytest-django==4.11.1
pytest==8.4.0
python-dotenv==1.1.0
requirementscheck==0.0.6
types-requests==2.32.0.20250602

View File

@@ -1,15 +1,15 @@
apprise==1.9.3 apprise==1.9.4
celery==5.5.3 celery==5.5.3
django-auth-ldap==5.2.0 django-auth-ldap==5.2.0
django-celery-beat==2.8.1 django-celery-beat==2.8.1
django-cors-headers==4.7.0 django-cors-headers==4.7.0
Django==5.2.2 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.2.0 redis==6.4.0
requests==2.32.4 requests==2.32.5
ryd-client==0.0.6 ryd-client==0.0.6
uvicorn==0.34.3 uvicorn==0.35.0
whitenoise==6.9.0 whitenoise==6.9.0
yt-dlp[default]==2025.6.9 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,
@@ -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(auto_start=auto_start) 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):
@@ -37,11 +37,17 @@ class UserMeConfigSerializer(serializers.Serializer):
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

@@ -112,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)
@@ -120,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"""

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,
@@ -174,15 +199,12 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
# extract # extract
self.channel_id = self.youtube_meta["channel_id"] self.channel_id = self.youtube_meta["channel_id"]
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": self._build_published(), "published": self._build_published(),
"vid_last_refresh": last_refresh, "vid_last_refresh": last_refresh,
@@ -394,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"""
@@ -409,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

@@ -35,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)
@@ -44,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
@@ -83,6 +88,11 @@ 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") playlist = self.request_params.get("playlist")

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

@@ -38,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

@@ -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

@@ -9,6 +9,15 @@ else
LOGLEVEL="INFO" LOGLEVEL="INFO"
fi 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

File diff suppressed because it is too large Load Diff

View File

@@ -12,26 +12,26 @@
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.2.6", "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.5" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.5", "@types/react": "^19.1.9",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.7",
"@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-react-swc": "^3.10.0", "@vitejs/plugin-react-swc": "^4.0.0",
"eslint": "^9.27.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.1", "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

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?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"> <!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"> <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)"> <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 " /> <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> </g>
</svg> </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,6 +1,6 @@
import { ViewStylesType } from '../../configuration/constants/ViewStyle'; import { ViewStylesType } from '../../configuration/constants/ViewStyle';
import APIClient from '../../functions/APIClient'; import APIClient from '../../functions/APIClient';
import { SortByType, SortOrderType } from '../loader/loadVideoListByPage'; import { SortByType, SortOrderType, VideoTypes } from '../loader/loadVideoListByPage';
export type ColourVariants = export type ColourVariants =
| 'dark.css' | 'dark.css'
@@ -31,11 +31,15 @@ export type UserConfigType = {
view_style_channel: ViewStylesType; view_style_channel: ViewStylesType;
view_style_downloads: ViewStylesType; view_style_downloads: ViewStylesType;
view_style_playlist: ViewStylesType; 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

@@ -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()}` : ''}`;

View File

@@ -36,7 +36,7 @@ export const SortOrderEnum = {
export type VideoTypes = 'videos' | 'streams' | 'shorts'; export type VideoTypes = 'videos' | 'streams' | 'shorts';
export type WatchTypes = 'watched' | 'unwatched' | 'continue'; export type WatchTypes = 'watched' | 'unwatched' | 'continue' | null;
export const WatchTypesEnum = { export const WatchTypesEnum = {
Watched: 'watched', Watched: 'watched',
Unwatched: 'unwatched', Unwatched: 'unwatched',
@@ -51,6 +51,7 @@ type FilterType = {
sort?: SortByType; sort?: SortByType;
order?: SortOrderType; order?: SortOrderType;
type?: VideoTypes; type?: VideoTypes;
height?: string;
}; };
const loadVideoListByFilter = async (filter: FilterType) => { const loadVideoListByFilter = async (filter: FilterType) => {
@@ -67,6 +68,7 @@ const loadVideoListByFilter = async (filter: FilterType) => {
if (filter.sort) searchParams.append('sort', filter.sort); if (filter.sort) searchParams.append('sort', filter.sort);
if (filter.order) searchParams.append('order', filter.order); if (filter.order) searchParams.append('order', filter.order);
if (filter.type) searchParams.append('type', filter.type); if (filter.type) searchParams.append('type', filter.type);
if (filter.height) searchParams.append('height', filter.height.toString());
const endpoint = `/api/video/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; const endpoint = `/api/video/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient<VideoListByFilterResponseType>(endpoint); return APIClient<VideoListByFilterResponseType>(endpoint);

View File

@@ -9,6 +9,7 @@ export interface ButtonProps {
children?: string | ReactNode | ReactNode[]; children?: string | ReactNode | ReactNode[];
value?: string; value?: string;
title?: string; title?: string;
disabled?: boolean;
onClick?: () => void; onClick?: () => void;
} }
@@ -21,6 +22,7 @@ const Button = ({
children, children,
value, value,
title, title,
disabled,
onClick, onClick,
}: ButtonProps) => { }: ButtonProps) => {
return ( return (
@@ -31,6 +33,7 @@ const Button = ({
type={type} type={type}
value={value} value={value}
title={title} title={title}
disabled={disabled}
onClick={onClick} onClick={onClick}
> >
{label} {label}

View File

@@ -7,6 +7,7 @@ import FormattedNumber from './FormattedNumber';
import Button from './Button'; import Button from './Button';
import ChannelIcon from './ChannelIcon'; import ChannelIcon from './ChannelIcon';
import ChannelBanner from './ChannelBanner'; import ChannelBanner from './ChannelBanner';
import LoadingIndicator from './LoadingIndicator';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
type ChannelListProps = { type ChannelListProps = {
@@ -18,8 +19,11 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
const { userConfig } = useUserConfigStore(); const { userConfig } = useUserConfigStore();
const viewStyle = userConfig.view_style_channel; const viewStyle = userConfig.view_style_channel;
if (!channelList || channelList.length === 0) { if (!channelList) {
return <p>No channels found.</p>; return <LoadingIndicator />;
}
if (channelList.length === 0) {
return <h2>No channels found...</h2>;
} }
return ( return (

View File

@@ -37,9 +37,10 @@ export type CommentsType = {
type CommentBoxProps = { type CommentBoxProps = {
comment: CommentsType; comment: CommentsType;
onTimestampClick?: (seconds: number) => void;
}; };
const CommentBox = ({ comment }: CommentBoxProps) => { const CommentBox = ({ comment, onTimestampClick }: CommentBoxProps) => {
const [showSubComments, setShowSubComments] = useState(false); const [showSubComments, setShowSubComments] = useState(false);
const hasSubComments = const hasSubComments =
@@ -51,7 +52,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => {
{comment.comment_author} {comment.comment_author}
</h3> </h3>
<p> <p>
<Linkify>{comment.comment_text}</Linkify> <Linkify onTimestampClick={onTimestampClick}>{comment.comment_text}</Linkify>
</p> </p>
<div className="comment-meta"> <div className="comment-meta">
@@ -95,7 +96,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => {
comment.comment_replies?.map(comment => { comment.comment_replies?.map(comment => {
return ( return (
<Fragment key={comment.comment_id}> <Fragment key={comment.comment_id}>
<CommentBox comment={comment} /> <CommentBox comment={comment} onTimestampClick={onTimestampClick} />
</Fragment> </Fragment>
); );
})} })}

View File

@@ -47,14 +47,19 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
{!download.channel_indexed && <span>{download.channel_name}</span>} {!download.channel_indexed && <span>{download.channel_name}</span>}
<a href={`https://www.youtube.com/watch?v=${download.youtube_id}`} target="_blank"> <a
href={`https://www.youtube.com/watch?v=${download.youtube_id}`}
target="_blank"
rel="noopener noreferrer"
>
<h3>{download.title}</h3> <h3>{download.title}</h3>
</a> </a>
</div> </div>
<p> <p>
Published: {formatDate(download.published)} | Duration: {download.duration} |{' '} {download.published && <span>Published: {formatDate(download.published)} | </span>}
{download.youtube_id} <span>Duration: {download.duration} | </span>
<span>{download.youtube_id}</span>
</p> </p>
{download.message && <p className="danger-zone">{download.message}</p>} {download.message && <p className="danger-zone">{download.message}</p>}

View File

@@ -5,6 +5,8 @@ import iconSubstract from '/img/icon-substract.svg';
import iconGridView from '/img/icon-gridview.svg'; import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg'; import iconListView from '/img/icon-listview.svg';
import iconTableView from '/img/icon-tableview.svg'; import iconTableView from '/img/icon-tableview.svg';
import iconFilter from '/img/icon-filter.svg';
import iconMultiSelect from '/img/icon-multi-select.svg';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
import { ViewStyleNamesType, ViewStylesEnum } from '../configuration/constants/ViewStyle'; import { ViewStyleNamesType, ViewStylesEnum } from '../configuration/constants/ViewStyle';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig'; import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
@@ -13,20 +15,43 @@ import {
SortByType, SortByType,
SortOrderEnum, SortOrderEnum,
SortOrderType, SortOrderType,
VideoTypes,
} from '../api/loader/loadVideoListByPage'; } from '../api/loader/loadVideoListByPage';
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
import Button from './Button';
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
import { HideWatchedType } from '../configuration/constants/HideWatched';
type FilterbarProps = { type FilterbarProps = {
hideToggleText: string;
viewStyle: ViewStyleNamesType; viewStyle: ViewStyleNamesType;
hideWatched: HideWatchedType;
showSort?: boolean; showSort?: boolean;
showTypeFilter?: boolean;
}; };
const Filterbar = ({ hideToggleText, viewStyle, showSort = true }: FilterbarProps) => { const Filterbar = ({
viewStyle,
hideWatched,
showSort = true,
showTypeFilter = false,
}: FilterbarProps) => {
const { userConfig, setUserConfig } = useUserConfigStore(); const { userConfig, setUserConfig } = useUserConfigStore();
const {
selectedVideoIds,
clearSelected,
showSelection,
setShowSelection,
selectedAction,
setSelectedAction,
} = useVideoSelectionStore();
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const { filterHeight, setFilterHeight, showFilterItems, setShowFilterItems } =
useFilterBarTempConf();
const currentViewStyle = userConfig[viewStyle]; const currentViewStyle = userConfig[viewStyle];
const currentHideWatched = userConfig[hideWatched];
const isGridView = currentViewStyle === ViewStylesEnum.Grid; const isGridView = currentViewStyle === ViewStylesEnum.Grid;
useEffect(() => { useEffect(() => {
@@ -50,120 +75,203 @@ const Filterbar = ({ hideToggleText, viewStyle, showSort = true }: FilterbarProp
} }
}; };
return ( const redownloadSelected = async (ids: string[]) => {
<div className="view-controls three"> updateDownloadQueue({
<div className="toggle"> youtubeIdStrings: ids.join(' '),
<span>{hideToggleText}</span> autostart: true,
<div className="toggleBox"> force: true,
<input });
id="hide_watched" };
type="checkbox"
checked={userConfig.hide_watched}
onChange={() => {
handleUserConfigUpdate({ hide_watched: !userConfig.hide_watched });
}}
/>
{userConfig.hide_watched ? ( const actionList = [
<label htmlFor="" className="onbtn"> {
On label: 'Redownload',
</label> handler: redownloadSelected,
) : ( },
<label htmlFor="" className="ofbtn"> ];
Off
</label> const handleActionSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value === '') {
setSelectedAction(null);
} else {
setSelectedAction(actionList[Number(value)].handler);
}
};
const handleSelectedActionRun = async () => {
if (selectedAction) {
selectedAction(selectedVideoIds);
setSelectedAction(null);
clearSelected();
setShowSelection(false);
}
};
return (
<>
<div className="view-controls">
<div className="view-icons">
<img
alt="icon multi select"
src={iconMultiSelect}
onClick={() => setShowSelection(!showSelection)}
title={showSelection ? 'Hide multi select boxes' : 'Show multi select boxes'}
/>
{showFilterItems && (
<div>
<span>Filter:</span>
<select
value={currentHideWatched === null ? '' : currentHideWatched.toString()}
onChange={event => {
handleUserConfigUpdate({
[hideWatched]: event.target.value === '' ? null : event.target.value === 'true',
});
}}
>
<option value="">All watched state</option>
<option value="true">Watched only</option>
<option value="false">Unwatched only</option>
</select>
{showTypeFilter && (
<select
value={userConfig.vid_type_filter === null ? '' : userConfig.vid_type_filter}
onChange={event => {
handleUserConfigUpdate({
vid_type_filter:
event.target.value === '' ? null : (event.target.value as VideoTypes),
});
}}
>
<option value="">All Types</option>
<option value="videos">Videos</option>
<option value="streams">Streams</option>
<option value="shorts">Shorts</option>
</select>
)}
<input
placeholder="height in px"
value={filterHeight}
onChange={e => setFilterHeight(e.target.value)}
/>
</div>
)} )}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
{showHidden && (
<div className="sort">
<span>Sort:</span>
<select
name="sort_by"
id="sort"
value={userConfig.sort_by}
onChange={event => {
handleUserConfigUpdate({ sort_by: event.target.value as SortByType });
}}
>
{Object.entries(SortByEnum).map(([key, value], idx) => {
return (
<option key={idx} value={value}>
{key}
</option>
);
})}
</select>
<select
name="sort_order"
id="sort-order"
value={userConfig.sort_order}
onChange={event => {
handleUserConfigUpdate({ sort_order: event.target.value as SortOrderType });
}}
>
{Object.entries(SortOrderEnum).map(([key, value], idx) => {
return (
<option key={idx} value={value}>
{key}
</option>
);
})}
</select>
</div>
)}
{showSort && (
<img
src={iconSort}
alt="sort-icon"
onClick={() => {
setShowHidden(!showHidden);
}}
id="animate-icon"
/>
)}
{userConfig.grid_items !== undefined && isGridView && (
<div className="grid-count">
{userConfig.grid_items < 7 && (
<img
src={iconAdd}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 });
}}
alt="grid plus row"
/>
)}
{userConfig.grid_items > 3 && (
<img
src={iconSubstract}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 });
}}
alt="grid minus row"
/>
)}
</div>
)}
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
}}
alt="list view"
/>
<img
src={iconTableView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
}}
alt="table view"
/>
</div> </div>
</div> </div>
{showSelection && (
{showHidden && ( <div className="info-box-item">
<div className="sort"> <p>
<div id="form"> Selected Videos: {selectedVideoIds.length} -{' '}
<span>Sort by:</span> <Button onClick={clearSelected}>Clear</Button>
<select </p>
name="sort_by" <p>Apply action:</p>
id="sort" <select onChange={handleActionSelectChange}>
value={userConfig.sort_by} <option value="">---</option>
onChange={event => { {actionList.map((actionItem, idx) => (
handleUserConfigUpdate({ sort_by: event.target.value as SortByType }); <option key={idx} value={idx}>
}} {actionItem.label}
> </option>
{Object.entries(SortByEnum).map(([key, value]) => { ))}
return <option value={value}>{key}</option>; </select>
})} {selectedAction !== null && <Button onClick={handleSelectedActionRun}>Apply</Button>}
</select>
<select
name="sort_order"
id="sort-order"
value={userConfig.sort_order}
onChange={event => {
handleUserConfigUpdate({ sort_order: event.target.value as SortOrderType });
}}
>
{Object.entries(SortOrderEnum).map(([key, value]) => {
return <option value={value}>{key}</option>;
})}
</select>
</div>
</div> </div>
)} )}
<div className="view-icons"> </>
{showSort && (
<img
src={iconSort}
alt="sort-icon"
onClick={() => {
setShowHidden(!showHidden);
}}
id="animate-icon"
/>
)}
{userConfig.grid_items !== undefined && isGridView && (
<div className="grid-count">
{userConfig.grid_items < 7 && (
<img
src={iconAdd}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 });
}}
alt="grid plus row"
/>
)}
{userConfig.grid_items > 3 && (
<img
src={iconSubstract}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 });
}}
alt="grid minus row"
/>
)}
</div>
)}
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
}}
alt="list view"
/>
<img
src={iconTableView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
}}
alt="table view"
/>
</div>
</div>
); );
}; };

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import LoadingIndicator from './LoadingIndicator';
type InputTextProps = { type InputTextProps = {
type: 'text' | 'number'; type: 'text' | 'number';
@@ -51,13 +52,7 @@ const InputConfig = ({ type, name, value, setValue, oldValue, updateCallback }:
</> </>
)} )}
{oldValue !== null && <button onClick={() => handleUpdate(name, null)}>reset</button>} {oldValue !== null && <button onClick={() => handleUpdate(name, null)}>reset</button>}
{loading && ( {loading && <LoadingIndicator />}
<>
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
<div />
</div>
</>
)}
{success && <span></span>} {success && <span></span>}
</div> </div>
</div> </div>

View File

@@ -3,17 +3,61 @@ import DOMPurify from 'dompurify';
type LinkifyProps = { type LinkifyProps = {
children: string; children: string;
ignoreLineBreak?: boolean; ignoreLineBreak?: boolean;
onTimestampClick?: (seconds: number) => void;
}; };
// source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/ // source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/
const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => { const Linkify = ({ children, ignoreLineBreak = false, onTimestampClick }: LinkifyProps) => {
const isUrl = (word: string) => { const isUrl = (word: string) => {
const urlPattern = /(https?:\/\/[^\s]+)/g; const urlPattern = /(https?:\/\/[^\s]+)/g;
return word.match(urlPattern); return word.match(urlPattern);
}; };
const isTimestamp = (word: string) => {
const timestampPattern = /^(\d{1,}:)?(\d{1,2}):(\d{1,2})$/;
return word.match(timestampPattern);
};
const parseTimestamp = (timestamp: string): number => {
const parts = timestamp.split(':').map(part => parseInt(part, 10));
if (parts.length === 2) {
// MM:SS format
const [minutes, seconds] = parts;
return minutes * 60 + seconds;
}
if (parts.length === 3) {
// HH:MM:SS format
const [hours, minutes, seconds] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
return 0;
};
const addMarkup = (word: string) => { const addMarkup = (word: string) => {
return isUrl(word) ? `<a href="${word}">${word}</a>` : word; if (isUrl(word)) {
return `<a href="${word}" rel="noopener noreferrer" target="_blank">${word}</a>`;
}
if (isTimestamp(word) && onTimestampClick) {
const seconds = parseTimestamp(word);
return `<span class="timestamp-link" data-timestamp="${seconds}">${word}</span>`;
}
return word;
};
const handleClick = (event: React.MouseEvent<HTMLSpanElement>) => {
const target = event.target as HTMLElement;
if (target.classList.contains('timestamp-link') && onTimestampClick) {
const timestamp = target.getAttribute('data-timestamp');
if (timestamp) {
const seconds = parseInt(timestamp, 10);
onTimestampClick(seconds);
}
}
}; };
let workingText = children; let workingText = children;
@@ -26,9 +70,11 @@ const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => {
const formatedWords = words.map(w => addMarkup(w)); const formatedWords = words.map(w => addMarkup(w));
const html = DOMPurify.sanitize(formatedWords.join(' ')); const html = DOMPurify.sanitize(formatedWords.join(' '), {
ADD_ATTR: ['target'],
});
return <span dangerouslySetInnerHTML={{ __html: html }} />; return <span dangerouslySetInnerHTML={{ __html: html }} onClick={handleClick} />;
}; };
export default Linkify; export default Linkify;

View File

@@ -0,0 +1,9 @@
const LoadingIndicator = () => {
return (
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
<div />
</div>
);
};
export default LoadingIndicator;

View File

@@ -0,0 +1,219 @@
import { useEffect, useState } from 'react';
import APIClient, { ApiError } from '../functions/APIClient';
import LoadingIndicator from './LoadingIndicator';
type ApiTokenResponse = {
token: string;
};
type ProfileUserType = {
id: number;
username: string;
};
type SponsorTierType = {
tier_id: number;
name: string;
description: string;
max_subs: number;
};
type ProfileResponseType = {
id: number;
user: ProfileUserType;
sponsor_tier: SponsorTierType;
subscription_count: number;
subscription_is_max: boolean;
};
export default function MembershipAppsettings({ show_help_text }: { show_help_text: boolean }) {
const [inputType, setInputType] = useState('password');
const [membershipApiToken, setMembershipApiToken] = useState<string | null>(null);
const [newToken, setNewToken] = useState<string | null>(null);
const [profileResponse, setProfileResponse] = useState<ProfileResponseType | null>(null);
const [profileResponseError, setProfileResponseError] = useState('');
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
const [isLoadingSync, setIsLoadingSync] = useState(false);
const [subSyncMessage, setSubSyncMessage] = useState('');
const fetchMembershipToken = async () => {
const apiTokenResponse = await APIClient<ApiTokenResponse>(
'/api/appsettings/membership/token/',
);
setMembershipApiToken(apiTokenResponse.data?.token || null);
};
const deleteMembershipToken = async () => {
await APIClient('/api/appsettings/membership/token/', { method: 'DELETE' });
setMembershipApiToken(null);
setProfileResponseError('');
setProfileResponse(null);
};
const updateToken = async () => {
const { data } = await APIClient<ApiTokenResponse>('/api/appsettings/membership/token/', {
method: 'POST',
body: { token: newToken },
});
if (data) {
setNewToken(null);
setMembershipApiToken(data.token);
setInputType('password');
}
};
useEffect(() => {
fetchMembershipToken();
}, []);
const fetchProfile = async () => {
setProfileResponse(null);
setProfileResponseError('');
setSubSyncMessage('');
try {
setIsLoadingProfile(true);
const { data } = await APIClient<ProfileResponseType>('/api/appsettings/membership/profile/');
if (data) setProfileResponse(data);
} catch (error) {
const apiError = error as ApiError;
if (apiError.status && apiError.message) {
setProfileResponseError(apiError.message);
}
} finally {
setIsLoadingProfile(false);
}
};
const fetchSyncSubscriptions = async () => {
setProfileResponseError('');
setSubSyncMessage('');
try {
setIsLoadingSync(true);
await APIClient('/api/appsettings/membership/sync/', { method: 'POST' });
setSubSyncMessage('Task created');
} catch (error) {
const apiError = error as ApiError;
if (apiError.status && apiError.message) {
setProfileResponseError(apiError.message);
}
} finally {
setIsLoadingSync(false);
}
};
const toggleShowKey = () => {
if (inputType === 'password') {
setInputType('text');
} else {
setInputType('password');
}
};
const handleInputChange = (value: string) => {
setNewToken(value);
};
return (
<>
<h2>Membership</h2>
{show_help_text && (
<div className="help-text">
<p>
Unlock additional perks by sponsoring this project. More details on{' '}
<a href="https://members.tubearchivist.com/" target="_blank" rel="noopener noreferrer">
members.tubearchivist.com
</a>
.
</p>
<ul>
<li>
Enter the API token from{' '}
<a href="https://members.tubearchivist.com/profile">
members.tubearchivist.com/profile
</a>
.
</li>
<li>Click on validate to verify everything is working.</li>
<li>
If you are subscribed to less channels than your sponsor tier allows, you can directly
sync all your subscriptions here.
</li>
<ul>
<li>Repeat the sync after changing subscriptions here.</li>
<li>
That will unsubscribe from channels on the membership platform if you are no longer
subscribed here.
</li>
</ul>
</ul>
</div>
)}
<div className="settings-box-wrapper">
<div>
<p>Membership API key</p>
</div>
<div>
<input
type={inputType}
value={newToken || membershipApiToken || ''}
onChange={e => handleInputChange(e.target.value)}
/>
<div className="button-box">
{(membershipApiToken || newToken) && (
<button onClick={toggleShowKey}>{inputType === 'password' ? 'Show' : 'Hide'}</button>
)}
{newToken && (
<>
<button onClick={updateToken}>Save</button>
<button onClick={() => setNewToken(null)}>Cancel</button>
</>
)}
{membershipApiToken && (
<button className="danger-button" onClick={deleteMembershipToken}>
Delete
</button>
)}
</div>
</div>
{membershipApiToken && (
<>
<div>
<p>Your Profile</p>
</div>
<div>
<div className="button-box">
{isLoadingProfile ? (
<LoadingIndicator />
) : (
<button onClick={fetchProfile}>Validate</button>
)}
{isLoadingSync ? (
<LoadingIndicator />
) : (
<button onClick={fetchSyncSubscriptions}>Sync Subscriptions</button>
)}
</div>
{profileResponseError && <p className="danger-zone">Error: {profileResponseError}</p>}
{profileResponse && (
<>
<p>
Username: {profileResponse.user.username}
<br />
Sponsortier: {profileResponse.sponsor_tier.name} -{' '}
{profileResponse.sponsor_tier.description}
<br />
Subscriptions: {profileResponse.subscription_count}/
{profileResponse.sponsor_tier.max_subs}
</p>
</>
)}
{subSyncMessage && <p>Sync: {subSyncMessage}</p>}
</div>
</>
)}
</div>
</>
);
}

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