Compare commits

...

106 Commits

Author SHA1 Message Date
Simon
22b53e6820 remove unstable tag 2025-06-08 12:16:48 +07:00
James Kerrane
2c2129179a Remove obsolete "version" attribute (#988)
The attribute `version` is obsolete, and docker compose recommends removing it to avoid potential confusion, so this removes the attribute.
2025-06-08 12:14:28 +07:00
Baku
ffe9444295 Update CONTRIBUTING.md (#987)
* Update CONTRIBUTING.md

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

* Update CONTRIBUTING.md

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

* Add option A

* Refac remove option A

* Refac viewStyleType and viewStyleEnum

* Add viewStyle Table

* Refac remove unused code

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

Fix typo ("infos" > "info")

* fix pre-commit

---------

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

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

* Bugfix #934: Download a video from the queue

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

* Bugfix #934: Download a video from the queue

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

* Bugfix #934: Download a video from the queue

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

* Bugfix #934: Download a video from the queue

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

* Bugfix #934: Download a video from the queue

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

* Improved code

* Improved code

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

* Fixed file because of black pre-commit

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

* Fixed file because of black pre-commit

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

---------

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

* fix for Ignored button action

* use ignored parameter as bool

---------

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

* lint
2025-04-29 09:22:19 +07:00
Simon
c00368d1f2 fix playlist video grid type, #916 2025-04-29 08:57:44 +07:00
MerlinScheurer
e28a6be3c5 update frontend dependencies 2025-04-22 18:35:10 +02:00
MerlinScheurer
2b71dbca9a Update frontend dependencies 2025-04-05 13:36:26 +02:00
MerlinScheurer
b7a29897dd Fix formatting of backend\common\src\helper.py 2025-04-05 13:28:24 +02:00
MerlinScheurer
68a69185fe Add custom.css #884 2025-04-05 13:16:30 +02:00
Simon
253571b5ac Player URL fixes, #build
Changed:
- Fix player url query persistence
- Fix various search params handling
- Bumped yt-dlp
2025-04-04 06:03:01 +02:00
Simon
3cd6bad9d3 fix dependabot warnings 2025-04-04 06:02:43 +02:00
Simon
5ca295ccff add unstable TA_VERSION tag 2025-04-04 05:59:01 +02:00
Simon
8fd217e6f0 bump requirements 2025-04-04 05:58:37 +02:00
MerlinScheurer
85be8f1114 Fix block pagination hotkeys when embeddableVideoPlayer is active 2025-03-29 11:07:47 +01:00
MerlinScheurer
e254a848e0 Revert pagination preventDefault | it was eating all key events 2025-03-29 10:58:39 +01:00
MerlinScheurer
947ec803b3 Fix Download page not refetching on page change 2025-03-29 10:56:26 +01:00
MerlinScheurer
0d1bdb8904 Fix pagination not preving page navigation on arrow key navigation 2025-03-29 10:55:39 +01:00
Merlin
a2cb2d283c Fix URL params in embed player #907 2025-03-29 10:34:04 +01:00
MerlinScheurer
11afb29384 Refac change searchParam handling for subtitle and download page 2025-03-29 10:27:54 +01:00
MerlinScheurer
cbfb66b5c8 Merge branch 'master' of github.com:tubearchivist/tubearchivist into testing 2025-03-29 09:30:46 +01:00
MerlinScheurer
7b26a01063 Refac reduce notification interval from 500 to 1000 #912 2025-03-28 11:58:27 +01:00
MerlinScheurer
96b55bf9d5 Fix download page requesting pending downloads twice 2025-03-28 11:50:53 +01:00
MerlinScheurer
50a3442ca6 Add VideoThumbnail to video playlist view 2025-03-28 11:31:32 +01:00
MerlinScheurer
c407f27ecf Add VideoThumbnail to subtitleList 2025-03-28 11:29:15 +01:00
MerlinScheurer
148f9e801b Refac extract VideoThumbnail into a component 2025-03-28 11:25:54 +01:00
MerlinScheurer
5fe0afaeb7 Refac add 'waiting for backend' text to make it more clear 2025-03-28 11:12:39 +01:00
MerlinScheurer
c73a350095 Refac take searchParams from router in Base.tsx 2025-03-28 01:46:56 +01:00
MerlinScheurer
2be190aa65 Refac preemptive image fallback when url is undefined. Ticket: #911 2025-03-28 01:34:34 +01:00
MerlinScheurer
0aa5ef5b31 Add trim to subscribe textarea fields #908 2025-03-28 01:26:34 +01:00
Mason James
40bae8d73d Fix URL params in embed player
When opening/closing embedded videos, maintain existing URL parameters including pagination state. Previously, the video player would reset page to 0 and lose other query parameters.
2025-03-27 19:36:32 -04:00
Simon
8f34626a27 bump TA_VERSION 2025-03-27 21:40:00 +01:00
Alex
8978e6510a Added delete_shorts to the User Scripts section (#903) 2025-03-28 03:39:33 +07:00
Simon
04ea6254b7 fix health API endpoint url 2025-03-27 21:36:59 +01:00
Simon
f18d812eb8 delay playlist extraction 2025-03-27 21:13:13 +01:00
Simon
d69c579839 Update yt-dlp, again, #build
Changed:
- bumped yt-dlp for more nsig fixes
- bumped node dependencies
2025-03-26 07:46:07 +01:00
Simon
9fe11c3d22 bump dependencies 2025-03-26 07:45:28 +01:00
Simon
f8846836e7 Bump yt-dlp, #build
Changed:
- Bump yt-dlp fixing nsig extraction
2025-03-25 09:12:26 +01:00
Simon
3ab218d744 bump requirements 2025-03-25 09:11:12 +01:00
Simon
c6329571ae add unstable TA_VERSION 2025-03-25 09:10:52 +01:00
Simon
4ae59848b4 Various fixes, #build
Changed:
- Added better debug statements to login page
- Fixed appsettings admin read permissions
- Fixed notification types
- Fixed DISABLE_STATIC_AUTH
2025-03-24 22:01:00 +01:00
Simon
17aa693fdf fix backend tasks messages notification types 2025-03-24 21:59:35 +01:00
Simon
164f3d2a22 bump requirements 2025-03-24 21:53:37 +01:00
MerlinScheurer
512f661823 Add sourcemaps for easier debugging after build 2025-03-22 10:36:35 +01:00
MerlinScheurer
7fdb93f183 Fix formatting in frontend 2025-03-20 20:30:32 +01:00
MerlinScheurer
edcede5de6 Refac APIClient to return data, error and status as object 2025-03-20 20:30:08 +01:00
Simon
52083e6fb7 fix redis connection failure error message 2025-03-19 07:53:30 +01:00
Simon
a4824497ef handle 404 pages, #890 2025-03-15 16:58:28 +01:00
MerlinScheurer
c5f549967b Refac use file_size_unit from user config in frontend #886 2025-03-15 15:15:22 +01:00
Simon
87d4f16456 add page query param to playlist list fetching, #894 2025-03-15 14:24:17 +01:00
smantic
f0bac927fe AdminWriteOnly /appsettings/config (#892) 2025-03-15 20:13:48 +07:00
trick77
4b19294899 Call _disable_static_auth() during startup (#897) 2025-03-15 20:03:43 +07:00
Simon
9f351b5907 backend: store file_size_unit in user config, #886 2025-03-15 13:59:49 +01:00
Simon
677f85b993 add delete and ignore, #891 2025-03-15 13:50:26 +01:00
MerlinScheurer
158115d319 Fix playlist being sorted, instead of playlist sort order 2025-03-15 11:06:01 +01:00
MerlinScheurer
7abcfcc579 Add 'having issues' help text to login after 10 seconds 2025-03-14 09:08:40 +01:00
Simon
862a854e64 add health API endpoint, #887 2025-03-11 22:12:32 +07:00
MerlinScheurer
e06e1be433 Add TA_HOST example and compose with port 2025-03-10 20:26:40 +01:00
151 changed files with 2800 additions and 1764 deletions

View File

@@ -5,6 +5,13 @@
# Docker
.docker
# Backend development
backend/static
backend/staticfiles
# Frontend development
frontend/node_modules
# Python
tubearchivist/__pycache__/
tubearchivist/*/__pycache__/

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ __pycache__
# django testing
backend/static
backend/staticfiles
backend/.env
# vscode custom conf

View File

@@ -1,6 +1,6 @@
# Contributing to Tube Archivist
Welcome, and thanks for showing interest in improving Tube Archivist!
Welcome, and thanks for showing interest in improving Tube Archivist!
## Table of Content
- [Beta Testing](#beta-testing)
@@ -16,20 +16,20 @@ Welcome, and thanks for showing interest in improving Tube Archivist!
---
## Beta Testing
Be the first to help test new features and improvements and provide feedback! There are regular `:unstable` builds for easy access. That's for the tinkerers and the breave. Ideally use a testing environment first, before a release be the first to install it on your main system.
Be the first to help test new features/improvements and provide feedback! Regular `:unstable` builds are available for early access. These are for the tinkerers and the brave. Ideally, use a testing environment first, before upgrading your main installation.
There is always something that can get missed during development. Look at the commit messages tagged with `#build`, these are the unstable builds and give a quick overview what has changed.
There is always something that can get missed during development. Look at the commit messages tagged with `#build` - these are the unstable builds and give a quick overview of what has changed.
- Test the features mentioned, play around, try to break it.
- Test the update path by installing the `:latest` release first, the upgrade to `:unstable` to check for any errors.
- Test the update path by installing the `:latest` release first, then upgrade to `:unstable` to check for any errors.
- Test the unstable build on a fresh install.
Then provide feedback, if there is a problem but also if there is no problem. Reach out on [Discord](https://tubearchivist.com/discord) in the `#beta-testing` channel with your findings.
Then provide feedback - even if you don't encounter any issues! You can do this in the `#beta-testing` channel on the [Discord](https://tubearchivist.com/discord) Discord server.
This will help with a smooth update for the regular release. Plus you get to test things out early!
This helps ensure a smooth update for the stable release. Plus you get to test things out early!
## How to open an issue
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub. Make sure you read [Next Steps](#next-steps) above.
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
**Do**:
- Do provide details and context, this matters a lot and makes it easier for people to help.
@@ -65,16 +65,22 @@ IMPORTANT: When receiving help, contribute back to the community by improving th
## How to make a Pull Request
Make sure you read [Next Steps](#next-steps) above.
Thank you for contributing and helping improve this project. Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application.
This is a quick checklist to help streamline the process:
- For **code changes**, make your PR against the [testing branch](https://github.com/tubearchivist/tubearchivist/tree/testing). That's where all active development happens. This simplifies the later merging into *master*, minimizes any conflicts and usually allows for easy and convenient *fast-forward* merging.
- For **documentation changes**, make your PR directly against the *master* branch.
- Show off your progress, even if not yet complete, by creating a [draft](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests) PR first and switch it as *ready* when you are ready.
- Make sure all your code is linted and formatted correctly, see below. The automatic GH action unfortunately needs to be triggered manually by a maintainer for first time contributors, but will trigger automatically for existing contributors.
- Make sure all your code is linted and formatted correctly, see below.
### Documentation Changes
All documentation is intended to represent the state of the [latest](https://github.com/tubearchivist/tubearchivist/releases/latest) release.
- If your PR with code changes also requires changes to documentation *.md files here in this repo, create a separate PR for that, so it can be merged separately at release.
- You can make the PR directly against the *master* branch.
- If your PR requires changes on the [tubearchivist/docs](https://github.com/tubearchivist/docs), make the PR over there.
- Prepare your documentation updates at the same time as the code changes, so people testing your PR can consult the prepared docs if needed.
### Code formatting and linting
@@ -83,7 +89,7 @@ This project uses the excellent [pre-commit](https://github.com/pre-commit/pre-c
**Quick Start**
- Run `pre-commit install` from the root of the repo.
- Next time you commit to your local git repo, the defined hooks will run.
- On first run, this will download and install the needed environments to your local machine, that can take some time. But that will be reused on sunsequent commits.
- On first run, this will download and install the needed environments to your local machine, that can take some time. But that will be reused on sunsequent commits.
That is also running as a Git Hub action.
@@ -93,7 +99,7 @@ That is also running as a Git Hub action.
As you have read the [FAQ](https://docs.tubearchivist.com/faq/) and the [known limitations](https://github.com/tubearchivist/tubearchivist#known-limitations) and have gotten an idea what this project tries to do, there will be some obvious shortcomings that stand out, that have been explicitly excluded from the scope of this project, at least for the time being.
Extending the scope of this project will only be feasible with more [regular contributors](https://github.com/tubearchivist/tubearchivist/graphs/contributors) that are willing to help improve this project in the long run. Contributors that have an overall improvement of the project in mind and not just about implementing this *one* thing.
Extending the scope of this project will only be feasible with more [regular contributors](https://github.com/tubearchivist/tubearchivist/graphs/contributors) that are willing to help improve this project in the long run. Contributors that have an overall improvement of the project in mind and not just about implementing this *one* thing.
Small minor additions, or making a PR for a documented feature request or bug, even if that was and will be your only contribution to this project, are always welcome and is *not* what this is about.
@@ -206,7 +212,7 @@ You may find it nice to run everything inside of a VM for complete environment s
### 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.
**Quick start**
**Quick start**
Generate your access token in Elasitcsearch:
```bash
bin/elasticsearch-service-tokens create elastic/kibana kibana
@@ -227,11 +233,11 @@ services:
If you want to run queries on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it.
**Persist Token**
**Persist Token**
The token will get stored in ES in the `config` folder, and not in the `data` folder. To persist the token between ES container rebuilds, you'll need to persist the config folder as an additional volume:
1. Create the token as described above
2. While the container is running, copy the current config folder out of the container, e.g.:
2. While the container is running, copy the current config folder out of the container, e.g.:
```
docker cp archivist-es:/usr/share/elasticsearch/config/ volume/es_config
```

View File

@@ -70,6 +70,7 @@ RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
COPY ./backend /app
COPY ./docker_assets/run.sh /app
COPY ./docker_assets/backend_start.py /app
COPY ./docker_assets/beat_auto_spawn.sh /app
COPY --from=node-builder ./frontend/dist /app/static

View File

@@ -1,4 +1,4 @@
![Tube Archivist](assets/tube-archivist-front.jpg?raw=true "Tube Archivist Banner")
![Tube Archivist](assets/tube-archivist-front.jpg?raw=true "Tube Archivist Banner")
[*more screenshots and video*](SHOWCASE.MD)
<div align="center">
@@ -23,13 +23,13 @@
------------------------
## Core functionality
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface. This includes:
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface. This includes:
* Subscribe to your favorite YouTube channels
* Download Videos using **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**
* Index and make videos searchable
* Play videos
* Keep track of viewed and unviewed videos
## Resources
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
@@ -38,8 +38,8 @@ Once your YouTube video collection grows, it becomes hard to search and find a s
- [Plex Plugin](https://github.com/tubearchivist/tubearchivist-plex): Add your videos to Plex
## Installing
For minimal system requirements, the Tube Archivist stack needs around 2GB of available memory for a small testing setup and around 4GB of available memory for a mid to large sized installation. Minimal with dual core with 4 threads, better quad core plus.
This project requires docker. Ensure it is installed and running on your system.
For minimal system requirements, the Tube Archivist stack needs around 2GB of available memory for a small testing setup and around 4GB of available memory for a mid to large sized installation. Minimal with dual core with 4 threads, better quad core plus.
This project requires docker. Ensure it is installed and running on your system.
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/) and [Podman](https://docs.tubearchivist.com/installation/podman/).
@@ -49,10 +49,10 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
All environment variables are explained in detail in the docs [here](https://docs.tubearchivist.com/installation/env-vars/).
**TubeArchivist**:
**TubeArchivist**:
| Environment Var | Value | |
| ----------- | ----------- | ----------- |
| TA_HOST | Server IP or hostname | Required |
| TA_HOST | Server IP or hostname `http://tubearchivist.local:8000` | Required |
| TA_USERNAME | Initial username when logging into TA | Required |
| TA_PASSWORD | Initial password when logging into TA | Required |
| ELASTIC_PASSWORD | Password for ElasticSearch | Required |
@@ -72,8 +72,17 @@ All environment variables are explained in detail in the docs [here](https://doc
| 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) |
| DJANGO_DEBUG | Return additional error messages, for debug only | Optional |
| TA_LOGIN_AUTH_MODE | Configure the order of login authentication backends (Default: single) | Optional |
**ElasticSearch**
| TA_LOGIN_AUTH_MODE value | Description |
| ------------------------ | ----------- |
| single | Only use a single backend (default, or LDAP, or Forward auth, selected by TA_LDAP or TA_ENABLE_AUTH_PROXY) |
| local | Use local password database only |
| ldap | Use LDAP backend only |
| forwardauth | Use reverse proxy headers only |
| ldap_local | Use LDAP backend in addition to the local password database |
**ElasticSearch**
| Environment Var | Value | State |
| ----------- | ----------- | ----------- |
| ELASTIC_PASSWORD | Matching password `ELASTIC_PASSWORD` from TubeArchivist | Required |
@@ -81,58 +90,58 @@ All environment variables are explained in detail in the docs [here](https://doc
## 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 are only for your testing environment, there might not be an update path for these testing builds.
You will see the current version number of **Tube Archivist** in the footer of the interface. There is a daily version check task querying tubearchivist.com, notifying you of any new releases in the footer. To 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. To update, you need to update the docker images, the method for which will depend on your platform. For example, if you're using `docker-compose`, run `docker-compose pull` and then restart with `docker-compose up -d`. After updating, check the footer to verify you are running the expected version.
- This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported and you might have to reset your index and configurations to update. Ideally apply new updates at least once per month.
- There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there.
- All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version.
- This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported and you might have to reset your index and configurations to update. Ideally apply new updates at least once per month.
- There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there.
- All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version.
## Getting Started
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example:
```
bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
```
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists.
4. Click on *Start download* and let **Tube Archivist** to it's thing.
4. Click on *Start download* and let **Tube Archivist** to it's thing.
5. Enjoy your archived collection!
### Port Collisions
If you have a collision on port `8000`, best solution is to use dockers *HOST_PORT* and *CONTAINER_PORT* distinction: To for example change the interface to port 9000 use `9000:8000` in your docker-compose file.
### Port Collisions
If you have a collision on port `8000`, best solution is to use dockers *HOST_PORT* and *CONTAINER_PORT* distinction: To for example change the interface to port 9000 use `9000:8000` in your docker-compose file.
For more information on port collisions, check the docs.
## Common Errors
Here is a list of common errors and their solutions.
## Common Errors
Here is a list of common errors and their solutions.
### `vm.max_map_count`
**Elastic Search** in Docker requires the kernel setting of the host machine `vm.max_map_count` to be set to at least 262144.
To temporary set the value run:
To temporary set the value run:
```
sudo sysctl -w vm.max_map_count=262144
```
To apply the change permanently depends on your host operating system:
```
To apply the change permanently depends on your host operating system:
- For example on Ubuntu Server add `vm.max_map_count = 262144` to the file `/etc/sysctl.conf`.
- On Arch based systems create a file `/etc/sysctl.d/max_map_count.conf` with the content `vm.max_map_count = 262144`.
- On any other platform look up in the documentation on how to pass kernel parameters.
- On Arch based systems create a file `/etc/sysctl.d/max_map_count.conf` with the content `vm.max_map_count = 262144`.
- On any other platform look up in the documentation on how to pass kernel parameters.
### Permissions for elasticsearch
If you see a message similar to `Unable to access 'path.repo' (/usr/share/elasticsearch/data/snapshot)` or `failed to obtain node locks, tried [/usr/share/elasticsearch/data]` and `maybe these locations are not writable` when initially starting elasticsearch, that probably means the container is not allowed to write files to the volume.
If you see a message similar to `Unable to access 'path.repo' (/usr/share/elasticsearch/data/snapshot)` or `failed to obtain node locks, tried [/usr/share/elasticsearch/data]` and `maybe these locations are not writable` when initially starting elasticsearch, that probably means the container is not allowed to write files to the volume.
To fix that issue, shutdown the container and on your host machine run:
```
chown 1000:0 -R /path/to/mount/point
```
This will match the permissions with the **UID** and **GID** of elasticsearch process within the container and should fix the issue.
This will match the permissions with the **UID** and **GID** of elasticsearch process within the container and should fix the issue.
### Disk usage
The Elasticsearch index will turn to ***read only*** if the disk usage of the container goes above 95% until the usage drops below 90% again, you will see error messages like `disk usage exceeded flood-stage watermark`.
The Elasticsearch index will turn to ***read only*** if the disk usage of the container goes above 95% until the usage drops below 90% again, you will see error messages like `disk usage exceeded flood-stage watermark`.
Similar to that, TubeArchivist will become all sorts of messed up when running out of disk space. There are some error messages in the logs when that happens, but it's best to make sure to have enough disk space before starting to download.
@@ -142,7 +151,7 @@ If you are seeing errors like `failed to create shim: OCI runtime create failed`
This can happen if you have nested virtualizations, e.g. LXC running Docker in Proxmox.
## Known limitations
- Video files created by Tube Archivist need to be playable in your browser of choice. Not every codec is compatible with every browser and might require some testing with format selection.
- Video files created by Tube Archivist need to be playable in your browser of choice. Not every codec is compatible with every browser and might require some testing with format selection.
- Every limitation of **yt-dlp** will also be present in Tube Archivist. If **yt-dlp** can't download or extract a video for any reason, Tube Archivist won't be able to either.
- There is no flexibility in naming of the media files.
@@ -191,7 +200,7 @@ Implemented:
- [X] Scan your file system to index already downloaded videos [2021-09-14]
## User Scripts
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. Make sure to check the respective repository links for detailed license information.
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. Make sure to check the respective repository links for detailed license information.
This is your time to shine, [read this](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#user-scripts) then open a PR to add your script here.
@@ -200,12 +209,13 @@ This is your time to shine, [read this](https://github.com/tubearchivist/tubearc
- [DarkFighterLuke/ta_base_url_nginx](https://gist.github.com/DarkFighterLuke/4561b6bfbf83720493dc59171c58ac36): Set base URL with Nginx when you can't use subdomains.
- [lamusmaser/ta_migration_helper](https://github.com/lamusmaser/ta_migration_helper): Advanced helper script for migration issues to TubeArchivist v0.4.4 or later.
- [lamusmaser/create_info_json](https://gist.github.com/lamusmaser/837fb58f73ea0cad784a33497932e0dd): Script to generate `.info.json` files using `ffmpeg` collecting information from downloaded videos.
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles.
- [tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State](https://github.com/tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State) Mark videos watched in Plex (through streaming not manually) through Tautulli back to TubeArchivist
- [Dhs92/delete_shorts](https://github.com/Dhs92/delete_shorts): A script to delete ALL YouTube Shorts from TubeArchivist
## Donate
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
Second best way to support the development is to provide for caffeinated beverages:
* [GitHub Sponsor](https://github.com/sponsors/bbilly1) become a sponsor here on GitHub
* [Paypal.me](https://paypal.me/bbilly1) for a one time coffee

View File

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

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -239,7 +239,8 @@
"type": "keyword"
},
"published": {
"type": "date"
"type": "date",
"format": "epoch_second||strict_date_optional_time"
},
"playlist": {
"type": "text",

View File

@@ -201,7 +201,15 @@ class ElasitIndexWrap:
if self.backup_run:
return
config = AppConfig().config
try:
config = AppConfig().config
except ValueError:
# create defaults in ES if config not found
print("AppConfig not found, creating defaults...")
handler = AppConfig.__new__(AppConfig)
handler.sync_defaults()
config = AppConfig.CONFIG_DEFAULTS
if config["application"]["enable_snapshot"]:
# take snapshot if enabled
ElasticSnapshot().take_snapshot_now(wait=True)

View File

@@ -20,7 +20,7 @@ from common.serializers import (
ErrorResponseSerializer,
)
from common.src.ta_redis import RedisArchivist
from common.views_base import AdminOnly, ApiBaseView
from common.views_base import AdminOnly, AdminWriteOnly, ApiBaseView
from django.conf import settings
from download.src.yt_dlp_base import CookieHandler, POTokenHandler
from drf_spectacular.utils import OpenApiResponse, extend_schema
@@ -152,7 +152,7 @@ class AppConfigApiView(ApiBaseView):
POST: update app settings
"""
permission_classes = [AdminOnly]
permission_classes = [AdminWriteOnly]
@staticmethod
@extend_schema(

View File

@@ -90,6 +90,7 @@ class ChannelNavSerializer(serializers.Serializer):
"""serialize channel navigation"""
has_pending = serializers.BooleanField()
has_ignored = serializers.BooleanField()
has_playlists = serializers.BooleanField()
has_videos = serializers.BooleanField()
has_streams = serializers.BooleanField()

View File

@@ -10,10 +10,10 @@ from datetime import datetime
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import rand_sleep
from common.src.index_generic import YouTubeItem
from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist
class YoutubeChannel(YouTubeItem):
@@ -55,12 +55,13 @@ class YoutubeChannel(YouTubeItem):
def process_youtube_meta(self):
"""extract relevant fields"""
self.youtube_meta["thumbnails"].reverse()
channel_name = self.youtube_meta["uploader"] or self.youtube_meta["id"]
self.json_data = {
"channel_active": True,
"channel_description": self.youtube_meta.get("description", ""),
"channel_id": self.youtube_id,
"channel_last_refresh": int(datetime.now().timestamp()),
"channel_name": self.youtube_meta["uploader"],
"channel_name": channel_name,
"channel_subs": self.youtube_meta.get("channel_follower_count", 0),
"channel_subscribed": False,
"channel_tags": self.youtube_meta.get("tags", []),
@@ -156,8 +157,18 @@ class YoutubeChannel(YouTubeItem):
# add ingest pipeline
processors = []
for field, value in self.json_data.items():
line = {"set": {"field": "channel." + field, "value": value}}
if value is None:
line = {
"script": {
"lang": "painless",
"source": f"ctx['{field}'] = null;",
}
}
else:
line = {"set": {"field": "channel." + field, "value": value}}
processors.append(line)
data = {"description": self.youtube_id, "processors": processors}
ingest_path = f"_ingest/pipeline/{self.youtube_id}"
_, _ = ElasticWrap(ingest_path).put(data)
@@ -203,6 +214,8 @@ class YoutubeChannel(YouTubeItem):
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()
@@ -251,6 +264,7 @@ class YoutubeChannel(YouTubeItem):
self._index_single_playlist(playlist)
print("add playlist: " + playlist[1])
rand_sleep(self.config)
def _notify_single_playlist(self, idx, total):
"""send notification"""
@@ -264,6 +278,8 @@ class YoutubeChannel(YouTubeItem):
@staticmethod
def _index_single_playlist(playlist):
"""add single playlist if needed"""
from playlist.src.index import YoutubePlaylist
playlist = YoutubePlaylist(playlist[0])
playlist.update_playlist(skip_on_empty=True)
@@ -287,6 +303,7 @@ class YoutubeChannel(YouTubeItem):
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"]]

View File

@@ -13,6 +13,7 @@ class ChannelNav:
"""build nav items"""
nav = {
"has_pending": self._get_has_pending(),
"has_ignored": self._get_has_ignored(),
"has_playlists": self._get_has_playlists(),
}
nav.update(self._get_vid_types())
@@ -63,6 +64,24 @@ class ChannelNav:
return bool(response["hits"]["hits"])
def _get_has_ignored(self):
"""Check if there are ignored videos in the download queue"""
data = {
"size": 1,
"query": {
"bool": {
"must": [
{"term": {"status": {"value": "ignore"}}},
{"term": {"channel_id": {"value": self.channel_id}}},
]
}
},
"_source": False,
}
response, _ = ElasticWrap("ta_download/_search").get(data=data)
return bool(response["hits"]["hits"])
def _get_has_playlists(self):
"""check if channel has playlists"""
path = "ta_playlist/_search"

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import os
import random
import string
import subprocess
from datetime import datetime
from datetime import datetime, timezone
from time import sleep
from typing import Any
from urllib.parse import urlparse
@@ -106,13 +106,14 @@ def requests_headers() -> dict[str, str]:
def date_parser(timestamp: int | str) -> str:
"""return formatted date string"""
if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp)
date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
date_obj = date_obj.replace(tzinfo=timezone.utc)
else:
raise TypeError(f"invalid timestamp: {timestamp}")
return date_obj.date().isoformat()
return date_obj.isoformat()
def time_parser(timestamp: str) -> float:
@@ -231,7 +232,13 @@ def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
def get_stylesheets() -> list:
"""Get all valid stylesheets from /static/css"""
stylesheets = ["dark.css", "light.css", "matrix.css", "midnight.css"]
stylesheets = [
"dark.css",
"light.css",
"matrix.css",
"midnight.css",
"custom.css",
]
return stylesheets

View File

@@ -43,14 +43,9 @@ class WatchState:
def change_vid_state(self):
"""change watched state of video"""
path = f"ta_video/_update/{self.youtube_id}"
data = {
"doc": {
"player": {
"watched": self.is_watched,
"watched_date": self.stamp,
}
}
}
data = {"doc": {"player": {"watched": self.is_watched}}}
if self.is_watched:
data["doc"]["player"]["watched_date"] = self.stamp
response, status_code = ElasticWrap(path).post(data=data)
key = f"{self.user_id}:progress:{self.youtube_id}"
RedisArchivist().del_message(key)

View File

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

View File

@@ -25,4 +25,9 @@ urlpatterns = [
views.NotificationView.as_view(),
name="api-notification",
),
path(
"health/",
views.HealthCheck.as_view(),
name="api-health",
),
]

View File

@@ -20,6 +20,7 @@ from common.src.watched import WatchState
from common.views_base import AdminOnly, ApiBaseView
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.response import Response
from rest_framework.views import APIView
from task.tasks import check_reindex
@@ -199,3 +200,11 @@ class NotificationView(ApiBaseView):
response_serializer = NotificationSerializer(notifications, many=True)
return Response(response_serializer.data)
class HealthCheck(APIView):
"""health check view, no auth needed"""
def get(self, request):
"""health check, no auth needed"""
return Response("OK", status=200)

View File

@@ -58,7 +58,12 @@ class Command(BaseCommand):
message = " 🗙 Redis connection failed"
self.stdout.write(self.style.ERROR(f"{message}"))
RedisArchivist().exec("PING")
try:
redis_conn.execute_command("PING")
except Exception as err: # pylint: disable=broad-except
message = f" 🗙 {type(err).__name__}: {err}"
self.stdout.write(self.style.ERROR(f"{message}"))
sleep(60)
raise CommandError(message)
@@ -89,8 +94,17 @@ class Command(BaseCommand):
sleep(5)
continue
if status_code and status_code == 401:
sleep(5)
continue
if status_code and status_code == 200:
path = "_cluster/health?wait_for_status=yellow&timeout=60s"
path = (
"_cluster/health?"
"wait_for_status=yellow&"
"timeout=60s&"
"wait_for_active_shards=1"
)
_, _ = ElasticWrap(path).get(timeout=60)
self.stdout.write(
self.style.SUCCESS(" ✓ ES connection established")

View File

@@ -86,6 +86,7 @@ class Command(BaseCommand):
self._elastic_user_overwrite()
self._ta_port_overwrite()
self._ta_backend_port_overwrite()
self._disable_static_auth()
self._create_superuser()
def _expected_vars(self):

View File

@@ -55,6 +55,7 @@ class Command(BaseCommand):
self._init_app_config()
self._mig_channel_tags()
self._mig_video_channel_tags()
self._mig_fix_download_channel_indexed()
def _make_folders(self):
"""make expected cache folders"""
@@ -262,7 +263,7 @@ class Command(BaseCommand):
def _init_app_config(self) -> None:
"""init default app config to ES"""
self.stdout.write("[10] Check AppConfig")
_, status_code = ElasticWrap("ta_config/_doc/appsettings").get()
response, status_code = ElasticWrap("ta_config/_doc/appsettings").get()
if status_code in [200, 201]:
self.stdout.write(
self.style.SUCCESS(" skip completed appsettings init")
@@ -275,6 +276,13 @@ class Command(BaseCommand):
return
if status_code != 404:
message = " 🗙 ta_config index lookup failed"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
handler = AppConfig.__new__(AppConfig)
_, status_code = handler.sync_defaults()
self.stdout.write(
@@ -347,3 +355,37 @@ class Command(BaseCommand):
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _mig_fix_download_channel_indexed(self) -> None:
"""migrate from v0.5.2 to 0.5.3, fix missing channel_indexed"""
self.stdout.write("[MIGRATION] fix incorrect video channel tags types")
path = "ta_download/_update_by_query"
data = {
"query": {
"bool": {
"must_not": [{"exists": {"field": "channel_indexed"}}]
}
},
"script": {
"source": "ctx._source.channel_indexed = false",
"lang": "painless",
},
}
response, status_code = ElasticWrap(path).post(data)
if status_code in [200, 201]:
updated = response.get("updated")
if updated:
self.stdout.write(
self.style.SUCCESS(f" ✓ fixed {updated} queued videos")
)
else:
self.stdout.write(
self.style.SUCCESS(" no queued videos to fix")
)
return
message = " 🗙 failed to fix video channel tags"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)

View File

@@ -82,7 +82,6 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"common.src.health.HealthCheckMiddleware",
]
ROOT_URLCONF = "config.urls"
@@ -196,7 +195,6 @@ if bool(environ.get("TA_LDAP")):
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
}
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
@@ -240,10 +238,33 @@ if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
# Configure Authentication Backend Combinations
_login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold()
if _login_auth_mode == "local":
AUTHENTICATION_BACKENDS: tuple = (
"django.contrib.auth.backends.ModelBackend",
)
elif _login_auth_mode == "ldap":
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
elif _login_auth_mode == "forwardauth":
AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.RemoteUserBackend",
)
elif _login_auth_mode == "ldap_local":
AUTHENTICATION_BACKENDS = (
"django_auth_ldap.backend.LDAPBackend",
"django.contrib.auth.backends.ModelBackend",
)
else:
# If none of these cases match, AUTHENTICATION_BACKENDS is unset, which
# means the ModelBackend should be used by default
if bool(environ.get("TA_LDAP")):
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.RemoteUserBackend",
)
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
@@ -295,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.5.0"
TA_VERSION = "v0.5.3"
# API
REST_FRAMEWORK = {

View File

@@ -56,7 +56,7 @@ class AddDownloadItemSerializer(serializers.Serializer):
"""serialize single item to add"""
youtube_id = serializers.CharField()
status = serializers.ChoiceField(choices=["pending"])
status = serializers.ChoiceField(choices=["pending", "ignore-force"])
class AddToDownloadListSerializer(serializers.Serializer):

View File

@@ -167,14 +167,14 @@ class PendingList(PendingIndex):
self.to_skip = False
self.missing_videos = False
def parse_url_list(self):
def parse_url_list(self, auto_start=False):
"""extract youtube ids from list"""
self.missing_videos = []
self.get_download()
self.get_indexed()
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids):
self._process_entry(entry)
self._process_entry(entry, auto_start=auto_start)
if not self.task:
continue
@@ -183,11 +183,11 @@ class PendingList(PendingIndex):
progress=(idx + 1) / total,
)
def _process_entry(self, entry):
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)
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":
@@ -204,8 +204,14 @@ class PendingList(PendingIndex):
return VideoTypeEnum(vid_type_str)
def _add_video(self, url, vid_type):
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:
@@ -330,9 +336,6 @@ class PendingList(PendingIndex):
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
"""parse response"""
vid_id = vid.get("id")
published = datetime.strptime(vid["upload_date"], "%Y%m%d").strftime(
"%Y-%m-%d"
)
# build dict
youtube_details = {
@@ -342,13 +345,23 @@ class PendingList(PendingIndex):
"title": vid["title"],
"channel_id": vid["channel_id"],
"duration": get_duration_str(vid["duration"]),
"published": published,
"published": self._build_published(vid),
"timestamp": int(datetime.now().timestamp()),
# Pulling enum value out so it is serializable
"vid_type": vid_type.value,
"channel_indexed": vid["channel_id"] in self.all_channels,
}
if self.all_channels:
youtube_details.update(
{"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

@@ -20,7 +20,6 @@ class YtWrap:
OBS_BASE = {
"default_search": "ytsearch",
"quiet": True,
"check_formats": "selected",
"socket_timeout": 10,
"extractor_retries": 3,
"retries": 10,
@@ -66,6 +65,7 @@ class YtWrap:
def download(self, url):
"""make download request"""
self.obs.update({"check_formats": "selected"})
with yt_dlp.YoutubeDL(self.obs) as ydl:
try:
ydl.download([url])

View File

@@ -311,8 +311,19 @@ class DownloadPostProcess(DownloaderBase):
print(f"auto delete older than {autodelete_days} days")
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
channel_overwrite = "channel.channel_overwrites.autodelete_days"
data = {
"query": {"range": {"player.watched_date": {"lte": now_lte}}},
"query": {
"bool": {
"must": [
{"range": {"player.watched_date": {"lte": now_lte}}},
{"term": {"player.watched": True}},
],
"must_not": [
{"exists": {"field": channel_overwrite}},
],
}
},
"sort": [{"player.watched_date": {"order": "asc"}}],
}
self._auto_delete_watched(data)
@@ -327,6 +338,7 @@ class DownloadPostProcess(DownloaderBase):
must_list = [
{"range": {"player.watched_date": {"lte": now_lte}}},
{"term": {"channel.channel_id": {"value": channel_id}}},
{"term": {"player.watched": True}},
]
data = {
"query": {"bool": {"must": must_list}},

View File

@@ -7,7 +7,6 @@ functionality:
import json
from datetime import datetime
from channel.src import index as channel
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.index_generic import YouTubeItem
@@ -74,8 +73,10 @@ class YoutubePlaylist(YouTubeItem):
def _ensure_channel(self):
"""make sure channel is indexed"""
from channel.src.index import YoutubeChannel
channel_id = self.json_data["playlist_channel_id"]
channel_handler = channel.YoutubeChannel(channel_id)
channel_handler = YoutubeChannel(channel_id)
channel_handler.build_json(upload=True)
def get_local_vids(self) -> list[str]:

View File

@@ -1,10 +1,10 @@
-r requirements.txt
ipython==9.0.1
pre-commit==4.1.0
ipython==9.3.0
pre-commit==4.2.0
pylint-django==2.6.1
pylint==3.3.4
pytest-django==4.10.0
pytest==8.3.5
python-dotenv==1.0.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.20250306
types-requests==2.32.0.20250602

View File

@@ -1,15 +1,15 @@
apprise==1.9.2
celery==5.4.0
django-auth-ldap==5.1.0
django-celery-beat==2.7.0
apprise==1.9.3
celery==5.5.3
django-auth-ldap==5.2.0
django-celery-beat==2.8.1
django-cors-headers==4.7.0
Django==5.1.7
djangorestframework==3.15.2
Django==5.2.2
djangorestframework==3.16.0
drf-spectacular==0.28.0
Pillow==11.1.0
redis==5.2.1
Pillow==11.2.1
redis==6.2.0
requests==2.32.3
ryd-client==0.0.6
uvicorn==0.34.0
uvicorn==0.34.3
whitenoise==6.9.0
yt-dlp[default]==2025.2.19
yt-dlp[default]==2025.5.22

View File

@@ -79,7 +79,7 @@ class BaseTask(Task):
message.update({"level": level, "id": task_id})
task_result = TaskManager().get_task(task_id)
if task_result:
command = task_result.get("command", False)
command = task_result.get("command", None)
message.update({"command": command})
key = f"message:{message.get('group')}:{task_id.split('-')[0]}"
@@ -96,7 +96,7 @@ def update_subscribed(self):
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] rescan already running")
self.send_progress("Rescan already in progress.")
self.send_progress(["Rescan already in progress."])
return None
manager.init(self)
@@ -124,7 +124,7 @@ def download_pending(self, auto_only=False):
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] download queue already running")
self.send_progress("Download Queue is already running.")
self.send_progress(["Download Queue is already running."])
return None
manager.init(self)
@@ -134,7 +134,7 @@ def download_pending(self, auto_only=False):
if failed:
print(f"[task][{self.name}] Videos failed, retry.")
self.send_progress("Videos failed, retry.")
self.send_progress(["Videos failed, retry."])
raise self.retry()
except Retry as exc:
@@ -156,7 +156,7 @@ def extrac_dl(self, youtube_ids, auto_start=False, status="pending"):
to_add = youtube_ids
pending_handler = PendingList(youtube_ids=to_add, task=self)
pending_handler.parse_url_list()
pending_handler.parse_url_list(auto_start=auto_start)
videos_added = pending_handler.add_to_pending(
status=status, auto_start=auto_start
)
@@ -176,13 +176,13 @@ def check_reindex(self, data=False, extract_videos=False):
if data:
# started from frontend through API
print(f"[task][{self.name}] reindex {data}")
self.send_progress("Add items to the reindex Queue.")
self.send_progress(["Add items to the reindex Queue."])
ReindexManual(extract_videos=extract_videos).extract_data(data)
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] reindex queue is already running")
self.send_progress("Reindex Queue is already running.")
self.send_progress(["Reindex Queue is already running."])
return
manager.init(self)
@@ -190,10 +190,10 @@ def check_reindex(self, data=False, extract_videos=False):
# started from scheduler
populate = ReindexPopulate()
print(f"[task][{self.name}] reindex outdated documents")
self.send_progress("Add recent documents to the reindex Queue.")
self.send_progress(["Add recent documents to the reindex Queue."])
populate.get_interval()
populate.add_recent()
self.send_progress("Add outdated documents to the reindex Queue.")
self.send_progress(["Add outdated documents to the reindex Queue."])
populate.add_outdated()
handler = Reindex(task=self)
@@ -208,7 +208,7 @@ def run_manual_import(self):
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] manual import is already running")
self.send_progress("Manual import is already running.")
self.send_progress(["Manual import is already running."])
return
manager.init(self)
@@ -221,7 +221,7 @@ def run_backup(self, reason="auto"):
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] backup is already running")
self.send_progress("Backup is already running.")
self.send_progress(["Backup is already running."])
return
manager.init(self)
@@ -234,7 +234,7 @@ def run_restore_backup(self, filename):
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] restore is already running")
self.send_progress("Restore is already running.")
self.send_progress(["Restore is already running."])
return None
manager.init(self)
@@ -252,7 +252,7 @@ def rescan_filesystem(self):
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] filesystem rescan already running")
self.send_progress("Filesystem Rescan is already running.")
self.send_progress(["Filesystem Rescan is already running."])
return
manager.init(self)
@@ -268,7 +268,7 @@ def thumbnail_check(self):
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] thumbnail check is already running")
self.send_progress("Thumbnail check is already running.")
self.send_progress(["Thumbnail check is already running."])
return
manager.init(self)
@@ -283,7 +283,7 @@ def re_sync_thumbs(self):
manager = TaskManager()
if manager.is_pending(self):
print(f"[task][{self.name}] thumb re-embed is already running")
self.send_progress("Thumbnail re-embed is already running.")
self.send_progress(["Thumbnail re-embed is already running."])
return
manager.init(self)

View File

@@ -31,12 +31,15 @@ class UserMeConfigSerializer(serializers.Serializer):
page_size = serializers.IntegerField()
sort_by = serializers.ChoiceField(choices=SortEnum.names())
sort_order = serializers.ChoiceField(choices=OrderEnum.values())
view_style_home = serializers.ChoiceField(choices=["grid", "list"])
view_style_home = serializers.ChoiceField(
choices=["grid", "list", "table"]
)
view_style_channel = serializers.ChoiceField(choices=["grid", "list"])
view_style_downloads = serializers.ChoiceField(choices=["grid", "list"])
view_style_playlist = serializers.ChoiceField(choices=["grid", "list"])
grid_items = serializers.IntegerField(max_value=7, min_value=3)
hide_watched = serializers.BooleanField()
file_size_unit = serializers.ChoiceField(choices=["binary", "metric"])
show_ignored_only = serializers.BooleanField()
show_subed_only = serializers.BooleanField()
show_help_text = serializers.BooleanField()

View File

@@ -22,6 +22,7 @@ class UserConfigType(TypedDict, total=False):
view_style_playlist: str
grid_items: int
hide_watched: bool
file_size_unit: str
show_ignored_only: bool
show_subed_only: bool
show_help_text: bool
@@ -44,6 +45,7 @@ class UserConfig:
view_style_playlist="grid",
grid_items=3,
hide_watched=False,
file_size_unit="binary",
show_ignored_only=False,
show_subed_only=False,
show_help_text=True,

View File

@@ -12,6 +12,7 @@ class PlayerSerializer(serializers.Serializer):
"""serialize player"""
watched = serializers.BooleanField()
watched_date = serializers.IntegerField(required=False)
duration = serializers.IntegerField()
duration_str = serializers.CharField()
progress = serializers.FloatField(required=False)

View File

@@ -31,6 +31,8 @@ class SortEnum(enum.Enum):
LIKES = "stats.like_count"
DURATION = "player.duration"
MEDIASIZE = "media_size"
WIDTH = "streams.width"
HEIGHT = "streams.height"
@classmethod
def values(cls) -> list[str]:

View File

@@ -173,9 +173,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
self._validate_id()
# extract
self.channel_id = self.youtube_meta["channel_id"]
upload_date = self.youtube_meta["upload_date"]
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
last_refresh = int(datetime.now().timestamp())
# base64_blur = ThumbManager().get_base64_blur(self.youtube_id)
base64_blur = False
@@ -187,7 +184,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
"vid_thumb_url": self.youtube_meta["thumbnail"],
"vid_thumb_base64": base64_blur,
"tags": self.youtube_meta.get("tags", []),
"published": published,
"published": self._build_published(),
"vid_last_refresh": last_refresh,
"date_downloaded": last_refresh,
"youtube_id": self.youtube_id,
@@ -196,6 +193,18 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
"active": True,
}
def _build_published(self):
"""build published date or timestamp"""
timestamp = self.youtube_meta.get("timestamp")
if timestamp:
return timestamp
upload_date = self.youtube_meta["upload_date"]
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
return published
def _validate_id(self):
"""validate expected video ID, raise value error on mismatch"""
remote_id = self.youtube_meta["id"]

View File

@@ -1,6 +1,7 @@
"""build query for video fetching"""
from common.src.ta_redis import RedisArchivist
from playlist.src.index import YoutubePlaylist
from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum
@@ -84,6 +85,11 @@ class QueryBuilder:
def parse_sort(self) -> dict | None:
"""build sort key"""
playlist = self.request_params.get("playlist")
if playlist:
# overwrite sort based on idx in playlist
return self._get_playlist_sort(playlist_id=playlist)
sort = self.request_params.get("sort")
if not sort:
return None
@@ -100,3 +106,39 @@ class QueryBuilder:
order_by = getattr(OrderEnum, order.upper()).value
return {"sort": [{sort_field: {"order": order_by}}]}
def _get_playlist_sort(self, playlist_id: str):
"""get sort for playlist"""
playlist = YoutubePlaylist(playlist_id)
playlist.get_from_es()
if not playlist.json_data:
raise ValueError(f"playlist {playlist_id} not found")
sort_score = {
i["youtube_id"]: i["idx"]
for i in playlist.json_data["playlist_entries"]
if i["downloaded"]
}
script = (
"if(params.scores.containsKey(doc['youtube_id'].value)) "
+ "{return params.scores[doc['youtube_id'].value];} "
+ "return 100000;"
)
sort = {
"sort": [
{
"_script": {
"type": "number",
"script": {
"lang": "painless",
"source": script,
"params": {"scores": sort_score},
},
"order": "asc",
}
}
],
}
return sort

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
version: '3.5'
services:
tubearchivist:
container_name: tubearchivist
@@ -15,13 +13,13 @@ services:
- REDIS_CON=redis://archivist-redis:6379
- HOST_UID=1000
- HOST_GID=1000
- TA_HOST=http://tubearchivist.local # set your host name with protocol and port
- TA_HOST=http://tubearchivist.local:8000 # set your host name with protocol and port
- TA_USERNAME=tubearchivist # your initial TA credentials
- TA_PASSWORD=verysecret # your initial TA credentials
- ELASTIC_PASSWORD=verysecret # set password for Elasticsearch
- TZ=America/New_York # set your time zone
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 2m
timeout: 10s
retries: 3
@@ -40,7 +38,7 @@ services:
depends_on:
- archivist-es
archivist-es:
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.17.2
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.18.0
container_name: archivist-es
restart: unless-stopped
environment:

View File

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

View File

@@ -3,6 +3,12 @@
set -e
if [[ -n "$DJANGO_DEBUG" ]]; then
LOGLEVEL="DEBUG"
else
LOGLEVEL="INFO"
fi
# stop on pending manual migration
python manage.py ta_stop_on_error
@@ -18,10 +24,11 @@ python manage.py ta_startup
# start all tasks
nginx &
celery -A task.celery worker \
--loglevel=INFO \
--loglevel=$LOGLEVEL \
--concurrency 4 \
--max-tasks-per-child 5 \
--max-memory-per-child 150000 &
celery -A task beat --loglevel=INFO \
--scheduler django_celery_beat.schedulers:DatabaseScheduler &
./beat_auto_spawn.sh &
python backend_start.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "tubearchivist-frontend",
"version": "0.1.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,27 +11,27 @@
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.2.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5",
"zustand": "^5.0.3"
"dompurify": "^3.2.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"zustand": "^5.0.5"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"@typescript-eslint/parser": "^8.24.0",
"@vitejs/plugin-react-swc": "^3.8.0",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "3.5.1",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.0",
"vite": "^6.1.0",
"vite-plugin-checker": "^0.8.0"
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react-swc": "^3.10.0",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"prettier": "3.5.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"vite": ">=6.3.5",
"vite-plugin-checker": "^0.9.3"
}
}

View File

@@ -0,0 +1,16 @@
:root {
--main-bg: #00202f;
--highlight-bg: #00293b;
--highlight-error: #990202;
--highlight-error-light: #c44343;
--highlight-bg-transparent: #00293baf;
--main-font: #eeeeee;
--accent-font-dark: #259485;
--accent-font-light: #97d4c8;
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%)
contrast(90%);
--img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
contrast(120%);
--banner: url('/img/banner-tube-archivist-dark.png');
--logo: url('/img/logo-tube-archivist-dark.png');
}

View File

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

View File

@@ -3,7 +3,7 @@ import APIClient from '../../functions/APIClient';
const createCustomPlaylist = async (playlistId: string) => {
return APIClient('/api/playlist/custom/', {
method: 'POST',
body: { playlist_name: playlistId },
body: { playlist_name: playlistId.trim() },
});
};

View File

@@ -1,8 +1,8 @@
import APIClient from '../../functions/APIClient';
import { CookieStateType } from '../loader/loadCookie';
const deleteCookie = async (): Promise<CookieStateType> => {
return APIClient('/api/appsettings/cookie/', {
const deleteCookie = async () => {
return APIClient<CookieStateType>('/api/appsettings/cookie/', {
method: 'DELETE',
});
};

View File

@@ -8,7 +8,9 @@ const updateBulkChannelSubscriptions = async (channelIds: string, status: boolea
const youtubeChannelIds = channelIds.split('\n');
youtubeChannelIds.forEach(channelId => {
channels.push({ channel_id: channelId, channel_subscribed: status });
if (channelId.trim()) {
channels.push({ channel_id: channelId, channel_subscribed: status });
}
});
} else {
channels.push({ channel_id: channelIds, channel_subscribed: status });

View File

@@ -8,7 +8,9 @@ const updateBulkPlaylistSubscriptions = async (playlistIds: string, status: bool
const youtubePlaylistIds = playlistIds.split('\n');
youtubePlaylistIds.forEach(playlistId => {
playlists.push({ playlist_id: playlistId, playlist_subscribed: status });
if (playlistId.trim()) {
playlists.push({ playlist_id: playlistId, playlist_subscribed: status });
}
});
} else {
playlists.push({ playlist_id: playlistIds, playlist_subscribed: status });

View File

@@ -1,8 +1,8 @@
import APIClient from '../../functions/APIClient';
import { CookieStateType } from '../loader/loadCookie';
const updateCookie = async (cookie: string): Promise<CookieStateType> => {
return APIClient('/api/appsettings/cookie/', {
const updateCookie = async (cookie: string) => {
return APIClient<CookieStateType>('/api/appsettings/cookie/', {
method: 'PUT',
body: { cookie },
});

View File

@@ -8,7 +8,9 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean)
const youtubeIds = youtubeIdStrings.split('\n');
youtubeIds.forEach(youtubeId => {
urls.push({ youtube_id: youtubeId, status: 'pending' });
if (youtubeId.trim()) {
urls.push({ youtube_id: youtubeId, status: 'pending' });
}
});
} else {
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' });

View File

@@ -1,6 +1,6 @@
import APIClient from '../../functions/APIClient';
export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority';
export type DownloadQueueStatus = 'ignore' | 'ignore-force' | 'pending' | 'priority';
const updateDownloadQueueStatusById = async (youtubeId: string, status: DownloadQueueStatus) => {
return APIClient(`/api/download/${youtubeId}/`, {

View File

@@ -1,26 +1,46 @@
import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home';
import { ViewStylesType } from '../../configuration/constants/ViewStyle';
import APIClient from '../../functions/APIClient';
import { SortByType, SortOrderType } from '../loader/loadVideoListByPage';
export type ColourVariants = 'dark.css' | 'light.css' | 'matrix.css' | 'midnight.css';
export type ColourVariants =
| 'dark.css'
| 'light.css'
| 'matrix.css'
| 'midnight.css'
| 'custom.css';
export const ColourConstant = {
Dark: 'dark.css',
Light: 'light.css',
Matrix: 'matrix.css',
Midnight: 'midnight.css',
Custom: 'custom.css',
};
export const FileSizeUnits = {
Binary: 'binary',
Metric: 'metric',
};
export type UserConfigType = {
stylesheet: ColourVariants;
page_size: number;
sort_by: SortByType;
sort_order: SortOrderType;
view_style_home: ViewLayoutType;
view_style_channel: ViewLayoutType;
view_style_downloads: ViewLayoutType;
view_style_playlist: ViewLayoutType;
view_style_home: ViewStylesType;
view_style_channel: ViewStylesType;
view_style_downloads: ViewStylesType;
view_style_playlist: ViewStylesType;
grid_items: number;
hide_watched: boolean;
file_size_unit: 'binary' | 'metric';
show_ignored_only: boolean;
show_subed_only: boolean;
show_help_text: boolean;
};
const updateUserConfig = async (config: Partial<UserConfigType>): Promise<UserConfigType> => {
return APIClient('/api/user/me/', {
const updateUserConfig = async (config: Partial<UserConfigType>) => {
return APIClient<UserConfigType>('/api/user/me/', {
method: 'POST',
body: config,
});

View File

@@ -14,11 +14,8 @@ type VideoProgressProp = {
currentProgress: number;
};
const updateVideoProgressById = async ({
youtubeId,
currentProgress,
}: VideoProgressProp): Promise<VideoProgressResponseType> => {
return APIClient(`/api/video/${youtubeId}/progress/`, {
const updateVideoProgressById = async ({ youtubeId, currentProgress }: VideoProgressProp) => {
return APIClient<VideoProgressResponseType>(`/api/video/${youtubeId}/progress/`, {
method: 'POST',
body: { position: currentProgress },
});

View File

@@ -1,8 +1,8 @@
import APIClient from '../../functions/APIClient';
import { CookieStateType } from '../loader/loadCookie';
const validateCookie = async (): Promise<CookieStateType> => {
return APIClient('/api/appsettings/cookie/', {
const validateCookie = async () => {
return APIClient<CookieStateType>('/api/appsettings/cookie/', {
method: 'POST',
});
};

View File

@@ -4,8 +4,8 @@ type ApiTokenResponse = {
token: string;
};
const loadApiToken = async (): Promise<ApiTokenResponse> => {
return APIClient('/api/appsettings/token/');
const loadApiToken = async () => {
return APIClient<ApiTokenResponse>('/api/appsettings/token/');
};
export default loadApiToken;

View File

@@ -19,8 +19,8 @@ export type AppriseNotificationType = {
};
};
const loadAppriseNotification = async (): Promise<AppriseNotificationType> => {
return APIClient('/api/task/notification/');
const loadAppriseNotification = async () => {
return APIClient<AppriseNotificationType>('/api/task/notification/');
};
export default loadAppriseNotification;

View File

@@ -33,8 +33,8 @@ export type AppSettingsConfigType = {
};
};
const loadAppsettingsConfig = async (): Promise<AppSettingsConfigType> => {
return APIClient('/api/appsettings/config/');
const loadAppsettingsConfig = async () => {
return APIClient<AppSettingsConfigType>('/api/appsettings/config/');
};
export default loadAppsettingsConfig;

View File

@@ -1,7 +1,17 @@
import APIClient from '../../functions/APIClient';
type Backup = {
filename: string;
file_path: string;
file_size: number;
timestamp: string;
reason: string;
};
export type BackupListType = Backup[];
const loadBackupList = async () => {
return APIClient('/api/appsettings/backup/');
return APIClient<BackupListType>('/api/appsettings/backup/');
};
export default loadBackupList;

View File

@@ -13,8 +13,8 @@ export type ChannelAggsType = {
};
};
const loadChannelAggs = async (channelId: string): Promise<ChannelAggsType> => {
return APIClient(`/api/channel/${channelId}/aggs/`);
const loadChannelAggs = async (channelId: string) => {
return APIClient<ChannelAggsType>(`/api/channel/${channelId}/aggs/`);
};
export default loadChannelAggs;

View File

@@ -1,8 +1,10 @@
import APIClient from '../../functions/APIClient';
import { ChannelResponseType } from '../../pages/ChannelBase';
import { ChannelType } from '../../pages/Channels';
const loadChannelById = async (youtubeChannelId: string): Promise<ChannelResponseType> => {
return APIClient(`/api/channel/${youtubeChannelId}/`);
export type ChannelResponseType = ChannelType;
const loadChannelById = async (youtubeChannelId: string) => {
return APIClient<ChannelResponseType>(`/api/channel/${youtubeChannelId}/`);
};
export default loadChannelById;

View File

@@ -1,4 +1,13 @@
import { PaginationType } from '../../components/Pagination';
import APIClient from '../../functions/APIClient';
import { ChannelType } from '../../pages/Channels';
import { ConfigType } from '../../pages/Home';
export type ChannelsListResponse = {
data: ChannelType[];
paginate: PaginationType;
config?: ConfigType;
};
const loadChannelList = async (page: number, showSubscribed: boolean) => {
const searchParams = new URLSearchParams();
@@ -8,7 +17,7 @@ const loadChannelList = async (page: number, showSubscribed: boolean) => {
const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient(endpoint);
return APIClient<ChannelsListResponse>(endpoint);
};
export default loadChannelList;

View File

@@ -5,10 +5,11 @@ export type ChannelNavResponseType = {
has_shorts: boolean;
has_playlists: boolean;
has_pending: boolean;
has_ignored: boolean;
};
const loadChannelNav = async (youtubeChannelId: string): Promise<ChannelNavResponseType> => {
return APIClient(`/api/channel/${youtubeChannelId}/nav/`);
const loadChannelNav = async (youtubeChannelId: string) => {
return APIClient<ChannelNavResponseType>(`/api/channel/${youtubeChannelId}/nav/`);
};
export default loadChannelNav;

View File

@@ -1,7 +1,10 @@
import { CommentsType } from '../../components/CommentBox';
import APIClient from '../../functions/APIClient';
export type CommentsResponseType = CommentsType[];
const loadCommentsbyVideoId = async (youtubeId: string) => {
return APIClient(`/api/video/${youtubeId}/comment/`);
return APIClient<CommentsResponseType>(`/api/video/${youtubeId}/comment/`);
};
export default loadCommentsbyVideoId;

View File

@@ -7,8 +7,8 @@ export type CookieStateType = {
validated_str?: string;
};
const loadCookie = async (): Promise<CookieStateType> => {
return APIClient('/api/appsettings/cookie/');
const loadCookie = async () => {
return APIClient<CookieStateType>('/api/appsettings/cookie/');
};
export default loadCookie;

View File

@@ -12,10 +12,10 @@ export type DownloadAggsType = {
buckets: DownloadAggsBucket[];
};
const loadDownloadAggs = async (showIgnored: boolean): Promise<DownloadAggsType> => {
const loadDownloadAggs = async (showIgnored: boolean) => {
const searchParams = new URLSearchParams();
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
return APIClient(
return APIClient<DownloadAggsType>(
`/api/download/aggs/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`,
);
};

View File

@@ -1,11 +1,7 @@
import APIClient from '../../functions/APIClient';
import { DownloadResponseType } from '../../pages/Download';
const loadDownloadQueue = async (
page: number,
channelId: string | null,
showIgnored: boolean,
): Promise<DownloadResponseType> => {
const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => {
const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString());
@@ -14,7 +10,7 @@ const loadDownloadQueue = async (
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient(endpoint);
return APIClient<DownloadResponseType>(endpoint);
};
export default loadDownloadQueue;

View File

@@ -2,6 +2,19 @@ import APIClient from '../../functions/APIClient';
export type NotificationPages = 'download' | 'settings' | 'channel' | 'all';
type NotificationType = {
title: string;
group: string;
api_stop: boolean;
level: string;
id: string;
command: boolean | string;
messages: string[];
progress: number;
};
export type NotificationResponseType = NotificationType[];
const loadNotifications = async (pageName: NotificationPages, includeReindex = false) => {
const searchParams = new URLSearchParams();
@@ -10,7 +23,7 @@ const loadNotifications = async (pageName: NotificationPages, includeReindex = f
}
const endpoint = `/api/notification/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient(endpoint);
return APIClient<NotificationResponseType>(endpoint);
};
export default loadNotifications;

View File

@@ -26,8 +26,8 @@ export type PlaylistType = {
export type PlaylistResponseType = PlaylistType;
const loadPlaylistById = async (playlistId: string | undefined): Promise<PlaylistResponseType> => {
return APIClient(`/api/playlist/${playlistId}/`);
const loadPlaylistById = async (playlistId: string | undefined) => {
return APIClient<PlaylistResponseType>(`/api/playlist/${playlistId}/`);
};
export default loadPlaylistById;

View File

@@ -1,12 +1,19 @@
import { PaginationType } from '../../components/Pagination';
import APIClient from '../../functions/APIClient';
import { PlaylistType } from './loadPlaylistById';
type PlaylistType = 'regular' | 'custom';
export type PlaylistsResponseType = {
data?: PlaylistType[];
paginate?: PaginationType;
};
type PlaylistCategoryType = 'regular' | 'custom';
type LoadPlaylistListProps = {
channel?: string;
page?: number | undefined;
subscribed?: boolean;
type?: PlaylistType;
type?: PlaylistCategoryType;
};
const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylistListProps) => {
@@ -18,7 +25,7 @@ const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylis
if (type) searchParams.append('type', type);
const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient(endpoint);
return APIClient<PlaylistsResponseType>(endpoint);
};
export default loadPlaylistList;

View File

@@ -13,8 +13,8 @@ type ScheduleType = {
export type ScheduleResponseType = ScheduleType[];
const loadSchedule = async (): Promise<ScheduleResponseType> => {
return APIClient('/api/task/schedule/');
const loadSchedule = async () => {
return APIClient<ScheduleResponseType>('/api/task/schedule/');
};
export default loadSchedule;

View File

@@ -1,7 +1,22 @@
import APIClient from '../../functions/APIClient';
import { ChannelType } from '../../pages/Channels';
import { VideoType } from '../../pages/Home';
import { PlaylistType } from './loadPlaylistById';
type SearchResultType = {
video_results: VideoType[];
channel_results: ChannelType[];
playlist_results: PlaylistType[];
fulltext_results: [];
};
export type SearchResultsType = {
results: SearchResultType;
queryType: string;
};
const loadSearch = async (query: string) => {
return APIClient(`/api/search/?query=${query}`);
return APIClient<SearchResultsType>(`/api/search/?query=${query}`);
};
export default loadSearch;

View File

@@ -0,0 +1,8 @@
import APIClient from '../../functions/APIClient';
import { VideoResponseType } from './loadVideoById';
const loadSimilarVideosById = async (youtubeId: string) => {
return APIClient<VideoResponseType[]>(`/api/video/${youtubeId}/similar/`);
};
export default loadSimilarVideosById;

View File

@@ -1,7 +0,0 @@
import APIClient from '../../functions/APIClient';
const loadSimmilarVideosById = async (youtubeId: string) => {
return APIClient(`/api/video/${youtubeId}/similar/`);
};
export default loadSimmilarVideosById;

View File

@@ -1,7 +1,24 @@
import APIClient from '../../functions/APIClient';
export type SnapshotType = {
id: string;
state: string;
es_version: string;
start_date: string;
end_date: string;
end_stamp: number;
duration_s: number;
};
export type SnapshotListType = {
next_exec: number;
next_exec_str: string;
expire_after: string;
snapshots?: SnapshotType[];
};
const loadSnapshots = async () => {
return APIClient('/api/appsettings/snapshot/');
return APIClient<SnapshotListType>('/api/appsettings/snapshot/');
};
export default loadSnapshots;

View File

@@ -2,11 +2,24 @@ import APIClient from '../../functions/APIClient';
type BiggestChannelsOrderType = 'doc_count' | 'duration' | 'media_size';
type BiggestChannelsType = {
id: string;
name: string;
doc_count: number;
duration: number;
duration_str: string;
media_size: number;
};
export type BiggestChannelsStatsType = BiggestChannelsType[];
const loadStatsBiggestChannels = async (order: BiggestChannelsOrderType) => {
const searchParams = new URLSearchParams();
searchParams.append('order', order);
return APIClient(`/api/stats/biggestchannels/?${searchParams.toString()}`);
return APIClient<BiggestChannelsStatsType>(
`/api/stats/biggestchannels/?${searchParams.toString()}`,
);
};
export default loadStatsBiggestChannels;

View File

@@ -1,7 +1,13 @@
import APIClient from '../../functions/APIClient';
export type ChannelStatsType = {
doc_count: number;
active_true: number;
subscribed_true: number;
};
const loadStatsChannel = async () => {
return APIClient('/api/stats/channel/');
return APIClient<ChannelStatsType>('/api/stats/channel/');
};
export default loadStatsChannel;

View File

@@ -1,7 +1,14 @@
import APIClient from '../../functions/APIClient';
export type DownloadStatsType = {
pending: number;
pending_videos: number;
pending_shorts: number;
pending_streams: number;
};
const loadStatsDownload = async () => {
return APIClient('/api/stats/download/');
return APIClient<DownloadStatsType>('/api/stats/download/');
};
export default loadStatsDownload;

View File

@@ -1,7 +1,15 @@
import APIClient from '../../functions/APIClient';
type DownloadHistoryType = {
date: string;
count: number;
media_size: number;
};
export type DownloadHistoryStatsType = DownloadHistoryType[];
const loadStatsDownloadHistory = async () => {
return APIClient('/api/stats/downloadhist/');
return APIClient<DownloadHistoryStatsType>('/api/stats/downloadhist/');
};
export default loadStatsDownloadHistory;

View File

@@ -1,7 +1,14 @@
import APIClient from '../../functions/APIClient';
export type PlaylistStatsType = {
doc_count: number;
active_false: number;
active_true: number;
subscribed_true: number;
};
const loadStatsPlaylist = async () => {
return APIClient('/api/stats/playlist/');
return APIClient<PlaylistStatsType>('/api/stats/playlist/');
};
export default loadStatsPlaylist;

View File

@@ -1,7 +1,44 @@
import APIClient from '../../functions/APIClient';
export type VideoStatsType = {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
type_videos: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
type_shorts: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
active_true: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
active_false: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
type_streams: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
};
const loadStatsVideo = async () => {
return APIClient('/api/stats/video/');
return APIClient<VideoStatsType>('/api/stats/video/');
};
export default loadStatsVideo;

View File

@@ -1,7 +1,27 @@
import APIClient from '../../functions/APIClient';
export type WatchProgressStatsType = {
total: {
duration: number;
duration_str: string;
items: number;
};
unwatched: {
duration: number;
duration_str: string;
progress: number;
items: number;
};
watched: {
duration: number;
duration_str: string;
progress: number;
items: number;
};
};
const loadStatsWatchProgress = async () => {
return APIClient('/api/stats/watch/');
return APIClient<WatchProgressStatsType>('/api/stats/watch/');
};
export default loadStatsWatchProgress;

View File

@@ -10,8 +10,8 @@ export type UserAccountType = {
last_login: string;
};
const loadUserAccount = async (): Promise<UserAccountType> => {
return APIClient('/api/user/account/');
const loadUserAccount = async () => {
return APIClient<UserAccountType>('/api/user/account/');
};
export default loadUserAccount;

View File

@@ -1,8 +1,8 @@
import { UserConfigType } from '../actions/updateUserConfig';
import APIClient from '../../functions/APIClient';
const loadUserMeConfig = async (): Promise<UserConfigType> => {
return APIClient('/api/user/me/');
const loadUserMeConfig = async () => {
return APIClient<UserConfigType>('/api/user/me/');
};
export default loadUserMeConfig;

View File

@@ -1,8 +1,10 @@
import APIClient from '../../functions/APIClient';
import { VideoResponseType } from '../../pages/Video';
import { VideoType } from '../../pages/Home';
const loadVideoById = async (youtubeId: string): Promise<VideoResponseType> => {
return APIClient(`/api/video/${youtubeId}/`);
export type VideoResponseType = VideoType;
const loadVideoById = async (youtubeId: string) => {
return APIClient<VideoResponseType>(`/api/video/${youtubeId}/`);
};
export default loadVideoById;

View File

@@ -1,4 +1,4 @@
import { ConfigType, SortByType, SortOrderType, VideoType } from '../../pages/Home';
import { ConfigType, VideoType } from '../../pages/Home';
import { PaginationType } from '../../components/Pagination';
import APIClient from '../../functions/APIClient';
@@ -8,9 +8,41 @@ export type VideoListByFilterResponseType = {
paginate?: PaginationType;
};
type WatchTypes = 'watched' | 'unwatched' | 'continue';
export type SortByType =
| 'published'
| 'downloaded'
| 'views'
| 'likes'
| 'duration'
| 'mediasize'
| 'width'
| 'height';
export const SortByEnum = {
Published: 'published',
Downloaded: 'downloaded',
Views: 'views',
Likes: 'likes',
Duration: 'duration',
'Media Size': 'mediasize',
Width: 'width',
Height: 'height',
};
export type SortOrderType = 'asc' | 'desc';
export const SortOrderEnum = {
Asc: 'asc',
Desc: 'desc',
};
export type VideoTypes = 'videos' | 'streams' | 'shorts';
export type WatchTypes = 'watched' | 'unwatched' | 'continue';
export const WatchTypesEnum = {
Watched: 'watched',
Unwatched: 'unwatched',
Continue: 'continue',
};
type FilterType = {
page?: number;
playlist?: string;
@@ -21,9 +53,7 @@ type FilterType = {
type?: VideoTypes;
};
const loadVideoListByFilter = async (
filter: FilterType,
): Promise<VideoListByFilterResponseType> => {
const loadVideoListByFilter = async (filter: FilterType) => {
const searchParams = new URLSearchParams();
if (filter.playlist) {
@@ -39,7 +69,7 @@ const loadVideoListByFilter = async (
if (filter.type) searchParams.append('type', filter.type);
const endpoint = `/api/video/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient(endpoint);
return APIClient<VideoListByFilterResponseType>(endpoint);
};
export default loadVideoListByFilter;

View File

@@ -25,8 +25,8 @@ export type VideoNavResponseType = {
};
};
const loadVideoNav = async (youtubeVideoId: string): Promise<VideoNavResponseType[]> => {
return APIClient(`/api/video/${youtubeVideoId}/nav/`);
const loadVideoNav = async (youtubeVideoId: string) => {
return APIClient<VideoNavResponseType[]>(`/api/video/${youtubeVideoId}/nav/`);
};
export default loadVideoNav;

View File

@@ -1,7 +1,9 @@
import { Fragment } from 'react';
import StatsInfoBoxItem from './StatsInfoBoxItem';
import formatNumbers from '../functions/formatNumbers';
import { ChannelStatsType, PlaylistStatsType, DownloadStatsType } from '../pages/SettingsDashboard';
import { ChannelStatsType } from '../api/loader/loadStatsChannel';
import { PlaylistStatsType } from '../api/loader/loadStatsPlaylist';
import { DownloadStatsType } from '../api/loader/loadStatsDownload';
type ApplicationStatsProps = {
channelStats?: ChannelStatsType;

View File

@@ -2,20 +2,20 @@ import humanFileSize from '../functions/humanFileSize';
import formatNumbers from '../functions/formatNumbers';
import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { BiggestChannelsStatsType } from '../pages/SettingsDashboard';
import { BiggestChannelsStatsType } from '../api/loader/loadStatsBiggestChannels';
type BiggestChannelsStatsProps = {
biggestChannelsStatsByCount?: BiggestChannelsStatsType;
biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
useSI: boolean;
useSIUnits: boolean;
};
const BiggestChannelsStats = ({
biggestChannelsStatsByCount,
biggestChannelsStatsByDuration,
biggestChannelsStatsByMediaSize,
useSI,
useSIUnits,
}: BiggestChannelsStatsProps) => {
if (
!biggestChannelsStatsByCount &&
@@ -94,7 +94,9 @@ const BiggestChannelsStats = ({
<td className="agg-channel-name">
<Link to={Routes.Channel(id)}>{name}</Link>
</td>
<td className="agg-channel-right-align">{humanFileSize(media_size, useSI)}</td>
<td className="agg-channel-right-align">
{humanFileSize(media_size, useSIUnits)}
</td>
</tr>
);
})}

View File

@@ -7,9 +7,15 @@ type ChannelIconProps = {
};
const ChannelBanner = ({ channelId, channelBannerUrl }: ChannelIconProps) => {
let src = `${getApiUrl()}${channelBannerUrl}`;
if (channelBannerUrl === undefined) {
src = defaultChannelImage;
}
return (
<img
src={`${getApiUrl()}${channelBannerUrl}`}
src={src}
alt={`${channelId}-banner`}
onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping

View File

@@ -7,9 +7,15 @@ type ChannelIconProps = {
};
const ChannelIcon = ({ channelId, channelThumbUrl }: ChannelIconProps) => {
let src = `${getApiUrl()}${channelThumbUrl}`;
if (channelThumbUrl === undefined) {
src = defaultChannelIcon;
}
return (
<img
src={`${getApiUrl()}${channelThumbUrl}`}
src={src}
alt={`${channelId}-thumb`}
onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping

View File

@@ -16,7 +16,7 @@ type ChannelListProps = {
const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
const { userConfig } = useUserConfigStore();
const viewLayout = userConfig.view_style_channel;
const viewStyle = userConfig.view_style_channel;
if (!channelList || channelList.length === 0) {
return <p>No channels found.</p>;
@@ -26,8 +26,8 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
<>
{channelList.map(channel => {
return (
<div key={channel.channel_id} className={`channel-item ${viewLayout}`}>
<div className={`channel-banner ${viewLayout}`}>
<div key={channel.channel_id} className={`channel-item ${viewStyle}`}>
<div className={`channel-banner ${viewStyle}`}>
<Link to={Routes.Channel(channel.channel_id)}>
<ChannelBanner
channelId={channel.channel_id}
@@ -35,7 +35,7 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
/>
</Link>
</div>
<div className={`info-box info-box-2 ${viewLayout}`}>
<div className={`info-box info-box-2 ${viewStyle}`}>
<div className="info-box-item">
<div className="round-img">
<Link to={Routes.Channel(channel.channel_id)}>
@@ -49,7 +49,9 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
<h3>
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
</h3>
<FormattedNumber text="Subscribers:" number={channel.channel_subs} />
{channel.channel_subs !== null && (
<FormattedNumber text="Subscribers:" number={channel.channel_subs} />
)}
</div>
</div>
<div className="info-box-item">

View File

@@ -38,7 +38,7 @@ const ChannelOverview = ({
<Link to={Routes.ChannelVideo(channelId)}>{channelname}</Link>
</h3>
<FormattedNumber text="Subscribers:" number={channelSubs} />
{channelSubs !== null && <FormattedNumber text="Subscribers:" number={channelSubs} />}
{isAdmin && (
<>

View File

@@ -1,14 +1,14 @@
import humanFileSize from '../functions/humanFileSize';
import formatDate from '../functions/formatDates';
import formatNumbers from '../functions/formatNumbers';
import { DownloadHistoryStatsType } from '../pages/SettingsDashboard';
import { DownloadHistoryStatsType } from '../api/loader/loadStatsDownloadHistory';
type DownloadHistoryStatsProps = {
downloadHistoryStats?: DownloadHistoryStatsType;
useSI: boolean;
useSIUnits: boolean;
};
const DownloadHistoryStats = ({ downloadHistoryStats, useSI }: DownloadHistoryStatsProps) => {
const DownloadHistoryStats = ({ downloadHistoryStats, useSIUnits }: DownloadHistoryStatsProps) => {
if (!downloadHistoryStats) {
return <p id="loading">Loading...</p>;
}
@@ -31,7 +31,7 @@ const DownloadHistoryStats = ({ downloadHistoryStats, useSI }: DownloadHistorySt
<p>
+{formatNumbers(count)} {videoText}
<br />
{humanFileSize(media_size, useSI)}
{humanFileSize(media_size, useSIUnits)}
</p>
</div>
);

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