mirror of
https://git.vectorsigma.ru/public/tubearchivist.git
synced 2026-08-08 18:09:23 +00:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f0a09f5f | ||
|
|
12fc7e1663 | ||
|
|
1c60b2bb46 | ||
|
|
3631fa0b2c | ||
|
|
dfcd46efbf | ||
|
|
aa943567de | ||
|
|
178c25f2f0 | ||
|
|
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 |
@@ -5,6 +5,13 @@
|
||||
# Docker
|
||||
.docker
|
||||
|
||||
# Backend development
|
||||
backend/static
|
||||
backend/staticfiles
|
||||
|
||||
# Frontend development
|
||||
frontend/node_modules
|
||||
|
||||
# Python
|
||||
tubearchivist/__pycache__/
|
||||
tubearchivist/*/__pycache__/
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
2
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
@@ -10,3 +10,5 @@ body:
|
||||
options:
|
||||
- label: I understand that this issue will be closed without comment.
|
||||
required: true
|
||||
- label: I will resist the temptation and I will not submit this issue. If I submit this, I understand I might get blocked from this repo.
|
||||
required: true
|
||||
|
||||
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
|
||||
|
||||
|
||||
69
README.md
69
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,7 +49,7 @@ 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 `http://tubearchivist.local:8000` | 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,13 +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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -14,7 +14,6 @@ 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):
|
||||
@@ -56,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", []),
|
||||
@@ -157,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)
|
||||
@@ -204,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()
|
||||
@@ -266,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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -94,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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -195,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
|
||||
@@ -239,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/
|
||||
@@ -294,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
|
||||
|
||||
# TA application settings
|
||||
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
||||
TA_VERSION = "v0.5.1"
|
||||
TA_VERSION = "v0.5.4"
|
||||
|
||||
# API
|
||||
REST_FRAMEWORK = {
|
||||
|
||||
@@ -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.2
|
||||
ipython==9.3.0
|
||||
pre-commit==4.2.0
|
||||
pylint-django==2.6.1
|
||||
pylint==3.3.6
|
||||
pytest-django==4.10.0
|
||||
pytest==8.3.5
|
||||
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
|
||||
requests==2.32.3
|
||||
Pillow==11.2.1
|
||||
redis==6.2.0
|
||||
requests==2.32.4
|
||||
ryd-client==0.0.6
|
||||
uvicorn==0.34.0
|
||||
uvicorn==0.34.3
|
||||
whitenoise==6.9.0
|
||||
yt-dlp[default]==2025.3.26
|
||||
yt-dlp[default]==2025.6.9
|
||||
|
||||
@@ -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]}"
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -31,7 +31,9 @@ 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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
1289
frontend/package-lock.json
generated
1289
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() },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,7 +1,21 @@
|
||||
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',
|
||||
@@ -13,10 +27,10 @@ export type UserConfigType = {
|
||||
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';
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ChannelNavResponseType = {
|
||||
has_shorts: boolean;
|
||||
has_playlists: boolean;
|
||||
has_pending: boolean;
|
||||
has_ignored: boolean;
|
||||
};
|
||||
|
||||
const loadChannelNav = async (youtubeChannelId: string) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -6,9 +6,8 @@ import formatDate from '../functions/formatDates';
|
||||
import Button from './Button';
|
||||
import deleteDownloadById from '../api/actions/deleteDownloadById';
|
||||
import updateDownloadQueueStatusById from '../api/actions/updateDownloadQueueStatusById';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import defaultVideoThumb from '/img/default-video-thumb.jpg';
|
||||
import VideoThumbnail from './VideoThumbail';
|
||||
|
||||
type DownloadListItemProps = {
|
||||
download: Download;
|
||||
@@ -17,23 +16,16 @@ type DownloadListItemProps = {
|
||||
|
||||
const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
const view = userConfig.view_style_downloads;
|
||||
const viewStyle = userConfig.view_style_downloads;
|
||||
const showIgnored = userConfig.show_ignored_only;
|
||||
|
||||
const [hideDownload, setHideDownload] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`video-item ${view}`} id={`dl-${download.youtube_id}`}>
|
||||
<div className={`video-thumb-wrap ${view}`}>
|
||||
<div className={`video-item ${viewStyle}`} id={`dl-${download.youtube_id}`}>
|
||||
<div className={`video-thumb-wrap ${viewStyle}`}>
|
||||
<div className="video-thumb">
|
||||
<img
|
||||
src={`${getApiUrl()}${download.vid_thumb_url}`}
|
||||
alt="video_thumb"
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultVideoThumb;
|
||||
}}
|
||||
/>
|
||||
<VideoThumbnail videoThumbUrl={download.vid_thumb_url} />
|
||||
|
||||
<div className="video-tags">
|
||||
{showIgnored && <span>ignored</span>}
|
||||
@@ -47,7 +39,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`video-desc ${view}`}>
|
||||
<div className={`video-desc ${viewStyle}`}>
|
||||
<div>
|
||||
{download.channel_indexed && (
|
||||
<Link to={Routes.Channel(download.channel_id)}>{download.channel_name}</Link>
|
||||
|
||||
@@ -121,7 +121,11 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
alt="close-icon"
|
||||
title="Close player"
|
||||
onClick={() => {
|
||||
setSearchParams({});
|
||||
setSearchParams(params => {
|
||||
const newParams = new URLSearchParams(params);
|
||||
newParams.delete('videoId');
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import iconSort from '/img/icon-sort.svg';
|
||||
import iconAdd from '/img/icon-add.svg';
|
||||
import iconSubstract from '/img/icon-substract.svg';
|
||||
import iconGridView from '/img/icon-gridview.svg';
|
||||
import iconListView from '/img/icon-listview.svg';
|
||||
import { SortByType, SortOrderType } from '../pages/Home';
|
||||
import iconTableView from '/img/icon-tableview.svg';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import { ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import { ViewStyleNamesType, ViewStylesEnum } from '../configuration/constants/ViewStyle';
|
||||
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
|
||||
import {
|
||||
SortByEnum,
|
||||
SortByType,
|
||||
SortOrderEnum,
|
||||
SortOrderType,
|
||||
} from '../api/loader/loadVideoListByPage';
|
||||
|
||||
type FilterbarProps = {
|
||||
hideToggleText: string;
|
||||
viewStyleName: string;
|
||||
viewStyle: ViewStyleNamesType;
|
||||
showSort?: boolean;
|
||||
};
|
||||
|
||||
const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: FilterbarProps) => {
|
||||
const Filterbar = ({ hideToggleText, viewStyle, showSort = true }: FilterbarProps) => {
|
||||
const { userConfig, setUserConfig } = useUserConfigStore();
|
||||
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const isGridView = userConfig.view_style_home === ViewStyles.grid;
|
||||
|
||||
const currentViewStyle = userConfig[viewStyle];
|
||||
const isGridView = currentViewStyle === ViewStylesEnum.Grid;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSort) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentViewStyle === ViewStylesEnum.Table) {
|
||||
setShowHidden(true);
|
||||
} else {
|
||||
setShowHidden(false);
|
||||
}
|
||||
}, [currentViewStyle, showSort]);
|
||||
|
||||
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
|
||||
const updatedUserConfig = await updateUserConfig(config);
|
||||
@@ -55,7 +76,7 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHidden && showSort && (
|
||||
{showHidden && (
|
||||
<div className="sort">
|
||||
<div id="form">
|
||||
<span>Sort by:</span>
|
||||
@@ -67,12 +88,9 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
|
||||
handleUserConfigUpdate({ sort_by: event.target.value as SortByType });
|
||||
}}
|
||||
>
|
||||
<option value="published">date published</option>
|
||||
<option value="downloaded">date downloaded</option>
|
||||
<option value="views">views</option>
|
||||
<option value="likes">likes</option>
|
||||
<option value="duration">duration</option>
|
||||
<option value="mediasize">media size</option>
|
||||
{Object.entries(SortByEnum).map(([key, value]) => {
|
||||
return <option value={value}>{key}</option>;
|
||||
})}
|
||||
</select>
|
||||
<select
|
||||
name="sort_order"
|
||||
@@ -82,19 +100,20 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
|
||||
handleUserConfigUpdate({ sort_order: event.target.value as SortOrderType });
|
||||
}}
|
||||
>
|
||||
<option value="asc">asc</option>
|
||||
<option value="desc">desc</option>
|
||||
{Object.entries(SortOrderEnum).map(([key, value]) => {
|
||||
return <option value={value}>{key}</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="view-icons">
|
||||
{setShowHidden && showSort && (
|
||||
{showSort && (
|
||||
<img
|
||||
src={iconSort}
|
||||
alt="sort-icon"
|
||||
onClick={() => {
|
||||
setShowHidden?.(!showHidden);
|
||||
setShowHidden(!showHidden);
|
||||
}}
|
||||
id="animate-icon"
|
||||
/>
|
||||
@@ -125,17 +144,24 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ [viewStyleName]: 'grid' });
|
||||
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
|
||||
}}
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ [viewStyleName]: 'list' });
|
||||
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
|
||||
}}
|
||||
alt="list view"
|
||||
/>
|
||||
<img
|
||||
src={iconTableView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
|
||||
}}
|
||||
alt="table view"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ const Notifications = ({
|
||||
}
|
||||
|
||||
setNotificationResponse(notifications);
|
||||
}, 500);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Fragment } from 'react/jsx-runtime';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
@@ -23,6 +23,9 @@ interface Props {
|
||||
const Pagination = ({ pagination, setPage }: Props) => {
|
||||
const { total_hits, params, prev_pages, current_page, next_pages, last_page, max_hits } =
|
||||
pagination;
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
const totalHits = Number(total_hits);
|
||||
const currentPage = Number(current_page);
|
||||
@@ -39,7 +42,7 @@ const Pagination = ({ pagination, setPage }: Props) => {
|
||||
(event: KeyboardEvent) => {
|
||||
const { code } = event;
|
||||
|
||||
if (code === 'ArrowRight') {
|
||||
if (code === 'ArrowRight' && videoId === null) {
|
||||
if (currentPage === 0 && totalHits > 1) {
|
||||
setPage(2);
|
||||
return;
|
||||
@@ -52,7 +55,7 @@ const Pagination = ({ pagination, setPage }: Props) => {
|
||||
setPage(currentPage + 1);
|
||||
}
|
||||
|
||||
if (code === 'ArrowLeft') {
|
||||
if (code === 'ArrowLeft' && videoId === null) {
|
||||
if (currentPage === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +68,7 @@ const Pagination = ({ pagination, setPage }: Props) => {
|
||||
setPage(currentPage - 1);
|
||||
}
|
||||
},
|
||||
[currentPage, lastPage, setPage, totalHits],
|
||||
[currentPage, lastPage, setPage, totalHits, videoId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ type PlaylistListProps = {
|
||||
|
||||
const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
const viewLayout = userConfig.view_style_playlist;
|
||||
const viewStyle = userConfig.view_style_playlist;
|
||||
|
||||
if (!playlistList || playlistList.length === 0) {
|
||||
return <p>No playlists found.</p>;
|
||||
@@ -24,7 +24,7 @@ const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => {
|
||||
<>
|
||||
{playlistList.map((playlist: PlaylistType) => {
|
||||
return (
|
||||
<div key={playlist.playlist_id} className={`playlist-item ${viewLayout}`}>
|
||||
<div key={playlist.playlist_id} className={`playlist-item ${viewStyle}`}>
|
||||
<div className="playlist-thumbnail">
|
||||
<Link to={Routes.Playlist(playlist.playlist_id)}>
|
||||
<PlaylistThumbnail
|
||||
@@ -33,7 +33,7 @@ const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => {
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`playlist-desc ${viewLayout}`}>
|
||||
<div className={`playlist-desc ${viewStyle}`}>
|
||||
{playlist.playlist_type != 'custom' && (
|
||||
<Link to={Routes.Channel(playlist.playlist_channel_id)}>
|
||||
<h3>{playlist.playlist_channel}</h3>
|
||||
|
||||
@@ -7,9 +7,15 @@ type PlaylistThumbnailProps = {
|
||||
};
|
||||
|
||||
const PlaylistThumbnail = ({ playlistId, playlistThumbnail }: PlaylistThumbnailProps) => {
|
||||
let src = `${getApiUrl()}${playlistThumbnail}`;
|
||||
|
||||
if (playlistThumbnail === undefined) {
|
||||
src = defaultPlaylistThumbnail;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${getApiUrl()}${playlistThumbnail}`}
|
||||
src={src}
|
||||
alt={`${playlistId}-thumbnail`}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link, useSearchParams } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import iconPlay from '/img/icon-play.svg';
|
||||
import Linkify from './Linkify';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import VideoThumbnail from './VideoThumbail';
|
||||
|
||||
type SubtitleListType = {
|
||||
subtitle_index: number;
|
||||
@@ -44,15 +44,17 @@ const SubtitleList = ({ subtitleList }: SubtitleListProps) => {
|
||||
<div className="video-item list">
|
||||
<a
|
||||
onClick={() => {
|
||||
setSearchParams({
|
||||
videoId: subtitle.youtube_id,
|
||||
t: stripNanoSecs(subtitle.subtitle_start) || '00:00:00',
|
||||
setSearchParams(params => {
|
||||
params.set('videoId', subtitle.youtube_id);
|
||||
params.set('t', stripNanoSecs(subtitle.subtitle_start) || '00:00:00');
|
||||
|
||||
return params;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="video-thumb-wrap list">
|
||||
<div className="video-thumb">
|
||||
<img src={`${getApiUrl()}${subtitle.vid_thumb_url}`} alt="video-thumb" />
|
||||
<VideoThumbnail videoThumbUrl={subtitle.vid_thumb_url} />
|
||||
</div>
|
||||
<div className="video-play">
|
||||
<img src={iconPlay} alt="play-icon" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { VideoType, ViewLayoutType } from '../pages/Home';
|
||||
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
import { VideoType } from '../pages/Home';
|
||||
import VideoListItem from './VideoListItem';
|
||||
import VideoListItemTable from './VideoListItemTable';
|
||||
|
||||
type VideoListProps = {
|
||||
videoList: VideoType[] | undefined;
|
||||
viewLayout: ViewLayoutType;
|
||||
viewStyle: ViewStylesType;
|
||||
playlistId?: string;
|
||||
showReorderButton?: boolean;
|
||||
refreshVideoList: (refresh: boolean) => void;
|
||||
@@ -11,7 +13,7 @@ type VideoListProps = {
|
||||
|
||||
const VideoList = ({
|
||||
videoList,
|
||||
viewLayout,
|
||||
viewStyle,
|
||||
playlistId,
|
||||
showReorderButton = false,
|
||||
refreshVideoList,
|
||||
@@ -20,6 +22,10 @@ const VideoList = ({
|
||||
return <p>No videos found.</p>;
|
||||
}
|
||||
|
||||
if (viewStyle === ViewStylesEnum.Table) {
|
||||
return <VideoListItemTable videoList={videoList} viewStyle={viewStyle} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{videoList.map(video => {
|
||||
@@ -27,7 +33,7 @@ const VideoList = ({
|
||||
<VideoListItem
|
||||
key={video.youtube_id}
|
||||
video={video}
|
||||
viewLayout={viewLayout}
|
||||
viewStyle={viewStyle}
|
||||
playlistId={playlistId}
|
||||
showReorderButton={showReorderButton}
|
||||
refreshVideoList={refreshVideoList}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { VideoType, ViewLayoutType } from '../pages/Home';
|
||||
import { VideoType } from '../pages/Home';
|
||||
import iconPlay from '/img/icon-play.svg';
|
||||
import iconDotMenu from '/img/icon-dot-menu.svg';
|
||||
import iconClose from '/img/icon-close.svg';
|
||||
import defaultVideoThumb from '/img/default-video-thumb.jpg';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import WatchedCheckBox from './WatchedCheckBox';
|
||||
import MoveVideoMenu from './MoveVideoMenu';
|
||||
import { useState } from 'react';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import deleteVideoProgressById from '../api/actions/deleteVideoProgressById';
|
||||
import VideoThumbnail from './VideoThumbail';
|
||||
import { ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
|
||||
type VideoListItemProps = {
|
||||
video: VideoType;
|
||||
viewLayout: ViewLayoutType;
|
||||
viewStyle: ViewStylesType;
|
||||
playlistId?: string;
|
||||
showReorderButton?: boolean;
|
||||
refreshVideoList: (refresh: boolean) => void;
|
||||
@@ -23,7 +23,7 @@ type VideoListItemProps = {
|
||||
|
||||
const VideoListItem = ({
|
||||
video,
|
||||
viewLayout,
|
||||
viewStyle,
|
||||
playlistId,
|
||||
showReorderButton = false,
|
||||
refreshVideoList,
|
||||
@@ -37,22 +37,19 @@ const VideoListItem = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`video-item ${viewLayout}`}>
|
||||
<div className={`video-item ${viewStyle}`}>
|
||||
<a
|
||||
onClick={() => {
|
||||
setSearchParams({ videoId: video.youtube_id });
|
||||
setSearchParams(params => {
|
||||
const newParams = new URLSearchParams(params);
|
||||
newParams.set('videoId', video.youtube_id);
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className={`video-thumb-wrap ${viewLayout}`}>
|
||||
<div className={`video-thumb-wrap ${viewStyle}`}>
|
||||
<div className="video-thumb">
|
||||
<img
|
||||
src={`${getApiUrl()}${video.vid_thumb_url}`}
|
||||
alt="video-thumb"
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultVideoThumb;
|
||||
}}
|
||||
/>
|
||||
<VideoThumbnail videoThumbUrl={video.vid_thumb_url} />
|
||||
|
||||
{video.player.progress && (
|
||||
<div
|
||||
@@ -76,7 +73,7 @@ const VideoListItem = ({
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div className={`video-desc ${viewLayout}`}>
|
||||
<div className={`video-desc ${viewStyle}`}>
|
||||
<div className="video-desc-player" id={`video-info-${video.youtube_id}`}>
|
||||
<WatchedCheckBox
|
||||
watched={video.player.watched}
|
||||
|
||||
64
frontend/src/components/VideoListItemTable.tsx
Normal file
64
frontend/src/components/VideoListItemTable.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { VideoType } from '../pages/Home';
|
||||
import { ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
import humanFileSize from '../functions/humanFileSize';
|
||||
import { FileSizeUnits } from '../api/actions/updateUserConfig';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
|
||||
type VideoListItemProps = {
|
||||
videoList: VideoType[] | undefined;
|
||||
viewStyle: ViewStylesType;
|
||||
};
|
||||
|
||||
const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
|
||||
const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
|
||||
|
||||
return (
|
||||
<div className={`video-item ${viewStyle}`}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Resolution</th>
|
||||
<th>Media size</th>
|
||||
<th>Video codec</th>
|
||||
<th>Video bitrate</th>
|
||||
<th>Audio codec</th>
|
||||
<th>Audio bitrate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{videoList?.map(({ youtube_id, title, channel, vid_type, media_size, streams }) => {
|
||||
const [videoStream, audioStream] = streams;
|
||||
|
||||
return (
|
||||
<tr key={youtube_id}>
|
||||
<td className="no-nowrap">
|
||||
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
|
||||
</td>
|
||||
<td className="no-nowrap title">
|
||||
<Link to={Routes.Video(youtube_id)}>{title}</Link>
|
||||
</td>
|
||||
<td>{vid_type}</td>
|
||||
<td>{`${videoStream.width}x${videoStream.height}`}</td>
|
||||
<td>{humanFileSize(media_size, useSiUnits)}</td>
|
||||
<td>{videoStream.codec}</td>
|
||||
<td>{humanFileSize(videoStream.bitrate, useSiUnits)}</td>
|
||||
<td>{audioStream.codec}</td>
|
||||
<td>{humanFileSize(audioStream.bitrate, useSiUnits)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoListItemTable;
|
||||
@@ -150,6 +150,7 @@ const VideoPlayer = ({
|
||||
const resetPlaybackSpeedPressed = useKeyPress('=');
|
||||
const arrowRightPressed = useKeyPress('ArrowRight');
|
||||
const arrowLeftPressed = useKeyPress('ArrowLeft');
|
||||
const pPausedPressed = useKeyPress('p');
|
||||
|
||||
const videoId = video.youtube_id;
|
||||
const videoUrl = video.media_url;
|
||||
@@ -214,6 +215,16 @@ const VideoPlayer = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mutePressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pPausedPressed) {
|
||||
if (videoRef.current?.paused) {
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current?.pause();
|
||||
}
|
||||
}
|
||||
}, [pPausedPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (increasePlaybackSpeedPressed) {
|
||||
const newSpeed = playbackSpeedIndex + 1;
|
||||
@@ -301,16 +312,25 @@ const VideoPlayer = ({
|
||||
}, [subtitlesPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (arrowLeftPressed) {
|
||||
infoDialog('- 5 seconds');
|
||||
}
|
||||
}, [arrowLeftPressed]);
|
||||
if (arrowLeftPressed || arrowRightPressed) {
|
||||
let timeStep = 5;
|
||||
|
||||
useEffect(() => {
|
||||
if (arrowRightPressed) {
|
||||
infoDialog('+ 5 seconds');
|
||||
if (arrowLeftPressed) {
|
||||
infoDialog('- 5 seconds');
|
||||
timeStep *= -1;
|
||||
}
|
||||
|
||||
if (arrowRightPressed) {
|
||||
infoDialog('+ 5 seconds');
|
||||
}
|
||||
|
||||
const currentCurrentTime = videoRef.current?.currentTime;
|
||||
|
||||
if (currentCurrentTime !== undefined && videoRef.current) {
|
||||
videoRef.current.currentTime = currentCurrentTime + timeStep;
|
||||
}
|
||||
}
|
||||
}, [arrowRightPressed]);
|
||||
}, [arrowLeftPressed, arrowRightPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (questionmarkPressed) {
|
||||
@@ -361,6 +381,11 @@ const VideoPlayer = ({
|
||||
});
|
||||
}}
|
||||
onEnded={handleVideoEnd(videoId, watched)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
autoPlay={autoplay}
|
||||
controls
|
||||
width="100%"
|
||||
@@ -386,6 +411,10 @@ const VideoPlayer = ({
|
||||
<td>Show help</td>
|
||||
<td>?</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Toggle pause play</td>
|
||||
<td>p</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Toggle mute</td>
|
||||
<td>m</td>
|
||||
|
||||
27
frontend/src/components/VideoThumbail.tsx
Normal file
27
frontend/src/components/VideoThumbail.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import defaultVideoThumb from '/img/default-video-thumb.jpg';
|
||||
|
||||
type VideoThumbailProps = {
|
||||
videoThumbUrl: string | undefined;
|
||||
};
|
||||
|
||||
const VideoThumbnail = ({ videoThumbUrl }: VideoThumbailProps) => {
|
||||
let src = `${getApiUrl()}${videoThumbUrl}`;
|
||||
|
||||
if (videoThumbUrl === undefined) {
|
||||
src = defaultVideoThumb;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="video_thumb"
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultVideoThumb;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoThumbnail;
|
||||
60
frontend/src/configuration/colours/Colours.tsx
Normal file
60
frontend/src/configuration/colours/Colours.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ColourConstant, ColourVariants } from '../../api/actions/updateUserConfig';
|
||||
import { useUserConfigStore } from '../../stores/UserConfigStore';
|
||||
import CustomStylesheet from './components/Custom';
|
||||
import DarkStylesheet from './components/Dark';
|
||||
import LightStylesheet from './components/Light';
|
||||
import MatrixStylesheet from './components/Matrix';
|
||||
import MidnightStylesheet from './components/Midnight';
|
||||
|
||||
function getThemeFromLocalStorage(stylesheet: string): ColourVariants {
|
||||
// Check when localStorage when its possibly the default theme. ( e.g. login page )
|
||||
if (stylesheet === ColourConstant.Dark) {
|
||||
const fromLocalStorage = localStorage.getItem('stylesheet');
|
||||
|
||||
if (!fromLocalStorage) {
|
||||
localStorage.setItem('stylesheet', stylesheet);
|
||||
}
|
||||
|
||||
if (fromLocalStorage) {
|
||||
stylesheet = fromLocalStorage;
|
||||
}
|
||||
} else {
|
||||
const fromLocalStorage = localStorage.getItem('stylesheet');
|
||||
|
||||
// Re-sync when localStorage is not the same as in userConfig
|
||||
if (stylesheet !== fromLocalStorage) {
|
||||
localStorage.setItem('stylesheet', stylesheet);
|
||||
}
|
||||
}
|
||||
|
||||
return stylesheet as ColourVariants;
|
||||
}
|
||||
|
||||
const Colours = () => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
let stylesheet = userConfig?.stylesheet;
|
||||
|
||||
stylesheet = getThemeFromLocalStorage(stylesheet);
|
||||
|
||||
switch (stylesheet) {
|
||||
case ColourConstant.Dark:
|
||||
return <DarkStylesheet />;
|
||||
|
||||
case ColourConstant.Matrix:
|
||||
return <MatrixStylesheet />;
|
||||
|
||||
case ColourConstant.Midnight:
|
||||
return <MidnightStylesheet />;
|
||||
|
||||
case ColourConstant.Light:
|
||||
return <LightStylesheet />;
|
||||
|
||||
case ColourConstant.Custom:
|
||||
return <CustomStylesheet />;
|
||||
|
||||
default:
|
||||
return <DarkStylesheet />;
|
||||
}
|
||||
};
|
||||
|
||||
export default Colours;
|
||||
9
frontend/src/configuration/colours/components/Custom.tsx
Normal file
9
frontend/src/configuration/colours/components/Custom.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
const CustomStylesheet = () => {
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/custom.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomStylesheet;
|
||||
@@ -1,7 +1,9 @@
|
||||
import './css/dark.css';
|
||||
|
||||
const DarkStylesheet = () => {
|
||||
return <></>;
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/dark.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default DarkStylesheet;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import './css/light.css';
|
||||
|
||||
const LightStylesheet = () => {
|
||||
return <></>;
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/light.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default LightStylesheet;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import './css/matrix.css';
|
||||
|
||||
const MatrixStylesheet = () => {
|
||||
return <></>;
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/matrix.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatrixStylesheet;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import './css/midnight.css';
|
||||
|
||||
const MidnightStylesheet = () => {
|
||||
return <></>;
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/midnight.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default MidnightStylesheet;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useUserConfigStore } from '../../stores/UserConfigStore';
|
||||
|
||||
export const ColourConstant = {
|
||||
Dark: 'dark.css',
|
||||
Light: 'light.css',
|
||||
Matrix: 'matrix.css',
|
||||
Midnight: 'midnight.css',
|
||||
};
|
||||
|
||||
const useColours = () => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
const stylesheet = userConfig?.stylesheet;
|
||||
|
||||
switch (stylesheet) {
|
||||
case ColourConstant.Dark:
|
||||
return import('./components/Dark');
|
||||
|
||||
case ColourConstant.Matrix:
|
||||
return import('./components/Matrix');
|
||||
|
||||
case ColourConstant.Midnight:
|
||||
return import('./components/Midnight');
|
||||
|
||||
case ColourConstant.Light:
|
||||
return import('./components/Light');
|
||||
|
||||
default:
|
||||
return import('./components/Dark');
|
||||
}
|
||||
};
|
||||
|
||||
export default useColours;
|
||||
@@ -1,11 +1,18 @@
|
||||
export type ViewStyleNamesType =
|
||||
| 'view_style_home'
|
||||
| 'view_style_channel'
|
||||
| 'view_style_downloads'
|
||||
| 'view_style_playlist';
|
||||
export const ViewStyleNames = {
|
||||
home: 'view_style_home',
|
||||
channel: 'view_style_channel',
|
||||
downloads: 'view_style_downloads',
|
||||
playlist: 'view_style_playlist',
|
||||
Home: 'view_style_home',
|
||||
Channel: 'view_style_channel',
|
||||
Downloads: 'view_style_downloads',
|
||||
Playlist: 'view_style_playlist',
|
||||
};
|
||||
|
||||
export const ViewStyles = {
|
||||
grid: 'grid',
|
||||
list: 'list',
|
||||
export type ViewStylesType = 'grid' | 'list' | 'table';
|
||||
export const ViewStylesEnum = {
|
||||
Grid: 'grid',
|
||||
List: 'list',
|
||||
Table: 'table',
|
||||
};
|
||||
|
||||
@@ -10,7 +10,8 @@ const Routes = {
|
||||
Playlists: '/playlist/',
|
||||
Playlist: (id: string) => `/playlist/${id}`,
|
||||
Downloads: '/downloads/',
|
||||
DownloadsByChannelId: (channelId: string) => `/downloads/?channel=${channelId}`,
|
||||
DownloadsByChannelId: (channelId: string) => `/downloads/?channel=${channelId}&ignored=false`,
|
||||
IgnoredByChannelId: (channelId: string) => `/downloads/?channel=${channelId}&ignored=true`,
|
||||
Search: '/search/',
|
||||
SettingsDashboard: '/settings/',
|
||||
SettingsUser: '/settings/user/',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Outlet, useLoaderData, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import Footer from '../components/Footer';
|
||||
import useColours from '../configuration/colours/useColours';
|
||||
import Colours from '../configuration/colours/Colours';
|
||||
import { UserConfigType } from '../api/actions/updateUserConfig';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Navigation from '../components/Navigation';
|
||||
@@ -36,6 +36,7 @@ export type OutletContextType = {
|
||||
};
|
||||
|
||||
const Base = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { setAuth } = useAuthStore();
|
||||
const { setUserConfig } = useUserConfigStore();
|
||||
const { setUserAccount } = useUserAccountStore();
|
||||
@@ -45,12 +46,9 @@ const Base = () => {
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
const currentPageFromUrl = Number(searchParams.get('page'));
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(currentPageFromUrl);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
setAuth(auth);
|
||||
@@ -82,7 +80,11 @@ const Base = () => {
|
||||
useEffect(() => {
|
||||
if (currentPageFromUrl !== currentPage) {
|
||||
setSearchParams(params => {
|
||||
params.set('page', currentPage.toString());
|
||||
if (currentPage == 0) {
|
||||
params.delete('page');
|
||||
} else {
|
||||
params.set('page', currentPage.toString());
|
||||
}
|
||||
|
||||
return params;
|
||||
});
|
||||
@@ -92,10 +94,9 @@ const Base = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
useColours();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Colours />
|
||||
<div className="main-content">
|
||||
<Navigation />
|
||||
{/** Outlet: https://reactrouter.com/en/main/components/outlet */}
|
||||
|
||||
@@ -27,7 +27,7 @@ const ChannelBase = () => {
|
||||
const { data: channelNavData } = channelNav ?? {};
|
||||
|
||||
const channel = channelResponseData;
|
||||
const { has_streams, has_shorts, has_playlists, has_pending } = channelNavData || {};
|
||||
const { has_streams, has_shorts, has_playlists, has_pending, has_ignored } = channelNavData || {};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -84,6 +84,11 @@ const ChannelBase = () => {
|
||||
<h3>Downloads</h3>
|
||||
</Link>
|
||||
)}
|
||||
{has_ignored && isAdmin && (
|
||||
<Link to={Routes.IgnoredByChannelId(channelId)}>
|
||||
<h3>Ignored</h3>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Notifications
|
||||
|
||||
@@ -11,6 +11,7 @@ import iconListView from '/img/icon-listview.svg';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
|
||||
import { ApiResponseType } from '../functions/APIClient';
|
||||
import { ViewStylesType, ViewStylesEnum } from '../configuration/constants/ViewStyle';
|
||||
|
||||
const ChannelPlaylist = () => {
|
||||
const { channelId } = useParams();
|
||||
@@ -27,7 +28,7 @@ const ChannelPlaylist = () => {
|
||||
const playlistList = playlistsResponseData?.data;
|
||||
const pagination = playlistsResponseData?.paginate;
|
||||
|
||||
const view = userConfig.view_style_playlist;
|
||||
const viewStyle = userConfig.view_style_playlist;
|
||||
const showSubedOnly = userConfig.show_subed_only;
|
||||
|
||||
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
|
||||
@@ -87,14 +88,18 @@ const ChannelPlaylist = () => {
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ view_style_playlist: 'grid' });
|
||||
handleUserConfigUpdate({
|
||||
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
|
||||
});
|
||||
}}
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ view_style_playlist: 'list' });
|
||||
handleUserConfigUpdate({
|
||||
view_style_playlist: ViewStylesEnum.List as ViewStylesType,
|
||||
});
|
||||
}}
|
||||
alt="list view"
|
||||
/>
|
||||
@@ -103,7 +108,7 @@ const ChannelPlaylist = () => {
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content`}>
|
||||
<div className={`playlist-list ${view}`}>
|
||||
<div className={`playlist-list ${viewStyle}`}>
|
||||
<PlaylistList playlistList={playlistList} setRefresh={setRefreshPlaylists} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,11 @@ import VideoList from '../components/VideoList';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import Pagination from '../components/Pagination';
|
||||
import Filterbar from '../components/Filterbar';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import {
|
||||
ViewStyleNames,
|
||||
ViewStyleNamesType,
|
||||
ViewStylesEnum,
|
||||
} from '../configuration/constants/ViewStyle';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import loadChannelById, { ChannelResponseType } from '../api/loader/loadChannelById';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
@@ -15,6 +19,8 @@ import Button from '../components/Button';
|
||||
import loadVideoListByFilter, {
|
||||
VideoListByFilterResponseType,
|
||||
VideoTypes,
|
||||
WatchTypes,
|
||||
WatchTypesEnum,
|
||||
} from '../api/loader/loadVideoListByPage';
|
||||
import loadChannelAggs, { ChannelAggsType } from '../api/loader/loadChannelAggs';
|
||||
import humanFileSize from '../functions/humanFileSize';
|
||||
@@ -56,8 +62,8 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
||||
const hasVideos = videoResponseData?.data?.length !== 0;
|
||||
const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
|
||||
|
||||
const view = userConfig.view_style_home;
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const viewStyle = userConfig.view_style_home;
|
||||
const isGridView = viewStyle === ViewStylesEnum.Grid;
|
||||
const gridView = isGridView ? `boxed-${userConfig.grid_items}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : '';
|
||||
|
||||
@@ -67,7 +73,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
||||
const videos = await loadVideoListByFilter({
|
||||
channel: channelId,
|
||||
page: currentPage,
|
||||
watch: userConfig.hide_watched ? 'unwatched' : undefined,
|
||||
watch: userConfig.hide_watched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined,
|
||||
sort: userConfig.sort_by,
|
||||
order: userConfig.sort_order,
|
||||
type: videoType,
|
||||
@@ -160,13 +166,16 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<Filterbar hideToggleText={'Hide watched videos:'} viewStyleName={ViewStyleNames.home} />
|
||||
<Filterbar
|
||||
hideToggleText={'Hide watched videos:'}
|
||||
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmbeddableVideoPlayer videoId={videoId} />
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
|
||||
{!hasVideos && (
|
||||
<>
|
||||
<h2>No videos found...</h2>
|
||||
@@ -177,7 +186,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
|
||||
<VideoList videoList={videoList} viewStyle={viewStyle} refreshVideoList={setRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
{pagination && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import useIsAdmin from '../functions/useIsAdmin';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
|
||||
import { ApiResponseType } from '../functions/APIClient';
|
||||
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
|
||||
type ChannelOverwritesType = {
|
||||
download_format: string | null;
|
||||
@@ -119,10 +120,12 @@ const Channels = () => {
|
||||
label="Subscribe"
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
await updateBulkChannelSubscriptions(channelsToSubscribeTo, true);
|
||||
if (channelsToSubscribeTo.trim()) {
|
||||
await updateBulkChannelSubscriptions(channelsToSubscribeTo, true);
|
||||
|
||||
setShowNotification(true);
|
||||
setShowAddForm(false);
|
||||
setShowNotification(true);
|
||||
setShowAddForm(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -170,16 +173,19 @@ const Channels = () => {
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ view_style_channel: 'grid' });
|
||||
handleUserConfigUpdate({
|
||||
view_style_channel: ViewStylesEnum.Grid as ViewStylesType,
|
||||
});
|
||||
}}
|
||||
data-origin="channel"
|
||||
data-value="grid"
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ view_style_channel: 'list' });
|
||||
handleUserConfigUpdate({
|
||||
view_style_channel: ViewStylesEnum.List as ViewStylesType,
|
||||
});
|
||||
}}
|
||||
data-origin="channel"
|
||||
data-value="list"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ConfigType } from './Home';
|
||||
import loadDownloadQueue from '../api/loader/loadDownloadQueue';
|
||||
import { OutletContextType } from './Base';
|
||||
import Pagination, { PaginationType } from '../components/Pagination';
|
||||
import { ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
|
||||
import updateTaskByName from '../api/actions/updateTaskByName';
|
||||
import Notifications from '../components/Notifications';
|
||||
@@ -52,6 +52,7 @@ const Download = () => {
|
||||
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const channelFilterFromUrl = searchParams.get('channel');
|
||||
const ignoredOnlyParam = searchParams.get('ignored');
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [showHiddenForm, setShowHiddenForm] = useState(false);
|
||||
@@ -80,10 +81,11 @@ const Download = () => {
|
||||
? downloadResponseData?.data[0].channel_name
|
||||
: '';
|
||||
|
||||
const view = userConfig.view_style_downloads;
|
||||
const viewStyle = userConfig.view_style_downloads;
|
||||
const gridItems = userConfig.grid_items;
|
||||
const showIgnored = userConfig.show_ignored_only;
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const showIgnored =
|
||||
ignoredOnlyParam !== null ? ignoredOnlyParam === 'true' : userConfig.show_ignored_only;
|
||||
const isGridView = viewStyle === ViewStylesEnum.Grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
@@ -98,24 +100,30 @@ const Download = () => {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const videosResponse = await loadDownloadQueue(
|
||||
currentPage,
|
||||
channelFilterFromUrl,
|
||||
showIgnored,
|
||||
);
|
||||
const { data: channelResponseData } = videosResponse ?? {};
|
||||
const videoCount = channelResponseData?.paginate?.total_hits;
|
||||
if (refresh) {
|
||||
const videosResponse = await loadDownloadQueue(
|
||||
currentPage,
|
||||
channelFilterFromUrl,
|
||||
showIgnored,
|
||||
);
|
||||
const { data: channelResponseData } = videosResponse ?? {};
|
||||
const videoCount = channelResponseData?.paginate?.total_hits;
|
||||
|
||||
if (videoCount && lastVideoCount !== videoCount) {
|
||||
setLastVideoCount(videoCount);
|
||||
if (videoCount && lastVideoCount !== videoCount) {
|
||||
setLastVideoCount(videoCount);
|
||||
}
|
||||
|
||||
setDownloadResponse(videosResponse);
|
||||
setRefresh(false);
|
||||
}
|
||||
|
||||
setDownloadResponse(videosResponse);
|
||||
setRefresh(false);
|
||||
})();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refresh, showIgnored, currentPage, downloadPending]);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
setRefresh(true);
|
||||
}, [channelFilterFromUrl, currentPage, showIgnored]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -125,10 +133,6 @@ const Download = () => {
|
||||
})();
|
||||
}, [lastVideoCount, showIgnored]);
|
||||
|
||||
useEffect(() => {
|
||||
setRefresh(true);
|
||||
}, [channelFilterFromUrl]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Downloads</title>
|
||||
@@ -200,19 +204,23 @@ const Download = () => {
|
||||
<Button
|
||||
label="Add to queue"
|
||||
onClick={async () => {
|
||||
await updateDownloadQueue(downloadQueueText, false);
|
||||
setDownloadQueueText('');
|
||||
setRefresh(true);
|
||||
setShowHiddenForm(false);
|
||||
if (downloadQueueText.trim()) {
|
||||
await updateDownloadQueue(downloadQueueText, false);
|
||||
setDownloadQueueText('');
|
||||
setRefresh(true);
|
||||
setShowHiddenForm(false);
|
||||
}
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Download now"
|
||||
onClick={async () => {
|
||||
await updateDownloadQueue(downloadQueueText, true);
|
||||
setDownloadQueueText('');
|
||||
setRefresh(true);
|
||||
setShowHiddenForm(false);
|
||||
if (downloadQueueText.trim()) {
|
||||
await updateDownloadQueue(downloadQueueText, true);
|
||||
setDownloadQueueText('');
|
||||
setRefresh(true);
|
||||
setShowHiddenForm(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -228,6 +236,9 @@ const Download = () => {
|
||||
id="showIgnored"
|
||||
onChange={() => {
|
||||
handleUserConfigUpdate({ show_ignored_only: !showIgnored });
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
newParams.set('ignored', String(!showIgnored));
|
||||
setSearchParams(newParams);
|
||||
setRefresh(true);
|
||||
}}
|
||||
type="checkbox"
|
||||
@@ -254,9 +265,11 @@ const Download = () => {
|
||||
onChange={async event => {
|
||||
const value = event.currentTarget.value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const params = searchParams;
|
||||
if (value !== 'all') {
|
||||
params.append('channel', value);
|
||||
params.set('channel', value);
|
||||
} else {
|
||||
params.delete('channel');
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
@@ -302,14 +315,18 @@ const Download = () => {
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ view_style_downloads: 'grid' });
|
||||
handleUserConfigUpdate({
|
||||
view_style_downloads: ViewStylesEnum.Grid as ViewStylesType,
|
||||
});
|
||||
}}
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ view_style_downloads: 'list' });
|
||||
handleUserConfigUpdate({
|
||||
view_style_downloads: ViewStylesEnum.List as ViewStylesType,
|
||||
});
|
||||
}}
|
||||
alt="list view"
|
||||
/>
|
||||
@@ -328,7 +345,7 @@ const Download = () => {
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
|
||||
{downloadList &&
|
||||
downloadList?.map(download => {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import useColours from '../configuration/colours/useColours';
|
||||
import Colours from '../configuration/colours/Colours';
|
||||
|
||||
// This is not always the correct response
|
||||
type ErrorType = {
|
||||
@@ -9,13 +9,13 @@ type ErrorType = {
|
||||
|
||||
const ErrorPage = () => {
|
||||
const error = useRouteError() as ErrorType;
|
||||
useColours();
|
||||
|
||||
console.error('ErrorPage', error);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Oops!</title>
|
||||
<Colours />
|
||||
|
||||
<div id="error-page" style={{ margin: '10%' }}>
|
||||
<h1>Oops!</h1>
|
||||
|
||||
@@ -4,12 +4,18 @@ import Routes from '../configuration/routes/RouteList';
|
||||
import Pagination from '../components/Pagination';
|
||||
import loadVideoListByFilter, {
|
||||
VideoListByFilterResponseType,
|
||||
WatchTypes,
|
||||
WatchTypesEnum,
|
||||
} from '../api/loader/loadVideoListByPage';
|
||||
import VideoList from '../components/VideoList';
|
||||
import { ChannelType } from './Channels';
|
||||
import { OutletContextType } from './Base';
|
||||
import Filterbar from '../components/Filterbar';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import {
|
||||
ViewStyleNames,
|
||||
ViewStyleNamesType,
|
||||
ViewStylesEnum,
|
||||
} from '../configuration/constants/ViewStyle';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import { SponsorBlockType } from './Video';
|
||||
@@ -99,10 +105,6 @@ export type ConfigType = {
|
||||
downloads: DownloadsType;
|
||||
};
|
||||
|
||||
export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'mediasize';
|
||||
export type SortOrderType = 'asc' | 'desc';
|
||||
export type ViewLayoutType = 'grid' | 'list';
|
||||
|
||||
const Home = () => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
@@ -125,15 +127,16 @@ const Home = () => {
|
||||
|
||||
const hasVideos = videoResponseData?.data?.length !== 0;
|
||||
|
||||
const isGridView = userConfig.view_style_home === ViewStyles.grid;
|
||||
const isGridView = userConfig.view_style_home === ViewStylesEnum.Grid;
|
||||
const gridView = isGridView ? `boxed-${userConfig.grid_items}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : '';
|
||||
const isTableView = userConfig.view_style_home === ViewStylesEnum.Table;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const videos = await loadVideoListByFilter({
|
||||
page: currentPage,
|
||||
watch: userConfig.hide_watched ? 'unwatched' : undefined,
|
||||
watch: userConfig.hide_watched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined,
|
||||
sort: userConfig.sort_by,
|
||||
order: userConfig.sort_order,
|
||||
});
|
||||
@@ -168,7 +171,7 @@ const Home = () => {
|
||||
<EmbeddableVideoPlayer videoId={videoId} />
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
{continueVideos && continueVideos.length > 0 && (
|
||||
{continueVideos && continueVideos.length > 0 && !isTableView && (
|
||||
<>
|
||||
<div className="title-bar">
|
||||
<h1>Continue Watching</h1>
|
||||
@@ -176,7 +179,7 @@ const Home = () => {
|
||||
<div className={`video-list ${userConfig.view_style_home} ${gridViewGrid}`}>
|
||||
<VideoList
|
||||
videoList={continueVideos}
|
||||
viewLayout={userConfig.view_style_home}
|
||||
viewStyle={userConfig.view_style_home}
|
||||
refreshVideoList={setRefreshVideoList}
|
||||
/>
|
||||
</div>
|
||||
@@ -187,7 +190,10 @@ const Home = () => {
|
||||
<h1>Recent Videos</h1>
|
||||
</div>
|
||||
|
||||
<Filterbar hideToggleText="Show unwatched only:" viewStyleName={ViewStyleNames.home} />
|
||||
<Filterbar
|
||||
hideToggleText="Show unwatched only:"
|
||||
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
@@ -206,7 +212,7 @@ const Home = () => {
|
||||
{hasVideos && (
|
||||
<VideoList
|
||||
videoList={videoList}
|
||||
viewLayout={userConfig.view_style_home}
|
||||
viewStyle={userConfig.view_style_home}
|
||||
refreshVideoList={setRefreshVideoList}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useColours from '../configuration/colours/useColours';
|
||||
import Colours from '../configuration/colours/Colours';
|
||||
import Button from '../components/Button';
|
||||
import signIn from '../api/actions/signIn';
|
||||
import loadAuth from '../api/loader/loadAuth';
|
||||
|
||||
const Login = () => {
|
||||
useColours();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -76,6 +74,7 @@ const Login = () => {
|
||||
return (
|
||||
<>
|
||||
<title>TA | Welcome</title>
|
||||
<Colours />
|
||||
<div className="boxed-content login-page">
|
||||
<img alt="tube-archivist-logo" />
|
||||
<h1>Tube Archivist</h1>
|
||||
@@ -134,9 +133,12 @@ const Login = () => {
|
||||
|
||||
{waitingForBackend && (
|
||||
<>
|
||||
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
|
||||
<div />
|
||||
</div>
|
||||
<p>
|
||||
Waiting for backend{' '}
|
||||
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
|
||||
<div />
|
||||
</div>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import useColours from '../configuration/colours/useColours';
|
||||
import Colours from '../configuration/colours/Colours';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
|
||||
const NotFound = ({ failType = 'page' }) => {
|
||||
useColours();
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>404 | Not found</title>
|
||||
<Colours />
|
||||
<div id="error-page" style={{ margin: '10%' }}>
|
||||
<h1>Oops!</h1>
|
||||
<p>
|
||||
|
||||
@@ -9,7 +9,11 @@ import VideoList from '../components/VideoList';
|
||||
import Pagination, { PaginationType } from '../components/Pagination';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import Linkify from '../components/Linkify';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import {
|
||||
ViewStyleNames,
|
||||
ViewStyleNamesType,
|
||||
ViewStylesEnum,
|
||||
} from '../configuration/constants/ViewStyle';
|
||||
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
|
||||
import deletePlaylist from '../api/actions/deletePlaylist';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
@@ -19,7 +23,10 @@ import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import Button from '../components/Button';
|
||||
import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
|
||||
import loadVideoListByFilter, {
|
||||
WatchTypes,
|
||||
WatchTypesEnum,
|
||||
} from '../api/loader/loadVideoListByPage';
|
||||
import useIsAdmin from '../functions/useIsAdmin';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import { ApiResponseType } from '../functions/APIClient';
|
||||
@@ -60,12 +67,12 @@ const Playlist = () => {
|
||||
|
||||
const palylistEntries = playlistResponseData?.playlist_entries;
|
||||
const videoArchivedCount = Number(palylistEntries?.filter(video => video.downloaded).length);
|
||||
const videoInPlaylistCount = pagination?.total_hits;
|
||||
const videoInPlaylistCount = Number(palylistEntries?.length);
|
||||
|
||||
const view = userConfig.view_style_home;
|
||||
const viewStyle = userConfig.view_style_home; // its a list of videos, so view_style_home
|
||||
const gridItems = userConfig.grid_items;
|
||||
const hideWatched = userConfig.hide_watched;
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const isGridView = viewStyle === ViewStylesEnum.Grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
@@ -75,7 +82,7 @@ const Playlist = () => {
|
||||
const video = await loadVideoListByFilter({
|
||||
playlist: playlistId,
|
||||
page: currentPage,
|
||||
watch: hideWatched ? 'unwatched' : undefined,
|
||||
watch: hideWatched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined,
|
||||
});
|
||||
|
||||
setPlaylistResponse(playlist);
|
||||
@@ -304,7 +311,7 @@ const Playlist = () => {
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<Filterbar
|
||||
hideToggleText="Hide watched videos:"
|
||||
viewStyleName={ViewStyleNames.playlist}
|
||||
viewStyle={ViewStyleNames.Home as ViewStyleNamesType} // its a list of videos, so ViewStyleNames.Home
|
||||
showSort={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -312,7 +319,7 @@ const Playlist = () => {
|
||||
<EmbeddableVideoPlayer videoId={videoId} />
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
|
||||
{videoInPlaylistCount === 0 && (
|
||||
<>
|
||||
<h2>No videos found...</h2>
|
||||
@@ -334,7 +341,7 @@ const Playlist = () => {
|
||||
{videoInPlaylistCount !== 0 && (
|
||||
<VideoList
|
||||
videoList={videos}
|
||||
viewLayout={view}
|
||||
viewStyle={viewStyle}
|
||||
playlistId={playlistId}
|
||||
showReorderButton={isCustomPlaylist}
|
||||
refreshVideoList={setRefresh}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import Notifications from '../components/Notifications';
|
||||
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
|
||||
import { ApiResponseType } from '../functions/APIClient';
|
||||
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
|
||||
const Playlists = () => {
|
||||
const { userConfig, setUserConfig } = useUserConfigStore();
|
||||
@@ -39,7 +40,7 @@ const Playlists = () => {
|
||||
|
||||
const hasPlaylists = playlistResponseData?.data?.length !== 0;
|
||||
|
||||
const view = userConfig.view_style_playlist;
|
||||
const viewStyle = userConfig.view_style_playlist;
|
||||
const showSubedOnly = userConfig.show_subed_only;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,9 +103,11 @@ const Playlists = () => {
|
||||
label="Subscribe"
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
await updateBulkPlaylistSubscriptions(playlistsToAddText, true);
|
||||
setShowNotification(true);
|
||||
setShowAddForm(false);
|
||||
if (playlistsToAddText.trim()) {
|
||||
await updateBulkPlaylistSubscriptions(playlistsToAddText, true);
|
||||
setShowNotification(true);
|
||||
setShowAddForm(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -125,8 +128,10 @@ const Playlists = () => {
|
||||
label="Create"
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
await createCustomPlaylist(customPlaylistsToAddText);
|
||||
setRefresh(true);
|
||||
if (customPlaylistsToAddText.trim()) {
|
||||
await createCustomPlaylist(customPlaylistsToAddText);
|
||||
setRefresh(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -173,21 +178,25 @@ const Playlists = () => {
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ view_style_playlist: 'grid' });
|
||||
handleUserConfigUpdate({
|
||||
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
|
||||
});
|
||||
}}
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
handleUserConfigUpdate({ view_style_playlist: 'list' });
|
||||
handleUserConfigUpdate({
|
||||
view_style_playlist: ViewStylesEnum.List as ViewStylesType,
|
||||
});
|
||||
}}
|
||||
alt="list view"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`playlist-list ${view}`}>
|
||||
<div className={`playlist-list ${viewStyle}`}>
|
||||
{!hasPlaylists && <h2>No playlists found...</h2>}
|
||||
|
||||
{hasPlaylists && <PlaylistList playlistList={playlistList} setRefresh={setRefresh} />}
|
||||
|
||||
@@ -5,7 +5,7 @@ import VideoList from '../components/VideoList';
|
||||
import ChannelList from '../components/ChannelList';
|
||||
import PlaylistList from '../components/PlaylistList';
|
||||
import SubtitleList from '../components/SubtitleList';
|
||||
import { ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import { ViewStylesEnum } from '../configuration/constants/ViewStyle';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import SearchExampleQueries from '../components/SearchExampleQueries';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
@@ -61,7 +61,7 @@ const Search = () => {
|
||||
const isPlaylistQuery = queryType === 'playlist' || isSimpleQuery;
|
||||
const isFullTextQuery = queryType === 'full' || isSimpleQuery;
|
||||
|
||||
const isGridView = viewVideos === ViewStyles.grid;
|
||||
const isGridView = viewVideos === ViewStylesEnum.Grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
@@ -125,7 +125,7 @@ const Search = () => {
|
||||
<div id="video-results" className={`video-list ${viewVideos} ${gridViewGrid}`}>
|
||||
<VideoList
|
||||
videoList={videoList}
|
||||
viewLayout={viewVideos}
|
||||
viewStyle={viewVideos}
|
||||
refreshVideoList={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -204,8 +204,8 @@ const SettingsActions = () => {
|
||||
deleted videos from the filesystem.
|
||||
</p>
|
||||
<p>
|
||||
Rescan your media folder looking for missing videos and clean up index. More infos on
|
||||
the Github{' '}
|
||||
Rescan your media folder looking for missing videos and clean up index. More info on the
|
||||
Github{' '}
|
||||
<a
|
||||
href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem"
|
||||
target="_blank"
|
||||
|
||||
@@ -15,6 +15,32 @@ import createAppriseNotificationUrl, {
|
||||
import deleteAppriseNotificationUrl from '../api/actions/deleteAppriseNotificationUrl';
|
||||
import { ApiError, ApiResponseType } from '../functions/APIClient';
|
||||
|
||||
const getGroupedSchedule = (
|
||||
scheduleResponse: ApiResponseType<ScheduleResponseType> | undefined,
|
||||
) => {
|
||||
const { data: scheduleResponseData } = scheduleResponse ?? {};
|
||||
|
||||
const groupedSchedules = Object.groupBy(scheduleResponseData || [], ({ name }) => name);
|
||||
|
||||
const { update_subscribed, download_pending, run_backup, check_reindex, thumbnail_check } =
|
||||
groupedSchedules;
|
||||
|
||||
const updateSubscribedSchedule = update_subscribed?.pop();
|
||||
const downloadPendingSchedule = download_pending?.pop();
|
||||
const runBackup = run_backup?.pop();
|
||||
const checkReindexSchedule = check_reindex?.pop();
|
||||
const thumbnailCheckSchedule = thumbnail_check?.pop();
|
||||
|
||||
return {
|
||||
updateSubscribedSchedule,
|
||||
downloadPendingSchedule,
|
||||
runBackup,
|
||||
checkReindexSchedule,
|
||||
thumbnailCheckSchedule,
|
||||
download_pending,
|
||||
};
|
||||
};
|
||||
|
||||
const SettingsScheduling = () => {
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
@@ -37,7 +63,6 @@ const SettingsScheduling = () => {
|
||||
const [thumnailCheckError, setThumnailCheckError] = useState<string | null>(null);
|
||||
const [zipBackupError, setZipBackupError] = useState<string | null>(null);
|
||||
|
||||
const { data: scheduleResponseData } = scheduleResponse ?? {};
|
||||
const { data: appriseNotificationData } = appriseNotification ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,6 +74,22 @@ const SettingsScheduling = () => {
|
||||
setScheduleResponse(scheduleResponse);
|
||||
setAppriseNotification(appriseNotificationResponse);
|
||||
|
||||
const {
|
||||
checkReindexSchedule,
|
||||
downloadPendingSchedule,
|
||||
runBackup,
|
||||
thumbnailCheckSchedule,
|
||||
updateSubscribedSchedule,
|
||||
} = getGroupedSchedule(scheduleResponse);
|
||||
|
||||
setUpdateSubscribed(updateSubscribedSchedule?.schedule || '');
|
||||
setDownloadPending(downloadPendingSchedule?.schedule || '');
|
||||
setCheckReindex(checkReindexSchedule?.schedule || '');
|
||||
setCheckReindexDays(checkReindexSchedule?.config?.days || 0);
|
||||
setThumbnailCheck(thumbnailCheckSchedule?.schedule || '');
|
||||
setZipBackup(runBackup?.schedule || '');
|
||||
setZipBackupDays(runBackup?.config?.rotate || 0);
|
||||
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
@@ -58,18 +99,14 @@ const SettingsScheduling = () => {
|
||||
setRefresh(true);
|
||||
}, []);
|
||||
|
||||
const groupedSchedules = Object.groupBy(scheduleResponseData || [], ({ name }) => name);
|
||||
|
||||
console.log(groupedSchedules);
|
||||
|
||||
const { update_subscribed, download_pending, run_backup, check_reindex, thumbnail_check } =
|
||||
groupedSchedules;
|
||||
|
||||
const updateSubscribedSchedule = update_subscribed?.pop();
|
||||
const downloadPendingSchedule = download_pending?.pop();
|
||||
const runBackup = run_backup?.pop();
|
||||
const checkReindexSchedule = check_reindex?.pop();
|
||||
const thumbnailCheckSchedule = thumbnail_check?.pop();
|
||||
const {
|
||||
checkReindexSchedule,
|
||||
downloadPendingSchedule,
|
||||
runBackup,
|
||||
thumbnailCheckSchedule,
|
||||
updateSubscribedSchedule,
|
||||
download_pending,
|
||||
} = getGroupedSchedule(scheduleResponse);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -146,7 +183,7 @@ const SettingsScheduling = () => {
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={updateSubscribed || updateSubscribedSchedule?.schedule || ''}
|
||||
value={updateSubscribed}
|
||||
onChange={e => {
|
||||
setUpdateSubscribed(e.currentTarget.value);
|
||||
}}
|
||||
@@ -201,7 +238,7 @@ const SettingsScheduling = () => {
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={downloadPending || downloadPendingSchedule?.schedule || ''}
|
||||
value={downloadPending}
|
||||
onChange={e => {
|
||||
setDownloadPending(e.currentTarget.value);
|
||||
}}
|
||||
@@ -257,7 +294,7 @@ const SettingsScheduling = () => {
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={checkReindex || checkReindexSchedule?.schedule || ''}
|
||||
value={checkReindex}
|
||||
onChange={e => {
|
||||
setCheckReindex(e.currentTarget.value);
|
||||
}}
|
||||
@@ -293,7 +330,7 @@ const SettingsScheduling = () => {
|
||||
|
||||
<input
|
||||
type="number"
|
||||
value={checkReindexDays || checkReindexSchedule?.config?.days || 0}
|
||||
value={checkReindexDays}
|
||||
onChange={e => {
|
||||
setCheckReindexDays(Number(e.currentTarget.value));
|
||||
}}
|
||||
@@ -342,7 +379,7 @@ const SettingsScheduling = () => {
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={thumbnailCheck || thumbnailCheckSchedule?.schedule || ''}
|
||||
value={thumbnailCheck}
|
||||
onChange={e => {
|
||||
setThumbnailCheck(e.currentTarget.value);
|
||||
}}
|
||||
@@ -404,7 +441,7 @@ const SettingsScheduling = () => {
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={zipBackup || runBackup?.schedule || ''}
|
||||
value={zipBackup}
|
||||
onChange={e => {
|
||||
setZipBackup(e.currentTarget.value);
|
||||
}}
|
||||
@@ -440,7 +477,7 @@ const SettingsScheduling = () => {
|
||||
|
||||
<input
|
||||
type="number"
|
||||
value={(zipBackupDays || runBackup?.config?.rotate)?.toString() || 0}
|
||||
value={zipBackupDays?.toString()}
|
||||
onChange={e => {
|
||||
setZipBackupDays(Number(e.currentTarget.value));
|
||||
}}
|
||||
@@ -525,7 +562,7 @@ const SettingsScheduling = () => {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Apprise notification URL"
|
||||
value={notificationUrl || ''}
|
||||
value={notificationUrl}
|
||||
onChange={e => {
|
||||
setNotificationUrl(e.currentTarget.value);
|
||||
}}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import updateUserConfig, {
|
||||
ColourConstant,
|
||||
ColourVariants,
|
||||
FileSizeUnits,
|
||||
UserConfigType,
|
||||
} from '../api/actions/updateUserConfig';
|
||||
import { ColourConstant } from '../configuration/colours/useColours';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import Notifications from '../components/Notifications';
|
||||
import Button from '../components/Button';
|
||||
@@ -16,10 +15,8 @@ import ToggleConfig from '../components/ToggleConfig';
|
||||
const SettingsUser = () => {
|
||||
const { userConfig, setUserConfig } = useUserConfigStore();
|
||||
const isAdmin = useIsAdmin();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [styleSheet, setStyleSheet] = useState<ColourVariants>(userConfig.stylesheet);
|
||||
const [styleSheetRefresh, setStyleSheetRefresh] = useState(false);
|
||||
const [pageSize, setPageSize] = useState<number>(userConfig.page_size);
|
||||
const [showHelpText, setShowHelpText] = useState(userConfig.show_help_text);
|
||||
const [selectedFileSizeUnit, setSelectedFileSizeUnit] = useState(FileSizeUnits.Binary);
|
||||
@@ -41,7 +38,9 @@ const SettingsUser = () => {
|
||||
const handleStyleSheetChange = async (selectedStyleSheet: ColourVariants) => {
|
||||
handleUserConfigUpdate({ stylesheet: selectedStyleSheet });
|
||||
setStyleSheet(selectedStyleSheet);
|
||||
setStyleSheetRefresh(true);
|
||||
|
||||
// Store in local storage for pages like login, without a userConfig
|
||||
localStorage.setItem('stylesheet', selectedStyleSheet);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async () => {
|
||||
@@ -65,11 +64,6 @@ const SettingsUser = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageRefresh = () => {
|
||||
navigate(0);
|
||||
setStyleSheetRefresh(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | User Settings</title>
|
||||
@@ -104,7 +98,6 @@ const SettingsUser = () => {
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{styleSheetRefresh && <button onClick={handlePageRefresh}>Refresh</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ import { FileSizeUnits } from '../api/actions/updateUserConfig';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import NotFound from './NotFound';
|
||||
import { ApiResponseType } from '../functions/APIClient';
|
||||
import VideoThumbnail from '../components/VideoThumbail';
|
||||
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
|
||||
const isInPlaylist = (videoId: string, playlist: PlaylistType) => {
|
||||
return playlist.playlist_entries.some(entry => {
|
||||
@@ -534,9 +536,8 @@ const Video = () => {
|
||||
{playlistItem.playlist_previous && (
|
||||
<>
|
||||
<Link to={Routes.Video(playlistItem.playlist_previous.youtube_id)}>
|
||||
<img
|
||||
src={`${getApiUrl()}/${playlistItem.playlist_previous.vid_thumb}`}
|
||||
alt="previous thumbnail"
|
||||
<VideoThumbnail
|
||||
videoThumbUrl={playlistItem.playlist_previous.vid_thumb}
|
||||
/>
|
||||
</Link>
|
||||
<div className="playlist-desc">
|
||||
@@ -564,10 +565,7 @@ const Video = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<Link to={Routes.Video(playlistItem.playlist_next.youtube_id)}>
|
||||
<img
|
||||
src={`${getApiUrl()}/${playlistItem.playlist_next.vid_thumb}`}
|
||||
alt="previous thumbnail"
|
||||
/>
|
||||
<VideoThumbnail videoThumbUrl={playlistItem.playlist_next.vid_thumb} />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
@@ -584,7 +582,7 @@ const Video = () => {
|
||||
<div className="video-list grid grid-3" id="similar-videos">
|
||||
<VideoList
|
||||
videoList={similarVideosResponseData}
|
||||
viewLayout="grid"
|
||||
viewStyle={ViewStylesEnum.Grid as ViewStylesType}
|
||||
refreshVideoList={setRefreshVideoList}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { UserConfigType } from '../api/actions/updateUserConfig';
|
||||
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
import { SortOrderEnum, SortOrderType } from '../api/loader/loadVideoListByPage';
|
||||
|
||||
interface UserConfigState {
|
||||
userConfig: UserConfigType;
|
||||
@@ -11,11 +13,11 @@ export const useUserConfigStore = create<UserConfigState>(set => ({
|
||||
stylesheet: 'dark.css',
|
||||
page_size: 12,
|
||||
sort_by: 'published',
|
||||
sort_order: 'desc',
|
||||
view_style_home: 'grid',
|
||||
view_style_channel: 'list',
|
||||
view_style_downloads: 'list',
|
||||
view_style_playlist: 'grid',
|
||||
sort_order: SortOrderEnum.Desc as SortOrderType,
|
||||
view_style_home: ViewStylesEnum.Grid as ViewStylesType,
|
||||
view_style_channel: ViewStylesEnum.List as ViewStylesType,
|
||||
view_style_downloads: ViewStylesEnum.List as ViewStylesType,
|
||||
view_style_playlist: ViewStylesEnum.Grid as ViewStylesType,
|
||||
grid_items: 3,
|
||||
hide_watched: false,
|
||||
file_size_unit: 'binary',
|
||||
|
||||
@@ -133,6 +133,40 @@ button:hover {
|
||||
color: var(--main-bg);
|
||||
}
|
||||
|
||||
.video-item.table {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.video-item.table table {
|
||||
min-width: 800px;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-item.table th,
|
||||
.video-item.table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.video-item.table th {
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.video-item.table td {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.video-item.table td.no-nowrap {
|
||||
white-space: normal;
|
||||
word-break: keep-all;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.button-box {
|
||||
padding: 5px 2px;
|
||||
display: inline-flex;
|
||||
|
||||
Reference in New Issue
Block a user