Compare commits
268 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf7a429dac | ||
|
|
67b3e51454 | ||
|
|
5723ff1d20 | ||
|
|
82d873a70f | ||
|
|
7e4f564f45 | ||
|
|
3c8f7aeecf | ||
|
|
3f7b38713f | ||
|
|
0b33edcb4c | ||
|
|
4130a8c5c9 | ||
|
|
06c7968070 | ||
|
|
a98a30cc85 | ||
|
|
a924d648d6 | ||
|
|
0750547236 | ||
|
|
1aa4401e6c | ||
|
|
5e841bf7f8 | ||
|
|
5d524e5110 | ||
|
|
1e96c3211f | ||
|
|
d2649c29c3 | ||
|
|
ff89432d86 | ||
|
|
fb77ab44ee | ||
|
|
e43840da31 | ||
|
|
3fa5ed725e | ||
|
|
841f5ab67f | ||
|
|
4038a837cf | ||
|
|
5b2d7db102 | ||
|
|
c8dd391fd3 | ||
|
|
bde2cd4586 | ||
|
|
780115adb6 | ||
|
|
8eec866b31 | ||
|
|
239657bc3c | ||
|
|
db2bdb8eff | ||
|
|
87d68a3e5c | ||
|
|
09c62fb095 | ||
|
|
b16c00f480 | ||
|
|
9461783f5a | ||
|
|
6420bfbe87 | ||
|
|
5f8cc6c4fb | ||
|
|
f1bc3c9f37 | ||
|
|
0b300e7e12 | ||
|
|
1b067dd95e | ||
|
|
20a39895f9 | ||
|
|
0daba99f08 | ||
|
|
efdffb365a | ||
|
|
ee4dbf99b3 | ||
|
|
e9d7523a1f | ||
|
|
981a4cb8b3 | ||
|
|
2a0a04ac65 | ||
|
|
db0ade203a | ||
|
|
f0becb750a | ||
|
|
8fc9afbad9 | ||
|
|
2b8012b5d4 | ||
|
|
81701df1c7 | ||
|
|
df9a52a8f2 | ||
|
|
d082d3deba | ||
|
|
19c853f07b | ||
|
|
c63249b778 | ||
|
|
8cf41a4043 | ||
|
|
0e726af2de | ||
|
|
f304c2eb02 | ||
|
|
2bc92c9095 | ||
|
|
33b9a012d4 | ||
|
|
860213c427 | ||
|
|
272972429b | ||
|
|
3e2b2f2be1 | ||
|
|
db0e362b7d | ||
|
|
5ffc2046d4 | ||
|
|
3bd6075a9b | ||
|
|
11a61a06a6 | ||
|
|
3f96b37b51 | ||
|
|
4f89a0696b | ||
|
|
16862df71e | ||
|
|
3f4d0c0c76 | ||
|
|
73c82a91c0 | ||
|
|
f194259ab3 | ||
|
|
3b9d083f5e | ||
|
|
9062f61508 | ||
|
|
75441cdf56 | ||
|
|
b2fa92a8c3 | ||
|
|
d533c7acfe | ||
|
|
e6da63ff09 | ||
|
|
461c2c4600 | ||
|
|
0ab41f74dc | ||
|
|
1ab7127ed3 | ||
|
|
6b2bd06393 | ||
|
|
04a559e471 | ||
|
|
6b7354b14f | ||
|
|
2c719ae1ae | ||
|
|
488711ee8f | ||
|
|
78f04a2ffc | ||
|
|
58530563ce | ||
|
|
259008df50 | ||
|
|
20f8a5a501 | ||
|
|
9afd81b372 | ||
|
|
2d2431364d | ||
|
|
16440a4170 | ||
|
|
1f08ea9eea | ||
|
|
2850988bfe | ||
|
|
2d6c0bd02b | ||
|
|
9280159531 | ||
|
|
8d13b947e2 | ||
|
|
22ef33c6ca | ||
|
|
ee568534f6 | ||
|
|
c204c6ca7f | ||
|
|
87cfd9e154 | ||
|
|
72e5d7ccf8 | ||
|
|
a459661fa2 | ||
|
|
a56de2818d | ||
|
|
9ea41bd2a9 | ||
|
|
14b48e676c | ||
|
|
3397418cab | ||
|
|
7e9fed3d26 | ||
|
|
7dc67d6b6f | ||
|
|
4fb5744cb3 | ||
|
|
5617d1c7ae | ||
|
|
666074ed49 | ||
|
|
4c0de78fb4 | ||
|
|
dcb0cf6a6d | ||
|
|
e7fd9c0aec | ||
|
|
ea002ad45d | ||
|
|
6328e316f4 | ||
|
|
b49e87c468 | ||
|
|
c4b175b2db | ||
|
|
b52aa4daba | ||
|
|
8d76961e77 | ||
|
|
4cf7eab950 | ||
|
|
5f2febf09c | ||
|
|
e276700b6d | ||
|
|
4a64748da8 | ||
|
|
5d8dc76e7a | ||
|
|
4dfc9cef52 | ||
|
|
571c2ce3a4 | ||
|
|
229555d97d | ||
|
|
73da59ffbf | ||
|
|
c7a05a9ba3 | ||
|
|
9e1316b543 | ||
|
|
72924fab66 | ||
|
|
72f091b85e | ||
|
|
fdf5b2d802 | ||
|
|
a34c9479f2 | ||
|
|
162c05628c | ||
|
|
1125c0c4bf | ||
|
|
0e5421a558 | ||
|
|
5ec0636807 | ||
|
|
6641db3e7e | ||
|
|
77c26134d1 | ||
|
|
ef685ecb42 | ||
|
|
df1018e70c | ||
|
|
0658424b94 | ||
|
|
d1b5644c63 | ||
|
|
4936f2fdf2 | ||
|
|
0fcd6c086b | ||
|
|
34c5b2613c | ||
|
|
6b246e7808 | ||
|
|
eda7528f7d | ||
|
|
fa46b30e61 | ||
|
|
2acaeb34db | ||
|
|
f8b0977a7f | ||
|
|
4bab1a9815 | ||
|
|
ce7adfee3a | ||
|
|
a350d735be | ||
|
|
3f86786ee8 | ||
|
|
d6c124aae8 | ||
|
|
2d942fe9b5 | ||
|
|
f18e2ce599 | ||
|
|
08350181f3 | ||
|
|
114c7c2811 | ||
|
|
83057d9e54 | ||
|
|
d73a131616 | ||
|
|
1b5a810520 | ||
|
|
2f685859cf | ||
|
|
cacf6e43b8 | ||
|
|
991ab6a2bd | ||
|
|
188eb9fc41 | ||
|
|
4db3775eba | ||
|
|
57841ea1c0 | ||
|
|
3602ec4560 | ||
|
|
afa865edb1 | ||
|
|
bf5a93dae0 | ||
|
|
4d65d45a59 | ||
|
|
c75ef72e74 | ||
|
|
5d0689abba | ||
|
|
c0e36133a1 | ||
|
|
89ac53083f | ||
|
|
18acf22d2e | ||
|
|
98366f1051 | ||
|
|
122b7f0d82 | ||
|
|
d6a861118f | ||
|
|
37e173a5cc | ||
|
|
18a4484fa8 | ||
|
|
fc04fdadd5 | ||
|
|
96cb5ef9ed | ||
|
|
f665a1c7f5 | ||
|
|
092c467a2e | ||
|
|
1471fce2c3 | ||
|
|
8db361cc88 | ||
|
|
00d7c33af6 | ||
|
|
7d2bdc58ba | ||
|
|
61b9110a5c | ||
|
|
d033573bd6 | ||
|
|
7d6a879966 | ||
|
|
9a526f93f4 | ||
|
|
5888e5442a | ||
|
|
d5885273ac | ||
|
|
db4798754a | ||
|
|
646bc1b12e | ||
|
|
d9f73622a5 | ||
|
|
cd8b8aafc6 | ||
|
|
e299308711 | ||
|
|
eccad8968b | ||
|
|
72e9234b2b | ||
|
|
eb812d2b0a | ||
|
|
ce4288ff66 | ||
|
|
14d5a7ee1a | ||
|
|
0c8830793c | ||
|
|
46229db64c | ||
|
|
7941b0412b | ||
|
|
3db6077adf | ||
|
|
d690c4591c | ||
|
|
98f5b66826 | ||
|
|
43a41973d9 | ||
|
|
00379bfe26 | ||
|
|
7f603cb06a | ||
|
|
34ed6ebc63 | ||
|
|
14e0429758 | ||
|
|
73e1bba541 | ||
|
|
599dd26b53 | ||
|
|
0b60377e19 | ||
|
|
6ed2308f99 | ||
|
|
a2e2fd1b89 | ||
|
|
09556dbf21 | ||
|
|
470ecb007d | ||
|
|
8af10f8c07 | ||
|
|
7a9f5e5685 | ||
|
|
bad4b48573 | ||
|
|
db21ee8bcb | ||
|
|
63f35b19fe | ||
|
|
ef44cb852c | ||
|
|
ae6bb4e757 | ||
|
|
5244cddeb3 | ||
|
|
fff389359a | ||
|
|
49961b2c4b | ||
|
|
c987c13fc7 | ||
|
|
4cccb73812 | ||
|
|
e804dd1aec | ||
|
|
f45a3095cb | ||
|
|
3b4969dcea | ||
|
|
18f6455eb2 | ||
|
|
71dd63d3f0 | ||
|
|
21028d4e25 | ||
|
|
2753ce93a2 | ||
|
|
3b3d151ec3 | ||
|
|
c25af5cfaa | ||
|
|
018b578982 | ||
|
|
0f6bc3a420 | ||
|
|
617790b68f | ||
|
|
762a0fe8a1 | ||
|
|
0d21bfe929 | ||
|
|
abc3150f59 | ||
|
|
49b120ed2b | ||
|
|
37218fea1f | ||
|
|
2ad9448dfa | ||
|
|
5c63d3cdc6 | ||
|
|
bf6cd01d80 | ||
|
|
c06f6c68b4 | ||
|
|
706a710587 | ||
|
|
31391c5289 | ||
|
|
40c4ad1f30 | ||
|
|
41c530e994 |
4
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
@@ -6,7 +6,7 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to help improve this project!
|
Thanks for taking the time to help improve this project! Please read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide carefully before continuing.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: latest
|
id: latest
|
||||||
@@ -15,7 +15,7 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I'm running the latest version of Tube Archivist and have read the [release notes](https://github.com/tubearchivist/tubearchivist/releases/latest).
|
- label: I'm running the latest version of Tube Archivist and have read the [release notes](https://github.com/tubearchivist/tubearchivist/releases/latest).
|
||||||
required: true
|
required: true
|
||||||
- label: I have read through the [wiki](https://github.com/tubearchivist/tubearchivist/wiki) and the [readme](https://github.com/tubearchivist/tubearchivist#installing-and-updating), particularly the [common errors](https://github.com/tubearchivist/tubearchivist#common-errors) section.
|
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [bug report](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#bug-report) section.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
|
|||||||
13
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
@@ -1,21 +1,19 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Create a new feature request
|
description: I have an idea for a great addition to this project
|
||||||
title: "[Feature Request]: "
|
title: "[Feature Request]: "
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for taking the time to help improve this project!
|
Thanks for taking the time to help improve this project! This project is *very* selective with accepting new feature requests. Please read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide carefully before continuing.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: already
|
id: already
|
||||||
attributes:
|
attributes:
|
||||||
label: Already implemented?
|
label: "I've read the documentation"
|
||||||
options:
|
options:
|
||||||
- label: I have read through the [wiki](https://github.com/tubearchivist/tubearchivist/wiki).
|
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [feature request](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#feature-request) section.
|
||||||
required: true
|
|
||||||
- label: I understand the [scope](https://github.com/tubearchivist/tubearchivist/wiki/FAQ) of this project and am aware of the [known limitations](https://github.com/tubearchivist/tubearchivist#known-limitations) and my idea is not already on the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap).
|
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -33,5 +31,4 @@ body:
|
|||||||
label: Your help is needed!
|
label: Your help is needed!
|
||||||
description: This project is ambitious as it is, please contribute.
|
description: This project is ambitious as it is, please contribute.
|
||||||
options:
|
options:
|
||||||
- label: Yes I can help with this feature request!
|
- label: Yes I will work on this in the next few days or weeks.
|
||||||
required: false
|
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/INSTALLATION-HELP.yml
vendored
@@ -13,9 +13,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Installation instructions
|
label: Installation instructions
|
||||||
options:
|
options:
|
||||||
- label: I have read and understand the [installation instructions](https://github.com/tubearchivist/tubearchivist#installing-and-updating).
|
- label: I have read the [how to open an issue](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-open-an-issue) guide, particularly the [installation help](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#installation-help) section.
|
||||||
required: true
|
|
||||||
- label: My issue is not described in the [common errors](https://github.com/tubearchivist/tubearchivist#common-errors) section.
|
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
|
|||||||
3
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Thank you for taking the time to improve this project. Please take a look at the [How to make a Pull Request](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-make-a-pull-request) section to help get your contribution merged.
|
||||||
|
|
||||||
|
You can delete this text before submitting.
|
||||||
48
ARCHITECTURE.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# The Inner Workings of Tube Archivist
|
||||||
|
This is a high level overview of the architecture of Tube Archivist, intended for interested contributors to find your way around quickly.
|
||||||
|
|
||||||
|
```
|
||||||
|
Tube Archivist
|
||||||
|
______________________|_____________________
|
||||||
|
| | |
|
||||||
|
------------------- --------------- -------------------
|
||||||
|
| | | | | |
|
||||||
|
| DjangoProject | | RedisJson | | ElasticSearch |
|
||||||
|
| | | | | |
|
||||||
|
------------------- --------------- -------------------
|
||||||
|
```
|
||||||
|
|
||||||
|
## DjangoProject
|
||||||
|
This is the main Python application. Django serves its data container internally with **Uwsgi** on port 8080, the interface is served with **Nginx** on the public port 8000.
|
||||||
|
|
||||||
|
Users created static files like media files and artwork as well as application artwork like logos and fonts are served directly from Nginx, while the rest of the application uses uwsgi_pass to proxy the requests to uwsgi.
|
||||||
|
|
||||||
|
Config files are located in the `docker_assets` folder. The script `run.sh` is the container `CMD` command and entry point, validating env vars, connection to ElasticSearch (ES) and will start the application.
|
||||||
|
|
||||||
|
Compared to other Django projects, this application doesn't make use of the database models, due to a lack of integration with ES. This project has its own abstractions and integrations, treating ES as a REST API.
|
||||||
|
|
||||||
|
Long running application tasks are handed off to **Celery** - using **Redis** as a broker - to run asynchronously from the main threads.
|
||||||
|
- All tasks are defined in the `home.tasks.py` module.
|
||||||
|
|
||||||
|
There are three Django apps:
|
||||||
|
- **config**: The root app, routing the main endpoints and the main `settings.py` file
|
||||||
|
- **api**: The API app with its views and functionality
|
||||||
|
- **home**: Most of the application logic, templates and views, will probably get split up further in the future.
|
||||||
|
|
||||||
|
The *home* app is split up into packages in the `src` directory:
|
||||||
|
- **download**: All download related classes, interact with yt-dlp, download artwork, handle the download queue and post processing tasks.
|
||||||
|
- **es**: All index setup and validation classes, handles mapping validations and makes mapping changes, wrapper functions to simplify interactions with Elasticsearch, backup and restore.
|
||||||
|
- **frontend**: All direct interactions with the frontend, like Django forms, searching, watched state changes, and legacy api_calls in the process of moving to the api app.
|
||||||
|
- **index**: Contains all functionality for scraping and indexing videos, channels, playlists, comments, subtitles, etc...
|
||||||
|
- **ta**: Loose collection of functions and classes, handle application config and contains redis wrapper classes.
|
||||||
|
|
||||||
|
## RedisJson
|
||||||
|
Holds the main application config json object that gets dynamically edited from the frontend, serves as a message broker for **Celery**. Redis serves as a temporary and thread safe link between Django and the frontend, storing progress messages and temporary queues for processing. Used to store locking keys for threads and execution details for tasks.
|
||||||
|
|
||||||
|
- Wrapper classes to interact with Redis are located in the `home.src.ta.ta_redis.py` module.
|
||||||
|
|
||||||
|
## ElasticSearch (ES)
|
||||||
|
Is used to store and index all metadata, functions as an application database and makes it all searchable. The mapping defines which fields are indexed as searchable text fields and which fields are used for match filtering.
|
||||||
|
|
||||||
|
- The index setup and validation is handled in the `home.src.es.index_setup.py` module.
|
||||||
|
- Wrapper classes for making requests to ES are located in the `home.src.es.connect.py` module.
|
||||||
141
CONTRIBUTING.md
@@ -1,17 +1,97 @@
|
|||||||
## Contributing to Tube Archivist
|
## Contributing to Tube Archivist
|
||||||
|
|
||||||
Welcome, and thanks for showing interest in improving Tube Archivist!
|
Welcome, and thanks for showing interest in improving Tube Archivist!
|
||||||
If you haven't already, the best place to start is the README. This will give you an overview on what the project is all about.
|
|
||||||
|
|
||||||
## Report a bug
|
## Table of Content
|
||||||
|
- [How to open an issue](#how-to-open-an-issue)
|
||||||
|
- [Bug Report](#bug-report)
|
||||||
|
- [Feature Request](#feature-request)
|
||||||
|
- [Installation Help](#installation-help)
|
||||||
|
- [How to make a Pull Request](#how-to-make-a-pull-request)
|
||||||
|
- [Improve to the Documentation](#improve-to-the-documentation)
|
||||||
|
- [Development Environment](#development-environment)
|
||||||
|
---
|
||||||
|
|
||||||
If you notice something is not working as expected, check to see if it has been previously reported in the [open issues](https://github.com/tubearchivist/tubearchivist/issues).
|
## How to open an issue
|
||||||
If it has not yet been disclosed, go ahead and create an issue.
|
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
|
||||||
If the issue doesn't move forward due to a lack of response, I assume it's solved and will close it after some time to keep the list fresh.
|
|
||||||
|
|
||||||
## Wiki
|
**Do**:
|
||||||
|
- Do provide details and context, this matters a lot and makes it easier for people to help.
|
||||||
|
- Do familiarize yourself with the project first, some questions answer themselves when using the project for some time. Familiarize yourself with the [Readme](https://github.com/tubearchivist/tubearchivist) and the [documentation](https://docs.tubearchivist.com/), this covers a lot of the common questions, particularly the [FAQ](https://docs.tubearchivist.com/faq/).
|
||||||
|
- Do respond to questions within a day or two so issues can progress. If the issue doesn't move forward due to a lack of response, we'll assume it's solved and we'll close it after some time to keep the list fresh.
|
||||||
|
|
||||||
The wiki is where all user functions are documented in detail. These pages are mirrored into the **docs** folder of the repo. This allows for pull requests and all other features like regular code. Make any changes there, and I'll sync them with the wiki tab.
|
**Don't**:
|
||||||
|
- Don't open *duplicates*, that includes open and closed issues.
|
||||||
|
- Don't open an issue for something that's already on the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), this needs your help to implement it, not another issue.
|
||||||
|
- Don't open an issue for something that's a [known limitation](https://github.com/tubearchivist/tubearchivist#known-limitations). These are *known* by definition and don't need another reminder. Some limitations may be solved in the future, maybe by you?
|
||||||
|
- Don't overwrite the *issue template*, they are there for a reason. Overwriting that shows that you don't really care about this project. It shows that you have a misunderstanding how open source collaboration works and just want to push your ideas through. Overwriting the template may result in a ban.
|
||||||
|
|
||||||
|
### Bug Report
|
||||||
|
Bug reports are highly welcome! This project has improved a lot due to your help by providing feedback when something doesn't work as expected. The developers can't possibly cover all edge cases in an ever changing environment like YouTube and yt-dlp.
|
||||||
|
|
||||||
|
Please keep in mind:
|
||||||
|
- Docker logs are the easiest way to understand what's happening when something goes wrong, *always* provide the logs upfront.
|
||||||
|
- Set the environment variable `DJANGO_DEBUG=True` to Tube Archivist and reproduce the bug for a better log output. Don't forget to remove that variable again after.
|
||||||
|
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
|
||||||
|
|
||||||
|
### Feature Request
|
||||||
|
This project needs your help to grow further. There is no shortage of ideas, see the open [issues on GH](https://github.com/tubearchivist/tubearchivist/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) and the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), what this project lacks is contributors to implement these ideas.
|
||||||
|
|
||||||
|
Existing ideas are easily *multiple years* worth of development effort, at least at current speed. Best and fastest way to implement your feature is to do it yourself, that's why this project is open source after all. This project is *very* selective with accepting new feature requests at this point.
|
||||||
|
|
||||||
|
Good feature requests usually fall into one or more of these categories:
|
||||||
|
- You want to work on your own idea within the next few days or weeks.
|
||||||
|
- Your idea is beneficial for a wide range of users, not just for you.
|
||||||
|
- Your idea extends the current project by building on and improving existing functionality.
|
||||||
|
- Your idea is quick and easy to implement, for an experienced as well as for a first time contributor.
|
||||||
|
|
||||||
|
Your request is likely going to be rejected if:
|
||||||
|
- Your idea requires multiple days worth of development time and is unrealistic to be implemented any time soon.
|
||||||
|
- There are already other ways to do what you are trying to do.
|
||||||
|
- You are trying to do something that only applies to your platform, your specific workflow or your specific setup.
|
||||||
|
- Your idea would fundamentally change how the project works or it wouldn't be able to be implemented with backwards compatibility.
|
||||||
|
- Your idea is not a good fit for this project.
|
||||||
|
|
||||||
|
### Installation Help
|
||||||
|
GitHub is most likely not the best place to ask for installation help. That's inherently individual and one on one.
|
||||||
|
1. First step is always, help yourself. Start at the [Readme](https://github.com/tubearchivist/tubearchivist) or the additional platform specific installation pages in the [docs](https://docs.tubearchivist.com/).
|
||||||
|
2. If that doesn't answer your question, open a `#support` thread on [Discord](https://www.tubearchivist.com/discord).
|
||||||
|
3. Only if that is not an option, open an issue here.
|
||||||
|
|
||||||
|
IMPORTANT: When receiving help, contribute back to the community by improving the installation instructions with your newly gained knowledge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to make a Pull Request
|
||||||
|
|
||||||
|
Thank you for contributing and helping improve this project. 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.
|
||||||
|
|
||||||
|
### Making changes to the JavaScript
|
||||||
|
|
||||||
|
The JavaScript does not require any build step; you just edit the files directly. However, there is config for eslint and prettier (a linter and formatter respectively); their use is recommended but not required. To use them, install `node`, run `npm i` from the root directory of this repository to install dependencies, then run `npm run lint` and `npm run format` to run eslint and prettier respectively.
|
||||||
|
|
||||||
|
### Code formatting and linting
|
||||||
|
|
||||||
|
To keep things clean and consistent for everybody, there is a github action setup to lint and check the changes. You can test your code locally first if you want. For example if you made changes in the **video** module, run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
./deploy.sh validate tubearchivist/home/src/index/video.py
|
||||||
|
```
|
||||||
|
|
||||||
|
to validate your changes. If you omit the path, all the project files will get checked. This is subject to change as the codebase improves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Improve to the Documentation
|
||||||
|
|
||||||
|
The documentation available at [docs.tubearchivist.com](https://docs.tubearchivist.com/) and is build from a separate repo [tubearchivist/docs](https://github.com/tubearchivist/docs). The Readme has additional instructions on how to make changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development Environment
|
## Development Environment
|
||||||
|
|
||||||
@@ -35,18 +115,18 @@ Make your changes locally and re-run `docker compose up --build`. The `Dockerfil
|
|||||||
|
|
||||||
You may find it nice to run everything inside of a VM, though this is not necessary. There's a `deploy.sh` script which has some helpers for this use case. YMMV, this is what one of the developers does:
|
You may find it nice to run everything inside of a VM, though this is not necessary. There's a `deploy.sh` script which has some helpers for this use case. YMMV, this is what one of the developers does:
|
||||||
|
|
||||||
- Clone the repo, work on it with your favorite code editor in your local filesystem. *testing* branch is the where all the changes are happening, might be unstable and is WIP.
|
- Clone the repo, work on it with your favorite code editor in your local filesystem. *testing* branch is where all the changes are happening, might be unstable and is WIP.
|
||||||
- Then I have a VM running standard Ubuntu Server LTS with docker installed. The VM keeps my projects separate and offers convenient snapshot functionality. The VM also offers ways to simulate lowend environments by limiting CPU cores and memory. You can use this [Ansible Docker Ubuntu](https://github.com/bbilly1/ansible-playbooks) playbook to get started quickly. But you could also just run docker on your host system.
|
- Then I have a VM running standard Ubuntu Server LTS with docker installed. The VM keeps my projects separate and offers convenient snapshot functionality. The VM also offers ways to simulate low end environments by limiting CPU cores and memory. You can use this [Ansible Docker Ubuntu](https://github.com/bbilly1/ansible-playbooks) playbook to get started quickly. But you could also just run docker on your host system.
|
||||||
- I have my local DNS resolve `tubearchivist.local` to the IP of the VM for convenience. To deploy the latest changes and rebuild the application to the testing VM run:
|
- I have my local DNS resolve `tubearchivist.local` to the IP of the VM for convenience. To deploy the latest changes and rebuild the application to the testing VM run:
|
||||||
```bash
|
```bash
|
||||||
./deploy.sh test
|
./deploy.sh test
|
||||||
```
|
```
|
||||||
- The command above will call the docker build command with `--build-arg INSTALL_DEBUG=1` to install additional useful debug tools.
|
- The command above will call the docker build command with `--build-arg INSTALL_DEBUG=1` to install additional useful debug tools.
|
||||||
- The `test` argument takes another optional argument to build for a specific architecture valid options are: `amd64`, `arm64` and `multi`, default is `amd64`.
|
- The `test` argument takes another optional argument to build for a specific architecture valid options are: `amd64`, `arm64` and `multi`, default is `amd64`.
|
||||||
- This `deploy.sh` script is not meant to be universally usable for every possible environment but could serve as an idea on how to automatically rebuild containers to test changes - customize to your liking.
|
- This `deploy.sh` script is not meant to be universally usable for every possible environment but could serve as an idea on how to automatically rebuild containers to test changes - customize to your liking.
|
||||||
|
|
||||||
## Working with Elasticsearch
|
### Working with Elasticsearch
|
||||||
Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries.
|
Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries.
|
||||||
|
|
||||||
**Quick start**
|
**Quick start**
|
||||||
Generate your access token in Elasitcsearch:
|
Generate your access token in Elasitcsearch:
|
||||||
@@ -60,41 +140,10 @@ kibana:
|
|||||||
image: docker.elastic.co/kibana/kibana:0.0.0
|
image: docker.elastic.co/kibana/kibana:0.0.0
|
||||||
container_name: kibana
|
container_name: kibana
|
||||||
environment:
|
environment:
|
||||||
- "ELASTICSEARCH_HOSTS=http://archivist-es:9200"
|
- "ELASTICSEARCH_HOSTS=http://archivist-es:9200"
|
||||||
- "ELASTICSEARCH_SERVICEACCOUNTTOKEN=<your-token-here>"
|
- "ELASTICSEARCH_SERVICEACCOUNTTOKEN=<your-token-here>"
|
||||||
ports:
|
ports:
|
||||||
- "5601:5601"
|
- "5601:5601"
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want to run queries on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it.
|
If you want to run queries on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it.
|
||||||
|
|
||||||
## Implementing a new feature
|
|
||||||
|
|
||||||
Do you see anything on the roadmap that you would like to take a closer look at but you are not sure, what's the best way to tackle that? Or anything not on there yet you'd like to implement but are not sure how? Reach out on Discord and we'll look into it together.
|
|
||||||
|
|
||||||
## Making changes
|
|
||||||
|
|
||||||
To fix a bug or implement a feature, fork the repository and make all changes to the testing branch. When ready, create a pull request.
|
|
||||||
|
|
||||||
## Making changes to the JavaScript
|
|
||||||
|
|
||||||
The JavaScript does not require any build step; you just edit the files directly. However, there is config for eslint and prettier (a linter and formatter respectively); their use is recommended but not required. To use them, install `node`, run `npm i` from the root directory of this repository to install dependencies, then run `npm run lint` and `npm run format` to run eslint and prettier respectively.
|
|
||||||
|
|
||||||
## Releases
|
|
||||||
|
|
||||||
There are three different docker tags:
|
|
||||||
- **latest**: As the name implies is the latest multiarch release for regular usage.
|
|
||||||
- **unstable**: Intermediate amd64 builds for quick testing and improved collaboration. Don't mix with a *latest* installation, for your testing environment only. This is untested and WIP and will have breaking changes between commits that might require a reset to resolve.
|
|
||||||
- **semantic versioning**: There will be a handful named version tags that will also have a matching release and tag on github.
|
|
||||||
|
|
||||||
If you want to see what's in your container, checkout the matching release tag. A merge to **master** usually means a *latest* or *unstable* release. If you want to preview changes in your testing environment, pull the *unstable* tag or clone the repository and build the docker container with the Dockerfile from the **testing** branch.
|
|
||||||
|
|
||||||
## Code formatting and linting
|
|
||||||
|
|
||||||
To keep things clean and consistent for everybody, there is a github action setup to lint and check the changes. You can test your code locally first if you want. For example if you made changes in the **video** module, run
|
|
||||||
|
|
||||||
```shell
|
|
||||||
./deploy.sh validate tubearchivist/home/src/index/video.py
|
|
||||||
```
|
|
||||||
|
|
||||||
to validate your changes. If you omit the path, all the project files will get checked. This is subject to change as the codebase improves.
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
|
|
||||||
# First stage to build python wheel
|
# First stage to build python wheel
|
||||||
FROM python:3.10.8-slim-bullseye AS builder
|
FROM python:3.10.9-slim-bullseye AS builder
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -14,7 +14,7 @@ COPY ./tubearchivist/requirements.txt /requirements.txt
|
|||||||
RUN pip install --user -r requirements.txt
|
RUN pip install --user -r requirements.txt
|
||||||
|
|
||||||
# build final image
|
# build final image
|
||||||
FROM python:3.10.8-slim-bullseye as tubearchivist
|
FROM python:3.10.9-slim-bullseye as tubearchivist
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
ARG INSTALL_DEBUG
|
ARG INSTALL_DEBUG
|
||||||
|
|||||||
280
README.md
@@ -2,195 +2,88 @@
|
|||||||
|
|
||||||
<h1 align="center">Your self hosted YouTube media server</h1>
|
<h1 align="center">Your self hosted YouTube media server</h1>
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-docker.png" alt="tubearchivist-docker" title="Tube Archivist Docker Pulls" height="50" width="200"/></a>
|
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-docker.png" alt="tubearchivist-docker" title="Tube Archivist Docker Pulls" height="50" width="190"/></a>
|
||||||
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-star.png" alt="tubearchivist-github-star" title="Tube Archivist GitHub Stars" height="50" width="200"/></a>
|
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-star.png" alt="tubearchivist-github-star" title="Tube Archivist GitHub Stars" height="50" width="190"/></a>
|
||||||
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-forks.png" alt="tubearchivist-github-forks" title="Tube Archivist GitHub Forks" height="50" width="200"/></a>
|
<a href="https://github.com/bbilly1/tilefy" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-github-forks.png" alt="tubearchivist-github-forks" title="Tube Archivist GitHub Forks" height="50" width="190"/></a>
|
||||||
|
<a href="https://www.tubearchivist.com/discord" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-discord.png" alt="tubearchivist-discord" title="TA Discord Server Members" height="50" width="190"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Table of contents:
|
## Table of contents:
|
||||||
* [Wiki](https://github.com/tubearchivist/tubearchivist/wiki) with [FAQ](https://github.com/tubearchivist/tubearchivist/wiki/FAQ)
|
* [Docs](https://docs.tubearchivist.com/) with [FAQ](https://docs.tubearchivist.com/faq/), and API documentation
|
||||||
* [Core functionality](#core-functionality)
|
* [Core functionality](#core-functionality)
|
||||||
* [Screenshots](#screenshots)
|
* [Resources](#resources)
|
||||||
* [Problem Tube Archivist tries to solve](#problem-tube-archivist-tries-to-solve)
|
* [Installing](#installing)
|
||||||
* [Connect](#connect)
|
|
||||||
* [Extended Universe](#extended-universe)
|
|
||||||
* [Installing and updating](#installing-and-updating)
|
|
||||||
* [Getting Started](#getting-started)
|
* [Getting Started](#getting-started)
|
||||||
|
* [Known limitations](#known-limitations)
|
||||||
|
* [Port Collisions](#port-collisions)
|
||||||
* [Common Errors](#common-errors)
|
* [Common Errors](#common-errors)
|
||||||
* [Roadmap](#roadmap)
|
* [Roadmap](#roadmap)
|
||||||
* [Known limitations](#known-limitations)
|
|
||||||
* [Donate](#donate)
|
* [Donate](#donate)
|
||||||
|
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
## Core functionality
|
## 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:
|
||||||
* Subscribe to your favorite YouTube channels
|
* Subscribe to your favorite YouTube channels
|
||||||
* Download Videos using **yt-dlp**
|
* Download Videos using **yt-dlp**
|
||||||
* Index and make videos searchable
|
* Index and make videos searchable
|
||||||
* Play videos
|
* Play videos
|
||||||
* Keep track of viewed and unviewed videos
|
* Keep track of viewed and unviewed videos
|
||||||
|
|
||||||
## Tube Archivist on YouTube
|
|
||||||
[](https://www.youtube.com/watch?v=O8H8Z01c0Ys)
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||

|
|
||||||
*Home Page*
|
|
||||||
|
|
||||||

|
|
||||||
*All Channels*
|
|
||||||
|
|
||||||

|
|
||||||
*Single Channel*
|
|
||||||
|
|
||||||

|
|
||||||
*Video Page*
|
|
||||||
|
|
||||||

|
|
||||||
*Downloads Page*
|
|
||||||
|
|
||||||
## Problem Tube Archivist tries to solve
|
## Resources
|
||||||
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.
|
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
|
||||||
|
|
||||||
## Connect
|
|
||||||
- [Discord](https://discord.gg/AFwz8nE7BK): Connect with us on our Discord server.
|
|
||||||
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
|
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
|
||||||
|
|
||||||
## Extended Universe
|
|
||||||
- [Browser Extension](https://github.com/tubearchivist/browser-extension) Tube Archivist Companion, for [Firefox](https://addons.mozilla.org/addon/tubearchivist-companion/) and [Chrome](https://chrome.google.com/webstore/detail/tubearchivist-companion/jjnkmicfnfojkkgobdfeieblocadmcie)
|
- [Browser Extension](https://github.com/tubearchivist/browser-extension) Tube Archivist Companion, for [Firefox](https://addons.mozilla.org/addon/tubearchivist-companion/) and [Chrome](https://chrome.google.com/webstore/detail/tubearchivist-companion/jjnkmicfnfojkkgobdfeieblocadmcie)
|
||||||
- [Tube Archivist Metrics](https://github.com/tubearchivist/tubearchivist-metrics) to create statistics in Prometheus/OpenMetrics format.
|
- [Tube Archivist Metrics](https://github.com/tubearchivist/tubearchivist-metrics) to create statistics in Prometheus/OpenMetrics format.
|
||||||
|
- [Showcase](SHOWCASE.MD) To see more screenshots and Youtube videos showing off TubeArchvist.
|
||||||
|
|
||||||
## Installing and updating
|
## Installing
|
||||||
There's dedicated user-contributed install steps under [docs/Installation.md](./docs/Installation.md) for podman, Unraid, Truenas and Synology which you can use instead of this section if you happen to be using one of those. Otherwise, continue on.
|
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.
|
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/), [Podman](https://docs.tubearchivist.com/installation/podman/) and [True NAS](https://docs.tubearchivist.com/installation/truenas-scale/).
|
||||||
|
|
||||||
Note for arm64 hosts: The Tube Archivist container is multi arch, so is Elasticsearch. RedisJSON doesn't offer arm builds, but you can use the image `bbilly1/rejson`, an unofficial rebuild for arm64.
|
The instructions here should get you up and running quickly, for Docker beginners and full explanation about each environment variable, see the [docs](https://docs.tubearchivist.com/installation/docker-compose/).
|
||||||
|
|
||||||
This project requires docker. Ensure it is installed and running on your system.
|
Take a look at the example [docker-compose.yml](https://github.com/tubearchivist/tubearchivist/blob/master/docker-compose.yml) and configure the required environment variables.
|
||||||
|
|
||||||
Save the [docker-compose.yml](./docker-compose.yml) file from this reposity somewhere permanent on your system, keeping it named `docker-compose.yml`. You'll need to refer to it whenever starting this application.
|
**TubeArchivist**:
|
||||||
|
| Environment Var | Value | |
|
||||||
|
| ----------- | ----------- | ----------- |
|
||||||
|
| TA_HOST | Server IP or hostname | Required |
|
||||||
|
| TA_USERNAME | Initial username when logging into TA | Required |
|
||||||
|
| TA_PASSWORD | Initial password when logging into TA | Required |
|
||||||
|
| ELASTIC_PASSWORD | Password for ElasticSearch | Required |
|
||||||
|
| REDIS_HOST | Hostname for Redis | Required |
|
||||||
|
| TZ | Set your timezone for the scheduler | Required |
|
||||||
|
| TA_PORT | Overwrite Nginx port | Optional |
|
||||||
|
| TA_UWSGI_PORT | Overwrite container internal uwsgi port | Optional |
|
||||||
|
| ES_URL | URL That ElasticSearch runs on | Optional |
|
||||||
|
| HOST_GID | Allow TA to own the video files instead of container user | Optional |
|
||||||
|
| HOST_UID | Allow TA to own the video files instead of container user | Optional |
|
||||||
|
| ELASTIC_USER | Change the default ElasticSearch user | Optional |
|
||||||
|
| REDIS_PORT | Port that Redis runs on | Optional |
|
||||||
|
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
|
||||||
|
| ENABLE_CAST | Enable casting support | [Read more](https://docs.tubearchivist.com/configuration/cast/) |
|
||||||
|
| DJANGO_DEBUG | Return additional error messages, for debug only | |
|
||||||
|
|
||||||
Edit the following values from that file:
|
**ElasticSearch**
|
||||||
- under `tubearchivist`->`environment`:
|
| Environment Var | Value | State |
|
||||||
- `HOST_UID`: your UID, if you want TubeArchivist to create files with your UID. Remove if you are OK with files being owned by the the container user.
|
| ----------- | ----------- | ----------- |
|
||||||
- `HOST_GID`: as above but GID.
|
| ELASTIC_PASSWORD | Matching password `ELASTIC_PASSWORD` from TubeArchivist | Required |
|
||||||
- `TA_HOST`: change it to the address of the machine you're running this on. This can be an IP address or a domain name.
|
| http.port | Change the port ElasticSearch runs on | Optional |
|
||||||
- `TA_PASSWORD`: pick a password to use when logging in.
|
|
||||||
- `ELASTIC_PASSWORD`: pick a password for the elastic service. You won't need to type this yourself.
|
|
||||||
- `TZ`: your time zone. If you don't know yours, you can look it up [here](https://www.timezoneconverter.com/cgi-bin/findzone/findzone).
|
|
||||||
- under `archivist-es`->`environment`:
|
|
||||||
- `"ELASTIC_PASSWORD=verysecret"`: change `verysecret` to match the ELASTIC_PASSWORD you picked above.
|
|
||||||
|
|
||||||
By default Docker will store all data, including downloaded data, in its own data-root directory (which you can find by running `docker info` and looking for the "Docker Root Dir"). If you want to use other locations, you can replace the `media:`, `cache:`, `redis:`, and `es:` volume names with absolute paths; if you do, remove them from the `volumes:` list at the bottom of the file.
|
|
||||||
|
|
||||||
From a terminal, `cd` into the directory you saved the `docker-compose.yml` file in and run `docker compose up --detach`. The first time you do this it will download the appropriate images, which can take a minute.
|
## 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.
|
||||||
|
|
||||||
You can follow the logs with `docker compose logs -f`. Once it's ready it will print something like `celery@1234567890ab ready`. At this point you should be able to go to `http://your-host:8000` and log in with the `TA_USER`/`TA_PASSWORD` credentials.
|
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 take advantage of the latest fixes and improvements, make sure you are running the *latest and greatest*. After updating, check the footer to verify you are running the expected version.
|
||||||
|
|
||||||
You can bring the application down by running `docker compose down` in the same directory.
|
- 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.
|
||||||
Use the *latest* (the default) or a named semantic version tag for the docker images. The *unstable* tag is for intermediate testing and as the name implies, is **unstable** and not be used on your main installation but in a [testing environment](CONTRIBUTING.md).
|
- 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.
|
||||||
|
|
||||||
## Installation Details
|
|
||||||
|
|
||||||
Tube Archivist depends on three main components split up into separate docker containers:
|
|
||||||
|
|
||||||
### Tube Archivist
|
|
||||||
The main Python application that displays and serves your video collection, built with Django.
|
|
||||||
- Serves the interface on port `8000`
|
|
||||||
- Needs a volume for the video archive at **/youtube**
|
|
||||||
- And another volume to save application data at **/cache**.
|
|
||||||
- The environment variables `ES_URL` and `REDIS_HOST` are needed to tell Tube Archivist where Elasticsearch and Redis respectively are located.
|
|
||||||
- The environment variables `HOST_UID` and `HOST_GID` allows Tube Archivist to `chown` the video files to the main host system user instead of the container user. Those two variables are optional, not setting them will disable that functionality. That might be needed if the underlying filesystem doesn't support `chown` like *NFS*.
|
|
||||||
- Set the environment variable `TA_HOST` to match with the system running Tube Archivist. This can be a domain like *example.com*, a subdomain like *ta.example.com* or an IP address like *192.168.1.20*, add without the protocol and without the port. You can add multiple hostnames separated with a space. Any wrong configurations here will result in a `Bad Request (400)` response.
|
|
||||||
- Change the environment variables `TA_USERNAME` and `TA_PASSWORD` to create the initial credentials.
|
|
||||||
- `ELASTIC_PASSWORD` is for the password for Elasticsearch. The environment variable `ELASTIC_USER` is optional, should you want to change the username from the default *elastic*.
|
|
||||||
- For the scheduler to know what time it is, set your timezone with the `TZ` environment variable, defaults to *UTC*.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
Should that not be an option, the Tube Archivist container takes these two additional environment variables:
|
|
||||||
- **TA_PORT**: To actually change the port where nginx listens, make sure to also change the ports value in your docker-compose file.
|
|
||||||
- **TA_UWSGI_PORT**: To change the default uwsgi port 8080 used for container internal networking between uwsgi serving the django application and nginx.
|
|
||||||
|
|
||||||
Changing any of these two environment variables will change the files *nginx.conf* and *uwsgi.ini* at startup using `sed` in your container.
|
|
||||||
|
|
||||||
### LDAP Authentication
|
|
||||||
You can configure LDAP with the following environment variables:
|
|
||||||
|
|
||||||
- `TA_LDAP` (ex: `true`) Set to anything besides empty string to use LDAP authentication **instead** of local user authentication.
|
|
||||||
- `TA_LDAP_SERVER_URI` (ex: `ldap://ldap-server:389`) Set to the uri of your LDAP server.
|
|
||||||
- `TA_LDAP_DISABLE_CERT_CHECK` (ex: `true`) Set to anything besides empty string to disable certificate checking when connecting over LDAPS.
|
|
||||||
- `TA_LDAP_BIND_DN` (ex: `uid=search-user,ou=users,dc=your-server`) DN of the user that is able to perform searches on your LDAP account.
|
|
||||||
- `TA_LDAP_BIND_PASSWORD` (ex: `yoursecretpassword`) Password for the search user.
|
|
||||||
- `TA_LDAP_USER_ATTR_MAP_USERNAME` (default: `uid`) Bind attribute used to map LDAP user's username
|
|
||||||
- `TA_LDAP_USER_ATTR_MAP_PERSONALNAME` (default: `givenName`) Bind attribute used to match LDAP user's First Name/Personal Name.
|
|
||||||
- `TA_LDAP_USER_ATTR_MAP_SURNAME` (default: `sn`) Bind attribute used to match LDAP user's Last Name/Surname.
|
|
||||||
- `TA_LDAP_USER_ATTR_MAP_EMAIL` (default: `mail`) Bind attribute used to match LDAP user's EMail address
|
|
||||||
- `TA_LDAP_USER_BASE` (ex: `ou=users,dc=your-server`) Search base for user filter.
|
|
||||||
- `TA_LDAP_USER_FILTER` (ex: `(objectClass=user)`) Filter for valid users. Login usernames are matched using the attribute specified in `TA_LDAP_USER_ATTR_MAP_USERNAME` and should not be specified in this filter.
|
|
||||||
|
|
||||||
When LDAP authentication is enabled, django passwords (e.g. the password defined in TA_PASSWORD), will not allow you to login, only the LDAP server is used.
|
|
||||||
|
|
||||||
### Elasticsearch
|
|
||||||
**Note**: Tube Archivist depends on Elasticsearch 8.
|
|
||||||
|
|
||||||
Use `bbilly1/tubearchivist-es` to automatically get the recommended version, or use the official image with the version tag in the docker-compose file.
|
|
||||||
|
|
||||||
Stores video meta data and makes everything searchable. Also keeps track of the download queue.
|
|
||||||
- Needs to be accessible over the default port `9200`
|
|
||||||
- Needs a volume at **/usr/share/elasticsearch/data** to store data
|
|
||||||
|
|
||||||
Follow the [documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html) for additional installation details.
|
|
||||||
|
|
||||||
### Redis JSON
|
|
||||||
Functions as a cache and temporary link between the application and the file system. Used to store and display messages and configuration variables.
|
|
||||||
- Needs to be accessible over the default port `6379`
|
|
||||||
- Needs a volume at **/data** to make your configuration changes permanent.
|
|
||||||
|
|
||||||
### Redis on a custom port
|
|
||||||
For some architectures it might be required to run Redis JSON on a nonstandard port. To for example change the Redis port to **6380**, set the following values:
|
|
||||||
- Set the environment variable `REDIS_PORT=6380` to the *tubearchivist* service.
|
|
||||||
- For the *archivist-redis* service, change the ports to `6380:6380`
|
|
||||||
- Additionally set the following value to the *archivist-redis* service: `command: --port 6380 --loadmodule /usr/lib/redis/modules/rejson.so`
|
|
||||||
|
|
||||||
### Updating Tube Archivist
|
|
||||||
You will see the current version number of **Tube Archivist** in the footer of the interface so you can compare it with the latest release to make sure you are running the *latest and greatest*.
|
|
||||||
* 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.
|
|
||||||
|
|
||||||
### Helm charts
|
|
||||||
|
|
||||||
There is a Helm Chart available at https://github.com/insuusvenerati/helm-charts. Mostly self-explanatory but feel free to ask questions in the discord / subreddit.
|
|
||||||
|
|
||||||
## Common Errors
|
|
||||||
### 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:
|
|
||||||
```
|
|
||||||
sudo sysctl -w vm.max_map_count=262144
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Permissions for elasticsearch
|
|
||||||
If you see a message similar to `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.
|
|
||||||
|
|
||||||
### 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`, [link](https://github.com/tubearchivist/tubearchivist#disk-usage).
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example:
|
1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example:
|
||||||
@@ -201,7 +94,49 @@ bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
|
|||||||
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.
|
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!
|
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.
|
||||||
|
|
||||||
|
For more information on port collisions, check the docs.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
```
|
||||||
|
sudo sysctl -w vm.max_map_count=262144
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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 currently no flexibility in naming of the media files.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
We have come far, nonetheless we are not short of ideas on how to improve and extend this project. Issues waiting for you to be tackled in no particular order:
|
We have come far, nonetheless we are not short of ideas on how to improve and extend this project. Issues waiting for you to be tackled in no particular order:
|
||||||
|
|
||||||
@@ -211,16 +146,16 @@ We have come far, nonetheless we are not short of ideas on how to improve and ex
|
|||||||
- [ ] Implement [Apprise](https://github.com/caronc/apprise) for notifications ([#97](https://github.com/tubearchivist/tubearchivist/issues/97))
|
- [ ] Implement [Apprise](https://github.com/caronc/apprise) for notifications ([#97](https://github.com/tubearchivist/tubearchivist/issues/97))
|
||||||
- [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
|
- [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
|
||||||
- [ ] Auto play or play next link ([#226](https://github.com/tubearchivist/tubearchivist/issues/226))
|
- [ ] Auto play or play next link ([#226](https://github.com/tubearchivist/tubearchivist/issues/226))
|
||||||
- [ ] Show similar videos on video page
|
|
||||||
- [ ] Multi language support
|
- [ ] Multi language support
|
||||||
- [ ] Show total video downloaded vs total videos available in channel
|
- [ ] Show total video downloaded vs total videos available in channel
|
||||||
- [ ] Add statistics of index
|
- [ ] Add statistics of index
|
||||||
- [ ] Download speed schedule ([#198](https://github.com/tubearchivist/tubearchivist/issues/198))
|
- [ ] Download speed schedule ([#198](https://github.com/tubearchivist/tubearchivist/issues/198))
|
||||||
- [ ] Auto ignore videos by keyword ([#163](https://github.com/tubearchivist/tubearchivist/issues/163))
|
- [ ] Download or Ignore videos by keyword ([#163](https://github.com/tubearchivist/tubearchivist/issues/163))
|
||||||
- [ ] Custom searchable notes to videos, channels, playlists ([#144](https://github.com/tubearchivist/tubearchivist/issues/144))
|
- [ ] Custom searchable notes to videos, channels, playlists ([#144](https://github.com/tubearchivist/tubearchivist/issues/144))
|
||||||
- [ ] Download video comments
|
|
||||||
|
|
||||||
Implemented:
|
Implemented:
|
||||||
|
- [X] Download video comments [2022-11-30]
|
||||||
|
- [X] Show similar videos on video page [2022-11-30]
|
||||||
- [X] Implement complete offline media file import from json file [2022-08-20]
|
- [X] Implement complete offline media file import from json file [2022-08-20]
|
||||||
- [X] Filter and query in search form, search by url query [2022-07-23]
|
- [X] Filter and query in search form, search by url query [2022-07-23]
|
||||||
- [X] Make items in grid row configurable to use more of the screen [2022-06-04]
|
- [X] Make items in grid row configurable to use more of the screen [2022-06-04]
|
||||||
@@ -242,12 +177,6 @@ Implemented:
|
|||||||
- [X] Backup and restore [2021-09-22]
|
- [X] Backup and restore [2021-09-22]
|
||||||
- [X] Scan your file system to index already downloaded videos [2021-09-14]
|
- [X] Scan your file system to index already downloaded videos [2021-09-14]
|
||||||
|
|
||||||
## 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.
|
|
||||||
- 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 currently no flexibility in naming of the media files.
|
|
||||||
|
|
||||||
|
|
||||||
## Donate
|
## Donate
|
||||||
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
|
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
|
||||||
Second best way to support the development is to provide for caffeinated beverages:
|
Second best way to support the development is to provide for caffeinated beverages:
|
||||||
@@ -256,6 +185,18 @@ Second best way to support the development is to provide for caffeinated beverag
|
|||||||
* [Paypal Subscription](https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-03770005GR991451KMFGVPMQ) for a monthly coffee
|
* [Paypal Subscription](https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-03770005GR991451KMFGVPMQ) for a monthly coffee
|
||||||
* [ko-fi.com](https://ko-fi.com/bbilly1) for an alternative platform
|
* [ko-fi.com](https://ko-fi.com/bbilly1) for an alternative platform
|
||||||
|
|
||||||
|
## Notable mentions
|
||||||
|
This is a selection of places where this project has been featured on reddit, in the news, blogs or any other online media, newest on top.
|
||||||
|
* **noted.lol**: Dev Debrief, An Interview With the Developer of Tube Archivist, [2023-03-30] [[link](https://noted.lol/dev-debrief-tube-archivist/)]
|
||||||
|
* **console.substack.com**: Interview With Simon of Tube Archivist, [2023-01-29] [[link](https://console.substack.com/p/console-142#%C2%A7interview-with-simon-of-tube-archivist)]
|
||||||
|
* **reddit.com**: Tube Archivist v0.3.0 - Now Archiving Comments, [2022-12-02] [[link](https://www.reddit.com/r/selfhosted/comments/zaonzp/tube_archivist_v030_now_archiving_comments/)]
|
||||||
|
* **reddit.com**: Tube Archivist v0.2 - Now with Full Text Search, [2022-07-24] [[link](https://www.reddit.com/r/selfhosted/comments/w6jfa1/tube_archivist_v02_now_with_full_text_search/)]
|
||||||
|
* **noted.lol**: How I Control What Media My Kids Watch Using Tube Archivist, [2022-03-27] [[link](https://noted.lol/how-i-control-what-media-my-kids-watch-using-tube-archivist/)]
|
||||||
|
* **thehomelab.wiki**: Tube Archivist - A Youtube-DL Alternative on Steroids, [2022-01-27] [[link](https://thehomelab.wiki/books/news/page/tube-archivist-a-youtube-dl-alternative-on-steroids)]
|
||||||
|
* **reddit.com**: Celebrating TubeArchivist v0.1, [2022-01-09] [[link](https://www.reddit.com/r/selfhosted/comments/rzh084/celebrating_tubearchivist_v01/)]
|
||||||
|
* **linuxunplugged.com**: Pick: tubearchivist — Your self-hosted YouTube media server, [2021-09-11] [[link](https://linuxunplugged.com/425)] and [2021-10-05] [[link](https://linuxunplugged.com/426)]
|
||||||
|
* **reddit.com**: Introducing Tube Archivist, your self hosted Youtube media server, [2021-09-12] [[link](https://www.reddit.com/r/selfhosted/comments/pmj07b/introducing_tube_archivist_your_self_hosted/)]
|
||||||
|
|
||||||
|
|
||||||
## Sponsor
|
## Sponsor
|
||||||
Big thank you to [Digitalocean](https://www.digitalocean.com/) for generously donating credit for the tubearchivist.com VPS and buildserver.
|
Big thank you to [Digitalocean](https://www.digitalocean.com/) for generously donating credit for the tubearchivist.com VPS and buildserver.
|
||||||
@@ -264,3 +205,4 @@ Big thank you to [Digitalocean](https://www.digitalocean.com/) for generously do
|
|||||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px">
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
19
SHOWCASE.MD
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Tube Archivist on YouTube
|
||||||
|
[](https://www.youtube.com/watch?v=O8H8Z01c0Ys)
|
||||||
|
Video featuring Tube Archivist generously created by [IBRACORP](https://www.youtube.com/@IBRACORP).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|

|
||||||
|
*Home Page*: Your recent videos, continue watching incomplete videos.
|
||||||
|
|
||||||
|

|
||||||
|
*All Channels*: A list of all your indexed channels, filtered by subscribed only.
|
||||||
|
|
||||||
|

|
||||||
|
*Single Channel*: Single channel page with additional metadata and sub pages.
|
||||||
|
|
||||||
|

|
||||||
|
*Video Page*: Stream your video directly from the interface.
|
||||||
|
|
||||||
|

|
||||||
|
*Downloads Page*: Add, control and monitor your download queue.
|
||||||
16
deploy.sh
@@ -26,6 +26,7 @@ function sync_blackhole {
|
|||||||
--exclude "**/cache" \
|
--exclude "**/cache" \
|
||||||
--exclude "**/__pycache__/" \
|
--exclude "**/__pycache__/" \
|
||||||
--exclude "db.sqlite3" \
|
--exclude "db.sqlite3" \
|
||||||
|
--exclude ".mypy_cache" \
|
||||||
. -e ssh "$host":tubearchivist
|
. -e ssh "$host":tubearchivist
|
||||||
|
|
||||||
ssh "$host" 'docker build -t bbilly1/tubearchivist --build-arg TARGETPLATFORM="linux/amd64" tubearchivist'
|
ssh "$host" 'docker build -t bbilly1/tubearchivist --build-arg TARGETPLATFORM="linux/amd64" tubearchivist'
|
||||||
@@ -49,6 +50,7 @@ function sync_test {
|
|||||||
--exclude "**/cache" \
|
--exclude "**/cache" \
|
||||||
--exclude "**/__pycache__/" \
|
--exclude "**/__pycache__/" \
|
||||||
--exclude "db.sqlite3" \
|
--exclude "db.sqlite3" \
|
||||||
|
--exclude ".mypy_cache" \
|
||||||
. -e ssh "$host":tubearchivist
|
. -e ssh "$host":tubearchivist
|
||||||
|
|
||||||
# copy default docker-compose file if not exist
|
# copy default docker-compose file if not exist
|
||||||
@@ -85,14 +87,14 @@ function validate {
|
|||||||
# note: this logic is duplicated in the `./github/workflows/lint_python.yml` config
|
# note: this logic is duplicated in the `./github/workflows/lint_python.yml` config
|
||||||
# if you update this file, you should update that as well
|
# if you update this file, you should update that as well
|
||||||
echo "running black"
|
echo "running black"
|
||||||
black --diff --color --check -l 79 "$check_path"
|
black --exclude "migrations/*" --diff --color --check -l 79 "$check_path"
|
||||||
echo "running codespell"
|
echo "running codespell"
|
||||||
codespell --skip="./.git,./package.json,./package-lock.json,./node_modules" "$check_path"
|
codespell --skip="./.git,./package.json,./package-lock.json,./node_modules,./.mypy_cache" "$check_path"
|
||||||
echo "running flake8"
|
echo "running flake8"
|
||||||
flake8 "$check_path" --count --max-complexity=10 --max-line-length=79 \
|
flake8 "$check_path" --exclude "migrations" --count --max-complexity=10 \
|
||||||
--show-source --statistics
|
--max-line-length=79 --show-source --statistics
|
||||||
echo "running isort"
|
echo "running isort"
|
||||||
isort --check-only --diff --profile black -l 79 "$check_path"
|
isort --skip "migrations" --check-only --diff --profile black -l 79 "$check_path"
|
||||||
printf " \n> all validations passed\n"
|
printf " \n> all validations passed\n"
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -214,8 +216,6 @@ elif [[ $1 == "test" ]]; then
|
|||||||
sync_test "$2"
|
sync_test "$2"
|
||||||
elif [[ $1 == "validate" ]]; then
|
elif [[ $1 == "validate" ]]; then
|
||||||
validate "$2"
|
validate "$2"
|
||||||
elif [[ $1 == "versioncheck" ]]; then
|
|
||||||
python version_check.py
|
|
||||||
elif [[ $1 == "docker" ]]; then
|
elif [[ $1 == "docker" ]]; then
|
||||||
sync_docker
|
sync_docker
|
||||||
elif [[ $1 == "unstable" ]]; then
|
elif [[ $1 == "unstable" ]]; then
|
||||||
@@ -223,7 +223,7 @@ elif [[ $1 == "unstable" ]]; then
|
|||||||
elif [[ $1 == "es" ]]; then
|
elif [[ $1 == "es" ]]; then
|
||||||
sync_latest_es
|
sync_latest_es
|
||||||
else
|
else
|
||||||
echo "valid options are: blackhole | test | validate | versioncheck | docker | unstable | es"
|
echo "valid options are: blackhole | test | validate | docker | unstable | es"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
- archivist-es
|
- archivist-es
|
||||||
- archivist-redis
|
- archivist-redis
|
||||||
archivist-redis:
|
archivist-redis:
|
||||||
image: redislabs/rejson # for arm64 use bbilly1/rejson
|
image: redis/redis-stack-server
|
||||||
container_name: archivist-redis
|
container_name: archivist-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
@@ -34,7 +34,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- archivist-es
|
- archivist-es
|
||||||
archivist-es:
|
archivist-es:
|
||||||
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.4.3
|
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.7.0
|
||||||
container_name: archivist-es
|
container_name: archivist-es
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,66 +1,21 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# startup script inside the container for tubearchivist
|
# startup script inside the container for tubearchivist
|
||||||
|
|
||||||
if [[ -z "$ELASTIC_USER" ]]; then
|
set -e
|
||||||
export ELASTIC_USER=elastic
|
|
||||||
fi
|
|
||||||
|
|
||||||
cachedir=/cache
|
# django setup
|
||||||
[[ -d $cachedir ]] || cachedir=.
|
|
||||||
lockfile=${cachedir}/initsu.lock
|
|
||||||
|
|
||||||
required="Missing required environment variable"
|
|
||||||
[[ -f $lockfile ]] || : "${TA_USERNAME:?$required}"
|
|
||||||
: "${TA_PASSWORD:?$required}"
|
|
||||||
: "${ELASTIC_PASSWORD:?$required}"
|
|
||||||
: "${TA_HOST:?$required}"
|
|
||||||
|
|
||||||
# ugly nginx and uwsgi port overwrite with env vars
|
|
||||||
if [[ -n "$TA_PORT" ]]; then
|
|
||||||
sed -i "s/8000/$TA_PORT/g" /etc/nginx/sites-available/default
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$TA_UWSGI_PORT" ]]; then
|
|
||||||
sed -i "s/8080/$TA_UWSGI_PORT/g" /etc/nginx/sites-available/default
|
|
||||||
sed -i "s/8080/$TA_UWSGI_PORT/g" /app/uwsgi.ini
|
|
||||||
fi
|
|
||||||
|
|
||||||
# wait for elasticsearch
|
|
||||||
counter=0
|
|
||||||
until curl -u "$ELASTIC_USER":"$ELASTIC_PASSWORD" "$ES_URL" -fs; do
|
|
||||||
echo "waiting for elastic search to start"
|
|
||||||
counter=$((counter+1))
|
|
||||||
if [[ $counter -eq 12 ]]; then
|
|
||||||
# fail after 2 min
|
|
||||||
echo "failed to connect to elastic search, exiting..."
|
|
||||||
curl -v -u "$ELASTIC_USER":"$ELASTIC_PASSWORD" "$ES_URL"?pretty
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
|
|
||||||
# start python application
|
|
||||||
python manage.py makemigrations
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
|
|
||||||
if [[ -f $lockfile ]]; then
|
if [[ -z "$DJANGO_DEBUG" ]]; then
|
||||||
echo -e "\e[33;1m[WARNING]\e[0m This is not the first run! Skipping" \
|
python manage.py collectstatic --noinput -c
|
||||||
"superuser creation.\nTo force it, remove $lockfile"
|
|
||||||
else
|
|
||||||
export DJANGO_SUPERUSER_PASSWORD=$TA_PASSWORD
|
|
||||||
output="$(python manage.py createsuperuser --noinput --name "$TA_USERNAME" 2>&1)"
|
|
||||||
|
|
||||||
case "$output" in
|
|
||||||
*"Superuser created successfully"*)
|
|
||||||
echo "$output" && touch $lockfile ;;
|
|
||||||
*"That name is already taken."*)
|
|
||||||
echo "Superuser already exists. Creation will be skipped on next start."
|
|
||||||
touch $lockfile ;;
|
|
||||||
*) echo "$output" && exit 1
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python manage.py collectstatic --noinput -c
|
# ta setup
|
||||||
|
python manage.py ta_envcheck
|
||||||
|
python manage.py ta_connection
|
||||||
|
python manage.py ta_startup
|
||||||
|
|
||||||
|
# start all tasks
|
||||||
nginx &
|
nginx &
|
||||||
celery -A home.tasks worker --loglevel=INFO &
|
celery -A home.tasks worker --loglevel=INFO &
|
||||||
celery -A home beat --loglevel=INFO \
|
celery -A home beat --loglevel=INFO \
|
||||||
|
|||||||
@@ -1,36 +1,3 @@
|
|||||||
# Channels Overview and Channel Detail Page
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
|
|
||||||
The channels are organized on two different levels, similar as the [playlists](Playlists):
|
Equivalent Channels page is: [here](https://docs.tubearchivist.com/channels/).
|
||||||
|
|
||||||
## Channels Overview
|
|
||||||
Accessible at `/channel/` of your Tube Archivist, the **Overview Page** shows a list of all channels you have indexed.
|
|
||||||
- You can filter that list to show or hide subscribed channels with the toggle. Clicking on the channel banner or the channel name will direct you to the *Channel Detail Page*.
|
|
||||||
- If you are subscribed to a channel a *Unsubscribe* button will show, if you aren't subscribed, a *Subscribe* button will show instead.
|
|
||||||
|
|
||||||
The **Subscribe to Channels** button <img src="assets/icon-add.png?raw=true" alt="add icon" width="20px" style="margin:0 5px;"> opens a text field to subscribe to a channel. You have a few options:
|
|
||||||
- Enter the YouTube channel ID, a 25 character alphanumeric string. For example *UCBa659QWEk1AI4Tg--mrJ2A*
|
|
||||||
- Enter the URL to the channel page on YouTube. For example *https://www.youtube.com/channel/UCBa659QWEk1AI4Tg--mrJ2A*
|
|
||||||
- Enter the channel name for example: *https://www.youtube.com/c/TomScottGo*.
|
|
||||||
- Enter the video URL for any video and let Tube Archivist extract the channel ID for you. For example *https://www.youtube.com/watch?v=2tdiKTSdE9Y*
|
|
||||||
- Add one per line.
|
|
||||||
|
|
||||||
You can search your indexed channels by clicking on the search icon <img src="assets/icon-search.png?raw=true" alt="search icon" width="20px" style="margin:0 5px;">. This will open a dedicated page.
|
|
||||||
|
|
||||||
## Channel Detail
|
|
||||||
Each channel will get a dedicated channel detail page accessible at `/channel/<channel-id>/` of your Tube Archivist. This page shows all the videos you have downloaded from this channel.
|
|
||||||
|
|
||||||
- If you are subscribed to the channel, an *Unsubscribe* button will show, else the *Subscribe* button will show.
|
|
||||||
- The **Mark as Watched** button will mark all videos of this channel as watched.
|
|
||||||
|
|
||||||
Additionally there is a *Channel Playlist* page, accessible at `/channel/<channel-id>/playlist/` to show all indexed playlists from this channel.
|
|
||||||
|
|
||||||
On the *Channel About* page, accessible at `/channel/<channel-id>/about/`, you can see additional metadata.
|
|
||||||
- The button **Delete Channel** will delete the channel plus all videos of this channel, both media files and metadata additionally this will also delete playlists metadata belonging to that channel.
|
|
||||||
|
|
||||||
The channel customize form gives options to change settings on a per channel basis. Any configurations here will overwrite your configurations from the [settings](Settings) page.
|
|
||||||
- **Download Format**: Overwrite the download quality for videos from this channel.
|
|
||||||
- **Auto Delete**: Automatically delete watched videos from this channel after selected days.
|
|
||||||
- **Index Playlists**: Automatically add all Playlists with at least a video downloaded to your index. Only do this for channels where you care about playlists as this will slow down indexing new videos for having to check which playlist this belongs to.
|
|
||||||
- **SponsorBlock**: Using [SponsorBlock](https://sponsor.ajay.app/) to get and skip sponsored content. Customize per channel: You can *disable* or *enable* SponsorBlock for certain channels only to overwrite the behavior set on the [Settings](settings) page. Selecting *unset* will remove the overwrite and your setting will fall back to the default on the settings page.
|
|
||||||
|
|
||||||
If you have any videos pending in the download queue, a *Downloads* link will show, bringing you directly to the [downloads](Downloads) page, filtering the list by the selected channel.
|
|
||||||
|
|||||||
@@ -1,43 +1,3 @@
|
|||||||
# Downloads Page
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
Accessible at `/downloads/` of your Tube Archivist, this page handles all the download functionality.
|
|
||||||
|
|
||||||
|
Equivalent Downloads page is: [here](https://docs.tubearchivist.com/downloads/).
|
||||||
## Rescan Subscriptions
|
|
||||||
The **Rescan Subscriptions** icon <img src="assets/icon-rescan.png?raw=true" alt="rescan icon" width="20px" style="margin:0 5px;"> will start a background task to look for new videos from the channels and playlists you are subscribed to. You can define the channel and playlist page size on the [settings page](Settings#subscriptions). With the default page size, expect this process to take around 2-3 seconds for each channel or playlist you are subscribed to. A status message will show the progress.
|
|
||||||
|
|
||||||
Then for every video found, **Tube Archivist** will skip the video if it has already been downloaded or if you added it to the *ignored* list before. All the other videos will get added to the download queue. Expect this to take around 2 seconds for each video as **Tube Archivist** needs to grab some additional metadata. New videos will get added at the bottom of the download queue.
|
|
||||||
|
|
||||||
## Download Queue
|
|
||||||
The **Start Download** icon <img src="assets/icon-download.png?raw=true" alt="download icon" width="20px" style="margin:0 5px;"> will start the download process starting from the top of the queue. Take a look at the relevant settings on the [Settings Page](Settings#downloads). Once the process started, a progress message will show with additional details and controls:
|
|
||||||
- The stop icon <img src="assets/icon-stop.png?raw=true" alt="stop icon" width="20px" style="margin:0 5px;"> will gracefully stop the download process, once the current video has been finished successfully.
|
|
||||||
- The cancel icon <img src="assets/icon-close-red.png?raw=true" alt="close icon" width="20px" style="margin:0 5px;"> is equivalent to killing the process and will stop the download immediately. Any leftover files will get deleted, the canceled video will still be available in the download queue.
|
|
||||||
|
|
||||||
After downloading, Tube Archivist tries to add new videos to already indexed playlists.
|
|
||||||
|
|
||||||
## Add to Download Queue
|
|
||||||
The **Add to Download Queue** icon <img src="assets/icon-add.png?raw=true" alt="add icon" width="20px" style="margin:0 5px;"> opens a text field to manually add videos to the download queue. You have a few options:
|
|
||||||
- Add a link to a YouTube video. For example *https://www.youtube.com/watch?v=2tdiKTSdE9Y*.
|
|
||||||
- Add a YouTube video ID. For example *2tdiKTSdE9Y*.
|
|
||||||
- Add a link to a YouTube video by providing the shortened URL, for example *https://youtu.be/2tdiKTSdE9Y*.
|
|
||||||
- Add a Channel ID or Channel URL to add every available video to the download queue. This will ignore the channel page size as described before and is meant for an initial download of the whole channel. You can still ignore selected videos before starting the download.
|
|
||||||
- Add a channel name like for example *https://www.youtube.com/c/TomScottGo*.
|
|
||||||
- Add a playlist ID or URL to add every available video in the list to the download queue, for example *https://www.youtube.com/playlist?list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha* or *PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha*.
|
|
||||||
- Note: When adding a playlist to the queue, this playlist will automatically get [indexed](Playlists#playlist-detail).
|
|
||||||
- Note: When you add a link to a video in a playlist, Tube Archivist assumes you want to download only the specific video and not the whole playlist, for example *https://www.youtube.com/watch?v=CINVwWHlzTY&list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha* will only add one video *CINVwWHlzTY* to the queue.
|
|
||||||
- Add one link per line.
|
|
||||||
|
|
||||||
## The Download Queue
|
|
||||||
Below the three buttons you find the download queue. New items will get added at the bottom of the queue, the next video to download once you click on **Start Download** will be the first in the list.
|
|
||||||
|
|
||||||
You can filter the download queue with the **filter** dropdown box, the filter will show once you have more than one channel in the download queue. Select the channel to filter by name, the number in parentheses indicates how many videos you have pending from this channel. Reset the filter by selecting *all* from the dropdown. This will generate links for the top 30 channels with pending videos.
|
|
||||||
|
|
||||||
Every video in the download queue has two buttons:
|
|
||||||
- **Ignore**: This will remove that video from the download queue and this video will not get added again, even when you **Rescan Subscriptions**.
|
|
||||||
- **Download now**: This will give priority to this video. If the download process is already running, the prioritized video will get downloaded as soon as the current video is finished. If there is no download process running, this will start downloading this single video and stop after that.
|
|
||||||
|
|
||||||
You can flip the view by activating **Show Only Ignored Videos**. This will show all videos you have previously *ignored*.
|
|
||||||
Every video in the ignored list has two buttons:
|
|
||||||
- **Forget**: This will delete the item form the ignored list.
|
|
||||||
- **Add to Queue**: This will add the ignored video back to the download queue.
|
|
||||||
|
|
||||||
You can delete your download queue from the [Settings](Settings#actions) page.
|
|
||||||
|
|||||||
37
docs/FAQ.md
@@ -1,36 +1,3 @@
|
|||||||
# Frequently Asked Questions
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
|
|
||||||
## 1. Scope of this project
|
Equivalent FAQ page is: [here](https://docs.tubearchivist.com/faq/).
|
||||||
Tube Archivist is *Your self hosted YouTube media server*, which also defines the primary scope of what this project tries to do:
|
|
||||||
- **Self hosted**: This assumes you have full control over the underlying operating system and hardware and can configure things to work properly with Docker, it's volumes and networks as well as whatever disk storage and filesystem you choose to use.
|
|
||||||
- **YouTube**: Downloading, indexing and playing videos from YouTube, there are currently no plans to expand this to any additional platforms.
|
|
||||||
- **Media server**: This project tries to be a stand alone media server in it's own web interface.
|
|
||||||
|
|
||||||
Additionally to that, progress is also happening on:
|
|
||||||
- **API**: Endpoints for additional integrations.
|
|
||||||
- **Browser Extension**: To integrate between youtube.com and Tube Archivist.
|
|
||||||
|
|
||||||
Defining the scope is important for the success of any project:
|
|
||||||
- A scope too broad will result in development effort spreading too thin and will run into danger that his project tries to do too many things and none of them well.
|
|
||||||
- A too narrow scope will make this project uninteresting and will exclude audiences that could also benefit from this project.
|
|
||||||
- Not defining a scope will easily lead to misunderstandings and false hopes of where this project tries to go.
|
|
||||||
|
|
||||||
Of course this is subject to change: The scope can be expanded as this project continues to grow and more people contribute.
|
|
||||||
|
|
||||||
## 2. Emby/Plex/Jellyfin/Kodi integrations
|
|
||||||
Although there are similarities between these excellent projects and Tube Archivist, they have a very different use case. Trying to fit the metadata relations and database structure of a YouTube archival project into these media servers that specialize in Movies and TV shows is always going to be limiting.
|
|
||||||
|
|
||||||
Part of the scope is to be its own media server, so that's where the focus and effort of this project is. That being said, the nature of self hosted and open source software gives you all the possible freedom to use your media as you wish.
|
|
||||||
|
|
||||||
## 3. To Docker or not to Docker
|
|
||||||
This project is a classical docker application: There are multiple moving parts that need to be able to interact with each other and need to be compatible with multiple architectures and operating systems. Additionally Docker also drastically reduces development complexity which is highly appreciated.
|
|
||||||
|
|
||||||
So Docker is the only supported installation method. If you don't have any experience with Docker, consider investing the time to learn this very useful technology.
|
|
||||||
|
|
||||||
## 4. Finetuning Elasticsearch
|
|
||||||
A minimal configuration of Elasticsearch (ES) is provided in the example docker-compose.yml file. ES is highly configurable and very interesting to learn more about. Refer to the [documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) if you want to get into it.
|
|
||||||
|
|
||||||
## 5. When I subscribe to a channel it only downloads the most recent 50 videos
|
|
||||||
Subscribing to a channel is a different operation from downloading it. You can [add the channel to the download queue](https://github.com/tubearchivist/tubearchivist/wiki/Downloads#add-to-download-queue) to download all past videos.
|
|
||||||
|
|
||||||
If you want to download the existing videos and also automatically download new videos, then you should both download the channel and subscribe to it.
|
|
||||||
|
|||||||
42
docs/Home.md
@@ -1,41 +1 @@
|
|||||||
# Tube Archivist Wiki
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
Welcome to the official Tube Archivist Wiki. This is an up-to-date documentation of user functionality.
|
|
||||||
|
|
||||||
Table of contents:
|
|
||||||
* [FAQ](FAQ): Frequently asked questions what this project is and tries to do
|
|
||||||
* [Channels](Channels): Browse your channels, handle channel subscriptions
|
|
||||||
* [Playlists](Playlists): Browse your indexed playlists, handle playlist subscriptions
|
|
||||||
* [Downloads](Downloads): Scanning subscriptions, handle download queue
|
|
||||||
* [Settings](Settings): All the configuration options
|
|
||||||
* [Video](Video): All details of a single video and playlist navigation.
|
|
||||||
* [Users](Users): User management admin interface
|
|
||||||
* [Search](Search): Search your archive
|
|
||||||
* [Installation](Installation): Detailed installation instructions for various platforms.
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
1. [Subscribe](Channels#channels-overview) to some of your favourite YouTube channels.
|
|
||||||
2. [Scan](Downloads#rescan-subscriptions) subscriptions to add the latest videos to the download queue.
|
|
||||||
3. [Add](Downloads#add-to-download-queue) additional videos, channels or playlist - ignore the ones you don't want to download.
|
|
||||||
4. [Download](Downloads#download-queue) and let **Tube Archivist** do it's thing.
|
|
||||||
5. Sit back and enjoy your archived and indexed collection!
|
|
||||||
|
|
||||||
## General Navigation
|
|
||||||
* Clicking on the channel name or the channel icon brings you to the dedicated channel page to show videos from that channel.
|
|
||||||
* Clicking on a video title brings you to the dedicated video page and shows additional details.
|
|
||||||
* Clicking on a video thumbnail opens the video player and starts streaming the selected video.
|
|
||||||
* Clicking on the search icon <img src="assets/icon-search.png?raw=true" alt="gridview icon" width="20px" style="margin:0 5px;"> will open a dedicated search page to search over your complete index.
|
|
||||||
* The pagination - if available - builds links for up to 10'000 results, use the search, sort or filter functionality to find what you are looking for.
|
|
||||||
|
|
||||||
|
|
||||||
An empty checkbox icon <img src="assets/icon-unseen.png?raw=true" alt="unseen icon" width="20px" style="margin:0 5px;"> will show for videos you haven't marked as watched. Click on it and the icon will change to a filled checkbox <img src="assets/icon-seen.png?raw=true" alt="seen icon" width="20px" style="margin:0 5px;"> indicating it as watched - click again to revert.
|
|
||||||
|
|
||||||
When available the <img src="assets/icon-gridview.png?raw=true" alt="gridview icon" width="20px" style="margin:0 5px;"> gridview icon will display the list in a grid. A grid row holds 3 items by default, use the <img src="assets/icon-add.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> icon to add more or the <img src="assets/icon-substract.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> icon to remove items per row, depending on your screen size. The <img src="assets/icon-listview.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> listview icon will arrange the items in a list. The sort icon <img src="assets/icon-sort.png?raw=true" alt="listview icon" width="20px" style="margin:0 5px;"> will open additional sort options.
|
|
||||||
|
|
||||||
You can control the video player with the following keyboard shortcuts:
|
|
||||||
- `?`: Show help
|
|
||||||
- `m`: toggle mute
|
|
||||||
- `c`: toggle subtitles if available
|
|
||||||
- `>`: increase playback speed
|
|
||||||
- `<`: decrease playback speed
|
|
||||||
- `←` (left arrow): jump back 5 seconds
|
|
||||||
- `→` (right arrow): jump forward 5 seconds
|
|
||||||
@@ -1,285 +1,3 @@
|
|||||||
# Detailed Installation Instructions for Various Platforms
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
|
|
||||||
## Table of Contents
|
Equivalent pages are located under *installation* on the left.
|
||||||
- [Podman](#podman)
|
|
||||||
- [Unraid](#unraid)
|
|
||||||
- [Truenas Scale](#truenas-scale)
|
|
||||||
- [Synology](#synology)
|
|
||||||
|
|
||||||
These are beginners guides installation instructions for additional platforms generously provided by users of these platforms. When in doubt, verify the details with the [Readme](https://github.com/tubearchivist/tubearchivist#installing-and-updating). If you see any issues here while using these instructions, please contribute.
|
|
||||||
|
|
||||||
## Podman
|
|
||||||
Podman handles container hostname resolving slightly differently than docker, so you need to make a few changes to the `docker-compose.yml` to get up and running.
|
|
||||||
|
|
||||||
### Step 1: Follow the installation instructions from the [README](https://github.com/tubearchivist/tubearchivist#installing-and-updating), with a few additional changes to the `docker-compose.yml`.
|
|
||||||
|
|
||||||
Edit these additional changes to the `docker-compose.yml`:
|
|
||||||
- under `tubearchivist`->`image`:
|
|
||||||
- prefix the container name with `docker.io/` (or the url of your repo of choice).
|
|
||||||
- under `tubearchivist`->`environment`:
|
|
||||||
- `ES_URL`: change `archivist-es` to the internal IP of the computer that will be running the containers.
|
|
||||||
- `REDIS_HOST`: change `archivist-redis` to the internal IP of the computer that will be running the containers (should be the same as above).
|
|
||||||
- under `archivist-redis`->`image`:
|
|
||||||
- prefix the container name with `docker.io/` again.
|
|
||||||
- under `archivist-redis`->`expose`:
|
|
||||||
- change the whole entry from `expose: ["<PORT>"]` into `ports: ["<PORT>:<PORT>"].
|
|
||||||
- under `archivist-es`->`image`:
|
|
||||||
- prefix the container name with `docker.io/` again.
|
|
||||||
- under `archivist-es`->`expose`:
|
|
||||||
- change the whole entry from `expose: ["<PORT>"]` into `ports: ["<PORT>:<PORT>"].
|
|
||||||
|
|
||||||
### Step 2: Create service files (optional)
|
|
||||||
|
|
||||||
Since podman doesn't run as a service, it can't start containers after reboots, at least not without some help.
|
|
||||||
|
|
||||||
If you want to enable this behavior, you can follow [this example](https://techblog.jeppson.org/2020/04/create-podman-services-with-podman-compose/) to have `systemd` start up the containers with `podman-compose` when the computer boots up.
|
|
||||||
|
|
||||||
|
|
||||||
## Unraid
|
|
||||||
|
|
||||||
Tube Archivist, and all if it's dependencies are located in the [community applications](https://forums.unraid.net/topic/38582-plug-in-community-applications/) store. The three containers you will need are as follows:
|
|
||||||
|
|
||||||
- **TubeArchivist-RedisJSON**: This container acts as a cache and temporary link between the application and the file system. Used to store and display messages and configuration variables.
|
|
||||||
- **TubeArchivist-ES**: ElasticSearch stores video meta data and makes everything searchable. Also keeps track of the download queue.
|
|
||||||
- **TubeArchivist**: 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.
|
|
||||||
|
|
||||||
### Step 1: Install `TubeArchivist-RedisJSON`
|
|
||||||
|
|
||||||

|
|
||||||
This is the easiest container to setup of the thee, just make sure that you do not have any port conflicts, and that your `/data` is mounted to the correct path. The other containers will map to the same directory.
|
|
||||||
|
|
||||||
If you need to install `TubeArchivist-RedisJSON`on a different port, you'll have to follow [these steps](https://github.com/tubearchivist/tubearchivist#redis-on-a-custom-port) later on when installing the `TubeArchivist` container
|
|
||||||
|
|
||||||
|
|
||||||
### Step 2: Install `TubeArchivist-ES`
|
|
||||||

|
|
||||||
ElasticSeach is also pretty easy to setup. Again, make sure you have no port conflicts, make sure that you mapped `/usr/share/elasticsearch/data` to the same directory as `RedisJSON`, and make sure to change the default password to something more secure.
|
|
||||||
|
|
||||||
There is three additional settings in the "show more settings" area, but leave those as they are.
|
|
||||||
|
|
||||||
|
|
||||||
### Step 3: Install `TubeArchivist`
|
|
||||||
|
|
||||||

|
|
||||||
It's finally time to set up TubeArchivist!
|
|
||||||
|
|
||||||
- `Port:`Again, make sure that you have no port conflicts on 8000.
|
|
||||||
|
|
||||||
- `Youtube Media Path:` is where you'll download all of your videos to.
|
|
||||||
Make sure that this is an empty directory to not cause confusion when
|
|
||||||
starting the application. If you have existing videos that you'd like
|
|
||||||
to import into Tube Archivist, please checkout the [settings
|
|
||||||
wiki.](https://github.com/tubearchivist/tubearchivist/wiki/Settings#manual-media-files-import)
|
|
||||||
|
|
||||||
|
|
||||||
- `Appdata:` This should be the same base path as the other two containers.
|
|
||||||
|
|
||||||
- `TA Username:`This will be your username for TubeArchivist.
|
|
||||||
|
|
||||||
- `TA Password:`This will be your password for TubeArchivist.
|
|
||||||
|
|
||||||
- `Redis` This will be JUST the ip address of your redis container
|
|
||||||
|
|
||||||
- `ElasticSearch Password:`This is the password you defined in the `TubeArchivist-ES` container.
|
|
||||||
- `ElasticSearch:` This seems to cause some confusion, but it's a pretty simple step, just replace the IP and Port to match you `TubeArchivist-ES` container.
|
|
||||||
|
|
||||||
(example: if your IP is 192.168.1.15, the value should be http://192.168.1.15:9200)
|
|
||||||
|
|
||||||
- `Time Zone:` This is an important step for your scheduler, to find your timezone, use a site like [TimeZoneConverter](http://www.timezoneconverter.com/cgi-bin/findzone.tzc)
|
|
||||||
|
|
||||||
### From there, you should be able to start up your containers and you're good to go!
|
|
||||||
If you're still having trouble, join us on [discord](https://discord.gg/AFwz8nE7BK) and come to the #unraid channel.
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
## Truenas Scale
|
|
||||||
|
|
||||||
Truenas Scale can be a bit confusing, with its k3s kubernetes implementation.
|
|
||||||
|
|
||||||
However, there is a step by step guide available for it's users here:
|
|
||||||
|
|
||||||
https://heavysetup.info/applications/tube-archivist/dataset/
|
|
||||||
|
|
||||||
- Ensure you are navigating the columns under `Tube Archivist` on the left hand side of the screen
|
|
||||||
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
## Synology
|
|
||||||
|
|
||||||
There are several different methods to install TubeArchivist on Synology platforms. This will focus on the available `docker` package and `docker-compose` implementations.
|
|
||||||
|
|
||||||
### Prepare Directories/Folders
|
|
||||||
Before we setup TubeArchivist, we need to setup the directories/folders. You are assumed to be logged into the Synology NAS.
|
|
||||||
#### 1. Docker Base Folder
|
|
||||||
1. Open the `File Station` utility.
|
|
||||||
2. Click on the **Create🔽** button and choose *Create New Shared Folder*.
|
|
||||||
3. **Name** the folder "Docker".
|
|
||||||
4. Add a **Description**.
|
|
||||||
5. Select the **Volume Location**.
|
|
||||||
> Note: By default, this will be where all data is stored. Change the folders as best meets your requirements.
|
|
||||||
6. Select the appropriate options from the remaining checkbox configurations.
|
|
||||||

|
|
||||||
7. Click the **Next** button.
|
|
||||||
8. If you are going to **Encrypt** your folder, check the appropriate box and provide the Encryption Key and its confirmation.
|
|
||||||
9. Click the **Next** button.
|
|
||||||
10. On the **Advanced Settings** page, you can select the *Enable data checksum for advanced data integrity* setting. This may cause a performance impact, but will allow for potential file self-healing. **This cannot be changed later.**
|
|
||||||
> Note: This is not recommended, as we will be hosting databases within this folder.
|
|
||||||
11. If you are enabling a quota for how large the folder can get, you can select the *Enabled shared folder quota* setting and choose the maximum size this folder can grow. This can be changed later.
|
|
||||||
12. Click the **Next** button.
|
|
||||||
13. Confirm the settings, then click the **Apply** button. This will create the folder.
|
|
||||||
#### 2. TubeArchivist Base Folder
|
|
||||||
1. Open the `File Station` utility.
|
|
||||||
2. Select the "Docker" folder on the left-hand side.
|
|
||||||
3. Click on the `Create🔽` button and choose *create Folder*.
|
|
||||||
4. **Name** the folder "TubeArchivist".
|
|
||||||
#### 3. Redis Data
|
|
||||||
1. Open the `File Station` utility.
|
|
||||||
2. Select the "Docker" folder on the left-hand side.
|
|
||||||
3. Select the "TubeArchivist" folder beneath "Docker".
|
|
||||||
4. Click on the `Create🔽` button and choose *create Folder*.
|
|
||||||
5. **Name** the folder "redis".
|
|
||||||
#### 4. Elastic Search Data
|
|
||||||
1. Open the `File Station` utility.
|
|
||||||
2. Select the "Docker" folder on the left-hand side.
|
|
||||||
3. Select the "TubeArchivist" folder beneath "Docker".
|
|
||||||
4. Click on the `Create🔽` button and choose *create Folder*.
|
|
||||||
5. **Name** the folder "es".
|
|
||||||
#### 5. TubeArchivist Cache
|
|
||||||
1. Open the `File Station` utility.
|
|
||||||
2. Select the "Docker" folder on the left-hand side.
|
|
||||||
3. Select the "TubeArchivist" folder beneath "Docker".
|
|
||||||
4. Click on the `Create🔽` button and choose *create Folder*.
|
|
||||||
5. **Name** the folder "cache".
|
|
||||||
#### 6. TubeArchivist Output
|
|
||||||
1. Open the `File Station` utility.
|
|
||||||
2. Select the "Docker" folder on the left-hand side.
|
|
||||||
3. Select the "TubeArchivist" folder beneath "Docker".
|
|
||||||
4. Click on the `Create🔽` button and choose *create Folder*.
|
|
||||||
5. **Name** the folder "media".
|
|
||||||
#### 7. Confirm Folder Structure
|
|
||||||
Once all of the folders have been created, it should have a folder structure within Docker\TubeArchivist that includes "cache", "es", "media", and "redis" folders.
|
|
||||||

|
|
||||||
|
|
||||||
#### 8. Change Permissions - CLI Required
|
|
||||||
> If you do not have SSH access enabled for CLI, [enable it](https://kb.synology.com/en-sg/DSM/tutorial/How_to_login_to_DSM_with_root_permission_via_SSH_Telnet) before continuing.
|
|
||||||
1. Open the SSH connection to the Synology. Login as your primary `Admin` user, or the user that was enabled for SSH access.
|
|
||||||
2. Elevate your access to `root`. Steps are provided [here](https://kb.synology.com/en-sg/DSM/tutorial/How_to_login_to_DSM_with_root_permission_via_SSH_Telnet).
|
|
||||||
3. Change directories to the **Volume** where the "Docker" folder resides.
|
|
||||||
</br>Example: `cd /volume1`
|
|
||||||
4. Change directories to the "Docker" folder.
|
|
||||||
</br>Example: `cd Docker`
|
|
||||||
5. Change directories to the "TubeArchivist" folder.
|
|
||||||
</br>Example: `cd TubeArchivist`
|
|
||||||
6. Change the owner of the "redis" folder. *If correct, this does not have an output.*
|
|
||||||
</br>Example: `chown 999:100 redis`
|
|
||||||
7. Change the owner of the "es" folder. *If correct, this does not have an output.*
|
|
||||||
</br>Example: `chown 1000:1000 es`
|
|
||||||
8. Confirm that the folders have the correct permissions.
|
|
||||||
</br>Example: `ls -hl`
|
|
||||||

|
|
||||||
9. Logout from root.
|
|
||||||
</br>Example: `logout`
|
|
||||||
10. Disconnect from the SSH connection.
|
|
||||||
</br>Example: `exit`
|
|
||||||
### Docker Setup
|
|
||||||
1. Install the `Docker` Synology Package.
|
|
||||||
1. Log in to your Synology NAS.
|
|
||||||
2. Open the `Package Center` utility.
|
|
||||||
3. Search for `Docker`.
|
|
||||||
4. Click `Install`.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
2. After `Docker` is installed, open the `Docker` utility.
|
|
||||||
3. Go to the `Registry` tab.
|
|
||||||
4. Search for the following `images` and download them. Follow the recommended versions for each of the images.
|
|
||||||
- `redislabs/rejson`
|
|
||||||

|
|
||||||
- `bbilly1/tubearchivist-es`
|
|
||||||

|
|
||||||
- `bbilly1/tubearchivist`
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
5. Go to the `Image` tab. From here, create an container based on each image with the associated configurations below.
|
|
||||||
- ElasticSearch
|
|
||||||
1. Select the associated image.
|
|
||||||
2. Click the **Launch** button in the top.
|
|
||||||
3. Edit the **Container Name** to be "tubearchivist-es".
|
|
||||||
4. Click on the **Advanced Settings** button.
|
|
||||||
5. In the **Advanced Settings** tab, check the box for `Enable auto-restart`.
|
|
||||||
6. In the **Volume** tab, click the **Add Folder** button and select the "Docker/TubeArchivist/es" folder, then type in `/usr/share/elasticsearch/data` for the mount path.
|
|
||||||
7. In the **Network** tab, leave the default `bridge` Network (unless you have a specific Network design that you know how to implement).
|
|
||||||
8. In the **Port Settings** tab, replace the "Auto" entry under **Local Port** with the port that will be used to connect to ElasticSearch (default is 9200).
|
|
||||||
9. In the **Port Settings** tab, select the entryline for port 9300 and **➖ delete** the line. It is not needed for this container.
|
|
||||||
10. The **Links** tab does not require configuration for this container.
|
|
||||||
11. In the **Environment** tab, add in the following ElasticSearch specific environment variables that may apply.
|
|
||||||
- "discovery.type=single-node"
|
|
||||||
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
|
|
||||||
- "UID=1000"
|
|
||||||
- "GID=0"
|
|
||||||
- "xpack.security.enabled=true"
|
|
||||||
- "ELASTIC_PASSWORD=verysecret"
|
|
||||||
- "path.repo=/usr/share/elasticsearch/data/snapshot"
|
|
||||||
> Do not use the default password as it is very insecure.
|
|
||||||
> Activating snapshots for backups should only be done *after* setting the `path.repo` setting.
|
|
||||||

|
|
||||||
12. Click on the **Apply** button.
|
|
||||||
13. Back on the **Create Container** screen, click the **Next** button.
|
|
||||||
14. Review the settings to confirm, then click the **Apply** button.
|
|
||||||
- Redis
|
|
||||||
1. Select the associated image.
|
|
||||||
2. Click the **Launch** button in the top.
|
|
||||||
3. Edit the **Container Name** to be "tubearchivist-redis".
|
|
||||||
4. Click on the **Advanced Settings** button.
|
|
||||||
5. In the **Advanced Settings** tab, check the box for `Enable auto-restart`.
|
|
||||||
6. In the **Volume** tab, click the **Add Folder** button and select the "Docker/TubeArchivist/redis" folder, then type in `/data` for the mount path.
|
|
||||||
7. In the **Network** tab, leave the default `bridge` Network (unless you have a specific Network design that you know how to implement).
|
|
||||||
8. In the **Port Settings** tab, replace the "Auto" entry under **Local Port** with the port that will be used to connect to Redis (default is 6379).
|
|
||||||
9. In the **Links** tab, select the "tubearchivist-es" container from the **Container Name** dropdown and provide it the same alias, "tubearchivist-es".
|
|
||||||
10. In the **Environment** tab, add in any Redis specific environment variables that may apply (none by default).
|
|
||||||
11. Click on the **Apply** button.
|
|
||||||
12. Back on the **Create Container** screen, click the **Next** button.
|
|
||||||
13. Review the settings to confirm, then click the **Apply** button.
|
|
||||||
|
|
||||||
- TubeArchivist
|
|
||||||
1. Select the associated image.
|
|
||||||
2. Click the **Launch** button in the top.
|
|
||||||
3. Edit the **Container Name** to be "tubearchivist".
|
|
||||||
4. Click on the **Advanced Settings** button.
|
|
||||||
5. In the **Advanced Settings** tab, check the box for `Enable auto-restart`.
|
|
||||||
6. In the **Volume** tab, click the **Add Folder** button and select the "Docker/TubeArchivist/cache" folder, then type in `/cache` for the mount path.
|
|
||||||
7. In the **Volume** tab, click the **Add Folder** button and select the "Docker/TubeArchivist/media" folder, then type in `/youtube` for the mount path.
|
|
||||||
8. In the **Network** tab, leave the default `bridge` Network (unless you have a specific Network design that you know how to implement).
|
|
||||||
9. In the **Port Settings** tab, replace the "Auto" entry under **Local Port** with the port that will be used to connect to TubeArchivist (default is 8000).
|
|
||||||
10. In the **Links** tab, select the "tubearchivist-es" container from the **Container Name** dropdown and provide it the same alias, "tubearchivist-es".
|
|
||||||
11. In the **Links** tab, select the "tubearchivist-redis" container from the **Container Name** dropdown and provide it the same alias, "tubearchivist-redis".
|
|
||||||
12. In the **Environment** tab, add in the following TubeArchivist specific environment variables that may apply. **Change the variables as-is appropriate to your use case. Follow the [README section](https://github.com/tubearchivist/tubearchivist#tube-archivist) for details on what to set each variable.**
|
|
||||||
- "TA_HOST=synology.local"
|
|
||||||
- "ES_URL=http://tubearchivist-es:9200"
|
|
||||||
- "REDIS_HOST=tubearchivist-redis"
|
|
||||||
- "HOST_UID=1000"
|
|
||||||
- "HOST_GID=1000"
|
|
||||||
- "TA_USERNAME=tubearchivist"
|
|
||||||
- "TA_PASSWORD=verysecret"
|
|
||||||
- "ELASTIC_PASSWORD=verysecret"
|
|
||||||
- "TZ=America/New_York"
|
|
||||||
> Do not use the default password as it is very insecure.
|
|
||||||
> Ensure that ELASTIC_PASSWORD matches the password used on the tubearchivist-es container.
|
|
||||||

|
|
||||||
13. Click on the **Apply** button.
|
|
||||||
14. Back on the **Create Container** screen, click the **Next** button.
|
|
||||||
15. Review the settings to confirm, then click the **Apply** button.
|
|
||||||
6. After the containers have been configured and started, you can go to the **Container** tab and monitor the containers.
|
|
||||||
7. To review the logs to ensure that the system has started successfully, select the "tubearchivist" container and click on the **Details** button. In the new window, go to the **Log** tab. Monitor the logs until either an error occurs or the message `celery@tubearchivist ready.` is in the logs. This may take a few minutes, especially for a first time setup.
|
|
||||||
> Note: Synology Docker presents the logs in a pagination format. If you are not seeing the logs update, check if there are additional pages.
|
|
||||||
8. After it has started, go to the location in the `TA_HOST`. This should give you the standard TubeArchivist login screen.
|
|
||||||
<!--
|
|
||||||
### Docker-Compose Setup -->
|
|
||||||
<!-- This section is a Work In Progress -->
|
|
||||||
|
|
||||||
### From there, you should be able to start up your containers and you're good to go!
|
|
||||||
If you're still having trouble, join us on [discord](https://discord.gg/AFwz8nE7BK) and come to the #synology channel.
|
|
||||||
|
|||||||
@@ -1,23 +1,3 @@
|
|||||||
# Playlist Overview and Playlist Detail Page
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
The playlists are organized in two different levels, similar as the [channels](Channels):
|
|
||||||
|
|
||||||
## Playlist Overview
|
Equivalent Playlist page is: [here](https://docs.tubearchivist.com/playlists/).
|
||||||
Accessible at `/playlist/` of your Tube Archivist, this **Overview Page** shows a list of all playlists you have indexed over all your channels.
|
|
||||||
- You can filter that list to show only subscribed to playlists with the toggle.
|
|
||||||
|
|
||||||
You can index playlists of a channel from the channel detail page as described [here](Channels#channel-detail).
|
|
||||||
|
|
||||||
The **Subscribe to Playlist** button <img src="assets/icon-add.png?raw=true" alt="add icon" width="20px" style="margin:0 5px;"> opens a text field to subscribe to playlists. You have a few options:
|
|
||||||
- Enter the YouTube playlist id, for example: *PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha*
|
|
||||||
- Enter the Youtube dedicated playlist url, for example: *https://www.youtube.com/playlist?list=PL96C35uN7xGLLeET0dOWaKHkAlPsrkcha*
|
|
||||||
- Add one per line.
|
|
||||||
- NOTE: It doesn't make sense to subscribe to a playlist if you are already subscribed the corresponding channel as this will slow down the **Rescan Subscriptions** [task](Downloads#rescan-subscriptions).
|
|
||||||
|
|
||||||
You can search your indexed playlists by clicking on the search icon <img src="assets/icon-search.png?raw=true" alt="search icon" width="20px" style="margin:0 5px;">. This will open a dedicated page.
|
|
||||||
|
|
||||||
## Playlist Detail
|
|
||||||
Each playlist will get a dedicated playlist detail page accessible at `/playlist/<playlist-id>/` of your Tube Archivist. This page shows all the videos you have downloaded from this playlist.
|
|
||||||
|
|
||||||
- If you are subscribed to the playlist, an Unsubscribe button will show, else the Subscribe button will show.
|
|
||||||
- The Mark as Watched button will mark all videos of this playlist as watched.
|
|
||||||
- The **Delete Playlist** button will give you the option to delete just the *metadata* which won't delete any media files or *delete all* which will delete metadata plus all videos belonging to this playlist.
|
|
||||||
|
|||||||
@@ -1,57 +1,3 @@
|
|||||||
# Search Page
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
Accessible at `/search/` of your **Tube Archivist**, search your archive for Videos, Channels and Playlists - or even full text search throughout your indexed subtitles.
|
|
||||||
|
|
||||||
- All your queries are case insensitive and are normalized to lowercase.
|
Equivalent Search page is: [here](https://docs.tubearchivist.com/search/).
|
||||||
- All your queries are analyzed for the english language, this means *singular*, *plural* and word variations like *-ing*, *-ed*, *-able* etc are treated as synonyms.
|
|
||||||
- Fuzzy search is activated for all your searches by default. This can catch typos in your queries or in the matching documents with one to two letters difference, depending on the query length. You can configure fuzziness with the secondary keyword `fuzzy:`, e.g:
|
|
||||||
- `fuzzy:0` or `fuzzy:no`: Deactivate fuzzy matching.
|
|
||||||
- `fuzzy:1`: Set fuzziness to one letter difference.
|
|
||||||
- `fuzzy:2`: Set fuzziness to two letters difference.
|
|
||||||
- All text searches are ranked, meaning the better a match the higher ranked the result. Unless otherwise stated, queries with multiple words are processed with the `and` operator, meaning all words need to match so each word will narrow down the result.
|
|
||||||
- This will return 30 results per query, pagination is not implemented yet.
|
|
||||||
|
|
||||||
Just start typing to start a *simple* search or start your query with a primary keyword to search for a specific type and narrow down the result with secondary keywords. Secondary keywords can be in any order. Use *yes* or *no* for boolean values.
|
|
||||||
|
|
||||||
## Simple
|
|
||||||
Start your query without a keyword to make a simple query. This will search in *video titles*, *channel names* and *playlist titles* and will return matching videos, channels and playlists. Keyword searches will return more results in a particular category due to the fact that more fields are searched for matches.
|
|
||||||
|
|
||||||
## Video
|
|
||||||
Start your query with the primary keyword `video:` to search for videos only. This will search through the *video titles*, *tags* and *category* fields. Narrow your search down with secondary keywords:
|
|
||||||
- `channel:` search for videos matching the channel name.
|
|
||||||
- `active:` is a boolean value, to search for videos that are still active on youtube or that are not active any more.
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
- `video:learn python channel:corey shafer active:yes`: This will return all videos with the term *Learn Python* from the channel *Corey Shafer* that are still *Active* on YouTube.
|
|
||||||
- `video: channel:tom scott active:no`: Note the omitted term after the primary key, this will show all videos from the channel *Tom Scott* that are no longer active on YouTube.
|
|
||||||
|
|
||||||
## Channel
|
|
||||||
Start with the `channel:` primary keyword to search for channels matching your query. This will search through the *channel name* and *channel description* fields. Narrow your search down with secondary keywords:
|
|
||||||
- `subscribed:` is a boolean value, search for channels that you are subscribed to or not.
|
|
||||||
- `active:` is a boolean value, to search for channels that are still active on YouTube or that are no longer active.
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
- `channel:linux subscribed:yes`: Search for channels with the term *Linux* that you are subscribed to.
|
|
||||||
- `channel: active:no`: Note the omitted term after the primary key, this will return all channels that are no longer active on YouTube.
|
|
||||||
|
|
||||||
## Playlist
|
|
||||||
Start your query with the primary keyword `playlist:` to search for playlists only. This will search through the *playlist title* and *playlist description* fields. Narrow down your search with these secondary keywords:
|
|
||||||
- `subscribed`: is a boolean value, search for playlists that you are subscribed to or not.
|
|
||||||
- `active:` is a boolean value, to search for playlists that are still active on YouTube or that are no longer active.
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
- `playlist:backend engineering subscribed:yes`: Search for playlists about *Backend Engineering* that you are subscribed to.
|
|
||||||
- `playlist: active:yes subscribed:yes`: Note the omitted primary search term, this will return all playlists active on YouTube that you are subscribed to.
|
|
||||||
- `playlist:html css active:yes`: Search for playlists containing *HTML CSS* that are still active on YouTube.
|
|
||||||
|
|
||||||
## Full
|
|
||||||
Start a full text search by beginning your query with the primary keyword `full:`. This will search through your indexed Subtitles showing segments with possible matches. This will only show any results if you have activated *subtitle download and index* on the settings page. The operator for full text searches is `or` meaning when searching for multiple words not all words need to match, but additional words will change the ranking of the result, the more words match and the better they match, the higher ranked the result. The matching words will get highlighted in the text preview.
|
|
||||||
|
|
||||||
Clicking the play button on the thumbnail will open the inplace player at the timestamp from where the segment starts. Same when clicking the video title, this will open the video page and put the player at the segment timestamp. This will overwrite any previous playback position.
|
|
||||||
|
|
||||||
Narrow down your search with these secondary keywords:
|
|
||||||
- `lang`: Search for matches only within a language. Use the same two letter ISO country code as you have set on the settings page.
|
|
||||||
- `source`: Can either be *auto* to search through auto generated subtitles only or *user* to search through user uploaded subtitles only.
|
|
||||||
|
|
||||||
**Example**:
|
|
||||||
- `full:contribute to open source lang:en` search for subtitle segments matching with the words *Contribute to Open Source* in the language *en*.
|
|
||||||
- `full:flight simulator cockpit source:user` to search for the words *Flight Simulator Cockpit* from *user* uploaded subtitle segments.
|
|
||||||
|
|||||||
197
docs/Settings.md
@@ -1,196 +1,3 @@
|
|||||||
# Settings Page
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
Accessible at `/settings/` of your **Tube Archivist**, this page holds all the configurations and additional functionality related to the database.
|
|
||||||
|
|
||||||
Click on **Update Settings** at the bottom of the form to apply your configurations.
|
Equivalent Settings page is: [here](https://docs.tubearchivist.com/settings/).
|
||||||
|
|
||||||
## Color scheme
|
|
||||||
Switch between the easy on the eyes dark theme and the burning bright theme.
|
|
||||||
|
|
||||||
## Archive View
|
|
||||||
- **Page Size**: Defines how many results get displayed on a given page. Same value goes for all archive views.
|
|
||||||
|
|
||||||
## Subscriptions
|
|
||||||
Settings related to the channel management.
|
|
||||||
- **Channel Page Size**: Defines how many pages will get analyzed by **Tube Archivist** each time you click on *Rescan Subscriptions*. The default page size used by yt-dlp is **50**, that's also the recommended value to set here. Any value higher will slow down the rescan process, for example if you set the value to 51, that means yt-dlp will have to go through 2 pages of results instead of 1 and by that doubling the time that process takes.
|
|
||||||
|
|
||||||
## Downloads
|
|
||||||
Settings related to the download process.
|
|
||||||
- **Download Limit**: Stop the download process after downloading the set quantity of videos.
|
|
||||||
- **Download Speed Limit**: Set your download speed limit in KB/s. This will pass the option `--limit-rate` to yt-dlp.
|
|
||||||
- **Throttled Rate Limit**: Restart download if the download speed drops below this value in KB/s. This will pass the option `--throttled-rate` to yt-dlp. Using this option might have a negative effect if you have an unstable or slow internet connection.
|
|
||||||
- **Sleep Interval**: Time in seconds to sleep between requests to YouTube. It's a good idea to set this to **3** seconds. Might be necessary to avoid throttling.
|
|
||||||
- **Auto Delete Watched Videos**: Automatically delete videos marked as watched after selected days. If activated, checks your videos after download task is finished.
|
|
||||||
|
|
||||||
## Download Format
|
|
||||||
Additional settings passed to yt-dlp.
|
|
||||||
- **Format**: This controls which streams get downloaded and is equivalent to passing `--format` to yt-dlp. Use one of the recommended one or look at the documentation of [yt-dlp](https://github.com/yt-dlp/yt-dlp#format-selection). Please note: The option `--merge-output-format mp4` is automatically passed to yt-dlp to guarantee browser compatibility. Similar to that, `--check-formats` is passed as well to check that the selected formats are actually downloadable.
|
|
||||||
- **Embed Metadata**: This saves the available tags directly into the media file by passing `--embed-metadata` to yt-dlp.
|
|
||||||
- **Embed Thumbnail**: This will save the thumbnail into the media file by passing `--embed-thumbnail` to yt-dlp.
|
|
||||||
|
|
||||||
## Subtitles
|
|
||||||
- **Download Setting**: Select the subtitle language you like to download. Add a comma separated list for multiple languages.
|
|
||||||
- **Source Settings**: User created subtitles are provided from the uploader and are usually the video script. Auto generated is from YouTube, quality varies, particularly for auto translated tracks.
|
|
||||||
- **Index Settings**: Enabling subtitle indexing will add the lines to Elasticsearch and will make subtitles searchable. This will increase the index size and is not recommended on low-end hardware.
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
- **Download and index comments**: Set your configuration for downloading and indexing comments. This takes the same values as documented in the `max_comments` section for the youtube extractor of [yt-dlp](https://github.com/yt-dlp/yt-dlp#youtube). Add without space between the four different fields: *max-comments,max-parents,max-replies,max-replies-per-thread*. Example:
|
|
||||||
- `all,100,all,30`: Get 100 max-parents and 30 max-replies-per-thread.
|
|
||||||
- `1000,all,all,50`: Get a total of 1000 comments over all, 50 replies per thread.
|
|
||||||
- **Comment sort method**: Change sort method between *top* or *new*. The default is *top*, as decided by YouTube.
|
|
||||||
- The [Refresh Metadata](#refresh-metadata) background task will get comments from your already archived videos, spreading the requests out over time.
|
|
||||||
|
|
||||||
Archiving comments is slow as only very few comments get returned per request with yt-dlp. Choose your configuration above wisely. Tube Archivist will download comments after the download queue finishes, your videos will be already available while the comments are getting downloaded.
|
|
||||||
|
|
||||||
## Cookie
|
|
||||||
Importing your YouTube Cookie into Tube Archivist allows yt-dlp to bypass age restrictions, gives access to private videos and your *watch later* or *liked videos*.
|
|
||||||
|
|
||||||
### Security concerns
|
|
||||||
Cookies are used to store your session and contain your access token to your google account, this information can be used to take over your account. Treat that data with utmost care as you would any other password or credential. *Tube Archivist* stores your cookie in Redis and will automatically append it to yt-dlp for every request.
|
|
||||||
|
|
||||||
### Auto import
|
|
||||||
Easiest way to import your cookie is to use the **Tube Archivist Companion** [browser extension](https://github.com/tubearchivist/browser-extension) for Firefox and Chrome.
|
|
||||||
|
|
||||||
### Alternative Manual Export your cookie
|
|
||||||
- Install **Cookies.txt** addon for [chrome](https://chrome.google.com/webstore/detail/get-cookiestxt/bgaddhkoddajcdgocldbbfleckgcbcid) or [firefox](https://addons.mozilla.org/firefox/addon/cookies-txt).
|
|
||||||
- Visit YouTube and login with whichever YouTube account you wish to use to generate the cookies.
|
|
||||||
- Click on the extension icon in the toolbar - it will drop down showing the active cookies for YT.
|
|
||||||
- Click Export to export the cookies, filename is by default *cookies.google.txt*.
|
|
||||||
|
|
||||||
### Alternative Manual Import your cookie
|
|
||||||
Place the file *cookies.google.txt* into the *cache/import* folder of Tube Archivist and enable the cookie import. Once you click on *Update Application Configurations* to save your changes, your cookie will get imported and stored internally.
|
|
||||||
|
|
||||||
Once imported, a **Validate Cookie File** button will show, where you can confirm if your cookie is working or not.
|
|
||||||
|
|
||||||
### Use your cookie
|
|
||||||
Once imported, additionally to the advantages above, your [Watch Later](https://www.youtube.com/playlist?list=WL) and [Liked Videos](https://www.youtube.com/playlist?list=LL) become a regular playlist you can download and subscribe to as any other [playlist](Playlists).
|
|
||||||
|
|
||||||
### Limitation
|
|
||||||
There is only one cookie per Tube Archivist instance, this will be shared between all users.
|
|
||||||
|
|
||||||
## Integrations
|
|
||||||
All third party integrations of TubeArchivist will **always** be *opt in*.
|
|
||||||
- **API**: Your access token for the Tube Archivist API.
|
|
||||||
- **returnyoutubedislike.com**: This will get return dislikes and average ratings for each video by integrating with the API from [returnyoutubedislike.com](https://www.returnyoutubedislike.com/).
|
|
||||||
- **SponsorBlock**: Using [SponsorBlock](https://sponsor.ajay.app/) to get and skip sponsored content. If a video doesn't have timestamps, or has unlocked timestamps, use the browser addon to contribute to this excellent project. Can also be activated and deactivated as a per [channel overwrite](Settings#channel-customize).
|
|
||||||
- **Cast**: Enabling the cast integration in the settings page will load an additional JS library from **Google**.
|
|
||||||
*NOTE*: This feature is currently broken due to an authentication issue, see #331.
|
|
||||||
Requirements:
|
|
||||||
- HTTPS: To use the cast integration HTTPS needs to be enabled, which can be done using a reverse proxy. This is a requirement by Google as communication to the cast device is required to be encrypted, but the content itself is not.
|
|
||||||
- Supported Browser:A supported browser is required for this integration such as Google Chrome. Other browsers, especially Chromium-based browsers, may support casting by enabling it in the settings.
|
|
||||||
- Subtitles: Subtitles are supported however they do not work out of the box and require additional configuration. Due to requirements by Google, to use subtitles you need additional headers which will need to be configured in your reverse proxy. See this [page](https://developers.google.com/cast/docs/web_sender/advanced#cors_requirements) for the specific requirements.
|
|
||||||
You need the following headers: Content-Type, Accept-Encoding, and Range. Note that the last two headers, Accept-Encoding and Range, are additional headers that you may not have needed previously.
|
|
||||||
Wildcards "*" cannot be used for the Access-Control-Allow-Origin header. If the page has protected media content, it must use a domain instead of a wildcard.
|
|
||||||
|
|
||||||
## Snapshots
|
|
||||||
System snapshots will automatically make daily snapshots of the Elasticsearch index. The task will start at 12pm your local time. Snapshots are deduplicated, meaning that each snapshot will only have to backup changes since the last snapshot. The initial snapshot may be slow, but subsequent runs will be much faster. There is also a cleanup function implemented, that will remove snapshots older than 30 days.
|
|
||||||
|
|
||||||
This will make a snapshot of your metadata index only, no media files or additional configuration variables you have set on the settings page will be backed up.
|
|
||||||
|
|
||||||
Due to these improvements compared to the previous backup solution, system snapshots will replace the current backup system in a future version.
|
|
||||||
|
|
||||||
Before activating system snapshots, you'll have to add one additional environment variables to the *archivist-es* container:
|
|
||||||
```
|
|
||||||
path.repo=/usr/share/elasticsearch/data/snapshot
|
|
||||||
```
|
|
||||||
The variable `path.repo` will set the folder where the snapshots will go inside the Elasticsearch container, you can't change it, but the variable needs to be set. Rebuild the container for changes to take effect, e.g `docker compose up -d`.
|
|
||||||
|
|
||||||
- **Create snapshot now**: Will start the snapshot process now, outside of the regular daily schedule.
|
|
||||||
- **Restore**: Restore your index to that point in time.
|
|
||||||
|
|
||||||
# Scheduler Setup
|
|
||||||
Schedule settings expect a cron like format, where the first value is minute, second is hour and third is day of the week. Day 0 is Sunday, day 1 is Monday etc.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- **0 15 \***: Run task every day at 15:00 in the afternoon.
|
|
||||||
- **30 8 \*/2**: Run task every second day of the week (Sun, Tue, Thu, Sat) at 08:30 in the morning.
|
|
||||||
- **0 \*/3,8-17 \***: Execute every hour divisible by 3, and every hour during office hours (8 in the morning - 5 in the afternoon).
|
|
||||||
- **0 8,16 \***: Execute every day at 8 in the morning and at 4 in the afternoon.
|
|
||||||
- **auto**: Sensible default.
|
|
||||||
- **0**: (zero), deactivate that task.
|
|
||||||
|
|
||||||
NOTE:
|
|
||||||
- Changes in the scheduler settings require a container restart to take effect.
|
|
||||||
- Cron format as *number*/*number* are none standard cron and are not supported by the scheduler, for example **0 0/12 \*** is invalid, use **0 \*/12 \*** instead.
|
|
||||||
- Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that reason, the scheduler doesn't support schedules that trigger more than once per hour.
|
|
||||||
|
|
||||||
## Rescan Subscriptions
|
|
||||||
That's the equivalent task as run from the downloads page looking through your channel and playlist and add missing videos to the download queue.
|
|
||||||
|
|
||||||
## Start download
|
|
||||||
Start downloading all videos currently in the download queue.
|
|
||||||
|
|
||||||
## Refresh Metadata
|
|
||||||
Rescan videos, channels and playlists on youtube and update metadata periodically. This will also refresh your subtitles based on your current settings. If an item is no longer available on YouTube, this will deactivate it and exclude it from future refreshes. This task is meant to be run once per day, set your schedule accordingly.
|
|
||||||
|
|
||||||
The field **Refresh older than x days** takes a number where TubeArchivist will consider an item as *outdated*. This value is used to calculate how many items need to be refreshed today based on the total indexed. This will spread out the requests to YouTube. Sensible value here is **90** days.
|
|
||||||
|
|
||||||
## Thumbnail check
|
|
||||||
This will check if all expected thumbnails are there and will delete any artwork without matching video.
|
|
||||||
|
|
||||||
## Index backup
|
|
||||||
Create a zip file of the metadata and select **Max auto backups to keep** to automatically delete old backups created from this task.
|
|
||||||
|
|
||||||
|
|
||||||
# Actions
|
|
||||||
Additional database functionality.
|
|
||||||
|
|
||||||
## Delete download queue
|
|
||||||
The button **Delete all queued** will delete all pending videos from the download queue. The button **Delete all ignored** will delete all videos you have previously ignored.
|
|
||||||
|
|
||||||
## Manual Media Files Import
|
|
||||||
NOTE: This is inherently error prone, as there are many variables, some outside of the control of this project. Read this carefully and use at your own risk.
|
|
||||||
|
|
||||||
Add the files you'd like to import to the */cache/import* folder. Only add files, don't add subdirectories. All files you are adding, need to have the same *base name* as the media file. Then start the process from the settings page *Manual Media Files Import*.
|
|
||||||
|
|
||||||
Valid media extensions are *.mp4*, *.mkv* or *.webm*. If you have other file extensions or incompatible codecs, convert them first to mp4. **Tube Archivist** can identify the videos with one of the following methods.
|
|
||||||
|
|
||||||
### Method 1:
|
|
||||||
Add a matching *.info.json* file with the media file. Both files need to have the same base name, for example:
|
|
||||||
- For the media file: `<base-name>.mp4`
|
|
||||||
- For the JSON file: `<base-name>.info.json`
|
|
||||||
|
|
||||||
The import process then looks for the 'id' key within the JSON file to identify the video.
|
|
||||||
|
|
||||||
### Method 2:
|
|
||||||
Detect the YouTube ID from filename, this accepts the default yt-dlp naming convention for file names like:
|
|
||||||
- `<base-name>[<youtube-id>].mp4`
|
|
||||||
- The YouTube ID in square brackets at the end of the filename is the crucial part.
|
|
||||||
|
|
||||||
### Offline import:
|
|
||||||
If the video you are trying to import is not available on YouTube any more, **Tube Archivist** can import the required metadata:
|
|
||||||
- The file `<base-name>.info.json` is required to extract the required information.
|
|
||||||
- Add the thumbnail as `<base-name>.<ext>`, where valid file extensions are *.jpg*, *.png* or *.webp*. If there is no thumbnail file, **Tube Archivist** will try to extract the embedded cover from the media file or will fallback to a default thumbnail.
|
|
||||||
- Add subtitles as `<base-name>.<lang>.vtt` where *lang* is the two letter ISO country code. This will archive all subtitle files you add to the import folder, independent from your configurations. Subtitles can be archived and used in the player, but they can't be indexed or made searchable due to the fact, that they have a very different structure than the subtitles as **Tube Archivist** needs them.
|
|
||||||
- For videos, where the whole channel is not available any more, you can add the `<channel-id>.info.json` file as generated by *youtube-dl/yt-dlp* to get the full metadata. Alternatively **Tube Archivist** will extract as much info as possible from the video info.json file.
|
|
||||||
|
|
||||||
### Some notes:
|
|
||||||
- This will **consume** the files you put into the import folder: Files will get converted to mp4 if needed (this might take a long time...) and moved to the archive, *.json* files will get deleted upon completion to avoid having duplicates on the next run.
|
|
||||||
- For best file transcoding quality, convert your media files with desired settings first before importing.
|
|
||||||
- Maybe start with a subset of your files to import to make sure everything goes well...
|
|
||||||
- Follow the logs to monitor progress and errors: `docker-compose logs -f tubearchivist`.
|
|
||||||
|
|
||||||
## Embed thumbnails into media file
|
|
||||||
This will write or overwrite all thumbnails in the media file using the downloaded thumbnail. This is only necessary if you didn't download the files with the option *Embed Thumbnail* enabled or want to make sure all media files get the newest thumbnail. Follow the docker-compose logs to monitor progress.
|
|
||||||
|
|
||||||
## Backup Database
|
|
||||||
This will backup your metadata into a zip file. The file will get stored at *cache/backup* and will contain the necessary files to restore the Elasticsearch index formatted **nd-json** files.
|
|
||||||
|
|
||||||
BE AWARE: This will **not** backup any media files, just the metadata from the Elasticsearch.
|
|
||||||
|
|
||||||
## Restore From Backup
|
|
||||||
The restore functionality will expect the same zip file in *cache/backup* as created from the **Backup database** function. This will recreate the index from the snapshot. There will be a list of all available backup to choose from. The *source* tag can have these different values:
|
|
||||||
- **manual**: For backups manually created from here on the settings page.
|
|
||||||
- **auto**: For backups automatically created via a sceduled task.
|
|
||||||
- **update**: For backups created after a Tube Archivist update due to changes in the index.
|
|
||||||
- **False**: Undefined.
|
|
||||||
|
|
||||||
BE AWARE: This will **replace** your current index with the one from the backup file. This won't restore any media files.
|
|
||||||
|
|
||||||
## Rescan Filesystem
|
|
||||||
This function will go through all your media files and looks at the whole index to try to find any issues:
|
|
||||||
- Should the filename not match with the indexed media url, this will rename the video files correctly and update the index with the new link.
|
|
||||||
- When you delete media files from the filesystem outside of the Tube Archivist interface, this will delete leftover metadata from the index.
|
|
||||||
- When you have media files that are not indexed yet, this will grab the metadata from YouTube like it was a newly downloaded video. This can be useful when restoring from an older backup file with missing metadata but already downloaded mediafiles. NOTE: This only works if the media files are named in the same convention as Tube Archivist does, particularly the YouTube ID needs to be at the same index in the filename, alternatively see above for *Manual Media Files Import*.
|
|
||||||
-This will also check all of your thumbnails and download any that are missing.
|
|
||||||
|
|
||||||
BE AWARE: There is no undo.
|
|
||||||
|
|||||||
@@ -1,20 +1,3 @@
|
|||||||
# User Management
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
|
|
||||||
For now, **Tube Archivist** is a single user application. You can create multiple users with different names and passwords, they will share the same videos and permissions but some interface configurations are on a per user basis. *More is on the roadmap*.
|
Equivalent Users page is: [here](https://docs.tubearchivist.com/users/).
|
||||||
|
|
||||||
## Superuser
|
|
||||||
The first user gets created with the environment variables **TA_USERNAME** and **TA_PASSWORD** from your docker-compose file. That first user will automatically have *superuser* privileges.
|
|
||||||
|
|
||||||
## Admin Interface
|
|
||||||
When logged in from your *superuser* account, you are able to access the admin interface from the settings page or at `/admin/`. This interface holds all functionality for user management.
|
|
||||||
|
|
||||||
## Create additional users
|
|
||||||
From the admin interface when you click on *Accounts* you will get a list of all users. From there you can create additional users by clicking on *Add Account*, provide a name and confirm password and click on *Save* to create the user.
|
|
||||||
|
|
||||||
## Changing users
|
|
||||||
You can delete or change permissions and password of a user by clicking on the username from the *Accounts* list page and follow the interface from there. Changing the password of the *superuser* here will overwrite the password originally set with the environment variables.
|
|
||||||
|
|
||||||
## Reset
|
|
||||||
Delete all user configurations by deleting the file `cache/db.sqlite3` and restart the container. This will create the superuser again from the environment variables.
|
|
||||||
|
|
||||||
NOTE: Future improvements here will most likely require such a reset.
|
|
||||||
|
|||||||
@@ -1,23 +1,3 @@
|
|||||||
# Video Page
|
All user documentation has moved to a more flexible, easier to extend and modify documentation platform accessible [here](https://docs.tubearchivist.com) and built from [here](https://github.com/tubearchivist/docs). Don't make any more changes here, keeping this around for some time to keep old links alive.
|
||||||
Every video downloaded gets a dedicated page accessible at `/video/<video-id>/` of your Tube Archivist.
|
|
||||||
|
|
||||||
Clicking on the channel name or the channel icon will bring you to the dedicated channel detail [page](Channels#channel-detail).
|
Equivalent Video page is: [here](https://docs.tubearchivist.com/video/).
|
||||||
|
|
||||||
The button **Delete Video** will delete that video including the media file.
|
|
||||||
|
|
||||||
If available, a tag cloud will show, representing the tags set by the uploader.
|
|
||||||
|
|
||||||
The video description is truncated to the first few lines, click on *show more* to expand the whole description.
|
|
||||||
|
|
||||||
## Playlist
|
|
||||||
When available, a playlist navigation will show at the bottom. Clicking on the playlist name will bring you to the dedicated [Playlist Detail](Playlists#playlist-detail) page showing all videos downloaded from that playlist. The number in square brackets indicates the position of the current video in that playlist.
|
|
||||||
|
|
||||||
Clicking on the next or previous video name or thumbnail will bring you to that dedicated video page.
|
|
||||||
|
|
||||||
## Similar Videos
|
|
||||||
Tube Archivist will show up to six similar videos in a grid. Similarity is detected from the **video title** and the **video tags**. This naturally will show some videos from the same channel, but can also return videos about the same topic from other channels.
|
|
||||||
|
|
||||||
When playing a video from the similar section with the inline player, the current video will get replaced, refresh the page to reset that or click on the video title to avoid that behavior.
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
If activated on the settings page, this will show the indexed comments. Reveal the threads by clicking the *+ Replies* button. Comments with a heart symbol are favorited by the uploader, comments by the uploader are highlighted in a different color.
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
348
package-lock.json
generated
@@ -1,363 +1,3 @@
|
|||||||
# TubeArchivist API
|
# TubeArchivist API
|
||||||
Documentation of available API endpoints.
|
|
||||||
|
|
||||||
Note:
|
All API documentation has moved to [docs.tubearchivist.com](https://docs.tubearchivist.com/).
|
||||||
- This is very early stages and will change!
|
|
||||||
- Check the commit history to see if a documented feature is already in your release
|
|
||||||
|
|
||||||
## Table of contents
|
|
||||||
- [Authentication](#authentication)
|
|
||||||
- [Pagination](#pagination)
|
|
||||||
|
|
||||||
**Video**
|
|
||||||
- [Video List](#video-list-view)
|
|
||||||
- [Video Single](#video-item-view)
|
|
||||||
- [Video Comments](#video-comment-view)
|
|
||||||
- [Video Single Progress](#video-progress-view)
|
|
||||||
- [Video Single Sponsorblock](#sponsor-block-view) WIP
|
|
||||||
|
|
||||||
**Channel**
|
|
||||||
- [Channel List](#channel-list-view)
|
|
||||||
- [Channel Single](#channel-item-view)
|
|
||||||
- [Channel Video List](#channel-videos-view)
|
|
||||||
|
|
||||||
**Playlist**
|
|
||||||
- [Playlist List](#playlist-list-view)
|
|
||||||
- [Playlist Single](#playlist-item-view)
|
|
||||||
- [Playlist Videos List](#playlist-videos-view)
|
|
||||||
|
|
||||||
**Download queue**
|
|
||||||
- [Download Queue List](#download-queue-list-view)
|
|
||||||
- [Download Queue Single](#download-queue-item-view)
|
|
||||||
|
|
||||||
**Snapshot management**
|
|
||||||
- [Snapshot List](#snapshot-list-view)
|
|
||||||
- [Snapshot Single](#snapshot-item-view)
|
|
||||||
|
|
||||||
**Additional**
|
|
||||||
- [Login](#login-view)
|
|
||||||
- [Task](#task-view) WIP
|
|
||||||
- [Cookie](#cookie-view)
|
|
||||||
- [Search](#search-view)
|
|
||||||
- [Ping](#ping-view)
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
API token will get automatically created, accessible on the settings page. Token needs to be passed as an authorization header with every request. Additionally session based authentication is enabled too: When you are logged into your TubeArchivist instance, you'll have access to the api in the browser for testing.
|
|
||||||
|
|
||||||
Curl example:
|
|
||||||
```shell
|
|
||||||
curl -v /api/video/<video-id>/ \
|
|
||||||
-H "Authorization: Token xxxxxxxxxx"
|
|
||||||
```
|
|
||||||
|
|
||||||
Python requests example:
|
|
||||||
```python
|
|
||||||
import requests
|
|
||||||
|
|
||||||
url = "/api/video/<video-id>/"
|
|
||||||
headers = {"Authorization": "Token xxxxxxxxxx"}
|
|
||||||
response = requests.get(url, headers=headers)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pagination
|
|
||||||
The list views return a paginate object with the following keys:
|
|
||||||
- page_size: *int* current page size set in config
|
|
||||||
- page_from: *int* first result idx
|
|
||||||
- prev_pages: *array of ints* of previous pages, if available
|
|
||||||
- current_page: *int* current page from query
|
|
||||||
- max_hits: *bool* if max of 10k results is reached
|
|
||||||
- params: *str* additional url encoded query parameters
|
|
||||||
- last_page: *int* of last page link
|
|
||||||
- next_pages: *array of ints* of next pages
|
|
||||||
- total_hits: *int* total results
|
|
||||||
|
|
||||||
Pass page number as a query parameter: `page=2`. Defaults to *0*, `page=1` is redundant and falls back to *0*. If a page query doesn't return any results, you'll get `HTTP 404 Not Found`.
|
|
||||||
|
|
||||||
## Video List View
|
|
||||||
/api/video/
|
|
||||||
|
|
||||||
## Video Item View
|
|
||||||
/api/video/\<video_id>/
|
|
||||||
|
|
||||||
## Video Comment View
|
|
||||||
/api/video/\<video_id>/comment/
|
|
||||||
|
|
||||||
## Video Progress View
|
|
||||||
/api/video/\<video_id>/progress
|
|
||||||
|
|
||||||
Progress is stored for each user.
|
|
||||||
|
|
||||||
### Get last player position of a video
|
|
||||||
GET /api/video/\<video_id>/progress
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"youtube_id": "<video_id>",
|
|
||||||
"user_id": 1,
|
|
||||||
"position": 100
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Post player position of video
|
|
||||||
POST /api/video/\<video_id>/progress
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"position": 100
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete player position of video
|
|
||||||
DELETE /api/video/\<video_id>/progress
|
|
||||||
|
|
||||||
|
|
||||||
## Sponsor Block View
|
|
||||||
/api/video/\<video_id>/sponsor/
|
|
||||||
|
|
||||||
Integrate with sponsorblock
|
|
||||||
|
|
||||||
### Get list of segments
|
|
||||||
GET /api/video/\<video_id>/sponsor/
|
|
||||||
|
|
||||||
|
|
||||||
### Vote on existing segment
|
|
||||||
**This only simulates the request**
|
|
||||||
POST /api/video/\<video_id>/sponsor/
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"vote": {
|
|
||||||
"uuid": "<uuid>",
|
|
||||||
"yourVote": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
yourVote needs to be *int*: 0 for downvote, 1 for upvote, 20 to undo vote
|
|
||||||
|
|
||||||
### Create new segment
|
|
||||||
**This only simulates the request**
|
|
||||||
POST /api/video/\<video_id>/sponsor/
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"segment": {
|
|
||||||
"startTime": 5,
|
|
||||||
"endTime": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Timestamps either *int* or *float*, end time can't be before start time.
|
|
||||||
|
|
||||||
|
|
||||||
## Channel List View
|
|
||||||
/api/channel/
|
|
||||||
|
|
||||||
### Subscribe to a list of channels
|
|
||||||
POST /api/channel/
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{"channel_id": "UC9-y-6csu5WGm29I7JiwpnA", "channel_subscribed": true}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Channel Item View
|
|
||||||
/api/channel/\<channel_id>/
|
|
||||||
|
|
||||||
## Channel Videos View
|
|
||||||
/api/channel/\<channel_id>/video/
|
|
||||||
|
|
||||||
## Playlist List View
|
|
||||||
/api/playlist/
|
|
||||||
|
|
||||||
## Playlist Item View
|
|
||||||
/api/playlist/\<playlist_id>/
|
|
||||||
|
|
||||||
## Playlist Videos View
|
|
||||||
/api/playlist/\<playlist_id>/video/
|
|
||||||
|
|
||||||
## Download Queue List View
|
|
||||||
GET /api/download/
|
|
||||||
|
|
||||||
Parameter:
|
|
||||||
- filter: pending, ignore
|
|
||||||
- channel: channel-id
|
|
||||||
|
|
||||||
### Add list of videos to download queue
|
|
||||||
POST /api/download/
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{"youtube_id": "NYj3DnI81AQ", "status": "pending"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete download queue items by filter
|
|
||||||
DELETE /api/download/?filter=ignore
|
|
||||||
DELETE /api/download/?filter=pending
|
|
||||||
|
|
||||||
## Download Queue Item View
|
|
||||||
GET /api/download/\<video_id>/
|
|
||||||
POST /api/download/\<video_id>/
|
|
||||||
|
|
||||||
Ignore video in download queue:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ignore"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to queue previously ignored video:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "pending"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
DELETE /api/download/\<video_id>/
|
|
||||||
Forget or delete from download queue
|
|
||||||
|
|
||||||
## Snapshot List View
|
|
||||||
GET /api/snapshot/
|
|
||||||
Return snapshot config and a list of available snapshots.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"next_exec": epoch,
|
|
||||||
"next_exec_str": "date_str",
|
|
||||||
"expire_after": "30d",
|
|
||||||
"snapshots": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
POST /api/snapshot/
|
|
||||||
Create new snapshot now, will return immediately, task will run async in the background, will return snapshot name:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"snapshot_name": "ta_daily_<random-id>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Snapshot Item View
|
|
||||||
GET /api/snapshot/\<snapshot-id>/
|
|
||||||
Return metadata of a single snapshot
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "ta_daily_<random-id>,
|
|
||||||
"state": "SUCCESS",
|
|
||||||
"es_version": "0.0.0",
|
|
||||||
"start_date": "date_str",
|
|
||||||
"end_date": "date_str",
|
|
||||||
"end_stamp": epoch,
|
|
||||||
"duration_s": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
GET /api/snapshot/\<snapshot-id>/
|
|
||||||
Restore this snapshot
|
|
||||||
|
|
||||||
DELETE /api/snapshot/\<snapshot-id>/
|
|
||||||
Remove this snapshot from index
|
|
||||||
|
|
||||||
## Login View
|
|
||||||
Return token and user ID for username and password:
|
|
||||||
POST /api/login
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"username": "tubearchivist",
|
|
||||||
"password": "verysecret"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
after successful login returns
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
||||||
"user_id": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task View
|
|
||||||
GET /api/task/
|
|
||||||
POST /api/task/
|
|
||||||
|
|
||||||
Check if there is an ongoing task:
|
|
||||||
GET /api/task/
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rescan": false,
|
|
||||||
"downloading": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Start a background task
|
|
||||||
POST /api/task/
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"run": "task_name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
List of valid task names:
|
|
||||||
- **download_pending**: Start the download queue
|
|
||||||
- **rescan_pending**: Rescan your subscriptions
|
|
||||||
|
|
||||||
|
|
||||||
## Cookie View
|
|
||||||
Check your youtube cookie settings, *status* turns to `true` if cookie has been validated.
|
|
||||||
GET /api/cookie/
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cookie_enabled": true,
|
|
||||||
"status": true,
|
|
||||||
"validated": <timestamp>,
|
|
||||||
"validated_str": "timestamp"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
POST /api/cookie/
|
|
||||||
Send empty post request to validate cookie.
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cookie_validated": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
PUT /api/cookie/
|
|
||||||
Send put request containing the cookie as a string:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cookie": "your-cookie-as-string"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Imports and validates cookie, returns on success:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cookie_import": "done",
|
|
||||||
"cookie_validated": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Or returns status code 400 on failure:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"cookie_import": "fail",
|
|
||||||
"cookie_validated": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Search View
|
|
||||||
GET /api/search/?query=\<query>
|
|
||||||
|
|
||||||
Returns search results from your query.
|
|
||||||
|
|
||||||
## Ping View
|
|
||||||
Validate your connection with the API
|
|
||||||
GET /api/ping
|
|
||||||
|
|
||||||
When valid returns message with user id:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"response": "pong",
|
|
||||||
"user": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class SearchProcess:
|
|||||||
"channel_last_refresh": date_str,
|
"channel_last_refresh": date_str,
|
||||||
"channel_banner_url": f"{art_base}_banner.jpg",
|
"channel_banner_url": f"{art_base}_banner.jpg",
|
||||||
"channel_thumb_url": f"{art_base}_thumb.jpg",
|
"channel_thumb_url": f"{art_base}_thumb.jpg",
|
||||||
"channel_tvart_url": False,
|
"channel_tvart_url": f"{art_base}_tvart.jpg",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"""
|
|
||||||
Functionality:
|
|
||||||
- process tasks from API
|
|
||||||
- validate
|
|
||||||
- handover to celery
|
|
||||||
"""
|
|
||||||
|
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
|
||||||
from home.tasks import download_pending, update_subscribed
|
|
||||||
|
|
||||||
|
|
||||||
class TaskHandler:
|
|
||||||
"""handle tasks from api"""
|
|
||||||
|
|
||||||
def __init__(self, data):
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
def run_task(self):
|
|
||||||
"""map data and run"""
|
|
||||||
task_name = self.data["run"]
|
|
||||||
try:
|
|
||||||
to_run = self.exec_map(task_name)
|
|
||||||
except KeyError as err:
|
|
||||||
print(f"invalid task name {task_name}")
|
|
||||||
raise ValueError from err
|
|
||||||
|
|
||||||
response = to_run()
|
|
||||||
response.update({"task": task_name})
|
|
||||||
return response
|
|
||||||
|
|
||||||
def exec_map(self, task_name):
|
|
||||||
"""map dict key and return function to execute"""
|
|
||||||
exec_map = {
|
|
||||||
"download_pending": self._download_pending,
|
|
||||||
"rescan_pending": self._rescan_pending,
|
|
||||||
}
|
|
||||||
|
|
||||||
return exec_map[task_name]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _rescan_pending():
|
|
||||||
"""look for new items in subscribed channels"""
|
|
||||||
print("rescan subscribed channels")
|
|
||||||
update_subscribed.delay()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _download_pending():
|
|
||||||
"""start the download queue"""
|
|
||||||
print("download pending")
|
|
||||||
running = download_pending.delay()
|
|
||||||
print("set task id: " + running.id)
|
|
||||||
RedisArchivist().set_message("dl_queue_id", running.id)
|
|
||||||
return {"success": True}
|
|
||||||
@@ -1,126 +1,134 @@
|
|||||||
"""all api urls"""
|
"""all api urls"""
|
||||||
|
|
||||||
from api.views import (
|
from api import views
|
||||||
ChannelApiListView,
|
|
||||||
ChannelApiVideoView,
|
|
||||||
ChannelApiView,
|
|
||||||
CookieView,
|
|
||||||
DownloadApiListView,
|
|
||||||
DownloadApiView,
|
|
||||||
LoginApiView,
|
|
||||||
PingView,
|
|
||||||
PlaylistApiListView,
|
|
||||||
PlaylistApiVideoView,
|
|
||||||
PlaylistApiView,
|
|
||||||
SearchView,
|
|
||||||
SnapshotApiListView,
|
|
||||||
SnapshotApiView,
|
|
||||||
TaskApiView,
|
|
||||||
VideoApiListView,
|
|
||||||
VideoApiView,
|
|
||||||
VideoCommentView,
|
|
||||||
VideoProgressView,
|
|
||||||
VideoSimilarView,
|
|
||||||
VideoSponsorView,
|
|
||||||
)
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("ping/", PingView.as_view(), name="ping"),
|
path("ping/", views.PingView.as_view(), name="ping"),
|
||||||
path("login/", LoginApiView.as_view(), name="api-login"),
|
path("login/", views.LoginApiView.as_view(), name="api-login"),
|
||||||
path(
|
path(
|
||||||
"video/",
|
"video/",
|
||||||
VideoApiListView.as_view(),
|
views.VideoApiListView.as_view(),
|
||||||
name="api-video-list",
|
name="api-video-list",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"video/<slug:video_id>/",
|
"video/<slug:video_id>/",
|
||||||
VideoApiView.as_view(),
|
views.VideoApiView.as_view(),
|
||||||
name="api-video",
|
name="api-video",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"video/<slug:video_id>/progress/",
|
"video/<slug:video_id>/progress/",
|
||||||
VideoProgressView.as_view(),
|
views.VideoProgressView.as_view(),
|
||||||
name="api-video-progress",
|
name="api-video-progress",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"video/<slug:video_id>/comment/",
|
"video/<slug:video_id>/comment/",
|
||||||
VideoCommentView.as_view(),
|
views.VideoCommentView.as_view(),
|
||||||
name="api-video-comment",
|
name="api-video-comment",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"video/<slug:video_id>/similar/",
|
"video/<slug:video_id>/similar/",
|
||||||
VideoSimilarView.as_view(),
|
views.VideoSimilarView.as_view(),
|
||||||
name="api-video-similar",
|
name="api-video-similar",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"video/<slug:video_id>/sponsor/",
|
"video/<slug:video_id>/sponsor/",
|
||||||
VideoSponsorView.as_view(),
|
views.VideoSponsorView.as_view(),
|
||||||
name="api-video-sponsor",
|
name="api-video-sponsor",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"channel/",
|
"channel/",
|
||||||
ChannelApiListView.as_view(),
|
views.ChannelApiListView.as_view(),
|
||||||
name="api-channel-list",
|
name="api-channel-list",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"channel/<slug:channel_id>/",
|
"channel/<slug:channel_id>/",
|
||||||
ChannelApiView.as_view(),
|
views.ChannelApiView.as_view(),
|
||||||
name="api-channel",
|
name="api-channel",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"channel/<slug:channel_id>/video/",
|
"channel/<slug:channel_id>/video/",
|
||||||
ChannelApiVideoView.as_view(),
|
views.ChannelApiVideoView.as_view(),
|
||||||
name="api-channel-video",
|
name="api-channel-video",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"playlist/",
|
"playlist/",
|
||||||
PlaylistApiListView.as_view(),
|
views.PlaylistApiListView.as_view(),
|
||||||
name="api-playlist-list",
|
name="api-playlist-list",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"playlist/<slug:playlist_id>/",
|
"playlist/<slug:playlist_id>/",
|
||||||
PlaylistApiView.as_view(),
|
views.PlaylistApiView.as_view(),
|
||||||
name="api-playlist",
|
name="api-playlist",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"playlist/<slug:playlist_id>/video/",
|
"playlist/<slug:playlist_id>/video/",
|
||||||
PlaylistApiVideoView.as_view(),
|
views.PlaylistApiVideoView.as_view(),
|
||||||
name="api-playlist-video",
|
name="api-playlist-video",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"download/",
|
"download/",
|
||||||
DownloadApiListView.as_view(),
|
views.DownloadApiListView.as_view(),
|
||||||
name="api-download-list",
|
name="api-download-list",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"download/<slug:video_id>/",
|
"download/<slug:video_id>/",
|
||||||
DownloadApiView.as_view(),
|
views.DownloadApiView.as_view(),
|
||||||
name="api-download",
|
name="api-download",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"task/",
|
"refresh/",
|
||||||
TaskApiView.as_view(),
|
views.RefreshView.as_view(),
|
||||||
name="api-task",
|
name="api-refresh",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"snapshot/",
|
"snapshot/",
|
||||||
SnapshotApiListView.as_view(),
|
views.SnapshotApiListView.as_view(),
|
||||||
name="api-snapshot-list",
|
name="api-snapshot-list",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"snapshot/<slug:snapshot_id>/",
|
"snapshot/<slug:snapshot_id>/",
|
||||||
SnapshotApiView.as_view(),
|
views.SnapshotApiView.as_view(),
|
||||||
name="api-snapshot",
|
name="api-snapshot",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"task-name/",
|
||||||
|
views.TaskListView.as_view(),
|
||||||
|
name="api-task-list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"task-name/<slug:task_name>/",
|
||||||
|
views.TaskNameListView.as_view(),
|
||||||
|
name="api-task-name-list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"task-id/<slug:task_id>/",
|
||||||
|
views.TaskIDView.as_view(),
|
||||||
|
name="api-task-id",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"cookie/",
|
"cookie/",
|
||||||
CookieView.as_view(),
|
views.CookieView.as_view(),
|
||||||
name="api-cookie",
|
name="api-cookie",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"watched/",
|
||||||
|
views.WatchedView.as_view(),
|
||||||
|
name="api-watched",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"search/",
|
"search/",
|
||||||
SearchView.as_view(),
|
views.SearchView.as_view(),
|
||||||
name="api-search",
|
name="api-search",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"token/",
|
||||||
|
views.TokenView.as_view(),
|
||||||
|
name="api-token",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"notification/",
|
||||||
|
views.NotificationView.as_view(),
|
||||||
|
name="api-notification",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
"""all API views"""
|
"""all API views"""
|
||||||
|
|
||||||
from api.src.search_processor import SearchProcess
|
from api.src.search_processor import SearchProcess
|
||||||
from api.src.task_processor import TaskHandler
|
|
||||||
from home.src.download.queue import PendingInteract
|
from home.src.download.queue import PendingInteract
|
||||||
from home.src.download.yt_dlp_base import CookieHandler
|
from home.src.download.yt_dlp_base import CookieHandler
|
||||||
from home.src.es.connect import ElasticWrap
|
from home.src.es.connect import ElasticWrap
|
||||||
from home.src.es.snapshot import ElasticSnapshot
|
from home.src.es.snapshot import ElasticSnapshot
|
||||||
from home.src.frontend.searching import SearchForm
|
from home.src.frontend.searching import SearchForm
|
||||||
|
from home.src.frontend.watched import WatchState
|
||||||
|
from home.src.index.channel import YoutubeChannel
|
||||||
from home.src.index.generic import Pagination
|
from home.src.index.generic import Pagination
|
||||||
from home.src.index.video import SponsorBlock
|
from home.src.index.reindex import ReindexProgress
|
||||||
|
from home.src.index.video import SponsorBlock, YoutubeVideo
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
from home.src.ta.helper import UrlListParser
|
|
||||||
from home.src.ta.ta_redis import RedisArchivist, RedisQueue
|
from home.src.ta.ta_redis import RedisArchivist, RedisQueue
|
||||||
from home.tasks import extrac_dl, subscribe_to
|
from home.src.ta.task_manager import TaskCommand, TaskManager
|
||||||
|
from home.src.ta.urlparser import Parser
|
||||||
|
from home.tasks import (
|
||||||
|
BaseTask,
|
||||||
|
check_reindex,
|
||||||
|
download_pending,
|
||||||
|
extrac_dl,
|
||||||
|
subscribe_to,
|
||||||
|
)
|
||||||
from rest_framework.authentication import (
|
from rest_framework.authentication import (
|
||||||
SessionAuthentication,
|
SessionAuthentication,
|
||||||
TokenAuthentication,
|
TokenAuthentication,
|
||||||
@@ -94,6 +103,20 @@ class VideoApiView(ApiBaseView):
|
|||||||
self.get_document(video_id)
|
self.get_document(video_id)
|
||||||
return Response(self.response, status=self.status_code)
|
return Response(self.response, status=self.status_code)
|
||||||
|
|
||||||
|
def delete(self, request, video_id):
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
"""delete single video"""
|
||||||
|
message = {"video": video_id}
|
||||||
|
try:
|
||||||
|
YoutubeVideo(video_id).delete_media_file()
|
||||||
|
status_code = 200
|
||||||
|
message.update({"state": "delete"})
|
||||||
|
except FileNotFoundError:
|
||||||
|
status_code = 404
|
||||||
|
message.update({"state": "not found"})
|
||||||
|
|
||||||
|
return Response(message, status=status_code)
|
||||||
|
|
||||||
|
|
||||||
class VideoApiListView(ApiBaseView):
|
class VideoApiListView(ApiBaseView):
|
||||||
"""resolves to /api/video/
|
"""resolves to /api/video/
|
||||||
@@ -250,6 +273,20 @@ class ChannelApiView(ApiBaseView):
|
|||||||
self.get_document(channel_id)
|
self.get_document(channel_id)
|
||||||
return Response(self.response, status=self.status_code)
|
return Response(self.response, status=self.status_code)
|
||||||
|
|
||||||
|
def delete(self, request, channel_id):
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
"""delete channel"""
|
||||||
|
message = {"channel": channel_id}
|
||||||
|
try:
|
||||||
|
YoutubeChannel(channel_id).delete_channel()
|
||||||
|
status_code = 200
|
||||||
|
message.update({"state": "delete"})
|
||||||
|
except FileNotFoundError:
|
||||||
|
status_code = 404
|
||||||
|
message.update({"state": "not found"})
|
||||||
|
|
||||||
|
return Response(message, status=status_code)
|
||||||
|
|
||||||
|
|
||||||
class ChannelApiListView(ApiBaseView):
|
class ChannelApiListView(ApiBaseView):
|
||||||
"""resolves to /api/channel/
|
"""resolves to /api/channel/
|
||||||
@@ -258,14 +295,27 @@ class ChannelApiListView(ApiBaseView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
search_base = "ta_channel/_search/"
|
search_base = "ta_channel/_search/"
|
||||||
|
valid_filter = ["subscribed"]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""get request"""
|
"""get request"""
|
||||||
self.get_document_list(request)
|
|
||||||
self.data.update(
|
self.data.update(
|
||||||
{"sort": [{"channel_name.keyword": {"order": "asc"}}]}
|
{"sort": [{"channel_name.keyword": {"order": "asc"}}]}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
query_filter = request.GET.get("filter", False)
|
||||||
|
must_list = []
|
||||||
|
if query_filter:
|
||||||
|
if query_filter not in self.valid_filter:
|
||||||
|
message = f"invalid url query filder: {query_filter}"
|
||||||
|
print(message)
|
||||||
|
return Response({"message": message}, status=400)
|
||||||
|
|
||||||
|
must_list.append({"term": {"channel_subscribed": {"value": True}}})
|
||||||
|
|
||||||
|
self.data["query"] = {"bool": {"must": must_list}}
|
||||||
|
self.get_document_list(request)
|
||||||
|
|
||||||
return Response(self.response)
|
return Response(self.response)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -364,7 +414,7 @@ class DownloadApiView(ApiBaseView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
search_base = "ta_download/_doc/"
|
search_base = "ta_download/_doc/"
|
||||||
valid_status = ["pending", "ignore"]
|
valid_status = ["pending", "ignore", "priority"]
|
||||||
|
|
||||||
def get(self, request, video_id):
|
def get(self, request, video_id):
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
@@ -374,15 +424,24 @@ class DownloadApiView(ApiBaseView):
|
|||||||
|
|
||||||
def post(self, request, video_id):
|
def post(self, request, video_id):
|
||||||
"""post to video to change status"""
|
"""post to video to change status"""
|
||||||
item_status = request.data["status"]
|
item_status = request.data.get("status")
|
||||||
if item_status not in self.valid_status:
|
if item_status not in self.valid_status:
|
||||||
message = f"{video_id}: invalid status {item_status}"
|
message = f"{video_id}: invalid status {item_status}"
|
||||||
print(message)
|
print(message)
|
||||||
return Response({"message": message}, status=400)
|
return Response({"message": message}, status=400)
|
||||||
|
|
||||||
|
_, status_code = PendingInteract(video_id).get_item()
|
||||||
|
if status_code == 404:
|
||||||
|
message = f"{video_id}: item not found {status_code}"
|
||||||
|
return Response({"message": message}, status=404)
|
||||||
|
|
||||||
print(f"{video_id}: change status to {item_status}")
|
print(f"{video_id}: change status to {item_status}")
|
||||||
PendingInteract(video_id=video_id, status=item_status).update_status()
|
if item_status == "priority":
|
||||||
RedisQueue().clear_item(video_id)
|
PendingInteract(youtube_id=video_id).prioritize()
|
||||||
|
download_pending.delay(from_queue=False)
|
||||||
|
else:
|
||||||
|
PendingInteract(video_id, item_status).update_status()
|
||||||
|
RedisQueue(queue_name="dl_queue").clear_item(video_id)
|
||||||
|
|
||||||
return Response(request.data)
|
return Response(request.data)
|
||||||
|
|
||||||
@@ -391,7 +450,7 @@ class DownloadApiView(ApiBaseView):
|
|||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
"""delete single video from queue"""
|
"""delete single video from queue"""
|
||||||
print(f"{video_id}: delete from queue")
|
print(f"{video_id}: delete from queue")
|
||||||
PendingInteract(video_id=video_id).delete_item()
|
PendingInteract(video_id).delete_item()
|
||||||
|
|
||||||
return Response({"success": True})
|
return Response({"success": True})
|
||||||
|
|
||||||
@@ -445,7 +504,7 @@ class DownloadApiListView(ApiBaseView):
|
|||||||
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
|
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
|
||||||
url_str = " ".join(pending)
|
url_str = " ".join(pending)
|
||||||
try:
|
try:
|
||||||
youtube_ids = UrlListParser(url_str).process_list()
|
youtube_ids = Parser(url_str).parse()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
message = f"failed to parse: {url_str}"
|
message = f"failed to parse: {url_str}"
|
||||||
print(message)
|
print(message)
|
||||||
@@ -502,29 +561,6 @@ class LoginApiView(ObtainAuthToken):
|
|||||||
return Response({"token": token.key, "user_id": user.pk})
|
return Response({"token": token.key, "user_id": user.pk})
|
||||||
|
|
||||||
|
|
||||||
class TaskApiView(ApiBaseView):
|
|
||||||
"""resolves to /api/task/
|
|
||||||
GET: check if ongoing background task
|
|
||||||
POST: start a new background task
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get(request):
|
|
||||||
"""handle get request"""
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
response = {"rescan": False, "downloading": False}
|
|
||||||
for key in response.keys():
|
|
||||||
response[key] = RedisArchivist().is_locked(key)
|
|
||||||
|
|
||||||
return Response(response)
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
"""handle post request"""
|
|
||||||
response = TaskHandler(request.data).run_task()
|
|
||||||
|
|
||||||
return Response(response)
|
|
||||||
|
|
||||||
|
|
||||||
class SnapshotApiListView(ApiBaseView):
|
class SnapshotApiListView(ApiBaseView):
|
||||||
"""resolves to /api/snapshot/
|
"""resolves to /api/snapshot/
|
||||||
GET: returns snashot config plus list of existing snapshots
|
GET: returns snashot config plus list of existing snapshots
|
||||||
@@ -589,6 +625,140 @@ class SnapshotApiView(ApiBaseView):
|
|||||||
return Response(response)
|
return Response(response)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskListView(ApiBaseView):
|
||||||
|
"""resolves to /api/task-name/
|
||||||
|
GET: return a list of all stored task results
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""handle get request"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
all_results = TaskManager().get_all_results()
|
||||||
|
|
||||||
|
return Response(all_results)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskNameListView(ApiBaseView):
|
||||||
|
"""resolves to /api/task-name/<task-name>/
|
||||||
|
GET: return a list of stored results of task
|
||||||
|
POST: start new background process
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request, task_name):
|
||||||
|
"""handle get request"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
if task_name not in BaseTask.TASK_CONFIG:
|
||||||
|
message = {"message": "invalid task name"}
|
||||||
|
return Response(message, status=404)
|
||||||
|
|
||||||
|
all_results = TaskManager().get_tasks_by_name(task_name)
|
||||||
|
|
||||||
|
return Response(all_results)
|
||||||
|
|
||||||
|
def post(self, request, task_name):
|
||||||
|
"""
|
||||||
|
handle post request
|
||||||
|
404 for invalid task_name
|
||||||
|
400 if task can't be started here without argument
|
||||||
|
"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
task_config = BaseTask.TASK_CONFIG.get(task_name)
|
||||||
|
if not task_config:
|
||||||
|
message = {"message": "invalid task name"}
|
||||||
|
return Response(message, status=404)
|
||||||
|
|
||||||
|
if not task_config.get("api-start"):
|
||||||
|
message = {"message": "can not start task through this endpoint"}
|
||||||
|
return Response(message, status=400)
|
||||||
|
|
||||||
|
message = TaskCommand().start(task_name)
|
||||||
|
|
||||||
|
return Response({"message": message})
|
||||||
|
|
||||||
|
|
||||||
|
class TaskIDView(ApiBaseView):
|
||||||
|
"""resolves to /api/task-id/<task-id>/
|
||||||
|
GET: return details of task id
|
||||||
|
"""
|
||||||
|
|
||||||
|
valid_commands = ["stop", "kill"]
|
||||||
|
|
||||||
|
def get(self, request, task_id):
|
||||||
|
"""handle get request"""
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
task_result = TaskManager().get_task(task_id)
|
||||||
|
if not task_result:
|
||||||
|
message = {"message": "task id not found"}
|
||||||
|
return Response(message, status=404)
|
||||||
|
|
||||||
|
return Response(task_result)
|
||||||
|
|
||||||
|
def post(self, request, task_id):
|
||||||
|
"""post command to task"""
|
||||||
|
command = request.data.get("command")
|
||||||
|
if not command or command not in self.valid_commands:
|
||||||
|
message = {"message": "no valid command found"}
|
||||||
|
return Response(message, status=400)
|
||||||
|
|
||||||
|
task_result = TaskManager().get_task(task_id)
|
||||||
|
if not task_result:
|
||||||
|
message = {"message": "task id not found"}
|
||||||
|
return Response(message, status=404)
|
||||||
|
|
||||||
|
task_conf = BaseTask.TASK_CONFIG.get(task_result.get("name"))
|
||||||
|
if command == "stop":
|
||||||
|
if not task_conf.get("api-stop"):
|
||||||
|
message = {"message": "task can not be stopped"}
|
||||||
|
return Response(message, status=400)
|
||||||
|
|
||||||
|
message_key = self._build_message_key(task_conf, task_id)
|
||||||
|
TaskCommand().stop(task_id, message_key)
|
||||||
|
if command == "kill":
|
||||||
|
if not task_conf.get("api-stop"):
|
||||||
|
message = {"message": "task can not be killed"}
|
||||||
|
return Response(message, status=400)
|
||||||
|
|
||||||
|
TaskCommand().kill(task_id)
|
||||||
|
|
||||||
|
return Response({"message": "command sent"})
|
||||||
|
|
||||||
|
def _build_message_key(self, task_conf, task_id):
|
||||||
|
"""build message key to forward command to notification"""
|
||||||
|
return f"message:{task_conf.get('group')}:{task_id.split('-')[0]}"
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshView(ApiBaseView):
|
||||||
|
"""resolves to /api/refresh/
|
||||||
|
GET: get refresh progress
|
||||||
|
POST: start a manual refresh task
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""handle get request"""
|
||||||
|
request_type = request.GET.get("type")
|
||||||
|
request_id = request.GET.get("id")
|
||||||
|
|
||||||
|
if request_id and not request_type:
|
||||||
|
return Response({"status": "Bad Request"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
progress = ReindexProgress(
|
||||||
|
request_type=request_type, request_id=request_id
|
||||||
|
).get_progress()
|
||||||
|
except ValueError:
|
||||||
|
return Response({"status": "Bad Request"}, status=400)
|
||||||
|
|
||||||
|
return Response(progress)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""handle post request"""
|
||||||
|
data = request.data
|
||||||
|
extract_videos = bool(request.GET.get("extract_videos", False))
|
||||||
|
check_reindex.delay(data=data, extract_videos=extract_videos)
|
||||||
|
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
class CookieView(ApiBaseView):
|
class CookieView(ApiBaseView):
|
||||||
"""resolves to /api/cookie/
|
"""resolves to /api/cookie/
|
||||||
GET: check if cookie is enabled
|
GET: check if cookie is enabled
|
||||||
@@ -641,6 +811,24 @@ class CookieView(ApiBaseView):
|
|||||||
return Response(message)
|
return Response(message)
|
||||||
|
|
||||||
|
|
||||||
|
class WatchedView(ApiBaseView):
|
||||||
|
"""resolves to /api/watched/
|
||||||
|
POST: change watched state of video, channel or playlist
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""change watched state"""
|
||||||
|
youtube_id = request.data.get("id")
|
||||||
|
is_watched = request.data.get("is_watched")
|
||||||
|
|
||||||
|
if not youtube_id or is_watched is None:
|
||||||
|
message = {"message": "missing id or is_watched"}
|
||||||
|
return Response(message, status=400)
|
||||||
|
|
||||||
|
WatchState(youtube_id, is_watched).change()
|
||||||
|
return Response({"message": "success"}, status=200)
|
||||||
|
|
||||||
|
|
||||||
class SearchView(ApiBaseView):
|
class SearchView(ApiBaseView):
|
||||||
"""resolves to /api/search/
|
"""resolves to /api/search/
|
||||||
GET: run a search with the string in the ?query parameter
|
GET: run a search with the string in the ?query parameter
|
||||||
@@ -658,3 +846,33 @@ class SearchView(ApiBaseView):
|
|||||||
|
|
||||||
search_results = SearchForm().multi_search(search_query)
|
search_results = SearchForm().multi_search(search_query)
|
||||||
return Response(search_results)
|
return Response(search_results)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenView(ApiBaseView):
|
||||||
|
"""resolves to /api/token/
|
||||||
|
DELETE: revoke the token
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete(request):
|
||||||
|
print("revoke API token")
|
||||||
|
request.user.auth_token.delete()
|
||||||
|
return Response({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationView(ApiBaseView):
|
||||||
|
"""resolves to /api/notification/
|
||||||
|
GET: returns a list of notifications
|
||||||
|
filter query to filter messages by group
|
||||||
|
"""
|
||||||
|
|
||||||
|
valid_filters = ["download", "settings", "channel"]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""get all notifications"""
|
||||||
|
query = "message"
|
||||||
|
filter_by = request.GET.get("filter", None)
|
||||||
|
if filter_by in self.valid_filters:
|
||||||
|
query = f"{query}:{filter_by}"
|
||||||
|
|
||||||
|
return Response(RedisArchivist().list_items(query))
|
||||||
|
|||||||
0
tubearchivist/config/management/__init__.py
Normal file
144
tubearchivist/config/management/commands/ta_connection.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
Functionality:
|
||||||
|
- check that all connections are working
|
||||||
|
"""
|
||||||
|
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from home.src.es.connect import ElasticWrap
|
||||||
|
from home.src.ta.ta_redis import RedisArchivist
|
||||||
|
|
||||||
|
TOPIC = """
|
||||||
|
|
||||||
|
#######################
|
||||||
|
# Connection check #
|
||||||
|
#######################
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""command framework"""
|
||||||
|
|
||||||
|
TIMEOUT = 120
|
||||||
|
MIN_MAJOR, MAX_MAJOR = 8, 8
|
||||||
|
MIN_MINOR = 0
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
help = "Check connections"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""run all commands"""
|
||||||
|
self.stdout.write(TOPIC)
|
||||||
|
self._redis_connection_check()
|
||||||
|
self._redis_config_set()
|
||||||
|
self._es_connection_check()
|
||||||
|
self._es_version_check()
|
||||||
|
self._es_path_check()
|
||||||
|
|
||||||
|
def _redis_connection_check(self):
|
||||||
|
"""check ir redis connection is established"""
|
||||||
|
self.stdout.write("[1] connect to Redis")
|
||||||
|
redis_conn = RedisArchivist().conn
|
||||||
|
for _ in range(5):
|
||||||
|
try:
|
||||||
|
pong = redis_conn.execute_command("PING")
|
||||||
|
if pong:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" ✓ Redis connection verified")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
self.stdout.write(" ... retry Redis connection")
|
||||||
|
sleep(2)
|
||||||
|
|
||||||
|
message = " 🗙 Redis connection failed"
|
||||||
|
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||||
|
RedisArchivist().exec("PING")
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _redis_config_set(self):
|
||||||
|
"""set config for redis if not set already"""
|
||||||
|
self.stdout.write("[2] set Redis config")
|
||||||
|
redis_conn = RedisArchivist().conn
|
||||||
|
timeout_is = int(redis_conn.config_get("timeout").get("timeout"))
|
||||||
|
if not timeout_is:
|
||||||
|
redis_conn.config_set("timeout", 3600)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(" ✓ Redis config set"))
|
||||||
|
|
||||||
|
def _es_connection_check(self):
|
||||||
|
"""wait for elasticsearch connection"""
|
||||||
|
self.stdout.write("[3] connect to Elastic Search")
|
||||||
|
total = self.TIMEOUT // 5
|
||||||
|
for i in range(total):
|
||||||
|
self.stdout.write(f" ... waiting for ES [{i}/{total}]")
|
||||||
|
try:
|
||||||
|
_, status_code = ElasticWrap("/").get(
|
||||||
|
timeout=1, print_error=False
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if status_code and status_code == 200:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" ✓ ES connection established")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response, status_code = ElasticWrap("/").get(
|
||||||
|
timeout=1, print_error=False
|
||||||
|
)
|
||||||
|
|
||||||
|
message = " 🗙 ES connection failed"
|
||||||
|
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||||
|
self.stdout.write(f" error message: {response}")
|
||||||
|
self.stdout.write(f" status code: {status_code}")
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _es_version_check(self):
|
||||||
|
"""check for minimal elasticsearch version"""
|
||||||
|
self.stdout.write("[4] Elastic Search version check")
|
||||||
|
response, _ = ElasticWrap("/").get()
|
||||||
|
version = response["version"]["number"]
|
||||||
|
major = int(version.split(".")[0])
|
||||||
|
|
||||||
|
if self.MIN_MAJOR <= major <= self.MAX_MAJOR:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" ✓ ES version check passed")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = (
|
||||||
|
" 🗙 ES connection failed. "
|
||||||
|
+ f"Expected {self.MIN_MAJOR}.{self.MIN_MINOR} but got {version}"
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _es_path_check(self):
|
||||||
|
"""check that path.repo var is set"""
|
||||||
|
self.stdout.write("[5] check ES path.repo env var")
|
||||||
|
response, _ = ElasticWrap("_nodes/_all/settings").get()
|
||||||
|
for node in response["nodes"].values():
|
||||||
|
if node["settings"]["path"].get("repo"):
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" ✓ path.repo env var is set")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = (
|
||||||
|
" 🗙 path.repo env var not found. "
|
||||||
|
+ "set the following env var to the ES container:\n"
|
||||||
|
+ " path.repo=/usr/share/elasticsearch/data/snapshot"
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
196
tubearchivist/config/management/commands/ta_envcheck.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
Functionality:
|
||||||
|
- Check environment at startup
|
||||||
|
- Process config file overwrites from env var
|
||||||
|
- Stop startup on error
|
||||||
|
- python management.py ta_envcheck
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from home.models import Account
|
||||||
|
|
||||||
|
LOGO = """
|
||||||
|
|
||||||
|
.... .....
|
||||||
|
...'',;:cc,. .;::;;,'...
|
||||||
|
..,;:cccllclc, .:ccllllcc;,..
|
||||||
|
..,:cllcc:;,'.',. ....'',;ccllc:,..
|
||||||
|
..;cllc:,'.. ...,:cccc:'.
|
||||||
|
.;cccc;.. ..,:ccc:'.
|
||||||
|
.ckkkOkxollllllllllllc. .,:::;. .,cclc;
|
||||||
|
.:0MMMMMMMMMMMMMMMMMMMX: .cNMMMWx. .;clc:
|
||||||
|
.;lOXK0000KNMMMMX00000KO; ;KMMMMMNl. .;ccl:,.
|
||||||
|
.;:c:'.....kMMMNo........ 'OMMMWMMMK: '::;;'.
|
||||||
|
....... .xMMMNl .dWMMXdOMMMO' ........
|
||||||
|
.:cc:;. .xMMMNc .lNMMNo.:XMMWx. .:cl:.
|
||||||
|
.:llc,. .:xxxd, ;KMMMk. .oWMMNl. .:llc'
|
||||||
|
.cll:. .;:;;:::,. 'OMMMK:';''kWMMK: .;llc,
|
||||||
|
.cll:. .,;;;;;;,. .,xWMMNl.:l:.;KMMMO' .;llc'
|
||||||
|
.:llc. .cOOOk; .lKNMMWx..:l:..lNMMWx. .:llc'
|
||||||
|
.;lcc,. .xMMMNc :KMMMM0, .:lc. .xWMMNl.'ccl:.
|
||||||
|
.cllc. .xMMMNc 'OMMMMXc...:lc...,0MMMKl:lcc,.
|
||||||
|
.,ccl:. .xMMMNc .xWMMMWo.,;;:lc;;;.cXMMMXdcc;.
|
||||||
|
.,clc:. .xMMMNc .lNMMMWk. .':clc:,. .dWMMW0o;.
|
||||||
|
.,clcc,. .ckkkx; .okkkOx, .';,. 'kKKK0l.
|
||||||
|
.':lcc:'..... . .. ..,;cllc,.
|
||||||
|
.,cclc,.... ....;clc;..
|
||||||
|
..,:,..,c:'.. ...';:,..,:,.
|
||||||
|
....:lcccc:;,'''.....'',;;:clllc,....
|
||||||
|
.'',;:cllllllccccclllllcc:,'..
|
||||||
|
...'',,;;;;;;;;;,''...
|
||||||
|
.....
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
TOPIC = """
|
||||||
|
#######################
|
||||||
|
# Environment Setup #
|
||||||
|
#######################
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
EXPECTED_ENV_VARS = [
|
||||||
|
"TA_USERNAME",
|
||||||
|
"TA_PASSWORD",
|
||||||
|
"ELASTIC_PASSWORD",
|
||||||
|
"ES_URL",
|
||||||
|
"TA_HOST",
|
||||||
|
]
|
||||||
|
INST = "https://github.com/tubearchivist/tubearchivist#installing-and-updating"
|
||||||
|
NGINX = "/etc/nginx/sites-available/default"
|
||||||
|
UWSGI = "/app/uwsgi.ini"
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""command framework"""
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
help = "Check environment before startup"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""run all commands"""
|
||||||
|
self.stdout.write(LOGO)
|
||||||
|
self.stdout.write(TOPIC)
|
||||||
|
self._expected_vars()
|
||||||
|
self._elastic_user_overwrite()
|
||||||
|
self._ta_port_overwrite()
|
||||||
|
self._ta_uwsgi_overwrite()
|
||||||
|
self._enable_cast_overwrite()
|
||||||
|
self._create_superuser()
|
||||||
|
|
||||||
|
def _expected_vars(self):
|
||||||
|
"""check if expected env vars are set"""
|
||||||
|
self.stdout.write("[1] checking expected env vars")
|
||||||
|
env = os.environ
|
||||||
|
for var in EXPECTED_ENV_VARS:
|
||||||
|
if not env.get(var):
|
||||||
|
message = f" 🗙 expected env var {var} not set\n {INST}"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
message = " ✓ all expected env vars are set"
|
||||||
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
|
def _elastic_user_overwrite(self):
|
||||||
|
"""check for ELASTIC_USER overwrite"""
|
||||||
|
self.stdout.write("[2] set default ES user")
|
||||||
|
if not os.environ.get("ELASTIC_USER"):
|
||||||
|
os.environ.setdefault("ELASTIC_USER", "elastic")
|
||||||
|
|
||||||
|
env = os.environ.get("ELASTIC_USER")
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" ✓ ES user is set to {env}"))
|
||||||
|
|
||||||
|
def _ta_port_overwrite(self):
|
||||||
|
"""set TA_PORT overwrite for nginx"""
|
||||||
|
self.stdout.write("[3] check TA_PORT overwrite")
|
||||||
|
overwrite = os.environ.get("TA_PORT")
|
||||||
|
if not overwrite:
|
||||||
|
self.stdout.write(self.style.SUCCESS(" TA_PORT is not set"))
|
||||||
|
return
|
||||||
|
|
||||||
|
regex = re.compile(r"listen [0-9]{1,5}")
|
||||||
|
to_overwrite = f"listen {overwrite}"
|
||||||
|
changed = file_overwrite(NGINX, regex, to_overwrite)
|
||||||
|
if changed:
|
||||||
|
message = f" ✓ TA_PORT changed to {overwrite}"
|
||||||
|
else:
|
||||||
|
message = f" ✓ TA_PORT already set to {overwrite}"
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
|
def _ta_uwsgi_overwrite(self):
|
||||||
|
"""set TA_UWSGI_PORT overwrite"""
|
||||||
|
self.stdout.write("[4] check TA_UWSGI_PORT overwrite")
|
||||||
|
overwrite = os.environ.get("TA_UWSGI_PORT")
|
||||||
|
if not overwrite:
|
||||||
|
message = " TA_UWSGI_PORT is not set"
|
||||||
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
return
|
||||||
|
|
||||||
|
# nginx
|
||||||
|
regex = re.compile(r"uwsgi_pass localhost:[0-9]{1,5}")
|
||||||
|
to_overwrite = f"uwsgi_pass localhost:{overwrite}"
|
||||||
|
changed = file_overwrite(NGINX, regex, to_overwrite)
|
||||||
|
|
||||||
|
# uwsgi
|
||||||
|
regex = re.compile(r"socket = :[0-9]{1,5}")
|
||||||
|
to_overwrite = f"socket = :{overwrite}"
|
||||||
|
changed = file_overwrite(UWSGI, regex, to_overwrite)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
message = f" ✓ TA_UWSGI_PORT changed to {overwrite}"
|
||||||
|
else:
|
||||||
|
message = f" ✓ TA_UWSGI_PORT already set to {overwrite}"
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
|
def _enable_cast_overwrite(self):
|
||||||
|
"""cast workaround, remove auth for static files in nginx"""
|
||||||
|
self.stdout.write("[5] check ENABLE_CAST overwrite")
|
||||||
|
overwrite = os.environ.get("ENABLE_CAST")
|
||||||
|
if not overwrite:
|
||||||
|
self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set"))
|
||||||
|
return
|
||||||
|
|
||||||
|
regex = re.compile(r"[^\S\r\n]*auth_request /api/ping/;\n")
|
||||||
|
changed = file_overwrite(NGINX, regex, "")
|
||||||
|
if changed:
|
||||||
|
message = " ✓ process nginx to enable Cast"
|
||||||
|
else:
|
||||||
|
message = " ✓ Cast is already enabled in nginx"
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
|
def _create_superuser(self):
|
||||||
|
"""create superuser if not exist"""
|
||||||
|
self.stdout.write("[6] create superuser")
|
||||||
|
is_created = Account.objects.filter(is_superuser=True)
|
||||||
|
if is_created:
|
||||||
|
message = " superuser already created"
|
||||||
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
return
|
||||||
|
|
||||||
|
name = os.environ.get("TA_USERNAME")
|
||||||
|
password = os.environ.get("TA_PASSWORD")
|
||||||
|
Account.objects.create_superuser(name, password)
|
||||||
|
message = f" ✓ new superuser with name {name} created"
|
||||||
|
self.stdout.write(self.style.SUCCESS(message))
|
||||||
|
|
||||||
|
|
||||||
|
def file_overwrite(file_path, regex, overwrite):
|
||||||
|
"""change file content from old to overwrite, return true when changed"""
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
file_content = f.read()
|
||||||
|
|
||||||
|
changed = re.sub(regex, overwrite, file_content)
|
||||||
|
if changed == file_content:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(changed)
|
||||||
|
|
||||||
|
return True
|
||||||
195
tubearchivist/config/management/commands/ta_startup.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Functionality:
|
||||||
|
- Application startup
|
||||||
|
- Apply migrations
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from home.src.es.connect import ElasticWrap
|
||||||
|
from home.src.es.index_setup import ElasitIndexWrap
|
||||||
|
from home.src.es.snapshot import ElasticSnapshot
|
||||||
|
from home.src.ta.config import AppConfig, ReleaseVersion
|
||||||
|
from home.src.ta.helper import clear_dl_cache
|
||||||
|
from home.src.ta.ta_redis import RedisArchivist
|
||||||
|
from home.src.ta.task_manager import TaskManager
|
||||||
|
|
||||||
|
TOPIC = """
|
||||||
|
|
||||||
|
#######################
|
||||||
|
# Application Start #
|
||||||
|
#######################
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""command framework"""
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""run all commands"""
|
||||||
|
self.stdout.write(TOPIC)
|
||||||
|
self._sync_redis_state()
|
||||||
|
self._make_folders()
|
||||||
|
self._release_locks()
|
||||||
|
self._clear_tasks()
|
||||||
|
self._clear_dl_cache()
|
||||||
|
self._version_check()
|
||||||
|
self._mig_index_setup()
|
||||||
|
self._mig_snapshot_check()
|
||||||
|
self._mig_set_vid_type()
|
||||||
|
|
||||||
|
def _sync_redis_state(self):
|
||||||
|
"""make sure redis gets new config.json values"""
|
||||||
|
self.stdout.write("[1] set new config.json values")
|
||||||
|
needs_update = AppConfig().load_new_defaults()
|
||||||
|
if needs_update:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" ✓ new config values set")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(" no new config values"))
|
||||||
|
|
||||||
|
def _make_folders(self):
|
||||||
|
"""make expected cache folders"""
|
||||||
|
self.stdout.write("[2] create expected cache folders")
|
||||||
|
folders = [
|
||||||
|
"backup",
|
||||||
|
"channels",
|
||||||
|
"download",
|
||||||
|
"import",
|
||||||
|
"playlists",
|
||||||
|
"videos",
|
||||||
|
]
|
||||||
|
cache_dir = AppConfig().config["application"]["cache_dir"]
|
||||||
|
for folder in folders:
|
||||||
|
folder_path = os.path.join(cache_dir, folder)
|
||||||
|
os.makedirs(folder_path, exist_ok=True)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(" ✓ expected folders created"))
|
||||||
|
|
||||||
|
def _release_locks(self):
|
||||||
|
"""make sure there are no leftover locks set in redis"""
|
||||||
|
self.stdout.write("[3] clear leftover locks in redis")
|
||||||
|
all_locks = [
|
||||||
|
"dl_queue_id",
|
||||||
|
"dl_queue",
|
||||||
|
"downloading",
|
||||||
|
"manual_import",
|
||||||
|
"reindex",
|
||||||
|
"rescan",
|
||||||
|
"run_backup",
|
||||||
|
"startup_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
redis_con = RedisArchivist()
|
||||||
|
has_changed = False
|
||||||
|
for lock in all_locks:
|
||||||
|
if redis_con.del_message(lock):
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ cleared lock {lock}")
|
||||||
|
)
|
||||||
|
has_changed = True
|
||||||
|
|
||||||
|
if not has_changed:
|
||||||
|
self.stdout.write(self.style.SUCCESS(" no locks found"))
|
||||||
|
|
||||||
|
def _clear_tasks(self):
|
||||||
|
"""clear tasks and messages"""
|
||||||
|
self.stdout.write("[4] clear task leftovers")
|
||||||
|
TaskManager().fail_pending()
|
||||||
|
redis_con = RedisArchivist()
|
||||||
|
to_delete = redis_con.list_keys("message:")
|
||||||
|
if to_delete:
|
||||||
|
for key in to_delete:
|
||||||
|
redis_con.del_message(key)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ cleared {len(to_delete)} messages")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clear_dl_cache(self):
|
||||||
|
"""clear leftover files from dl cache"""
|
||||||
|
self.stdout.write("[5] clear leftover files from dl cache")
|
||||||
|
config = AppConfig().config
|
||||||
|
leftover_files = clear_dl_cache(config)
|
||||||
|
if leftover_files:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ cleared {leftover_files} files")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(" no files found"))
|
||||||
|
|
||||||
|
def _version_check(self):
|
||||||
|
"""remove new release key if updated now"""
|
||||||
|
self.stdout.write("[6] check for first run after update")
|
||||||
|
new_version = ReleaseVersion().is_updated()
|
||||||
|
if new_version:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ update to {new_version} completed")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(" no new update found"))
|
||||||
|
|
||||||
|
def _mig_index_setup(self):
|
||||||
|
"""migration: validate index mappings"""
|
||||||
|
self.stdout.write("[MIGRATION] validate index mappings")
|
||||||
|
ElasitIndexWrap().setup()
|
||||||
|
|
||||||
|
def _mig_snapshot_check(self):
|
||||||
|
"""migration setup snapshots"""
|
||||||
|
self.stdout.write("[MIGRATION] setup snapshots")
|
||||||
|
ElasticSnapshot().setup()
|
||||||
|
|
||||||
|
def _mig_set_vid_type(self):
|
||||||
|
"""migration: update 0.3.0 to 0.3.1 set vid_type default"""
|
||||||
|
self.stdout.write("[MIGRATION] set default vid_type")
|
||||||
|
index_list = ["ta_video", "ta_download"]
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"should": [
|
||||||
|
{
|
||||||
|
"bool": {
|
||||||
|
"must_not": [{"exists": {"field": "vid_type"}}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"term": {"vid_type": {"value": "unknown"}}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"script": {"source": "ctx._source['vid_type'] = 'videos'"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for index_name in index_list:
|
||||||
|
path = f"{index_name}/_update_by_query"
|
||||||
|
response, status_code = ElasticWrap(path).post(data=data)
|
||||||
|
if status_code == 503:
|
||||||
|
message = f" 🗙 {index_name} retry failed migration."
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
sleep(10)
|
||||||
|
response, status_code = ElasticWrap(path).post(data=data)
|
||||||
|
|
||||||
|
if status_code == 200:
|
||||||
|
updated = response.get("updated", 0)
|
||||||
|
if not updated:
|
||||||
|
self.stdout.write(
|
||||||
|
f" no videos needed updating in {index_name}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f" ✓ {updated} videos updated in {index_name}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = f" 🗙 {index_name} vid_type update failed"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
@@ -18,6 +18,7 @@ import ldap
|
|||||||
from corsheaders.defaults import default_headers
|
from corsheaders.defaults import default_headers
|
||||||
from django_auth_ldap.config import LDAPSearch
|
from django_auth_ldap.config import LDAPSearch
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
|
from home.src.ta.helper import ta_host_parser
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -26,21 +27,13 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
||||||
|
|
||||||
PW_HASH = hashlib.sha256(environ.get("TA_PASSWORD").encode())
|
PW_HASH = hashlib.sha256(environ["TA_PASSWORD"].encode())
|
||||||
SECRET_KEY = PW_HASH.hexdigest()
|
SECRET_KEY = PW_HASH.hexdigest()
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = bool(environ.get("DJANGO_DEBUG"))
|
DEBUG = bool(environ.get("DJANGO_DEBUG"))
|
||||||
|
|
||||||
ALLOWED_HOSTS = [i.strip() for i in environ.get("TA_HOST").split()]
|
ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS = ta_host_parser(environ["TA_HOST"])
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = []
|
|
||||||
for host in ALLOWED_HOSTS:
|
|
||||||
if host.startswith("http://") or host.startswith("https://"):
|
|
||||||
CSRF_TRUSTED_ORIGINS.append(host)
|
|
||||||
else:
|
|
||||||
CSRF_TRUSTED_ORIGINS.append(f"http://{host}")
|
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
@@ -58,6 +51,7 @@ INSTALLED_APPS = [
|
|||||||
"rest_framework",
|
"rest_framework",
|
||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"api",
|
"api",
|
||||||
|
"config",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -262,4 +256,4 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
|
|||||||
|
|
||||||
# TA application settings
|
# TA application settings
|
||||||
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
||||||
TA_VERSION = "v0.3.0"
|
TA_VERSION = "v0.3.5"
|
||||||
|
|||||||
@@ -1,123 +1,6 @@
|
|||||||
"""handle custom startup functions"""
|
"""handle custom startup functions"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from home.src.es.connect import ElasticWrap
|
|
||||||
from home.src.es.index_setup import ElasitIndexWrap
|
|
||||||
from home.src.es.snapshot import ElasticSnapshot
|
|
||||||
from home.src.ta.config import AppConfig as ArchivistConfig
|
|
||||||
from home.src.ta.helper import clear_dl_cache
|
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
|
||||||
|
|
||||||
|
|
||||||
class StartupCheck:
|
|
||||||
"""checks to run at application startup"""
|
|
||||||
|
|
||||||
MIN_MAJOR, MAX_MAJOR = 8, 8
|
|
||||||
MIN_MINOR = 0
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.config_handler = ArchivistConfig()
|
|
||||||
self.redis_con = RedisArchivist()
|
|
||||||
self.has_run = self.get_has_run()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""run all startup checks"""
|
|
||||||
print("run startup checks")
|
|
||||||
self.es_version_check()
|
|
||||||
self.release_lock()
|
|
||||||
ElasitIndexWrap().setup()
|
|
||||||
self.sync_redis_state()
|
|
||||||
self.set_redis_conf()
|
|
||||||
self.make_folders()
|
|
||||||
clear_dl_cache(self.config_handler.config)
|
|
||||||
self.snapshot_check()
|
|
||||||
self.set_has_run()
|
|
||||||
|
|
||||||
def get_has_run(self):
|
|
||||||
"""validate if check has already executed"""
|
|
||||||
return self.redis_con.get_message("startup_check")
|
|
||||||
|
|
||||||
def set_has_run(self):
|
|
||||||
"""startup checks run"""
|
|
||||||
message = {"status": True}
|
|
||||||
self.redis_con.set_message("startup_check", message, expire=120)
|
|
||||||
|
|
||||||
def sync_redis_state(self):
|
|
||||||
"""make sure redis gets new config.json values"""
|
|
||||||
print("sync redis")
|
|
||||||
self.config_handler.load_new_defaults()
|
|
||||||
|
|
||||||
def set_redis_conf(self):
|
|
||||||
"""set conf values for redis"""
|
|
||||||
self.redis_con.conn.config_set("timeout", 3600)
|
|
||||||
|
|
||||||
def make_folders(self):
|
|
||||||
"""make needed cache folders here so docker doesn't mess it up"""
|
|
||||||
folders = [
|
|
||||||
"download",
|
|
||||||
"channels",
|
|
||||||
"videos",
|
|
||||||
"playlists",
|
|
||||||
"import",
|
|
||||||
"backup",
|
|
||||||
]
|
|
||||||
cache_dir = self.config_handler.config["application"]["cache_dir"]
|
|
||||||
for folder in folders:
|
|
||||||
folder_path = os.path.join(cache_dir, folder)
|
|
||||||
os.makedirs(folder_path, exist_ok=True)
|
|
||||||
|
|
||||||
def release_lock(self):
|
|
||||||
"""make sure there are no leftover locks set in redis"""
|
|
||||||
all_locks = [
|
|
||||||
"startup_check",
|
|
||||||
"manual_import",
|
|
||||||
"downloading",
|
|
||||||
"dl_queue",
|
|
||||||
"dl_queue_id",
|
|
||||||
"rescan",
|
|
||||||
"run_backup",
|
|
||||||
]
|
|
||||||
for lock in all_locks:
|
|
||||||
response = self.redis_con.del_message(lock)
|
|
||||||
if response:
|
|
||||||
print("deleted leftover key from redis: " + lock)
|
|
||||||
|
|
||||||
def snapshot_check(self):
|
|
||||||
"""setup snapshot config, create if needed"""
|
|
||||||
app = self.config_handler.config["application"]
|
|
||||||
if not app.get("enable_snapshot"):
|
|
||||||
return
|
|
||||||
|
|
||||||
ElasticSnapshot().setup()
|
|
||||||
|
|
||||||
def is_invalid(self, version):
|
|
||||||
"""return true if es version is invalid, false if ok"""
|
|
||||||
major, minor = [int(i) for i in version.split(".")[:2]]
|
|
||||||
if not self.MIN_MAJOR <= major <= self.MAX_MAJOR:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if minor >= self.MIN_MINOR:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def es_version_check(self):
|
|
||||||
"""check for minimal elasticsearch version"""
|
|
||||||
response, _ = ElasticWrap("/").get()
|
|
||||||
version = response["version"]["number"]
|
|
||||||
invalid = self.is_invalid(version)
|
|
||||||
|
|
||||||
if invalid:
|
|
||||||
print(
|
|
||||||
"required elasticsearch version: "
|
|
||||||
+ f"{self.MIN_MAJOR}.{self.MIN_MINOR}"
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("elasticsearch version check passed")
|
|
||||||
|
|
||||||
|
|
||||||
class HomeConfig(AppConfig):
|
class HomeConfig(AppConfig):
|
||||||
@@ -125,11 +8,3 @@ class HomeConfig(AppConfig):
|
|||||||
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "home"
|
name = "home"
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
startup = StartupCheck()
|
|
||||||
if startup.has_run["status"]:
|
|
||||||
print("startup checks run in other thread")
|
|
||||||
return
|
|
||||||
|
|
||||||
startup.run()
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"subscriptions": {
|
"subscriptions": {
|
||||||
"auto_search": false,
|
"auto_search": false,
|
||||||
"auto_download": false,
|
"auto_download": false,
|
||||||
"channel_size": 50
|
"channel_size": 50,
|
||||||
|
"live_channel_size": 50,
|
||||||
|
"shorts_channel_size": 50
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"limit_count": false,
|
"limit_count": false,
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"videos": "/youtube",
|
"videos": "/youtube",
|
||||||
"colors": "dark",
|
"colors": "dark",
|
||||||
"enable_cast": false,
|
"enable_cast": false,
|
||||||
"enable_snapshot": false
|
"enable_snapshot": true
|
||||||
},
|
},
|
||||||
"scheduler": {
|
"scheduler": {
|
||||||
"update_subscribed": false,
|
"update_subscribed": false,
|
||||||
@@ -48,7 +50,8 @@
|
|||||||
"check_reindex": {"minute": "0", "hour": "12", "day_of_week": "*"},
|
"check_reindex": {"minute": "0", "hour": "12", "day_of_week": "*"},
|
||||||
"check_reindex_days": 90,
|
"check_reindex_days": 90,
|
||||||
"thumbnail_check": {"minute": "0", "hour": "17", "day_of_week": "*"},
|
"thumbnail_check": {"minute": "0", "hour": "17", "day_of_week": "*"},
|
||||||
"run_backup": {"minute": "0", "hour": "8", "day_of_week": "0"},
|
"run_backup": false,
|
||||||
"run_backup_rotate": 5
|
"run_backup_rotate": 5,
|
||||||
|
"version_check": "rand-d"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
tubearchivist/home/migrations/0001_initial.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-02-02 06:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import home.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Account',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('name', models.CharField(max_length=150, unique=True)),
|
||||||
|
('is_staff', models.BooleanField(default=False)),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', home.models.AccountManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,9 +15,10 @@ from home.src.download.thumbnails import ThumbManager
|
|||||||
from home.src.download.yt_dlp_base import YtWrap
|
from home.src.download.yt_dlp_base import YtWrap
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from home.src.index.playlist import YoutubePlaylist
|
||||||
|
from home.src.index.video_constants import VideoTypeEnum
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
from home.src.ta.helper import DurationConverter
|
from home.src.ta.helper import DurationConverter, is_shorts
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
from home.src.ta.ta_redis import RedisQueue
|
||||||
|
|
||||||
|
|
||||||
class PendingIndex:
|
class PendingIndex:
|
||||||
@@ -95,13 +96,13 @@ class PendingIndex:
|
|||||||
class PendingInteract:
|
class PendingInteract:
|
||||||
"""interact with items in download queue"""
|
"""interact with items in download queue"""
|
||||||
|
|
||||||
def __init__(self, video_id=False, status=False):
|
def __init__(self, youtube_id=False, status=False):
|
||||||
self.video_id = video_id
|
self.youtube_id = youtube_id
|
||||||
self.status = status
|
self.status = status
|
||||||
|
|
||||||
def delete_item(self):
|
def delete_item(self):
|
||||||
"""delete single item from pending"""
|
"""delete single item from pending"""
|
||||||
path = f"ta_download/_doc/{self.video_id}"
|
path = f"ta_download/_doc/{self.youtube_id}"
|
||||||
_, _ = ElasticWrap(path).delete(refresh=True)
|
_, _ = ElasticWrap(path).delete(refresh=True)
|
||||||
|
|
||||||
def delete_by_status(self):
|
def delete_by_status(self):
|
||||||
@@ -113,27 +114,61 @@ class PendingInteract:
|
|||||||
def update_status(self):
|
def update_status(self):
|
||||||
"""update status field of pending item"""
|
"""update status field of pending item"""
|
||||||
data = {"doc": {"status": self.status}}
|
data = {"doc": {"status": self.status}}
|
||||||
path = f"ta_download/_update/{self.video_id}"
|
path = f"ta_download/_update/{self.youtube_id}"
|
||||||
_, _ = ElasticWrap(path).post(data=data)
|
_, _ = ElasticWrap(path).post(data=data)
|
||||||
|
|
||||||
|
def prioritize(self):
|
||||||
|
"""prioritize pending item in redis queue"""
|
||||||
|
pending_video, _ = self.get_item()
|
||||||
|
vid_type = pending_video.get("vid_type", VideoTypeEnum.VIDEOS.value)
|
||||||
|
to_add = {
|
||||||
|
"youtube_id": pending_video["youtube_id"],
|
||||||
|
"vid_type": vid_type,
|
||||||
|
}
|
||||||
|
RedisQueue(queue_name="dl_queue").add_priority(to_add)
|
||||||
|
|
||||||
|
def get_item(self):
|
||||||
|
"""return pending item dict"""
|
||||||
|
path = f"ta_download/_doc/{self.youtube_id}"
|
||||||
|
response, status_code = ElasticWrap(path).get()
|
||||||
|
return response["_source"], status_code
|
||||||
|
|
||||||
|
def get_channel(self):
|
||||||
|
"""
|
||||||
|
get channel metadata from queue to not depend on channel to be indexed
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"size": 1,
|
||||||
|
"query": {"term": {"channel_id": {"value": self.youtube_id}}},
|
||||||
|
}
|
||||||
|
response, _ = ElasticWrap("ta_download/_search").get(data=data)
|
||||||
|
hits = response["hits"]["hits"]
|
||||||
|
if not hits:
|
||||||
|
channel_name = "NA"
|
||||||
|
else:
|
||||||
|
channel_name = hits[0]["_source"].get("channel_name", "NA")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"channel_id": self.youtube_id,
|
||||||
|
"channel_name": channel_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PendingList(PendingIndex):
|
class PendingList(PendingIndex):
|
||||||
"""manage the pending videos list"""
|
"""manage the pending videos list"""
|
||||||
|
|
||||||
yt_obs = {
|
yt_obs = {
|
||||||
"default_search": "ytsearch",
|
|
||||||
"quiet": True,
|
|
||||||
"check_formats": "selected",
|
|
||||||
"noplaylist": True,
|
"noplaylist": True,
|
||||||
"writethumbnail": True,
|
"writethumbnail": True,
|
||||||
"simulate": True,
|
"simulate": True,
|
||||||
"socket_timeout": 3,
|
"check_formats": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, youtube_ids=False):
|
def __init__(self, youtube_ids=False, task=False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
self.youtube_ids = youtube_ids
|
self.youtube_ids = youtube_ids
|
||||||
|
self.task = task
|
||||||
self.to_skip = False
|
self.to_skip = False
|
||||||
self.missing_videos = False
|
self.missing_videos = False
|
||||||
|
|
||||||
@@ -142,44 +177,53 @@ class PendingList(PendingIndex):
|
|||||||
self.missing_videos = []
|
self.missing_videos = []
|
||||||
self.get_download()
|
self.get_download()
|
||||||
self.get_indexed()
|
self.get_indexed()
|
||||||
for entry in self.youtube_ids:
|
total = len(self.youtube_ids)
|
||||||
# notify
|
for idx, entry in enumerate(self.youtube_ids):
|
||||||
mess_dict = {
|
|
||||||
"status": "message:add",
|
|
||||||
"level": "info",
|
|
||||||
"title": "Adding to download queue.",
|
|
||||||
"message": "Extracting lists",
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message("message:add", mess_dict, expire=True)
|
|
||||||
self._process_entry(entry)
|
self._process_entry(entry)
|
||||||
|
if not self.task:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=[f"Extracting items {idx + 1}/{total}"],
|
||||||
|
progress=(idx + 1) / total,
|
||||||
|
)
|
||||||
|
|
||||||
def _process_entry(self, entry):
|
def _process_entry(self, entry):
|
||||||
"""process single entry from url list"""
|
"""process single entry from url list"""
|
||||||
|
vid_type = self._get_vid_type(entry)
|
||||||
if entry["type"] == "video":
|
if entry["type"] == "video":
|
||||||
self._add_video(entry["url"])
|
self._add_video(entry["url"], vid_type)
|
||||||
elif entry["type"] == "channel":
|
elif entry["type"] == "channel":
|
||||||
self._parse_channel(entry["url"])
|
self._parse_channel(entry["url"], vid_type)
|
||||||
elif entry["type"] == "playlist":
|
elif entry["type"] == "playlist":
|
||||||
self._parse_playlist(entry["url"])
|
self._parse_playlist(entry["url"])
|
||||||
PlaylistSubscription().process_url_str([entry], subscribed=False)
|
PlaylistSubscription().process_url_str([entry], subscribed=False)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"invalid url_type: {entry}")
|
raise ValueError(f"invalid url_type: {entry}")
|
||||||
|
|
||||||
def _add_video(self, url):
|
@staticmethod
|
||||||
|
def _get_vid_type(entry):
|
||||||
|
"""add vid type enum if available"""
|
||||||
|
vid_type_str = entry.get("vid_type")
|
||||||
|
if not vid_type_str:
|
||||||
|
return VideoTypeEnum.UNKNOWN
|
||||||
|
|
||||||
|
return VideoTypeEnum(vid_type_str)
|
||||||
|
|
||||||
|
def _add_video(self, url, vid_type):
|
||||||
"""add video to list"""
|
"""add video to list"""
|
||||||
if url not in self.missing_videos and url not in self.to_skip:
|
if url not in self.missing_videos and url not in self.to_skip:
|
||||||
self.missing_videos.append(url)
|
self.missing_videos.append((url, vid_type))
|
||||||
else:
|
else:
|
||||||
print(f"{url}: skipped adding already indexed video to download.")
|
print(f"{url}: skipped adding already indexed video to download.")
|
||||||
|
|
||||||
def _parse_channel(self, url):
|
def _parse_channel(self, url, vid_type):
|
||||||
"""add all videos of channel to list"""
|
"""add all videos of channel to list"""
|
||||||
video_results = ChannelSubscription().get_last_youtube_videos(
|
video_results = ChannelSubscription().get_last_youtube_videos(
|
||||||
url, limit=False
|
url, limit=False, query_filter=vid_type
|
||||||
)
|
)
|
||||||
youtube_ids = [i[0] for i in video_results]
|
for video_id, _, vid_type in video_results:
|
||||||
for video_id in youtube_ids:
|
self._add_video(video_id, vid_type)
|
||||||
self._add_video(video_id)
|
|
||||||
|
|
||||||
def _parse_playlist(self, url):
|
def _parse_playlist(self, url):
|
||||||
"""add all videos of playlist to list"""
|
"""add all videos of playlist to list"""
|
||||||
@@ -188,16 +232,22 @@ class PendingList(PendingIndex):
|
|||||||
video_results = playlist.json_data.get("playlist_entries")
|
video_results = playlist.json_data.get("playlist_entries")
|
||||||
youtube_ids = [i["youtube_id"] for i in video_results]
|
youtube_ids = [i["youtube_id"] for i in video_results]
|
||||||
for video_id in youtube_ids:
|
for video_id in youtube_ids:
|
||||||
self._add_video(video_id)
|
# match vid_type later
|
||||||
|
self._add_video(video_id, VideoTypeEnum.UNKNOWN)
|
||||||
|
|
||||||
def add_to_pending(self, status="pending"):
|
def add_to_pending(self, status="pending"):
|
||||||
"""add missing videos to pending list"""
|
"""add missing videos to pending list"""
|
||||||
self.get_channels()
|
self.get_channels()
|
||||||
bulk_list = []
|
bulk_list = []
|
||||||
|
|
||||||
for idx, youtube_id in enumerate(self.missing_videos):
|
total = len(self.missing_videos)
|
||||||
print(f"{youtube_id}: add to download queue")
|
for idx, (youtube_id, vid_type) in enumerate(self.missing_videos):
|
||||||
video_details = self.get_youtube_details(youtube_id)
|
if self.task and self.task.is_stopped():
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"{youtube_id}: [{idx + 1}/{total}]: add to queue")
|
||||||
|
self._notify_add(idx, total)
|
||||||
|
video_details = self.get_youtube_details(youtube_id, vid_type)
|
||||||
if not video_details:
|
if not video_details:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -209,33 +259,36 @@ class PendingList(PendingIndex):
|
|||||||
url = video_details["vid_thumb_url"]
|
url = video_details["vid_thumb_url"]
|
||||||
ThumbManager(youtube_id).download_video_thumb(url)
|
ThumbManager(youtube_id).download_video_thumb(url)
|
||||||
|
|
||||||
self._notify_add(idx)
|
if len(bulk_list) >= 20:
|
||||||
|
self._ingest_bulk(bulk_list)
|
||||||
|
bulk_list = []
|
||||||
|
|
||||||
if bulk_list:
|
self._ingest_bulk(bulk_list)
|
||||||
# add last newline
|
|
||||||
bulk_list.append("\n")
|
|
||||||
query_str = "\n".join(bulk_list)
|
|
||||||
_, _ = ElasticWrap("_bulk").post(query_str, ndjson=True)
|
|
||||||
|
|
||||||
def _notify_add(self, idx):
|
def _ingest_bulk(self, bulk_list):
|
||||||
|
"""add items to queue in bulk"""
|
||||||
|
if not bulk_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
# add last newline
|
||||||
|
bulk_list.append("\n")
|
||||||
|
query_str = "\n".join(bulk_list)
|
||||||
|
_, _ = ElasticWrap("_bulk").post(query_str, ndjson=True)
|
||||||
|
|
||||||
|
def _notify_add(self, idx, total):
|
||||||
"""send notification for adding videos to download queue"""
|
"""send notification for adding videos to download queue"""
|
||||||
progress = f"{idx + 1}/{len(self.missing_videos)}"
|
if not self.task:
|
||||||
mess_dict = {
|
return
|
||||||
"status": "message:add",
|
|
||||||
"level": "info",
|
|
||||||
"title": "Adding new videos to download queue.",
|
|
||||||
"message": "Progress: " + progress,
|
|
||||||
}
|
|
||||||
if idx + 1 == len(self.missing_videos):
|
|
||||||
expire = 4
|
|
||||||
else:
|
|
||||||
expire = True
|
|
||||||
|
|
||||||
RedisArchivist().set_message("message:add", mess_dict, expire=expire)
|
self.task.send_progress(
|
||||||
if idx + 1 % 25 == 0:
|
message_lines=[
|
||||||
print("adding to queue progress: " + progress)
|
"Adding new videos to download queue.",
|
||||||
|
f"Extracting items {idx + 1}/{total}",
|
||||||
|
],
|
||||||
|
progress=(idx + 1) / total,
|
||||||
|
)
|
||||||
|
|
||||||
def get_youtube_details(self, youtube_id):
|
def get_youtube_details(self, youtube_id, vid_type=VideoTypeEnum.VIDEOS):
|
||||||
"""get details from youtubedl for single pending video"""
|
"""get details from youtubedl for single pending video"""
|
||||||
vid = YtWrap(self.yt_obs, self.config).extract(youtube_id)
|
vid = YtWrap(self.yt_obs, self.config).extract(youtube_id)
|
||||||
if not vid:
|
if not vid:
|
||||||
@@ -247,11 +300,33 @@ class PendingList(PendingIndex):
|
|||||||
return False
|
return False
|
||||||
# stop if video is streaming live now
|
# stop if video is streaming live now
|
||||||
if vid["live_status"] in ["is_upcoming", "is_live"]:
|
if vid["live_status"] in ["is_upcoming", "is_live"]:
|
||||||
|
print(f"{youtube_id}: skip is_upcoming or is_live")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return self._parse_youtube_details(vid)
|
if vid["live_status"] == "was_live":
|
||||||
|
vid_type = VideoTypeEnum.STREAMS
|
||||||
|
else:
|
||||||
|
if self._check_shorts(vid):
|
||||||
|
vid_type = VideoTypeEnum.SHORTS
|
||||||
|
else:
|
||||||
|
vid_type = VideoTypeEnum.VIDEOS
|
||||||
|
|
||||||
def _parse_youtube_details(self, vid):
|
return self._parse_youtube_details(vid, vid_type)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_shorts(vid):
|
||||||
|
"""check if vid is shorts video"""
|
||||||
|
if vid["width"] > vid["height"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
duration = vid.get("duration")
|
||||||
|
if duration and isinstance(duration, int):
|
||||||
|
if duration > 60:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return is_shorts(vid["id"])
|
||||||
|
|
||||||
|
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
|
||||||
"""parse response"""
|
"""parse response"""
|
||||||
vid_id = vid.get("id")
|
vid_id = vid.get("id")
|
||||||
duration_str = DurationConverter.get_str(vid["duration"])
|
duration_str = DurationConverter.get_str(vid["duration"])
|
||||||
@@ -270,7 +345,9 @@ class PendingList(PendingIndex):
|
|||||||
"channel_id": vid["channel_id"],
|
"channel_id": vid["channel_id"],
|
||||||
"duration": duration_str,
|
"duration": duration_str,
|
||||||
"published": published,
|
"published": published,
|
||||||
"timestamp": int(datetime.now().strftime("%s")),
|
"timestamp": int(datetime.now().timestamp()),
|
||||||
|
# Pulling enum value out so it is serializable
|
||||||
|
"vid_type": vid_type.value,
|
||||||
}
|
}
|
||||||
if self.all_channels:
|
if self.all_channels:
|
||||||
youtube_details.update(
|
youtube_details.update(
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ from home.src.download.yt_dlp_base import YtWrap
|
|||||||
from home.src.es.connect import IndexPaginate
|
from home.src.es.connect import IndexPaginate
|
||||||
from home.src.index.channel import YoutubeChannel
|
from home.src.index.channel import YoutubeChannel
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from home.src.index.playlist import YoutubePlaylist
|
||||||
|
from home.src.index.video_constants import VideoTypeEnum
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
from home.src.ta.ta_redis import RedisArchivist
|
||||||
|
from home.src.ta.urlparser import Parser
|
||||||
|
|
||||||
|
|
||||||
class ChannelSubscription:
|
class ChannelSubscription:
|
||||||
"""manage the list of channels subscribed"""
|
"""manage the list of channels subscribed"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, task=False):
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
|
self.task = task
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_channels(subscribed_only=True):
|
def get_channels(subscribed_only=True):
|
||||||
@@ -35,56 +38,103 @@ class ChannelSubscription:
|
|||||||
|
|
||||||
return all_channels
|
return all_channels
|
||||||
|
|
||||||
def get_last_youtube_videos(self, channel_id, limit=True):
|
def get_last_youtube_videos(
|
||||||
|
self, channel_id, limit=True, query_filter=VideoTypeEnum.UNKNOWN
|
||||||
|
):
|
||||||
"""get a list of last videos from channel"""
|
"""get a list of last videos from channel"""
|
||||||
obs = {
|
queries = self._build_queries(query_filter, limit)
|
||||||
"skip_download": True,
|
|
||||||
"extract_flat": True,
|
|
||||||
}
|
|
||||||
if limit:
|
|
||||||
obs["playlistend"] = self.config["subscriptions"]["channel_size"]
|
|
||||||
|
|
||||||
url = f"https://www.youtube.com/channel/{channel_id}/videos"
|
last_videos = []
|
||||||
channel = YtWrap(obs, self.config).extract(url)
|
|
||||||
if not channel:
|
for vid_type_enum, limit_amount in queries:
|
||||||
return False
|
obs = {
|
||||||
|
"skip_download": True,
|
||||||
|
"extract_flat": True,
|
||||||
|
}
|
||||||
|
if limit:
|
||||||
|
obs["playlistend"] = limit_amount
|
||||||
|
|
||||||
|
vid_type = vid_type_enum.value
|
||||||
|
channel = YtWrap(obs, self.config).extract(
|
||||||
|
f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
|
||||||
|
)
|
||||||
|
if not channel:
|
||||||
|
continue
|
||||||
|
last_videos.extend(
|
||||||
|
[(i["id"], i["title"], vid_type) for i in channel["entries"]]
|
||||||
|
)
|
||||||
|
|
||||||
last_videos = [(i["id"], i["title"]) for i in channel["entries"]]
|
|
||||||
return last_videos
|
return last_videos
|
||||||
|
|
||||||
|
def _build_queries(self, query_filter, limit):
|
||||||
|
"""build query list for vid_type"""
|
||||||
|
limit_map = {
|
||||||
|
"videos": self.config["subscriptions"]["channel_size"],
|
||||||
|
"streams": self.config["subscriptions"]["live_channel_size"],
|
||||||
|
"shorts": self.config["subscriptions"]["shorts_channel_size"],
|
||||||
|
}
|
||||||
|
|
||||||
|
queries = []
|
||||||
|
|
||||||
|
if query_filter and query_filter.value != "unknown":
|
||||||
|
if limit:
|
||||||
|
query_limit = limit_map.get(query_filter.value)
|
||||||
|
else:
|
||||||
|
query_limit = False
|
||||||
|
|
||||||
|
queries.append((query_filter, query_limit))
|
||||||
|
|
||||||
|
return queries
|
||||||
|
|
||||||
|
for query_item, default_limit in limit_map.items():
|
||||||
|
if not default_limit:
|
||||||
|
# is deactivated in config
|
||||||
|
continue
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
query_limit = default_limit
|
||||||
|
else:
|
||||||
|
query_limit = False
|
||||||
|
|
||||||
|
queries.append((VideoTypeEnum(query_item), query_limit))
|
||||||
|
|
||||||
|
return queries
|
||||||
|
|
||||||
def find_missing(self):
|
def find_missing(self):
|
||||||
"""add missing videos from subscribed channels to pending"""
|
"""add missing videos from subscribed channels to pending"""
|
||||||
all_channels = self.get_channels()
|
all_channels = self.get_channels()
|
||||||
|
if not all_channels:
|
||||||
|
return False
|
||||||
|
|
||||||
pending = queue.PendingList()
|
pending = queue.PendingList()
|
||||||
pending.get_download()
|
pending.get_download()
|
||||||
pending.get_indexed()
|
pending.get_indexed()
|
||||||
|
|
||||||
missing_videos = []
|
missing_videos = []
|
||||||
|
|
||||||
|
total = len(all_channels)
|
||||||
for idx, channel in enumerate(all_channels):
|
for idx, channel in enumerate(all_channels):
|
||||||
channel_id = channel["channel_id"]
|
channel_id = channel["channel_id"]
|
||||||
print(f"{channel_id}: find missing videos.")
|
print(f"{channel_id}: find missing videos.")
|
||||||
last_videos = self.get_last_youtube_videos(channel_id)
|
last_videos = self.get_last_youtube_videos(channel_id)
|
||||||
|
|
||||||
if last_videos:
|
if last_videos:
|
||||||
for video in last_videos:
|
for video_id, _, vid_type in last_videos:
|
||||||
if video[0] not in pending.to_skip:
|
if video_id not in pending.to_skip:
|
||||||
missing_videos.append(video[0])
|
missing_videos.append((video_id, vid_type))
|
||||||
# notify
|
|
||||||
message = {
|
|
||||||
"status": "message:rescan",
|
|
||||||
"level": "info",
|
|
||||||
"title": "Scanning channels: Looking for new videos.",
|
|
||||||
"message": f"Progress: {idx + 1}/{len(all_channels)}",
|
|
||||||
}
|
|
||||||
if idx + 1 == len(all_channels):
|
|
||||||
expire = 4
|
|
||||||
else:
|
|
||||||
expire = True
|
|
||||||
|
|
||||||
RedisArchivist().set_message(
|
if not self.task:
|
||||||
"message:rescan", message=message, expire=expire
|
continue
|
||||||
)
|
|
||||||
|
if self.task:
|
||||||
|
if self.task.is_stopped():
|
||||||
|
self.task.send_progress(["Received Stop signal."])
|
||||||
|
break
|
||||||
|
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=[f"Scanning Channel {idx + 1}/{total}"],
|
||||||
|
progress=(idx + 1) / total,
|
||||||
|
)
|
||||||
|
|
||||||
return missing_videos
|
return missing_videos
|
||||||
|
|
||||||
@@ -101,8 +151,9 @@ class ChannelSubscription:
|
|||||||
class PlaylistSubscription:
|
class PlaylistSubscription:
|
||||||
"""manage the playlist download functionality"""
|
"""manage the playlist download functionality"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, task=False):
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
|
self.task = task
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_playlists(subscribed_only=True):
|
def get_playlists(subscribed_only=True):
|
||||||
@@ -186,14 +237,18 @@ class PlaylistSubscription:
|
|||||||
def find_missing(self):
|
def find_missing(self):
|
||||||
"""find videos in subscribed playlists not downloaded yet"""
|
"""find videos in subscribed playlists not downloaded yet"""
|
||||||
all_playlists = [i["playlist_id"] for i in self.get_playlists()]
|
all_playlists = [i["playlist_id"] for i in self.get_playlists()]
|
||||||
|
if not all_playlists:
|
||||||
|
return False
|
||||||
|
|
||||||
to_ignore = self.get_to_ignore()
|
to_ignore = self.get_to_ignore()
|
||||||
|
|
||||||
missing_videos = []
|
missing_videos = []
|
||||||
|
total = len(all_playlists)
|
||||||
for idx, playlist_id in enumerate(all_playlists):
|
for idx, playlist_id in enumerate(all_playlists):
|
||||||
size_limit = self.config["subscriptions"]["channel_size"]
|
size_limit = self.config["subscriptions"]["channel_size"]
|
||||||
playlist = YoutubePlaylist(playlist_id)
|
playlist = YoutubePlaylist(playlist_id)
|
||||||
playlist.update_playlist()
|
is_active = playlist.update_playlist()
|
||||||
if not playlist:
|
if not is_active:
|
||||||
playlist.deactivate()
|
playlist.deactivate()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -203,19 +258,123 @@ class PlaylistSubscription:
|
|||||||
|
|
||||||
all_missing = [i for i in playlist_entries if not i["downloaded"]]
|
all_missing = [i for i in playlist_entries if not i["downloaded"]]
|
||||||
|
|
||||||
message = {
|
|
||||||
"status": "message:rescan",
|
|
||||||
"level": "info",
|
|
||||||
"title": "Scanning playlists: Looking for new videos.",
|
|
||||||
"message": f"Progress: {idx + 1}/{len(all_playlists)}",
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message(
|
|
||||||
"message:rescan", message=message, expire=True
|
|
||||||
)
|
|
||||||
|
|
||||||
for video in all_missing:
|
for video in all_missing:
|
||||||
youtube_id = video["youtube_id"]
|
youtube_id = video["youtube_id"]
|
||||||
if youtube_id not in to_ignore:
|
if youtube_id not in to_ignore:
|
||||||
missing_videos.append(youtube_id)
|
missing_videos.append(youtube_id)
|
||||||
|
|
||||||
|
if not self.task:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
|
||||||
|
progress=(idx + 1) / total,
|
||||||
|
)
|
||||||
|
if self.task.is_stopped():
|
||||||
|
self.task.send_progress(["Received Stop signal."])
|
||||||
|
break
|
||||||
|
|
||||||
return missing_videos
|
return missing_videos
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionScanner:
|
||||||
|
"""add missing videos to queue"""
|
||||||
|
|
||||||
|
def __init__(self, task=False):
|
||||||
|
self.task = task
|
||||||
|
self.missing_videos = False
|
||||||
|
|
||||||
|
def scan(self):
|
||||||
|
"""scan channels and playlists"""
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress(["Rescanning channels and playlists."])
|
||||||
|
|
||||||
|
self.missing_videos = []
|
||||||
|
self.scan_channels()
|
||||||
|
if self.task and not self.task.is_stopped():
|
||||||
|
self.scan_playlists()
|
||||||
|
|
||||||
|
return self.missing_videos
|
||||||
|
|
||||||
|
def scan_channels(self):
|
||||||
|
"""get missing from channels"""
|
||||||
|
channel_handler = ChannelSubscription(task=self.task)
|
||||||
|
missing = channel_handler.find_missing()
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
for vid_id, vid_type in missing:
|
||||||
|
self.missing_videos.append(
|
||||||
|
{"type": "video", "vid_type": vid_type, "url": vid_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
def scan_playlists(self):
|
||||||
|
"""get missing from playlists"""
|
||||||
|
playlist_handler = PlaylistSubscription(task=self.task)
|
||||||
|
missing = playlist_handler.find_missing()
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
for i in missing:
|
||||||
|
self.missing_videos.append(
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"vid_type": VideoTypeEnum.VIDEOS.value,
|
||||||
|
"url": i,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionHandler:
|
||||||
|
"""subscribe to channels and playlists from url_str"""
|
||||||
|
|
||||||
|
def __init__(self, url_str, task=False):
|
||||||
|
self.url_str = url_str
|
||||||
|
self.task = task
|
||||||
|
self.to_subscribe = False
|
||||||
|
|
||||||
|
def subscribe(self):
|
||||||
|
"""subscribe to url_str items"""
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress(["Processing form content."])
|
||||||
|
self.to_subscribe = Parser(self.url_str).parse()
|
||||||
|
|
||||||
|
total = len(self.to_subscribe)
|
||||||
|
for idx, item in enumerate(self.to_subscribe):
|
||||||
|
if self.task:
|
||||||
|
self._notify(idx, item, total)
|
||||||
|
|
||||||
|
self.subscribe_type(item)
|
||||||
|
|
||||||
|
def subscribe_type(self, item):
|
||||||
|
"""process single item"""
|
||||||
|
if item["type"] == "playlist":
|
||||||
|
PlaylistSubscription().process_url_str([item])
|
||||||
|
return
|
||||||
|
|
||||||
|
if item["type"] == "video":
|
||||||
|
# extract channel id from video
|
||||||
|
vid = queue.PendingList().get_youtube_details(item["url"])
|
||||||
|
channel_id = vid["channel_id"]
|
||||||
|
elif item["type"] == "channel":
|
||||||
|
channel_id = item["url"]
|
||||||
|
else:
|
||||||
|
raise ValueError("failed to subscribe to: " + item["url"])
|
||||||
|
|
||||||
|
self._subscribe(channel_id)
|
||||||
|
|
||||||
|
def _subscribe(self, channel_id):
|
||||||
|
"""subscribe to channel"""
|
||||||
|
ChannelSubscription().change_subscribe(
|
||||||
|
channel_id, channel_subscribed=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def _notify(self, idx, item, total):
|
||||||
|
"""send notification message to redis"""
|
||||||
|
subscribe_type = item["type"].title()
|
||||||
|
message_lines = [
|
||||||
|
f"Subscribe to {subscribe_type}",
|
||||||
|
f"Progress: {idx + 1}/{total}",
|
||||||
|
]
|
||||||
|
self.task.send_progress(message_lines, progress=(idx + 1) / total)
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ from io import BytesIO
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from home.src.download import queue # partial import
|
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.es.connect import IndexPaginate
|
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
from mutagen.mp4 import MP4, MP4Cover
|
from mutagen.mp4 import MP4, MP4Cover
|
||||||
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
|
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError
|
||||||
@@ -43,8 +42,12 @@ class ThumbManagerBase:
|
|||||||
response = requests.get(url, stream=True, timeout=5)
|
response = requests.get(url, stream=True, timeout=5)
|
||||||
if response.ok:
|
if response.ok:
|
||||||
try:
|
try:
|
||||||
return Image.open(response.raw)
|
img = Image.open(response.raw)
|
||||||
except UnidentifiedImageError:
|
if isinstance(img, Image.Image):
|
||||||
|
return img
|
||||||
|
return self.get_fallback()
|
||||||
|
|
||||||
|
except (UnidentifiedImageError, OSError):
|
||||||
print(f"failed to open thumbnail: {url}")
|
print(f"failed to open thumbnail: {url}")
|
||||||
return self.get_fallback()
|
return self.get_fallback()
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ class ThumbManagerBase:
|
|||||||
|
|
||||||
def get_fallback(self):
|
def get_fallback(self):
|
||||||
"""get fallback thumbnail if not available"""
|
"""get fallback thumbnail if not available"""
|
||||||
|
print(f"{self.item_id}: failed to extract thumbnail, use fallback")
|
||||||
if self.fallback:
|
if self.fallback:
|
||||||
img_raw = Image.open(self.fallback)
|
img_raw = Image.open(self.fallback)
|
||||||
return img_raw
|
return img_raw
|
||||||
@@ -77,6 +81,9 @@ class ThumbManagerBase:
|
|||||||
"banner": os.path.join(
|
"banner": os.path.join(
|
||||||
app_root, "static/img/default-channel-banner.jpg"
|
app_root, "static/img/default-channel-banner.jpg"
|
||||||
),
|
),
|
||||||
|
"tvart": os.path.join(
|
||||||
|
app_root, "static/img/default-channel-art.jpg"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
img_raw = Image.open(default_map[self.item_type])
|
img_raw = Image.open(default_map[self.item_type])
|
||||||
@@ -145,9 +152,10 @@ class ThumbManager(ThumbManagerBase):
|
|||||||
|
|
||||||
def download_channel_art(self, urls, skip_existing=False):
|
def download_channel_art(self, urls, skip_existing=False):
|
||||||
"""pass tuple of channel thumbnails"""
|
"""pass tuple of channel thumbnails"""
|
||||||
channel_thumb, channel_banner = urls
|
channel_thumb, channel_banner, channel_tv = urls
|
||||||
self._download_channel_thumb(channel_thumb, skip_existing)
|
self._download_channel_thumb(channel_thumb, skip_existing)
|
||||||
self._download_channel_banner(channel_banner, skip_existing)
|
self._download_channel_banner(channel_banner, skip_existing)
|
||||||
|
self._download_channel_tv(channel_tv, skip_existing)
|
||||||
|
|
||||||
def _download_channel_thumb(self, channel_thumb, skip_existing):
|
def _download_channel_thumb(self, channel_thumb, skip_existing):
|
||||||
"""download channel thumbnail"""
|
"""download channel thumbnail"""
|
||||||
@@ -176,6 +184,16 @@ class ThumbManager(ThumbManagerBase):
|
|||||||
img_raw = self.download_raw(channel_banner)
|
img_raw = self.download_raw(channel_banner)
|
||||||
img_raw.convert("RGB").save(banner_path)
|
img_raw.convert("RGB").save(banner_path)
|
||||||
|
|
||||||
|
def _download_channel_tv(self, channel_tv, skip_existing):
|
||||||
|
"""download channel tv art"""
|
||||||
|
art_path = os.path.join(self.CHANNEL_DIR, self.item_id + "_tvart.jpg")
|
||||||
|
self.item_type = "tvart"
|
||||||
|
if skip_existing and os.path.exists(art_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
img_raw = self.download_raw(channel_tv)
|
||||||
|
img_raw.convert("RGB").save(art_path)
|
||||||
|
|
||||||
def download_playlist_thumb(self, url, skip_existing=False):
|
def download_playlist_thumb(self, url, skip_existing=False):
|
||||||
"""pass thumbnail url"""
|
"""pass thumbnail url"""
|
||||||
thumb_path = os.path.join(self.PLAYLIST_DIR, f"{self.item_id}.jpg")
|
thumb_path = os.path.join(self.PLAYLIST_DIR, f"{self.item_id}.jpg")
|
||||||
@@ -252,6 +270,7 @@ class ValidatorCallback:
|
|||||||
urls = (
|
urls = (
|
||||||
channel["_source"]["channel_thumb_url"],
|
channel["_source"]["channel_thumb_url"],
|
||||||
channel["_source"]["channel_banner_url"],
|
channel["_source"]["channel_banner_url"],
|
||||||
|
channel["_source"]["channel_tvart_url"],
|
||||||
)
|
)
|
||||||
handler = ThumbManager(channel["_source"]["channel_id"])
|
handler = ThumbManager(channel["_source"]["channel_id"])
|
||||||
handler.download_channel_art(urls, skip_existing=True)
|
handler.download_channel_art(urls, skip_existing=True)
|
||||||
@@ -267,102 +286,122 @@ class ValidatorCallback:
|
|||||||
class ThumbValidator:
|
class ThumbValidator:
|
||||||
"""validate thumbnails"""
|
"""validate thumbnails"""
|
||||||
|
|
||||||
def download_missing(self):
|
INDEX = [
|
||||||
"""download all missing artwork"""
|
{
|
||||||
self.download_missing_videos()
|
"data": {
|
||||||
self.download_missing_channels()
|
"query": {"term": {"active": {"value": True}}},
|
||||||
self.download_missing_playlists()
|
"_source": ["vid_thumb_url", "youtube_id"],
|
||||||
|
|
||||||
def download_missing_videos(self):
|
|
||||||
"""get all missing video thumbnails"""
|
|
||||||
data = {
|
|
||||||
"query": {"term": {"active": {"value": True}}},
|
|
||||||
"sort": [{"youtube_id": {"order": "asc"}}],
|
|
||||||
"_source": ["vid_thumb_url", "youtube_id"],
|
|
||||||
}
|
|
||||||
paginate = IndexPaginate(
|
|
||||||
"ta_video", data, size=5000, callback=ValidatorCallback
|
|
||||||
)
|
|
||||||
_ = paginate.get_results()
|
|
||||||
|
|
||||||
def download_missing_channels(self):
|
|
||||||
"""get all missing channel thumbnails"""
|
|
||||||
data = {
|
|
||||||
"query": {"term": {"channel_active": {"value": True}}},
|
|
||||||
"sort": [{"channel_id": {"order": "asc"}}],
|
|
||||||
"_source": {
|
|
||||||
"excludes": ["channel_description", "channel_overwrites"]
|
|
||||||
},
|
},
|
||||||
}
|
"name": "ta_video",
|
||||||
paginate = IndexPaginate(
|
},
|
||||||
"ta_channel", data, callback=ValidatorCallback
|
{
|
||||||
)
|
"data": {
|
||||||
_ = paginate.get_results()
|
"query": {"term": {"channel_active": {"value": True}}},
|
||||||
|
"_source": {
|
||||||
|
"excludes": ["channel_description", "channel_overwrites"]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"name": "ta_channel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"query": {"term": {"playlist_active": {"value": True}}},
|
||||||
|
"_source": ["playlist_id", "playlist_thumbnail"],
|
||||||
|
},
|
||||||
|
"name": "ta_playlist",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def download_missing_playlists(self):
|
def __init__(self, task):
|
||||||
"""get all missing playlist artwork"""
|
self.task = task
|
||||||
data = {
|
|
||||||
"query": {"term": {"playlist_active": {"value": True}}},
|
def validate(self):
|
||||||
"sort": [{"playlist_id": {"order": "asc"}}],
|
"""validate all indexes"""
|
||||||
"_source": ["playlist_id", "playlist_thumbnail"],
|
for index in self.INDEX:
|
||||||
}
|
total = self._get_total(index["name"])
|
||||||
paginate = IndexPaginate(
|
if not total:
|
||||||
"ta_playlist", data, callback=ValidatorCallback
|
continue
|
||||||
)
|
|
||||||
_ = paginate.get_results()
|
paginate = IndexPaginate(
|
||||||
|
index_name=index["name"],
|
||||||
|
data=index["data"],
|
||||||
|
size=1000,
|
||||||
|
callback=ValidatorCallback,
|
||||||
|
task=self.task,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
_ = paginate.get_results()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_total(index_name):
|
||||||
|
"""get total documents in index"""
|
||||||
|
path = f"{index_name}/_count"
|
||||||
|
response, _ = ElasticWrap(path).get()
|
||||||
|
|
||||||
|
return response.get("count")
|
||||||
|
|
||||||
|
|
||||||
class ThumbFilesystem:
|
class ThumbFilesystem:
|
||||||
"""filesystem tasks for thumbnails"""
|
"""sync thumbnail files to media files"""
|
||||||
|
|
||||||
|
INDEX_NAME = "ta_video"
|
||||||
|
|
||||||
|
def __init__(self, task=False):
|
||||||
|
self.task = task
|
||||||
|
|
||||||
|
def embed(self):
|
||||||
|
"""entry point"""
|
||||||
|
data = {
|
||||||
|
"query": {"match_all": {}},
|
||||||
|
"_source": ["media_url", "youtube_id"],
|
||||||
|
}
|
||||||
|
paginate = IndexPaginate(
|
||||||
|
index_name=self.INDEX_NAME,
|
||||||
|
data=data,
|
||||||
|
size=200,
|
||||||
|
callback=EmbedCallback,
|
||||||
|
task=self.task,
|
||||||
|
total=self._get_total(),
|
||||||
|
)
|
||||||
|
_ = paginate.get_results()
|
||||||
|
|
||||||
|
def _get_total(self):
|
||||||
|
"""get total documents in index"""
|
||||||
|
path = f"{self.INDEX_NAME}/_count"
|
||||||
|
response, _ = ElasticWrap(path).get()
|
||||||
|
|
||||||
|
return response.get("count")
|
||||||
|
|
||||||
|
|
||||||
|
class EmbedCallback:
|
||||||
|
"""callback class to embed thumbnails"""
|
||||||
|
|
||||||
CONFIG = AppConfig().config
|
CONFIG = AppConfig().config
|
||||||
CACHE_DIR = CONFIG["application"]["cache_dir"]
|
CACHE_DIR = CONFIG["application"]["cache_dir"]
|
||||||
MEDIA_DIR = CONFIG["application"]["videos"]
|
MEDIA_DIR = CONFIG["application"]["videos"]
|
||||||
VIDEO_DIR = os.path.join(CACHE_DIR, "videos")
|
FORMAT = MP4Cover.FORMAT_JPEG
|
||||||
|
|
||||||
def sync(self):
|
def __init__(self, source, index_name):
|
||||||
"""embed thumbnails to mediafiles"""
|
self.source = source
|
||||||
video_list = self.get_thumb_list()
|
self.index_name = index_name
|
||||||
self._embed_thumbs(video_list)
|
|
||||||
|
|
||||||
def get_thumb_list(self):
|
def run(self):
|
||||||
"""get list of mediafiles and matching thumbnails"""
|
"""run embed"""
|
||||||
pending = queue.PendingList()
|
for video in self.source:
|
||||||
pending.get_download()
|
video_id = video["_source"]["youtube_id"]
|
||||||
pending.get_indexed()
|
media_url = os.path.join(
|
||||||
|
self.MEDIA_DIR, video["_source"]["media_url"]
|
||||||
video_list = []
|
)
|
||||||
for video in pending.all_videos:
|
|
||||||
video_id = video["youtube_id"]
|
|
||||||
media_url = os.path.join(self.MEDIA_DIR, video["media_url"])
|
|
||||||
thumb_path = os.path.join(
|
thumb_path = os.path.join(
|
||||||
self.CACHE_DIR, ThumbManager(video_id).vid_thumb_path()
|
self.CACHE_DIR, ThumbManager(video_id).vid_thumb_path()
|
||||||
)
|
)
|
||||||
video_list.append(
|
if os.path.exists(thumb_path):
|
||||||
{
|
self.embed(media_url, thumb_path)
|
||||||
"media_url": media_url,
|
|
||||||
"thumb_path": thumb_path,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return video_list
|
def embed(self, media_url, thumb_path):
|
||||||
|
"""embed thumb in single media file"""
|
||||||
|
video = MP4(media_url)
|
||||||
|
with open(thumb_path, "rb") as f:
|
||||||
|
video["covr"] = [MP4Cover(f.read(), imageformat=self.FORMAT)]
|
||||||
|
|
||||||
@staticmethod
|
video.save()
|
||||||
def _embed_thumbs(video_list):
|
|
||||||
"""rewrite the thumbnail into media file"""
|
|
||||||
|
|
||||||
counter = 1
|
|
||||||
for video in video_list:
|
|
||||||
# loop through all videos
|
|
||||||
media_url = video["media_url"]
|
|
||||||
thumb_path = video["thumb_path"]
|
|
||||||
|
|
||||||
mutagen_vid = MP4(media_url)
|
|
||||||
with open(thumb_path, "rb") as f:
|
|
||||||
mutagen_vid["covr"] = [
|
|
||||||
MP4Cover(f.read(), imageformat=MP4Cover.FORMAT_JPEG)
|
|
||||||
]
|
|
||||||
mutagen_vid.save()
|
|
||||||
if counter % 50 == 0:
|
|
||||||
print(f"thumbnail write progress {counter}/{len(video_list)}")
|
|
||||||
counter = counter + 1
|
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ class YtWrap:
|
|||||||
"default_search": "ytsearch",
|
"default_search": "ytsearch",
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"check_formats": "selected",
|
"check_formats": "selected",
|
||||||
"socket_timeout": 3,
|
"socket_timeout": 10,
|
||||||
"extractor_retries": 3,
|
"extractor_retries": 3,
|
||||||
|
"retries": 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, obs_request, config=False):
|
def __init__(self, obs_request, config=False):
|
||||||
@@ -151,7 +152,7 @@ class CookieHandler:
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
message = {
|
message = {
|
||||||
"status": response,
|
"status": response,
|
||||||
"validated": int(now.strftime("%s")),
|
"validated": int(now.timestamp()),
|
||||||
"validated_str": now.strftime("%Y-%m-%d %H:%M"),
|
"validated_str": now.strftime("%Y-%m-%d %H:%M"),
|
||||||
}
|
}
|
||||||
RedisArchivist().set_message("cookie:valid", message)
|
RedisArchivist().set_message("cookie:valid", message)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ functionality:
|
|||||||
- move to archive
|
- move to archive
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -15,12 +16,13 @@ from home.src.download.subscriptions import PlaylistSubscription
|
|||||||
from home.src.download.yt_dlp_base import CookieHandler, YtWrap
|
from home.src.download.yt_dlp_base import CookieHandler, YtWrap
|
||||||
from home.src.es.connect import ElasticWrap, IndexPaginate
|
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.index.channel import YoutubeChannel
|
from home.src.index.channel import YoutubeChannel
|
||||||
from home.src.index.comments import Comments
|
from home.src.index.comments import CommentList
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from home.src.index.playlist import YoutubePlaylist
|
||||||
from home.src.index.video import YoutubeVideo, index_new_video
|
from home.src.index.video import YoutubeVideo, index_new_video
|
||||||
|
from home.src.index.video_constants import VideoTypeEnum
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
from home.src.ta.helper import clean_string, ignore_filelist
|
from home.src.ta.helper import clean_string, ignore_filelist
|
||||||
from home.src.ta.ta_redis import RedisArchivist, RedisQueue
|
from home.src.ta.ta_redis import RedisQueue
|
||||||
|
|
||||||
|
|
||||||
class DownloadPostProcess:
|
class DownloadPostProcess:
|
||||||
@@ -28,7 +30,7 @@ class DownloadPostProcess:
|
|||||||
|
|
||||||
def __init__(self, download):
|
def __init__(self, download):
|
||||||
self.download = download
|
self.download = download
|
||||||
self.now = int(datetime.now().strftime("%s"))
|
self.now = int(datetime.now().timestamp())
|
||||||
self.pending = False
|
self.pending = False
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
@@ -94,7 +96,7 @@ class DownloadPostProcess:
|
|||||||
def validate_playlists(self):
|
def validate_playlists(self):
|
||||||
"""look for playlist needing to update"""
|
"""look for playlist needing to update"""
|
||||||
for id_c, channel_id in enumerate(self.download.channels):
|
for id_c, channel_id in enumerate(self.download.channels):
|
||||||
channel = YoutubeChannel(channel_id)
|
channel = YoutubeChannel(channel_id, task=self.download.task)
|
||||||
overwrites = self.pending.channel_overwrites.get(channel_id, False)
|
overwrites = self.pending.channel_overwrites.get(channel_id, False)
|
||||||
if overwrites and overwrites.get("index_playlists"):
|
if overwrites and overwrites.get("index_playlists"):
|
||||||
# validate from remote
|
# validate from remote
|
||||||
@@ -102,7 +104,7 @@ class DownloadPostProcess:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# validate from local
|
# validate from local
|
||||||
playlists = channel.get_indexed_playlists()
|
playlists = channel.get_indexed_playlists(active_only=True)
|
||||||
all_channel_playlist = [i["playlist_id"] for i in playlists]
|
all_channel_playlist = [i["playlist_id"] for i in playlists]
|
||||||
self._validate_channel_playlist(all_channel_playlist, id_c)
|
self._validate_channel_playlist(all_channel_playlist, id_c)
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ class DownloadPostProcess:
|
|||||||
playlist.build_json(scrape=True)
|
playlist.build_json(scrape=True)
|
||||||
if not playlist.json_data:
|
if not playlist.json_data:
|
||||||
playlist.deactivate()
|
playlist.deactivate()
|
||||||
|
continue
|
||||||
|
|
||||||
playlist.add_vids_to_playlist()
|
playlist.add_vids_to_playlist()
|
||||||
playlist.upload_to_es()
|
playlist.upload_to_es()
|
||||||
@@ -122,45 +125,22 @@ class DownloadPostProcess:
|
|||||||
|
|
||||||
def _notify_playlist_progress(self, all_channel_playlist, id_c, id_p):
|
def _notify_playlist_progress(self, all_channel_playlist, id_c, id_p):
|
||||||
"""notify to UI"""
|
"""notify to UI"""
|
||||||
title = (
|
if not self.download.task:
|
||||||
"Processing playlists for channels: "
|
return
|
||||||
+ f"{id_c + 1}/{len(self.download.channels)}"
|
|
||||||
)
|
|
||||||
message = f"Progress: {id_p + 1}/{len(all_channel_playlist)}"
|
|
||||||
key = "message:download"
|
|
||||||
mess_dict = {
|
|
||||||
"status": key,
|
|
||||||
"level": "info",
|
|
||||||
"title": title,
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
if id_p + 1 == len(all_channel_playlist):
|
|
||||||
expire = 4
|
|
||||||
else:
|
|
||||||
expire = True
|
|
||||||
|
|
||||||
RedisArchivist().set_message(key, mess_dict, expire=expire)
|
total_channel = len(self.download.channels)
|
||||||
|
total_playlist = len(all_channel_playlist)
|
||||||
|
|
||||||
|
message = [
|
||||||
|
f"Post Processing Channels: {id_c}/{total_channel}",
|
||||||
|
f"Validate Playlists {id_p + 1}/{total_playlist}",
|
||||||
|
]
|
||||||
|
progress = (id_c + 1) / total_channel
|
||||||
|
self.download.task.send_progress(message, progress=progress)
|
||||||
|
|
||||||
def get_comments(self):
|
def get_comments(self):
|
||||||
"""get comments from youtube"""
|
"""get comments from youtube"""
|
||||||
if not self.download.config["downloads"]["comment_max"]:
|
CommentList(self.download.videos, task=self.download.task).index()
|
||||||
return
|
|
||||||
|
|
||||||
total_videos = len(self.download.videos)
|
|
||||||
for idx, video_id in enumerate(self.download.videos):
|
|
||||||
comment = Comments(video_id, config=self.download.config)
|
|
||||||
comment.build_json(notify=(idx, total_videos))
|
|
||||||
comment.upload_comments()
|
|
||||||
|
|
||||||
key = "message:download"
|
|
||||||
message = {
|
|
||||||
"status": key,
|
|
||||||
"level": "info",
|
|
||||||
"title": "Download and index comments finished",
|
|
||||||
"message": f"added comments for {total_videos} videos",
|
|
||||||
}
|
|
||||||
|
|
||||||
RedisArchivist().set_message(key, message, expire=4)
|
|
||||||
|
|
||||||
|
|
||||||
class VideoDownloader:
|
class VideoDownloader:
|
||||||
@@ -169,12 +149,11 @@ class VideoDownloader:
|
|||||||
if not initiated with list, take from queue
|
if not initiated with list, take from queue
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MSG = "message:download"
|
def __init__(self, youtube_id_list=False, task=False):
|
||||||
|
|
||||||
def __init__(self, youtube_id_list=False):
|
|
||||||
self.obs = False
|
self.obs = False
|
||||||
self.video_overwrites = False
|
self.video_overwrites = False
|
||||||
self.youtube_id_list = youtube_id_list
|
self.youtube_id_list = youtube_id_list
|
||||||
|
self.task = task
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
self._build_obs()
|
self._build_obs()
|
||||||
self.channels = set()
|
self.channels = set()
|
||||||
@@ -183,56 +162,65 @@ class VideoDownloader:
|
|||||||
def run_queue(self):
|
def run_queue(self):
|
||||||
"""setup download queue in redis loop until no more items"""
|
"""setup download queue in redis loop until no more items"""
|
||||||
self._setup_queue()
|
self._setup_queue()
|
||||||
|
queue = RedisQueue(queue_name="dl_queue")
|
||||||
queue = RedisQueue()
|
|
||||||
|
|
||||||
limit_queue = self.config["downloads"]["limit_count"]
|
limit_queue = self.config["downloads"]["limit_count"]
|
||||||
if limit_queue:
|
if limit_queue:
|
||||||
queue.trim(limit_queue - 1)
|
queue.trim(limit_queue - 1)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
youtube_id = queue.get_next()
|
youtube_data = queue.get_next()
|
||||||
if not youtube_id:
|
if self.task.is_stopped() or not youtube_data:
|
||||||
|
queue.clear()
|
||||||
break
|
break
|
||||||
|
|
||||||
|
youtube_data = json.loads(youtube_data)
|
||||||
|
youtube_id = youtube_data.get("youtube_id")
|
||||||
|
|
||||||
|
tmp_vid_type = youtube_data.get(
|
||||||
|
"vid_type", VideoTypeEnum.VIDEOS.value
|
||||||
|
)
|
||||||
|
video_type = VideoTypeEnum(tmp_vid_type)
|
||||||
|
print(f"{youtube_id}: Downloading type: {video_type}")
|
||||||
|
|
||||||
success = self._dl_single_vid(youtube_id)
|
success = self._dl_single_vid(youtube_id)
|
||||||
if not success:
|
if not success:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
mess_dict = {
|
if self.task:
|
||||||
"status": self.MSG,
|
self.task.send_progress(
|
||||||
"level": "info",
|
[
|
||||||
"title": "Indexing....",
|
f"Processing video {youtube_id}",
|
||||||
"message": "Add video metadata to index.",
|
"Add video metadata to index.",
|
||||||
}
|
]
|
||||||
RedisArchivist().set_message(self.MSG, mess_dict, expire=60)
|
)
|
||||||
|
|
||||||
vid_dict = index_new_video(
|
vid_dict = index_new_video(
|
||||||
youtube_id, video_overwrites=self.video_overwrites
|
youtube_id,
|
||||||
|
video_overwrites=self.video_overwrites,
|
||||||
|
video_type=video_type,
|
||||||
)
|
)
|
||||||
self.channels.add(vid_dict["channel"]["channel_id"])
|
self.channels.add(vid_dict["channel"]["channel_id"])
|
||||||
self.videos.add(vid_dict["youtube_id"])
|
self.videos.add(vid_dict["youtube_id"])
|
||||||
mess_dict = {
|
|
||||||
"status": self.MSG,
|
if self.task:
|
||||||
"level": "info",
|
self.task.send_progress(
|
||||||
"title": "Moving....",
|
[
|
||||||
"message": "Moving downloaded file to storage folder",
|
f"Processing video {youtube_id}",
|
||||||
}
|
"Move downloaded file to archive.",
|
||||||
RedisArchivist().set_message(self.MSG, mess_dict)
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.move_to_archive(vid_dict)
|
||||||
|
|
||||||
if queue.has_item():
|
if queue.has_item():
|
||||||
message = "Continue with next video."
|
message = "Continue with next video."
|
||||||
else:
|
else:
|
||||||
message = "Download queue is finished."
|
message = "Download queue is finished."
|
||||||
|
|
||||||
self.move_to_archive(vid_dict)
|
if self.task:
|
||||||
mess_dict = {
|
self.task.send_progress([message])
|
||||||
"status": self.MSG,
|
|
||||||
"level": "info",
|
|
||||||
"title": "Completed",
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message(self.MSG, mess_dict, expire=10)
|
|
||||||
self._delete_from_pending(youtube_id)
|
self._delete_from_pending(youtube_id)
|
||||||
|
|
||||||
# post processing
|
# post processing
|
||||||
@@ -253,50 +241,51 @@ class VideoDownloader:
|
|||||||
|
|
||||||
def add_pending(self):
|
def add_pending(self):
|
||||||
"""add pending videos to download queue"""
|
"""add pending videos to download queue"""
|
||||||
mess_dict = {
|
if self.task:
|
||||||
"status": self.MSG,
|
self.task.send_progress(["Scanning your download queue."])
|
||||||
"level": "info",
|
|
||||||
"title": "Looking for videos to download",
|
|
||||||
"message": "Scanning your download queue.",
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message(self.MSG, mess_dict, expire=True)
|
|
||||||
pending = PendingList()
|
pending = PendingList()
|
||||||
pending.get_download()
|
pending.get_download()
|
||||||
to_add = [i["youtube_id"] for i in pending.all_pending]
|
to_add = [
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"youtube_id": i["youtube_id"],
|
||||||
|
# Using .value in default val to match what would be
|
||||||
|
# decoded when parsing json if not set
|
||||||
|
"vid_type": i.get("vid_type", VideoTypeEnum.VIDEOS.value),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for i in pending.all_pending
|
||||||
|
]
|
||||||
if not to_add:
|
if not to_add:
|
||||||
# there is nothing pending
|
# there is nothing pending
|
||||||
print("download queue is empty")
|
print("download queue is empty")
|
||||||
mess_dict = {
|
if self.task:
|
||||||
"status": self.MSG,
|
self.task.send_progress(["Download queue is empty."])
|
||||||
"level": "error",
|
|
||||||
"title": "Download queue is empty",
|
|
||||||
"message": "Add some videos to the queue first.",
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message(self.MSG, mess_dict, expire=True)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
RedisQueue().add_list(to_add)
|
RedisQueue(queue_name="dl_queue").add_list(to_add)
|
||||||
|
|
||||||
def _progress_hook(self, response):
|
def _progress_hook(self, response):
|
||||||
"""process the progress_hooks from yt_dlp"""
|
"""process the progress_hooks from yt_dlp"""
|
||||||
title = "Downloading: " + response["info_dict"]["title"]
|
progress = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
size = response.get("_total_bytes_str")
|
||||||
|
if size.strip() == "N/A":
|
||||||
|
size = response.get("_total_bytes_estimate_str", "N/A")
|
||||||
|
|
||||||
percent = response["_percent_str"]
|
percent = response["_percent_str"]
|
||||||
size = response["_total_bytes_str"]
|
progress = float(percent.strip("%")) / 100
|
||||||
speed = response["_speed_str"]
|
speed = response["_speed_str"]
|
||||||
eta = response["_eta_str"]
|
eta = response["_eta_str"]
|
||||||
message = f"{percent} of {size} at {speed} - time left: {eta}"
|
message = f"{percent} of {size} at {speed} - time left: {eta}"
|
||||||
except KeyError:
|
except KeyError:
|
||||||
message = "processing"
|
message = "processing"
|
||||||
|
|
||||||
mess_dict = {
|
if self.task:
|
||||||
"status": self.MSG,
|
title = response["info_dict"]["title"]
|
||||||
"level": "info",
|
self.task.send_progress([title, message], progress=progress)
|
||||||
"title": title,
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message(self.MSG, mess_dict, expire=True)
|
|
||||||
|
|
||||||
def _build_obs(self):
|
def _build_obs(self):
|
||||||
"""collection to build all obs passed to yt-dlp"""
|
"""collection to build all obs passed to yt-dlp"""
|
||||||
@@ -307,7 +296,6 @@ class VideoDownloader:
|
|||||||
def _build_obs_basic(self):
|
def _build_obs_basic(self):
|
||||||
"""initial obs"""
|
"""initial obs"""
|
||||||
self.obs = {
|
self.obs = {
|
||||||
"default_search": "ytsearch",
|
|
||||||
"merge_output_format": "mp4",
|
"merge_output_format": "mp4",
|
||||||
"outtmpl": (
|
"outtmpl": (
|
||||||
self.config["application"]["cache_dir"]
|
self.config["application"]["cache_dir"]
|
||||||
@@ -315,13 +303,9 @@ class VideoDownloader:
|
|||||||
),
|
),
|
||||||
"progress_hooks": [self._progress_hook],
|
"progress_hooks": [self._progress_hook],
|
||||||
"noprogress": True,
|
"noprogress": True,
|
||||||
"quiet": True,
|
|
||||||
"continuedl": True,
|
"continuedl": True,
|
||||||
"retries": 3,
|
|
||||||
"writethumbnail": False,
|
"writethumbnail": False,
|
||||||
"noplaylist": True,
|
"noplaylist": True,
|
||||||
"check_formats": "selected",
|
|
||||||
"socket_timeout": 3,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_obs_user(self):
|
def _build_obs_user(self):
|
||||||
@@ -420,8 +404,8 @@ class VideoDownloader:
|
|||||||
new_folder = os.path.join(videos, channel_name)
|
new_folder = os.path.join(videos, channel_name)
|
||||||
if not os.path.exists(new_folder):
|
if not os.path.exists(new_folder):
|
||||||
os.makedirs(new_folder)
|
os.makedirs(new_folder)
|
||||||
if host_uid and host_gid:
|
if host_uid and host_gid:
|
||||||
os.chown(new_folder, host_uid, host_gid)
|
os.chown(new_folder, host_uid, host_gid)
|
||||||
# find real filename
|
# find real filename
|
||||||
cache_dir = self.config["application"]["cache_dir"]
|
cache_dir = self.config["application"]["cache_dir"]
|
||||||
all_cached = ignore_filelist(os.listdir(cache_dir + "/download/"))
|
all_cached = ignore_filelist(os.listdir(cache_dir + "/download/"))
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ from home.src.ta.helper import get_mapping, ignore_filelist
|
|||||||
class ElasticBackup:
|
class ElasticBackup:
|
||||||
"""dump index to nd-json files for later bulk import"""
|
"""dump index to nd-json files for later bulk import"""
|
||||||
|
|
||||||
def __init__(self, reason=False):
|
def __init__(self, reason=False, task=False):
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
self.cache_dir = self.config["application"]["cache_dir"]
|
self.cache_dir = self.config["application"]["cache_dir"]
|
||||||
self.timestamp = datetime.now().strftime("%Y%m%d")
|
self.timestamp = datetime.now().strftime("%Y%m%d")
|
||||||
self.index_config = get_mapping()
|
self.index_config = get_mapping()
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
|
self.task = task
|
||||||
|
|
||||||
def backup_all_indexes(self):
|
def backup_all_indexes(self):
|
||||||
"""backup all indexes, add reason to init"""
|
"""backup all indexes, add reason to init"""
|
||||||
@@ -31,6 +32,7 @@ class ElasticBackup:
|
|||||||
if not self.reason:
|
if not self.reason:
|
||||||
raise ValueError("missing backup reason in ElasticBackup")
|
raise ValueError("missing backup reason in ElasticBackup")
|
||||||
|
|
||||||
|
self.task.send_progress(["Scanning your index."])
|
||||||
for index in self.index_config:
|
for index in self.index_config:
|
||||||
index_name = index["index_name"]
|
index_name = index["index_name"]
|
||||||
print(f"backup: export in progress for {index_name}")
|
print(f"backup: export in progress for {index_name}")
|
||||||
@@ -40,22 +42,31 @@ class ElasticBackup:
|
|||||||
|
|
||||||
self.backup_index(index_name)
|
self.backup_index(index_name)
|
||||||
|
|
||||||
|
self.task.send_progress(["Compress files to zip archive."])
|
||||||
self.zip_it()
|
self.zip_it()
|
||||||
if self.reason == "auto":
|
if self.reason == "auto":
|
||||||
self.rotate_backup()
|
self.rotate_backup()
|
||||||
|
|
||||||
@staticmethod
|
def backup_index(self, index_name):
|
||||||
def backup_index(index_name):
|
|
||||||
"""export all documents of a single index"""
|
"""export all documents of a single index"""
|
||||||
data = {
|
|
||||||
"query": {"match_all": {}},
|
|
||||||
"sort": [{"_doc": {"order": "desc"}}],
|
|
||||||
}
|
|
||||||
paginate = IndexPaginate(
|
paginate = IndexPaginate(
|
||||||
f"ta_{index_name}", data, keep_source=True, callback=BackupCallback
|
f"ta_{index_name}",
|
||||||
|
data={"query": {"match_all": {}}},
|
||||||
|
keep_source=True,
|
||||||
|
callback=BackupCallback,
|
||||||
|
task=self.task,
|
||||||
|
total=self._get_total(index_name),
|
||||||
)
|
)
|
||||||
_ = paginate.get_results()
|
_ = paginate.get_results()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_total(index_name):
|
||||||
|
"""get total documents in index"""
|
||||||
|
path = f"ta_{index_name}/_count"
|
||||||
|
response, _ = ElasticWrap(path).get()
|
||||||
|
|
||||||
|
return response.get("count")
|
||||||
|
|
||||||
def zip_it(self):
|
def zip_it(self):
|
||||||
"""pack it up into single zip file"""
|
"""pack it up into single zip file"""
|
||||||
file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip"
|
file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip"
|
||||||
@@ -142,8 +153,8 @@ class ElasticBackup:
|
|||||||
"""go through the unpacked files and restore"""
|
"""go through the unpacked files and restore"""
|
||||||
backup_dir = os.path.join(self.cache_dir, "backup")
|
backup_dir = os.path.join(self.cache_dir, "backup")
|
||||||
|
|
||||||
for json_f in zip_content:
|
for idx, json_f in enumerate(zip_content):
|
||||||
|
self._notify_restore(idx, json_f, len(zip_content))
|
||||||
file_name = os.path.join(backup_dir, json_f)
|
file_name = os.path.join(backup_dir, json_f)
|
||||||
|
|
||||||
if not json_f.startswith("es_") or not json_f.endswith(".json"):
|
if not json_f.startswith("es_") or not json_f.endswith(".json"):
|
||||||
@@ -154,6 +165,12 @@ class ElasticBackup:
|
|||||||
self.post_bulk_restore(file_name)
|
self.post_bulk_restore(file_name)
|
||||||
os.remove(file_name)
|
os.remove(file_name)
|
||||||
|
|
||||||
|
def _notify_restore(self, idx, json_f, total_files):
|
||||||
|
"""notify restore progress"""
|
||||||
|
message = [f"Restore index from json backup file {json_f}."]
|
||||||
|
progress = (idx + 1) / total_files
|
||||||
|
self.task.send_progress(message_lines=message, progress=progress)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def index_exists(index_name):
|
def index_exists(index_name):
|
||||||
"""check if index already exists to skip"""
|
"""check if index already exists to skip"""
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ functionality:
|
|||||||
- wrapper around requests to call elastic search
|
- wrapper around requests to call elastic search
|
||||||
- reusable search_after to extract total index
|
- reusable search_after to extract total index
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=missing-timeout
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -31,13 +32,15 @@ class ElasticWrap:
|
|||||||
self.auth = self.config["application"]["es_auth"]
|
self.auth = self.config["application"]["es_auth"]
|
||||||
self.url = f"{es_url}/{self.path}"
|
self.url = f"{es_url}/{self.path}"
|
||||||
|
|
||||||
def get(self, data=False):
|
def get(self, data=False, timeout=10, print_error=True):
|
||||||
"""get data from es"""
|
"""get data from es"""
|
||||||
if data:
|
if data:
|
||||||
response = requests.get(self.url, json=data, auth=self.auth)
|
response = requests.get(
|
||||||
|
self.url, json=data, auth=self.auth, timeout=timeout
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
response = requests.get(self.url, auth=self.auth)
|
response = requests.get(self.url, auth=self.auth, timeout=timeout)
|
||||||
if not response.ok:
|
if print_error and not response.ok:
|
||||||
print(response.text)
|
print(response.text)
|
||||||
|
|
||||||
return response.json(), response.status_code
|
return response.json(), response.status_code
|
||||||
@@ -94,8 +97,10 @@ class IndexPaginate:
|
|||||||
"""use search_after to go through whole index
|
"""use search_after to go through whole index
|
||||||
kwargs:
|
kwargs:
|
||||||
- size: int, overwrite DEFAULT_SIZE
|
- size: int, overwrite DEFAULT_SIZE
|
||||||
- keep_source: bool, keep _source key from es resutls
|
- keep_source: bool, keep _source key from es results
|
||||||
- callback: obj, Class with run method collback for every loop
|
- callback: obj, Class implementing run method callback for every loop
|
||||||
|
- task: task object to send notification
|
||||||
|
- total: int, total items in index for progress message
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEFAULT_SIZE = 500
|
DEFAULT_SIZE = 500
|
||||||
@@ -104,12 +109,10 @@ class IndexPaginate:
|
|||||||
self.index_name = index_name
|
self.index_name = index_name
|
||||||
self.data = data
|
self.data = data
|
||||||
self.pit_id = False
|
self.pit_id = False
|
||||||
self.size = kwargs.get("size")
|
self.kwargs = kwargs
|
||||||
self.keep_source = kwargs.get("keep_source")
|
|
||||||
self.callback = kwargs.get("callback")
|
|
||||||
|
|
||||||
def get_results(self):
|
def get_results(self):
|
||||||
"""get all results"""
|
"""get all results, add task and total for notifications"""
|
||||||
self.get_pit()
|
self.get_pit()
|
||||||
self.validate_data()
|
self.validate_data()
|
||||||
all_results = self.run_loop()
|
all_results = self.run_loop()
|
||||||
@@ -125,10 +128,9 @@ class IndexPaginate:
|
|||||||
def validate_data(self):
|
def validate_data(self):
|
||||||
"""add pit and size to data"""
|
"""add pit and size to data"""
|
||||||
if "sort" not in self.data.keys():
|
if "sort" not in self.data.keys():
|
||||||
print(self.data)
|
self.data.update({"sort": [{"_doc": {"order": "desc"}}]})
|
||||||
raise ValueError("missing sort key in data")
|
|
||||||
|
|
||||||
self.data["size"] = self.size or self.DEFAULT_SIZE
|
self.data["size"] = self.kwargs.get("size") or self.DEFAULT_SIZE
|
||||||
self.data["pit"] = {"id": self.pit_id, "keep_alive": "10m"}
|
self.data["pit"] = {"id": self.pit_id, "keep_alive": "10m"}
|
||||||
|
|
||||||
def run_loop(self):
|
def run_loop(self):
|
||||||
@@ -138,30 +140,37 @@ class IndexPaginate:
|
|||||||
while True:
|
while True:
|
||||||
response, _ = ElasticWrap("_search").get(data=self.data)
|
response, _ = ElasticWrap("_search").get(data=self.data)
|
||||||
all_hits = response["hits"]["hits"]
|
all_hits = response["hits"]["hits"]
|
||||||
if all_hits:
|
if not all_hits:
|
||||||
for hit in all_hits:
|
|
||||||
if self.keep_source:
|
|
||||||
source = hit
|
|
||||||
else:
|
|
||||||
source = hit["_source"]
|
|
||||||
|
|
||||||
if not self.callback:
|
|
||||||
all_results.append(source)
|
|
||||||
|
|
||||||
if self.callback:
|
|
||||||
self.callback(all_hits, self.index_name).run()
|
|
||||||
if counter % 10 == 0:
|
|
||||||
print(f"{self.index_name}: processing page {counter}")
|
|
||||||
counter = counter + 1
|
|
||||||
|
|
||||||
# update search_after with last hit data
|
|
||||||
self.data["search_after"] = all_hits[-1]["sort"]
|
|
||||||
else:
|
|
||||||
break
|
break
|
||||||
|
|
||||||
|
for hit in all_hits:
|
||||||
|
if self.kwargs.get("keep_source"):
|
||||||
|
all_results.append(hit)
|
||||||
|
else:
|
||||||
|
all_results.append(hit["_source"])
|
||||||
|
|
||||||
|
if self.kwargs.get("callback"):
|
||||||
|
self.kwargs.get("callback")(all_hits, self.index_name).run()
|
||||||
|
|
||||||
|
if self.kwargs.get("task"):
|
||||||
|
print(f"{self.index_name}: processing page {counter}")
|
||||||
|
self._notify(len(all_results))
|
||||||
|
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# update search_after with last hit data
|
||||||
|
self.data["search_after"] = all_hits[-1]["sort"]
|
||||||
|
|
||||||
return all_results
|
return all_results
|
||||||
|
|
||||||
|
def _notify(self, processed):
|
||||||
|
"""send notification on task"""
|
||||||
|
total = self.kwargs.get("total")
|
||||||
|
progress = processed / total
|
||||||
|
index_clean = self.index_name.lstrip("ta_").title()
|
||||||
|
message = [f"Processing {index_clean}s {processed}/{total}"]
|
||||||
|
self.kwargs.get("task").send_progress(message, progress=progress)
|
||||||
|
|
||||||
def clean_pit(self):
|
def clean_pit(self):
|
||||||
"""delete pit from elastic search"""
|
"""delete pit from elastic search"""
|
||||||
data = {"id": self.pit_id}
|
ElasticWrap("_pit").delete(data={"id": self.pit_id})
|
||||||
ElasticWrap("_pit").delete(data=data)
|
|
||||||
|
|||||||
@@ -178,6 +178,9 @@
|
|||||||
"youtube_id": {
|
"youtube_id": {
|
||||||
"type": "keyword"
|
"type": "keyword"
|
||||||
},
|
},
|
||||||
|
"vid_type": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
"published": {
|
"published": {
|
||||||
"type": "date"
|
"type": "date"
|
||||||
},
|
},
|
||||||
@@ -324,6 +327,9 @@
|
|||||||
},
|
},
|
||||||
"youtube_id": {
|
"youtube_id": {
|
||||||
"type": "keyword"
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"vid_type": {
|
||||||
|
"type": "keyword"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"expected_set": {
|
"expected_set": {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class ElasticSnapshot:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
last_stamp = snap_dicts[0]["end_stamp"]
|
last_stamp = snap_dicts[0]["end_stamp"]
|
||||||
now = int(datetime.now().strftime("%s"))
|
now = int(datetime.now().timestamp())
|
||||||
outdated = (now - last_stamp) / 60 / 60 > 24
|
outdated = (now - last_stamp) / 60 / 60 > 24
|
||||||
if outdated:
|
if outdated:
|
||||||
print("snapshot: is outdated, create new now")
|
print("snapshot: is outdated, create new now")
|
||||||
|
|||||||
@@ -4,30 +4,14 @@ Functionality:
|
|||||||
- called via user input
|
- called via user input
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from home.src.download.queue import PendingInteract
|
|
||||||
from home.src.download.subscriptions import (
|
from home.src.download.subscriptions import (
|
||||||
ChannelSubscription,
|
ChannelSubscription,
|
||||||
PlaylistSubscription,
|
PlaylistSubscription,
|
||||||
)
|
)
|
||||||
from home.src.frontend.watched import WatchState
|
|
||||||
from home.src.index.channel import YoutubeChannel
|
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from home.src.index.playlist import YoutubePlaylist
|
||||||
from home.src.index.video import YoutubeVideo
|
from home.src.ta.ta_redis import RedisArchivist
|
||||||
from home.src.ta.helper import UrlListParser
|
from home.src.ta.urlparser import Parser
|
||||||
from home.src.ta.ta_redis import RedisArchivist, RedisQueue
|
from home.tasks import run_restore_backup, subscribe_to
|
||||||
from home.tasks import (
|
|
||||||
download_pending,
|
|
||||||
download_single,
|
|
||||||
index_channel_playlists,
|
|
||||||
kill_dl,
|
|
||||||
re_sync_thumbs,
|
|
||||||
rescan_filesystem,
|
|
||||||
run_backup,
|
|
||||||
run_manual_import,
|
|
||||||
run_restore_backup,
|
|
||||||
subscribe_to,
|
|
||||||
update_subscribed,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PostData:
|
class PostData:
|
||||||
@@ -50,47 +34,20 @@ class PostData:
|
|||||||
def exec_map(self):
|
def exec_map(self):
|
||||||
"""map dict key and return function to execute"""
|
"""map dict key and return function to execute"""
|
||||||
exec_map = {
|
exec_map = {
|
||||||
"watched": self._watched,
|
|
||||||
"un_watched": self._un_watched,
|
|
||||||
"change_view": self._change_view,
|
"change_view": self._change_view,
|
||||||
"change_grid": self._change_grid,
|
"change_grid": self._change_grid,
|
||||||
"rescan_pending": self._rescan_pending,
|
|
||||||
"ignore": self._ignore,
|
|
||||||
"dl_pending": self._dl_pending,
|
|
||||||
"queue": self._queue_handler,
|
|
||||||
"unsubscribe": self._unsubscribe,
|
"unsubscribe": self._unsubscribe,
|
||||||
"subscribe": self._subscribe,
|
"subscribe": self._subscribe,
|
||||||
"sort_order": self._sort_order,
|
"sort_order": self._sort_order,
|
||||||
"hide_watched": self._hide_watched,
|
"hide_watched": self._hide_watched,
|
||||||
"show_subed_only": self._show_subed_only,
|
"show_subed_only": self._show_subed_only,
|
||||||
"dlnow": self._dlnow,
|
|
||||||
"show_ignored_only": self._show_ignored_only,
|
"show_ignored_only": self._show_ignored_only,
|
||||||
"forgetIgnore": self._forget_ignore,
|
|
||||||
"addSingle": self._add_single,
|
|
||||||
"deleteQueue": self._delete_queue,
|
|
||||||
"manual-import": self._manual_import,
|
|
||||||
"re-embed": self._re_embed,
|
|
||||||
"db-backup": self._db_backup,
|
|
||||||
"db-restore": self._db_restore,
|
"db-restore": self._db_restore,
|
||||||
"fs-rescan": self._fs_rescan,
|
|
||||||
"delete-video": self._delete_video,
|
|
||||||
"delete-channel": self._delete_channel,
|
|
||||||
"delete-playlist": self._delete_playlist,
|
"delete-playlist": self._delete_playlist,
|
||||||
"find-playlists": self._find_playlists,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return exec_map[self.to_exec]
|
return exec_map[self.to_exec]
|
||||||
|
|
||||||
def _watched(self):
|
|
||||||
"""mark as watched"""
|
|
||||||
WatchState(self.exec_val).mark_as_watched()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _un_watched(self):
|
|
||||||
"""mark as unwatched"""
|
|
||||||
WatchState(self.exec_val).mark_as_unwatched()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _change_view(self):
|
def _change_view(self):
|
||||||
"""process view changes in home, channel, and downloads"""
|
"""process view changes in home, channel, and downloads"""
|
||||||
origin, new_view = self.exec_val.split(":")
|
origin, new_view = self.exec_val.split(":")
|
||||||
@@ -110,53 +67,11 @@ class PostData:
|
|||||||
RedisArchivist().set_message(key, {"status": grid_items})
|
RedisArchivist().set_message(key, {"status": grid_items})
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _rescan_pending():
|
|
||||||
"""look for new items in subscribed channels"""
|
|
||||||
print("rescan subscribed channels")
|
|
||||||
update_subscribed.delay()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _ignore(self):
|
|
||||||
"""ignore from download queue"""
|
|
||||||
video_id = self.exec_val
|
|
||||||
print(f"{video_id}: ignore video from download queue")
|
|
||||||
PendingInteract(video_id=video_id, status="ignore").update_status()
|
|
||||||
# also clear from redis queue
|
|
||||||
RedisQueue().clear_item(video_id)
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dl_pending():
|
|
||||||
"""start the download queue"""
|
|
||||||
print("download pending")
|
|
||||||
running = download_pending.delay()
|
|
||||||
task_id = running.id
|
|
||||||
print(f"{task_id}: set task id")
|
|
||||||
RedisArchivist().set_message("dl_queue_id", task_id)
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _queue_handler(self):
|
|
||||||
"""queue controls from frontend"""
|
|
||||||
to_execute = self.exec_val
|
|
||||||
if to_execute == "stop":
|
|
||||||
print("stopping download queue")
|
|
||||||
RedisQueue().clear()
|
|
||||||
elif to_execute == "kill":
|
|
||||||
task_id = RedisArchivist().get_message("dl_queue_id")
|
|
||||||
if not isinstance(task_id, str):
|
|
||||||
task_id = False
|
|
||||||
else:
|
|
||||||
print("brutally killing " + task_id)
|
|
||||||
kill_dl(task_id)
|
|
||||||
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _unsubscribe(self):
|
def _unsubscribe(self):
|
||||||
"""unsubscribe from channels or playlists"""
|
"""unsubscribe from channels or playlists"""
|
||||||
id_unsub = self.exec_val
|
id_unsub = self.exec_val
|
||||||
print(f"{id_unsub}: unsubscribe")
|
print(f"{id_unsub}: unsubscribe")
|
||||||
to_unsub_list = UrlListParser(id_unsub).process_list()
|
to_unsub_list = Parser(id_unsub).parse()
|
||||||
for to_unsub in to_unsub_list:
|
for to_unsub in to_unsub_list:
|
||||||
unsub_type = to_unsub["type"]
|
unsub_type = to_unsub["type"]
|
||||||
unsub_id = to_unsub["url"]
|
unsub_id = to_unsub["url"]
|
||||||
@@ -209,16 +124,6 @@ class PostData:
|
|||||||
RedisArchivist().set_message(key, message)
|
RedisArchivist().set_message(key, message)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
def _dlnow(self):
|
|
||||||
"""start downloading single vid now"""
|
|
||||||
youtube_id = self.exec_val
|
|
||||||
print(f"{youtube_id}: downloading now")
|
|
||||||
running = download_single.delay(youtube_id=youtube_id)
|
|
||||||
task_id = running.id
|
|
||||||
print("set task id: " + task_id)
|
|
||||||
RedisArchivist().set_message("dl_queue_id", task_id)
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _show_ignored_only(self):
|
def _show_ignored_only(self):
|
||||||
"""switch view on /downloads/ to show ignored only"""
|
"""switch view on /downloads/ to show ignored only"""
|
||||||
show_value = self.exec_val
|
show_value = self.exec_val
|
||||||
@@ -228,48 +133,6 @@ class PostData:
|
|||||||
RedisArchivist().set_message(key, value)
|
RedisArchivist().set_message(key, value)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
def _forget_ignore(self):
|
|
||||||
"""delete from ta_download index"""
|
|
||||||
video_id = self.exec_val
|
|
||||||
print(f"{video_id}: forget from download")
|
|
||||||
PendingInteract(video_id=video_id).delete_item()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _add_single(self):
|
|
||||||
"""add single youtube_id to download queue"""
|
|
||||||
video_id = self.exec_val
|
|
||||||
print(f"{video_id}: add single vid to download queue")
|
|
||||||
PendingInteract(video_id=video_id, status="pending").update_status()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _delete_queue(self):
|
|
||||||
"""delete download queue"""
|
|
||||||
status = self.exec_val
|
|
||||||
print("deleting from download queue: " + status)
|
|
||||||
PendingInteract(status=status).delete_by_status()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _manual_import():
|
|
||||||
"""run manual import from settings page"""
|
|
||||||
print("starting manual import")
|
|
||||||
run_manual_import.delay()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _re_embed():
|
|
||||||
"""rewrite thumbnails into media files"""
|
|
||||||
print("start video thumbnail embed process")
|
|
||||||
re_sync_thumbs.delay()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _db_backup():
|
|
||||||
"""backup es to zip from settings page"""
|
|
||||||
print("backing up database")
|
|
||||||
run_backup.delay("manual")
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _db_restore(self):
|
def _db_restore(self):
|
||||||
"""restore es zip from settings page"""
|
"""restore es zip from settings page"""
|
||||||
print("restoring index from backup zip")
|
print("restoring index from backup zip")
|
||||||
@@ -277,25 +140,6 @@ class PostData:
|
|||||||
run_restore_backup.delay(filename)
|
run_restore_backup.delay(filename)
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _fs_rescan():
|
|
||||||
"""start file system rescan task"""
|
|
||||||
print("start filesystem scan")
|
|
||||||
rescan_filesystem.delay()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _delete_video(self):
|
|
||||||
"""delete media file, metadata and thumb"""
|
|
||||||
youtube_id = self.exec_val
|
|
||||||
YoutubeVideo(youtube_id).delete_media_file()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _delete_channel(self):
|
|
||||||
"""delete channel and all matching videos"""
|
|
||||||
channel_id = self.exec_val
|
|
||||||
YoutubeChannel(channel_id).delete_channel()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def _delete_playlist(self):
|
def _delete_playlist(self):
|
||||||
"""delete playlist, only metadata or incl all videos"""
|
"""delete playlist, only metadata or incl all videos"""
|
||||||
playlist_dict = self.exec_val
|
playlist_dict = self.exec_val
|
||||||
@@ -308,9 +152,3 @@ class PostData:
|
|||||||
YoutubePlaylist(playlist_id).delete_videos_playlist()
|
YoutubePlaylist(playlist_id).delete_videos_playlist()
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
def _find_playlists(self):
|
|
||||||
"""add all playlists of a channel"""
|
|
||||||
channel_id = self.exec_val
|
|
||||||
index_channel_playlists.delay(channel_id)
|
|
||||||
return {"success": True}
|
|
||||||
|
|||||||
@@ -68,12 +68,6 @@ class ApplicationSettingsForm(forms.Form):
|
|||||||
("1", "enable sponsorblock integration"),
|
("1", "enable sponsorblock integration"),
|
||||||
]
|
]
|
||||||
|
|
||||||
CAST_CHOICES = [
|
|
||||||
("", "-- change Cast integration --"),
|
|
||||||
("0", "disable Cast"),
|
|
||||||
("1", "enable Cast"),
|
|
||||||
]
|
|
||||||
|
|
||||||
SNAPSHOT_CHOICES = [
|
SNAPSHOT_CHOICES = [
|
||||||
("", "-- change snapshot settings --"),
|
("", "-- change snapshot settings --"),
|
||||||
("0", "disable system snapshots"),
|
("0", "disable system snapshots"),
|
||||||
@@ -107,6 +101,12 @@ class ApplicationSettingsForm(forms.Form):
|
|||||||
subscriptions_channel_size = forms.IntegerField(
|
subscriptions_channel_size = forms.IntegerField(
|
||||||
required=False, min_value=1
|
required=False, min_value=1
|
||||||
)
|
)
|
||||||
|
subscriptions_live_channel_size = forms.IntegerField(
|
||||||
|
required=False, min_value=0
|
||||||
|
)
|
||||||
|
subscriptions_shorts_channel_size = forms.IntegerField(
|
||||||
|
required=False, min_value=0
|
||||||
|
)
|
||||||
downloads_limit_count = forms.IntegerField(required=False)
|
downloads_limit_count = forms.IntegerField(required=False)
|
||||||
downloads_limit_speed = forms.IntegerField(required=False)
|
downloads_limit_speed = forms.IntegerField(required=False)
|
||||||
downloads_throttledratelimit = forms.IntegerField(required=False)
|
downloads_throttledratelimit = forms.IntegerField(required=False)
|
||||||
@@ -139,9 +139,6 @@ class ApplicationSettingsForm(forms.Form):
|
|||||||
downloads_integrate_sponsorblock = forms.ChoiceField(
|
downloads_integrate_sponsorblock = forms.ChoiceField(
|
||||||
widget=forms.Select, choices=SP_CHOICES, required=False
|
widget=forms.Select, choices=SP_CHOICES, required=False
|
||||||
)
|
)
|
||||||
application_enable_cast = forms.ChoiceField(
|
|
||||||
widget=forms.Select, choices=CAST_CHOICES, required=False
|
|
||||||
)
|
|
||||||
application_enable_snapshot = forms.ChoiceField(
|
application_enable_snapshot = forms.ChoiceField(
|
||||||
widget=forms.Select, choices=SNAPSHOT_CHOICES, required=False
|
widget=forms.Select, choices=SNAPSHOT_CHOICES, required=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,101 +6,100 @@ functionality:
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from home.src.es.connect import ElasticWrap
|
from home.src.es.connect import ElasticWrap
|
||||||
from home.src.ta.helper import UrlListParser
|
from home.src.ta.urlparser import Parser
|
||||||
|
|
||||||
|
|
||||||
class WatchState:
|
class WatchState:
|
||||||
"""handle watched checkbox for videos and channels"""
|
"""handle watched checkbox for videos and channels"""
|
||||||
|
|
||||||
def __init__(self, youtube_id):
|
def __init__(self, youtube_id, is_watched):
|
||||||
self.youtube_id = youtube_id
|
self.youtube_id = youtube_id
|
||||||
self.stamp = int(datetime.now().strftime("%s"))
|
self.is_watched = is_watched
|
||||||
|
self.stamp = int(datetime.now().timestamp())
|
||||||
|
self.pipeline = f"_ingest/pipeline/watch_{youtube_id}"
|
||||||
|
|
||||||
def mark_as_watched(self):
|
def change(self):
|
||||||
"""update es with new watched value"""
|
"""change watched state of item(s)"""
|
||||||
url_type = self.dedect_type()
|
print(f"{self.youtube_id}: change watched state to {self.is_watched}")
|
||||||
|
url_type = self._dedect_type()
|
||||||
if url_type == "video":
|
if url_type == "video":
|
||||||
self.mark_vid_watched()
|
self.change_vid_state()
|
||||||
elif url_type == "channel":
|
return
|
||||||
self.mark_channel_watched()
|
|
||||||
elif url_type == "playlist":
|
|
||||||
self.mark_playlist_watched()
|
|
||||||
|
|
||||||
print(f"{self.youtube_id}: marked as watched")
|
self._add_pipeline()
|
||||||
|
path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}"
|
||||||
|
data = self._build_update_data(url_type)
|
||||||
|
_, _ = ElasticWrap(path).post(data)
|
||||||
|
self._delete_pipeline()
|
||||||
|
|
||||||
def mark_as_unwatched(self):
|
def _dedect_type(self):
|
||||||
"""revert watched state to false"""
|
|
||||||
url_type = self.dedect_type()
|
|
||||||
if url_type == "video":
|
|
||||||
self.mark_vid_watched(revert=True)
|
|
||||||
|
|
||||||
print(f"{self.youtube_id}: revert as unwatched")
|
|
||||||
|
|
||||||
def dedect_type(self):
|
|
||||||
"""find youtube id type"""
|
"""find youtube id type"""
|
||||||
print(self.youtube_id)
|
url_process = Parser(self.youtube_id).parse()
|
||||||
url_process = UrlListParser(self.youtube_id).process_list()
|
|
||||||
url_type = url_process[0]["type"]
|
url_type = url_process[0]["type"]
|
||||||
return url_type
|
return url_type
|
||||||
|
|
||||||
def mark_vid_watched(self, revert=False):
|
def change_vid_state(self):
|
||||||
"""change watched status of single video"""
|
"""change watched state of video"""
|
||||||
path = f"ta_video/_update/{self.youtube_id}"
|
path = f"ta_video/_update/{self.youtube_id}"
|
||||||
data = {
|
data = {
|
||||||
"doc": {"player": {"watched": True, "watched_date": self.stamp}}
|
"doc": {
|
||||||
|
"player": {
|
||||||
|
"watched": self.is_watched,
|
||||||
|
"watched_date": self.stamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if revert:
|
|
||||||
data["doc"]["player"]["watched"] = False
|
|
||||||
|
|
||||||
response, status_code = ElasticWrap(path).post(data=data)
|
response, status_code = ElasticWrap(path).post(data=data)
|
||||||
if status_code != 200:
|
if status_code != 200:
|
||||||
print(response)
|
print(response)
|
||||||
raise ValueError("failed to mark video as watched")
|
raise ValueError("failed to mark video as watched")
|
||||||
|
|
||||||
def _get_source(self):
|
def _build_update_data(self, url_type):
|
||||||
"""build source line for update_by_query script"""
|
"""build update by query data based on url_type"""
|
||||||
source = [
|
term_key_map = {
|
||||||
"ctx._source.player['watched'] = true",
|
"channel": "channel.channel_id",
|
||||||
f"ctx._source.player['watched_date'] = {self.stamp}",
|
"playlist": "playlist.keyword",
|
||||||
]
|
}
|
||||||
return "; ".join(source)
|
term_key = term_key_map.get(url_type)
|
||||||
|
|
||||||
def mark_channel_watched(self):
|
return {
|
||||||
"""change watched status of every video in channel"""
|
"query": {
|
||||||
path = "ta_video/_update_by_query"
|
"bool": {
|
||||||
must_list = [
|
"must": [
|
||||||
{"term": {"channel.channel_id": {"value": self.youtube_id}}},
|
{"term": {term_key: {"value": self.youtube_id}}},
|
||||||
{"term": {"player.watched": {"value": False}}},
|
{
|
||||||
]
|
"term": {
|
||||||
data = {
|
"player.watched": {
|
||||||
"query": {"bool": {"must": must_list}},
|
"value": not self.is_watched
|
||||||
"script": {
|
}
|
||||||
"source": self._get_source(),
|
}
|
||||||
"lang": "painless",
|
},
|
||||||
},
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response, status_code = ElasticWrap(path).post(data=data)
|
def _add_pipeline(self):
|
||||||
if status_code != 200:
|
"""add ingest pipeline"""
|
||||||
print(response)
|
|
||||||
raise ValueError("failed mark channel as watched")
|
|
||||||
|
|
||||||
def mark_playlist_watched(self):
|
|
||||||
"""change watched state of all videos in playlist"""
|
|
||||||
path = "ta_video/_update_by_query"
|
|
||||||
must_list = [
|
|
||||||
{"term": {"playlist.keyword": {"value": self.youtube_id}}},
|
|
||||||
{"term": {"player.watched": {"value": False}}},
|
|
||||||
]
|
|
||||||
data = {
|
data = {
|
||||||
"query": {"bool": {"must": must_list}},
|
"description": f"{self.youtube_id}: watched {self.is_watched}",
|
||||||
"script": {
|
"processors": [
|
||||||
"source": self._get_source(),
|
{
|
||||||
"lang": "painless",
|
"set": {
|
||||||
},
|
"field": "player.watched",
|
||||||
|
"value": self.is_watched,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"set": {
|
||||||
|
"field": "player.watched_date",
|
||||||
|
"value": self.stamp,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
_, _ = ElasticWrap(self.pipeline).put(data)
|
||||||
|
|
||||||
response, status_code = ElasticWrap(path).post(data=data)
|
def _delete_pipeline(self):
|
||||||
if status_code != 200:
|
"""delete pipeline"""
|
||||||
print(response)
|
ElasticWrap(self.pipeline).delete()
|
||||||
raise ValueError("failed mark playlist as watched")
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from home.src.es.connect import ElasticWrap, IndexPaginate
|
|||||||
from home.src.index.generic import YouTubeItem
|
from home.src.index.generic import YouTubeItem
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from home.src.index.playlist import YoutubePlaylist
|
||||||
from home.src.ta.helper import clean_string, requests_headers
|
from home.src.ta.helper import clean_string, requests_headers
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelScraper:
|
class ChannelScraper:
|
||||||
@@ -88,7 +87,7 @@ class ChannelScraper:
|
|||||||
# build and return dict
|
# build and return dict
|
||||||
self.json_data = {
|
self.json_data = {
|
||||||
"channel_active": True,
|
"channel_active": True,
|
||||||
"channel_last_refresh": int(datetime.now().strftime("%s")),
|
"channel_last_refresh": int(datetime.now().timestamp()),
|
||||||
"channel_subs": self._get_channel_subs(main_tab),
|
"channel_subs": self._get_channel_subs(main_tab),
|
||||||
"channel_name": main_tab["title"],
|
"channel_name": main_tab["title"],
|
||||||
"channel_banner_url": self._get_thumbnails(main_tab, "banner"),
|
"channel_banner_url": self._get_thumbnails(main_tab, "banner"),
|
||||||
@@ -167,12 +166,12 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
es_path = False
|
es_path = False
|
||||||
index_name = "ta_channel"
|
index_name = "ta_channel"
|
||||||
yt_base = "https://www.youtube.com/channel/"
|
yt_base = "https://www.youtube.com/channel/"
|
||||||
msg = "message:playlistscan"
|
|
||||||
|
|
||||||
def __init__(self, youtube_id):
|
def __init__(self, youtube_id, task=False):
|
||||||
super().__init__(youtube_id)
|
super().__init__(youtube_id)
|
||||||
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
|
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
|
||||||
self.all_playlists = False
|
self.all_playlists = False
|
||||||
|
self.task = task
|
||||||
|
|
||||||
def build_json(self, upload=False, fallback=False):
|
def build_json(self, upload=False, fallback=False):
|
||||||
"""get from es or from youtube"""
|
"""get from es or from youtube"""
|
||||||
@@ -203,7 +202,7 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
print(f"{self.youtube_id}: fallback to video metadata")
|
print(f"{self.youtube_id}: fallback to video metadata")
|
||||||
self.json_data = {
|
self.json_data = {
|
||||||
"channel_active": False,
|
"channel_active": False,
|
||||||
"channel_last_refresh": int(datetime.now().strftime("%s")),
|
"channel_last_refresh": int(datetime.now().timestamp()),
|
||||||
"channel_subs": fallback.get("channel_follower_count", 0),
|
"channel_subs": fallback.get("channel_follower_count", 0),
|
||||||
"channel_name": fallback["uploader"],
|
"channel_name": fallback["uploader"],
|
||||||
"channel_banner_url": False,
|
"channel_banner_url": False,
|
||||||
@@ -241,6 +240,7 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
urls = (
|
urls = (
|
||||||
self.json_data["channel_thumb_url"],
|
self.json_data["channel_thumb_url"],
|
||||||
self.json_data["channel_banner_url"],
|
self.json_data["channel_banner_url"],
|
||||||
|
self.json_data["channel_tvart_url"],
|
||||||
)
|
)
|
||||||
ThumbManager(self.youtube_id, item_type="channel").download(urls)
|
ThumbManager(self.youtube_id, item_type="channel").download(urls)
|
||||||
|
|
||||||
@@ -298,6 +298,9 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
"""delete channel and all videos"""
|
"""delete channel and all videos"""
|
||||||
print(f"{self.youtube_id}: delete channel")
|
print(f"{self.youtube_id}: delete channel")
|
||||||
self.get_from_es()
|
self.get_from_es()
|
||||||
|
if not self.json_data:
|
||||||
|
raise FileNotFoundError
|
||||||
|
|
||||||
folder_path = self.get_folder_path()
|
folder_path = self.get_folder_path()
|
||||||
print(f"{self.youtube_id}: delete all media files")
|
print(f"{self.youtube_id}: delete all media files")
|
||||||
try:
|
try:
|
||||||
@@ -321,34 +324,29 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
print(f"{self.youtube_id}: index all playlists")
|
print(f"{self.youtube_id}: index all playlists")
|
||||||
self.get_from_es()
|
self.get_from_es()
|
||||||
channel_name = self.json_data["channel_name"]
|
channel_name = self.json_data["channel_name"]
|
||||||
mess_dict = {
|
self.task.send_progress([f"{channel_name}: Looking for Playlists"])
|
||||||
"status": self.msg,
|
|
||||||
"level": "info",
|
|
||||||
"title": "Looking for playlists",
|
|
||||||
"message": f"{channel_name}: Scanning channel in progress",
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message(self.msg, mess_dict, expire=True)
|
|
||||||
self.get_all_playlists()
|
self.get_all_playlists()
|
||||||
if not self.all_playlists:
|
if not self.all_playlists:
|
||||||
print(f"{self.youtube_id}: no playlists found.")
|
print(f"{self.youtube_id}: no playlists found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
all_youtube_ids = self.get_all_video_ids()
|
all_youtube_ids = self.get_all_video_ids()
|
||||||
|
total = len(self.all_playlists)
|
||||||
for idx, playlist in enumerate(self.all_playlists):
|
for idx, playlist in enumerate(self.all_playlists):
|
||||||
self._notify_single_playlist(idx, playlist)
|
if self.task:
|
||||||
self._index_single_playlist(playlist, all_youtube_ids)
|
self._notify_single_playlist(idx, total)
|
||||||
|
|
||||||
def _notify_single_playlist(self, idx, playlist):
|
self._index_single_playlist(playlist, all_youtube_ids)
|
||||||
|
print("add playlist: " + playlist[1])
|
||||||
|
|
||||||
|
def _notify_single_playlist(self, idx, total):
|
||||||
"""send notification"""
|
"""send notification"""
|
||||||
channel_name = self.json_data["channel_name"]
|
channel_name = self.json_data["channel_name"]
|
||||||
mess_dict = {
|
message = [
|
||||||
"status": self.msg,
|
f"{channel_name}: Scanning channel for playlists",
|
||||||
"level": "info",
|
f"Progress: {idx + 1}/{total}",
|
||||||
"title": f"{channel_name}: Scanning channel for playlists",
|
]
|
||||||
"message": f"Progress: {idx + 1}/{len(self.all_playlists)}",
|
self.task.send_progress(message, progress=(idx + 1) / total)
|
||||||
}
|
|
||||||
RedisArchivist().set_message(self.msg, mess_dict, expire=True)
|
|
||||||
print("add playlist: " + playlist[1])
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _index_single_playlist(playlist, all_youtube_ids):
|
def _index_single_playlist(playlist, all_youtube_ids):
|
||||||
@@ -378,6 +376,17 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
|
|
||||||
return all_youtube_ids
|
return all_youtube_ids
|
||||||
|
|
||||||
|
def get_channel_videos(self):
|
||||||
|
"""get all videos from channel"""
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"term": {"channel.channel_id": {"value": self.youtube_id}}
|
||||||
|
},
|
||||||
|
"_source": ["youtube_id", "vid_type"],
|
||||||
|
}
|
||||||
|
all_videos = IndexPaginate("ta_video", data).get_results()
|
||||||
|
return all_videos
|
||||||
|
|
||||||
def get_all_playlists(self):
|
def get_all_playlists(self):
|
||||||
"""get all playlists owned by this channel"""
|
"""get all playlists owned by this channel"""
|
||||||
url = (
|
url = (
|
||||||
@@ -389,14 +398,16 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
|
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
|
||||||
self.all_playlists = all_entries
|
self.all_playlists = all_entries
|
||||||
|
|
||||||
def get_indexed_playlists(self):
|
def get_indexed_playlists(self, active_only=False):
|
||||||
"""get all indexed playlists from channel"""
|
"""get all indexed playlists from channel"""
|
||||||
data = {
|
must_list = [
|
||||||
"query": {
|
{"term": {"playlist_channel_id": {"value": self.youtube_id}}}
|
||||||
"term": {"playlist_channel_id": {"value": self.youtube_id}}
|
]
|
||||||
},
|
if active_only:
|
||||||
"sort": [{"playlist_channel.keyword": {"order": "desc"}}],
|
must_list.append({"term": {"playlist_active": {"value": True}}})
|
||||||
}
|
|
||||||
|
data = {"query": {"bool": {"must": must_list}}}
|
||||||
|
|
||||||
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
||||||
return all_playlists
|
return all_playlists
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ from datetime import datetime
|
|||||||
from home.src.download.yt_dlp_base import YtWrap
|
from home.src.download.yt_dlp_base import YtWrap
|
||||||
from home.src.es.connect import ElasticWrap
|
from home.src.es.connect import ElasticWrap
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
|
||||||
|
|
||||||
|
|
||||||
class Comments:
|
class Comments:
|
||||||
"""hold all comments functionality"""
|
"""interact with comments per video"""
|
||||||
|
|
||||||
def __init__(self, youtube_id, config=False):
|
def __init__(self, youtube_id, config=False):
|
||||||
self.youtube_id = youtube_id
|
self.youtube_id = youtube_id
|
||||||
@@ -24,23 +23,22 @@ class Comments:
|
|||||||
self.is_activated = False
|
self.is_activated = False
|
||||||
self.comments_format = False
|
self.comments_format = False
|
||||||
|
|
||||||
def build_json(self, notify=False):
|
def build_json(self):
|
||||||
"""build json document for es"""
|
"""build json document for es"""
|
||||||
print(f"{self.youtube_id}: get comments")
|
print(f"{self.youtube_id}: get comments")
|
||||||
self.check_config()
|
self.check_config()
|
||||||
if not self.is_activated:
|
if not self.is_activated:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._send_notification(notify)
|
|
||||||
comments_raw, channel_id = self.get_yt_comments()
|
comments_raw, channel_id = self.get_yt_comments()
|
||||||
if comments_raw:
|
if not comments_raw and not channel_id:
|
||||||
self.format_comments(comments_raw)
|
return
|
||||||
else:
|
|
||||||
self.comments_format = []
|
self.format_comments(comments_raw)
|
||||||
|
|
||||||
self.json_data = {
|
self.json_data = {
|
||||||
"youtube_id": self.youtube_id,
|
"youtube_id": self.youtube_id,
|
||||||
"comment_last_refresh": int(datetime.now().strftime("%s")),
|
"comment_last_refresh": int(datetime.now().timestamp()),
|
||||||
"comment_channel_id": channel_id,
|
"comment_channel_id": channel_id,
|
||||||
"comment_comments": self.comments_format,
|
"comment_comments": self.comments_format,
|
||||||
}
|
}
|
||||||
@@ -52,23 +50,6 @@ class Comments:
|
|||||||
|
|
||||||
self.is_activated = bool(self.config["downloads"]["comment_max"])
|
self.is_activated = bool(self.config["downloads"]["comment_max"])
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _send_notification(notify):
|
|
||||||
"""send notification for download post process message"""
|
|
||||||
if not notify:
|
|
||||||
return
|
|
||||||
|
|
||||||
key = "message:download"
|
|
||||||
idx, total_videos = notify
|
|
||||||
message = {
|
|
||||||
"status": key,
|
|
||||||
"level": "info",
|
|
||||||
"title": "Download and index comments",
|
|
||||||
"message": f"Progress: {idx + 1}/{total_videos}",
|
|
||||||
}
|
|
||||||
|
|
||||||
RedisArchivist().set_message(key, message)
|
|
||||||
|
|
||||||
def build_yt_obs(self):
|
def build_yt_obs(self):
|
||||||
"""
|
"""
|
||||||
get extractor config
|
get extractor config
|
||||||
@@ -79,8 +60,8 @@ class Comments:
|
|||||||
comment_sort = self.config["downloads"]["comment_sort"]
|
comment_sort = self.config["downloads"]["comment_sort"]
|
||||||
|
|
||||||
yt_obs = {
|
yt_obs = {
|
||||||
|
"check_formats": None,
|
||||||
"skip_download": True,
|
"skip_download": True,
|
||||||
"quiet": False,
|
|
||||||
"getcomments": True,
|
"getcomments": True,
|
||||||
"extractor_args": {
|
"extractor_args": {
|
||||||
"youtube": {
|
"youtube": {
|
||||||
@@ -96,6 +77,9 @@ class Comments:
|
|||||||
"""get comments from youtube"""
|
"""get comments from youtube"""
|
||||||
yt_obs = self.build_yt_obs()
|
yt_obs = self.build_yt_obs()
|
||||||
info_json = YtWrap(yt_obs).extract(self.youtube_id)
|
info_json = YtWrap(yt_obs).extract(self.youtube_id)
|
||||||
|
if not info_json:
|
||||||
|
return False, False
|
||||||
|
|
||||||
comments_raw = info_json.get("comments")
|
comments_raw = info_json.get("comments")
|
||||||
channel_id = info_json.get("channel_id")
|
channel_id = info_json.get("channel_id")
|
||||||
return comments_raw, channel_id
|
return comments_raw, channel_id
|
||||||
@@ -104,14 +88,23 @@ class Comments:
|
|||||||
"""process comments to match format"""
|
"""process comments to match format"""
|
||||||
comments = []
|
comments = []
|
||||||
|
|
||||||
for comment in comments_raw:
|
if comments_raw:
|
||||||
cleaned_comment = self.clean_comment(comment)
|
for comment in comments_raw:
|
||||||
comments.append(cleaned_comment)
|
cleaned_comment = self.clean_comment(comment)
|
||||||
|
if not cleaned_comment:
|
||||||
|
continue
|
||||||
|
|
||||||
|
comments.append(cleaned_comment)
|
||||||
|
|
||||||
self.comments_format = comments
|
self.comments_format = comments
|
||||||
|
|
||||||
def clean_comment(self, comment):
|
def clean_comment(self, comment):
|
||||||
"""parse metadata from comment for indexing"""
|
"""parse metadata from comment for indexing"""
|
||||||
|
if not comment.get("text"):
|
||||||
|
# comment text can be empty
|
||||||
|
print(f"{self.youtube_id}: Failed to extract text, {comment}")
|
||||||
|
return False
|
||||||
|
|
||||||
time_text_datetime = datetime.utcfromtimestamp(comment["timestamp"])
|
time_text_datetime = datetime.utcfromtimestamp(comment["timestamp"])
|
||||||
|
|
||||||
if time_text_datetime.hour == 0 and time_text_datetime.minute == 0:
|
if time_text_datetime.hour == 0 and time_text_datetime.minute == 0:
|
||||||
@@ -142,6 +135,7 @@ class Comments:
|
|||||||
if not self.is_activated:
|
if not self.is_activated:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
print(f"{self.youtube_id}: upload comments")
|
||||||
_, _ = ElasticWrap(self.es_path).put(self.json_data)
|
_, _ = ElasticWrap(self.es_path).put(self.json_data)
|
||||||
|
|
||||||
vid_path = f"ta_video/_update/{self.youtube_id}"
|
vid_path = f"ta_video/_update/{self.youtube_id}"
|
||||||
@@ -169,11 +163,47 @@ class Comments:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.build_json()
|
self.build_json()
|
||||||
|
if not self.json_data:
|
||||||
|
return
|
||||||
|
|
||||||
es_comments = self.get_es_comments()
|
es_comments = self.get_es_comments()
|
||||||
|
|
||||||
|
if not self.comments_format:
|
||||||
|
return
|
||||||
|
|
||||||
if not self.comments_format and es_comments["comment_comments"]:
|
if not self.comments_format and es_comments["comment_comments"]:
|
||||||
# don't overwrite comments in es
|
# don't overwrite comments in es
|
||||||
return
|
return
|
||||||
|
|
||||||
self.delete_comments()
|
self.delete_comments()
|
||||||
self.upload_comments()
|
self.upload_comments()
|
||||||
|
|
||||||
|
|
||||||
|
class CommentList:
|
||||||
|
"""interact with comments in group"""
|
||||||
|
|
||||||
|
def __init__(self, video_ids, task=False):
|
||||||
|
self.video_ids = video_ids
|
||||||
|
self.task = task
|
||||||
|
self.config = AppConfig().config
|
||||||
|
|
||||||
|
def index(self):
|
||||||
|
"""index comments for list, init with task object to notify"""
|
||||||
|
if not self.config["downloads"].get("comment_max"):
|
||||||
|
return
|
||||||
|
|
||||||
|
total_videos = len(self.video_ids)
|
||||||
|
for idx, youtube_id in enumerate(self.video_ids):
|
||||||
|
if self.task:
|
||||||
|
self.notify(idx, total_videos)
|
||||||
|
|
||||||
|
comment = Comments(youtube_id, config=self.config)
|
||||||
|
comment.build_json()
|
||||||
|
if comment.json_data:
|
||||||
|
comment.upload_comments()
|
||||||
|
|
||||||
|
def notify(self, idx, total_videos):
|
||||||
|
"""send notification on task"""
|
||||||
|
message = [f"Add comments for new videos {idx + 1}/{total_videos}"]
|
||||||
|
progress = (idx + 1) / total_videos
|
||||||
|
self.task.send_progress(message, progress=progress)
|
||||||
|
|||||||
@@ -7,39 +7,37 @@ Functionality:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from home.src.download.queue import PendingList
|
from home.src.download.queue import PendingList
|
||||||
from home.src.download.thumbnails import ThumbManager
|
|
||||||
from home.src.es.connect import ElasticWrap
|
from home.src.es.connect import ElasticWrap
|
||||||
from home.src.index.reindex import Reindex
|
from home.src.index.comments import CommentList
|
||||||
from home.src.index.video import YoutubeVideo, index_new_video
|
from home.src.index.video import index_new_video
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
from home.src.ta.helper import clean_string, ignore_filelist
|
from home.src.ta.helper import clean_string, ignore_filelist
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
from PIL import ImageFile
|
||||||
from PIL import Image, ImageFile
|
|
||||||
from yt_dlp.utils import ISO639Utils
|
|
||||||
|
|
||||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||||
|
|
||||||
|
|
||||||
class FilesystemScanner:
|
class ScannerBase:
|
||||||
"""handle scanning and fixing from filesystem"""
|
"""scan the filesystem base class"""
|
||||||
|
|
||||||
CONFIG = AppConfig().config
|
CONFIG = AppConfig().config
|
||||||
VIDEOS = CONFIG["application"]["videos"]
|
VIDEOS = CONFIG["application"]["videos"]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.all_downloaded = self.get_all_downloaded()
|
self.to_index = False
|
||||||
self.all_indexed = self.get_all_indexed()
|
self.to_delete = False
|
||||||
self.mismatch = None
|
self.mismatch = False
|
||||||
self.to_rename = None
|
self.to_rename = False
|
||||||
self.to_index = None
|
|
||||||
self.to_delete = None
|
|
||||||
|
|
||||||
def get_all_downloaded(self):
|
def scan(self):
|
||||||
|
"""entry point, scan and compare"""
|
||||||
|
all_downloaded = self._get_all_downloaded()
|
||||||
|
all_indexed = self._get_all_indexed()
|
||||||
|
self.list_comarison(all_downloaded, all_indexed)
|
||||||
|
|
||||||
|
def _get_all_downloaded(self):
|
||||||
"""get a list of all video files downloaded"""
|
"""get a list of all video files downloaded"""
|
||||||
channels = os.listdir(self.VIDEOS)
|
channels = os.listdir(self.VIDEOS)
|
||||||
all_channels = ignore_filelist(channels)
|
all_channels = ignore_filelist(channels)
|
||||||
@@ -57,7 +55,7 @@ class FilesystemScanner:
|
|||||||
return all_downloaded
|
return all_downloaded
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_all_indexed():
|
def _get_all_indexed():
|
||||||
"""get a list of all indexed videos"""
|
"""get a list of all indexed videos"""
|
||||||
index_handler = PendingList()
|
index_handler = PendingList()
|
||||||
index_handler.get_download()
|
index_handler.get_download()
|
||||||
@@ -72,64 +70,77 @@ class FilesystemScanner:
|
|||||||
all_indexed.append((youtube_id, media_url, published, title))
|
all_indexed.append((youtube_id, media_url, published, title))
|
||||||
return all_indexed
|
return all_indexed
|
||||||
|
|
||||||
def list_comarison(self):
|
def list_comarison(self, all_downloaded, all_indexed):
|
||||||
"""compare the lists to figure out what to do"""
|
"""compare the lists to figure out what to do"""
|
||||||
self.find_unindexed()
|
self._find_unindexed(all_downloaded, all_indexed)
|
||||||
self.find_missing()
|
self._find_missing(all_downloaded, all_indexed)
|
||||||
self.find_bad_media_url()
|
self._find_bad_media_url(all_downloaded, all_indexed)
|
||||||
|
|
||||||
def find_unindexed(self):
|
def _find_unindexed(self, all_downloaded, all_indexed):
|
||||||
"""find video files without a matching document indexed"""
|
"""find video files without a matching document indexed"""
|
||||||
all_indexed_ids = [i[0] for i in self.all_indexed]
|
all_indexed_ids = [i[0] for i in all_indexed]
|
||||||
to_index = []
|
self.to_index = []
|
||||||
for downloaded in self.all_downloaded:
|
for downloaded in all_downloaded:
|
||||||
if downloaded[2] not in all_indexed_ids:
|
if downloaded[2] not in all_indexed_ids:
|
||||||
to_index.append(downloaded)
|
self.to_index.append(downloaded)
|
||||||
|
|
||||||
self.to_index = to_index
|
def _find_missing(self, all_downloaded, all_indexed):
|
||||||
|
|
||||||
def find_missing(self):
|
|
||||||
"""find indexed videos without matching media file"""
|
"""find indexed videos without matching media file"""
|
||||||
all_downloaded_ids = [i[2] for i in self.all_downloaded]
|
all_downloaded_ids = [i[2] for i in all_downloaded]
|
||||||
to_delete = []
|
self.to_delete = []
|
||||||
for video in self.all_indexed:
|
for video in all_indexed:
|
||||||
youtube_id = video[0]
|
youtube_id = video[0]
|
||||||
if youtube_id not in all_downloaded_ids:
|
if youtube_id not in all_downloaded_ids:
|
||||||
to_delete.append(video)
|
self.to_delete.append(video)
|
||||||
|
|
||||||
self.to_delete = to_delete
|
def _find_bad_media_url(self, all_downloaded, all_indexed):
|
||||||
|
|
||||||
def find_bad_media_url(self):
|
|
||||||
"""rename media files not matching the indexed title"""
|
"""rename media files not matching the indexed title"""
|
||||||
to_fix = []
|
self.mismatch = []
|
||||||
to_rename = []
|
self.to_rename = []
|
||||||
for downloaded in self.all_downloaded:
|
|
||||||
|
for downloaded in all_downloaded:
|
||||||
channel, filename, downloaded_id = downloaded
|
channel, filename, downloaded_id = downloaded
|
||||||
# find in indexed
|
# find in indexed
|
||||||
for indexed in self.all_indexed:
|
for indexed in all_indexed:
|
||||||
indexed_id, media_url, published, title = indexed
|
indexed_id, media_url, published, title = indexed
|
||||||
if indexed_id == downloaded_id:
|
if indexed_id == downloaded_id:
|
||||||
# found it
|
# found it
|
||||||
title_c = clean_string(title)
|
|
||||||
pub = published.replace("-", "")
|
pub = published.replace("-", "")
|
||||||
expected_filename = f"{pub}_{indexed_id}_{title_c}.mp4"
|
expected = f"{pub}_{indexed_id}_{clean_string(title)}.mp4"
|
||||||
new_url = os.path.join(channel, expected_filename)
|
new_url = os.path.join(channel, expected)
|
||||||
if expected_filename != filename:
|
if expected != filename:
|
||||||
# file to rename
|
# file to rename
|
||||||
to_rename.append(
|
self.to_rename.append((channel, filename, expected))
|
||||||
(channel, filename, expected_filename)
|
|
||||||
)
|
|
||||||
if media_url != new_url:
|
if media_url != new_url:
|
||||||
# media_url to update in es
|
# media_url to update in es
|
||||||
to_fix.append((indexed_id, new_url))
|
self.mismatch.append((indexed_id, new_url))
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
self.mismatch = to_fix
|
|
||||||
self.to_rename = to_rename
|
class Filesystem(ScannerBase):
|
||||||
|
"""handle scanning and fixing from filesystem"""
|
||||||
|
|
||||||
|
def __init__(self, task=False):
|
||||||
|
super().__init__()
|
||||||
|
self.task = task
|
||||||
|
|
||||||
|
def process(self):
|
||||||
|
"""entry point"""
|
||||||
|
self.task.send_progress(["Scanning your archive and index."])
|
||||||
|
self.scan()
|
||||||
|
self.rename_files()
|
||||||
|
self.send_mismatch_bulk()
|
||||||
|
self.delete_from_index()
|
||||||
|
self.add_missing()
|
||||||
|
|
||||||
def rename_files(self):
|
def rename_files(self):
|
||||||
"""rename media files as identified by find_bad_media_url"""
|
"""rename media files as identified by find_bad_media_url"""
|
||||||
|
if not self.to_rename:
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(self.to_rename)
|
||||||
|
self.task.send_progress([f"Rename {total} media files."])
|
||||||
for bad_filename in self.to_rename:
|
for bad_filename in self.to_rename:
|
||||||
channel, filename, expected_filename = bad_filename
|
channel, filename, expected_filename = bad_filename
|
||||||
print(f"renaming [{filename}] to [{expected_filename}]")
|
print(f"renaming [{filename}] to [{expected_filename}]")
|
||||||
@@ -139,6 +150,11 @@ class FilesystemScanner:
|
|||||||
|
|
||||||
def send_mismatch_bulk(self):
|
def send_mismatch_bulk(self):
|
||||||
"""build bulk update"""
|
"""build bulk update"""
|
||||||
|
if not self.mismatch:
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(self.mismatch)
|
||||||
|
self.task.send_progress([f"Fix media urls for {total} files"])
|
||||||
bulk_list = []
|
bulk_list = []
|
||||||
for video_mismatch in self.mismatch:
|
for video_mismatch in self.mismatch:
|
||||||
youtube_id, media_url = video_mismatch
|
youtube_id, media_url = video_mismatch
|
||||||
@@ -154,463 +170,32 @@ class FilesystemScanner:
|
|||||||
|
|
||||||
def delete_from_index(self):
|
def delete_from_index(self):
|
||||||
"""find indexed but deleted mediafile"""
|
"""find indexed but deleted mediafile"""
|
||||||
|
if not self.to_delete:
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(self.to_delete)
|
||||||
|
self.task.send_progress([f"Clean up {total} items from index."])
|
||||||
for indexed in self.to_delete:
|
for indexed in self.to_delete:
|
||||||
youtube_id = indexed[0]
|
youtube_id = indexed[0]
|
||||||
print(f"deleting {youtube_id} from index")
|
print(f"deleting {youtube_id} from index")
|
||||||
path = f"ta_video/_doc/{youtube_id}"
|
path = f"ta_video/_doc/{youtube_id}"
|
||||||
_, _ = ElasticWrap(path).delete()
|
_, _ = ElasticWrap(path).delete()
|
||||||
|
|
||||||
|
def add_missing(self):
|
||||||
class ImportFolderScanner:
|
"""add missing videos to index"""
|
||||||
"""import and indexing existing video files
|
video_ids = [i[2] for i in self.to_index]
|
||||||
- identify all media files belonging to a video
|
if not video_ids:
|
||||||
- identify youtube id
|
|
||||||
- convert if needed
|
|
||||||
"""
|
|
||||||
|
|
||||||
CONFIG = AppConfig().config
|
|
||||||
CACHE_DIR = CONFIG["application"]["cache_dir"]
|
|
||||||
IMPORT_DIR = os.path.join(CACHE_DIR, "import")
|
|
||||||
|
|
||||||
EXT_MAP = {
|
|
||||||
"media": [".mp4", ".mkv", ".webm"],
|
|
||||||
"metadata": [".json"],
|
|
||||||
"thumb": [".jpg", ".png", ".webp"],
|
|
||||||
"subtitle": [".vtt"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.to_import = False
|
|
||||||
|
|
||||||
def scan(self):
|
|
||||||
"""scan and match media files"""
|
|
||||||
all_files = self.get_all_files()
|
|
||||||
self.match_files(all_files)
|
|
||||||
self.process_videos()
|
|
||||||
|
|
||||||
return self.to_import
|
|
||||||
|
|
||||||
def get_all_files(self):
|
|
||||||
"""get all files in /import"""
|
|
||||||
rel_paths = ignore_filelist(os.listdir(self.IMPORT_DIR))
|
|
||||||
all_files = [os.path.join(self.IMPORT_DIR, i) for i in rel_paths]
|
|
||||||
all_files.sort()
|
|
||||||
|
|
||||||
return all_files
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_template():
|
|
||||||
"""base dict for video"""
|
|
||||||
return {
|
|
||||||
"media": False,
|
|
||||||
"video_id": False,
|
|
||||||
"metadata": False,
|
|
||||||
"thumb": False,
|
|
||||||
"subtitle": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
def match_files(self, all_files):
|
|
||||||
"""loop through all files, join what matches"""
|
|
||||||
self.to_import = []
|
|
||||||
|
|
||||||
current_video = self._get_template()
|
|
||||||
last_base = False
|
|
||||||
|
|
||||||
for file_path in all_files:
|
|
||||||
base_name, ext = self._detect_base_name(file_path)
|
|
||||||
key, file_path = self._detect_type(file_path, ext)
|
|
||||||
if not key or not file_path:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if base_name != last_base:
|
|
||||||
if last_base:
|
|
||||||
print(f"manual import: {current_video}")
|
|
||||||
self.to_import.append(current_video)
|
|
||||||
|
|
||||||
current_video = self._get_template()
|
|
||||||
last_base = base_name
|
|
||||||
|
|
||||||
if key == "subtitle":
|
|
||||||
current_video["subtitle"].append(file_path)
|
|
||||||
else:
|
|
||||||
current_video[key] = file_path
|
|
||||||
|
|
||||||
if current_video.get("media"):
|
|
||||||
print(f"manual import: {current_video}")
|
|
||||||
self.to_import.append(current_video)
|
|
||||||
|
|
||||||
def _detect_base_name(self, file_path):
|
|
||||||
"""extract base_name and ext for matching"""
|
|
||||||
base_name_raw, ext = os.path.splitext(file_path)
|
|
||||||
base_name, ext2 = os.path.splitext(base_name_raw)
|
|
||||||
|
|
||||||
if ext2:
|
|
||||||
if ISO639Utils.short2long(ext2.strip(".")) or ext2 == ".info":
|
|
||||||
# valid secondary extension
|
|
||||||
return base_name, ext
|
|
||||||
|
|
||||||
return base_name_raw, ext
|
|
||||||
|
|
||||||
def _detect_type(self, file_path, ext):
|
|
||||||
"""detect metadata type for file"""
|
|
||||||
|
|
||||||
for key, value in self.EXT_MAP.items():
|
|
||||||
if ext in value:
|
|
||||||
return key, file_path
|
|
||||||
|
|
||||||
return False, False
|
|
||||||
|
|
||||||
def process_videos(self):
|
|
||||||
"""loop through all videos"""
|
|
||||||
for current_video in self.to_import:
|
|
||||||
if not current_video["media"]:
|
|
||||||
print(f"{current_video}: no matching media file found.")
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
self._detect_youtube_id(current_video)
|
|
||||||
self._dump_thumb(current_video)
|
|
||||||
self._convert_thumb(current_video)
|
|
||||||
self._get_subtitles(current_video)
|
|
||||||
self._convert_video(current_video)
|
|
||||||
print(f"manual import: {current_video}")
|
|
||||||
|
|
||||||
ManualImport(current_video, self.CONFIG).run()
|
|
||||||
|
|
||||||
def _detect_youtube_id(self, current_video):
|
|
||||||
"""find video id from filename or json"""
|
|
||||||
youtube_id = self._extract_id_from_filename(current_video["media"])
|
|
||||||
if youtube_id:
|
|
||||||
current_video["video_id"] = youtube_id
|
|
||||||
return
|
return
|
||||||
|
|
||||||
youtube_id = self._extract_id_from_json(current_video["metadata"])
|
total = len(video_ids)
|
||||||
if youtube_id:
|
for idx, youtube_id in enumerate(video_ids):
|
||||||
current_video["video_id"] = youtube_id
|
if self.task:
|
||||||
return
|
self.task.send_progress(
|
||||||
|
message_lines=[
|
||||||
raise ValueError("failed to find video id")
|
f"Index missing video {youtube_id}, {idx}/{total}"
|
||||||
|
],
|
||||||
@staticmethod
|
progress=(idx + 1) / total,
|
||||||
def _extract_id_from_filename(file_name):
|
)
|
||||||
"""
|
|
||||||
look at the file name for the youtube id
|
|
||||||
expects filename ending in [<youtube_id>].<ext>
|
|
||||||
"""
|
|
||||||
base_name, _ = os.path.splitext(file_name)
|
|
||||||
id_search = re.search(r"\[([a-zA-Z0-9_-]{11})\]$", base_name)
|
|
||||||
if id_search:
|
|
||||||
youtube_id = id_search.group(1)
|
|
||||||
return youtube_id
|
|
||||||
|
|
||||||
print(f"id extraction failed from filename: {file_name}")
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _extract_id_from_json(self, json_file):
|
|
||||||
"""open json file and extract id"""
|
|
||||||
json_path = os.path.join(self.CACHE_DIR, "import", json_file)
|
|
||||||
with open(json_path, "r", encoding="utf-8") as f:
|
|
||||||
json_content = f.read()
|
|
||||||
|
|
||||||
youtube_id = json.loads(json_content)["id"]
|
|
||||||
|
|
||||||
return youtube_id
|
|
||||||
|
|
||||||
def _dump_thumb(self, current_video):
|
|
||||||
"""extract embedded thumb before converting"""
|
|
||||||
if current_video["thumb"]:
|
|
||||||
return
|
|
||||||
|
|
||||||
media_path = current_video["media"]
|
|
||||||
_, ext = os.path.splitext(media_path)
|
|
||||||
|
|
||||||
new_path = False
|
|
||||||
if ext == ".mkv":
|
|
||||||
idx, thumb_type = self._get_mkv_thumb_stream(media_path)
|
|
||||||
if idx is not None:
|
|
||||||
new_path = self.dump_mpv_thumb(media_path, idx, thumb_type)
|
|
||||||
|
|
||||||
elif ext == ".mp4":
|
|
||||||
thumb_type = self.get_mp4_thumb_type(media_path)
|
|
||||||
if thumb_type:
|
|
||||||
new_path = self.dump_mp4_thumb(media_path, thumb_type)
|
|
||||||
|
|
||||||
if new_path:
|
|
||||||
current_video["thumb"] = new_path
|
|
||||||
|
|
||||||
def _get_mkv_thumb_stream(self, media_path):
|
|
||||||
"""get stream idx of thumbnail for mkv files"""
|
|
||||||
streams = self._get_streams(media_path)
|
|
||||||
attachments = [
|
|
||||||
i for i in streams["streams"] if i["codec_type"] == "attachment"
|
|
||||||
]
|
|
||||||
|
|
||||||
for idx, stream in enumerate(attachments):
|
|
||||||
tags = stream["tags"]
|
|
||||||
if "mimetype" in tags and tags["filename"].startswith("cover"):
|
|
||||||
_, ext = os.path.splitext(tags["filename"])
|
|
||||||
return idx, ext
|
|
||||||
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def dump_mpv_thumb(media_path, idx, thumb_type):
|
|
||||||
"""write cover to disk for mkv"""
|
|
||||||
_, media_ext = os.path.splitext(media_path)
|
|
||||||
new_path = f"{media_path.rstrip(media_ext)}{thumb_type}"
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-v",
|
|
||||||
"quiet",
|
|
||||||
f"-dump_attachment:t:{idx}",
|
|
||||||
new_path,
|
|
||||||
"-i",
|
|
||||||
media_path,
|
|
||||||
],
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
return new_path
|
|
||||||
|
|
||||||
def get_mp4_thumb_type(self, media_path):
|
|
||||||
"""detect filetype of embedded thumbnail"""
|
|
||||||
streams = self._get_streams(media_path)
|
|
||||||
|
|
||||||
for stream in streams["streams"]:
|
|
||||||
if stream["codec_name"] in ["png", "jpg"]:
|
|
||||||
return stream["codec_name"]
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _convert_thumb(self, current_video):
|
|
||||||
"""convert all thumbnails to jpg"""
|
|
||||||
if not current_video["thumb"]:
|
|
||||||
return
|
|
||||||
|
|
||||||
thumb_path = current_video["thumb"]
|
|
||||||
|
|
||||||
base_path, ext = os.path.splitext(thumb_path)
|
|
||||||
if ext == ".jpg":
|
|
||||||
return
|
|
||||||
|
|
||||||
new_path = f"{base_path}.jpg"
|
|
||||||
img_raw = Image.open(thumb_path)
|
|
||||||
img_raw.convert("RGB").save(new_path)
|
|
||||||
|
|
||||||
os.remove(thumb_path)
|
|
||||||
current_video["thumb"] = new_path
|
|
||||||
|
|
||||||
def _get_subtitles(self, current_video):
|
|
||||||
"""find all subtitles in media file"""
|
|
||||||
if current_video["subtitle"]:
|
|
||||||
return
|
|
||||||
|
|
||||||
media_path = current_video["media"]
|
|
||||||
streams = self._get_streams(media_path)
|
|
||||||
base_path, ext = os.path.splitext(media_path)
|
|
||||||
|
|
||||||
if ext == ".webm":
|
|
||||||
print(f"{media_path}: subtitle extract from webm not supported")
|
|
||||||
return
|
|
||||||
|
|
||||||
for idx, stream in enumerate(streams["streams"]):
|
|
||||||
if stream["codec_type"] == "subtitle":
|
|
||||||
lang = ISO639Utils.long2short(stream["tags"]["language"])
|
|
||||||
sub_path = f"{base_path}.{lang}.vtt"
|
|
||||||
self._dump_subtitle(idx, media_path, sub_path)
|
|
||||||
current_video["subtitle"].append(sub_path)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dump_subtitle(idx, media_path, sub_path):
|
|
||||||
"""extract subtitle from media file"""
|
|
||||||
subprocess.run(
|
|
||||||
["ffmpeg", "-i", media_path, "-map", f"0:{idx}", sub_path],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_streams(media_path):
|
|
||||||
"""return all streams from media_path"""
|
|
||||||
streams_raw = subprocess.run(
|
|
||||||
[
|
|
||||||
"ffprobe",
|
|
||||||
"-v",
|
|
||||||
"error",
|
|
||||||
"-show_streams",
|
|
||||||
"-print_format",
|
|
||||||
"json",
|
|
||||||
media_path,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
streams = json.loads(streams_raw.stdout.decode())
|
|
||||||
|
|
||||||
return streams
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def dump_mp4_thumb(media_path, thumb_type):
|
|
||||||
"""save cover to disk"""
|
|
||||||
_, ext = os.path.splitext(media_path)
|
|
||||||
new_path = f"{media_path.rstrip(ext)}.{thumb_type}"
|
|
||||||
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-i",
|
|
||||||
media_path,
|
|
||||||
"-map",
|
|
||||||
"0:v",
|
|
||||||
"-map",
|
|
||||||
"-0:V",
|
|
||||||
"-c",
|
|
||||||
"copy",
|
|
||||||
new_path,
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return new_path
|
|
||||||
|
|
||||||
def _convert_video(self, current_video):
|
|
||||||
"""convert if needed"""
|
|
||||||
current_path = current_video["media"]
|
|
||||||
base_path, ext = os.path.splitext(current_path)
|
|
||||||
if ext == ".mp4":
|
|
||||||
return
|
|
||||||
|
|
||||||
new_path = base_path + ".mp4"
|
|
||||||
subprocess.run(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-i",
|
|
||||||
current_path,
|
|
||||||
new_path,
|
|
||||||
"-loglevel",
|
|
||||||
"warning",
|
|
||||||
"-stats",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
current_video["media"] = new_path
|
|
||||||
os.remove(current_path)
|
|
||||||
|
|
||||||
|
|
||||||
class ManualImport:
|
|
||||||
"""import single identified video"""
|
|
||||||
|
|
||||||
def __init__(self, current_video, config):
|
|
||||||
self.current_video = current_video
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""run all"""
|
|
||||||
json_data = self.index_metadata()
|
|
||||||
self._move_to_archive(json_data)
|
|
||||||
self._cleanup(json_data)
|
|
||||||
|
|
||||||
def index_metadata(self):
|
|
||||||
"""get metadata from yt or json"""
|
|
||||||
video_id = self.current_video["video_id"]
|
|
||||||
video = YoutubeVideo(video_id)
|
|
||||||
video.build_json(
|
|
||||||
youtube_meta_overwrite=self._get_info_json(),
|
|
||||||
media_path=self.current_video["media"],
|
|
||||||
)
|
|
||||||
if not video.json_data:
|
|
||||||
print(f"{video_id}: manual import failed, and no metadata found.")
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
video.check_subtitles(subtitle_files=self.current_video["subtitle"])
|
|
||||||
video.upload_to_es()
|
|
||||||
|
|
||||||
if video.offline_import and self.current_video["thumb"]:
|
|
||||||
old_path = self.current_video["thumb"]
|
|
||||||
thumbs = ThumbManager(video_id)
|
|
||||||
new_path = thumbs.vid_thumb_path(absolute=True, create_folder=True)
|
|
||||||
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
|
||||||
else:
|
|
||||||
url = video.json_data["vid_thumb_url"]
|
|
||||||
ThumbManager(video_id).download_video_thumb(url)
|
|
||||||
|
|
||||||
return video.json_data
|
|
||||||
|
|
||||||
def _get_info_json(self):
|
|
||||||
"""read info_json from file"""
|
|
||||||
if not self.current_video["metadata"]:
|
|
||||||
return False
|
|
||||||
|
|
||||||
with open(self.current_video["metadata"], "r", encoding="utf-8") as f:
|
|
||||||
info_json = json.loads(f.read())
|
|
||||||
|
|
||||||
return info_json
|
|
||||||
|
|
||||||
def _move_to_archive(self, json_data):
|
|
||||||
"""move identified media file to archive"""
|
|
||||||
videos = self.config["application"]["videos"]
|
|
||||||
|
|
||||||
channel, file = os.path.split(json_data["media_url"])
|
|
||||||
channel_folder = os.path.join(videos, channel)
|
|
||||||
if not os.path.exists(channel_folder):
|
|
||||||
os.makedirs(channel_folder)
|
|
||||||
|
|
||||||
old_path = self.current_video["media"]
|
|
||||||
new_path = os.path.join(channel_folder, file)
|
|
||||||
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
|
||||||
|
|
||||||
base_name, _ = os.path.splitext(new_path)
|
|
||||||
for old_path in self.current_video["subtitle"]:
|
|
||||||
lang = old_path.split(".")[-2]
|
|
||||||
new_path = f"{base_name}.{lang}.vtt"
|
|
||||||
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
|
||||||
|
|
||||||
def _cleanup(self, json_data):
|
|
||||||
"""cleanup leftover files"""
|
|
||||||
meta_data = self.current_video["metadata"]
|
|
||||||
if meta_data and os.path.exists(meta_data):
|
|
||||||
os.remove(meta_data)
|
|
||||||
|
|
||||||
thumb = self.current_video["thumb"]
|
|
||||||
if thumb and os.path.exists(thumb):
|
|
||||||
os.remove(thumb)
|
|
||||||
|
|
||||||
for subtitle_file in self.current_video["subtitle"]:
|
|
||||||
if os.path.exists(subtitle_file):
|
|
||||||
os.remove(subtitle_file)
|
|
||||||
|
|
||||||
channel_info = os.path.join(
|
|
||||||
self.config["application"]["cache_dir"],
|
|
||||||
"import",
|
|
||||||
f"{json_data['channel']['channel_id']}.info.json",
|
|
||||||
)
|
|
||||||
if os.path.exists(channel_info):
|
|
||||||
os.remove(channel_info)
|
|
||||||
|
|
||||||
|
|
||||||
def scan_filesystem():
|
|
||||||
"""grouped function to delete and update index"""
|
|
||||||
filesystem_handler = FilesystemScanner()
|
|
||||||
filesystem_handler.list_comarison()
|
|
||||||
if filesystem_handler.to_rename:
|
|
||||||
print("renaming files")
|
|
||||||
filesystem_handler.rename_files()
|
|
||||||
if filesystem_handler.mismatch:
|
|
||||||
print("fixing media urls in index")
|
|
||||||
filesystem_handler.send_mismatch_bulk()
|
|
||||||
if filesystem_handler.to_delete:
|
|
||||||
print("delete metadata from index")
|
|
||||||
filesystem_handler.delete_from_index()
|
|
||||||
if filesystem_handler.to_index:
|
|
||||||
print("index new videos")
|
|
||||||
for missing_vid in filesystem_handler.to_index:
|
|
||||||
youtube_id = missing_vid[2]
|
|
||||||
index_new_video(youtube_id)
|
index_new_video(youtube_id)
|
||||||
|
|
||||||
|
CommentList(video_ids, task=self.task).index()
|
||||||
def reindex_old_documents():
|
|
||||||
"""daily refresh of old documents"""
|
|
||||||
handler = Reindex()
|
|
||||||
handler.check_outdated()
|
|
||||||
handler.reindex()
|
|
||||||
RedisArchivist().set_message("last_reindex", handler.now)
|
|
||||||
|
|||||||
477
tubearchivist/home/src/index/manual.py
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
"""
|
||||||
|
Functionality:
|
||||||
|
- Handle manual import task
|
||||||
|
- Scan and identify media files in import folder
|
||||||
|
- Process import media files
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from home.src.download.thumbnails import ThumbManager
|
||||||
|
from home.src.index.comments import CommentList
|
||||||
|
from home.src.index.video import YoutubeVideo
|
||||||
|
from home.src.ta.config import AppConfig
|
||||||
|
from home.src.ta.helper import ignore_filelist
|
||||||
|
from PIL import Image
|
||||||
|
from yt_dlp.utils import ISO639Utils
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFolderScanner:
|
||||||
|
"""import and indexing existing video files
|
||||||
|
- identify all media files belonging to a video
|
||||||
|
- identify youtube id
|
||||||
|
- convert if needed
|
||||||
|
"""
|
||||||
|
|
||||||
|
CONFIG = AppConfig().config
|
||||||
|
CACHE_DIR = CONFIG["application"]["cache_dir"]
|
||||||
|
IMPORT_DIR = os.path.join(CACHE_DIR, "import")
|
||||||
|
|
||||||
|
EXT_MAP = {
|
||||||
|
"media": [".mp4", ".mkv", ".webm"],
|
||||||
|
"metadata": [".json"],
|
||||||
|
"thumb": [".jpg", ".png", ".webp"],
|
||||||
|
"subtitle": [".vtt"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, task=False):
|
||||||
|
self.task = task
|
||||||
|
self.to_import = False
|
||||||
|
|
||||||
|
def scan(self):
|
||||||
|
"""scan and match media files"""
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress(["Scanning your import folder."])
|
||||||
|
|
||||||
|
all_files = self.get_all_files()
|
||||||
|
self.match_files(all_files)
|
||||||
|
self.process_videos()
|
||||||
|
|
||||||
|
return self.to_import
|
||||||
|
|
||||||
|
def get_all_files(self):
|
||||||
|
"""get all files in /import"""
|
||||||
|
rel_paths = ignore_filelist(os.listdir(self.IMPORT_DIR))
|
||||||
|
all_files = [os.path.join(self.IMPORT_DIR, i) for i in rel_paths]
|
||||||
|
all_files.sort()
|
||||||
|
|
||||||
|
return all_files
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_template():
|
||||||
|
"""base dict for video"""
|
||||||
|
return {
|
||||||
|
"media": False,
|
||||||
|
"video_id": False,
|
||||||
|
"metadata": False,
|
||||||
|
"thumb": False,
|
||||||
|
"subtitle": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def match_files(self, all_files):
|
||||||
|
"""loop through all files, join what matches"""
|
||||||
|
self.to_import = []
|
||||||
|
|
||||||
|
current_video = self._get_template()
|
||||||
|
last_base = False
|
||||||
|
|
||||||
|
for file_path in all_files:
|
||||||
|
base_name, ext = self._detect_base_name(file_path)
|
||||||
|
key, file_path = self._detect_type(file_path, ext)
|
||||||
|
if not key or not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if base_name != last_base:
|
||||||
|
if last_base:
|
||||||
|
print(f"manual import: {current_video}")
|
||||||
|
self.to_import.append(current_video)
|
||||||
|
|
||||||
|
current_video = self._get_template()
|
||||||
|
last_base = base_name
|
||||||
|
|
||||||
|
if key == "subtitle":
|
||||||
|
current_video["subtitle"].append(file_path)
|
||||||
|
else:
|
||||||
|
current_video[key] = file_path
|
||||||
|
|
||||||
|
if current_video.get("media"):
|
||||||
|
print(f"manual import: {current_video}")
|
||||||
|
self.to_import.append(current_video)
|
||||||
|
|
||||||
|
def _detect_base_name(self, file_path):
|
||||||
|
"""extract base_name and ext for matching"""
|
||||||
|
base_name_raw, ext = os.path.splitext(file_path)
|
||||||
|
base_name, ext2 = os.path.splitext(base_name_raw)
|
||||||
|
|
||||||
|
if ext2:
|
||||||
|
if ISO639Utils.short2long(ext2.strip(".")) or ext2 == ".info":
|
||||||
|
# valid secondary extension
|
||||||
|
return base_name, ext
|
||||||
|
|
||||||
|
return base_name_raw, ext
|
||||||
|
|
||||||
|
def _detect_type(self, file_path, ext):
|
||||||
|
"""detect metadata type for file"""
|
||||||
|
|
||||||
|
for key, value in self.EXT_MAP.items():
|
||||||
|
if ext in value:
|
||||||
|
return key, file_path
|
||||||
|
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
def process_videos(self):
|
||||||
|
"""loop through all videos"""
|
||||||
|
for idx, current_video in enumerate(self.to_import):
|
||||||
|
if not current_video["media"]:
|
||||||
|
print(f"{current_video}: no matching media file found.")
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
if self.task:
|
||||||
|
self._notify(idx, current_video)
|
||||||
|
|
||||||
|
self._detect_youtube_id(current_video)
|
||||||
|
self._dump_thumb(current_video)
|
||||||
|
self._convert_thumb(current_video)
|
||||||
|
self._get_subtitles(current_video)
|
||||||
|
self._convert_video(current_video)
|
||||||
|
print(f"manual import: {current_video}")
|
||||||
|
|
||||||
|
ManualImport(current_video, self.CONFIG).run()
|
||||||
|
|
||||||
|
video_ids = [i["video_id"] for i in self.to_import]
|
||||||
|
CommentList(video_ids, task=self.task).index()
|
||||||
|
|
||||||
|
def _notify(self, idx, current_video):
|
||||||
|
"""send notification back to task"""
|
||||||
|
filename = os.path.split(current_video["media"])[-1]
|
||||||
|
if len(filename) > 50:
|
||||||
|
filename = filename[:50] + "..."
|
||||||
|
|
||||||
|
message = [
|
||||||
|
f"Import queue processing video {idx + 1}/{len(self.to_import)}",
|
||||||
|
filename,
|
||||||
|
]
|
||||||
|
progress = (idx + 1) / len(self.to_import)
|
||||||
|
self.task.send_progress(message, progress=progress)
|
||||||
|
|
||||||
|
def _detect_youtube_id(self, current_video):
|
||||||
|
"""find video id from filename or json"""
|
||||||
|
youtube_id = self._extract_id_from_filename(current_video["media"])
|
||||||
|
if youtube_id:
|
||||||
|
current_video["video_id"] = youtube_id
|
||||||
|
return
|
||||||
|
|
||||||
|
youtube_id = self._extract_id_from_json(current_video["metadata"])
|
||||||
|
if youtube_id:
|
||||||
|
current_video["video_id"] = youtube_id
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError("failed to find video id")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_id_from_filename(file_name):
|
||||||
|
"""
|
||||||
|
look at the file name for the youtube id
|
||||||
|
expects filename ending in [<youtube_id>].<ext>
|
||||||
|
"""
|
||||||
|
base_name, _ = os.path.splitext(file_name)
|
||||||
|
id_search = re.search(r"\[([a-zA-Z0-9_-]{11})\]$", base_name)
|
||||||
|
if id_search:
|
||||||
|
youtube_id = id_search.group(1)
|
||||||
|
return youtube_id
|
||||||
|
|
||||||
|
print(f"id extraction failed from filename: {file_name}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _extract_id_from_json(self, json_file):
|
||||||
|
"""open json file and extract id"""
|
||||||
|
json_path = os.path.join(self.CACHE_DIR, "import", json_file)
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
json_content = f.read()
|
||||||
|
|
||||||
|
youtube_id = json.loads(json_content)["id"]
|
||||||
|
|
||||||
|
return youtube_id
|
||||||
|
|
||||||
|
def _dump_thumb(self, current_video):
|
||||||
|
"""extract embedded thumb before converting"""
|
||||||
|
if current_video["thumb"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
media_path = current_video["media"]
|
||||||
|
_, ext = os.path.splitext(media_path)
|
||||||
|
|
||||||
|
new_path = False
|
||||||
|
if ext == ".mkv":
|
||||||
|
idx, thumb_type = self._get_mkv_thumb_stream(media_path)
|
||||||
|
if idx is not None:
|
||||||
|
new_path = self.dump_mpv_thumb(media_path, idx, thumb_type)
|
||||||
|
|
||||||
|
elif ext == ".mp4":
|
||||||
|
thumb_type = self.get_mp4_thumb_type(media_path)
|
||||||
|
if thumb_type:
|
||||||
|
new_path = self.dump_mp4_thumb(media_path, thumb_type)
|
||||||
|
|
||||||
|
if new_path:
|
||||||
|
current_video["thumb"] = new_path
|
||||||
|
|
||||||
|
def _get_mkv_thumb_stream(self, media_path):
|
||||||
|
"""get stream idx of thumbnail for mkv files"""
|
||||||
|
streams = self._get_streams(media_path)
|
||||||
|
attachments = [
|
||||||
|
i for i in streams["streams"] if i["codec_type"] == "attachment"
|
||||||
|
]
|
||||||
|
|
||||||
|
for idx, stream in enumerate(attachments):
|
||||||
|
tags = stream["tags"]
|
||||||
|
if "mimetype" in tags and tags["filename"].startswith("cover"):
|
||||||
|
_, ext = os.path.splitext(tags["filename"])
|
||||||
|
return idx, ext
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dump_mpv_thumb(media_path, idx, thumb_type):
|
||||||
|
"""write cover to disk for mkv"""
|
||||||
|
_, media_ext = os.path.splitext(media_path)
|
||||||
|
new_path = f"{media_path.rstrip(media_ext)}{thumb_type}"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-v",
|
||||||
|
"quiet",
|
||||||
|
f"-dump_attachment:t:{idx}",
|
||||||
|
new_path,
|
||||||
|
"-i",
|
||||||
|
media_path,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_path
|
||||||
|
|
||||||
|
def get_mp4_thumb_type(self, media_path):
|
||||||
|
"""detect filetype of embedded thumbnail"""
|
||||||
|
streams = self._get_streams(media_path)
|
||||||
|
|
||||||
|
for stream in streams["streams"]:
|
||||||
|
if stream["codec_name"] in ["png", "jpg"]:
|
||||||
|
return stream["codec_name"]
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _convert_thumb(self, current_video):
|
||||||
|
"""convert all thumbnails to jpg"""
|
||||||
|
if not current_video["thumb"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
thumb_path = current_video["thumb"]
|
||||||
|
|
||||||
|
base_path, ext = os.path.splitext(thumb_path)
|
||||||
|
if ext == ".jpg":
|
||||||
|
return
|
||||||
|
|
||||||
|
new_path = f"{base_path}.jpg"
|
||||||
|
img_raw = Image.open(thumb_path)
|
||||||
|
img_raw.convert("RGB").save(new_path)
|
||||||
|
|
||||||
|
os.remove(thumb_path)
|
||||||
|
current_video["thumb"] = new_path
|
||||||
|
|
||||||
|
def _get_subtitles(self, current_video):
|
||||||
|
"""find all subtitles in media file"""
|
||||||
|
if current_video["subtitle"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
media_path = current_video["media"]
|
||||||
|
streams = self._get_streams(media_path)
|
||||||
|
base_path, ext = os.path.splitext(media_path)
|
||||||
|
|
||||||
|
if ext == ".webm":
|
||||||
|
print(f"{media_path}: subtitle extract from webm not supported")
|
||||||
|
return
|
||||||
|
|
||||||
|
for idx, stream in enumerate(streams["streams"]):
|
||||||
|
if stream["codec_type"] == "subtitle":
|
||||||
|
lang = ISO639Utils.long2short(stream["tags"]["language"])
|
||||||
|
sub_path = f"{base_path}.{lang}.vtt"
|
||||||
|
self._dump_subtitle(idx, media_path, sub_path)
|
||||||
|
current_video["subtitle"].append(sub_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dump_subtitle(idx, media_path, sub_path):
|
||||||
|
"""extract subtitle from media file"""
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-i", media_path, "-map", f"0:{idx}", sub_path],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_streams(media_path):
|
||||||
|
"""return all streams from media_path"""
|
||||||
|
streams_raw = subprocess.run(
|
||||||
|
[
|
||||||
|
"ffprobe",
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-show_streams",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
|
media_path,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
streams = json.loads(streams_raw.stdout.decode())
|
||||||
|
|
||||||
|
return streams
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dump_mp4_thumb(media_path, thumb_type):
|
||||||
|
"""save cover to disk"""
|
||||||
|
_, ext = os.path.splitext(media_path)
|
||||||
|
new_path = f"{media_path.rstrip(ext)}.{thumb_type}"
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-i",
|
||||||
|
media_path,
|
||||||
|
"-map",
|
||||||
|
"0:v",
|
||||||
|
"-map",
|
||||||
|
"-0:V",
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
new_path,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_path
|
||||||
|
|
||||||
|
def _convert_video(self, current_video):
|
||||||
|
"""convert if needed"""
|
||||||
|
current_path = current_video["media"]
|
||||||
|
base_path, ext = os.path.splitext(current_path)
|
||||||
|
if ext == ".mp4":
|
||||||
|
return
|
||||||
|
|
||||||
|
new_path = base_path + ".mp4"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-i",
|
||||||
|
current_path,
|
||||||
|
new_path,
|
||||||
|
"-loglevel",
|
||||||
|
"warning",
|
||||||
|
"-stats",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
current_video["media"] = new_path
|
||||||
|
os.remove(current_path)
|
||||||
|
|
||||||
|
|
||||||
|
class ManualImport:
|
||||||
|
"""import single identified video"""
|
||||||
|
|
||||||
|
def __init__(self, current_video, config):
|
||||||
|
self.current_video = current_video
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""run all"""
|
||||||
|
json_data = self.index_metadata()
|
||||||
|
self._move_to_archive(json_data)
|
||||||
|
self._cleanup(json_data)
|
||||||
|
|
||||||
|
def index_metadata(self):
|
||||||
|
"""get metadata from yt or json"""
|
||||||
|
video_id = self.current_video["video_id"]
|
||||||
|
video = YoutubeVideo(video_id)
|
||||||
|
video.build_json(
|
||||||
|
youtube_meta_overwrite=self._get_info_json(),
|
||||||
|
media_path=self.current_video["media"],
|
||||||
|
)
|
||||||
|
if not video.json_data:
|
||||||
|
print(f"{video_id}: manual import failed, and no metadata found.")
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
video.check_subtitles(subtitle_files=self.current_video["subtitle"])
|
||||||
|
video.upload_to_es()
|
||||||
|
|
||||||
|
if video.offline_import and self.current_video["thumb"]:
|
||||||
|
old_path = self.current_video["thumb"]
|
||||||
|
thumbs = ThumbManager(video_id)
|
||||||
|
new_path = thumbs.vid_thumb_path(absolute=True, create_folder=True)
|
||||||
|
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
||||||
|
else:
|
||||||
|
url = video.json_data["vid_thumb_url"]
|
||||||
|
ThumbManager(video_id).download_video_thumb(url)
|
||||||
|
|
||||||
|
return video.json_data
|
||||||
|
|
||||||
|
def _get_info_json(self):
|
||||||
|
"""read info_json from file"""
|
||||||
|
if not self.current_video["metadata"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(self.current_video["metadata"], "r", encoding="utf-8") as f:
|
||||||
|
info_json = json.loads(f.read())
|
||||||
|
|
||||||
|
return info_json
|
||||||
|
|
||||||
|
def _move_to_archive(self, json_data):
|
||||||
|
"""move identified media file to archive"""
|
||||||
|
videos = self.config["application"]["videos"]
|
||||||
|
host_uid = self.config["application"]["HOST_UID"]
|
||||||
|
host_gid = self.config["application"]["HOST_GID"]
|
||||||
|
|
||||||
|
channel, file = os.path.split(json_data["media_url"])
|
||||||
|
channel_folder = os.path.join(videos, channel)
|
||||||
|
if not os.path.exists(channel_folder):
|
||||||
|
os.makedirs(channel_folder)
|
||||||
|
|
||||||
|
if host_uid and host_gid:
|
||||||
|
os.chown(channel_folder, host_uid, host_gid)
|
||||||
|
|
||||||
|
old_path = self.current_video["media"]
|
||||||
|
new_path = os.path.join(channel_folder, file)
|
||||||
|
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
||||||
|
if host_uid and host_gid:
|
||||||
|
os.chown(new_path, host_uid, host_gid)
|
||||||
|
|
||||||
|
base_name, _ = os.path.splitext(new_path)
|
||||||
|
for old_path in self.current_video["subtitle"]:
|
||||||
|
lang = old_path.split(".")[-2]
|
||||||
|
new_path = f"{base_name}.{lang}.vtt"
|
||||||
|
shutil.move(old_path, new_path, copy_function=shutil.copyfile)
|
||||||
|
|
||||||
|
def _cleanup(self, json_data):
|
||||||
|
"""cleanup leftover files"""
|
||||||
|
meta_data = self.current_video["metadata"]
|
||||||
|
if meta_data and os.path.exists(meta_data):
|
||||||
|
os.remove(meta_data)
|
||||||
|
|
||||||
|
thumb = self.current_video["thumb"]
|
||||||
|
if thumb and os.path.exists(thumb):
|
||||||
|
os.remove(thumb)
|
||||||
|
|
||||||
|
for subtitle_file in self.current_video["subtitle"]:
|
||||||
|
if os.path.exists(subtitle_file):
|
||||||
|
os.remove(subtitle_file)
|
||||||
|
|
||||||
|
channel_info = os.path.join(
|
||||||
|
self.config["application"]["cache_dir"],
|
||||||
|
"import",
|
||||||
|
f"{json_data['channel']['channel_id']}.info.json",
|
||||||
|
)
|
||||||
|
if os.path.exists(channel_info):
|
||||||
|
os.remove(channel_info)
|
||||||
@@ -66,7 +66,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
"playlist_channel_id": self.youtube_meta["channel_id"],
|
"playlist_channel_id": self.youtube_meta["channel_id"],
|
||||||
"playlist_thumbnail": playlist_thumbnail,
|
"playlist_thumbnail": playlist_thumbnail,
|
||||||
"playlist_description": self.youtube_meta["description"] or False,
|
"playlist_description": self.youtube_meta["description"] or False,
|
||||||
"playlist_last_refresh": int(datetime.now().strftime("%s")),
|
"playlist_last_refresh": int(datetime.now().timestamp()),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_entries(self, playlistend=False):
|
def get_entries(self, playlistend=False):
|
||||||
|
|||||||
@@ -4,161 +4,291 @@ functionality:
|
|||||||
- index and update in es
|
- index and update in es
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from math import ceil
|
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from home.src.download.queue import PendingList
|
from home.src.download.queue import PendingList
|
||||||
|
from home.src.download.subscriptions import ChannelSubscription
|
||||||
from home.src.download.thumbnails import ThumbManager
|
from home.src.download.thumbnails import ThumbManager
|
||||||
from home.src.download.yt_dlp_base import CookieHandler
|
from home.src.download.yt_dlp_base import CookieHandler
|
||||||
from home.src.download.yt_dlp_handler import VideoDownloader
|
from home.src.download.yt_dlp_handler import VideoDownloader
|
||||||
from home.src.es.connect import ElasticWrap
|
from home.src.es.connect import ElasticWrap, IndexPaginate
|
||||||
from home.src.index.channel import YoutubeChannel
|
from home.src.index.channel import YoutubeChannel
|
||||||
from home.src.index.comments import Comments
|
from home.src.index.comments import Comments
|
||||||
from home.src.index.playlist import YoutubePlaylist
|
from home.src.index.playlist import YoutubePlaylist
|
||||||
from home.src.index.video import YoutubeVideo
|
from home.src.index.video import YoutubeVideo
|
||||||
from home.src.ta.config import AppConfig
|
from home.src.ta.config import AppConfig
|
||||||
|
from home.src.ta.ta_redis import RedisQueue
|
||||||
|
|
||||||
|
|
||||||
class Reindex:
|
class ReindexBase:
|
||||||
"""check for outdated documents and refresh data from youtube"""
|
"""base config class for reindex task"""
|
||||||
|
|
||||||
MATCH_FIELD = {
|
REINDEX_CONFIG = {
|
||||||
"ta_video": "active",
|
"video": {
|
||||||
"ta_channel": "channel_active",
|
"index_name": "ta_video",
|
||||||
"ta_playlist": "playlist_active",
|
"queue_name": "reindex:ta_video",
|
||||||
|
"active_key": "active",
|
||||||
|
"refresh_key": "vid_last_refresh",
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"index_name": "ta_channel",
|
||||||
|
"queue_name": "reindex:ta_channel",
|
||||||
|
"active_key": "channel_active",
|
||||||
|
"refresh_key": "channel_last_refresh",
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"index_name": "ta_playlist",
|
||||||
|
"queue_name": "reindex:ta_playlist",
|
||||||
|
"active_key": "playlist_active",
|
||||||
|
"refresh_key": "playlist_last_refresh",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
MULTIPLY = 1.2
|
MULTIPLY = 1.2
|
||||||
|
DAYS3 = 60 * 60 * 24 * 3
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# config
|
|
||||||
self.now = int(datetime.now().strftime("%s"))
|
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
|
self.now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
def populate(self, all_ids, reindex_config):
|
||||||
|
"""add all to reindex ids to redis queue"""
|
||||||
|
if not all_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
RedisQueue(queue_name=reindex_config["queue_name"]).add_list(all_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class ReindexPopulate(ReindexBase):
|
||||||
|
"""add outdated and recent documents to reindex queue"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
self.interval = self.config["scheduler"]["check_reindex_days"]
|
self.interval = self.config["scheduler"]["check_reindex_days"]
|
||||||
# scan
|
|
||||||
self.all_youtube_ids = False
|
|
||||||
self.all_channel_ids = False
|
|
||||||
self.all_playlist_ids = False
|
|
||||||
|
|
||||||
def check_cookie(self):
|
def add_recent(self):
|
||||||
"""validate cookie if enabled"""
|
"""add recent videos to refresh"""
|
||||||
if self.config["downloads"]["cookie_import"]:
|
gte = datetime.fromtimestamp(self.now - self.DAYS3).date().isoformat()
|
||||||
valid = CookieHandler(self.config).validate()
|
must_list = [
|
||||||
if not valid:
|
{"term": {"active": {"value": True}}},
|
||||||
return
|
{"range": {"published": {"gte": gte}}},
|
||||||
|
]
|
||||||
|
data = {
|
||||||
|
"size": 10000,
|
||||||
|
"query": {"bool": {"must": must_list}},
|
||||||
|
"sort": [{"published": {"order": "desc"}}],
|
||||||
|
}
|
||||||
|
response, _ = ElasticWrap("ta_video/_search").get(data=data)
|
||||||
|
hits = response["hits"]["hits"]
|
||||||
|
if not hits:
|
||||||
|
return
|
||||||
|
|
||||||
def _get_daily(self):
|
all_ids = [i["_source"]["youtube_id"] for i in hits]
|
||||||
"""get daily refresh values"""
|
reindex_config = self.REINDEX_CONFIG.get("video")
|
||||||
total_videos = self._get_total_hits("ta_video")
|
self.populate(all_ids, reindex_config)
|
||||||
video_daily = ceil(total_videos / self.interval * self.MULTIPLY)
|
|
||||||
if video_daily >= 10000:
|
|
||||||
video_daily = 9999
|
|
||||||
|
|
||||||
total_channels = self._get_total_hits("ta_channel")
|
def add_outdated(self):
|
||||||
channel_daily = ceil(total_channels / self.interval * self.MULTIPLY)
|
"""add outdated documents"""
|
||||||
total_playlists = self._get_total_hits("ta_playlist")
|
for reindex_config in self.REINDEX_CONFIG.values():
|
||||||
playlist_daily = ceil(total_playlists / self.interval * self.MULTIPLY)
|
total_hits = self._get_total_hits(reindex_config)
|
||||||
return (video_daily, channel_daily, playlist_daily)
|
daily_should = self._get_daily_should(total_hits)
|
||||||
|
all_ids = self._get_outdated_ids(reindex_config, daily_should)
|
||||||
|
self.populate(all_ids, reindex_config)
|
||||||
|
|
||||||
def _get_total_hits(self, index):
|
@staticmethod
|
||||||
|
def _get_total_hits(reindex_config):
|
||||||
"""get total hits from index"""
|
"""get total hits from index"""
|
||||||
match_field = self.MATCH_FIELD[index]
|
index_name = reindex_config["index_name"]
|
||||||
path = f"{index}/_search?filter_path=hits.total"
|
active_key = reindex_config["active_key"]
|
||||||
data = {"query": {"match": {match_field: True}}}
|
path = f"{index_name}/_search?filter_path=hits.total"
|
||||||
|
data = {"query": {"match": {active_key: True}}}
|
||||||
response, _ = ElasticWrap(path).post(data=data)
|
response, _ = ElasticWrap(path).post(data=data)
|
||||||
total_hits = response["hits"]["total"]["value"]
|
total_hits = response["hits"]["total"]["value"]
|
||||||
return total_hits
|
return total_hits
|
||||||
|
|
||||||
def _get_unrated_vids(self):
|
def _get_daily_should(self, total_hits):
|
||||||
"""get max 200 videos without rating if ryd integration is enabled"""
|
"""calc how many should reindex daily"""
|
||||||
must_not_list = [
|
daily_should = int((total_hits // self.interval + 1) * self.MULTIPLY)
|
||||||
{"exists": {"field": "stats.average_rating"}},
|
if daily_should >= 10000:
|
||||||
{"term": {"active": {"value": False}}},
|
daily_should = 9999
|
||||||
]
|
|
||||||
data = {"size": 200, "query": {"bool": {"must_not": must_not_list}}}
|
|
||||||
response, _ = ElasticWrap("ta_video/_search").get(data=data)
|
|
||||||
|
|
||||||
missing_rating = [i["_id"] for i in response["hits"]["hits"]]
|
return daily_should
|
||||||
self.all_youtube_ids = self.all_youtube_ids + missing_rating
|
|
||||||
|
|
||||||
def _get_outdated_vids(self, size):
|
def _get_outdated_ids(self, reindex_config, daily_should):
|
||||||
"""get daily videos to refresh"""
|
"""get outdated from index_name"""
|
||||||
|
index_name = reindex_config["index_name"]
|
||||||
|
refresh_key = reindex_config["refresh_key"]
|
||||||
now_lte = self.now - self.interval * 24 * 60 * 60
|
now_lte = self.now - self.interval * 24 * 60 * 60
|
||||||
must_list = [
|
must_list = [
|
||||||
{"match": {"active": True}},
|
{"match": {reindex_config["active_key"]: True}},
|
||||||
{"range": {"vid_last_refresh": {"lte": now_lte}}},
|
{"range": {refresh_key: {"lte": now_lte}}},
|
||||||
]
|
]
|
||||||
data = {
|
data = {
|
||||||
"size": size,
|
"size": daily_should,
|
||||||
"query": {"bool": {"must": must_list}},
|
"query": {"bool": {"must": must_list}},
|
||||||
"sort": [{"vid_last_refresh": {"order": "asc"}}],
|
"sort": [{refresh_key: {"order": "asc"}}],
|
||||||
"_source": False,
|
"_source": False,
|
||||||
}
|
}
|
||||||
response, _ = ElasticWrap("ta_video/_search").get(data=data)
|
response, _ = ElasticWrap(f"{index_name}/_search").get(data=data)
|
||||||
|
|
||||||
all_youtube_ids = [i["_id"] for i in response["hits"]["hits"]]
|
all_ids = [i["_id"] for i in response["hits"]["hits"]]
|
||||||
return all_youtube_ids
|
return all_ids
|
||||||
|
|
||||||
def _get_outdated_channels(self, size):
|
|
||||||
"""get daily channels to refresh"""
|
class ReindexManual(ReindexBase):
|
||||||
now_lte = self.now - self.interval * 24 * 60 * 60
|
"""
|
||||||
must_list = [
|
manually add ids to reindex queue from API
|
||||||
{"match": {"channel_active": True}},
|
data_example = {
|
||||||
{"range": {"channel_last_refresh": {"lte": now_lte}}},
|
"video": ["video1", "video2", "video3"],
|
||||||
]
|
"channel": ["channel1", "channel2", "channel3"],
|
||||||
|
"playlist": ["playlist1", "playlist2"],
|
||||||
|
}
|
||||||
|
extract_videos to also reindex all videos of channel/playlist
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, extract_videos=False):
|
||||||
|
super().__init__()
|
||||||
|
self.extract_videos = extract_videos
|
||||||
|
self.data = False
|
||||||
|
|
||||||
|
def extract_data(self, data):
|
||||||
|
"""process data"""
|
||||||
|
self.data = data
|
||||||
|
for key, values in self.data.items():
|
||||||
|
reindex_config = self.REINDEX_CONFIG.get(key)
|
||||||
|
if not reindex_config:
|
||||||
|
print(f"reindex type {key} not valid")
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
self.process_index(reindex_config, values)
|
||||||
|
|
||||||
|
def process_index(self, index_config, values):
|
||||||
|
"""process values per index"""
|
||||||
|
index_name = index_config["index_name"]
|
||||||
|
if index_name == "ta_video":
|
||||||
|
self._add_videos(values)
|
||||||
|
elif index_name == "ta_channel":
|
||||||
|
self._add_channels(values)
|
||||||
|
elif index_name == "ta_playlist":
|
||||||
|
self._add_playlists(values)
|
||||||
|
|
||||||
|
def _add_videos(self, values):
|
||||||
|
"""add list of videos to reindex queue"""
|
||||||
|
if not values:
|
||||||
|
return
|
||||||
|
|
||||||
|
RedisQueue("reindex:ta_video").add_list(values)
|
||||||
|
|
||||||
|
def _add_channels(self, values):
|
||||||
|
"""add list of channels to reindex queue"""
|
||||||
|
RedisQueue("reindex:ta_channel").add_list(values)
|
||||||
|
|
||||||
|
if self.extract_videos:
|
||||||
|
for channel_id in values:
|
||||||
|
all_videos = self._get_channel_videos(channel_id)
|
||||||
|
self._add_videos(all_videos)
|
||||||
|
|
||||||
|
def _add_playlists(self, values):
|
||||||
|
"""add list of playlists to reindex queue"""
|
||||||
|
RedisQueue("reindex:ta_playlist").add_list(values)
|
||||||
|
|
||||||
|
if self.extract_videos:
|
||||||
|
for playlist_id in values:
|
||||||
|
all_videos = self._get_playlist_videos(playlist_id)
|
||||||
|
self._add_videos(all_videos)
|
||||||
|
|
||||||
|
def _get_channel_videos(self, channel_id):
|
||||||
|
"""get all videos from channel"""
|
||||||
data = {
|
data = {
|
||||||
"size": size,
|
"query": {"term": {"channel.channel_id": {"value": channel_id}}},
|
||||||
"query": {"bool": {"must": must_list}},
|
"_source": ["youtube_id"],
|
||||||
"sort": [{"channel_last_refresh": {"order": "asc"}}],
|
|
||||||
"_source": False,
|
|
||||||
}
|
}
|
||||||
response, _ = ElasticWrap("ta_channel/_search").get(data=data)
|
all_results = IndexPaginate("ta_video", data).get_results()
|
||||||
|
return [i["youtube_id"] for i in all_results]
|
||||||
|
|
||||||
all_channel_ids = [i["_id"] for i in response["hits"]["hits"]]
|
def _get_playlist_videos(self, playlist_id):
|
||||||
return all_channel_ids
|
"""get all videos from playlist"""
|
||||||
|
|
||||||
def _get_outdated_playlists(self, size):
|
|
||||||
"""get daily outdated playlists to refresh"""
|
|
||||||
now_lte = self.now - self.interval * 24 * 60 * 60
|
|
||||||
must_list = [
|
|
||||||
{"match": {"playlist_active": True}},
|
|
||||||
{"range": {"playlist_last_refresh": {"lte": now_lte}}},
|
|
||||||
]
|
|
||||||
data = {
|
data = {
|
||||||
"size": size,
|
"query": {"term": {"playlist.keyword": {"value": playlist_id}}},
|
||||||
"query": {"bool": {"must": must_list}},
|
"_source": ["youtube_id"],
|
||||||
"sort": [{"playlist_last_refresh": {"order": "asc"}}],
|
|
||||||
"_source": False,
|
|
||||||
}
|
}
|
||||||
response, _ = ElasticWrap("ta_playlist/_search").get(data=data)
|
all_results = IndexPaginate("ta_video", data).get_results()
|
||||||
|
return [i["youtube_id"] for i in all_results]
|
||||||
|
|
||||||
all_playlist_ids = [i["_id"] for i in response["hits"]["hits"]]
|
|
||||||
return all_playlist_ids
|
|
||||||
|
|
||||||
def check_outdated(self):
|
class Reindex(ReindexBase):
|
||||||
"""add missing vids and channels"""
|
"""reindex all documents from redis queue"""
|
||||||
video_daily, channel_daily, playlist_daily = self._get_daily()
|
|
||||||
self.all_youtube_ids = self._get_outdated_vids(video_daily)
|
|
||||||
self.all_channel_ids = self._get_outdated_channels(channel_daily)
|
|
||||||
self.all_playlist_ids = self._get_outdated_playlists(playlist_daily)
|
|
||||||
|
|
||||||
integrate_ryd = self.config["downloads"]["integrate_ryd"]
|
def __init__(self, task=False):
|
||||||
if integrate_ryd:
|
super().__init__()
|
||||||
self._get_unrated_vids()
|
self.task = task
|
||||||
|
self.all_indexed_ids = False
|
||||||
|
|
||||||
|
def reindex_all(self):
|
||||||
|
"""reindex all in queue"""
|
||||||
|
if not self.cookie_is_valid():
|
||||||
|
print("[reindex] cookie invalid, exiting...")
|
||||||
|
return
|
||||||
|
|
||||||
|
for name, index_config in self.REINDEX_CONFIG.items():
|
||||||
|
if not RedisQueue(index_config["queue_name"]).has_item():
|
||||||
|
continue
|
||||||
|
|
||||||
|
total = RedisQueue(index_config["queue_name"]).length()
|
||||||
|
while True:
|
||||||
|
has_next = self.reindex_index(name, index_config, total)
|
||||||
|
if not has_next:
|
||||||
|
break
|
||||||
|
|
||||||
|
def reindex_index(self, name, index_config, total):
|
||||||
|
"""reindex all of a single index"""
|
||||||
|
reindex = self.get_reindex_map(index_config["index_name"])
|
||||||
|
youtube_id = RedisQueue(index_config["queue_name"]).get_next()
|
||||||
|
if youtube_id:
|
||||||
|
self._notify(name, index_config, total)
|
||||||
|
reindex(youtube_id)
|
||||||
|
sleep_interval = self.config["downloads"].get("sleep_interval", 0)
|
||||||
|
sleep(sleep_interval)
|
||||||
|
|
||||||
|
return bool(youtube_id)
|
||||||
|
|
||||||
|
def get_reindex_map(self, index_name):
|
||||||
|
"""return def to run for index"""
|
||||||
|
def_map = {
|
||||||
|
"ta_video": self._reindex_single_video,
|
||||||
|
"ta_channel": self._reindex_single_channel,
|
||||||
|
"ta_playlist": self._reindex_single_playlist,
|
||||||
|
}
|
||||||
|
|
||||||
|
return def_map.get(index_name)
|
||||||
|
|
||||||
|
def _notify(self, name, index_config, total):
|
||||||
|
"""send notification back to task"""
|
||||||
|
remaining = RedisQueue(index_config["queue_name"]).length()
|
||||||
|
idx = total - remaining
|
||||||
|
message = [f"Reindexing {name.title()}s {idx}/{total}"]
|
||||||
|
progress = idx / total
|
||||||
|
self.task.send_progress(message, progress=progress)
|
||||||
|
|
||||||
def _reindex_single_video(self, youtube_id):
|
def _reindex_single_video(self, youtube_id):
|
||||||
|
"""wrapper to handle channel name changes"""
|
||||||
|
try:
|
||||||
|
self._reindex_single_video_call(youtube_id)
|
||||||
|
except FileNotFoundError:
|
||||||
|
ChannelUrlFixer(youtube_id, self.config).run()
|
||||||
|
self._reindex_single_video_call(youtube_id)
|
||||||
|
|
||||||
|
def _reindex_single_video_call(self, youtube_id):
|
||||||
"""refresh data for single video"""
|
"""refresh data for single video"""
|
||||||
video = YoutubeVideo(youtube_id)
|
video = YoutubeVideo(youtube_id)
|
||||||
|
|
||||||
# read current state
|
# read current state
|
||||||
video.get_from_es()
|
video.get_from_es()
|
||||||
player = video.json_data["player"]
|
es_meta = video.json_data.copy()
|
||||||
date_downloaded = video.json_data["date_downloaded"]
|
|
||||||
channel_dict = video.json_data["channel"]
|
|
||||||
playlist = video.json_data.get("playlist")
|
|
||||||
subtitles = video.json_data.get("subtitles")
|
|
||||||
|
|
||||||
# get new
|
# get new
|
||||||
video.build_json()
|
video.build_json()
|
||||||
@@ -166,17 +296,21 @@ class Reindex:
|
|||||||
video.deactivate()
|
video.deactivate()
|
||||||
return
|
return
|
||||||
|
|
||||||
video.delete_subtitles(subtitles=subtitles)
|
video.delete_subtitles(subtitles=es_meta.get("subtitles"))
|
||||||
video.check_subtitles()
|
video.check_subtitles()
|
||||||
|
|
||||||
# add back
|
# add back
|
||||||
video.json_data["player"] = player
|
video.json_data["player"] = es_meta.get("player")
|
||||||
video.json_data["date_downloaded"] = date_downloaded
|
video.json_data["date_downloaded"] = es_meta.get("date_downloaded")
|
||||||
video.json_data["channel"] = channel_dict
|
video.json_data["channel"] = es_meta.get("channel")
|
||||||
if playlist:
|
if es_meta.get("playlist"):
|
||||||
video.json_data["playlist"] = playlist
|
video.json_data["playlist"] = es_meta.get("playlist")
|
||||||
|
|
||||||
video.upload_to_es()
|
video.upload_to_es()
|
||||||
|
if es_meta.get("media_url") != video.json_data["media_url"]:
|
||||||
|
self._rename_media_file(
|
||||||
|
es_meta.get("media_url"), video.json_data["media_url"]
|
||||||
|
)
|
||||||
|
|
||||||
thumb_handler = ThumbManager(youtube_id)
|
thumb_handler = ThumbManager(youtube_id)
|
||||||
thumb_handler.delete_video_thumb()
|
thumb_handler.delete_video_thumb()
|
||||||
@@ -186,6 +320,14 @@ class Reindex:
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _rename_media_file(self, media_url_is, media_url_should):
|
||||||
|
"""handle title change"""
|
||||||
|
print(f"[reindex] fix media_url {media_url_is} to {media_url_should}")
|
||||||
|
videos = self.config["application"]["videos"]
|
||||||
|
old_path = os.path.join(videos, media_url_is)
|
||||||
|
new_path = os.path.join(videos, media_url_should)
|
||||||
|
os.rename(old_path, new_path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _reindex_single_channel(channel_id):
|
def _reindex_single_channel(channel_id):
|
||||||
"""refresh channel data and sync to videos"""
|
"""refresh channel data and sync to videos"""
|
||||||
@@ -206,13 +348,15 @@ class Reindex:
|
|||||||
channel.upload_to_es()
|
channel.upload_to_es()
|
||||||
channel.sync_to_videos()
|
channel.sync_to_videos()
|
||||||
|
|
||||||
@staticmethod
|
ChannelFullScan(channel_id).scan()
|
||||||
def _reindex_single_playlist(playlist_id, all_indexed_ids):
|
|
||||||
|
def _reindex_single_playlist(self, playlist_id):
|
||||||
"""refresh playlist data"""
|
"""refresh playlist data"""
|
||||||
|
self._get_all_videos()
|
||||||
playlist = YoutubePlaylist(playlist_id)
|
playlist = YoutubePlaylist(playlist_id)
|
||||||
playlist.get_from_es()
|
playlist.get_from_es()
|
||||||
subscribed = playlist.json_data["playlist_subscribed"]
|
subscribed = playlist.json_data["playlist_subscribed"]
|
||||||
playlist.all_youtube_ids = all_indexed_ids
|
playlist.all_youtube_ids = self.all_indexed_ids
|
||||||
playlist.build_json(scrape=True)
|
playlist.build_json(scrape=True)
|
||||||
if not playlist.json_data:
|
if not playlist.json_data:
|
||||||
playlist.deactivate()
|
playlist.deactivate()
|
||||||
@@ -222,37 +366,98 @@ class Reindex:
|
|||||||
playlist.upload_to_es()
|
playlist.upload_to_es()
|
||||||
return
|
return
|
||||||
|
|
||||||
def reindex(self):
|
def _get_all_videos(self):
|
||||||
"""reindex what's needed"""
|
"""add all videos for playlist index validation"""
|
||||||
sleep_interval = self.config["downloads"]["sleep_interval"]
|
if self.all_indexed_ids:
|
||||||
# videos
|
return
|
||||||
print(f"reindexing {len(self.all_youtube_ids)} videos")
|
|
||||||
for youtube_id in self.all_youtube_ids:
|
handler = PendingList()
|
||||||
try:
|
handler.get_download()
|
||||||
self._reindex_single_video(youtube_id)
|
handler.get_indexed()
|
||||||
except FileNotFoundError:
|
self.all_indexed_ids = [i["youtube_id"] for i in handler.all_videos]
|
||||||
# handle channel name change here
|
|
||||||
ChannelUrlFixer(youtube_id, self.config).run()
|
def cookie_is_valid(self):
|
||||||
self._reindex_single_video(youtube_id)
|
"""return true if cookie is enabled and valid"""
|
||||||
if sleep_interval:
|
if not self.config["downloads"]["cookie_import"]:
|
||||||
sleep(sleep_interval)
|
# is not activated, continue reindex
|
||||||
# channels
|
return True
|
||||||
print(f"reindexing {len(self.all_channel_ids)} channels")
|
|
||||||
for channel_id in self.all_channel_ids:
|
valid = CookieHandler(self.config).validate()
|
||||||
self._reindex_single_channel(channel_id)
|
return valid
|
||||||
if sleep_interval:
|
|
||||||
sleep(sleep_interval)
|
|
||||||
# playlist
|
class ReindexProgress(ReindexBase):
|
||||||
print(f"reindexing {len(self.all_playlist_ids)} playlists")
|
"""
|
||||||
if self.all_playlist_ids:
|
get progress of reindex task
|
||||||
handler = PendingList()
|
request_type: key of self.REINDEX_CONFIG
|
||||||
handler.get_download()
|
request_id: id of request_type
|
||||||
handler.get_indexed()
|
return = {
|
||||||
all_indexed_ids = [i["youtube_id"] for i in handler.all_videos]
|
"state": "running" | "queued" | False
|
||||||
for playlist_id in self.all_playlist_ids:
|
"total_queued": int
|
||||||
self._reindex_single_playlist(playlist_id, all_indexed_ids)
|
"in_queue_name": "queue_name"
|
||||||
if sleep_interval:
|
}
|
||||||
sleep(sleep_interval)
|
"""
|
||||||
|
|
||||||
|
def __init__(self, request_type=False, request_id=False):
|
||||||
|
super().__init__()
|
||||||
|
self.request_type = request_type
|
||||||
|
self.request_id = request_id
|
||||||
|
|
||||||
|
def get_progress(self):
|
||||||
|
"""get progress from task"""
|
||||||
|
queue_name, request_type = self._get_queue_name()
|
||||||
|
total = self._get_total_in_queue(queue_name)
|
||||||
|
|
||||||
|
progress = {
|
||||||
|
"total_queued": total,
|
||||||
|
"type": request_type,
|
||||||
|
}
|
||||||
|
state = self._get_state(total, queue_name)
|
||||||
|
progress.update(state)
|
||||||
|
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def _get_queue_name(self):
|
||||||
|
"""return queue_name, queue_type, raise exception on error"""
|
||||||
|
if not self.request_type:
|
||||||
|
return "all", "all"
|
||||||
|
|
||||||
|
reindex_config = self.REINDEX_CONFIG.get(self.request_type)
|
||||||
|
if not reindex_config:
|
||||||
|
print(f"reindex_config not found: {self.request_type}")
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
return reindex_config["queue_name"], self.request_type
|
||||||
|
|
||||||
|
def _get_total_in_queue(self, queue_name):
|
||||||
|
"""get all items in queue"""
|
||||||
|
total = 0
|
||||||
|
if queue_name == "all":
|
||||||
|
queues = [i["queue_name"] for i in self.REINDEX_CONFIG.values()]
|
||||||
|
for queue in queues:
|
||||||
|
total += len(RedisQueue(queue).get_all())
|
||||||
|
else:
|
||||||
|
total += len(RedisQueue(queue_name).get_all())
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
def _get_state(self, total, queue_name):
|
||||||
|
"""get state based on request_id"""
|
||||||
|
state_dict = {}
|
||||||
|
if self.request_id:
|
||||||
|
state = RedisQueue(queue_name).in_queue(self.request_id)
|
||||||
|
state_dict.update({"id": self.request_id, "state": state})
|
||||||
|
|
||||||
|
return state_dict
|
||||||
|
|
||||||
|
if total:
|
||||||
|
state = "running"
|
||||||
|
else:
|
||||||
|
state = "empty"
|
||||||
|
|
||||||
|
state_dict.update({"state": state})
|
||||||
|
|
||||||
|
return state_dict
|
||||||
|
|
||||||
|
|
||||||
class ChannelUrlFixer:
|
class ChannelUrlFixer:
|
||||||
@@ -312,3 +517,74 @@ class ChannelUrlFixer:
|
|||||||
shutil.move(video_path_is, new_path, copy_function=shutil.copyfile)
|
shutil.move(video_path_is, new_path, copy_function=shutil.copyfile)
|
||||||
VideoDownloader().move_to_archive(self.video.json_data)
|
VideoDownloader().move_to_archive(self.video.json_data)
|
||||||
self.video.update_media_url()
|
self.video.update_media_url()
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelFullScan:
|
||||||
|
"""
|
||||||
|
update from v0.3.0 to v0.3.1
|
||||||
|
full scan of channel to fix vid_type mismatch
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, channel_id):
|
||||||
|
self.channel_id = channel_id
|
||||||
|
self.to_update = False
|
||||||
|
|
||||||
|
def scan(self):
|
||||||
|
"""match local with remote"""
|
||||||
|
print(f"{self.channel_id}: start full scan")
|
||||||
|
all_local_videos = self._get_all_local()
|
||||||
|
all_remote_videos = self._get_all_remote()
|
||||||
|
self.to_update = []
|
||||||
|
for video in all_local_videos:
|
||||||
|
video_id = video["youtube_id"]
|
||||||
|
remote_match = [i for i in all_remote_videos if i[0] == video_id]
|
||||||
|
if not remote_match:
|
||||||
|
print(f"{video_id}: no remote match found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
expected_type = remote_match[0][-1]
|
||||||
|
if video["vid_type"] != expected_type:
|
||||||
|
self.to_update.append(
|
||||||
|
{
|
||||||
|
"video_id": video_id,
|
||||||
|
"vid_type": expected_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def _get_all_remote(self):
|
||||||
|
"""get all channel videos"""
|
||||||
|
sub = ChannelSubscription()
|
||||||
|
all_remote_videos = sub.get_last_youtube_videos(
|
||||||
|
self.channel_id, limit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_remote_videos
|
||||||
|
|
||||||
|
def _get_all_local(self):
|
||||||
|
"""get all local indexed channel_videos"""
|
||||||
|
channel = YoutubeChannel(self.channel_id)
|
||||||
|
all_local_videos = channel.get_channel_videos()
|
||||||
|
|
||||||
|
return all_local_videos
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
"""build bulk query for updates"""
|
||||||
|
if not self.to_update:
|
||||||
|
print(f"{self.channel_id}: nothing to update")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{self.channel_id}: fixing {len(self.to_update)} videos")
|
||||||
|
bulk_list = []
|
||||||
|
for video in self.to_update:
|
||||||
|
action = {
|
||||||
|
"update": {"_id": video.get("video_id"), "_index": "ta_video"}
|
||||||
|
}
|
||||||
|
source = {"doc": {"vid_type": video.get("vid_type")}}
|
||||||
|
bulk_list.append(json.dumps(action))
|
||||||
|
bulk_list.append(json.dumps(source))
|
||||||
|
# add last newline
|
||||||
|
bulk_list.append("\n")
|
||||||
|
data = "\n".join(bulk_list)
|
||||||
|
_, _ = ElasticWrap("_bulk").post(data=data, ndjson=True)
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class YoutubeSubtitle:
|
|||||||
source = subtitle["source"]
|
source = subtitle["source"]
|
||||||
lang = subtitle.get("lang")
|
lang = subtitle.get("lang")
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
subtitle["url"], headers=requests_headers()
|
subtitle["url"], headers=requests_headers(), timeout=30
|
||||||
)
|
)
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
print(f"{self.video.youtube_id}: failed to download subtitle")
|
print(f"{self.video.youtube_id}: failed to download subtitle")
|
||||||
@@ -137,14 +137,18 @@ class YoutubeSubtitle:
|
|||||||
|
|
||||||
return indexed
|
return indexed
|
||||||
|
|
||||||
@staticmethod
|
def _write_subtitle_file(self, dest_path, subtitle_str):
|
||||||
def _write_subtitle_file(dest_path, subtitle_str):
|
|
||||||
"""write subtitle file to disk"""
|
"""write subtitle file to disk"""
|
||||||
# create folder here for first video of channel
|
# create folder here for first video of channel
|
||||||
os.makedirs(os.path.split(dest_path)[0], exist_ok=True)
|
os.makedirs(os.path.split(dest_path)[0], exist_ok=True)
|
||||||
with open(dest_path, "w", encoding="utf-8") as subfile:
|
with open(dest_path, "w", encoding="utf-8") as subfile:
|
||||||
subfile.write(subtitle_str)
|
subfile.write(subtitle_str)
|
||||||
|
|
||||||
|
host_uid = self.video.config["application"]["HOST_UID"]
|
||||||
|
host_gid = self.video.config["application"]["HOST_GID"]
|
||||||
|
if host_uid and host_gid:
|
||||||
|
os.chown(dest_path, host_uid, host_gid)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _index_subtitle(query_str):
|
def _index_subtitle(query_str):
|
||||||
"""send subtitle to es for indexing"""
|
"""send subtitle to es for indexing"""
|
||||||
@@ -285,7 +289,7 @@ class SubtitleParser:
|
|||||||
"title": video.json_data.get("title"),
|
"title": video.json_data.get("title"),
|
||||||
"subtitle_channel": channel.get("channel_name"),
|
"subtitle_channel": channel.get("channel_name"),
|
||||||
"subtitle_channel_id": channel.get("channel_id"),
|
"subtitle_channel_id": channel.get("channel_id"),
|
||||||
"subtitle_last_refresh": int(datetime.now().strftime("%s")),
|
"subtitle_last_refresh": int(datetime.now().timestamp()),
|
||||||
"subtitle_lang": self.lang,
|
"subtitle_lang": self.lang,
|
||||||
"subtitle_source": source,
|
"subtitle_source": source,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from home.src.index import comments as ta_comments
|
|||||||
from home.src.index import playlist as ta_playlist
|
from home.src.index import playlist as ta_playlist
|
||||||
from home.src.index.generic import YouTubeItem
|
from home.src.index.generic import YouTubeItem
|
||||||
from home.src.index.subtitle import YoutubeSubtitle
|
from home.src.index.subtitle import YoutubeSubtitle
|
||||||
|
from home.src.index.video_constants import VideoTypeEnum
|
||||||
from home.src.ta.helper import DurationConverter, clean_string, randomizor
|
from home.src.ta.helper import DurationConverter, clean_string, randomizor
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
from home.src.ta.ta_redis import RedisArchivist
|
||||||
from ryd_client import ryd_client
|
from ryd_client import ryd_client
|
||||||
@@ -28,7 +29,7 @@ class SponsorBlock:
|
|||||||
def __init__(self, user_id=False):
|
def __init__(self, user_id=False):
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
self.user_agent = f"{settings.TA_UPSTREAM} {settings.TA_VERSION}"
|
self.user_agent = f"{settings.TA_UPSTREAM} {settings.TA_VERSION}"
|
||||||
self.last_refresh = int(datetime.now().strftime("%s"))
|
self.last_refresh = int(datetime.now().timestamp())
|
||||||
|
|
||||||
def get_sb_id(self):
|
def get_sb_id(self):
|
||||||
"""get sponsorblock userid or generate if needed"""
|
"""get sponsorblock userid or generate if needed"""
|
||||||
@@ -73,20 +74,14 @@ class SponsorBlock:
|
|||||||
|
|
||||||
def _get_sponsor_dict(self, all_segments):
|
def _get_sponsor_dict(self, all_segments):
|
||||||
"""format and process response"""
|
"""format and process response"""
|
||||||
has_unlocked = False
|
_ = [i.pop("description", None) for i in all_segments]
|
||||||
cleaned_segments = []
|
has_unlocked = not any(i.get("locked") for i in all_segments)
|
||||||
for segment in all_segments:
|
|
||||||
if not segment["locked"]:
|
|
||||||
has_unlocked = True
|
|
||||||
del segment["userID"]
|
|
||||||
del segment["description"]
|
|
||||||
cleaned_segments.append(segment)
|
|
||||||
|
|
||||||
sponsor_dict = {
|
sponsor_dict = {
|
||||||
"last_refresh": self.last_refresh,
|
"last_refresh": self.last_refresh,
|
||||||
"has_unlocked": has_unlocked,
|
"has_unlocked": has_unlocked,
|
||||||
"is_enabled": True,
|
"is_enabled": True,
|
||||||
"segments": cleaned_segments,
|
"segments": all_segments,
|
||||||
}
|
}
|
||||||
return sponsor_dict
|
return sponsor_dict
|
||||||
|
|
||||||
@@ -129,10 +124,16 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||||||
index_name = "ta_video"
|
index_name = "ta_video"
|
||||||
yt_base = "https://www.youtube.com/watch?v="
|
yt_base = "https://www.youtube.com/watch?v="
|
||||||
|
|
||||||
def __init__(self, youtube_id, video_overwrites=False):
|
def __init__(
|
||||||
|
self,
|
||||||
|
youtube_id,
|
||||||
|
video_overwrites=False,
|
||||||
|
video_type=VideoTypeEnum.VIDEOS,
|
||||||
|
):
|
||||||
super().__init__(youtube_id)
|
super().__init__(youtube_id)
|
||||||
self.channel_id = False
|
self.channel_id = False
|
||||||
self.video_overwrites = video_overwrites
|
self.video_overwrites = video_overwrites
|
||||||
|
self.video_type = video_type
|
||||||
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
|
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
|
||||||
self.offline_import = False
|
self.offline_import = False
|
||||||
|
|
||||||
@@ -180,7 +181,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||||||
upload_date = self.youtube_meta["upload_date"]
|
upload_date = self.youtube_meta["upload_date"]
|
||||||
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
|
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
|
||||||
published = upload_date_time.strftime("%Y-%m-%d")
|
published = upload_date_time.strftime("%Y-%m-%d")
|
||||||
last_refresh = int(datetime.now().strftime("%s"))
|
last_refresh = int(datetime.now().timestamp())
|
||||||
# base64_blur = ThumbManager().get_base64_blur(self.youtube_id)
|
# base64_blur = ThumbManager().get_base64_blur(self.youtube_id)
|
||||||
base64_blur = False
|
base64_blur = False
|
||||||
# build json_data basics
|
# build json_data basics
|
||||||
@@ -195,6 +196,8 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||||||
"vid_last_refresh": last_refresh,
|
"vid_last_refresh": last_refresh,
|
||||||
"date_downloaded": last_refresh,
|
"date_downloaded": last_refresh,
|
||||||
"youtube_id": self.youtube_id,
|
"youtube_id": self.youtube_id,
|
||||||
|
# Using .value to make json encodable
|
||||||
|
"vid_type": self.video_type.value,
|
||||||
"active": True,
|
"active": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +295,9 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||||||
"""delete video file, meta data"""
|
"""delete video file, meta data"""
|
||||||
print(f"{self.youtube_id}: delete video")
|
print(f"{self.youtube_id}: delete video")
|
||||||
self.get_from_es()
|
self.get_from_es()
|
||||||
|
if not self.json_data:
|
||||||
|
raise FileNotFoundError
|
||||||
|
|
||||||
video_base = self.app_conf["videos"]
|
video_base = self.app_conf["videos"]
|
||||||
media_url = self.json_data.get("media_url")
|
media_url = self.json_data.get("media_url")
|
||||||
file_path = os.path.join(video_base, media_url)
|
file_path = os.path.join(video_base, media_url)
|
||||||
@@ -399,9 +405,13 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||||||
_, _ = ElasticWrap(path).post(data=data)
|
_, _ = ElasticWrap(path).post(data=data)
|
||||||
|
|
||||||
|
|
||||||
def index_new_video(youtube_id, video_overwrites=False):
|
def index_new_video(
|
||||||
|
youtube_id, video_overwrites=False, video_type=VideoTypeEnum.VIDEOS
|
||||||
|
):
|
||||||
"""combined classes to create new video in index"""
|
"""combined classes to create new video in index"""
|
||||||
video = YoutubeVideo(youtube_id, video_overwrites=video_overwrites)
|
video = YoutubeVideo(
|
||||||
|
youtube_id, video_overwrites=video_overwrites, video_type=video_type
|
||||||
|
)
|
||||||
video.build_json()
|
video.build_json()
|
||||||
if not video.json_data:
|
if not video.json_data:
|
||||||
raise ValueError("failed to get metadata for " + youtube_id)
|
raise ValueError("failed to get metadata for " + youtube_id)
|
||||||
|
|||||||
12
tubearchivist/home/src/index/video_constants.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""video constants"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTypeEnum(enum.Enum):
|
||||||
|
"""all vid_type fields"""
|
||||||
|
|
||||||
|
VIDEOS = "videos"
|
||||||
|
STREAMS = "streams"
|
||||||
|
SHORTS = "shorts"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
@@ -7,8 +7,11 @@ Functionality:
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from random import randint
|
||||||
|
|
||||||
|
import requests
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
from django.conf import settings
|
||||||
from home.src.ta.ta_redis import RedisArchivist
|
from home.src.ta.ta_redis import RedisArchivist
|
||||||
|
|
||||||
|
|
||||||
@@ -47,18 +50,6 @@ class AppConfig:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_config_env():
|
def get_config_env():
|
||||||
"""read environment application variables"""
|
"""read environment application variables"""
|
||||||
host_uid_env = os.environ.get("HOST_UID")
|
|
||||||
if host_uid_env:
|
|
||||||
host_uid = int(host_uid_env)
|
|
||||||
else:
|
|
||||||
host_uid = False
|
|
||||||
|
|
||||||
host_gid_env = os.environ.get("HOST_GID")
|
|
||||||
if host_gid_env:
|
|
||||||
host_gid = int(host_gid_env)
|
|
||||||
else:
|
|
||||||
host_gid = False
|
|
||||||
|
|
||||||
es_pass = os.environ.get("ELASTIC_PASSWORD")
|
es_pass = os.environ.get("ELASTIC_PASSWORD")
|
||||||
es_user = os.environ.get("ELASTIC_USER", default="elastic")
|
es_user = os.environ.get("ELASTIC_USER", default="elastic")
|
||||||
|
|
||||||
@@ -66,8 +57,9 @@ class AppConfig:
|
|||||||
"REDIS_HOST": os.environ.get("REDIS_HOST"),
|
"REDIS_HOST": os.environ.get("REDIS_HOST"),
|
||||||
"es_url": os.environ.get("ES_URL"),
|
"es_url": os.environ.get("ES_URL"),
|
||||||
"es_auth": (es_user, es_pass),
|
"es_auth": (es_user, es_pass),
|
||||||
"HOST_UID": host_uid,
|
"HOST_UID": int(os.environ.get("HOST_UID", False)),
|
||||||
"HOST_GID": host_gid,
|
"HOST_GID": int(os.environ.get("HOST_GID", False)),
|
||||||
|
"enable_cast": bool(os.environ.get("ENABLE_CAST")),
|
||||||
}
|
}
|
||||||
|
|
||||||
return application
|
return application
|
||||||
@@ -126,6 +118,15 @@ class AppConfig:
|
|||||||
self.config["application"]["colors"] = colors
|
self.config["application"]["colors"] = colors
|
||||||
return colors
|
return colors
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_rand_daily():
|
||||||
|
"""build random daily schedule per installation"""
|
||||||
|
return {
|
||||||
|
"minute": randint(0, 59),
|
||||||
|
"hour": randint(0, 23),
|
||||||
|
"day_of_week": "*",
|
||||||
|
}
|
||||||
|
|
||||||
def load_new_defaults(self):
|
def load_new_defaults(self):
|
||||||
"""check config.json for missing defaults"""
|
"""check config.json for missing defaults"""
|
||||||
default_config = self.get_config_file()
|
default_config = self.get_config_file()
|
||||||
@@ -134,8 +135,9 @@ class AppConfig:
|
|||||||
# check for customizations
|
# check for customizations
|
||||||
if not redis_config:
|
if not redis_config:
|
||||||
config = self.get_config()
|
config = self.get_config()
|
||||||
|
config["scheduler"]["version_check"] = self._build_rand_daily()
|
||||||
RedisArchivist().set_message("config", config)
|
RedisArchivist().set_message("config", config)
|
||||||
return
|
return False
|
||||||
|
|
||||||
needs_update = False
|
needs_update = False
|
||||||
|
|
||||||
@@ -149,12 +151,17 @@ class AppConfig:
|
|||||||
# missing nested values
|
# missing nested values
|
||||||
for sub_key, sub_value in value.items():
|
for sub_key, sub_value in value.items():
|
||||||
if sub_key not in redis_config[key].keys():
|
if sub_key not in redis_config[key].keys():
|
||||||
|
if sub_value == "rand-d":
|
||||||
|
sub_value = self._build_rand_daily()
|
||||||
|
|
||||||
redis_config[key].update({sub_key: sub_value})
|
redis_config[key].update({sub_key: sub_value})
|
||||||
needs_update = True
|
needs_update = True
|
||||||
|
|
||||||
if needs_update:
|
if needs_update:
|
||||||
RedisArchivist().set_message("config", redis_config)
|
RedisArchivist().set_message("config", redis_config)
|
||||||
|
|
||||||
|
return needs_update
|
||||||
|
|
||||||
|
|
||||||
class ScheduleBuilder:
|
class ScheduleBuilder:
|
||||||
"""build schedule dicts for beat"""
|
"""build schedule dicts for beat"""
|
||||||
@@ -165,6 +172,7 @@ class ScheduleBuilder:
|
|||||||
"check_reindex": "0 12 *",
|
"check_reindex": "0 12 *",
|
||||||
"thumbnail_check": "0 17 *",
|
"thumbnail_check": "0 17 *",
|
||||||
"run_backup": "0 18 0",
|
"run_backup": "0 18 0",
|
||||||
|
"version_check": "0 11 *",
|
||||||
}
|
}
|
||||||
CONFIG = ["check_reindex_days", "run_backup_rotate"]
|
CONFIG = ["check_reindex_days", "run_backup_rotate"]
|
||||||
MSG = "message:setting"
|
MSG = "message:setting"
|
||||||
@@ -257,6 +265,8 @@ class ScheduleBuilder:
|
|||||||
|
|
||||||
def build_schedule(self):
|
def build_schedule(self):
|
||||||
"""build schedule dict as expected by app.conf.beat_schedule"""
|
"""build schedule dict as expected by app.conf.beat_schedule"""
|
||||||
|
AppConfig().load_new_defaults()
|
||||||
|
self.config = AppConfig().config
|
||||||
schedule_dict = {}
|
schedule_dict = {}
|
||||||
|
|
||||||
for schedule_item in self.SCHEDULES:
|
for schedule_item in self.SCHEDULES:
|
||||||
@@ -264,18 +274,90 @@ class ScheduleBuilder:
|
|||||||
if not item_conf:
|
if not item_conf:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
minute = item_conf["minute"]
|
schedule_dict.update(
|
||||||
hour = item_conf["hour"]
|
{
|
||||||
day_of_week = item_conf["day_of_week"]
|
f"schedule_{schedule_item}": {
|
||||||
schedule_name = f"schedule_{schedule_item}"
|
"task": schedule_item,
|
||||||
to_add = {
|
"schedule": crontab(
|
||||||
schedule_name: {
|
minute=item_conf["minute"],
|
||||||
"task": schedule_item,
|
hour=item_conf["hour"],
|
||||||
"schedule": crontab(
|
day_of_week=item_conf["day_of_week"],
|
||||||
minute=minute, hour=hour, day_of_week=day_of_week
|
),
|
||||||
),
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
schedule_dict.update(to_add)
|
|
||||||
|
|
||||||
return schedule_dict
|
return schedule_dict
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseVersion:
|
||||||
|
"""compare local version with remote version"""
|
||||||
|
|
||||||
|
REMOTE_URL = "https://www.tubearchivist.com/api/release/latest/"
|
||||||
|
NEW_KEY = "versioncheck:new"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.local_version = self._parse_version(settings.TA_VERSION)
|
||||||
|
self.is_unstable = settings.TA_VERSION.endswith("-unstable")
|
||||||
|
self.remote_version = False
|
||||||
|
self.is_breaking = False
|
||||||
|
self.response = False
|
||||||
|
|
||||||
|
def check(self):
|
||||||
|
"""check version"""
|
||||||
|
print(f"[{self.local_version}]: look for updates")
|
||||||
|
self.get_remote_version()
|
||||||
|
new_version, is_breaking = self._has_update()
|
||||||
|
if new_version:
|
||||||
|
message = {
|
||||||
|
"status": True,
|
||||||
|
"version": new_version,
|
||||||
|
"is_breaking": is_breaking,
|
||||||
|
}
|
||||||
|
RedisArchivist().set_message(self.NEW_KEY, message)
|
||||||
|
print(f"[{self.local_version}]: found new version {new_version}")
|
||||||
|
|
||||||
|
def get_remote_version(self):
|
||||||
|
"""read version from remote"""
|
||||||
|
self.response = requests.get(self.REMOTE_URL, timeout=20).json()
|
||||||
|
remote_version_str = self.response["release_version"]
|
||||||
|
self.remote_version = self._parse_version(remote_version_str)
|
||||||
|
self.is_breaking = self.response["breaking_changes"]
|
||||||
|
|
||||||
|
def _has_update(self):
|
||||||
|
"""check if there is an update"""
|
||||||
|
for idx, number in enumerate(self.local_version):
|
||||||
|
is_newer = self.remote_version[idx] > number
|
||||||
|
if is_newer:
|
||||||
|
return self.response["release_version"], self.is_breaking
|
||||||
|
|
||||||
|
if self.is_unstable and self.local_version == self.remote_version:
|
||||||
|
return self.response["release_version"], self.is_breaking
|
||||||
|
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_version(version):
|
||||||
|
"""return version parts"""
|
||||||
|
clean = version.rstrip("-unstable").lstrip("v")
|
||||||
|
return tuple((int(i) for i in clean.split(".")))
|
||||||
|
|
||||||
|
def is_updated(self):
|
||||||
|
"""check if update happened in the mean time"""
|
||||||
|
message = self.get_update()
|
||||||
|
if not message:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._parse_version(message.get("version")) == self.local_version:
|
||||||
|
RedisArchivist().del_message(self.NEW_KEY)
|
||||||
|
return settings.TA_VERSION
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_update(self):
|
||||||
|
"""return new version dict if available"""
|
||||||
|
message = RedisArchivist().get_message(self.NEW_KEY)
|
||||||
|
if not message.get("status"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return message
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import string
|
|||||||
import subprocess
|
import subprocess
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from home.src.download.yt_dlp_base import YtWrap
|
import requests
|
||||||
|
|
||||||
|
|
||||||
def clean_string(file_name):
|
def clean_string(file_name):
|
||||||
@@ -123,10 +123,13 @@ def clear_dl_cache(config):
|
|||||||
"""clear leftover files from dl cache"""
|
"""clear leftover files from dl cache"""
|
||||||
print("clear download cache")
|
print("clear download cache")
|
||||||
cache_dir = os.path.join(config["application"]["cache_dir"], "download")
|
cache_dir = os.path.join(config["application"]["cache_dir"], "download")
|
||||||
for cached in os.listdir(cache_dir):
|
leftover_files = os.listdir(cache_dir)
|
||||||
|
for cached in leftover_files:
|
||||||
to_delete = os.path.join(cache_dir, cached)
|
to_delete = os.path.join(cache_dir, cached)
|
||||||
os.remove(to_delete)
|
os.remove(to_delete)
|
||||||
|
|
||||||
|
return len(leftover_files)
|
||||||
|
|
||||||
|
|
||||||
def get_mapping():
|
def get_mapping():
|
||||||
"""read index_mapping.json and get expected mapping and settings"""
|
"""read index_mapping.json and get expected mapping and settings"""
|
||||||
@@ -136,104 +139,30 @@ def get_mapping():
|
|||||||
return index_config
|
return index_config
|
||||||
|
|
||||||
|
|
||||||
class UrlListParser:
|
def is_shorts(youtube_id):
|
||||||
"""take a multi line string and detect valid youtube ids"""
|
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
|
||||||
|
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
|
||||||
|
response = requests.head(
|
||||||
|
shorts_url, headers=requests_headers(), timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, url_str):
|
return response.status_code == 200
|
||||||
self.url_list = [i.strip() for i in url_str.split()]
|
|
||||||
|
|
||||||
def process_list(self):
|
|
||||||
"""loop through the list"""
|
|
||||||
youtube_ids = []
|
|
||||||
for url in self.url_list:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
print(f"processing: {url}")
|
|
||||||
print(parsed)
|
|
||||||
if not parsed.netloc:
|
|
||||||
# is not a url
|
|
||||||
id_type = self.find_valid_id(url)
|
|
||||||
youtube_id = url
|
|
||||||
elif "youtube.com" not in url and "youtu.be" not in url:
|
|
||||||
raise ValueError(f"{url} is not a youtube link")
|
|
||||||
elif parsed.path:
|
|
||||||
# is a url
|
|
||||||
youtube_id, id_type = self.detect_from_url(parsed)
|
|
||||||
else:
|
|
||||||
# not detected
|
|
||||||
raise ValueError(f"failed to detect {url}")
|
|
||||||
|
|
||||||
youtube_ids.append({"url": youtube_id, "type": id_type})
|
def ta_host_parser(ta_host):
|
||||||
|
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
|
||||||
|
allowed_hosts = []
|
||||||
|
csrf_trusted_origins = []
|
||||||
|
for host in ta_host.split():
|
||||||
|
host_clean = host.strip()
|
||||||
|
if not host_clean.startswith("http"):
|
||||||
|
host_clean = f"http://{host_clean}"
|
||||||
|
|
||||||
return youtube_ids
|
parsed = urlparse(host_clean)
|
||||||
|
allowed_hosts.append(f"{parsed.hostname}")
|
||||||
|
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
|
||||||
|
|
||||||
def detect_from_url(self, parsed):
|
return allowed_hosts, csrf_trusted_origins
|
||||||
"""detect from parsed url"""
|
|
||||||
if parsed.netloc == "youtu.be":
|
|
||||||
# shortened
|
|
||||||
youtube_id = parsed.path.strip("/")
|
|
||||||
_ = self.find_valid_id(youtube_id)
|
|
||||||
return youtube_id, "video"
|
|
||||||
|
|
||||||
if parsed.query:
|
|
||||||
# detect from query string
|
|
||||||
query_parsed = parse_qs(parsed.query)
|
|
||||||
if "v" in query_parsed:
|
|
||||||
youtube_id = query_parsed["v"][0]
|
|
||||||
_ = self.find_valid_id(youtube_id)
|
|
||||||
return youtube_id, "video"
|
|
||||||
|
|
||||||
if "list" in query_parsed:
|
|
||||||
youtube_id = query_parsed["list"][0]
|
|
||||||
return youtube_id, "playlist"
|
|
||||||
|
|
||||||
if parsed.path.startswith("/channel/"):
|
|
||||||
# channel id in url
|
|
||||||
youtube_id = parsed.path.split("/")[2]
|
|
||||||
_ = self.find_valid_id(youtube_id)
|
|
||||||
return youtube_id, "channel"
|
|
||||||
|
|
||||||
# detect channel with yt_dlp
|
|
||||||
youtube_id = self.extract_channel_name(parsed.geturl())
|
|
||||||
return youtube_id, "channel"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def find_valid_id(id_str):
|
|
||||||
"""detect valid id from length of string"""
|
|
||||||
str_len = len(id_str)
|
|
||||||
if str_len == 11:
|
|
||||||
id_type = "video"
|
|
||||||
elif str_len == 24:
|
|
||||||
id_type = "channel"
|
|
||||||
elif str_len in [34, 18] or id_str in ["LL", "WL"]:
|
|
||||||
id_type = "playlist"
|
|
||||||
else:
|
|
||||||
# unable to parse
|
|
||||||
raise ValueError("not a valid id_str: " + id_str)
|
|
||||||
|
|
||||||
return id_type
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def extract_channel_name(url):
|
|
||||||
"""find channel id from channel name with yt-dlp help"""
|
|
||||||
obs_request = {
|
|
||||||
"skip_download": True,
|
|
||||||
"extract_flat": True,
|
|
||||||
"playlistend": 0,
|
|
||||||
}
|
|
||||||
url_info = YtWrap(obs_request).extract(url)
|
|
||||||
channel_id = url_info.get("channel_id", False)
|
|
||||||
if channel_id:
|
|
||||||
return channel_id
|
|
||||||
|
|
||||||
url = url_info.get("url", False)
|
|
||||||
if url:
|
|
||||||
# handle old channel name redirect with url path split
|
|
||||||
channel_id = urlparse(url).path.strip("/").split("/")[1]
|
|
||||||
|
|
||||||
return channel_id
|
|
||||||
|
|
||||||
print(f"failed to extract channel id from {url}")
|
|
||||||
raise ValueError
|
|
||||||
|
|
||||||
|
|
||||||
class DurationConverter:
|
class DurationConverter:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
functionality:
|
functionality:
|
||||||
- interact with redis
|
- interact with redis
|
||||||
- hold temporary download queue in redis
|
- hold temporary download queue in redis
|
||||||
|
- interact with celery tasks results
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -13,9 +14,9 @@ import redis
|
|||||||
class RedisBase:
|
class RedisBase:
|
||||||
"""connection base for redis"""
|
"""connection base for redis"""
|
||||||
|
|
||||||
REDIS_HOST = os.environ.get("REDIS_HOST")
|
REDIS_HOST: str = str(os.environ.get("REDIS_HOST"))
|
||||||
REDIS_PORT = os.environ.get("REDIS_PORT") or 6379
|
REDIS_PORT: int = int(os.environ.get("REDIS_PORT") or 6379)
|
||||||
NAME_SPACE = "ta:"
|
NAME_SPACE: str = "ta:"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.conn = redis.Redis(host=self.REDIS_HOST, port=self.REDIS_PORT)
|
self.conn = redis.Redis(host=self.REDIS_HOST, port=self.REDIS_PORT)
|
||||||
@@ -24,7 +25,7 @@ class RedisBase:
|
|||||||
class RedisArchivist(RedisBase):
|
class RedisArchivist(RedisBase):
|
||||||
"""collection of methods to interact with redis"""
|
"""collection of methods to interact with redis"""
|
||||||
|
|
||||||
CHANNELS = [
|
CHANNELS: list[str] = [
|
||||||
"download",
|
"download",
|
||||||
"add",
|
"add",
|
||||||
"rescan",
|
"rescan",
|
||||||
@@ -34,7 +35,13 @@ class RedisArchivist(RedisBase):
|
|||||||
"setting",
|
"setting",
|
||||||
]
|
]
|
||||||
|
|
||||||
def set_message(self, key, message, path=".", expire=False):
|
def set_message(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
message: dict,
|
||||||
|
path: str = ".",
|
||||||
|
expire: bool | int = False,
|
||||||
|
) -> None:
|
||||||
"""write new message to redis"""
|
"""write new message to redis"""
|
||||||
self.conn.execute_command(
|
self.conn.execute_command(
|
||||||
"JSON.SET", self.NAME_SPACE + key, path, json.dumps(message)
|
"JSON.SET", self.NAME_SPACE + key, path, json.dumps(message)
|
||||||
@@ -42,71 +49,49 @@ class RedisArchivist(RedisBase):
|
|||||||
|
|
||||||
if expire:
|
if expire:
|
||||||
if isinstance(expire, bool):
|
if isinstance(expire, bool):
|
||||||
secs = 20
|
secs: int = 20
|
||||||
else:
|
else:
|
||||||
secs = expire
|
secs = expire
|
||||||
self.conn.execute_command("EXPIRE", self.NAME_SPACE + key, secs)
|
self.conn.execute_command("EXPIRE", self.NAME_SPACE + key, secs)
|
||||||
|
|
||||||
def get_message(self, key):
|
def get_message(self, key: str) -> dict:
|
||||||
"""get message dict from redis"""
|
"""get message dict from redis"""
|
||||||
reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key)
|
reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key)
|
||||||
if reply:
|
if reply:
|
||||||
json_str = json.loads(reply)
|
return json.loads(reply)
|
||||||
else:
|
|
||||||
json_str = {"status": False}
|
|
||||||
|
|
||||||
return json_str
|
return {"status": False}
|
||||||
|
|
||||||
def list_items(self, query):
|
def list_keys(self, query: str) -> list:
|
||||||
"""list all matches"""
|
"""return all key matches"""
|
||||||
reply = self.conn.execute_command(
|
reply = self.conn.execute_command(
|
||||||
"KEYS", self.NAME_SPACE + query + "*"
|
"KEYS", self.NAME_SPACE + query + "*"
|
||||||
)
|
)
|
||||||
all_matches = [i.decode().lstrip(self.NAME_SPACE) for i in reply]
|
if not reply:
|
||||||
all_results = []
|
return []
|
||||||
for match in all_matches:
|
|
||||||
json_str = self.get_message(match)
|
|
||||||
all_results.append(json_str)
|
|
||||||
|
|
||||||
return all_results
|
return [i.decode().lstrip(self.NAME_SPACE) for i in reply]
|
||||||
|
|
||||||
def del_message(self, key):
|
def list_items(self, query: str) -> list:
|
||||||
|
"""list all matches"""
|
||||||
|
all_matches = self.list_keys(query)
|
||||||
|
if not all_matches:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [self.get_message(i) for i in all_matches]
|
||||||
|
|
||||||
|
def del_message(self, key: str) -> bool:
|
||||||
"""delete key from redis"""
|
"""delete key from redis"""
|
||||||
response = self.conn.execute_command("DEL", self.NAME_SPACE + key)
|
response = self.conn.execute_command("DEL", self.NAME_SPACE + key)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def get_lock(self, lock_key):
|
|
||||||
"""handle lock for task management"""
|
|
||||||
redis_lock = self.conn.lock(self.NAME_SPACE + lock_key)
|
|
||||||
return redis_lock
|
|
||||||
|
|
||||||
def is_locked(self, lock_key):
|
|
||||||
"""check if lock is set"""
|
|
||||||
lock_name = self.NAME_SPACE + lock_key
|
|
||||||
lock_status = bool(self.conn.execute_command("GET", lock_name))
|
|
||||||
return lock_status
|
|
||||||
|
|
||||||
def get_progress(self):
|
|
||||||
"""get a list of all progress messages"""
|
|
||||||
all_messages = []
|
|
||||||
for channel in self.CHANNELS:
|
|
||||||
key = "message:" + channel
|
|
||||||
reply = self.conn.execute_command(
|
|
||||||
"JSON.GET", self.NAME_SPACE + key
|
|
||||||
)
|
|
||||||
if reply:
|
|
||||||
json_str = json.loads(reply)
|
|
||||||
all_messages.append(json_str)
|
|
||||||
|
|
||||||
return all_messages
|
|
||||||
|
|
||||||
|
|
||||||
class RedisQueue(RedisBase):
|
class RedisQueue(RedisBase):
|
||||||
"""dynamically interact with the download queue in redis"""
|
"""dynamically interact with queues in redis"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, queue_name: str):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.key = self.NAME_SPACE + "dl_queue"
|
self.key = f"{self.NAME_SPACE}{queue_name}"
|
||||||
|
|
||||||
def get_all(self):
|
def get_all(self):
|
||||||
"""return all elements in list"""
|
"""return all elements in list"""
|
||||||
@@ -114,16 +99,29 @@ class RedisQueue(RedisBase):
|
|||||||
all_elements = [i.decode() for i in result]
|
all_elements = [i.decode() for i in result]
|
||||||
return all_elements
|
return all_elements
|
||||||
|
|
||||||
|
def length(self) -> int:
|
||||||
|
"""return total elements in list"""
|
||||||
|
return self.conn.execute_command("LLEN", self.key)
|
||||||
|
|
||||||
|
def in_queue(self, element) -> str | bool:
|
||||||
|
"""check if element is in list"""
|
||||||
|
result = self.conn.execute_command("LPOS", self.key, element)
|
||||||
|
if result is not None:
|
||||||
|
return "in_queue"
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def add_list(self, to_add):
|
def add_list(self, to_add):
|
||||||
"""add list to queue"""
|
"""add list to queue"""
|
||||||
self.conn.execute_command("RPUSH", self.key, *to_add)
|
self.conn.execute_command("RPUSH", self.key, *to_add)
|
||||||
|
|
||||||
def add_priority(self, to_add):
|
def add_priority(self, to_add: str) -> None:
|
||||||
"""add single video to front of queue"""
|
"""add single video to front of queue"""
|
||||||
self.clear_item(to_add)
|
item: str = json.dumps(to_add)
|
||||||
self.conn.execute_command("LPUSH", self.key, to_add)
|
self.clear_item(item)
|
||||||
|
self.conn.execute_command("LPUSH", self.key, item)
|
||||||
|
|
||||||
def get_next(self):
|
def get_next(self) -> str | bool:
|
||||||
"""return next element in the queue, False if none"""
|
"""return next element in the queue, False if none"""
|
||||||
result = self.conn.execute_command("LPOP", self.key)
|
result = self.conn.execute_command("LPOP", self.key)
|
||||||
if not result:
|
if not result:
|
||||||
@@ -132,19 +130,74 @@ class RedisQueue(RedisBase):
|
|||||||
next_element = result.decode()
|
next_element = result.decode()
|
||||||
return next_element
|
return next_element
|
||||||
|
|
||||||
def clear(self):
|
def clear(self) -> None:
|
||||||
"""delete list from redis"""
|
"""delete list from redis"""
|
||||||
self.conn.execute_command("DEL", self.key)
|
self.conn.execute_command("DEL", self.key)
|
||||||
|
|
||||||
def clear_item(self, to_clear):
|
def clear_item(self, to_clear: str) -> None:
|
||||||
"""remove single item from list if it's there"""
|
"""remove single item from list if it's there"""
|
||||||
self.conn.execute_command("LREM", self.key, 0, to_clear)
|
self.conn.execute_command("LREM", self.key, 0, to_clear)
|
||||||
|
|
||||||
def trim(self, size):
|
def trim(self, size: int) -> None:
|
||||||
"""trim the queue based on settings amount"""
|
"""trim the queue based on settings amount"""
|
||||||
self.conn.execute_command("LTRIM", self.key, 0, size)
|
self.conn.execute_command("LTRIM", self.key, 0, size)
|
||||||
|
|
||||||
def has_item(self):
|
def has_item(self) -> bool:
|
||||||
"""check if queue as at least one pending item"""
|
"""check if queue as at least one pending item"""
|
||||||
result = self.conn.execute_command("LRANGE", self.key, 0, 0)
|
result = self.conn.execute_command("LRANGE", self.key, 0, 0)
|
||||||
return bool(result)
|
return bool(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRedis(RedisBase):
|
||||||
|
"""interact with redis tasks"""
|
||||||
|
|
||||||
|
BASE: str = "celery-task-meta-"
|
||||||
|
EXPIRE: int = 60 * 60 * 24
|
||||||
|
COMMANDS: list[str] = ["STOP", "KILL"]
|
||||||
|
|
||||||
|
def get_all(self) -> list:
|
||||||
|
"""return all tasks"""
|
||||||
|
all_keys = self.conn.execute_command("KEYS", f"{self.BASE}*")
|
||||||
|
return [i.decode().replace(self.BASE, "") for i in all_keys]
|
||||||
|
|
||||||
|
def get_single(self, task_id: str) -> dict:
|
||||||
|
"""return content of single task"""
|
||||||
|
result = self.conn.execute_command("GET", self.BASE + task_id)
|
||||||
|
if not result:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return json.loads(result.decode())
|
||||||
|
|
||||||
|
def set_key(
|
||||||
|
self, task_id: str, message: dict, expire: bool | int = False
|
||||||
|
) -> None:
|
||||||
|
"""set value for lock, initial or update"""
|
||||||
|
key: str = f"{self.BASE}{task_id}"
|
||||||
|
self.conn.execute_command("SET", key, json.dumps(message))
|
||||||
|
|
||||||
|
if expire:
|
||||||
|
self.conn.execute_command("EXPIRE", key, self.EXPIRE)
|
||||||
|
|
||||||
|
def set_command(self, task_id: str, command: str) -> None:
|
||||||
|
"""set task command"""
|
||||||
|
if command not in self.COMMANDS:
|
||||||
|
print(f"{command} not in valid commands {self.COMMANDS}")
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
message = self.get_single(task_id)
|
||||||
|
if not message:
|
||||||
|
print(f"{task_id} not found")
|
||||||
|
raise KeyError
|
||||||
|
|
||||||
|
message.update({"command": command})
|
||||||
|
self.set_key(task_id, message)
|
||||||
|
|
||||||
|
def del_task(self, task_id: str) -> None:
|
||||||
|
"""delete task result by id"""
|
||||||
|
self.conn.execute_command("DEL", f"{self.BASE}{task_id}")
|
||||||
|
|
||||||
|
def del_all(self) -> None:
|
||||||
|
"""delete all task results"""
|
||||||
|
all_tasks = self.get_all()
|
||||||
|
for task_id in all_tasks:
|
||||||
|
self.del_task(task_id)
|
||||||
|
|||||||
116
tubearchivist/home/src/ta/task_manager.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
functionality:
|
||||||
|
- interact with in redis stored task results
|
||||||
|
- handle threads and locks
|
||||||
|
"""
|
||||||
|
|
||||||
|
from home import tasks as ta_tasks
|
||||||
|
from home.src.ta.ta_redis import RedisArchivist, TaskRedis
|
||||||
|
|
||||||
|
|
||||||
|
class TaskManager:
|
||||||
|
"""manage tasks"""
|
||||||
|
|
||||||
|
def get_all_results(self):
|
||||||
|
"""return all task results"""
|
||||||
|
handler = TaskRedis()
|
||||||
|
all_keys = handler.get_all()
|
||||||
|
if not all_keys:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return [handler.get_single(i) for i in all_keys]
|
||||||
|
|
||||||
|
def get_tasks_by_name(self, task_name):
|
||||||
|
"""get all tasks by name"""
|
||||||
|
all_results = self.get_all_results()
|
||||||
|
if not all_results:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return [i for i in all_results if i.get("name") == task_name]
|
||||||
|
|
||||||
|
def get_task(self, task_id):
|
||||||
|
"""get single task"""
|
||||||
|
return TaskRedis().get_single(task_id)
|
||||||
|
|
||||||
|
def is_pending(self, task):
|
||||||
|
"""check if task_name is pending, pass task object"""
|
||||||
|
tasks = self.get_tasks_by_name(task.name)
|
||||||
|
if not tasks:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return bool([i for i in tasks if i.get("status") == "PENDING"])
|
||||||
|
|
||||||
|
def is_stopped(self, task_id):
|
||||||
|
"""check if task_id has received STOP command"""
|
||||||
|
task = self.get_task(task_id)
|
||||||
|
|
||||||
|
return task.get("command") == "STOP"
|
||||||
|
|
||||||
|
def get_pending(self, task_name):
|
||||||
|
"""get all pending tasks of task_name"""
|
||||||
|
tasks = self.get_tasks_by_name(task_name)
|
||||||
|
if not tasks:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return [i for i in tasks if i.get("status") == "PENDING"]
|
||||||
|
|
||||||
|
def init(self, task):
|
||||||
|
"""pass task object from bind task to set initial pending message"""
|
||||||
|
message = {
|
||||||
|
"status": "PENDING",
|
||||||
|
"result": None,
|
||||||
|
"traceback": None,
|
||||||
|
"date_done": False,
|
||||||
|
"name": task.name,
|
||||||
|
"task_id": task.request.id,
|
||||||
|
}
|
||||||
|
TaskRedis().set_key(task.request.id, message)
|
||||||
|
|
||||||
|
def fail_pending(self):
|
||||||
|
"""
|
||||||
|
mark all pending as failed,
|
||||||
|
run at startup to recover from hard reset
|
||||||
|
"""
|
||||||
|
all_results = self.get_all_results()
|
||||||
|
if not all_results:
|
||||||
|
return
|
||||||
|
|
||||||
|
for result in all_results:
|
||||||
|
if result.get("status") == "PENDING":
|
||||||
|
result["status"] = "FAILED"
|
||||||
|
TaskRedis().set_key(result["task_id"], result, expire=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCommand:
|
||||||
|
"""run commands on task"""
|
||||||
|
|
||||||
|
def start(self, task_name):
|
||||||
|
"""start task by task_name, only pass task that don't take args"""
|
||||||
|
task = ta_tasks.app.tasks.get(task_name).delay()
|
||||||
|
message = {
|
||||||
|
"task_id": task.id,
|
||||||
|
"status": task.status,
|
||||||
|
"task_name": task.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
|
||||||
|
def stop(self, task_id, message_key):
|
||||||
|
"""
|
||||||
|
send stop signal to task_id,
|
||||||
|
needs to be implemented in task to take effect
|
||||||
|
"""
|
||||||
|
print(f"[task][{task_id}]: received STOP signal.")
|
||||||
|
handler = TaskRedis()
|
||||||
|
|
||||||
|
task = handler.get_single(task_id)
|
||||||
|
if not task["name"] in ta_tasks.BaseTask.TASK_CONFIG:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
handler.set_command(task_id, "STOP")
|
||||||
|
RedisArchivist().set_message(message_key, "STOP", path=".command")
|
||||||
|
|
||||||
|
def kill(self, task_id):
|
||||||
|
"""send kill signal to task_id"""
|
||||||
|
print(f"[task][{task_id}]: received KILL signal.")
|
||||||
|
ta_tasks.app.control.revoke(task_id, terminate=True)
|
||||||
134
tubearchivist/home/src/ta/urlparser.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Functionality:
|
||||||
|
- detect valid youtube ids and links from multi line string
|
||||||
|
- identify vid_type if possible
|
||||||
|
"""
|
||||||
|
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from home.src.download.yt_dlp_base import YtWrap
|
||||||
|
from home.src.index.video_constants import VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
|
class Parser:
|
||||||
|
"""take a multi line string and detect valid youtube ids"""
|
||||||
|
|
||||||
|
def __init__(self, url_str):
|
||||||
|
self.url_list = [i.strip() for i in url_str.split()]
|
||||||
|
|
||||||
|
def parse(self):
|
||||||
|
"""parse the list"""
|
||||||
|
ids = []
|
||||||
|
for url in self.url_list:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.netloc:
|
||||||
|
# is url
|
||||||
|
identified = self.process_url(parsed)
|
||||||
|
else:
|
||||||
|
# is not url
|
||||||
|
identified = self._find_valid_id(url)
|
||||||
|
|
||||||
|
if "vid_type" not in identified:
|
||||||
|
identified.update(self._detect_vid_type(parsed.path))
|
||||||
|
|
||||||
|
ids.append(identified)
|
||||||
|
|
||||||
|
return ids
|
||||||
|
|
||||||
|
def process_url(self, parsed):
|
||||||
|
"""process as url"""
|
||||||
|
if parsed.netloc == "youtu.be":
|
||||||
|
# shortened
|
||||||
|
youtube_id = parsed.path.strip("/")
|
||||||
|
return self._validate_expected(youtube_id, "video")
|
||||||
|
|
||||||
|
query_parsed = parse_qs(parsed.query)
|
||||||
|
if "v" in query_parsed:
|
||||||
|
# video from v query str
|
||||||
|
youtube_id = query_parsed["v"][0]
|
||||||
|
return self._validate_expected(youtube_id, "video")
|
||||||
|
|
||||||
|
if "list" in query_parsed:
|
||||||
|
# playlist from list query str
|
||||||
|
youtube_id = query_parsed["list"][0]
|
||||||
|
return self._validate_expected(youtube_id, "playlist")
|
||||||
|
|
||||||
|
all_paths = parsed.path.strip("/").split("/")
|
||||||
|
if all_paths[0] == "shorts":
|
||||||
|
# is shorts video
|
||||||
|
item = self._validate_expected(all_paths[1], "video")
|
||||||
|
item.update({"vid_type": VideoTypeEnum.SHORTS.value})
|
||||||
|
return item
|
||||||
|
|
||||||
|
if all_paths[0] == "channel":
|
||||||
|
return self._validate_expected(all_paths[1], "channel")
|
||||||
|
|
||||||
|
# detect channel
|
||||||
|
channel_id = self._extract_channel_name(parsed.geturl())
|
||||||
|
return {"type": "channel", "url": channel_id}
|
||||||
|
|
||||||
|
def _validate_expected(self, youtube_id, expected_type):
|
||||||
|
"""raise value error if not matching"""
|
||||||
|
matched = self._find_valid_id(youtube_id)
|
||||||
|
if matched["type"] != expected_type:
|
||||||
|
raise ValueError(
|
||||||
|
f"{youtube_id} not of expected type {expected_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"type": expected_type, "url": youtube_id}
|
||||||
|
|
||||||
|
def _find_valid_id(self, id_str):
|
||||||
|
"""detect valid id from length of string"""
|
||||||
|
if id_str in ("LL", "WL"):
|
||||||
|
return {"type": "playlist", "url": id_str}
|
||||||
|
|
||||||
|
if id_str.startswith("@"):
|
||||||
|
url = f"https://www.youtube.com/{id_str}"
|
||||||
|
channel_id = self._extract_channel_name(url)
|
||||||
|
return {"type": "channel", "url": channel_id}
|
||||||
|
|
||||||
|
len_id_str = len(id_str)
|
||||||
|
if len_id_str == 11:
|
||||||
|
item_type = "video"
|
||||||
|
elif len_id_str == 24:
|
||||||
|
item_type = "channel"
|
||||||
|
elif len_id_str in (34, 18):
|
||||||
|
item_type = "playlist"
|
||||||
|
else:
|
||||||
|
raise ValueError(f"not a valid id_str: {id_str}")
|
||||||
|
|
||||||
|
return {"type": item_type, "url": id_str}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_channel_name(url):
|
||||||
|
"""find channel id from channel name with yt-dlp help"""
|
||||||
|
obs_request = {
|
||||||
|
"check_formats": None,
|
||||||
|
"skip_download": True,
|
||||||
|
"extract_flat": True,
|
||||||
|
"playlistend": 0,
|
||||||
|
}
|
||||||
|
url_info = YtWrap(obs_request).extract(url)
|
||||||
|
channel_id = url_info.get("channel_id", False)
|
||||||
|
if channel_id:
|
||||||
|
return channel_id
|
||||||
|
|
||||||
|
url = url_info.get("url", False)
|
||||||
|
if url:
|
||||||
|
# handle old channel name redirect with url path split
|
||||||
|
channel_id = urlparse(url).path.strip("/").split("/")[1]
|
||||||
|
|
||||||
|
return channel_id
|
||||||
|
|
||||||
|
print(f"failed to extract channel id from {url}")
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
def _detect_vid_type(self, path):
|
||||||
|
"""try to match enum from path, needs to be serializable"""
|
||||||
|
last = path.strip("/").split("/")[-1]
|
||||||
|
try:
|
||||||
|
vid_type = VideoTypeEnum(last).value
|
||||||
|
except ValueError:
|
||||||
|
vid_type = VideoTypeEnum.UNKNOWN.value
|
||||||
|
|
||||||
|
return {"vid_type": vid_type}
|
||||||
@@ -8,276 +8,328 @@ Functionality:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from celery import Celery, shared_task
|
from celery import Celery, Task, shared_task
|
||||||
from home.apps import StartupCheck
|
|
||||||
from home.src.download.queue import PendingList
|
from home.src.download.queue import PendingList
|
||||||
from home.src.download.subscriptions import (
|
from home.src.download.subscriptions import (
|
||||||
ChannelSubscription,
|
SubscriptionHandler,
|
||||||
PlaylistSubscription,
|
SubscriptionScanner,
|
||||||
)
|
)
|
||||||
from home.src.download.thumbnails import ThumbFilesystem, ThumbValidator
|
from home.src.download.thumbnails import ThumbFilesystem, ThumbValidator
|
||||||
from home.src.download.yt_dlp_handler import VideoDownloader
|
from home.src.download.yt_dlp_handler import VideoDownloader
|
||||||
from home.src.es.backup import ElasticBackup
|
from home.src.es.backup import ElasticBackup
|
||||||
from home.src.es.index_setup import ElasitIndexWrap
|
from home.src.es.index_setup import ElasitIndexWrap
|
||||||
from home.src.index.channel import YoutubeChannel
|
from home.src.index.channel import YoutubeChannel
|
||||||
from home.src.index.filesystem import (
|
from home.src.index.filesystem import Filesystem
|
||||||
ImportFolderScanner,
|
from home.src.index.manual import ImportFolderScanner
|
||||||
reindex_old_documents,
|
from home.src.index.reindex import Reindex, ReindexManual, ReindexPopulate
|
||||||
scan_filesystem,
|
from home.src.ta.config import AppConfig, ReleaseVersion, ScheduleBuilder
|
||||||
)
|
from home.src.ta.ta_redis import RedisArchivist
|
||||||
from home.src.ta.config import AppConfig, ScheduleBuilder
|
from home.src.ta.task_manager import TaskManager
|
||||||
from home.src.ta.helper import UrlListParser, clear_dl_cache
|
|
||||||
from home.src.ta.ta_redis import RedisArchivist, RedisQueue
|
|
||||||
|
|
||||||
CONFIG = AppConfig().config
|
CONFIG = AppConfig().config
|
||||||
REDIS_HOST = os.environ.get("REDIS_HOST")
|
REDIS_HOST = os.environ.get("REDIS_HOST")
|
||||||
REDIS_PORT = os.environ.get("REDIS_PORT") or 6379
|
REDIS_PORT = os.environ.get("REDIS_PORT") or 6379
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||||
app = Celery("tasks", broker=f"redis://{REDIS_HOST}:{REDIS_PORT}")
|
app = Celery(
|
||||||
|
"tasks",
|
||||||
|
broker=f"redis://{REDIS_HOST}:{REDIS_PORT}",
|
||||||
|
backend=f"redis://{REDIS_HOST}:{REDIS_PORT}",
|
||||||
|
result_extended=True,
|
||||||
|
)
|
||||||
app.config_from_object("django.conf:settings", namespace="ta:")
|
app.config_from_object("django.conf:settings", namespace="ta:")
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
app.conf.timezone = os.environ.get("TZ") or "UTC"
|
app.conf.timezone = os.environ.get("TZ") or "UTC"
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="update_subscribed")
|
class BaseTask(Task):
|
||||||
def update_subscribed():
|
"""base class to inherit each class from"""
|
||||||
"""look for missing videos and add to pending"""
|
|
||||||
message = {
|
# pylint: disable=abstract-method
|
||||||
"status": "message:rescan",
|
|
||||||
"level": "info",
|
TASK_CONFIG = {
|
||||||
"title": "Rescanning channels and playlists.",
|
"update_subscribed": {
|
||||||
"message": "Looking for new videos.",
|
"title": "Rescan your Subscriptions",
|
||||||
|
"group": "download:scan",
|
||||||
|
"api-start": True,
|
||||||
|
"api-stop": True,
|
||||||
|
},
|
||||||
|
"download_pending": {
|
||||||
|
"title": "Downloading",
|
||||||
|
"group": "download:run",
|
||||||
|
"api-start": True,
|
||||||
|
"api-stop": True,
|
||||||
|
},
|
||||||
|
"extract_download": {
|
||||||
|
"title": "Add to download queue",
|
||||||
|
"group": "download:add",
|
||||||
|
"api-stop": True,
|
||||||
|
},
|
||||||
|
"check_reindex": {
|
||||||
|
"title": "Reindex Documents",
|
||||||
|
"group": "reindex:run",
|
||||||
|
},
|
||||||
|
"manual_import": {
|
||||||
|
"title": "Manual video import",
|
||||||
|
"group": "setting:import",
|
||||||
|
"api-start": True,
|
||||||
|
},
|
||||||
|
"run_backup": {
|
||||||
|
"title": "Index Backup",
|
||||||
|
"group": "setting:backup",
|
||||||
|
"api-start": True,
|
||||||
|
},
|
||||||
|
"restore_backup": {
|
||||||
|
"title": "Restore Backup",
|
||||||
|
"group": "setting:restore",
|
||||||
|
},
|
||||||
|
"rescan_filesystem": {
|
||||||
|
"title": "Rescan your Filesystem",
|
||||||
|
"group": "setting:filesystemscan",
|
||||||
|
"api-start": True,
|
||||||
|
},
|
||||||
|
"thumbnail_check": {
|
||||||
|
"title": "Check your Thumbnails",
|
||||||
|
"group": "setting:thumbnailcheck",
|
||||||
|
"api-start": True,
|
||||||
|
},
|
||||||
|
"resync_thumbs": {
|
||||||
|
"title": "Sync Thumbnails to Media Files",
|
||||||
|
"group": "setting:thumbnailsync",
|
||||||
|
"api-start": True,
|
||||||
|
},
|
||||||
|
"index_playlists": {
|
||||||
|
"title": "Index Channel Playlist",
|
||||||
|
"group": "channel:indexplaylist",
|
||||||
|
},
|
||||||
|
"subscribe_to": {
|
||||||
|
"title": "Add Subscription",
|
||||||
|
"group": "subscription:add",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
RedisArchivist().set_message("message:rescan", message, expire=True)
|
|
||||||
|
|
||||||
have_lock = False
|
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||||
my_lock = RedisArchivist().get_lock("rescan")
|
"""callback for task failure"""
|
||||||
|
print(f"{task_id} Failed callback")
|
||||||
|
message, key = self._build_message(level="error")
|
||||||
|
message.update({"messages": ["Task failed"]})
|
||||||
|
RedisArchivist().set_message(key, message, expire=20)
|
||||||
|
|
||||||
try:
|
def on_success(self, retval, task_id, args, kwargs):
|
||||||
have_lock = my_lock.acquire(blocking=False)
|
"""callback task completed successfully"""
|
||||||
if have_lock:
|
print(f"{task_id} success callback")
|
||||||
channel_handler = ChannelSubscription()
|
message, key = self._build_message()
|
||||||
missing_from_channels = channel_handler.find_missing()
|
message.update({"messages": ["Task completed successfully"]})
|
||||||
playlist_handler = PlaylistSubscription()
|
RedisArchivist().set_message(key, message, expire=5)
|
||||||
missing_from_playlists = playlist_handler.find_missing()
|
|
||||||
missing = missing_from_channels + missing_from_playlists
|
|
||||||
if missing:
|
|
||||||
youtube_ids = [{"type": "video", "url": i} for i in missing]
|
|
||||||
pending_handler = PendingList(youtube_ids=youtube_ids)
|
|
||||||
pending_handler.parse_url_list()
|
|
||||||
pending_handler.add_to_pending()
|
|
||||||
|
|
||||||
else:
|
def before_start(self, task_id, args, kwargs):
|
||||||
print("Did not acquire rescan lock.")
|
"""callback before initiating task"""
|
||||||
|
print(f"{self.name} create callback")
|
||||||
|
message, key = self._build_message()
|
||||||
|
message.update({"messages": ["New task received."]})
|
||||||
|
RedisArchivist().set_message(key, message)
|
||||||
|
|
||||||
finally:
|
def send_progress(self, message_lines, progress=False, title=False):
|
||||||
if have_lock:
|
"""send progress message"""
|
||||||
my_lock.release()
|
message, key = self._build_message()
|
||||||
|
message.update(
|
||||||
|
{
|
||||||
@shared_task(name="download_pending")
|
"messages": message_lines,
|
||||||
def download_pending():
|
"progress": progress,
|
||||||
"""download latest pending videos"""
|
|
||||||
have_lock = False
|
|
||||||
my_lock = RedisArchivist().get_lock("downloading")
|
|
||||||
|
|
||||||
try:
|
|
||||||
have_lock = my_lock.acquire(blocking=False)
|
|
||||||
if have_lock:
|
|
||||||
downloader = VideoDownloader()
|
|
||||||
downloader.add_pending()
|
|
||||||
downloader.run_queue()
|
|
||||||
else:
|
|
||||||
print("Did not acquire download lock.")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if have_lock:
|
|
||||||
my_lock.release()
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def download_single(youtube_id):
|
|
||||||
"""start download single video now"""
|
|
||||||
queue = RedisQueue()
|
|
||||||
queue.add_priority(youtube_id)
|
|
||||||
print("Added to queue with priority: " + youtube_id)
|
|
||||||
# start queue if needed
|
|
||||||
have_lock = False
|
|
||||||
my_lock = RedisArchivist().get_lock("downloading")
|
|
||||||
|
|
||||||
try:
|
|
||||||
have_lock = my_lock.acquire(blocking=False)
|
|
||||||
if have_lock:
|
|
||||||
key = "message:download"
|
|
||||||
mess_dict = {
|
|
||||||
"status": key,
|
|
||||||
"level": "info",
|
|
||||||
"title": "Download single video",
|
|
||||||
"message": "processing",
|
|
||||||
}
|
}
|
||||||
RedisArchivist().set_message(key, mess_dict, expire=True)
|
)
|
||||||
VideoDownloader().run_queue()
|
if title:
|
||||||
else:
|
message["title"] = title
|
||||||
print("Download queue already running.")
|
|
||||||
|
|
||||||
finally:
|
RedisArchivist().set_message(key, message)
|
||||||
# release if only single run
|
|
||||||
if have_lock and not queue.get_next():
|
def _build_message(self, level="info"):
|
||||||
my_lock.release()
|
"""build message dict"""
|
||||||
|
task_id = self.request.id
|
||||||
|
message = self.TASK_CONFIG.get(self.name).copy()
|
||||||
|
message.update({"level": level, "id": task_id})
|
||||||
|
task_result = TaskManager().get_task(task_id)
|
||||||
|
if task_result:
|
||||||
|
command = task_result.get("command", False)
|
||||||
|
message.update({"command": command})
|
||||||
|
|
||||||
|
key = f"message:{message.get('group')}:{task_id.split('-')[0]}"
|
||||||
|
return message, key
|
||||||
|
|
||||||
|
def is_stopped(self):
|
||||||
|
"""check if task is stopped"""
|
||||||
|
return TaskManager().is_stopped(self.request.id)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task(name="update_subscribed", bind=True, base=BaseTask)
|
||||||
def extrac_dl(youtube_ids):
|
def update_subscribed(self):
|
||||||
|
"""look for missing videos and add to pending"""
|
||||||
|
manager = TaskManager()
|
||||||
|
if manager.is_pending(self):
|
||||||
|
print(f"[task][{self.name}] rescan already running")
|
||||||
|
self.send_progress("Rescan already in progress.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.init(self)
|
||||||
|
missing_videos = SubscriptionScanner(task=self).scan()
|
||||||
|
if missing_videos:
|
||||||
|
print(missing_videos)
|
||||||
|
extrac_dl.delay(missing_videos)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="download_pending", bind=True, base=BaseTask)
|
||||||
|
def download_pending(self, from_queue=True):
|
||||||
|
"""download latest pending videos"""
|
||||||
|
manager = TaskManager()
|
||||||
|
if manager.is_pending(self):
|
||||||
|
print(f"[task][{self.name}] download queue already running")
|
||||||
|
self.send_progress("Download Queue is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.init(self)
|
||||||
|
downloader = VideoDownloader(task=self)
|
||||||
|
if from_queue:
|
||||||
|
downloader.add_pending()
|
||||||
|
downloader.run_queue()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="extract_download", bind=True, base=BaseTask)
|
||||||
|
def extrac_dl(self, youtube_ids):
|
||||||
"""parse list passed and add to pending"""
|
"""parse list passed and add to pending"""
|
||||||
pending_handler = PendingList(youtube_ids=youtube_ids)
|
TaskManager().init(self)
|
||||||
|
pending_handler = PendingList(youtube_ids=youtube_ids, task=self)
|
||||||
pending_handler.parse_url_list()
|
pending_handler.parse_url_list()
|
||||||
pending_handler.add_to_pending()
|
pending_handler.add_to_pending()
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="check_reindex")
|
@shared_task(bind=True, name="check_reindex", base=BaseTask)
|
||||||
def check_reindex():
|
def check_reindex(self, data=False, extract_videos=False):
|
||||||
"""run the reindex main command"""
|
"""run the reindex main command"""
|
||||||
reindex_old_documents()
|
if data:
|
||||||
|
# started from frontend through API
|
||||||
|
print(f"[task][{self.name}] reindex {data}")
|
||||||
|
self.send_progress("Add items to the reindex Queue.")
|
||||||
|
ReindexManual(extract_videos=extract_videos).extract_data(data)
|
||||||
|
|
||||||
|
manager = TaskManager()
|
||||||
|
if manager.is_pending(self):
|
||||||
|
print(f"[task][{self.name}] reindex queue is already running")
|
||||||
|
self.send_progress("Reindex Queue is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.init(self)
|
||||||
|
if not data:
|
||||||
|
# started from scheduler
|
||||||
|
populate = ReindexPopulate()
|
||||||
|
print(f"[task][{self.name}] reindex outdated documents")
|
||||||
|
self.send_progress("Add recent documents to the reindex Queue.")
|
||||||
|
populate.add_recent()
|
||||||
|
self.send_progress("Add outdated documents to the reindex Queue.")
|
||||||
|
populate.add_outdated()
|
||||||
|
|
||||||
|
Reindex(task=self).reindex_all()
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True, name="manual_import", base=BaseTask)
|
||||||
def run_manual_import():
|
def run_manual_import(self):
|
||||||
"""called from settings page, to go through import folder"""
|
"""called from settings page, to go through import folder"""
|
||||||
print("starting media file import")
|
manager = TaskManager()
|
||||||
have_lock = False
|
if manager.is_pending(self):
|
||||||
my_lock = RedisArchivist().get_lock("manual_import")
|
print(f"[task][{self.name}] manual import is already running")
|
||||||
|
self.send_progress("Manual import is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
manager.init(self)
|
||||||
have_lock = my_lock.acquire(blocking=False)
|
ImportFolderScanner(task=self).scan()
|
||||||
if have_lock:
|
|
||||||
ImportFolderScanner().scan()
|
|
||||||
else:
|
|
||||||
print("Did not acquire lock form import.")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if have_lock:
|
|
||||||
my_lock.release()
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="run_backup")
|
@shared_task(bind=True, name="run_backup", base=BaseTask)
|
||||||
def run_backup(reason="auto"):
|
def run_backup(self, reason="auto"):
|
||||||
"""called from settings page, dump backup to zip file"""
|
"""called from settings page, dump backup to zip file"""
|
||||||
have_lock = False
|
manager = TaskManager()
|
||||||
my_lock = RedisArchivist().get_lock("run_backup")
|
if manager.is_pending(self):
|
||||||
|
print(f"[task][{self.name}] backup is already running")
|
||||||
|
self.send_progress("Backup is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
manager.init(self)
|
||||||
have_lock = my_lock.acquire(blocking=False)
|
ElasticBackup(reason=reason, task=self).backup_all_indexes()
|
||||||
if have_lock:
|
|
||||||
ElasticBackup(reason=reason).backup_all_indexes()
|
|
||||||
else:
|
|
||||||
print("Did not acquire lock for backup task.")
|
|
||||||
finally:
|
|
||||||
if have_lock:
|
|
||||||
my_lock.release()
|
|
||||||
print("backup finished")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True, name="restore_backup", base=BaseTask)
|
||||||
def run_restore_backup(filename):
|
def run_restore_backup(self, filename):
|
||||||
"""called from settings page, dump backup to zip file"""
|
"""called from settings page, dump backup to zip file"""
|
||||||
|
manager = TaskManager()
|
||||||
|
if manager.is_pending(self):
|
||||||
|
print(f"[task][{self.name}] restore is already running")
|
||||||
|
self.send_progress("Restore is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.init(self)
|
||||||
|
self.send_progress(["Reset your Index"])
|
||||||
ElasitIndexWrap().reset()
|
ElasitIndexWrap().reset()
|
||||||
ElasticBackup().restore(filename)
|
ElasticBackup(task=self).restore(filename)
|
||||||
print("index restore finished")
|
print("index restore finished")
|
||||||
|
|
||||||
|
|
||||||
def kill_dl(task_id):
|
@shared_task(bind=True, name="rescan_filesystem", base=BaseTask)
|
||||||
"""kill download worker task by ID"""
|
def rescan_filesystem(self):
|
||||||
if task_id:
|
|
||||||
app.control.revoke(task_id, terminate=True)
|
|
||||||
|
|
||||||
_ = RedisArchivist().del_message("dl_queue_id")
|
|
||||||
RedisQueue().clear()
|
|
||||||
|
|
||||||
clear_dl_cache(CONFIG)
|
|
||||||
|
|
||||||
# notify
|
|
||||||
mess_dict = {
|
|
||||||
"status": "message:download",
|
|
||||||
"level": "error",
|
|
||||||
"title": "Canceling download process",
|
|
||||||
"message": "Canceling download queue now.",
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message("message:download", mess_dict, expire=True)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def rescan_filesystem():
|
|
||||||
"""check the media folder for mismatches"""
|
"""check the media folder for mismatches"""
|
||||||
scan_filesystem()
|
manager = TaskManager()
|
||||||
ThumbValidator().download_missing()
|
if manager.is_pending(self):
|
||||||
|
print(f"[task][{self.name}] filesystem rescan already running")
|
||||||
|
self.send_progress("Filesystem Rescan is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.init(self)
|
||||||
|
Filesystem(task=self).process()
|
||||||
|
ThumbValidator(task=self).validate()
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name="thumbnail_check")
|
@shared_task(bind=True, name="thumbnail_check", base=BaseTask)
|
||||||
def thumbnail_check():
|
def thumbnail_check(self):
|
||||||
"""validate thumbnails"""
|
"""validate thumbnails"""
|
||||||
ThumbValidator().download_missing()
|
manager = TaskManager()
|
||||||
|
if manager.is_pending(self):
|
||||||
|
print(f"[task][{self.name}] thumbnail check is already running")
|
||||||
|
self.send_progress("Thumbnail check is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.init(self)
|
||||||
|
ThumbValidator(task=self).validate()
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True, name="resync_thumbs", base=BaseTask)
|
||||||
def re_sync_thumbs():
|
def re_sync_thumbs(self):
|
||||||
"""sync thumbnails to mediafiles"""
|
"""sync thumbnails to mediafiles"""
|
||||||
ThumbFilesystem().sync()
|
manager = TaskManager()
|
||||||
|
if manager.is_pending(self):
|
||||||
|
print(f"[task][{self.name}] thumb re-embed is already running")
|
||||||
|
self.send_progress("Thumbnail re-embed is already running.")
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.init(self)
|
||||||
|
ThumbFilesystem(task=self).embed()
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True, name="subscribe_to", base=BaseTask)
|
||||||
def subscribe_to(url_str):
|
def subscribe_to(self, url_str):
|
||||||
"""take a list of urls to subscribe to"""
|
"""take a list of urls to subscribe to"""
|
||||||
to_subscribe_list = UrlListParser(url_str).process_list()
|
SubscriptionHandler(url_str, task=self).subscribe()
|
||||||
counter = 1
|
|
||||||
for item in to_subscribe_list:
|
|
||||||
to_sub_id = item["url"]
|
|
||||||
if item["type"] == "playlist":
|
|
||||||
PlaylistSubscription().process_url_str([item])
|
|
||||||
continue
|
|
||||||
|
|
||||||
if item["type"] == "video":
|
|
||||||
vid_details = PendingList().get_youtube_details(to_sub_id)
|
|
||||||
channel_id_sub = vid_details["channel_id"]
|
|
||||||
elif item["type"] == "channel":
|
|
||||||
channel_id_sub = to_sub_id
|
|
||||||
else:
|
|
||||||
raise ValueError("failed to subscribe to: " + to_sub_id)
|
|
||||||
|
|
||||||
ChannelSubscription().change_subscribe(
|
|
||||||
channel_id_sub, channel_subscribed=True
|
|
||||||
)
|
|
||||||
# notify for channels
|
|
||||||
key = "message:subchannel"
|
|
||||||
message = {
|
|
||||||
"status": key,
|
|
||||||
"level": "info",
|
|
||||||
"title": "Subscribing to Channels",
|
|
||||||
"message": f"Processing {counter} of {len(to_subscribe_list)}",
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message(key, message=message, expire=True)
|
|
||||||
counter = counter + 1
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True, name="index_playlists", base=BaseTask)
|
||||||
def index_channel_playlists(channel_id):
|
def index_channel_playlists(self, channel_id):
|
||||||
"""add all playlists of channel to index"""
|
"""add all playlists of channel to index"""
|
||||||
channel = YoutubeChannel(channel_id)
|
channel = YoutubeChannel(channel_id, task=self)
|
||||||
# notify
|
|
||||||
key = "message:playlistscan"
|
|
||||||
mess_dict = {
|
|
||||||
"status": key,
|
|
||||||
"level": "info",
|
|
||||||
"title": "Looking for playlists",
|
|
||||||
"message": f'Scanning channel "{channel.youtube_id}" in progress',
|
|
||||||
}
|
|
||||||
RedisArchivist().set_message(key, mess_dict, expire=True)
|
|
||||||
channel.index_channel_playlists()
|
channel.index_channel_playlists()
|
||||||
|
|
||||||
|
|
||||||
try:
|
@shared_task(name="version_check")
|
||||||
app.conf.beat_schedule = ScheduleBuilder().build_schedule()
|
def version_check():
|
||||||
except KeyError:
|
"""check for new updates"""
|
||||||
# update path from v0.0.8 to v0.0.9 to load new defaults
|
ReleaseVersion().check()
|
||||||
StartupCheck().sync_redis_state()
|
|
||||||
app.conf.beat_schedule = ScheduleBuilder().build_schedule()
|
|
||||||
|
# start schedule here
|
||||||
|
app.conf.beat_schedule = ScheduleBuilder().build_schedule()
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
<div class="about-section">
|
<div class="about-section">
|
||||||
<h2>Useful Links</h2>
|
<h2>Useful Links</h2>
|
||||||
<p>This project is in active and constant development, take a look at the <a href="https://github.com/tubearchivist/tubearchivist#roadmap" target="_blank">roadmap</a> for a overview.</p>
|
<p>This project is in active and constant development, take a look at the <a href="https://github.com/tubearchivist/tubearchivist#roadmap" target="_blank">roadmap</a> for a overview.</p>
|
||||||
<p>For any questions on what a button or a function does, You can find the up-to-date user documentation on <a href="https://github.com/tubearchivist/tubearchivist/wiki" target="_blank">Github</a>.</p>
|
<p>All functionality is documented in our up-to-date <a href="https://docs.tubearchivist.com" target="_blank">user guide</a>.</p>
|
||||||
<p>All contributions are welcome: Open an <a href="https://github.com/tubearchivist/tubearchivist/issues" target="_blank">issue</a> for any bugs and errors, join us on <a href="https://discord.gg/AFwz8nE7BK" target="_blank">Discord</a> to discuss details. The <a href="https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md" target="_blank">contributing</a> page is a good place to get started.</p>
|
<p>All contributions are welcome: Open an <a href="https://github.com/tubearchivist/tubearchivist/issues" target="_blank">issue</a> for any bugs and errors, join us on <a href="https://www.tubearchivist.com/discord" target="_blank">Discord</a> to discuss details. The <a href="https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md" target="_blank">contributing</a> page is a good place to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-section">
|
<div class="about-section">
|
||||||
<h2>Donate</h2>
|
<h2>Donate</h2>
|
||||||
|
|||||||
@@ -132,7 +132,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="boxed-content">
|
<div class="boxed-content">
|
||||||
<span>© 2021 - <script type="text/javascript">document.write(new Date().getFullYear());</script> TubeArchivist {{ version }} </span><span><a href="{% url 'about' %}">About</a> | <a href="https://github.com/tubearchivist/tubearchivist" target="_blank">GitHub</a> | <a href="https://hub.docker.com/r/bbilly1/tubearchivist" target="_blank">Docker Hub</a> | <a href="https://discord.gg/AFwz8nE7BK" target="_blank">Discord</a> | <a href="https://www.reddit.com/r/TubeArchivist/">Reddit</a></span>
|
<span>© 2021 - <script type="text/javascript">document.write(new Date().getFullYear());</script></span>
|
||||||
|
<span>TubeArchivist</span>
|
||||||
|
<span>{{ version }}</span>
|
||||||
|
{% if ta_update %}
|
||||||
|
<span class="danger-zone">{{ ta_update.version }} available{% if ta_update.is_breaking %}<span class="danger-zone">Breaking Changes!</span>{% endif %}</span>
|
||||||
|
<span><a href="https://github.com/tubearchivist/tubearchivist/releases/tag/{{ ta_update.version }}" target="_blank">Release Page</a> | </span>
|
||||||
|
{% endif %}
|
||||||
|
<span><a href="{% url 'about' %}">About</a> | <a href="https://github.com/tubearchivist/tubearchivist" target="_blank">GitHub</a> | <a href="https://hub.docker.com/r/bbilly1/tubearchivist" target="_blank">Docker Hub</a> | <a href="https://www.tubearchivist.com/discord" target="_blank">Discord</a> | <a href="https://www.reddit.com/r/TubeArchivist/">Reddit</a></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="notifications" data="channel"></div>
|
<div id="notifications" data="subscription"></div>
|
||||||
<div class="view-controls">
|
<div class="view-controls">
|
||||||
<div class="toggle">
|
<div class="toggle">
|
||||||
<span>Show subscribed only:</span>
|
<span>Show subscribed only:</span>
|
||||||
|
|||||||
@@ -8,13 +8,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-box-item channel-nav">
|
<div class="info-box-item channel-nav">
|
||||||
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
|
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
|
||||||
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
|
{% if has_streams %}
|
||||||
|
<a href="{% url 'channel_id_live' channel_info.channel_id %}"><h3>Streams</h3></a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_shorts %}
|
||||||
|
<a href="{% url 'channel_id_shorts' channel_info.channel_id %}"><h3>Shorts</h3></a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_playlists %}
|
||||||
|
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
|
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
|
||||||
{% if has_pending %}
|
{% if has_pending %}
|
||||||
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
|
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div id="notifications" data="channel_id"></div>
|
<div id="notifications" data="channel reindex"></div>
|
||||||
<div class="info-box info-box-2">
|
<div class="info-box info-box-2">
|
||||||
<div class="info-box-item">
|
<div class="info-box-item">
|
||||||
<div class="round-img">
|
<div class="round-img">
|
||||||
|
|||||||
@@ -8,12 +8,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-box-item channel-nav">
|
<div class="info-box-item channel-nav">
|
||||||
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
|
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
|
||||||
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
|
{% if has_streams %}
|
||||||
|
<a href="{% url 'channel_id_live' channel_info.channel_id %}"><h3>Streams</h3></a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_shorts %}
|
||||||
|
<a href="{% url 'channel_id_shorts' channel_info.channel_id %}"><h3>Shorts</h3></a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_playlists %}
|
||||||
|
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
|
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
|
||||||
{% if has_pending %}
|
{% if has_pending %}
|
||||||
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
|
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="notifications" data="channel reindex"></div>
|
||||||
<div class="info-box info-box-3">
|
<div class="info-box info-box-3">
|
||||||
<div class="info-box-item">
|
<div class="info-box-item">
|
||||||
<div class="round-img">
|
<div class="round-img">
|
||||||
@@ -47,10 +56,20 @@
|
|||||||
{% elif channel_info.channel_views > 0 %}
|
{% elif channel_info.channel_views > 0 %}
|
||||||
<p>Channel views: {{ channel_info.channel_views|intcomma }}</p>
|
<p>Channel views: {{ channel_info.channel_views|intcomma }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button onclick="deleteConfirm()" id="delete-item">Delete Channel</button>
|
<div class="button-box">
|
||||||
<div class="delete-confirm" id="delete-button">
|
<button onclick="deleteConfirm()" id="delete-item">Delete Channel</button>
|
||||||
<span>Delete {{ channel_info.channel_name }} including all videos? </span><button class="danger-button" onclick="deleteChannel(this)" data-id="{{ channel_info.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button>
|
<div class="delete-confirm" id="delete-button">
|
||||||
|
<span>Delete {{ channel_info.channel_name }} including all videos? </span><button class="danger-button" onclick="deleteChannel(this)" data-id="{{ channel_info.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if reindex %}
|
||||||
|
<p>Reindex scheduled</p>
|
||||||
|
{% else %}
|
||||||
|
<div id="reindex-button" class="button-box">
|
||||||
|
<button data-id="{{ channel_info.channel_id }}" data-type="channel" onclick="reindex(this)" title="Reindex Channel {{ channel_info.channel_name }}">Reindex</button>
|
||||||
|
<button data-id="{{ channel_info.channel_id }}" data-type="channel" data-extract-videos="true" onclick="reindex(this)" title="Reindex Videos of {{ channel_info.channel_name }}">Reindex Videos</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,4 +131,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="text/javascript" src="{% static 'progress.js' %}"></script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@@ -8,12 +8,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-box-item channel-nav">
|
<div class="info-box-item channel-nav">
|
||||||
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
|
<a href="{% url 'channel_id' channel_info.channel_id %}"><h3>Videos</h3></a>
|
||||||
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
|
{% if has_streams %}
|
||||||
|
<a href="{% url 'channel_id_live' channel_info.channel_id %}"><h3>Streams</h3></a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_shorts %}
|
||||||
|
<a href="{% url 'channel_id_shorts' channel_info.channel_id %}"><h3>Shorts</h3></a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_playlists %}
|
||||||
|
<a href="{% url 'channel_id_playlist' channel_info.channel_id %}"><h3>Playlists</h3></a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
|
<a href="{% url 'channel_id_about' channel_info.channel_id %}"><h3>About</h3></a>
|
||||||
{% if has_pending %}
|
{% if has_pending %}
|
||||||
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
|
<a href="{% url 'downloads' %}?channel={{ channel_info.channel_id }}"><h3>Downloads</h3></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="notifications" data="channel reindex"></div>
|
||||||
<div class="view-controls">
|
<div class="view-controls">
|
||||||
<div class="toggle">
|
<div class="toggle">
|
||||||
<span>Show subscribed only:</span>
|
<span>Show subscribed only:</span>
|
||||||
@@ -57,4 +66,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="text/javascript" src="{% static 'progress.js' %}"></script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@@ -73,11 +73,14 @@
|
|||||||
<div class="video-thumb-wrap {{ view_style }}">
|
<div class="video-thumb-wrap {{ view_style }}">
|
||||||
<div class="video-thumb">
|
<div class="video-thumb">
|
||||||
<img src="{{ video.source.vid_thumb_url }}" alt="video_thumb">
|
<img src="{{ video.source.vid_thumb_url }}" alt="video_thumb">
|
||||||
{% if show_ignored_only %}
|
<div class="video-tags">
|
||||||
<span>ignored</span>
|
{% if show_ignored_only %}
|
||||||
{% else %}
|
<span>ignored</span>
|
||||||
<span>queued</span>
|
{% else %}
|
||||||
{% endif %}
|
<span>queued</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>{{ video.source.vid_type }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-desc {{ view_style }}">
|
<div class="video-desc {{ view_style }}">
|
||||||
@@ -87,7 +90,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span>{{ video.source.channel_name }}</span>
|
<span>{{ video.source.channel_name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h3>{{ video.source.title }}</h3>
|
<a href="https://www.youtube.com/watch?v={{ video.source.youtube_id }}" target="_blank"><h3>{{ video.source.title }}</h3></a>
|
||||||
</div>
|
</div>
|
||||||
<p>Published: {{ video.source.published }} | Duration: {{ video.source.duration }} | {{ video.source.youtube_id }}</p>
|
<p>Published: {{ video.source.published }} | Duration: {{ video.source.duration }} | {{ video.source.youtube_id }}</p>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="notifications" data="playlist"></div>
|
<div id="notifications" data="subscription"></div>
|
||||||
<div class="view-controls">
|
<div class="view-controls">
|
||||||
<div class="toggle">
|
<div class="toggle">
|
||||||
<span>Show subscribed only:</span>
|
<span>Show subscribed only:</span>
|
||||||
|
|||||||
@@ -52,6 +52,14 @@
|
|||||||
<p>Total Videos archived: {{ max_hits }}/{{ playlist_info.playlist_entries|length }}</p>
|
<p>Total Videos archived: {{ max_hits }}/{{ playlist_info.playlist_entries|length }}</p>
|
||||||
<p>Watched: <button title="Mark all videos from {{ playlist_info.playlist_name }} as watched" type="button" id="watched-button" data-id="{{ playlist_info.playlist_id }}" onclick="isWatchedButton(this)">Mark as watched</button></p>
|
<p>Watched: <button title="Mark all videos from {{ playlist_info.playlist_name }} as watched" type="button" id="watched-button" data-id="{{ playlist_info.playlist_id }}" onclick="isWatchedButton(this)">Mark as watched</button></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if reindex %}
|
||||||
|
<p>Reindex scheduled</p>
|
||||||
|
{% else %}
|
||||||
|
<div id="reindex-button" class="button-box">
|
||||||
|
<button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" onclick="reindex(this)" title="Reindex Playlist {{ playlist_info.playlist_name }}">Reindex</button>
|
||||||
|
<button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" data-extract-videos="true" onclick="reindex(this)" title="Reindex Videos of {{ playlist_info.playlist_name }}">Reindex Videos</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,30 +7,80 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="multi-search-box">
|
<div class="multi-search-box">
|
||||||
{{ search_form }}
|
{{ search_form }}
|
||||||
<p>Start typing or use <span class="settings-current">video:</span>, <span class="settings-current">channel:</span>, <span class="settings-current">playlist:</span> or <span class="settings-current">full:</span> keywords for advanced queries. <a href="https://github.com/tubearchivist/tubearchivist/wiki/Search" target="_blank">Learn more</a>.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="multi-search-result">
|
<div id="multi-search-results" style="display: none;">
|
||||||
<h2>Video Results</h2>
|
<div class="multi-search-result">
|
||||||
<div id="video-results" class="video-list {{ all_styles.home }} {% if all_styles.home == "grid" %}grid-{{ grid_items }}{% endif %}">
|
<h2>Video Results</h2>
|
||||||
<p>No videos found.</p>
|
<div id="video-results" class="video-list {{ all_styles.home }} {% if all_styles.home == "grid" %}grid-{{ grid_items }}{% endif %}">
|
||||||
|
<p>No videos found.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="multi-search-result">
|
||||||
|
<h2>Channel Results</h2>
|
||||||
|
<div id="channel-results" class="channel-list {{ all_styles.channel }}">
|
||||||
|
<p>No channels found.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="multi-search-result">
|
||||||
|
<h2>Playlist Results</h2>
|
||||||
|
<div id="playlist-results" class="playlist-list {{ all_styles.playlist }}">
|
||||||
|
<p>No playlists found.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="multi-search-result">
|
||||||
|
<h2>Fulltext Results</h2>
|
||||||
|
<div id="fulltext-results" class="video-list list">
|
||||||
|
<p>No fulltext results found.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="multi-search-result">
|
<div id="multi-search-results-placeholder" style="display: block;">
|
||||||
<h2>Channel Results</h2>
|
<div>
|
||||||
<div id="channel-results" class="channel-list {{ all_styles.channel }}">
|
<h2>Example queries</h2>
|
||||||
<p>No channels found.</p>
|
<ul>
|
||||||
|
<li><span class="value">music video</span> — basic search</li>
|
||||||
|
<li><span>video: active:</span><span class="value">no</span> — all videos deleted from YouTube</li>
|
||||||
|
<li><span>video:</span><span class="value">learn javascript</span><span> channel:</span><span class="value">corey schafer</span><span> active:</span><span class="value">yes</span></li>
|
||||||
|
<li><span>channel:</span><span class="value">linux</span><span> subscribed:</span><span class="value">yes</span></li>
|
||||||
|
<li><span>playlist:</span><span class="value">backend engineering</span><span> active:</span><span class="value">yes</span><span> subscribed:</span><span class="value">yes</span></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div class="multi-search-result">
|
<h2>Keywords cheatsheet</h2>
|
||||||
<h2>Playlist Results</h2>
|
<p>For detailed usage check <a href="https://docs.tubearchivist.com/search/" target="_blank">wiki</a>.</p>
|
||||||
<div id="playlist-results" class="playlist-list {{ all_styles.playlist }}">
|
<div>
|
||||||
<p>No playlists found.</p>
|
<ul>
|
||||||
</div>
|
<li><span>simple:</span> (implied) — search in video titles, channel names and playlist titles</li>
|
||||||
</div>
|
<li>
|
||||||
<div class="multi-search-result">
|
<span>video:</span> — search in video titles, tags and category field
|
||||||
<h2>Fulltext Results</h2>
|
<ul>
|
||||||
<div id="fulltext-results" class="video-list list">
|
<li><span>channel:</span> — channel name</li>
|
||||||
<p>No fulltext results found.</p>
|
<li><span>active:</span><span class="value">yes/no</span> — whether the video is still active on YouTube</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>channel:</span> — search in channel name and channel description
|
||||||
|
<ul>
|
||||||
|
<li><span>subscribed:</span><span class="value">yes/no</span> — whether you are subscribed to the channel</li>
|
||||||
|
<li><span>active:</span><span class="value">yes/no</span> — whether the video is still active on YouTube</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>playlist:</span> — search in channel name and channel description
|
||||||
|
<ul>
|
||||||
|
<li><span>subscribed:</span><span class="value">yes/no</span> — whether you are subscribed to the channel</li>
|
||||||
|
<li><span>active:</span><span class="value">yes/no</span> — whether the video is still active on YouTube</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>full:</span> — search in video subtitles
|
||||||
|
<ul>
|
||||||
|
<li><span>lang:</span> — subtitles language (use two-letter ISO country code, same as the one from settings page)</li>
|
||||||
|
<li><span>source:</span><span class="value">auto/user</span> — <i>auto</i> to search though auto-generated subtitles only, or <i>user</i> to search through user-uploaded subtitles only</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="boxed-content">
|
<div class="boxed-content">
|
||||||
<div id="notifications" data="setting"></div>
|
<div id="notifications" data="setting reindex"></div>
|
||||||
<div class="title-bar">
|
<div class="title-bar">
|
||||||
<h1>User Configurations</h1>
|
<h1>User Configurations</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,11 +33,22 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h2 id="subscriptions">Subscriptions</h2>
|
<h2 id="subscriptions">Subscriptions</h2>
|
||||||
|
<p>Disable shorts or streams by setting their page size to 0 (zero).</p>
|
||||||
<div class="settings-item">
|
<div class="settings-item">
|
||||||
<p>YouTube page size: <span class="settings-current">{{ config.subscriptions.channel_size }}</span></p>
|
<p>YouTube page size: <span class="settings-current">{{ config.subscriptions.channel_size }}</span></p>
|
||||||
<i>Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
|
<i>Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
|
||||||
{{ app_form.subscriptions_channel_size }}
|
{{ app_form.subscriptions_channel_size }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-item">
|
||||||
|
<p>YouTube Live page size: <span class="settings-current">{{ config.subscriptions.live_channel_size }}</span></p>
|
||||||
|
<i>Live Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
|
||||||
|
{{ app_form.subscriptions_live_channel_size }}
|
||||||
|
</div>
|
||||||
|
<div class="settings-item">
|
||||||
|
<p>YouTube Shorts page size: <span class="settings-current">{{ config.subscriptions.shorts_channel_size }}</span></p>
|
||||||
|
<i>Shorts Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max recommended 50.</i><br>
|
||||||
|
{{ app_form.subscriptions_shorts_channel_size }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h2 id="downloads">Downloads</h2>
|
<h2 id="downloads">Downloads</h2>
|
||||||
@@ -137,7 +148,7 @@
|
|||||||
<div class="settings-item">
|
<div class="settings-item">
|
||||||
<p>Import YouTube cookie: <span class="settings-current">{{ config.downloads.cookie_import }}</span><br></p>
|
<p>Import YouTube cookie: <span class="settings-current">{{ config.downloads.cookie_import }}</span><br></p>
|
||||||
<p>For automatic cookie import use <b>Tube Archivist Companion</b> <a href="https://github.com/tubearchivist/browser-extension" target="_blank">browser extension</a>.</p>
|
<p>For automatic cookie import use <b>Tube Archivist Companion</b> <a href="https://github.com/tubearchivist/browser-extension" target="_blank">browser extension</a>.</p>
|
||||||
<i>For manual cookie import, place your cookie file named <span class="settings-current">cookies.google.txt</span> in <span class="settings-current">cache/import</span> before enabling. Instructions in the <a href="https://github.com/tubearchivist/tubearchivist/wiki/Settings" target="_blank">Wiki.</a></i><br>
|
<i>For manual cookie import, place your cookie file named <span class="settings-current">cookies.google.txt</span> in <span class="settings-current">cache/import</span> before enabling. Instructions in the <a href="https://docs.tubearchivist.com/settings/" target="_blank">Wiki.</a></i><br>
|
||||||
{{ app_form.downloads_cookie_import }}<br>
|
{{ app_form.downloads_cookie_import }}<br>
|
||||||
{% if config.downloads.cookie_import %}
|
{% if config.downloads.cookie_import %}
|
||||||
<div id="cookieMessage">
|
<div id="cookieMessage">
|
||||||
@@ -165,17 +176,12 @@
|
|||||||
<i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br>
|
<i>Before activating that, make sure you have a scraping sleep interval of at least 3 secs set to avoid ratelimiting issues.</i><br>
|
||||||
{{ app_form.downloads_integrate_sponsorblock }}
|
{{ app_form.downloads_integrate_sponsorblock }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-item">
|
|
||||||
<p>Current Cast integration: <span class="settings-current">{{ config.application.enable_cast }}</span></p>
|
|
||||||
<i>Enabling Cast will load an additional JS library from Google. HTTPS and a supported browser are required for this integration.</i><br>
|
|
||||||
{{ app_form.application_enable_cast }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h2 id="snapshots">Snapshots</h2>
|
<h2 id="snapshots">Snapshots</h2>
|
||||||
<div class="settings-item">
|
<div class="settings-item">
|
||||||
<p>Current system snapshot: <span class="settings-current">{{ config.application.enable_snapshot }}</span></p>
|
<p>Current system snapshot: <span class="settings-current">{{ config.application.enable_snapshot }}</span></p>
|
||||||
<i>Automatically create daily deduplicated snapshots of the index, stored in Elasticsearch. Read first before activating: <a target="_blank" href="https://github.com/tubearchivist/tubearchivist/wiki/Settings#snapshots">Wiki</a>.</i><br>
|
<i>Automatically create daily deduplicated snapshots of the index, stored in Elasticsearch. Read first before activating: <a target="_blank" href="https://docs.tubearchivist.com/settings/#snapshots">Wiki</a>.</i><br>
|
||||||
{{ app_form.application_enable_snapshot }}
|
{{ app_form.application_enable_snapshot }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -222,6 +228,7 @@
|
|||||||
False
|
False
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span></p>
|
</span></p>
|
||||||
|
<p>Become a sponsor and join <a href="https://members.tubearchivist.com/" target="_blank">members.tubearchivist.com</a> to get access to <span class="settings-current">real time</span> notifications for new videos uploaded by your favorite channels.</p>
|
||||||
<p>Periodically rescan your subscriptions:</p>
|
<p>Periodically rescan your subscriptions:</p>
|
||||||
{{ scheduler_form.update_subscribed }}
|
{{ scheduler_form.update_subscribed }}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,8 +287,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h2>Index backup</h2>
|
<h2>ZIP file index backup</h2>
|
||||||
<div class="settings-item">
|
<div class="settings-item">
|
||||||
|
<p><i>Zip file backups are very slow for large archives and consistency is not guaranteed, use snapshots instead. Make sure no other tasks are running when creating a Zip file backup.</i></p>
|
||||||
<p>Current index backup schedule: <span class="settings-current">
|
<p>Current index backup schedule: <span class="settings-current">
|
||||||
{% if config.scheduler.run_backup %}
|
{% if config.scheduler.run_backup %}
|
||||||
{% for key, value in config.scheduler.run_backup.items %}
|
{% for key, value in config.scheduler.run_backup.items %}
|
||||||
@@ -313,7 +321,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h2>Manual media files import.</h2>
|
<h2>Manual media files import.</h2>
|
||||||
<p>Add files to the <span class="settings-current">cache/import</span> folder. Make sure to follow the instructions in the Github <a href="https://github.com/tubearchivist/tubearchivist/wiki/Settings" target="_blank">Wiki</a>.</p>
|
<p>Add files to the <span class="settings-current">cache/import</span> folder. Make sure to follow the instructions in the Github <a href="https://docs.tubearchivist.com/settings/" target="_blank">Wiki</a>.</p>
|
||||||
<div id="manual-import">
|
<div id="manual-import">
|
||||||
<button onclick="manualImport()">Start import</button>
|
<button onclick="manualImport()">Start import</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,8 +334,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h2>Backup database</h2>
|
<h2>ZIP file index backup</h2>
|
||||||
<p>Export your database to a zip file stored at <span class="settings-current">cache/backup</span>.</p>
|
<p>Export your database to a zip file stored at <span class="settings-current">cache/backup</span>.</p>
|
||||||
|
<p><i>Zip file backups are very slow for large archives and consistency is not guaranteed, use snapshots instead. Make sure no other tasks are running when creating a Zip file backup.</i></p>
|
||||||
<div id="db-backup">
|
<div id="db-backup">
|
||||||
<button onclick="dbBackup()">Start backup</button>
|
<button onclick="dbBackup()">Start backup</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +367,7 @@
|
|||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h2>Rescan filesystem</h2>
|
<h2>Rescan filesystem</h2>
|
||||||
<p><span class="danger-zone">Danger Zone</span>: This will delete the metadata of deleted videos from the filesystem.</p>
|
<p><span class="danger-zone">Danger Zone</span>: This will delete the metadata of deleted videos from the filesystem.</p>
|
||||||
<p>Rescan your media folder looking for missing videos and clean up index. More infos on the Github <a href="https://github.com/tubearchivist/tubearchivist/wiki/Settings" target="_blank">Wiki</a>.</p>
|
<p>Rescan your media folder looking for missing videos and clean up index. More infos on the Github <a href="https://docs.tubearchivist.com/settings/" target="_blank">Wiki</a>.</p>
|
||||||
<div id="fs-rescan">
|
<div id="fs-rescan">
|
||||||
<button onclick="fsRescan()">Rescan filesystem</button>
|
<button onclick="fsRescan()">Rescan filesystem</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||