Compare commits

...

86 Commits

Author SHA1 Message Date
Simon
21f0a09f5f bump TA_VERSION 2025-06-11 08:34:56 +07:00
Simon
12fc7e1663 bump requirements 2025-06-11 08:34:22 +07:00
Simon
1c60b2bb46 Merge branch 'master' into testing 2025-06-10 08:49:26 +07:00
Simon
3631fa0b2c bump yt-dlp, #build 2025-06-10 08:47:57 +07:00
Simon
dfcd46efbf add unstable tag 2025-06-10 08:38:58 +07:00
Simon
aa943567de bump bump yt-dlp 2025-06-10 08:37:48 +07:00
Simon
178c25f2f0 really no more feature requests please 2025-06-08 19:52:55 +07:00
Simon
22b53e6820 remove unstable tag 2025-06-08 12:16:48 +07:00
James Kerrane
2c2129179a Remove obsolete "version" attribute (#988)
The attribute `version` is obsolete, and docker compose recommends removing it to avoid potential confusion, so this removes the attribute.
2025-06-08 12:14:28 +07:00
Baku
ffe9444295 Update CONTRIBUTING.md (#987)
* Update CONTRIBUTING.md

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

* Update CONTRIBUTING.md

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

* Add option A

* Refac remove option A

* Refac viewStyleType and viewStyleEnum

* Add viewStyle Table

* Refac remove unused code

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

Fix typo ("infos" > "info")

* fix pre-commit

---------

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

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

* Bugfix #934: Download a video from the queue

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

* Bugfix #934: Download a video from the queue

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

* Bugfix #934: Download a video from the queue

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

* Bugfix #934: Download a video from the queue

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

* Bugfix #934: Download a video from the queue

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

* Improved code

* Improved code

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

* Fixed file because of black pre-commit

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

* Fixed file because of black pre-commit

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

---------

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

* fix for Ignored button action

* use ignored parameter as bool

---------

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

* lint
2025-04-29 09:22:19 +07:00
Simon
c00368d1f2 fix playlist video grid type, #916 2025-04-29 08:57:44 +07:00
MerlinScheurer
e28a6be3c5 update frontend dependencies 2025-04-22 18:35:10 +02:00
MerlinScheurer
2b71dbca9a Update frontend dependencies 2025-04-05 13:36:26 +02:00
MerlinScheurer
b7a29897dd Fix formatting of backend\common\src\helper.py 2025-04-05 13:28:24 +02:00
MerlinScheurer
68a69185fe Add custom.css #884 2025-04-05 13:16:30 +02:00
Simon
253571b5ac Player URL fixes, #build
Changed:
- Fix player url query persistence
- Fix various search params handling
- Bumped yt-dlp
2025-04-04 06:03:01 +02:00
Simon
3cd6bad9d3 fix dependabot warnings 2025-04-04 06:02:43 +02:00
Simon
5ca295ccff add unstable TA_VERSION tag 2025-04-04 05:59:01 +02:00
Simon
8fd217e6f0 bump requirements 2025-04-04 05:58:37 +02:00
MerlinScheurer
85be8f1114 Fix block pagination hotkeys when embeddableVideoPlayer is active 2025-03-29 11:07:47 +01:00
MerlinScheurer
e254a848e0 Revert pagination preventDefault | it was eating all key events 2025-03-29 10:58:39 +01:00
MerlinScheurer
947ec803b3 Fix Download page not refetching on page change 2025-03-29 10:56:26 +01:00
MerlinScheurer
0d1bdb8904 Fix pagination not preving page navigation on arrow key navigation 2025-03-29 10:55:39 +01:00
Merlin
a2cb2d283c Fix URL params in embed player #907 2025-03-29 10:34:04 +01:00
MerlinScheurer
11afb29384 Refac change searchParam handling for subtitle and download page 2025-03-29 10:27:54 +01:00
MerlinScheurer
cbfb66b5c8 Merge branch 'master' of github.com:tubearchivist/tubearchivist into testing 2025-03-29 09:30:46 +01:00
MerlinScheurer
7b26a01063 Refac reduce notification interval from 500 to 1000 #912 2025-03-28 11:58:27 +01:00
MerlinScheurer
96b55bf9d5 Fix download page requesting pending downloads twice 2025-03-28 11:50:53 +01:00
MerlinScheurer
50a3442ca6 Add VideoThumbnail to video playlist view 2025-03-28 11:31:32 +01:00
MerlinScheurer
c407f27ecf Add VideoThumbnail to subtitleList 2025-03-28 11:29:15 +01:00
MerlinScheurer
148f9e801b Refac extract VideoThumbnail into a component 2025-03-28 11:25:54 +01:00
MerlinScheurer
5fe0afaeb7 Refac add 'waiting for backend' text to make it more clear 2025-03-28 11:12:39 +01:00
MerlinScheurer
c73a350095 Refac take searchParams from router in Base.tsx 2025-03-28 01:46:56 +01:00
MerlinScheurer
2be190aa65 Refac preemptive image fallback when url is undefined. Ticket: #911 2025-03-28 01:34:34 +01:00
MerlinScheurer
0aa5ef5b31 Add trim to subscribe textarea fields #908 2025-03-28 01:26:34 +01:00
Mason James
40bae8d73d Fix URL params in embed player
When opening/closing embedded videos, maintain existing URL parameters including pagination state. Previously, the video player would reset page to 0 and lose other query parameters.
2025-03-27 19:36:32 -04:00
97 changed files with 1929 additions and 1208 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
![Tube Archivist](assets/tube-archivist-front.jpg?raw=true "Tube Archivist Banner")
![Tube Archivist](assets/tube-archivist-front.jpg?raw=true "Tube Archivist Banner")
[*more screenshots and video*](SHOWCASE.MD)
<div align="center">
@@ -23,13 +23,13 @@
------------------------
## Core functionality
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface. This includes:
Once your YouTube video collection grows, it becomes hard to search and find a specific video. That's where Tube Archivist comes in: By indexing your video collection with metadata from YouTube, you can organize, search and enjoy your archived YouTube videos without hassle offline through a convenient web interface. This includes:
* Subscribe to your favorite YouTube channels
* Download Videos using **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**
* Index and make videos searchable
* Play videos
* Keep track of viewed and unviewed videos
## Resources
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
@@ -38,8 +38,8 @@ Once your YouTube video collection grows, it becomes hard to search and find a s
- [Plex Plugin](https://github.com/tubearchivist/tubearchivist-plex): Add your videos to Plex
## Installing
For minimal system requirements, the Tube Archivist stack needs around 2GB of available memory for a small testing setup and around 4GB of available memory for a mid to large sized installation. Minimal with dual core with 4 threads, better quad core plus.
This project requires docker. Ensure it is installed and running on your system.
For minimal system requirements, the Tube Archivist stack needs around 2GB of available memory for a small testing setup and around 4GB of available memory for a mid to large sized installation. Minimal with dual core with 4 threads, better quad core plus.
This project requires docker. Ensure it is installed and running on your system.
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/) and [Podman](https://docs.tubearchivist.com/installation/podman/).
@@ -49,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

View File

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

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,14 +167,14 @@ class PendingList(PendingIndex):
self.to_skip = False
self.missing_videos = False
def parse_url_list(self):
def parse_url_list(self, auto_start=False):
"""extract youtube ids from list"""
self.missing_videos = []
self.get_download()
self.get_indexed()
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids):
self._process_entry(entry)
self._process_entry(entry, auto_start=auto_start)
if not self.task:
continue
@@ -183,11 +183,11 @@ class PendingList(PendingIndex):
progress=(idx + 1) / total,
)
def _process_entry(self, entry):
def _process_entry(self, entry, auto_start=False):
"""process single entry from url list"""
vid_type = self._get_vid_type(entry)
if entry["type"] == "video":
self._add_video(entry["url"], vid_type)
self._add_video(entry["url"], vid_type, auto_start=auto_start)
elif entry["type"] == "channel":
self._parse_channel(entry["url"], vid_type)
elif entry["type"] == "playlist":
@@ -204,8 +204,14 @@ class PendingList(PendingIndex):
return VideoTypeEnum(vid_type_str)
def _add_video(self, url, vid_type):
def _add_video(self, url, vid_type, auto_start=False):
"""add video to list"""
if auto_start and url in set(
i["youtube_id"] for i in self.all_pending
):
PendingInteract(youtube_id=url, status="priority").update_status()
return
if url not in self.missing_videos and url not in self.to_skip:
self.missing_videos.append((url, vid_type))
else:
@@ -330,9 +336,6 @@ class PendingList(PendingIndex):
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
"""parse response"""
vid_id = vid.get("id")
published = datetime.strptime(vid["upload_date"], "%Y%m%d").strftime(
"%Y-%m-%d"
)
# build dict
youtube_details = {
@@ -342,13 +345,23 @@ class PendingList(PendingIndex):
"title": vid["title"],
"channel_id": vid["channel_id"],
"duration": get_duration_str(vid["duration"]),
"published": published,
"published": self._build_published(vid),
"timestamp": int(datetime.now().timestamp()),
# Pulling enum value out so it is serializable
"vid_type": vid_type.value,
"channel_indexed": vid["channel_id"] in self.all_channels,
}
if self.all_channels:
youtube_details.update(
{"channel_indexed": vid["channel_id"] in self.all_channels}
)
return youtube_details
@staticmethod
def _build_published(vid):
"""build published date or timestamp"""
timestamp = vid["timestamp"]
if timestamp:
return timestamp
upload_date = vid["upload_date"]
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
return published

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
-r requirements.txt
ipython==9.0.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

View File

@@ -1,15 +1,15 @@
apprise==1.9.2
celery==5.4.0
django-auth-ldap==5.1.0
django-celery-beat==2.7.0
apprise==1.9.3
celery==5.5.3
django-auth-ldap==5.2.0
django-celery-beat==2.8.1
django-cors-headers==4.7.0
Django==5.1.7
djangorestframework==3.15.2
Django==5.2.2
djangorestframework==3.16.0
drf-spectacular==0.28.0
Pillow==11.1.0
redis==5.2.1
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

View File

@@ -79,7 +79,7 @@ class BaseTask(Task):
message.update({"level": level, "id": task_id})
task_result = TaskManager().get_task(task_id)
if task_result:
command = task_result.get("command", False)
command = task_result.get("command", None)
message.update({"command": command})
key = f"message:{message.get('group')}:{task_id.split('-')[0]}"
@@ -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
)

View File

@@ -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"])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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';

View File

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

View File

@@ -1,4 +1,4 @@
import { ConfigType, SortByType, SortOrderType, VideoType } from '../../pages/Home';
import { ConfigType, VideoType } from '../../pages/Home';
import { PaginationType } from '../../components/Pagination';
import APIClient from '../../functions/APIClient';
@@ -8,9 +8,41 @@ export type VideoListByFilterResponseType = {
paginate?: PaginationType;
};
type WatchTypes = 'watched' | 'unwatched' | 'continue';
export type SortByType =
| 'published'
| 'downloaded'
| 'views'
| 'likes'
| 'duration'
| 'mediasize'
| 'width'
| 'height';
export const SortByEnum = {
Published: 'published',
Downloaded: 'downloaded',
Views: 'views',
Likes: 'likes',
Duration: 'duration',
'Media Size': 'mediasize',
Width: 'width',
Height: 'height',
};
export type SortOrderType = 'asc' | 'desc';
export const SortOrderEnum = {
Asc: 'asc',
Desc: 'desc',
};
export type VideoTypes = 'videos' | 'streams' | 'shorts';
export type WatchTypes = 'watched' | 'unwatched' | 'continue';
export const WatchTypesEnum = {
Watched: 'watched',
Unwatched: 'unwatched',
Continue: 'continue',
};
type FilterType = {
page?: number;
playlist?: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
});
}}
/>

View File

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

View File

@@ -40,7 +40,7 @@ const Notifications = ({
}
setNotificationResponse(notifications);
}, 500);
}, 1000);
return () => {
clearInterval(intervalId);

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View 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;

View 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;

View File

@@ -0,0 +1,9 @@
const CustomStylesheet = () => {
return (
<head>
<link rel="stylesheet" href="/css/custom.css" />
</head>
);
};
export default CustomStylesheet;

View File

@@ -1,7 +1,9 @@
import './css/dark.css';
const DarkStylesheet = () => {
return <></>;
return (
<head>
<link rel="stylesheet" href="/css/dark.css" />
</head>
);
};
export default DarkStylesheet;

View File

@@ -1,7 +1,9 @@
import './css/light.css';
const LightStylesheet = () => {
return <></>;
return (
<head>
<link rel="stylesheet" href="/css/light.css" />
</head>
);
};
export default LightStylesheet;

View File

@@ -1,7 +1,9 @@
import './css/matrix.css';
const MatrixStylesheet = () => {
return <></>;
return (
<head>
<link rel="stylesheet" href="/css/matrix.css" />
</head>
);
};
export default MatrixStylesheet;

View File

@@ -1,7 +1,9 @@
import './css/midnight.css';
const MidnightStylesheet = () => {
return <></>;
return (
<head>
<link rel="stylesheet" href="/css/midnight.css" />
</head>
);
};
export default MidnightStylesheet;

View File

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

View File

@@ -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',
};

View File

@@ -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/',

View File

@@ -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 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>
)}

View File

@@ -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>
</>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}}

View File

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

View File

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

View File

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

View File

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