mirror of
https://git.vectorsigma.ru/public/tubearchivist.git
synced 2026-08-09 04:39:31 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22b53e6820 | ||
|
|
2c2129179a | ||
|
|
ffe9444295 | ||
|
|
f8c5efb87a | ||
|
|
e62a4e0fcf | ||
|
|
b495761e9e | ||
|
|
b7b6ae0216 | ||
|
|
aa08701049 | ||
|
|
436a641746 | ||
|
|
710b0ddc2d | ||
|
|
703fd63f44 | ||
|
|
f040ac6b34 | ||
|
|
aebde2b993 | ||
|
|
372275c4d5 | ||
|
|
e68a9f9f1f | ||
|
|
b239f4bd84 | ||
|
|
8a227fc9b8 | ||
|
|
8c59d65ee5 | ||
|
|
d196d2e4f5 | ||
|
|
62ea518e1b | ||
|
|
a961c8f175 | ||
|
|
ec5204cd6c | ||
|
|
37a6922718 | ||
|
|
a82e4b1c51 | ||
|
|
58b4e22df4 | ||
|
|
efe1401518 | ||
|
|
02b5ed6917 | ||
|
|
fd3ccbec3a | ||
|
|
717d2b3098 | ||
|
|
068cd2e407 | ||
|
|
ef07dc91b4 | ||
|
|
514ad0d16b | ||
|
|
72d81cc45a | ||
|
|
6789cc90d8 | ||
|
|
e7f1921986 | ||
|
|
0ba6169524 | ||
|
|
6e5fac847f | ||
|
|
0a5ce3f786 | ||
|
|
0db4ae3c19 | ||
|
|
56f5c6bc4b | ||
|
|
008dc79ebc | ||
|
|
e34da1687e | ||
|
|
c5f212f520 | ||
|
|
6eb774be69 | ||
|
|
f53988b826 | ||
|
|
70b67e5f77 | ||
|
|
25b6ca2a1a | ||
|
|
776481c513 | ||
|
|
1d727a1170 | ||
|
|
270e6e8320 | ||
|
|
897250da6b | ||
|
|
af33dbb22d | ||
|
|
32cc2c8a13 | ||
|
|
c00368d1f2 | ||
|
|
e28a6be3c5 | ||
|
|
2b71dbca9a | ||
|
|
b7a29897dd | ||
|
|
68a69185fe | ||
|
|
253571b5ac | ||
|
|
3cd6bad9d3 | ||
|
|
5ca295ccff | ||
|
|
8fd217e6f0 | ||
|
|
85be8f1114 | ||
|
|
e254a848e0 | ||
|
|
947ec803b3 | ||
|
|
0d1bdb8904 | ||
|
|
a2cb2d283c | ||
|
|
11afb29384 | ||
|
|
cbfb66b5c8 | ||
|
|
7b26a01063 | ||
|
|
96b55bf9d5 | ||
|
|
50a3442ca6 | ||
|
|
c407f27ecf | ||
|
|
148f9e801b | ||
|
|
5fe0afaeb7 | ||
|
|
c73a350095 | ||
|
|
2be190aa65 | ||
|
|
0aa5ef5b31 | ||
|
|
40bae8d73d | ||
|
|
8f34626a27 | ||
|
|
8978e6510a | ||
|
|
04ea6254b7 | ||
|
|
f18d812eb8 | ||
|
|
d69c579839 | ||
|
|
9fe11c3d22 | ||
|
|
f8846836e7 | ||
|
|
3ab218d744 | ||
|
|
c6329571ae | ||
|
|
4ae59848b4 | ||
|
|
17aa693fdf | ||
|
|
164f3d2a22 | ||
|
|
512f661823 | ||
|
|
7fdb93f183 | ||
|
|
edcede5de6 | ||
|
|
52083e6fb7 | ||
|
|
a4824497ef | ||
|
|
c5f549967b | ||
|
|
87d4f16456 | ||
|
|
f0bac927fe | ||
|
|
4b19294899 | ||
|
|
9f351b5907 | ||
|
|
677f85b993 | ||
|
|
158115d319 | ||
|
|
7abcfcc579 | ||
|
|
862a854e64 | ||
|
|
e06e1be433 |
@@ -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
1
.gitignore
vendored
@@ -4,6 +4,7 @@ __pycache__
|
||||
|
||||
# django testing
|
||||
backend/static
|
||||
backend/staticfiles
|
||||
backend/.env
|
||||
|
||||
# vscode custom conf
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
72
README.md
72
README.md
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
[*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
|
||||
|
||||
79
assets/tube-archivist-logo-dark.svg
Normal file
79
assets/tube-archivist-logo-dark.svg
Normal 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 |
79
assets/tube-archivist-logo-light.svg
Normal file
79
assets/tube-archivist-logo-light.svg
Normal 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 |
@@ -239,7 +239,8 @@
|
||||
"type": "keyword"
|
||||
},
|
||||
"published": {
|
||||
"type": "date"
|
||||
"type": "date",
|
||||
"format": "epoch_second||strict_date_optional_time"
|
||||
},
|
||||
"playlist": {
|
||||
"type": "text",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -25,4 +25,9 @@ urlpatterns = [
|
||||
views.NotificationView.as_view(),
|
||||
name="api-notification",
|
||||
),
|
||||
path(
|
||||
"health/",
|
||||
views.HealthCheck.as_view(),
|
||||
name="api-health",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
32
docker_assets/beat_auto_spawn.sh
Executable file
32
docker_assets/beat_auto_spawn.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
1315
frontend/package-lock.json
generated
1315
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
16
frontend/public/css/dark.css
Normal file
16
frontend/public/css/dark.css
Normal 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');
|
||||
}
|
||||
7
frontend/public/img/icon-tableview.svg
Normal file
7
frontend/public/img/icon-tableview.svg
Normal 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>
|
||||
@@ -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() },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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}/`, {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}` : ''}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
8
frontend/src/api/loader/loadSimilarVideosById.ts
Normal file
8
frontend/src/api/loader/loadSimilarVideosById.ts
Normal 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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
const loadSimmilarVideosById = async (youtubeId: string) => {
|
||||
return APIClient(`/api/video/${youtubeId}/similar/`);
|
||||
};
|
||||
|
||||
export default loadSimmilarVideosById;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user