mirror of
https://git.vectorsigma.ru/public/tubearchivist.git
synced 2026-08-08 18:29:32 +00:00
Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005d2d1cf5 | ||
|
|
bc3463bdd8 | ||
|
|
39c66019eb | ||
|
|
a9264e348f | ||
|
|
ba44ab252e | ||
|
|
b6e95c6125 | ||
|
|
90e3a5c634 | ||
|
|
3fe26fa6a9 | ||
|
|
dc6276803d | ||
|
|
64a05d561f | ||
|
|
4766f703f2 | ||
|
|
d1da9f2f02 | ||
|
|
d2a6bdb18c | ||
|
|
9e6509f7d1 | ||
|
|
3d48b38c62 | ||
|
|
08e8381124 | ||
|
|
7f944a4805 | ||
|
|
2ab64b565f | ||
|
|
d9a3d7f12f | ||
|
|
41c9415b3f | ||
|
|
c6d0e02f16 | ||
|
|
be278fe5f6 | ||
|
|
3cf9b5ed40 | ||
|
|
9c5e4b599f | ||
|
|
f2da730aa2 | ||
|
|
445ef8ad05 | ||
|
|
5804fc09e6 | ||
|
|
d8e5ee7e04 | ||
|
|
59331b2b40 | ||
|
|
a925660078 | ||
|
|
7c28175e9b | ||
|
|
8161ee6c74 | ||
|
|
26423e11c8 | ||
|
|
bcb216d862 | ||
|
|
dc26354020 | ||
|
|
507c77fdbb | ||
|
|
1a4539c9ba | ||
|
|
9606eb44c7 | ||
|
|
b618a7c282 | ||
|
|
aa58f03323 | ||
|
|
11b31c493e | ||
|
|
d421e15405 | ||
|
|
0073b49a38 | ||
|
|
689aa60fa4 | ||
|
|
31f612769b | ||
|
|
6b859eb436 | ||
|
|
b0c435caf3 | ||
|
|
95c0e35db7 | ||
|
|
d2bbc7c583 | ||
|
|
c1d1355536 | ||
|
|
2f03dccf3b | ||
|
|
f17b62626d | ||
|
|
4f46492298 | ||
|
|
7e8ced001d | ||
|
|
5cee7af233 | ||
|
|
01db2df729 | ||
|
|
40f6ee60d4 | ||
|
|
c22fe144b0 | ||
|
|
64ac647ade | ||
|
|
16ec1f694f | ||
|
|
6192fcc350 | ||
|
|
e19a1c6166 | ||
|
|
f8a66ce7f0 | ||
|
|
e6260f6919 | ||
|
|
faf00bf35e | ||
|
|
4245147e7e | ||
|
|
4baaabe3f3 | ||
|
|
3fd061e838 | ||
|
|
ff4e41b932 | ||
|
|
454952d9dd | ||
|
|
2b709ce9c1 | ||
|
|
4d9be9853c | ||
|
|
759d034c86 | ||
|
|
f4392f43fa | ||
|
|
b5a79c4885 | ||
|
|
d7edaa3b70 | ||
|
|
2808f5ba0d | ||
|
|
e56059771c | ||
|
|
27bb5ff298 | ||
|
|
5504c333b8 | ||
|
|
ce19693a86 | ||
|
|
dfd86f8a80 | ||
|
|
fb92387540 | ||
|
|
42a8ae2e9f | ||
|
|
02bb52f276 | ||
|
|
5e6c94318c | ||
|
|
aefd678dca | ||
|
|
624a5f9bd4 | ||
|
|
fa7643e903 | ||
|
|
05ce2a7034 | ||
|
|
21f1d9cc00 | ||
|
|
e6c13698bd | ||
|
|
de0dd8eeec | ||
|
|
59f0c74e54 | ||
|
|
2868dae09d | ||
|
|
cefa0093ba | ||
|
|
f51e094745 | ||
|
|
90611dbe75 | ||
|
|
bbfd3f4423 | ||
|
|
59e9ee7eed | ||
|
|
b91408ada2 | ||
|
|
25c9fd99b1 | ||
|
|
29be11cf75 | ||
|
|
ffd3bab948 | ||
|
|
83404628e6 | ||
|
|
f45214714c | ||
|
|
8fbd94b120 | ||
|
|
569d97e2f3 | ||
|
|
5da7b2a3c9 | ||
|
|
ae40df1b6b | ||
|
|
97bc6f225f | ||
|
|
15ec8b5ab6 | ||
|
|
b6d38e9319 | ||
|
|
66f37a92d3 | ||
|
|
74c708baa2 | ||
|
|
f87309dbd1 | ||
|
|
3947653595 | ||
|
|
2a70f7ab58 | ||
|
|
a0f40d9970 | ||
|
|
8d9cb9261e | ||
|
|
28ac0ba620 | ||
|
|
e88ae9e2f0 | ||
|
|
79ee903f90 | ||
|
|
4a64bdea33 | ||
|
|
bc66b4bef6 | ||
|
|
dfb984590d | ||
|
|
08681f0e33 | ||
|
|
ab3b83ed3f | ||
|
|
28f2fbd6a7 | ||
|
|
d19190bf6a | ||
|
|
b14309daeb | ||
|
|
990cb9aaec | ||
|
|
bb4e5ecb50 | ||
|
|
ff94c324b3 | ||
|
|
21f0a09f5f | ||
|
|
12fc7e1663 | ||
|
|
1c60b2bb46 | ||
|
|
3631fa0b2c | ||
|
|
dfcd46efbf | ||
|
|
aa943567de | ||
|
|
178c25f2f0 | ||
|
|
22b53e6820 | ||
|
|
2c2129179a | ||
|
|
ffe9444295 | ||
|
|
f8c5efb87a | ||
|
|
e62a4e0fcf | ||
|
|
b495761e9e | ||
|
|
b7b6ae0216 | ||
|
|
aa08701049 | ||
|
|
436a641746 | ||
|
|
710b0ddc2d | ||
|
|
703fd63f44 | ||
|
|
f040ac6b34 | ||
|
|
aebde2b993 | ||
|
|
372275c4d5 | ||
|
|
e68a9f9f1f | ||
|
|
b239f4bd84 | ||
|
|
8a227fc9b8 | ||
|
|
8c59d65ee5 | ||
|
|
d196d2e4f5 | ||
|
|
62ea518e1b | ||
|
|
a961c8f175 | ||
|
|
ec5204cd6c | ||
|
|
37a6922718 | ||
|
|
a82e4b1c51 | ||
|
|
58b4e22df4 | ||
|
|
efe1401518 | ||
|
|
02b5ed6917 | ||
|
|
fd3ccbec3a | ||
|
|
717d2b3098 | ||
|
|
068cd2e407 | ||
|
|
ef07dc91b4 | ||
|
|
514ad0d16b | ||
|
|
72d81cc45a | ||
|
|
6789cc90d8 | ||
|
|
e7f1921986 | ||
|
|
0ba6169524 |
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1 +1 @@
|
|||||||
docker_assets\run.sh eol=lf
|
* text=auto eol=lf
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
3
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
@@ -15,7 +15,8 @@ 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 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.
|
- label: I'm [beta testing](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#beta-testing) and am running the latest unstable build.
|
||||||
|
- 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. I've double checked that I don't open a yt-dlp issue here.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
2
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
@@ -10,3 +10,5 @@ body:
|
|||||||
options:
|
options:
|
||||||
- label: I understand that this issue will be closed without comment.
|
- label: I understand that this issue will be closed without comment.
|
||||||
required: true
|
required: true
|
||||||
|
- label: I will resist the temptation and I will not submit this issue. If I submit this, I understand I might get blocked from this repo.
|
||||||
|
required: true
|
||||||
|
|||||||
23
.github/ISSUE_TEMPLATE/FRONTEND-MIGRATION.yml
vendored
23
.github/ISSUE_TEMPLATE/FRONTEND-MIGRATION.yml
vendored
@@ -1,23 +0,0 @@
|
|||||||
name: Frontend Migration
|
|
||||||
description: Tracking our new React based frontend
|
|
||||||
title: "[Frontend Migration]: "
|
|
||||||
labels: ["react migration"]
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: dropdown
|
|
||||||
id: domain
|
|
||||||
attributes:
|
|
||||||
label: Domain
|
|
||||||
options:
|
|
||||||
- Frontend
|
|
||||||
- Backend
|
|
||||||
- Combined
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
placeholder: Organizing our React frontend migration
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
2
.github/workflows/unit_tests.yml
vendored
2
.github/workflows/unit_tests.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r backend/requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pytest backend
|
run: pytest backend
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,3 +12,5 @@ backend/.env
|
|||||||
|
|
||||||
# JavaScript stuff
|
# JavaScript stuff
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
.editorconfig
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ repos:
|
|||||||
files: ^backend/
|
files: ^backend/
|
||||||
args: ["--profile", "black", "-l 79"]
|
args: ["--profile", "black", "-l 79"]
|
||||||
- repo: https://github.com/pycqa/flake8
|
- repo: https://github.com/pycqa/flake8
|
||||||
rev: 7.1.2
|
rev: 7.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
alias: python
|
alias: python
|
||||||
@@ -31,7 +31,7 @@ repos:
|
|||||||
- id: codespell
|
- id: codespell
|
||||||
exclude: ^frontend/package-lock.json
|
exclude: ^frontend/package-lock.json
|
||||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||||
rev: v9.22.0
|
rev: v9.30.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: eslint
|
- id: eslint
|
||||||
name: eslint
|
name: eslint
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 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!
|
||||||
|
|
||||||
## Table of Content
|
## Table of Content
|
||||||
- [Beta Testing](#beta-testing)
|
- [Beta Testing](#beta-testing)
|
||||||
@@ -16,20 +16,20 @@ Welcome, and thanks for showing interest in improving Tube Archivist!
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Beta Testing
|
## Beta Testing
|
||||||
Be the first to help test new features and improvements and provide feedback! There are regular `:unstable` builds for easy access. That's for the tinkerers and the breave. Ideally use a testing environment first, before a release be the first to install it on your main system.
|
Be the first to help test new features/improvements and provide feedback! Regular `:unstable` builds are available for early access. These are for the tinkerers and the brave. Ideally, use a testing environment first, before upgrading your main installation.
|
||||||
|
|
||||||
There is always something that can get missed during development. Look at the commit messages tagged with `#build`, these are the unstable builds and give a quick overview what has changed.
|
There is always something that can get missed during development. Look at the commit messages tagged with `#build` - these are the unstable builds and give a quick overview of what has changed.
|
||||||
|
|
||||||
- Test the features mentioned, play around, try to break it.
|
- Test the features mentioned, play around, try to break it.
|
||||||
- Test the update path by installing the `:latest` release first, the upgrade to `:unstable` to check for any errors.
|
- Test the update path by installing the `:latest` release first, then upgrade to `:unstable` to check for any errors.
|
||||||
- Test the unstable build on a fresh install.
|
- Test the unstable build on a fresh install.
|
||||||
|
|
||||||
Then provide feedback, if there is a problem but also if there is no problem. Reach out on [Discord](https://tubearchivist.com/discord) in the `#beta-testing` channel with your findings.
|
Then provide feedback - even if you don't encounter any issues! You can do this in the `#beta-testing` channel on the [Discord](https://tubearchivist.com/discord) Discord server.
|
||||||
|
|
||||||
This will help with a smooth update for the regular release. Plus you get to test things out early!
|
This helps ensure a smooth update for the stable release. Plus you get to test things out early!
|
||||||
|
|
||||||
## How to open an issue
|
## How to open an issue
|
||||||
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub. Make sure you read [Next Steps](#next-steps) above.
|
Please read this carefully before opening any [issue](https://github.com/tubearchivist/tubearchivist/issues) on GitHub.
|
||||||
|
|
||||||
**Do**:
|
**Do**:
|
||||||
- Do provide details and context, this matters a lot and makes it easier for people to help.
|
- Do provide details and context, this matters a lot and makes it easier for people to help.
|
||||||
@@ -37,7 +37,7 @@ Please read this carefully before opening any [issue](https://github.com/tubearc
|
|||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
**Don't**:
|
**Don't**:
|
||||||
- Don't open *duplicates*, that includes open and closed issues.
|
- Don't open *duplicates*, that includes open and closed issues. Also don't post the same issue on multiple platforms, that makes it unnecessarily hard for maintainers to keep up.
|
||||||
- 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 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 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.
|
- 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.
|
||||||
@@ -46,6 +46,7 @@ Please read this carefully before opening any [issue](https://github.com/tubearc
|
|||||||
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.
|
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:
|
Please keep in mind:
|
||||||
|
- Don't report bugs from yt-dlp here. There is a [dedicated repo](https://github.com/yt-dlp/yt-dlp/issues) for that. Make sure to check for duplicates before opening a new issue there.
|
||||||
- Docker logs are the easiest way to understand what's happening when something goes wrong, *always* provide the logs upfront.
|
- 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.
|
- 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*.
|
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
|
||||||
@@ -65,16 +66,21 @@ IMPORTANT: When receiving help, contribute back to the community by improving th
|
|||||||
|
|
||||||
## How to make a Pull Request
|
## How to make a Pull Request
|
||||||
|
|
||||||
Make sure you read [Next Steps](#next-steps) above.
|
Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application.
|
||||||
|
|
||||||
Thank you for contributing and helping improve this project. Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application.
|
|
||||||
|
|
||||||
This is a quick checklist to help streamline the process:
|
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.
|
- NEW: Make your PR against the [develop branch](https://github.com/tubearchivist/tubearchivist/tree/develop). 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.
|
- Show off your progress, even if not yet complete, by creating a [draft](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests) PR first and switch it as *ready* when you are ready.
|
||||||
- Make sure all your code is linted and formatted correctly, see below. The automatic GH action unfortunately needs to be triggered manually by a maintainer for first time contributors, but will trigger automatically for existing contributors.
|
- Make sure all your code is linted and formatted correctly, see below.
|
||||||
|
|
||||||
|
### Documentation Changes
|
||||||
|
|
||||||
|
All documentation is intended to represent the state of the [latest](https://github.com/tubearchivist/tubearchivist/releases/latest) release.
|
||||||
|
|
||||||
|
- If your PR with code changes also requires changes to documentation *.md files here in this repo, create a separate PR for that, so it can be merged separately at release.
|
||||||
|
- If your PR requires changes on the [tubearchivist/docs](https://github.com/tubearchivist/docs), make the PR over there.
|
||||||
|
- Prepare your documentation updates at the same time as the code changes, so people testing your PR can consult the prepared docs if needed.
|
||||||
|
|
||||||
### Code formatting and linting
|
### Code formatting and linting
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ This project uses the excellent [pre-commit](https://github.com/pre-commit/pre-c
|
|||||||
**Quick Start**
|
**Quick Start**
|
||||||
- Run `pre-commit install` from the root of the repo.
|
- Run `pre-commit install` from the root of the repo.
|
||||||
- Next time you commit to your local git repo, the defined hooks will run.
|
- Next time you commit to your local git repo, the defined hooks will run.
|
||||||
- On first run, this will download and install the needed environments to your local machine, that can take some time. But that will be reused on sunsequent commits.
|
- On first run, this will download and install the needed environments to your local machine, that can take some time. But that will be reused on sunsequent commits.
|
||||||
|
|
||||||
That is also running as a Git Hub action.
|
That is also running as a Git Hub action.
|
||||||
|
|
||||||
@@ -93,7 +99,7 @@ That is also running as a Git Hub action.
|
|||||||
|
|
||||||
As you have read the [FAQ](https://docs.tubearchivist.com/faq/) and the [known limitations](https://github.com/tubearchivist/tubearchivist#known-limitations) and have gotten an idea what this project tries to do, there will be some obvious shortcomings that stand out, that have been explicitly excluded from the scope of this project, at least for the time being.
|
As you have read the [FAQ](https://docs.tubearchivist.com/faq/) and the [known limitations](https://github.com/tubearchivist/tubearchivist#known-limitations) and have gotten an idea what this project tries to do, there will be some obvious shortcomings that stand out, that have been explicitly excluded from the scope of this project, at least for the time being.
|
||||||
|
|
||||||
Extending the scope of this project will only be feasible with more [regular contributors](https://github.com/tubearchivist/tubearchivist/graphs/contributors) that are willing to help improve this project in the long run. Contributors that have an overall improvement of the project in mind and not just about implementing this *one* thing.
|
Extending the scope of this project will only be feasible with more [regular contributors](https://github.com/tubearchivist/tubearchivist/graphs/contributors) that are willing to help improve this project in the long run. Contributors that have an overall improvement of the project in mind and not just about implementing this *one* thing.
|
||||||
|
|
||||||
Small minor additions, or making a PR for a documented feature request or bug, even if that was and will be your only contribution to this project, are always welcome and is *not* what this is about.
|
Small minor additions, or making a PR for a documented feature request or bug, even if that was and will be your only contribution to this project, are always welcome and is *not* what this is about.
|
||||||
|
|
||||||
@@ -101,7 +107,7 @@ Beyond that, general rules to consider:
|
|||||||
|
|
||||||
- Maintainability is key: It's not just about implementing something and being done with it, it's about maintaining it, fixing bugs as they occur, improving on it and supporting it in the long run.
|
- Maintainability is key: It's not just about implementing something and being done with it, it's about maintaining it, fixing bugs as they occur, improving on it and supporting it in the long run.
|
||||||
- Others can do it better: Some problems have been solved by very talented developers. These things don't need to be reinvented again here in this project.
|
- Others can do it better: Some problems have been solved by very talented developers. These things don't need to be reinvented again here in this project.
|
||||||
- Develop for the 80%: New features and additions *should* be beneficial for 80% of the users. If you are trying to solve your own problem that only applies to you, maybe that would be better to do in your own fork or if possible by a standalone implementation using the API.
|
- Develop for the 80%: New features and additions *should* be beneficial for 80% of the users. If you are trying to solve your own problem that only apply to you, maybe that would be better to do in your own fork or if possible by a standalone implementation using the API.
|
||||||
- If all of that sounds too strict for you, as stated above, start becoming a regular contributor to this project.
|
- If all of that sounds too strict for you, as stated above, start becoming a regular contributor to this project.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -127,7 +133,7 @@ The documentation available at [docs.tubearchivist.com](https://docs.tubearchivi
|
|||||||
This codebase is set up to be developed natively outside of docker as well as in a docker container. Developing outside of a docker container can be convenient, as IDE and hot reload usually works out of the box. But testing inside of a container is still essential, as there are subtle differences, especially when working with the filesystem and networking between containers.
|
This codebase is set up to be developed natively outside of docker as well as in a docker container. Developing outside of a docker container can be convenient, as IDE and hot reload usually works out of the box. But testing inside of a container is still essential, as there are subtle differences, especially when working with the filesystem and networking between containers.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
- Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers.
|
- Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers in the container.
|
||||||
|
|
||||||
### Native Instruction
|
### Native Instruction
|
||||||
|
|
||||||
@@ -177,12 +183,6 @@ And the frontend should be available at [localhost:3000](localhost:3000).
|
|||||||
|
|
||||||
### Docker Instructions
|
### Docker Instructions
|
||||||
|
|
||||||
Set up docker on your development machine.
|
|
||||||
|
|
||||||
Clone this repository.
|
|
||||||
|
|
||||||
Functional changes should be made against the unstable `testing` branch, so check that branch out, then make a new branch for your work.
|
|
||||||
|
|
||||||
Edit the `docker-compose.yml` file and replace the [`image: bbilly1/tubearchivist` line](https://github.com/tubearchivist/tubearchivist/blob/4af12aee15620e330adf3624c984c3acf6d0ac8b/docker-compose.yml#L7) with `build: .`. Also make any other changes to the environment variables and so on necessary to run the application, just like you're launching the application as normal.
|
Edit the `docker-compose.yml` file and replace the [`image: bbilly1/tubearchivist` line](https://github.com/tubearchivist/tubearchivist/blob/4af12aee15620e330adf3624c984c3acf6d0ac8b/docker-compose.yml#L7) with `build: .`. Also make any other changes to the environment variables and so on necessary to run the application, just like you're launching the application as normal.
|
||||||
|
|
||||||
Run `docker compose up --build`. This will bring up the application. Kill it with `ctrl-c` or by running `docker compose down` from a new terminal window in the same directory.
|
Run `docker compose up --build`. This will bring up the application. Kill it with `ctrl-c` or by running `docker compose down` from a new terminal window in the same directory.
|
||||||
@@ -231,7 +231,7 @@ If you want to run queries on the Elasticsearch container directly from your hos
|
|||||||
The token will get stored in ES in the `config` folder, and not in the `data` folder. To persist the token between ES container rebuilds, you'll need to persist the config folder as an additional volume:
|
The token will get stored in ES in the `config` folder, and not in the `data` folder. To persist the token between ES container rebuilds, you'll need to persist the config folder as an additional volume:
|
||||||
|
|
||||||
1. Create the token as described above
|
1. Create the token as described above
|
||||||
2. While the container is running, copy the current config folder out of the container, e.g.:
|
2. While the container is running, copy the current config folder out of the container, e.g.:
|
||||||
```
|
```
|
||||||
docker cp archivist-es:/usr/share/elasticsearch/config/ volume/es_config
|
docker cp archivist-es:/usr/share/elasticsearch/config/ volume/es_config
|
||||||
```
|
```
|
||||||
|
|||||||
21
Dockerfile
21
Dockerfile
@@ -1,20 +1,24 @@
|
|||||||
# multi stage to build tube archivist
|
# multi stage to build tube archivist
|
||||||
# build python wheel, download and extract ffmpeg, copy into final image
|
# build python wheel, download and extract ffmpeg, copy into final image
|
||||||
|
|
||||||
|
FROM node:lts-alpine AS npm-builder
|
||||||
|
COPY frontend/package.json frontend/package-lock.json /
|
||||||
|
RUN npm i
|
||||||
|
|
||||||
FROM node:lts-alpine AS node-builder
|
FROM node:lts-alpine AS node-builder
|
||||||
|
|
||||||
# RUN npm config set registry https://registry.npmjs.org/
|
# RUN npm config set registry https://registry.npmjs.org/
|
||||||
|
|
||||||
|
COPY --from=npm-builder ./node_modules /frontend/node_modules
|
||||||
COPY ./frontend /frontend
|
COPY ./frontend /frontend
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
RUN npm i
|
|
||||||
RUN npm run build:deploy
|
RUN npm run build:deploy
|
||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
# First stage to build python wheel
|
# First stage to build python wheel
|
||||||
FROM python:3.11.8-slim-bookworm AS builder
|
FROM python:3.11.13-slim-bookworm AS builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
|
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
|
||||||
@@ -24,7 +28,7 @@ COPY ./backend/requirements.txt /requirements.txt
|
|||||||
RUN pip install --user -r requirements.txt
|
RUN pip install --user -r requirements.txt
|
||||||
|
|
||||||
# build ffmpeg
|
# build ffmpeg
|
||||||
FROM python:3.11.8-slim-bookworm AS ffmpeg-builder
|
FROM python:3.11.13-slim-bookworm AS ffmpeg-builder
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@ COPY docker_assets/ffmpeg_download.py ffmpeg_download.py
|
|||||||
RUN python ffmpeg_download.py $TARGETPLATFORM
|
RUN python ffmpeg_download.py $TARGETPLATFORM
|
||||||
|
|
||||||
# build final image
|
# build final image
|
||||||
FROM python:3.11.8-slim-bookworm AS tubearchivist
|
FROM python:3.11.13-slim-bookworm AS tubearchivist
|
||||||
|
|
||||||
ARG INSTALL_DEBUG
|
ARG INSTALL_DEBUG
|
||||||
|
|
||||||
@@ -54,9 +58,9 @@ RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recomm
|
|||||||
|
|
||||||
# install debug tools for testing environment
|
# install debug tools for testing environment
|
||||||
RUN if [ "$INSTALL_DEBUG" ] ; then \
|
RUN if [ "$INSTALL_DEBUG" ] ; then \
|
||||||
apt-get -y update && apt-get -y install --no-install-recommends \
|
apt-get -y update && apt-get -y install --no-install-recommends \
|
||||||
vim htop bmon net-tools iputils-ping procps lsof \
|
vim htop bmon net-tools iputils-ping procps lsof \
|
||||||
&& pip install --user ipython pytest pytest-django \
|
&& pip install --user ipython pytest pytest-django \
|
||||||
; fi
|
; fi
|
||||||
|
|
||||||
# make folders
|
# make folders
|
||||||
@@ -70,6 +74,7 @@ RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
|
|||||||
COPY ./backend /app
|
COPY ./backend /app
|
||||||
COPY ./docker_assets/run.sh /app
|
COPY ./docker_assets/run.sh /app
|
||||||
COPY ./docker_assets/backend_start.py /app
|
COPY ./docker_assets/backend_start.py /app
|
||||||
|
COPY ./docker_assets/beat_auto_spawn.sh /app
|
||||||
|
|
||||||
COPY --from=node-builder ./frontend/dist /app/static
|
COPY --from=node-builder ./frontend/dist /app/static
|
||||||
|
|
||||||
|
|||||||
73
README.md
73
README.md
@@ -1,4 +1,4 @@
|
|||||||

|

|
||||||
[*more screenshots and video*](SHOWCASE.MD)
|
[*more screenshots and video*](SHOWCASE.MD)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -23,13 +23,13 @@
|
|||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
## 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:
|
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](https://github.com/yt-dlp/yt-dlp)**
|
* Download Videos using **[yt-dlp](https://github.com/yt-dlp/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
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
|
- [Discord](https://www.tubearchivist.com/discord): 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.
|
||||||
@@ -38,8 +38,8 @@ Once your YouTube video collection grows, it becomes hard to search and find a s
|
|||||||
- [Plex Plugin](https://github.com/tubearchivist/tubearchivist-plex): Add your videos to Plex
|
- [Plex Plugin](https://github.com/tubearchivist/tubearchivist-plex): Add your videos to Plex
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
For minimal system requirements, the Tube Archivist stack needs around 2GB of available memory for a small testing setup and around 4GB of available memory for a mid to large sized installation. Minimal with dual core with 4 threads, better quad core plus.
|
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.
|
This project requires docker. Ensure it is installed and running on your system.
|
||||||
|
|
||||||
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/) and [Podman](https://docs.tubearchivist.com/installation/podman/).
|
The documentation has additional user provided instructions for [Unraid](https://docs.tubearchivist.com/installation/unraid/), [Synology](https://docs.tubearchivist.com/installation/synology/) and [Podman](https://docs.tubearchivist.com/installation/podman/).
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
|
|||||||
|
|
||||||
All environment variables are explained in detail in the docs [here](https://docs.tubearchivist.com/installation/env-vars/).
|
All environment variables are explained in detail in the docs [here](https://docs.tubearchivist.com/installation/env-vars/).
|
||||||
|
|
||||||
**TubeArchivist**:
|
**TubeArchivist**:
|
||||||
| Environment Var | Value | |
|
| Environment Var | Value | |
|
||||||
| ----------- | ----------- | ----------- |
|
| ----------- | ----------- | ----------- |
|
||||||
| TA_HOST | Server IP or hostname `http://tubearchivist.local:8000` | Required |
|
| TA_HOST | Server IP or hostname `http://tubearchivist.local:8000` | Required |
|
||||||
@@ -71,9 +71,19 @@ All environment variables are explained in detail in the docs [here](https://doc
|
|||||||
| ELASTIC_USER | Change the default ElasticSearch user | Optional |
|
| ELASTIC_USER | Change the default ElasticSearch user | Optional |
|
||||||
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
|
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
|
||||||
| DISABLE_STATIC_AUTH | Remove authentication from media files, (Google Cast...) | [Read more](https://docs.tubearchivist.com/installation/env-vars/#disable_static_auth) |
|
| DISABLE_STATIC_AUTH | Remove authentication from media files, (Google Cast...) | [Read more](https://docs.tubearchivist.com/installation/env-vars/#disable_static_auth) |
|
||||||
|
| TA_AUTO_UPDATE_YTDLP | Configure TA to automatically install the latest yt-dlp on container start | Optional |
|
||||||
| DJANGO_DEBUG | Return additional error messages, for debug only | Optional |
|
| DJANGO_DEBUG | Return additional error messages, for debug only | Optional |
|
||||||
|
| TA_LOGIN_AUTH_MODE | Configure the order of login authentication backends (Default: single) | Optional |
|
||||||
|
|
||||||
**ElasticSearch**
|
| TA_LOGIN_AUTH_MODE value | Description |
|
||||||
|
| ------------------------ | ----------- |
|
||||||
|
| single | Only use a single backend (default, or LDAP, or Forward auth, selected by TA_LDAP or TA_ENABLE_AUTH_PROXY) |
|
||||||
|
| local | Use local password database only |
|
||||||
|
| ldap | Use LDAP backend only |
|
||||||
|
| forwardauth | Use reverse proxy headers only |
|
||||||
|
| ldap_local | Use LDAP backend in addition to the local password database |
|
||||||
|
|
||||||
|
**ElasticSearch**
|
||||||
| Environment Var | Value | State |
|
| Environment Var | Value | State |
|
||||||
| ----------- | ----------- | ----------- |
|
| ----------- | ----------- | ----------- |
|
||||||
| ELASTIC_PASSWORD | Matching password `ELASTIC_PASSWORD` from TubeArchivist | Required |
|
| ELASTIC_PASSWORD | Matching password `ELASTIC_PASSWORD` from TubeArchivist | Required |
|
||||||
@@ -81,58 +91,58 @@ All environment variables are explained in detail in the docs [here](https://doc
|
|||||||
|
|
||||||
|
|
||||||
## Update
|
## Update
|
||||||
Always use the *latest* (the default) or a named semantic version tag for the docker images. The *unstable* tags are only for your testing environment, there might not be an update path for these testing builds.
|
Always use the *latest* (the default) or a named semantic version tag for the docker images. The *unstable* tags see [CONTRIBUTING.md#beta-testing](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#beta-testing).
|
||||||
|
|
||||||
You will see the current version number of **Tube Archivist** in the footer of the interface. There is a daily version check task querying tubearchivist.com, notifying you of any new releases in the footer. To update, you need to update the docker images, the method for which will depend on your platform. For example, if you're using `docker-compose`, run `docker-compose pull` and then restart with `docker-compose up -d`. After updating, check the footer to verify you are running the expected version.
|
You will see the current version number of **Tube Archivist** in the footer of the interface. There is a daily version check task querying tubearchivist.com, notifying you of any new releases in the footer. After updating, check the footer to verify you are running the expected version.
|
||||||
|
|
||||||
- This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported and you might have to reset your index and configurations to update. Ideally apply new updates at least once per month.
|
- This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported. 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.
|
- 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.
|
- All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated from time to time. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version.
|
||||||
|
|
||||||
## Getting Started
|
## 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:
|
||||||
```
|
```
|
||||||
bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
|
bestvideo[vcodec*=avc1]+bestaudio[acodec*=mp4a]/mp4
|
||||||
```
|
```
|
||||||
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
|
2. Subscribe to some of your favorite YouTube channels on the **channels** page.
|
||||||
3. On the **downloads** page, click on *Rescan subscriptions* to add videos from the subscribed channels to your Download queue or click on *Add to download queue* to manually add Video IDs, links, channels or playlists.
|
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
|
### 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.
|
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.
|
For more information on port collisions, check the docs.
|
||||||
|
|
||||||
## Common Errors
|
## Common Errors
|
||||||
Here is a list of common errors and their solutions.
|
Here is a list of common errors and their solutions.
|
||||||
|
|
||||||
### `vm.max_map_count`
|
### `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.
|
**Elastic Search** in Docker requires the kernel setting of the host machine `vm.max_map_count` to be set to at least 262144.
|
||||||
|
|
||||||
To temporary set the value run:
|
To temporary set the value run:
|
||||||
```
|
```
|
||||||
sudo sysctl -w vm.max_map_count=262144
|
sudo sysctl -w vm.max_map_count=262144
|
||||||
```
|
```
|
||||||
To apply the change permanently depends on your host operating system:
|
To apply the change permanently depends on your host operating system:
|
||||||
|
|
||||||
- For example on Ubuntu Server add `vm.max_map_count = 262144` to the file `/etc/sysctl.conf`.
|
- 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 Arch based systems create a file `/etc/sysctl.d/max_map_count.conf` with the content `vm.max_map_count = 262144`.
|
||||||
- On any other platform look up in the documentation on how to pass kernel parameters.
|
- On any other platform look up in the documentation on how to pass kernel parameters.
|
||||||
|
|
||||||
|
|
||||||
### Permissions for elasticsearch
|
### Permissions for elasticsearch
|
||||||
If you see a message similar to `Unable to access 'path.repo' (/usr/share/elasticsearch/data/snapshot)` or `failed to obtain node locks, tried [/usr/share/elasticsearch/data]` and `maybe these locations are not writable` when initially starting elasticsearch, that probably means the container is not allowed to write files to the volume.
|
If you see a message similar to `Unable to access 'path.repo' (/usr/share/elasticsearch/data/snapshot)` or `failed to obtain node locks, tried [/usr/share/elasticsearch/data]` and `maybe these locations are not writable` when initially starting elasticsearch, that probably means the container is not allowed to write files to the volume.
|
||||||
To fix that issue, shutdown the container and on your host machine run:
|
To fix that issue, shutdown the container and on your host machine run:
|
||||||
```
|
```
|
||||||
chown 1000:0 -R /path/to/mount/point
|
chown 1000:0 -R /path/to/mount/point
|
||||||
```
|
```
|
||||||
This will match the permissions with the **UID** and **GID** of elasticsearch process within the container and should fix the issue.
|
This will match the permissions with the **UID** and **GID** of elasticsearch process within the container and should fix the issue.
|
||||||
|
|
||||||
|
|
||||||
### Disk usage
|
### Disk usage
|
||||||
The Elasticsearch index will turn to ***read only*** if the disk usage of the container goes above 95% until the usage drops below 90% again, you will see error messages like `disk usage exceeded flood-stage watermark`.
|
The Elasticsearch index will turn to ***read only*** if the disk usage of the container goes above 95% until the usage drops below 90% again, you will see error messages like `disk usage exceeded flood-stage watermark`.
|
||||||
|
|
||||||
Similar to that, TubeArchivist will become all sorts of messed up when running out of disk space. There are some error messages in the logs when that happens, but it's best to make sure to have enough disk space before starting to download.
|
Similar to that, TubeArchivist will become all sorts of messed up when running out of disk space. There are some error messages in the logs when that happens, but it's best to make sure to have enough disk space before starting to download.
|
||||||
|
|
||||||
@@ -142,7 +152,7 @@ If you are seeing errors like `failed to create shim: OCI runtime create failed`
|
|||||||
This can happen if you have nested virtualizations, e.g. LXC running Docker in Proxmox.
|
This can happen if you have nested virtualizations, e.g. LXC running Docker in Proxmox.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
- Video files created by Tube Archivist need to be playable in your browser of choice. Not every codec is compatible with every browser and might require some testing with format selection.
|
- Video files created by Tube Archivist need to be playable in your browser of choice. Not every codec is compatible with every browser and might require some testing with format selection.
|
||||||
- Every limitation of **yt-dlp** will also be present in Tube Archivist. If **yt-dlp** can't download or extract a video for any reason, Tube Archivist won't be able to either.
|
- Every limitation of **yt-dlp** will also be present in Tube Archivist. If **yt-dlp** can't download or extract a video for any reason, Tube Archivist won't be able to either.
|
||||||
- There is no flexibility in naming of the media files.
|
- There is no flexibility in naming of the media files.
|
||||||
|
|
||||||
@@ -158,10 +168,10 @@ We have come far, nonetheless we are not short of ideas on how to improve and ex
|
|||||||
- [ ] Download or 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))
|
||||||
- [ ] Search comments
|
- [ ] Search comments
|
||||||
- [ ] Search download queue
|
|
||||||
- [ ] Per user videos/channel/playlists
|
- [ ] Per user videos/channel/playlists
|
||||||
|
|
||||||
Implemented:
|
Implemented:
|
||||||
|
- [X] Search download queue [2025-07-31]
|
||||||
- [X] Configure shorts, streams and video sizes per channel [2024-07-15]
|
- [X] Configure shorts, streams and video sizes per channel [2024-07-15]
|
||||||
- [X] User created playlists [2024-04-10]
|
- [X] User created playlists [2024-04-10]
|
||||||
- [X] User roles, aka read only user [2023-11-10]
|
- [X] User roles, aka read only user [2023-11-10]
|
||||||
@@ -191,7 +201,7 @@ Implemented:
|
|||||||
- [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]
|
||||||
|
|
||||||
## User Scripts
|
## User Scripts
|
||||||
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. Make sure to check the respective repository links for detailed license information.
|
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. Make sure to check the respective repository links for detailed license information.
|
||||||
|
|
||||||
This is your time to shine, [read this](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#user-scripts) then open a PR to add your script here.
|
This is your time to shine, [read this](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#user-scripts) then open a PR to add your script here.
|
||||||
|
|
||||||
@@ -200,13 +210,14 @@ This is your time to shine, [read this](https://github.com/tubearchivist/tubearc
|
|||||||
- [DarkFighterLuke/ta_base_url_nginx](https://gist.github.com/DarkFighterLuke/4561b6bfbf83720493dc59171c58ac36): Set base URL with Nginx when you can't use subdomains.
|
- [DarkFighterLuke/ta_base_url_nginx](https://gist.github.com/DarkFighterLuke/4561b6bfbf83720493dc59171c58ac36): Set base URL with Nginx when you can't use subdomains.
|
||||||
- [lamusmaser/ta_migration_helper](https://github.com/lamusmaser/ta_migration_helper): Advanced helper script for migration issues to TubeArchivist v0.4.4 or later.
|
- [lamusmaser/ta_migration_helper](https://github.com/lamusmaser/ta_migration_helper): Advanced helper script for migration issues to TubeArchivist v0.4.4 or later.
|
||||||
- [lamusmaser/create_info_json](https://gist.github.com/lamusmaser/837fb58f73ea0cad784a33497932e0dd): Script to generate `.info.json` files using `ffmpeg` collecting information from downloaded videos.
|
- [lamusmaser/create_info_json](https://gist.github.com/lamusmaser/837fb58f73ea0cad784a33497932e0dd): Script to generate `.info.json` files using `ffmpeg` collecting information from downloaded videos.
|
||||||
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
|
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
|
||||||
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles.
|
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles.
|
||||||
- [tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State](https://github.com/tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State) Mark videos watched in Plex (through streaming not manually) through Tautulli back to TubeArchivist
|
- [tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State](https://github.com/tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State) Mark videos watched in Plex (through streaming not manually) through Tautulli back to TubeArchivist
|
||||||
- [Dhs92/delete_shorts](https://github.com/Dhs92/delete_shorts): A script to delete ALL YouTube Shorts from TubeArchivist
|
- [Dhs92/delete_shorts](https://github.com/Dhs92/delete_shorts): A script to delete ALL YouTube Shorts from TubeArchivist
|
||||||
|
- [arisenfromtheashes/TA_DVR](https://github.com/arisenfromtheashes/TA_DVR): Scripts to assist in using Tube Archivist like a DVR
|
||||||
|
|
||||||
## 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:
|
||||||
* [GitHub Sponsor](https://github.com/sponsors/bbilly1) become a sponsor here on GitHub
|
* [GitHub Sponsor](https://github.com/sponsors/bbilly1) become a sponsor here on GitHub
|
||||||
* [Paypal.me](https://paypal.me/bbilly1) for a one time coffee
|
* [Paypal.me](https://paypal.me/bbilly1) for a one time coffee
|
||||||
|
|||||||
79
assets/tube-archivist-logo-dark.svg
Normal file
79
assets/tube-archivist-logo-dark.svg
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: #039a86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st3 {
|
||||||
|
clip-path: url(#clippath-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
fill: #06131a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
clip-path: url(#clippath-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st7 {
|
||||||
|
clip-path: url(#clippath-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
clip-path: url(#clippath);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<clipPath id="clippath">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-1">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-2">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-3">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="Artwork_1" class="st6">
|
||||||
|
<g class="st8">
|
||||||
|
<g class="st3">
|
||||||
|
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
|
||||||
|
<polygon class="st4" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
|
||||||
|
<rect class="st4" x="261.1" y="554.4" width="90.8" height="200.1"/>
|
||||||
|
<polygon class="st4" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
|
||||||
|
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
|
||||||
|
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Artwork_2">
|
||||||
|
<g class="st7">
|
||||||
|
<g class="st5">
|
||||||
|
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
|
||||||
|
<polygon class="st0" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
|
||||||
|
<rect class="st0" x="261.1" y="554.4" width="90.8" height="200.1"/>
|
||||||
|
<polygon class="st0" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
|
||||||
|
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
|
||||||
|
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
79
assets/tube-archivist-logo-light.svg
Normal file
79
assets/tube-archivist-logo-light.svg
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.5.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 137) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: #039a86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2 {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st3 {
|
||||||
|
clip-path: url(#clippath-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
fill: #06131a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
clip-path: url(#clippath-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st7 {
|
||||||
|
clip-path: url(#clippath-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
clip-path: url(#clippath);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<clipPath id="clippath">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-1">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-2">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clippath-3">
|
||||||
|
<rect class="st2" x="25.6" y="22.9" width="948.9" height="954.2"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="Artwork_1">
|
||||||
|
<g class="st8">
|
||||||
|
<g class="st3">
|
||||||
|
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
|
||||||
|
<polygon class="st4" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
|
||||||
|
<rect class="st4" x="261.1" y="554.4" width="90.8" height="200.1"/>
|
||||||
|
<polygon class="st4" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
|
||||||
|
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
|
||||||
|
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Artwork_2" class="st6">
|
||||||
|
<g class="st7">
|
||||||
|
<g class="st5">
|
||||||
|
<path class="st1" d="M447.2,22.9v15.2C269.3,59.3,118.8,179.4,58.6,348.1l76,21.8c49.9-135.2,169.9-232.2,312.6-252.7v15.4h35.3s0-109.7,0-109.7h-35.3ZM523,34.5v79.1c142.3,7.7,269.2,91.9,331.7,219.9l-14.8,4.2,9.7,33.7,106.6-30.3-9.7-33.9-14.9,4.3c-73.1-161.9-231-269-408.5-277M957.6,382.9l-75.8,21.7c8.9,32.9,13.6,66.8,13.8,100.8-.2,103.8-41.6,203.3-114.9,276.8l-9.4-12.6-28.6,20.8,11.9,16,46.5,64,6.6,9.1,28.6-20.8-8.8-12.1c93.6-88.8,146.7-212.1,147-341.1-.2-41.4-5.9-82.6-16.8-122.6M35.3,383.5l-9.7,33.9,14,4c-5.3,27.7-8.1,55.8-8.4,84,0,145.5,67.3,282.8,182.1,372.1l46.5-64c-94.4-74.4-149.6-187.9-149.8-308.1.3-20.8,2.2-41.6,5.8-62.1l15.1,4.1,9.7-33.9-17.9-4.9-75.7-21.7-11.6-3.3ZM303.8,820.6l-64.8,88.8,28.6,20.8,8.5-11.7c69.4,38.3,147.4,58.5,226.7,58.7,94.9,0,187.7-28.7,266.1-82.2l-46.6-64.1c-64.8,43.9-141.2,67.3-219.5,67.5-62.6-.3-124.2-15.5-179.8-44.4l9.4-12.6-28.6-20.8Z"/>
|
||||||
|
<polygon class="st0" points="114.9 238.4 115.1 324.3 261.3 324.3 261.1 458.5 351.9 458.5 352.1 324.3 495.9 324.3 495.6 238 114.9 238.4"/>
|
||||||
|
<rect class="st0" x="261.1" y="554.4" width="90.8" height="200.1"/>
|
||||||
|
<polygon class="st0" points="622.7 244.2 429.6 754.5 526.4 754.4 666.6 361.6 806 754.4 902.9 754.4 710.4 244.2 622.7 244.2"/>
|
||||||
|
<path class="st1" d="M255.5,476.4c-16.5,0-29.9,13.6-29.9,30.1.2,17.6,16.1,30.1,30,30.1,34.5,0,69.9,0,103.3,0,16.1,0,28.9-14,28.9-30.1,0-16.1-12.2-30.1-28.8-30.1-35.8,0-72.8,0-103.4,0"/>
|
||||||
|
<path class="st1" d="M665.5,483.6c-16.1,0-29.8,12.2-29.8,28.8v172l-37.8-38.9-25,24.5,92.2,93.8,94.3-93.8-25-24.5-38.9,38.9c0-23.6,0-40.8,0-68.6-.3-34.5,0-69,0-103.6,0-16.1-13.7-28.6-29.8-28.6h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -1,12 +1,7 @@
|
|||||||
{
|
{
|
||||||
"index_config": [{
|
"index_config": [{
|
||||||
"index_name": "config",
|
"index_name": "config",
|
||||||
"expected_map": {
|
"expected_map": {},
|
||||||
"config": {
|
|
||||||
"type": "object",
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"expected_set": {
|
"expected_set": {
|
||||||
"number_of_replicas": "0"
|
"number_of_replicas": "0"
|
||||||
}
|
}
|
||||||
@@ -62,6 +57,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"channel_tabs": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
"channel_overwrites": {
|
"channel_overwrites": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"download_format": {
|
"download_format": {
|
||||||
@@ -165,6 +163,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"channel_tabs": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
"channel_overwrites": {
|
"channel_overwrites": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"download_format": {
|
"download_format": {
|
||||||
@@ -239,7 +240,8 @@
|
|||||||
"type": "keyword"
|
"type": "keyword"
|
||||||
},
|
},
|
||||||
"published": {
|
"published": {
|
||||||
"type": "date"
|
"type": "date",
|
||||||
|
"format": "epoch_second||strict_date_optional_time"
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -361,6 +363,15 @@
|
|||||||
"category": {
|
"category": {
|
||||||
"type": "keyword"
|
"type": "keyword"
|
||||||
},
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text",
|
||||||
|
"fields": {
|
||||||
|
"keyword": {
|
||||||
|
"type": "keyword",
|
||||||
|
"ignore_above": 256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"type": "short"
|
"type": "short"
|
||||||
},
|
},
|
||||||
@@ -460,6 +471,15 @@
|
|||||||
"playlist_description": {
|
"playlist_description": {
|
||||||
"type": "text"
|
"type": "text"
|
||||||
},
|
},
|
||||||
|
"playlist_subscribed": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"playlist_type": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"playlist_active": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"playlist_name": {
|
"playlist_name": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"analyzer": "english",
|
"analyzer": "english",
|
||||||
@@ -496,6 +516,9 @@
|
|||||||
"type": "date",
|
"type": "date",
|
||||||
"format": "epoch_second"
|
"format": "epoch_second"
|
||||||
},
|
},
|
||||||
|
"playlist_sort_order": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
"playlist_entries": {
|
"playlist_entries": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"downloaded": {
|
"downloaded": {
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ class AppConfigSubSerializer(
|
|||||||
):
|
):
|
||||||
"""serialize app config subscriptions"""
|
"""serialize app config subscriptions"""
|
||||||
|
|
||||||
channel_size = serializers.IntegerField(required=False)
|
channel_size = serializers.IntegerField(required=False, allow_null=True)
|
||||||
live_channel_size = serializers.IntegerField(required=False)
|
live_channel_size = serializers.IntegerField(
|
||||||
shorts_channel_size = serializers.IntegerField(required=False)
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
shorts_channel_size = serializers.IntegerField(
|
||||||
|
required=False, allow_null=True
|
||||||
|
)
|
||||||
|
playlist_size = serializers.IntegerField(required=False, allow_null=True)
|
||||||
auto_start = serializers.BooleanField(required=False)
|
auto_start = serializers.BooleanField(required=False)
|
||||||
|
extract_flat = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
class AppConfigDownloadsSerializer(
|
class AppConfigDownloadsSerializer(
|
||||||
@@ -130,4 +136,4 @@ class SnapshotRestoreResponseSerializer(serializers.Serializer):
|
|||||||
class TokenResponseSerializer(serializers.Serializer):
|
class TokenResponseSerializer(serializers.Serializer):
|
||||||
"""serialize token response"""
|
"""serialize token response"""
|
||||||
|
|
||||||
token = serializers.CharField()
|
token = serializers.CharField(allow_null=True)
|
||||||
|
|||||||
31
backend/appsettings/serializers_mb.py
Normal file
31
backend/appsettings/serializers_mb.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""membership platform serializers"""
|
||||||
|
|
||||||
|
# pylint: disable=abstract-method
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipUserSerializer(serializers.Serializer):
|
||||||
|
"""serialize user"""
|
||||||
|
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
username = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class SponsortierSerializer(serializers.Serializer):
|
||||||
|
"""serialize sponsor tier"""
|
||||||
|
|
||||||
|
tier_id = serializers.IntegerField()
|
||||||
|
name = serializers.CharField()
|
||||||
|
description = serializers.CharField()
|
||||||
|
max_subs = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipProfileSerializer(serializers.Serializer):
|
||||||
|
"""serialize membership profile"""
|
||||||
|
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
user = MembershipUserSerializer()
|
||||||
|
sponsor_tier = SponsortierSerializer()
|
||||||
|
subscription_count = serializers.IntegerField()
|
||||||
|
subscription_is_max = serializers.BooleanField()
|
||||||
@@ -153,7 +153,7 @@ class ElasticBackup:
|
|||||||
def restore(self, filename):
|
def restore(self, filename):
|
||||||
"""
|
"""
|
||||||
restore from backup zip file
|
restore from backup zip file
|
||||||
call reset from ElasitIndexWrap first to start blank
|
call reset from ElasticIndexWrap first to start blank
|
||||||
"""
|
"""
|
||||||
zip_content = self._unpack_zip_backup(filename)
|
zip_content = self._unpack_zip_backup(filename)
|
||||||
self._restore_json_files(zip_content)
|
self._restore_json_files(zip_content)
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ class SubscriptionsConfigType(TypedDict):
|
|||||||
channel_size: int
|
channel_size: int
|
||||||
live_channel_size: int
|
live_channel_size: int
|
||||||
shorts_channel_size: int
|
shorts_channel_size: int
|
||||||
|
playlist_size: int
|
||||||
auto_start: bool
|
auto_start: bool
|
||||||
|
extract_flat: bool
|
||||||
|
|
||||||
|
|
||||||
class DownloadsConfigType(TypedDict):
|
class DownloadsConfigType(TypedDict):
|
||||||
@@ -72,7 +74,9 @@ class AppConfig:
|
|||||||
"channel_size": 50,
|
"channel_size": 50,
|
||||||
"live_channel_size": 50,
|
"live_channel_size": 50,
|
||||||
"shorts_channel_size": 50,
|
"shorts_channel_size": 50,
|
||||||
|
"playlist_size": 50,
|
||||||
"auto_start": False,
|
"auto_start": False,
|
||||||
|
"extract_flat": False,
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"limit_speed": None,
|
"limit_speed": None,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ElasticIndex:
|
|||||||
returns True when rebuild is needed
|
returns True when rebuild is needed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.expected_map:
|
if self.expected_map or self.expected_map == {}:
|
||||||
rebuild = self.validate_mappings()
|
rebuild = self.validate_mappings()
|
||||||
if rebuild:
|
if rebuild:
|
||||||
return rebuild
|
return rebuild
|
||||||
@@ -49,7 +49,7 @@ class ElasticIndex:
|
|||||||
|
|
||||||
def validate_mappings(self):
|
def validate_mappings(self):
|
||||||
"""check if all mappings are as expected"""
|
"""check if all mappings are as expected"""
|
||||||
now_map = self.details["mappings"]["properties"]
|
now_map = self.details["mappings"].get("properties", {})
|
||||||
|
|
||||||
for key, value in self.expected_map.items():
|
for key, value in self.expected_map.items():
|
||||||
# nested
|
# nested
|
||||||
@@ -75,6 +75,10 @@ class ElasticIndex:
|
|||||||
print(f"detected mapping change: {key}, {value}")
|
print(f"detected mapping change: {key}, {value}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# simple doc store
|
||||||
|
if self.expected_map == {} and now_map != self.expected_map:
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def validate_settings(self):
|
def validate_settings(self):
|
||||||
@@ -135,13 +139,16 @@ class ElasticIndex:
|
|||||||
data = {}
|
data = {}
|
||||||
if self.expected_set:
|
if self.expected_set:
|
||||||
data.update({"settings": self.expected_set})
|
data.update({"settings": self.expected_set})
|
||||||
if self.expected_map:
|
if self.expected_map or self.expected_map == {}:
|
||||||
data.update({"mappings": {"properties": self.expected_map}})
|
data.update({"mappings": {"properties": self.expected_map}})
|
||||||
|
if self.index_name == "config":
|
||||||
|
# no indexing for config
|
||||||
|
data["mappings"]["dynamic"] = False
|
||||||
|
|
||||||
_, _ = ElasticWrap(path).put(data)
|
_, _ = ElasticWrap(path).put(data)
|
||||||
|
|
||||||
|
|
||||||
class ElasitIndexWrap:
|
class ElasticIndexWrap:
|
||||||
"""interact with all index mapping and setup"""
|
"""interact with all index mapping and setup"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -201,7 +208,15 @@ class ElasitIndexWrap:
|
|||||||
if self.backup_run:
|
if self.backup_run:
|
||||||
return
|
return
|
||||||
|
|
||||||
config = AppConfig().config
|
try:
|
||||||
|
config = AppConfig().config
|
||||||
|
except ValueError:
|
||||||
|
# create defaults in ES if config not found
|
||||||
|
print("AppConfig not found, creating defaults...")
|
||||||
|
handler = AppConfig.__new__(AppConfig)
|
||||||
|
handler.sync_defaults()
|
||||||
|
config = AppConfig.CONFIG_DEFAULTS
|
||||||
|
|
||||||
if config["application"]["enable_snapshot"]:
|
if config["application"]["enable_snapshot"]:
|
||||||
# take snapshot if enabled
|
# take snapshot if enabled
|
||||||
ElasticSnapshot().take_snapshot_now(wait=True)
|
ElasticSnapshot().take_snapshot_now(wait=True)
|
||||||
|
|||||||
89
backend/appsettings/src/membership.py
Normal file
89
backend/appsettings/src/membership.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
interact with members.tubearchivist.com
|
||||||
|
code related to sponsor perks
|
||||||
|
"""
|
||||||
|
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from appsettings.src.config import AppConfig
|
||||||
|
from common.src.helper import get_channels
|
||||||
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
|
||||||
|
|
||||||
|
class Membership:
|
||||||
|
"""membership"""
|
||||||
|
|
||||||
|
BASE_URL = environ.get("MB_URL", "https://members.tubearchivist.com")
|
||||||
|
REDIS_KEY = "MB:KEY"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config = AppConfig().config
|
||||||
|
|
||||||
|
def get_profile(self):
|
||||||
|
"""get profile"""
|
||||||
|
response = requests.get(
|
||||||
|
f"{self.BASE_URL}/api/profile/me/",
|
||||||
|
headers=self._get_headers(),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _get_headers(self):
|
||||||
|
"""get headers with api key"""
|
||||||
|
token = RedisArchivist().get_message_dict(self.REDIS_KEY)
|
||||||
|
if not token:
|
||||||
|
raise ValueError("expected MB_API_KEY")
|
||||||
|
|
||||||
|
token_str = token["token"]
|
||||||
|
|
||||||
|
return {"Authorization": f"Token {token_str}"}
|
||||||
|
|
||||||
|
def sync_subs(self):
|
||||||
|
"""sync subscriptions, works if within max limits"""
|
||||||
|
to_sync = self._get_to_sync()
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.BASE_URL}/api/profile/subscription/?delete=true",
|
||||||
|
headers=self._get_headers(),
|
||||||
|
json=to_sync,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _get_to_sync(self):
|
||||||
|
"""get channels to sync"""
|
||||||
|
to_sync = []
|
||||||
|
subscribed = get_channels(subscribed_only=True)
|
||||||
|
for channel in subscribed:
|
||||||
|
overwrites = channel.get("channel_overwrites", {})
|
||||||
|
to_sync.append(
|
||||||
|
{
|
||||||
|
"channel_id": channel["channel_id"],
|
||||||
|
"notify_videos": self._notify_videos(overwrites),
|
||||||
|
"notify_streams": self._notify_streams(overwrites),
|
||||||
|
"notify_shorts": self._notify_shorts(overwrites),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return to_sync
|
||||||
|
|
||||||
|
def _notify_videos(self, overwrites: dict) -> bool:
|
||||||
|
"""notify videos"""
|
||||||
|
if overwrites.get("subscriptions_channel_size") == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.config["subscriptions"].get("channel_size") != 0
|
||||||
|
|
||||||
|
def _notify_streams(self, overwrites: dict) -> bool:
|
||||||
|
"""notify streams"""
|
||||||
|
if overwrites.get("subscriptions_live_channel_size") == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.config["subscriptions"].get("live_channel_size") != 0
|
||||||
|
|
||||||
|
def _notify_shorts(self, overwrites: dict) -> bool:
|
||||||
|
"""notify shorts"""
|
||||||
|
if overwrites.get("subscriptions_shorts_channel_size") == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.config["subscriptions"].get("shorts_channel_size") != 0
|
||||||
@@ -11,11 +11,11 @@ from typing import Callable, TypedDict
|
|||||||
|
|
||||||
from appsettings.src.config import AppConfig
|
from appsettings.src.config import AppConfig
|
||||||
from channel.src.index import YoutubeChannel
|
from channel.src.index import YoutubeChannel
|
||||||
|
from channel.src.remote_query import get_last_channel_videos
|
||||||
from common.src.env_settings import EnvironmentSettings
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from common.src.es_connect import ElasticWrap, IndexPaginate
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from common.src.helper import rand_sleep
|
from common.src.helper import rand_sleep
|
||||||
from common.src.ta_redis import RedisQueue
|
from common.src.ta_redis import RedisQueue
|
||||||
from download.src.subscriptions import ChannelSubscription
|
|
||||||
from download.src.thumbnails import ThumbManager
|
from download.src.thumbnails import ThumbManager
|
||||||
from download.src.yt_dlp_base import CookieHandler
|
from download.src.yt_dlp_base import CookieHandler
|
||||||
from playlist.src.index import YoutubePlaylist
|
from playlist.src.index import YoutubePlaylist
|
||||||
@@ -376,7 +376,7 @@ class Reindex(ReindexBase):
|
|||||||
|
|
||||||
channel.upload_to_es()
|
channel.upload_to_es()
|
||||||
channel.sync_to_videos()
|
channel.sync_to_videos()
|
||||||
ChannelFullScan(channel_id).scan()
|
ChannelFullScan(channel_id, self.config).scan()
|
||||||
self.processed["channels"] += 1
|
self.processed["channels"] += 1
|
||||||
|
|
||||||
def _reindex_single_playlist(self, playlist_id: str) -> None:
|
def _reindex_single_playlist(self, playlist_id: str) -> None:
|
||||||
@@ -493,13 +493,11 @@ class ReindexProgress(ReindexBase):
|
|||||||
|
|
||||||
|
|
||||||
class ChannelFullScan:
|
class ChannelFullScan:
|
||||||
"""
|
"""full scan of channel to fix vid_type mismatch"""
|
||||||
update from v0.3.0 to v0.3.1
|
|
||||||
full scan of channel to fix vid_type mismatch
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, channel_id):
|
def __init__(self, channel_id, config):
|
||||||
self.channel_id = channel_id
|
self.channel_id = channel_id
|
||||||
|
self.config = config
|
||||||
self.to_update = False
|
self.to_update = False
|
||||||
|
|
||||||
def scan(self):
|
def scan(self):
|
||||||
@@ -510,12 +508,14 @@ class ChannelFullScan:
|
|||||||
self.to_update = []
|
self.to_update = []
|
||||||
for video in all_local_videos:
|
for video in all_local_videos:
|
||||||
video_id = video["youtube_id"]
|
video_id = video["youtube_id"]
|
||||||
remote_match = [i for i in all_remote_videos if i[0] == video_id]
|
remote_match = [
|
||||||
|
i for i in all_remote_videos if i["id"] == video_id
|
||||||
|
]
|
||||||
if not remote_match:
|
if not remote_match:
|
||||||
print(f"{video_id}: no remote match found")
|
print(f"{video_id}: no remote match found")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expected_type = remote_match[0][-1]
|
expected_type = remote_match[0]["vid_type"]
|
||||||
if video["vid_type"] != expected_type:
|
if video["vid_type"] != expected_type:
|
||||||
self.to_update.append(
|
self.to_update.append(
|
||||||
{
|
{
|
||||||
@@ -528,9 +528,8 @@ class ChannelFullScan:
|
|||||||
|
|
||||||
def _get_all_remote(self):
|
def _get_all_remote(self):
|
||||||
"""get all channel videos"""
|
"""get all channel videos"""
|
||||||
sub = ChannelSubscription()
|
all_remote_videos = get_last_channel_videos(
|
||||||
all_remote_videos = sub.get_last_youtube_videos(
|
self.channel_id, self.config, limit=False
|
||||||
self.channel_id, limit=False
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return all_remote_videos
|
return all_remote_videos
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""all app settings API urls"""
|
"""all app settings API urls"""
|
||||||
|
|
||||||
from appsettings import views
|
from appsettings import views, views_mb
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -44,4 +44,19 @@ urlpatterns = [
|
|||||||
views.TokenView.as_view(),
|
views.TokenView.as_view(),
|
||||||
name="api-token",
|
name="api-token",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"membership/profile/",
|
||||||
|
views_mb.MembershipProfileView.as_view(),
|
||||||
|
name="api-membership-profile",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"membership/sync/",
|
||||||
|
views_mb.MembershipSubscriptionSync.as_view(),
|
||||||
|
name="api-membership-sync",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"membership/token/",
|
||||||
|
views_mb.MembershipToken.as_view(),
|
||||||
|
name="api-membership-token",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
122
backend/appsettings/views_mb.py
Normal file
122
backend/appsettings/views_mb.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""membership platform views"""
|
||||||
|
|
||||||
|
from json import JSONDecodeError
|
||||||
|
|
||||||
|
from appsettings.serializers import TokenResponseSerializer
|
||||||
|
from appsettings.serializers_mb import MembershipProfileSerializer
|
||||||
|
from appsettings.src.membership import Membership
|
||||||
|
from common.serializers import ErrorResponseSerializer
|
||||||
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from common.views_base import AdminOnly, ApiBaseView
|
||||||
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipProfileView(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/membership/profile/
|
||||||
|
GET: get profile status
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(MembershipProfileSerializer()),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="bad request"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get(request):
|
||||||
|
"""get profile"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile_response = Membership().get_profile()
|
||||||
|
except ValueError as error:
|
||||||
|
error = ErrorResponseSerializer({"message": str(error)})
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_json = profile_response.json()
|
||||||
|
except JSONDecodeError:
|
||||||
|
code = profile_response.status_code
|
||||||
|
message = f"Connection to remote server failed: {code}"
|
||||||
|
error_message = {"message": message}
|
||||||
|
return Response(error_message, status=400)
|
||||||
|
|
||||||
|
if profile_response.status_code == 403:
|
||||||
|
message = response_json.get("detail", "undefined error")
|
||||||
|
error_message = {"message": message}
|
||||||
|
return Response(error_message, status=400)
|
||||||
|
|
||||||
|
serializer = MembershipProfileSerializer(data=response_json)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipSubscriptionSync(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/membership/sync/
|
||||||
|
POST: trigger sync task
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def post(request):
|
||||||
|
"""post request"""
|
||||||
|
response = Membership().sync_subs()
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
try:
|
||||||
|
response_json = response.json()
|
||||||
|
message = response_json.get("detail", "undefined error")
|
||||||
|
except JSONDecodeError:
|
||||||
|
code = response.status_code
|
||||||
|
message = f"Connection to remote server failed: {code}"
|
||||||
|
|
||||||
|
error_message = {"message": message}
|
||||||
|
return Response(error_message, status=400)
|
||||||
|
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipToken(ApiBaseView):
|
||||||
|
"""resolves to /api/appsettings/membership/token/
|
||||||
|
GET: get masked token
|
||||||
|
POST: add token
|
||||||
|
DELETE: delete token
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [AdminOnly]
|
||||||
|
REDIS_KEY = "MB:KEY"
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""get token"""
|
||||||
|
token = RedisArchivist().get_message_dict(self.REDIS_KEY)
|
||||||
|
if token:
|
||||||
|
serializer = TokenResponseSerializer(data=token)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
data = serializer.data
|
||||||
|
else:
|
||||||
|
data = {"token": None}
|
||||||
|
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""add token"""
|
||||||
|
serializer = TokenResponseSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
RedisArchivist().set_message(
|
||||||
|
self.REDIS_KEY, message=serializer.data, save=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def delete(self, request):
|
||||||
|
"""delete token"""
|
||||||
|
RedisArchivist().del_message(self.REDIS_KEY)
|
||||||
|
|
||||||
|
return Response(status=204)
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin
|
from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from video.src.constants import VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
class ChannelOverwriteSerializer(
|
class ChannelOverwriteSerializer(
|
||||||
@@ -45,6 +46,9 @@ class ChannelSerializer(serializers.Serializer):
|
|||||||
channel_tags = serializers.ListField(
|
channel_tags = serializers.ListField(
|
||||||
child=serializers.CharField(), required=False
|
child=serializers.CharField(), required=False
|
||||||
)
|
)
|
||||||
|
channel_tabs = serializers.ListField(
|
||||||
|
child=serializers.ChoiceField(VideoTypeEnum.values_known())
|
||||||
|
)
|
||||||
channel_views = serializers.IntegerField()
|
channel_views = serializers.IntegerField()
|
||||||
_index = serializers.CharField(required=False)
|
_index = serializers.CharField(required=False)
|
||||||
_score = serializers.IntegerField(required=False)
|
_score = serializers.IntegerField(required=False)
|
||||||
@@ -60,7 +64,9 @@ class ChannelListSerializer(serializers.Serializer):
|
|||||||
class ChannelListQuerySerializer(serializers.Serializer):
|
class ChannelListQuerySerializer(serializers.Serializer):
|
||||||
"""serialize list query"""
|
"""serialize list query"""
|
||||||
|
|
||||||
filter = serializers.ChoiceField(choices=["subscribed"], required=False)
|
filter = serializers.ChoiceField(
|
||||||
|
choices=["subscribed", "unsubscribed"], required=False
|
||||||
|
)
|
||||||
page = serializers.IntegerField(required=False)
|
page = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -90,6 +96,7 @@ class ChannelNavSerializer(serializers.Serializer):
|
|||||||
"""serialize channel navigation"""
|
"""serialize channel navigation"""
|
||||||
|
|
||||||
has_pending = serializers.BooleanField()
|
has_pending = serializers.BooleanField()
|
||||||
|
has_ignored = serializers.BooleanField()
|
||||||
has_playlists = serializers.BooleanField()
|
has_playlists = serializers.BooleanField()
|
||||||
has_videos = serializers.BooleanField()
|
has_videos = serializers.BooleanField()
|
||||||
has_streams = serializers.BooleanField()
|
has_streams = serializers.BooleanField()
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ functionality:
|
|||||||
- index and update in es
|
- index and update in es
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from channel.src.remote_query import get_last_channel_videos
|
||||||
from common.src.env_settings import EnvironmentSettings
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from common.src.es_connect import ElasticWrap, IndexPaginate
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from common.src.helper import rand_sleep
|
from common.src.helper import rand_sleep
|
||||||
from common.src.index_generic import YouTubeItem
|
from common.src.index_generic import YouTubeItem
|
||||||
from download.src.thumbnails import ThumbManager
|
from download.src.thumbnails import ThumbManager
|
||||||
from download.src.yt_dlp_base import YtWrap
|
from download.src.yt_dlp_base import YtWrap
|
||||||
from playlist.src.index import YoutubePlaylist
|
from video.src.constants import VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
class YoutubeChannel(YouTubeItem):
|
class YoutubeChannel(YouTubeItem):
|
||||||
@@ -70,6 +70,7 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
"channel_thumb_url": self._get_thumb_art(),
|
"channel_thumb_url": self._get_thumb_art(),
|
||||||
"channel_tvart_url": self._get_tv_art(),
|
"channel_tvart_url": self._get_tv_art(),
|
||||||
"channel_views": self.youtube_meta.get("view_count") or 0,
|
"channel_views": self.youtube_meta.get("view_count") or 0,
|
||||||
|
"channel_tabs": self.get_channel_tabs(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_thumb_art(self):
|
def _get_thumb_art(self):
|
||||||
@@ -105,6 +106,31 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_channel_tabs(self) -> list[str]:
|
||||||
|
"""get channel tabs"""
|
||||||
|
tabs = VideoTypeEnum.values_known()
|
||||||
|
config_cp = self.config.copy()
|
||||||
|
config_cp["subscriptions"] = {
|
||||||
|
"channel_size": 1,
|
||||||
|
"live_channel_size": 1,
|
||||||
|
"shorts_channel_size": 1,
|
||||||
|
}
|
||||||
|
tabs = []
|
||||||
|
for query_filter in VideoTypeEnum:
|
||||||
|
if query_filter == VideoTypeEnum.UNKNOWN:
|
||||||
|
continue
|
||||||
|
|
||||||
|
videos = get_last_channel_videos(
|
||||||
|
channel_id=self.youtube_id,
|
||||||
|
config=config_cp,
|
||||||
|
limit=True,
|
||||||
|
query_filter=query_filter,
|
||||||
|
)
|
||||||
|
if videos:
|
||||||
|
tabs.append(query_filter.value)
|
||||||
|
|
||||||
|
return tabs
|
||||||
|
|
||||||
def _video_fallback(self, fallback):
|
def _video_fallback(self, fallback):
|
||||||
"""use video metadata as fallback"""
|
"""use video metadata as fallback"""
|
||||||
print(f"{self.youtube_id}: fallback to video metadata")
|
print(f"{self.youtube_id}: fallback to video metadata")
|
||||||
@@ -122,27 +148,6 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
"channel_thumb_url": False,
|
"channel_thumb_url": False,
|
||||||
"channel_views": 0,
|
"channel_views": 0,
|
||||||
}
|
}
|
||||||
self._info_json_fallback()
|
|
||||||
|
|
||||||
def _info_json_fallback(self):
|
|
||||||
"""read channel info.json for additional metadata"""
|
|
||||||
info_json = os.path.join(
|
|
||||||
EnvironmentSettings.CACHE_DIR,
|
|
||||||
"import",
|
|
||||||
f"{self.youtube_id}.info.json",
|
|
||||||
)
|
|
||||||
if os.path.exists(info_json):
|
|
||||||
print(f"{self.youtube_id}: read info.json file")
|
|
||||||
with open(info_json, "r", encoding="utf-8") as f:
|
|
||||||
content = json.loads(f.read())
|
|
||||||
|
|
||||||
self.json_data.update(
|
|
||||||
{
|
|
||||||
"channel_subs": content.get("channel_follower_count", 0),
|
|
||||||
"channel_description": content.get("description", False),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
os.remove(info_json)
|
|
||||||
|
|
||||||
def get_channel_art(self):
|
def get_channel_art(self):
|
||||||
"""download channel art for new channels"""
|
"""download channel art for new channels"""
|
||||||
@@ -178,46 +183,15 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}"
|
update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}"
|
||||||
_, _ = ElasticWrap(update_path).post(data)
|
_, _ = ElasticWrap(update_path).post(data)
|
||||||
|
|
||||||
def get_folder_path(self):
|
def change_subscribe(self, new_subscribe_state: bool):
|
||||||
"""get folder where media files get stored"""
|
"""change subscribe status"""
|
||||||
folder_path = os.path.join(
|
if not self.json_data:
|
||||||
EnvironmentSettings.MEDIA_DIR,
|
self.build_json()
|
||||||
self.json_data["channel_id"],
|
|
||||||
)
|
|
||||||
return folder_path
|
|
||||||
|
|
||||||
def delete_es_videos(self):
|
self.json_data["channel_subscribed"] = new_subscribe_state
|
||||||
"""delete all channel documents from elasticsearch"""
|
self.upload_to_es()
|
||||||
data = {
|
self.sync_to_videos()
|
||||||
"query": {
|
return self.json_data
|
||||||
"term": {"channel.channel_id": {"value": self.youtube_id}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
|
|
||||||
|
|
||||||
def delete_es_comments(self):
|
|
||||||
"""delete all comments from this channel"""
|
|
||||||
data = {
|
|
||||||
"query": {
|
|
||||||
"term": {"comment_channel_id": {"value": self.youtube_id}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
|
|
||||||
|
|
||||||
def delete_es_subtitles(self):
|
|
||||||
"""delete all subtitles from this channel"""
|
|
||||||
data = {
|
|
||||||
"query": {
|
|
||||||
"term": {"subtitle_channel_id": {"value": self.youtube_id}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data)
|
|
||||||
|
|
||||||
def delete_playlists(self):
|
|
||||||
"""delete all indexed playlist from es"""
|
|
||||||
all_playlists = self.get_indexed_playlists()
|
|
||||||
for playlist in all_playlists:
|
|
||||||
YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
|
|
||||||
|
|
||||||
def delete_channel(self):
|
def delete_channel(self):
|
||||||
"""delete channel and all videos"""
|
"""delete channel and all videos"""
|
||||||
@@ -226,24 +200,7 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
if not self.json_data:
|
if not self.json_data:
|
||||||
raise FileNotFoundError
|
raise FileNotFoundError
|
||||||
|
|
||||||
folder_path = self.get_folder_path()
|
ChannelDelete(json_data=self.json_data).delete()
|
||||||
print(f"{self.youtube_id}: delete all media files")
|
|
||||||
try:
|
|
||||||
all_videos = os.listdir(folder_path)
|
|
||||||
for video in all_videos:
|
|
||||||
video_path = os.path.join(folder_path, video)
|
|
||||||
os.remove(video_path)
|
|
||||||
os.rmdir(folder_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"no videos found for {folder_path}")
|
|
||||||
|
|
||||||
print(f"{self.youtube_id}: delete indexed playlists")
|
|
||||||
self.delete_playlists()
|
|
||||||
print(f"{self.youtube_id}: delete indexed videos")
|
|
||||||
self.delete_es_videos()
|
|
||||||
self.delete_es_comments()
|
|
||||||
self.delete_es_subtitles()
|
|
||||||
self.del_in_es()
|
|
||||||
|
|
||||||
def index_channel_playlists(self):
|
def index_channel_playlists(self):
|
||||||
"""add all playlists of channel to index"""
|
"""add all playlists of channel to index"""
|
||||||
@@ -265,6 +222,21 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
print("add playlist: " + playlist[1])
|
print("add playlist: " + playlist[1])
|
||||||
rand_sleep(self.config)
|
rand_sleep(self.config)
|
||||||
|
|
||||||
|
def get_all_playlists(self):
|
||||||
|
"""get all playlists owned by this channel"""
|
||||||
|
url = (
|
||||||
|
f"https://www.youtube.com/channel/{self.youtube_id}"
|
||||||
|
+ "/playlists?view=1&sort=dd&shelf_id=0"
|
||||||
|
)
|
||||||
|
obs = {"skip_download": True, "extract_flat": True}
|
||||||
|
playlists, _ = YtWrap(obs, self.config).extract(url)
|
||||||
|
if not playlists:
|
||||||
|
self.all_playlists = []
|
||||||
|
return
|
||||||
|
|
||||||
|
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
|
||||||
|
self.all_playlists = all_entries
|
||||||
|
|
||||||
def _notify_single_playlist(self, idx, total):
|
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"]
|
||||||
@@ -277,6 +249,8 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _index_single_playlist(playlist):
|
def _index_single_playlist(playlist):
|
||||||
"""add single playlist if needed"""
|
"""add single playlist if needed"""
|
||||||
|
from playlist.src.index import YoutubePlaylist
|
||||||
|
|
||||||
playlist = YoutubePlaylist(playlist[0])
|
playlist = YoutubePlaylist(playlist[0])
|
||||||
playlist.update_playlist(skip_on_empty=True)
|
playlist.update_playlist(skip_on_empty=True)
|
||||||
|
|
||||||
@@ -291,34 +265,6 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
all_videos = IndexPaginate("ta_video", data).get_results()
|
all_videos = IndexPaginate("ta_video", data).get_results()
|
||||||
return all_videos
|
return all_videos
|
||||||
|
|
||||||
def get_all_playlists(self):
|
|
||||||
"""get all playlists owned by this channel"""
|
|
||||||
url = (
|
|
||||||
f"https://www.youtube.com/channel/{self.youtube_id}"
|
|
||||||
+ "/playlists?view=1&sort=dd&shelf_id=0"
|
|
||||||
)
|
|
||||||
obs = {"skip_download": True, "extract_flat": True}
|
|
||||||
playlists = YtWrap(obs, self.config).extract(url)
|
|
||||||
if not playlists:
|
|
||||||
self.all_playlists = []
|
|
||||||
return
|
|
||||||
|
|
||||||
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
|
|
||||||
self.all_playlists = all_entries
|
|
||||||
|
|
||||||
def get_indexed_playlists(self, active_only=False):
|
|
||||||
"""get all indexed playlists from channel"""
|
|
||||||
must_list = [
|
|
||||||
{"term": {"playlist_channel_id": {"value": self.youtube_id}}}
|
|
||||||
]
|
|
||||||
if active_only:
|
|
||||||
must_list.append({"term": {"playlist_active": {"value": True}}})
|
|
||||||
|
|
||||||
data = {"query": {"bool": {"must": must_list}}}
|
|
||||||
|
|
||||||
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
|
||||||
return all_playlists
|
|
||||||
|
|
||||||
def get_overwrites(self) -> dict:
|
def get_overwrites(self) -> dict:
|
||||||
"""get all per channel overwrites"""
|
"""get all per channel overwrites"""
|
||||||
return self.json_data.get("channel_overwrites", {})
|
return self.json_data.get("channel_overwrites", {})
|
||||||
@@ -349,6 +295,93 @@ class YoutubeChannel(YouTubeItem):
|
|||||||
self.json_data["channel_overwrites"] = to_write
|
self.json_data["channel_overwrites"] = to_write
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelDelete(YouTubeItem):
|
||||||
|
"""delete and cleanup"""
|
||||||
|
|
||||||
|
index_name = "ta_channel"
|
||||||
|
|
||||||
|
def __init__(self, json_data):
|
||||||
|
super().__init__(youtube_id=json_data["channel_id"])
|
||||||
|
self.json_data = json_data
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""delete channel and all videos"""
|
||||||
|
folder_path = self._get_folder_path()
|
||||||
|
print(f"{self.youtube_id}: delete all media files")
|
||||||
|
try:
|
||||||
|
all_videos = os.listdir(folder_path)
|
||||||
|
for video in all_videos:
|
||||||
|
video_path = os.path.join(folder_path, video)
|
||||||
|
os.remove(video_path)
|
||||||
|
os.rmdir(folder_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"no videos found for {folder_path}")
|
||||||
|
|
||||||
|
print(f"{self.youtube_id}: delete indexed playlists")
|
||||||
|
self._delete_playlists()
|
||||||
|
print(f"{self.youtube_id}: delete indexed videos")
|
||||||
|
self._delete_es_videos()
|
||||||
|
self._delete_es_comments()
|
||||||
|
self._delete_es_subtitles()
|
||||||
|
self.del_in_es()
|
||||||
|
|
||||||
|
def _get_folder_path(self):
|
||||||
|
"""get folder where media files get stored"""
|
||||||
|
folder_path = os.path.join(
|
||||||
|
EnvironmentSettings.MEDIA_DIR,
|
||||||
|
self.json_data["channel_id"],
|
||||||
|
)
|
||||||
|
return folder_path
|
||||||
|
|
||||||
|
def _delete_es_videos(self):
|
||||||
|
"""delete all channel documents from elasticsearch"""
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"term": {"channel.channel_id": {"value": self.youtube_id}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
|
||||||
|
|
||||||
|
def _delete_es_comments(self):
|
||||||
|
"""delete all comments from this channel"""
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"term": {"comment_channel_id": {"value": self.youtube_id}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
|
||||||
|
|
||||||
|
def _delete_es_subtitles(self):
|
||||||
|
"""delete all subtitles from this channel"""
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"term": {"subtitle_channel_id": {"value": self.youtube_id}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data)
|
||||||
|
|
||||||
|
def _delete_playlists(self):
|
||||||
|
"""delete all indexed playlist from es"""
|
||||||
|
from playlist.src.index import YoutubePlaylist
|
||||||
|
|
||||||
|
all_playlists = self._get_indexed_playlists()
|
||||||
|
for playlist in all_playlists:
|
||||||
|
YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
|
||||||
|
|
||||||
|
def _get_indexed_playlists(self, active_only=False):
|
||||||
|
"""get all indexed playlists from channel"""
|
||||||
|
must_list = [
|
||||||
|
{"term": {"playlist_channel_id": {"value": self.youtube_id}}}
|
||||||
|
]
|
||||||
|
if active_only:
|
||||||
|
must_list.append({"term": {"playlist_active": {"value": True}}})
|
||||||
|
|
||||||
|
data = {"query": {"bool": {"must": must_list}}}
|
||||||
|
|
||||||
|
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
||||||
|
return all_playlists
|
||||||
|
|
||||||
|
|
||||||
def channel_overwrites(channel_id, overwrites):
|
def channel_overwrites(channel_id, overwrites):
|
||||||
"""collection to overwrite settings per channel"""
|
"""collection to overwrite settings per channel"""
|
||||||
channel = YoutubeChannel(channel_id)
|
channel = YoutubeChannel(channel_id)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ChannelNav:
|
|||||||
"""build nav items"""
|
"""build nav items"""
|
||||||
nav = {
|
nav = {
|
||||||
"has_pending": self._get_has_pending(),
|
"has_pending": self._get_has_pending(),
|
||||||
|
"has_ignored": self._get_has_ignored(),
|
||||||
"has_playlists": self._get_has_playlists(),
|
"has_playlists": self._get_has_playlists(),
|
||||||
}
|
}
|
||||||
nav.update(self._get_vid_types())
|
nav.update(self._get_vid_types())
|
||||||
@@ -63,6 +64,24 @@ class ChannelNav:
|
|||||||
|
|
||||||
return bool(response["hits"]["hits"])
|
return bool(response["hits"]["hits"])
|
||||||
|
|
||||||
|
def _get_has_ignored(self):
|
||||||
|
"""Check if there are ignored videos in the download queue"""
|
||||||
|
data = {
|
||||||
|
"size": 1,
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must": [
|
||||||
|
{"term": {"status": {"value": "ignore"}}},
|
||||||
|
{"term": {"channel_id": {"value": self.channel_id}}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"_source": False,
|
||||||
|
}
|
||||||
|
response, _ = ElasticWrap("ta_download/_search").get(data=data)
|
||||||
|
|
||||||
|
return bool(response["hits"]["hits"])
|
||||||
|
|
||||||
def _get_has_playlists(self):
|
def _get_has_playlists(self):
|
||||||
"""check if channel has playlists"""
|
"""check if channel has playlists"""
|
||||||
path = "ta_playlist/_search"
|
path = "ta_playlist/_search"
|
||||||
|
|||||||
136
backend/channel/src/remote_query.py
Normal file
136
backend/channel/src/remote_query.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""build queries for video extraction from channel subscriptions"""
|
||||||
|
|
||||||
|
from download.src.yt_dlp_base import YtWrap
|
||||||
|
from video.src.constants import VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
|
class VideoQueryBuilder:
|
||||||
|
"""Build queries for yt-dlp."""
|
||||||
|
|
||||||
|
def __init__(self, config: dict, channel_overwrites: dict | None = None):
|
||||||
|
self.config = config
|
||||||
|
self.channel_overwrites = channel_overwrites or {}
|
||||||
|
|
||||||
|
def build_queries(
|
||||||
|
self,
|
||||||
|
video_type: VideoTypeEnum | list[VideoTypeEnum] | None,
|
||||||
|
limit: bool = True,
|
||||||
|
) -> list[tuple[VideoTypeEnum, int | None]]:
|
||||||
|
"""Build queries for all or specific video type."""
|
||||||
|
query_methods = {
|
||||||
|
VideoTypeEnum.VIDEOS: self.videos_query,
|
||||||
|
VideoTypeEnum.STREAMS: self.streams_query,
|
||||||
|
VideoTypeEnum.SHORTS: self.shorts_query,
|
||||||
|
}
|
||||||
|
|
||||||
|
if video_type and video_type != VideoTypeEnum.UNKNOWN:
|
||||||
|
# build query for specific type/s
|
||||||
|
if not isinstance(video_type, list):
|
||||||
|
video_type = [video_type]
|
||||||
|
|
||||||
|
queries = []
|
||||||
|
for video_type_item in video_type:
|
||||||
|
query_method = query_methods.get(video_type_item)
|
||||||
|
if not query_method:
|
||||||
|
continue
|
||||||
|
|
||||||
|
query = query_method(limit)
|
||||||
|
if query[1] != 0:
|
||||||
|
queries.append(query)
|
||||||
|
|
||||||
|
return queries
|
||||||
|
|
||||||
|
# Build and return queries for all video types
|
||||||
|
queries = []
|
||||||
|
for build_query in query_methods.values():
|
||||||
|
query = build_query(limit)
|
||||||
|
if query[1] != 0:
|
||||||
|
queries.append(query)
|
||||||
|
|
||||||
|
return queries
|
||||||
|
|
||||||
|
def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
||||||
|
"""Build query for videos."""
|
||||||
|
return self._build_generic_query(
|
||||||
|
video_type=VideoTypeEnum.VIDEOS,
|
||||||
|
overwrite_key="subscriptions_channel_size",
|
||||||
|
config_key="channel_size",
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
||||||
|
"""Build query for streams."""
|
||||||
|
return self._build_generic_query(
|
||||||
|
video_type=VideoTypeEnum.STREAMS,
|
||||||
|
overwrite_key="subscriptions_live_channel_size",
|
||||||
|
config_key="live_channel_size",
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
||||||
|
"""Build query for shorts."""
|
||||||
|
return self._build_generic_query(
|
||||||
|
video_type=VideoTypeEnum.SHORTS,
|
||||||
|
overwrite_key="subscriptions_shorts_channel_size",
|
||||||
|
config_key="shorts_channel_size",
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_generic_query(
|
||||||
|
self,
|
||||||
|
video_type: VideoTypeEnum,
|
||||||
|
overwrite_key: str,
|
||||||
|
config_key: str,
|
||||||
|
limit: bool,
|
||||||
|
) -> tuple[VideoTypeEnum, int | None]:
|
||||||
|
"""Generic query for video page scraping."""
|
||||||
|
app_config_size = self.config["subscriptions"].get(config_key)
|
||||||
|
if not limit or app_config_size is None:
|
||||||
|
# treat None as unlimited
|
||||||
|
return (video_type, None)
|
||||||
|
|
||||||
|
if (
|
||||||
|
overwrite_key in self.channel_overwrites
|
||||||
|
and self.channel_overwrites[overwrite_key] is not None
|
||||||
|
):
|
||||||
|
overwrite = self.channel_overwrites[overwrite_key]
|
||||||
|
return (video_type, overwrite)
|
||||||
|
|
||||||
|
if app_config_size:
|
||||||
|
return (video_type, app_config_size)
|
||||||
|
|
||||||
|
return (video_type, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_channel_videos(
|
||||||
|
channel_id,
|
||||||
|
config,
|
||||||
|
limit=None,
|
||||||
|
query_filter=None,
|
||||||
|
channel_overwrites=None,
|
||||||
|
):
|
||||||
|
"""get a list of last videos from channel"""
|
||||||
|
query_handler = VideoQueryBuilder(config, channel_overwrites)
|
||||||
|
queries = query_handler.build_queries(query_filter)
|
||||||
|
last_videos = []
|
||||||
|
|
||||||
|
for vid_type_enum, limit_amount in queries:
|
||||||
|
obs = {
|
||||||
|
"skip_download": True,
|
||||||
|
"extract_flat": True,
|
||||||
|
}
|
||||||
|
vid_type = vid_type_enum.value
|
||||||
|
|
||||||
|
if limit is not None:
|
||||||
|
obs.update({"playlist_items": f":{limit_amount}:1"})
|
||||||
|
|
||||||
|
url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
|
||||||
|
channel_query, _ = YtWrap(obs, config).extract(url)
|
||||||
|
if not channel_query:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in channel_query["entries"]:
|
||||||
|
entry["vid_type"] = vid_type
|
||||||
|
last_videos.append(entry)
|
||||||
|
|
||||||
|
return last_videos
|
||||||
0
backend/channel/tests/__init__.py
Normal file
0
backend/channel/tests/__init__.py
Normal file
0
backend/channel/tests/test_src/__init__.py
Normal file
0
backend/channel/tests/test_src/__init__.py
Normal file
128
backend/channel/tests/test_src/test_remote_query.py
Normal file
128
backend/channel/tests/test_src/test_remote_query.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""test video query building"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from channel.src.remote_query import VideoQueryBuilder
|
||||||
|
from video.src.constants import VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_config():
|
||||||
|
"""from appsettings"""
|
||||||
|
return {
|
||||||
|
"subscriptions": {
|
||||||
|
"channel_size": 5,
|
||||||
|
"live_channel_size": 3,
|
||||||
|
"shorts_channel_size": 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def empty_overwrites():
|
||||||
|
"""from channel overwrites"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def overwrites():
|
||||||
|
"""from channel overwrites"""
|
||||||
|
return {
|
||||||
|
"subscriptions_channel_size": 10,
|
||||||
|
"subscriptions_live_channel_size": 0,
|
||||||
|
"subscriptions_shorts_channel_size": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_all_queries_with_limit(default_config, empty_overwrites):
|
||||||
|
"""default, empty overwrite"""
|
||||||
|
builder = VideoQueryBuilder(default_config, empty_overwrites)
|
||||||
|
result = builder.build_queries(None, limit=True)
|
||||||
|
expected = [
|
||||||
|
(VideoTypeEnum.VIDEOS, 5),
|
||||||
|
(VideoTypeEnum.STREAMS, 3),
|
||||||
|
(VideoTypeEnum.SHORTS, 2),
|
||||||
|
]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_all_queries_without_limit(default_config, empty_overwrites):
|
||||||
|
"""limit disabled"""
|
||||||
|
builder = VideoQueryBuilder(default_config, empty_overwrites)
|
||||||
|
result = builder.build_queries(None, limit=False)
|
||||||
|
expected = [
|
||||||
|
(VideoTypeEnum.VIDEOS, None),
|
||||||
|
(VideoTypeEnum.STREAMS, None),
|
||||||
|
(VideoTypeEnum.SHORTS, None),
|
||||||
|
]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_specific_query(default_config, empty_overwrites):
|
||||||
|
"""single vid_type"""
|
||||||
|
builder = VideoQueryBuilder(default_config, empty_overwrites)
|
||||||
|
result = builder.build_queries(VideoTypeEnum.VIDEOS)
|
||||||
|
assert result == [(VideoTypeEnum.VIDEOS, 5)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_multiple_queries(default_config, empty_overwrites):
|
||||||
|
"""vid_type list"""
|
||||||
|
builder = VideoQueryBuilder(default_config, empty_overwrites)
|
||||||
|
result = builder.build_queries(
|
||||||
|
[VideoTypeEnum.VIDEOS, VideoTypeEnum.SHORTS]
|
||||||
|
)
|
||||||
|
assert result == [(VideoTypeEnum.VIDEOS, 5), (VideoTypeEnum.SHORTS, 2)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_unknown_queries(default_config, empty_overwrites):
|
||||||
|
"""vid_type unknown"""
|
||||||
|
builder = VideoQueryBuilder(default_config, empty_overwrites)
|
||||||
|
result = builder.build_queries(VideoTypeEnum.UNKNOWN)
|
||||||
|
assert result == [
|
||||||
|
(VideoTypeEnum.VIDEOS, 5),
|
||||||
|
(VideoTypeEnum.STREAMS, 3),
|
||||||
|
(VideoTypeEnum.SHORTS, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_overwrite_applied(default_config, overwrites):
|
||||||
|
"""with overwrite from channel config"""
|
||||||
|
builder = VideoQueryBuilder(default_config, overwrites)
|
||||||
|
result = builder.build_queries(None, limit=True)
|
||||||
|
expected = [
|
||||||
|
(VideoTypeEnum.VIDEOS, 10), # Overwritten
|
||||||
|
# STREAMS is overwritten to 0, should be excluded
|
||||||
|
(VideoTypeEnum.SHORTS, 2), # None in overwrite, fallback to config
|
||||||
|
]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_limit_ignores_config_and_overwrites(default_config, overwrites):
|
||||||
|
"""no limit single vid_type"""
|
||||||
|
builder = VideoQueryBuilder(default_config, overwrites)
|
||||||
|
result = builder.build_queries([VideoTypeEnum.STREAMS], limit=False)
|
||||||
|
assert result == [(VideoTypeEnum.STREAMS, None)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_query_not_included(default_config):
|
||||||
|
"""overwrite to zero to disable"""
|
||||||
|
overwrites = {"subscriptions_live_channel_size": 0}
|
||||||
|
builder = VideoQueryBuilder(default_config, overwrites)
|
||||||
|
result = builder.build_queries([VideoTypeEnum.STREAMS], limit=True)
|
||||||
|
assert not result # Should be skipped due to 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_video_type_is_ignored(default_config):
|
||||||
|
"""invalid enum"""
|
||||||
|
builder = VideoQueryBuilder(default_config)
|
||||||
|
|
||||||
|
class FakeEnum(Enum):
|
||||||
|
"""invalid"""
|
||||||
|
|
||||||
|
INVALID = "invalid"
|
||||||
|
|
||||||
|
result = builder.build_queries([FakeEnum.INVALID], limit=True)
|
||||||
|
assert not result
|
||||||
@@ -14,7 +14,6 @@ from channel.src.nav import ChannelNav
|
|||||||
from common.serializers import ErrorResponseSerializer
|
from common.serializers import ErrorResponseSerializer
|
||||||
from common.src.urlparser import Parser
|
from common.src.urlparser import Parser
|
||||||
from common.views_base import AdminWriteOnly, ApiBaseView
|
from common.views_base import AdminWriteOnly, ApiBaseView
|
||||||
from download.src.subscriptions import ChannelSubscription
|
|
||||||
from drf_spectacular.utils import (
|
from drf_spectacular.utils import (
|
||||||
OpenApiParameter,
|
OpenApiParameter,
|
||||||
OpenApiResponse,
|
OpenApiResponse,
|
||||||
@@ -52,8 +51,11 @@ class ChannelApiListView(ApiBaseView):
|
|||||||
|
|
||||||
must_list = []
|
must_list = []
|
||||||
query_filter = validated_data.get("filter")
|
query_filter = validated_data.get("filter")
|
||||||
if query_filter:
|
if query_filter is not None:
|
||||||
must_list.append({"term": {"channel_subscribed": {"value": True}}})
|
channel_subscribed = query_filter == "subscribed"
|
||||||
|
must_list.append(
|
||||||
|
{"term": {"channel_subscribed": {"value": channel_subscribed}}}
|
||||||
|
)
|
||||||
|
|
||||||
self.data["query"] = {"bool": {"must": must_list}}
|
self.data["query"] = {"bool": {"must": must_list}}
|
||||||
self.get_document_list(request)
|
self.get_document_list(request)
|
||||||
@@ -89,9 +91,7 @@ class ChannelApiListView(ApiBaseView):
|
|||||||
def _unsubscribe(channel_id: str):
|
def _unsubscribe(channel_id: str):
|
||||||
"""unsubscribe"""
|
"""unsubscribe"""
|
||||||
print(f"[{channel_id}] unsubscribe from channel")
|
print(f"[{channel_id}] unsubscribe from channel")
|
||||||
ChannelSubscription().change_subscribe(
|
YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=False)
|
||||||
channel_id, channel_subscribed=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelApiView(ApiBaseView):
|
class ChannelApiView(ApiBaseView):
|
||||||
@@ -146,7 +146,9 @@ class ChannelApiView(ApiBaseView):
|
|||||||
|
|
||||||
subscribed = validated_data.get("channel_subscribed")
|
subscribed = validated_data.get("channel_subscribed")
|
||||||
if subscribed is not None:
|
if subscribed is not None:
|
||||||
ChannelSubscription().change_subscribe(channel_id, subscribed)
|
YoutubeChannel(channel_id).change_subscribe(
|
||||||
|
new_subscribe_state=subscribed
|
||||||
|
)
|
||||||
|
|
||||||
overwrites = validated_data.get("channel_overwrites")
|
overwrites = validated_data.get("channel_overwrites")
|
||||||
if overwrites:
|
if overwrites:
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class NotificationSerializer(serializers.Serializer):
|
|||||||
level = serializers.ChoiceField(choices=["info", "error"])
|
level = serializers.ChoiceField(choices=["info", "error"])
|
||||||
messages = serializers.ListField(child=serializers.CharField())
|
messages = serializers.ListField(child=serializers.CharField())
|
||||||
progress = serializers.FloatField(required=False)
|
progress = serializers.FloatField(required=False)
|
||||||
|
command = serializers.ChoiceField(choices=["STOP", "KILL"], required=False)
|
||||||
|
|
||||||
|
|
||||||
class NotificationQueryFilterSerializer(serializers.Serializer):
|
class NotificationQueryFilterSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class ElasticWrap:
|
|||||||
return response.json(), response.status_code
|
return response.json(), response.status_code
|
||||||
|
|
||||||
def post(
|
def post(
|
||||||
self, data: bool | dict = False, ndjson: bool = False
|
self, data: bool | dict | str = False, ndjson: bool = False
|
||||||
) -> tuple[dict, int]:
|
) -> tuple[dict, int]:
|
||||||
"""post data to es"""
|
"""post data to es"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,299 +1,352 @@
|
|||||||
"""
|
"""
|
||||||
Loose collection of helper functions
|
Loose collection of helper functions
|
||||||
- don't import AppConfig class here to avoid circular imports
|
- don't import AppConfig class here to avoid circular imports
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from common.src.es_connect import IndexPaginate
|
from common.src.es_connect import IndexPaginate
|
||||||
|
|
||||||
|
|
||||||
def ignore_filelist(filelist: list[str]) -> list[str]:
|
def ignore_filelist(filelist: list[str]) -> list[str]:
|
||||||
"""ignore temp files for os.listdir sanitizer"""
|
"""ignore temp files for os.listdir sanitizer"""
|
||||||
to_ignore = [
|
to_ignore = [
|
||||||
"@eaDir",
|
"@eaDir",
|
||||||
"Icon\r\r",
|
"Icon\r\r",
|
||||||
"Network Trash Folder",
|
"Network Trash Folder",
|
||||||
"Temporary Items",
|
"Temporary Items",
|
||||||
]
|
]
|
||||||
cleaned: list[str] = []
|
cleaned: list[str] = []
|
||||||
for file_name in filelist:
|
for file_name in filelist:
|
||||||
if file_name.startswith(".") or file_name in to_ignore:
|
if file_name.startswith(".") or file_name in to_ignore:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cleaned.append(file_name)
|
cleaned.append(file_name)
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def randomizor(length: int) -> str:
|
def randomizor(length: int) -> str:
|
||||||
"""generate random alpha numeric string"""
|
"""generate random alpha numeric string"""
|
||||||
pool: str = string.digits + string.ascii_letters
|
pool: str = string.digits + string.ascii_letters
|
||||||
return "".join(random.choice(pool) for i in range(length))
|
return "".join(random.choice(pool) for i in range(length))
|
||||||
|
|
||||||
|
|
||||||
def rand_sleep(config) -> None:
|
def rand_sleep(config) -> None:
|
||||||
"""randomized sleep based on config"""
|
"""randomized sleep based on config"""
|
||||||
sleep_config = config["downloads"].get("sleep_interval")
|
sleep_config = config["downloads"].get("sleep_interval")
|
||||||
if not sleep_config:
|
if not sleep_config:
|
||||||
return
|
return
|
||||||
|
|
||||||
secs = random.randrange(int(sleep_config * 0.5), int(sleep_config * 1.5))
|
secs = random.randrange(int(sleep_config * 0.5), int(sleep_config * 1.5))
|
||||||
sleep(secs)
|
sleep(secs)
|
||||||
|
|
||||||
|
|
||||||
def requests_headers() -> dict[str, str]:
|
def requests_headers() -> dict[str, str]:
|
||||||
"""build header with random user agent for requests outside of yt-dlp"""
|
"""build header with random user agent for requests outside of yt-dlp"""
|
||||||
|
|
||||||
chrome_versions = (
|
chrome_versions = (
|
||||||
"90.0.4430.212",
|
"90.0.4430.212",
|
||||||
"90.0.4430.24",
|
"90.0.4430.24",
|
||||||
"90.0.4430.70",
|
"90.0.4430.70",
|
||||||
"90.0.4430.72",
|
"90.0.4430.72",
|
||||||
"90.0.4430.85",
|
"90.0.4430.85",
|
||||||
"90.0.4430.93",
|
"90.0.4430.93",
|
||||||
"91.0.4472.101",
|
"91.0.4472.101",
|
||||||
"91.0.4472.106",
|
"91.0.4472.106",
|
||||||
"91.0.4472.114",
|
"91.0.4472.114",
|
||||||
"91.0.4472.124",
|
"91.0.4472.124",
|
||||||
"91.0.4472.164",
|
"91.0.4472.164",
|
||||||
"91.0.4472.19",
|
"91.0.4472.19",
|
||||||
"91.0.4472.77",
|
"91.0.4472.77",
|
||||||
"92.0.4515.107",
|
"92.0.4515.107",
|
||||||
"92.0.4515.115",
|
"92.0.4515.115",
|
||||||
"92.0.4515.131",
|
"92.0.4515.131",
|
||||||
"92.0.4515.159",
|
"92.0.4515.159",
|
||||||
"92.0.4515.43",
|
"92.0.4515.43",
|
||||||
"93.0.4556.0",
|
"93.0.4556.0",
|
||||||
"93.0.4577.15",
|
"93.0.4577.15",
|
||||||
"93.0.4577.63",
|
"93.0.4577.63",
|
||||||
"93.0.4577.82",
|
"93.0.4577.82",
|
||||||
"94.0.4606.41",
|
"94.0.4606.41",
|
||||||
"94.0.4606.54",
|
"94.0.4606.54",
|
||||||
"94.0.4606.61",
|
"94.0.4606.61",
|
||||||
"94.0.4606.71",
|
"94.0.4606.71",
|
||||||
"94.0.4606.81",
|
"94.0.4606.81",
|
||||||
"94.0.4606.85",
|
"94.0.4606.85",
|
||||||
"95.0.4638.17",
|
"95.0.4638.17",
|
||||||
"95.0.4638.50",
|
"95.0.4638.50",
|
||||||
"95.0.4638.54",
|
"95.0.4638.54",
|
||||||
"95.0.4638.69",
|
"95.0.4638.69",
|
||||||
"95.0.4638.74",
|
"95.0.4638.74",
|
||||||
"96.0.4664.18",
|
"96.0.4664.18",
|
||||||
"96.0.4664.45",
|
"96.0.4664.45",
|
||||||
"96.0.4664.55",
|
"96.0.4664.55",
|
||||||
"96.0.4664.93",
|
"96.0.4664.93",
|
||||||
"97.0.4692.20",
|
"97.0.4692.20",
|
||||||
)
|
)
|
||||||
template = (
|
template = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
|
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
|
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"User-Agent": template}
|
return {"User-Agent": template}
|
||||||
|
|
||||||
|
|
||||||
def date_parser(timestamp: int | str) -> str:
|
def date_parser(timestamp: int | str | None) -> str | None:
|
||||||
"""return formatted date string"""
|
"""return formatted date string"""
|
||||||
if isinstance(timestamp, int):
|
if timestamp is None:
|
||||||
date_obj = datetime.fromtimestamp(timestamp)
|
return None
|
||||||
elif isinstance(timestamp, str):
|
|
||||||
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
|
if isinstance(timestamp, int):
|
||||||
else:
|
date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||||
raise TypeError(f"invalid timestamp: {timestamp}")
|
elif isinstance(timestamp, str):
|
||||||
|
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
|
||||||
return date_obj.date().isoformat()
|
date_obj = date_obj.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"invalid timestamp: {timestamp}")
|
||||||
def time_parser(timestamp: str) -> float:
|
|
||||||
"""return seconds from timestamp, false on empty"""
|
return date_obj.isoformat()
|
||||||
if not timestamp:
|
|
||||||
return False
|
|
||||||
|
def time_parser(timestamp: str) -> float:
|
||||||
if timestamp.isnumeric():
|
"""return seconds from timestamp, false on empty"""
|
||||||
return int(timestamp)
|
if not timestamp:
|
||||||
|
return False
|
||||||
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
|
|
||||||
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
|
if timestamp.isnumeric():
|
||||||
|
return int(timestamp)
|
||||||
|
|
||||||
def clear_dl_cache(cache_dir: str) -> int:
|
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
|
||||||
"""clear leftover files from dl cache"""
|
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
|
||||||
print("clear download cache")
|
|
||||||
download_cache_dir = os.path.join(cache_dir, "download")
|
|
||||||
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
|
def clear_dl_cache(cache_dir: str) -> int:
|
||||||
for cached in leftover_files:
|
"""clear leftover files from dl cache"""
|
||||||
to_delete = os.path.join(download_cache_dir, cached)
|
print("clear download cache")
|
||||||
os.remove(to_delete)
|
download_cache_dir = os.path.join(cache_dir, "download")
|
||||||
|
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
|
||||||
return len(leftover_files)
|
for cached in leftover_files:
|
||||||
|
to_delete = os.path.join(download_cache_dir, cached)
|
||||||
|
os.remove(to_delete)
|
||||||
def get_mapping() -> dict:
|
|
||||||
"""read index_mapping.json and get expected mapping and settings"""
|
return len(leftover_files)
|
||||||
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
|
|
||||||
index_config: dict = json.load(f).get("index_config")
|
|
||||||
|
def get_mapping() -> dict:
|
||||||
return index_config
|
"""read index_mapping.json and get expected mapping and settings"""
|
||||||
|
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
|
||||||
|
index_config: dict = json.load(f).get("index_config")
|
||||||
def is_shorts(youtube_id: str) -> bool:
|
|
||||||
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
|
return index_config
|
||||||
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
|
|
||||||
cookies = {"SOCS": "CAI"}
|
|
||||||
response = requests.head(
|
def is_shorts(youtube_id: str) -> bool:
|
||||||
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
|
"""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}"
|
||||||
|
cookies = {"SOCS": "CAI"}
|
||||||
return response.status_code == 200
|
try:
|
||||||
|
response = requests.head(
|
||||||
|
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
|
||||||
def get_duration_sec(file_path: str) -> int:
|
)
|
||||||
"""get duration of media file from file path"""
|
except requests.exceptions.RequestException:
|
||||||
|
# assume video on error
|
||||||
duration = subprocess.run(
|
return False
|
||||||
[
|
|
||||||
"ffprobe",
|
return response.status_code == 200
|
||||||
"-v",
|
|
||||||
"error",
|
|
||||||
"-show_entries",
|
def get_duration_sec(file_path: str) -> int:
|
||||||
"format=duration",
|
"""get duration of media file from file path"""
|
||||||
"-of",
|
|
||||||
"default=noprint_wrappers=1:nokey=1",
|
duration = subprocess.run(
|
||||||
file_path,
|
[
|
||||||
],
|
"ffprobe",
|
||||||
capture_output=True,
|
"-v",
|
||||||
check=True,
|
"error",
|
||||||
)
|
"-show_entries",
|
||||||
duration_raw = duration.stdout.decode().strip()
|
"format=duration",
|
||||||
if duration_raw == "N/A":
|
"-of",
|
||||||
return 0
|
"default=noprint_wrappers=1:nokey=1",
|
||||||
|
file_path,
|
||||||
duration_sec = int(float(duration_raw))
|
],
|
||||||
return duration_sec
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
def get_duration_str(seconds: int) -> str:
|
duration_raw = duration.stdout.decode().strip()
|
||||||
"""Return a human-readable duration string from seconds."""
|
if duration_raw == "N/A":
|
||||||
if not seconds:
|
return 0
|
||||||
return "NA"
|
|
||||||
|
duration_sec = int(float(duration_raw))
|
||||||
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
|
return duration_sec
|
||||||
duration_parts = []
|
|
||||||
|
|
||||||
for unit_label, unit_seconds in units:
|
def get_duration_str(seconds: int | float) -> str:
|
||||||
if seconds >= unit_seconds:
|
"""Return a human-readable duration string from seconds."""
|
||||||
unit_count, seconds = divmod(seconds, unit_seconds)
|
if not seconds:
|
||||||
duration_parts.append(f"{unit_count:02}{unit_label}")
|
return "NA"
|
||||||
|
|
||||||
duration_parts[0] = duration_parts[0].lstrip("0")
|
seconds = int(seconds)
|
||||||
|
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
|
||||||
return " ".join(duration_parts)
|
duration_parts = []
|
||||||
|
|
||||||
|
for unit_label, unit_seconds in units:
|
||||||
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
|
if seconds >= unit_seconds:
|
||||||
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
|
unit_count, seconds = divmod(seconds, unit_seconds)
|
||||||
allowed_hosts: list[str] = [
|
duration_parts.append(f"{unit_count:02}{unit_label}")
|
||||||
"localhost",
|
|
||||||
"tubearchivist",
|
duration_parts[0] = duration_parts[0].lstrip("0")
|
||||||
]
|
|
||||||
csrf_trusted_origins: list[str] = [
|
return " ".join(duration_parts)
|
||||||
"http://localhost",
|
|
||||||
"http://tubearchivist",
|
|
||||||
]
|
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
|
||||||
for host in ta_host.split():
|
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
|
||||||
host_clean = host.strip()
|
allowed_hosts: list[str] = [
|
||||||
if not host_clean.startswith("http"):
|
"localhost",
|
||||||
host_clean = f"http://{host_clean}"
|
"tubearchivist",
|
||||||
|
]
|
||||||
parsed = urlparse(host_clean)
|
csrf_trusted_origins: list[str] = [
|
||||||
allowed_hosts.append(f"{parsed.hostname}")
|
"http://localhost",
|
||||||
cors_url = f"{parsed.scheme}://{parsed.hostname}"
|
"http://tubearchivist",
|
||||||
|
]
|
||||||
if parsed.port:
|
for host in ta_host.split():
|
||||||
cors_url = f"{cors_url}:{parsed.port}"
|
host_clean = host.strip()
|
||||||
|
if not host_clean.startswith("http"):
|
||||||
csrf_trusted_origins.append(cors_url)
|
host_clean = f"http://{host_clean}"
|
||||||
|
|
||||||
return allowed_hosts, csrf_trusted_origins
|
parsed = urlparse(host_clean)
|
||||||
|
allowed_hosts.append(f"{parsed.hostname}")
|
||||||
|
cors_url = f"{parsed.scheme}://{parsed.hostname}"
|
||||||
def get_stylesheets() -> list:
|
|
||||||
"""Get all valid stylesheets from /static/css"""
|
if parsed.port:
|
||||||
|
cors_url = f"{cors_url}:{parsed.port}"
|
||||||
stylesheets = [
|
|
||||||
"dark.css",
|
csrf_trusted_origins.append(cors_url)
|
||||||
"light.css",
|
|
||||||
"matrix.css",
|
return allowed_hosts, csrf_trusted_origins
|
||||||
"midnight.css",
|
|
||||||
"custom.css",
|
|
||||||
]
|
def get_stylesheets() -> list:
|
||||||
return stylesheets
|
"""Get all valid stylesheets from /static/css"""
|
||||||
|
|
||||||
|
stylesheets = [
|
||||||
def check_stylesheet(stylesheet: str):
|
"dark.css",
|
||||||
"""Check if a stylesheet exists. Return dark.css as a fallback"""
|
"light.css",
|
||||||
if stylesheet in get_stylesheets():
|
"matrix.css",
|
||||||
return stylesheet
|
"midnight.css",
|
||||||
|
"custom.css",
|
||||||
return "dark.css"
|
]
|
||||||
|
return stylesheets
|
||||||
|
|
||||||
def is_missing(
|
|
||||||
to_check: str | list[str],
|
def check_stylesheet(stylesheet: str):
|
||||||
index_name: str = "ta_video,ta_download",
|
"""Check if a stylesheet exists. Return dark.css as a fallback"""
|
||||||
on_key: str = "youtube_id",
|
if stylesheet in get_stylesheets():
|
||||||
) -> list[str]:
|
return stylesheet
|
||||||
"""id or list of ids that are missing from index_name"""
|
|
||||||
if isinstance(to_check, str):
|
return "dark.css"
|
||||||
to_check = [to_check]
|
|
||||||
|
|
||||||
data = {
|
def is_missing(
|
||||||
"query": {"terms": {on_key: to_check}},
|
to_check: str | list[str],
|
||||||
"_source": [on_key],
|
index_name: str = "ta_video,ta_download",
|
||||||
}
|
on_key: str = "youtube_id",
|
||||||
result = IndexPaginate(index_name, data=data).get_results()
|
) -> list[str]:
|
||||||
existing_ids = [i[on_key] for i in result]
|
"""id or list of ids that are missing from index_name"""
|
||||||
dl = [i for i in to_check if i not in existing_ids]
|
if isinstance(to_check, str):
|
||||||
|
to_check = [to_check]
|
||||||
return dl
|
|
||||||
|
data = {
|
||||||
|
"query": {"terms": {on_key: to_check}},
|
||||||
def get_channel_overwrites() -> dict[str, dict[str, Any]]:
|
"_source": [on_key],
|
||||||
"""get overwrites indexed my channel_id"""
|
}
|
||||||
data = {
|
result = IndexPaginate(index_name, data=data).get_results()
|
||||||
"query": {
|
existing_ids = [i[on_key] for i in result]
|
||||||
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
|
dl = [i for i in to_check if i not in existing_ids]
|
||||||
},
|
|
||||||
"_source": ["channel_id", "channel_overwrites"],
|
return dl
|
||||||
}
|
|
||||||
result = IndexPaginate("ta_channel", data).get_results()
|
|
||||||
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
|
def get_channel_overwrites() -> dict[str, dict[str, Any]]:
|
||||||
|
"""get overwrites indexed my channel_id"""
|
||||||
return overwrites
|
data = {
|
||||||
|
"query": {
|
||||||
|
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
|
||||||
def calc_is_watched(duration: float, position: float) -> bool:
|
},
|
||||||
"""considered watched based on duration position"""
|
"_source": ["channel_id", "channel_overwrites"],
|
||||||
|
}
|
||||||
if not duration or duration <= 0:
|
result = IndexPaginate("ta_channel", data).get_results()
|
||||||
return False
|
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
|
||||||
|
|
||||||
if duration < 60:
|
return overwrites
|
||||||
threshold = 0.5
|
|
||||||
elif duration > 900:
|
|
||||||
threshold = 1 - (180 / duration)
|
def get_channels(
|
||||||
else:
|
subscribed_only: bool, source: list[str] | None = None
|
||||||
threshold = 0.9
|
) -> list[dict]:
|
||||||
|
"""get a list of all channels"""
|
||||||
return position >= duration * threshold
|
data = {
|
||||||
|
"sort": [{"channel_name.keyword": {"order": "asc"}}],
|
||||||
|
}
|
||||||
|
if subscribed_only:
|
||||||
|
query = {"term": {"channel_subscribed": {"value": True}}}
|
||||||
|
else:
|
||||||
|
query = {"match_all": {}}
|
||||||
|
|
||||||
|
data["query"] = query # type: ignore
|
||||||
|
|
||||||
|
if source:
|
||||||
|
data["_source"] = source # type: ignore
|
||||||
|
|
||||||
|
all_channels = IndexPaginate("ta_channel", data).get_results()
|
||||||
|
|
||||||
|
return all_channels
|
||||||
|
|
||||||
|
|
||||||
|
def get_playlists(
|
||||||
|
subscribed_only: bool, source: list[str] | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""get list of playlists"""
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"sort": [{"playlist_channel.keyword": {"order": "desc"}}],
|
||||||
|
}
|
||||||
|
|
||||||
|
must_list = [{"term": {"playlist_active": {"value": True}}}]
|
||||||
|
if subscribed_only:
|
||||||
|
must_list.append({"term": {"playlist_subscribed": {"value": True}}})
|
||||||
|
|
||||||
|
data = {"query": {"bool": {"must": must_list}}} # type: ignore
|
||||||
|
if source:
|
||||||
|
data["_source"] = source # type: ignore
|
||||||
|
|
||||||
|
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
||||||
|
|
||||||
|
return all_playlists
|
||||||
|
|
||||||
|
|
||||||
|
def calc_is_watched(duration: float, position: float) -> bool:
|
||||||
|
"""considered watched based on duration position"""
|
||||||
|
|
||||||
|
if not duration or duration <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if duration < 60:
|
||||||
|
threshold = 0.5
|
||||||
|
elif duration > 900:
|
||||||
|
threshold = 1 - (180 / duration)
|
||||||
|
else:
|
||||||
|
threshold = 0.9
|
||||||
|
|
||||||
|
return position >= duration * threshold
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class YouTubeItem:
|
|||||||
self.youtube_id = youtube_id
|
self.youtube_id = youtube_id
|
||||||
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
|
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
|
self.error = None
|
||||||
self.youtube_meta = False
|
self.youtube_meta = False
|
||||||
self.json_data = False
|
self.json_data = False
|
||||||
|
|
||||||
@@ -33,17 +34,24 @@ class YouTubeItem:
|
|||||||
"""build youtube url"""
|
"""build youtube url"""
|
||||||
return self.yt_base + self.youtube_id
|
return self.yt_base + self.youtube_id
|
||||||
|
|
||||||
def get_from_youtube(self):
|
def get_from_youtube(self, obs_overwrite: dict | None = None):
|
||||||
"""use yt-dlp to get meta data from youtube"""
|
"""use yt-dlp to get meta data from youtube"""
|
||||||
print(f"{self.youtube_id}: get metadata from youtube")
|
print(f"{self.youtube_id}: get metadata from youtube")
|
||||||
obs_request = self.yt_obs.copy()
|
obs_request = self.yt_obs.copy()
|
||||||
if self.config["downloads"]["extractor_lang"]:
|
if self.config["downloads"]["extractor_lang"]:
|
||||||
langs = self.config["downloads"]["extractor_lang"]
|
langs = self.config["downloads"]["extractor_lang"]
|
||||||
langs_list = [i.strip() for i in langs.split(",")]
|
langs_list = [i.strip() for i in langs.split(",")]
|
||||||
obs_request["extractor_args"] = {"youtube": {"lang": langs_list}}
|
obs_request["extractor_args"] = {
|
||||||
|
"youtube": {"lang": langs_list}
|
||||||
|
} # type: ignore
|
||||||
|
|
||||||
|
if obs_overwrite:
|
||||||
|
obs_request.update(obs_overwrite)
|
||||||
|
|
||||||
url = self.build_yt_url()
|
url = self.build_yt_url()
|
||||||
self.youtube_meta = YtWrap(obs_request, self.config).extract(url)
|
self.youtube_meta, self.error = YtWrap(
|
||||||
|
obs_request, self.config
|
||||||
|
).extract(url)
|
||||||
|
|
||||||
def get_from_es(self):
|
def get_from_es(self):
|
||||||
"""get indexed data from elastic search"""
|
"""get indexed data from elastic search"""
|
||||||
|
|||||||
@@ -165,15 +165,17 @@ class SearchProcess:
|
|||||||
|
|
||||||
def _process_download(self, download_dict):
|
def _process_download(self, download_dict):
|
||||||
"""run on single download item"""
|
"""run on single download item"""
|
||||||
video_id = download_dict["youtube_id"]
|
vid_thumb_url = None
|
||||||
cache_root = EnvironmentSettings().get_cache_root()
|
if download_dict.get("vid_thumb_url"):
|
||||||
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
|
video_id = download_dict["youtube_id"]
|
||||||
published = date_parser(download_dict["published"])
|
cache_root = EnvironmentSettings().get_cache_root()
|
||||||
|
relative_path = ThumbManager(video_id).vid_thumb_path()
|
||||||
|
vid_thumb_url = f"{cache_root}/{relative_path}"
|
||||||
|
|
||||||
download_dict.update(
|
download_dict.update(
|
||||||
{
|
{
|
||||||
"vid_thumb_url": f"{cache_root}/{vid_thumb_url}",
|
"vid_thumb_url": vid_thumb_url,
|
||||||
"published": published,
|
"published": date_parser(download_dict["published"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return dict(sorted(download_dict.items()))
|
return dict(sorted(download_dict.items()))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Functionality:
|
|||||||
- identify vid_type if possible
|
- identify vid_type if possible
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import Literal, NotRequired, TypedDict
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
from common.src.ta_redis import RedisArchivist
|
from common.src.ta_redis import RedisArchivist
|
||||||
@@ -11,19 +12,28 @@ from download.src.yt_dlp_base import YtWrap
|
|||||||
from video.src.constants import VideoTypeEnum
|
from video.src.constants import VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedURLType(TypedDict):
|
||||||
|
"""represents single parsed url"""
|
||||||
|
|
||||||
|
type: Literal["video", "channel", "playlist"]
|
||||||
|
url: str
|
||||||
|
vid_type: VideoTypeEnum
|
||||||
|
limit: NotRequired[int | None]
|
||||||
|
|
||||||
|
|
||||||
class Parser:
|
class Parser:
|
||||||
"""
|
"""
|
||||||
take a multi line string and detect valid youtube ids
|
take a multi line string and detect valid youtube ids
|
||||||
channel handle lookup is cached, can be disabled for unittests
|
channel handle lookup is cached, can be disabled for unittests
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, url_str, use_cache=True):
|
def __init__(self, url_str: str, use_cache: bool = True):
|
||||||
self.url_list = [i.strip() for i in url_str.split()]
|
self.url_list = [i.strip() for i in url_str.split()]
|
||||||
self.use_cache = use_cache
|
self.use_cache = use_cache
|
||||||
|
|
||||||
def parse(self):
|
def parse(self) -> list[ParsedURLType]:
|
||||||
"""parse the list"""
|
"""parse the list"""
|
||||||
ids = []
|
ids: list[ParsedURLType] = []
|
||||||
for url in self.url_list:
|
for url in self.url_list:
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if parsed.netloc:
|
if parsed.netloc:
|
||||||
@@ -124,9 +134,9 @@ class Parser:
|
|||||||
"extract_flat": True,
|
"extract_flat": True,
|
||||||
"playlistend": 0,
|
"playlistend": 0,
|
||||||
}
|
}
|
||||||
url_info = YtWrap(obs_request).extract(url)
|
url_info, error = YtWrap(obs_request).extract(url)
|
||||||
if not url_info:
|
if not url_info:
|
||||||
raise ValueError(f"failed to retrieve content from URL: {url}")
|
raise ValueError(f"failed to retrieve URL: {error}")
|
||||||
|
|
||||||
channel_id = url_info.get("channel_id", False)
|
channel_id = url_info.get("channel_id", False)
|
||||||
if channel_id:
|
if channel_id:
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class WatchState:
|
|||||||
self.change_vid_state()
|
self.change_vid_state()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if url_type == "channel":
|
||||||
|
self.reset_channel_progress()
|
||||||
|
if url_type == "playlist":
|
||||||
|
self.reset_playlist_progress()
|
||||||
|
|
||||||
self._add_pipeline()
|
self._add_pipeline()
|
||||||
path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}"
|
path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}"
|
||||||
data = self._build_update_data(url_type)
|
data = self._build_update_data(url_type)
|
||||||
@@ -53,6 +58,31 @@ class WatchState:
|
|||||||
print(response)
|
print(response)
|
||||||
raise ValueError("failed to mark video as watched")
|
raise ValueError("failed to mark video as watched")
|
||||||
|
|
||||||
|
def reset_channel_progress(self):
|
||||||
|
"""reset channel progress positions"""
|
||||||
|
from channel.src.index import YoutubeChannel
|
||||||
|
|
||||||
|
videos = YoutubeChannel(self.youtube_id).get_channel_videos()
|
||||||
|
video_ids = [i["youtube_id"] for i in videos]
|
||||||
|
self._reset_list(video_ids)
|
||||||
|
|
||||||
|
def reset_playlist_progress(self):
|
||||||
|
"""reset playlist progress positions"""
|
||||||
|
from playlist.src.index import YoutubePlaylist
|
||||||
|
|
||||||
|
videos = YoutubePlaylist(self.youtube_id).get_playlist_videos()
|
||||||
|
video_ids = [i["youtube_id"] for i in videos]
|
||||||
|
self._reset_list(video_ids)
|
||||||
|
|
||||||
|
def _reset_list(self, video_ids: list[str]):
|
||||||
|
"""reset list of video ids"""
|
||||||
|
redis_con = RedisArchivist()
|
||||||
|
all_ids = redis_con.list_keys(f"{self.user_id}:progress")
|
||||||
|
for progress_id in all_ids:
|
||||||
|
video_id = progress_id.split(":")[-1]
|
||||||
|
if video_id in video_ids:
|
||||||
|
redis_con.del_message(progress_id)
|
||||||
|
|
||||||
def _build_update_data(self, url_type):
|
def _build_update_data(self, url_type):
|
||||||
"""build update by query data based on url_type"""
|
"""build update by query data based on url_type"""
|
||||||
term_key_map = {
|
term_key_map = {
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ def test_randomizor_with_positive_length():
|
|||||||
def test_date_parser_with_int():
|
def test_date_parser_with_int():
|
||||||
"""unix timestamp"""
|
"""unix timestamp"""
|
||||||
timestamp = 1621539600
|
timestamp = 1621539600
|
||||||
expected_date = "2021-05-20"
|
expected_date = "2021-05-20T19:40:00+00:00"
|
||||||
assert date_parser(timestamp) == expected_date
|
assert date_parser(timestamp) == expected_date
|
||||||
|
|
||||||
|
|
||||||
def test_date_parser_with_str():
|
def test_date_parser_with_str():
|
||||||
"""iso timestamp"""
|
"""iso timestamp"""
|
||||||
date_str = "2021-05-21"
|
date_str = "2021-05-21"
|
||||||
expected_date = "2021-05-21"
|
expected_date = "2021-05-21T00:00:00+00:00"
|
||||||
assert date_parser(date_str) == expected_date
|
assert date_parser(date_str) == expected_date
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
6
backend/config/fwd_auth_settings.py
Normal file
6
backend/config/fwd_auth_settings.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from os import environ
|
||||||
|
|
||||||
|
TA_AUTH_PROXY_USERNAME_HEADER = (
|
||||||
|
environ.get("TA_AUTH_PROXY_USERNAME_HEADER") or "HTTP_REMOTE_USER"
|
||||||
|
)
|
||||||
|
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL")
|
||||||
111
backend/config/ldap_settings.py
Normal file
111
backend/config/ldap_settings.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from os import environ
|
||||||
|
|
||||||
|
import ldap
|
||||||
|
from django_auth_ldap.config import LDAPSearch
|
||||||
|
|
||||||
|
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
|
||||||
|
|
||||||
|
AUTH_LDAP_BIND_DN = environ.get("TA_LDAP_BIND_DN")
|
||||||
|
|
||||||
|
AUTH_LDAP_BIND_PASSWORD = environ.get("TA_LDAP_BIND_PASSWORD")
|
||||||
|
|
||||||
|
"""
|
||||||
|
Given Names are *_technically_* different from Personal names, as people
|
||||||
|
who change their names have different given names and personal names,
|
||||||
|
and they go by personal names. Additionally, "LastName" is actually
|
||||||
|
incorrect for many cultures, such as Korea, where the
|
||||||
|
family name comes first, and the personal name comes last.
|
||||||
|
|
||||||
|
But we all know people are going to try to guess at these, so still want
|
||||||
|
to include names that people will guess, hence using first/last as well.
|
||||||
|
"""
|
||||||
|
|
||||||
|
AUTH_LDAP_USER_ATTR_MAP_USERNAME = (
|
||||||
|
environ.get("TA_LDAP_USER_ATTR_MAP_USERNAME")
|
||||||
|
or environ.get("TA_LDAP_USER_ATTR_MAP_UID")
|
||||||
|
or "uid"
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME = (
|
||||||
|
environ.get("TA_LDAP_USER_ATTR_MAP_PERSONALNAME")
|
||||||
|
or environ.get("TA_LDAP_USER_ATTR_MAP_FIRSTNAME")
|
||||||
|
or environ.get("TA_LDAP_USER_ATTR_MAP_GIVENNAME")
|
||||||
|
or "givenName"
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_LDAP_USER_ATTR_MAP_SURNAME = (
|
||||||
|
environ.get("TA_LDAP_USER_ATTR_MAP_SURNAME")
|
||||||
|
or environ.get("TA_LDAP_USER_ATTR_MAP_LASTNAME")
|
||||||
|
or environ.get("TA_LDAP_USER_ATTR_MAP_FAMILYNAME")
|
||||||
|
or "sn"
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_LDAP_USER_ATTR_MAP_EMAIL = (
|
||||||
|
environ.get("TA_LDAP_USER_ATTR_MAP_EMAIL")
|
||||||
|
or environ.get("TA_LDAP_USER_ATTR_MAP_MAIL")
|
||||||
|
or "mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_LDAP_USER_BASE = environ.get("TA_LDAP_USER_BASE")
|
||||||
|
|
||||||
|
AUTH_LDAP_USER_FILTER = environ.get("TA_LDAP_USER_FILTER")
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
|
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||||
|
AUTH_LDAP_USER_BASE,
|
||||||
|
ldap.SCOPE_SUBTREE,
|
||||||
|
"(&("
|
||||||
|
+ AUTH_LDAP_USER_ATTR_MAP_USERNAME
|
||||||
|
+ "=%(user)s)"
|
||||||
|
+ AUTH_LDAP_USER_FILTER
|
||||||
|
+ ")",
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_LDAP_USER_ATTR_MAP = {
|
||||||
|
"username": AUTH_LDAP_USER_ATTR_MAP_USERNAME,
|
||||||
|
"first_name": AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME,
|
||||||
|
"last_name": AUTH_LDAP_USER_ATTR_MAP_SURNAME,
|
||||||
|
"email": AUTH_LDAP_USER_ATTR_MAP_EMAIL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if bool(environ.get("TA_LDAP_DISABLE_CERT_CHECK")):
|
||||||
|
# pylint: disable=global-at-module-level
|
||||||
|
global AUTH_LDAP_GLOBAL_OPTIONS
|
||||||
|
AUTH_LDAP_GLOBAL_OPTIONS = {
|
||||||
|
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Promote specific usernames to staff or superuser permission levels
|
||||||
|
_ldap_superuser_username_config = (
|
||||||
|
environ.get("TA_LDAP_PROMOTE_USERNAMES_TO_SUPERUSER") or ""
|
||||||
|
)
|
||||||
|
_ldap_superuser_usernames = []
|
||||||
|
if _ldap_superuser_username_config:
|
||||||
|
_ldap_superuser_usernames = [
|
||||||
|
u.strip() for u in _ldap_superuser_username_config.split(",")
|
||||||
|
]
|
||||||
|
|
||||||
|
_ldap_staff_username_config = (
|
||||||
|
environ.get("TA_LDAP_PROMOTE_USERNAMES_TO_STAFF") or ""
|
||||||
|
)
|
||||||
|
_ldap_staff_usernames = []
|
||||||
|
if _ldap_staff_username_config:
|
||||||
|
_ldap_staff_usernames = [
|
||||||
|
u.strip() for u in _ldap_staff_username_config.split(",")
|
||||||
|
]
|
||||||
|
|
||||||
|
if _ldap_staff_usernames or _ldap_superuser_usernames:
|
||||||
|
import django_auth_ldap.backend
|
||||||
|
|
||||||
|
def create_user(sender, user=None, ldap_user=None, **kwargs):
|
||||||
|
if user.ldap_username in _ldap_superuser_usernames and not (
|
||||||
|
user.is_superuser and user.is_staff
|
||||||
|
):
|
||||||
|
user.is_staff = True
|
||||||
|
user.is_superuser = True
|
||||||
|
user.save()
|
||||||
|
elif user.ldap_username in _ldap_staff_usernames and not user.is_staff:
|
||||||
|
user.is_staff = True
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
django_auth_ldap.backend.populate_user.connect(create_user)
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
"""backup config for sqlite reset and restore"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from home.models import CustomPeriodicTask
|
|
||||||
from home.src.ta.settings import EnvironmentSettings
|
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
"""export"""
|
|
||||||
|
|
||||||
help = "Exports all users and their auth tokens to a JSON file"
|
|
||||||
FILE = Path(EnvironmentSettings.CACHE_DIR) / "backup" / "migration.json"
|
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
|
||||||
"""entry point"""
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"user_data": self.get_users(),
|
|
||||||
"schedule_data": self.get_schedules(),
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(self.FILE, "w", encoding="utf-8") as json_file:
|
|
||||||
json_file.write(json.dumps(data))
|
|
||||||
|
|
||||||
def get_users(self):
|
|
||||||
"""get users"""
|
|
||||||
|
|
||||||
users = User.objects.all()
|
|
||||||
|
|
||||||
user_data = []
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
user_info = {
|
|
||||||
"username": user.name,
|
|
||||||
"is_staff": user.is_staff,
|
|
||||||
"is_superuser": user.is_superuser,
|
|
||||||
"password": user.password,
|
|
||||||
"tokens": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
token = Token.objects.get(user=user)
|
|
||||||
user_info["tokens"] = [token.key]
|
|
||||||
except Token.DoesNotExist:
|
|
||||||
user_info["tokens"] = []
|
|
||||||
|
|
||||||
user_data.append(user_info)
|
|
||||||
|
|
||||||
return user_data
|
|
||||||
|
|
||||||
def get_schedules(self):
|
|
||||||
"""get schedules"""
|
|
||||||
|
|
||||||
all_schedules = CustomPeriodicTask.objects.all()
|
|
||||||
schedule_data = []
|
|
||||||
|
|
||||||
for schedule in all_schedules:
|
|
||||||
schedule_info = {
|
|
||||||
"name": schedule.name,
|
|
||||||
"crontab": {
|
|
||||||
"minute": schedule.crontab.minute,
|
|
||||||
"hour": schedule.crontab.hour,
|
|
||||||
"day_of_week": schedule.crontab.day_of_week,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule_data.append(schedule_info)
|
|
||||||
|
|
||||||
return schedule_data
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""restore config from backup"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from common.src.env_settings import EnvironmentSettings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django_celery_beat.models import CrontabSchedule
|
|
||||||
from rest_framework.authtoken.models import Token
|
|
||||||
from task.models import CustomPeriodicTask
|
|
||||||
from task.src.task_config import TASK_CONFIG
|
|
||||||
from user.models import Account
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
"""export"""
|
|
||||||
|
|
||||||
help = "Exports all users and their auth tokens to a JSON file"
|
|
||||||
FILE = Path(EnvironmentSettings.CACHE_DIR) / "backup" / "migration.json"
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
"""handle"""
|
|
||||||
self.stdout.write("restore users and schedules")
|
|
||||||
data = self.get_config()
|
|
||||||
self.restore_users(data["user_data"])
|
|
||||||
self.restore_schedules(data["schedule_data"])
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
" ✓ restore completed. Please restart the container."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_config(self) -> dict:
|
|
||||||
"""get config from backup"""
|
|
||||||
with open(self.FILE, "r", encoding="utf-8") as json_file:
|
|
||||||
data = json.loads(json_file.read())
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f" ✓ json file found: {self.FILE}")
|
|
||||||
)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def restore_users(self, user_data: list[dict]) -> None:
|
|
||||||
"""restore users from config"""
|
|
||||||
self.stdout.write("delete existing users")
|
|
||||||
Account.objects.all().delete()
|
|
||||||
|
|
||||||
self.stdout.write("recreate users")
|
|
||||||
for user_info in user_data:
|
|
||||||
user = Account.objects.create(
|
|
||||||
name=user_info["username"],
|
|
||||||
is_staff=user_info["is_staff"],
|
|
||||||
is_superuser=user_info["is_superuser"],
|
|
||||||
password=user_info["password"],
|
|
||||||
)
|
|
||||||
for token in user_info["tokens"]:
|
|
||||||
Token.objects.create(user=user, key=token)
|
|
||||||
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f" ✓ recreated user with name: {user_info['username']}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def restore_schedules(self, schedule_data: list[dict]) -> None:
|
|
||||||
"""restore schedules"""
|
|
||||||
self.stdout.write("delete existing schedules")
|
|
||||||
CustomPeriodicTask.objects.all().delete()
|
|
||||||
|
|
||||||
self.stdout.write("recreate schedules")
|
|
||||||
for schedule in schedule_data:
|
|
||||||
task_name = schedule["name"]
|
|
||||||
description = TASK_CONFIG[task_name].get("title")
|
|
||||||
crontab, _ = CrontabSchedule.objects.get_or_create(
|
|
||||||
minute=schedule["crontab"]["minute"],
|
|
||||||
hour=schedule["crontab"]["hour"],
|
|
||||||
day_of_week=schedule["crontab"]["day_of_week"],
|
|
||||||
timezone=EnvironmentSettings.TZ,
|
|
||||||
)
|
|
||||||
task = CustomPeriodicTask.objects.create(
|
|
||||||
name=task_name,
|
|
||||||
task=task_name,
|
|
||||||
description=description,
|
|
||||||
crontab=crontab,
|
|
||||||
)
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f" ✓ recreated schedule: {task}")
|
|
||||||
)
|
|
||||||
@@ -24,7 +24,7 @@ class Command(BaseCommand):
|
|||||||
"""command framework"""
|
"""command framework"""
|
||||||
|
|
||||||
TIMEOUT = 120
|
TIMEOUT = 120
|
||||||
MIN_MAJOR, MAX_MAJOR = 8, 8
|
MIN_MAJOR, MAX_MAJOR = 8, 9
|
||||||
MIN_MINOR = 0
|
MIN_MINOR = 0
|
||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
@@ -99,7 +99,12 @@ class Command(BaseCommand):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if status_code and status_code == 200:
|
if status_code and status_code == 200:
|
||||||
path = "_cluster/health?wait_for_status=yellow&timeout=60s"
|
path = (
|
||||||
|
"_cluster/health?"
|
||||||
|
"wait_for_status=yellow&"
|
||||||
|
"timeout=60s&"
|
||||||
|
"wait_for_active_shards=1"
|
||||||
|
)
|
||||||
_, _ = ElasticWrap(path).get(timeout=60)
|
_, _ = ElasticWrap(path).get(timeout=60)
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(" ✓ ES connection established")
|
self.style.SUCCESS(" ✓ ES connection established")
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class Command(BaseCommand):
|
|||||||
"""run all commands"""
|
"""run all commands"""
|
||||||
self.stdout.write(LOGO)
|
self.stdout.write(LOGO)
|
||||||
self.stdout.write(TOPIC)
|
self.stdout.write(TOPIC)
|
||||||
|
self._additional_auth_vars_expectations()
|
||||||
self._expected_vars()
|
self._expected_vars()
|
||||||
self._unexpected_vars()
|
self._unexpected_vars()
|
||||||
self._elastic_user_overwrite()
|
self._elastic_user_overwrite()
|
||||||
@@ -89,6 +90,50 @@ class Command(BaseCommand):
|
|||||||
self._disable_static_auth()
|
self._disable_static_auth()
|
||||||
self._create_superuser()
|
self._create_superuser()
|
||||||
|
|
||||||
|
def _additional_auth_vars_expectations(self):
|
||||||
|
"""conditionally add additional expectations for auth modes"""
|
||||||
|
ldap_required_env = [
|
||||||
|
"TA_LDAP_SERVER_URI",
|
||||||
|
"TA_LDAP_BIND_DN",
|
||||||
|
"TA_LDAP_BIND_PASSWORD",
|
||||||
|
"TA_LDAP_USER_BASE",
|
||||||
|
"TA_LDAP_USER_FILTER",
|
||||||
|
]
|
||||||
|
_login_auth_mode = (
|
||||||
|
os.environ.get("TA_LOGIN_AUTH_MODE") or "single"
|
||||||
|
).casefold()
|
||||||
|
if _login_auth_mode == "local":
|
||||||
|
UNEXPECTED_ENV_VARS["TA_LDAP"] = (
|
||||||
|
"TA_LDAP is not valid with current auth mode"
|
||||||
|
)
|
||||||
|
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
|
||||||
|
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
|
||||||
|
)
|
||||||
|
elif _login_auth_mode == "ldap":
|
||||||
|
EXPECTED_ENV_VARS.extend(ldap_required_env)
|
||||||
|
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
|
||||||
|
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
|
||||||
|
)
|
||||||
|
elif _login_auth_mode == "forwardauth":
|
||||||
|
UNEXPECTED_ENV_VARS["TA_LDAP"] = (
|
||||||
|
"TA_LDAP is not valid with current auth mode"
|
||||||
|
)
|
||||||
|
elif _login_auth_mode == "ldap_local":
|
||||||
|
EXPECTED_ENV_VARS.extend(ldap_required_env)
|
||||||
|
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
|
||||||
|
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if bool(os.environ.get("TA_LDAP")):
|
||||||
|
EXPECTED_ENV_VARS.extend(ldap_required_env)
|
||||||
|
UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = (
|
||||||
|
"TA_ENABLE_AUTH_PROXY is not valid with current auth mode"
|
||||||
|
)
|
||||||
|
if bool(os.environ.get("TA_ENABLE_AUTH_PROXY")):
|
||||||
|
UNEXPECTED_ENV_VARS["TA_LDAP"] = (
|
||||||
|
"TA_LDAP is not valid with current auth mode"
|
||||||
|
)
|
||||||
|
|
||||||
def _expected_vars(self):
|
def _expected_vars(self):
|
||||||
"""check if expected env vars are set"""
|
"""check if expected env vars are set"""
|
||||||
self.stdout.write("[1] checking expected env vars")
|
self.stdout.write("[1] checking expected env vars")
|
||||||
|
|||||||
36
backend/config/management/commands/ta_index_channel_tabs.py
Normal file
36
backend/config/management/commands/ta_index_channel_tabs.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
migration for 0.5.4 to 0.5.5
|
||||||
|
index channel_tabs for subscribed channels
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from channel.src.index import YoutubeChannel
|
||||||
|
from common.src.helper import get_channels
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""command"""
|
||||||
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
"""handle task"""
|
||||||
|
|
||||||
|
self.stdout.write("channel tags initial index")
|
||||||
|
|
||||||
|
channels = get_channels(subscribed_only=True, source=["channel_id"])
|
||||||
|
|
||||||
|
for es_channel in channels:
|
||||||
|
channel = YoutubeChannel(es_channel["channel_id"])
|
||||||
|
channel.get_from_es()
|
||||||
|
channel_name = channel.json_data["channel_name"]
|
||||||
|
channel_tabs = channel.get_channel_tabs()
|
||||||
|
channel.json_data["channel_tabs"] = channel_tabs
|
||||||
|
channel.upload_to_es()
|
||||||
|
channel.sync_to_videos()
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f" ✓ updated '{channel_name}' tabs: {channel_tabs}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
time.sleep(5)
|
||||||
@@ -10,7 +10,7 @@ from random import randint
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from appsettings.src.config import AppConfig, ReleaseVersion
|
from appsettings.src.config import AppConfig, ReleaseVersion
|
||||||
from appsettings.src.index_setup import ElasitIndexWrap
|
from appsettings.src.index_setup import ElasticIndexWrap
|
||||||
from appsettings.src.snapshot import ElasticSnapshot
|
from appsettings.src.snapshot import ElasticSnapshot
|
||||||
from common.src.env_settings import EnvironmentSettings
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from common.src.es_connect import ElasticWrap
|
from common.src.es_connect import ElasticWrap
|
||||||
@@ -19,11 +19,11 @@ from common.src.ta_redis import RedisArchivist
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.utils import dateformat
|
from django.utils import dateformat
|
||||||
from django_celery_beat.models import CrontabSchedule, PeriodicTasks
|
from django_celery_beat.models import CrontabSchedule, PeriodicTasks
|
||||||
from redis.exceptions import ResponseError
|
|
||||||
from task.models import CustomPeriodicTask
|
from task.models import CustomPeriodicTask
|
||||||
from task.src.config_schedule import ScheduleBuilder
|
from task.src.config_schedule import ScheduleBuilder
|
||||||
from task.src.task_manager import TaskManager
|
from task.src.task_manager import TaskManager
|
||||||
from task.tasks import version_check
|
from task.tasks import version_check
|
||||||
|
from video.src.constants import VideoTypeEnum
|
||||||
|
|
||||||
TOPIC = """
|
TOPIC = """
|
||||||
|
|
||||||
@@ -49,12 +49,13 @@ class Command(BaseCommand):
|
|||||||
self._version_check()
|
self._version_check()
|
||||||
self._index_setup()
|
self._index_setup()
|
||||||
self._snapshot_check()
|
self._snapshot_check()
|
||||||
self._mig_app_settings()
|
|
||||||
self._create_default_schedules()
|
self._create_default_schedules()
|
||||||
self._update_schedule_tz()
|
self._update_schedule_tz()
|
||||||
self._init_app_config()
|
self._init_app_config()
|
||||||
self._mig_channel_tags()
|
self._mig_fix_download_channel_indexed()
|
||||||
self._mig_video_channel_tags()
|
self._mig_add_default_playlist_sort()
|
||||||
|
self._mig_set_channel_tabs()
|
||||||
|
self._mig_set_video_channel_tabs()
|
||||||
|
|
||||||
def _make_folders(self):
|
def _make_folders(self):
|
||||||
"""make expected cache folders"""
|
"""make expected cache folders"""
|
||||||
@@ -150,46 +151,13 @@ class Command(BaseCommand):
|
|||||||
def _index_setup(self):
|
def _index_setup(self):
|
||||||
"""migration: validate index mappings"""
|
"""migration: validate index mappings"""
|
||||||
self.stdout.write("[6] validate index mappings")
|
self.stdout.write("[6] validate index mappings")
|
||||||
ElasitIndexWrap().setup()
|
ElasticIndexWrap().setup()
|
||||||
|
|
||||||
def _snapshot_check(self):
|
def _snapshot_check(self):
|
||||||
"""migration setup snapshots"""
|
"""migration setup snapshots"""
|
||||||
self.stdout.write("[7] setup snapshots")
|
self.stdout.write("[7] setup snapshots")
|
||||||
ElasticSnapshot().setup()
|
ElasticSnapshot().setup()
|
||||||
|
|
||||||
def _mig_app_settings(self) -> None:
|
|
||||||
"""update from v0.4.13 to v0.5.0, migrate application settings"""
|
|
||||||
self.stdout.write("[MIGRATION] move appconfig to ES")
|
|
||||||
try:
|
|
||||||
config = RedisArchivist().get_message("config")
|
|
||||||
except ResponseError:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(" Redis does not support JSON decoding")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not config or config == {"status": False}:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(" no config values to migrate")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
path = "ta_config/_doc/appsettings"
|
|
||||||
response, status_code = ElasticWrap(path).post(config)
|
|
||||||
|
|
||||||
if status_code in [200, 201]:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(" ✓ migrated appconfig to ES")
|
|
||||||
)
|
|
||||||
RedisArchivist().del_message("config", save=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
message = " 🗙 failed to migrate app config"
|
|
||||||
self.stdout.write(self.style.ERROR(message))
|
|
||||||
self.stdout.write(response)
|
|
||||||
sleep(60)
|
|
||||||
raise CommandError(message)
|
|
||||||
|
|
||||||
def _create_default_schedules(self) -> None:
|
def _create_default_schedules(self) -> None:
|
||||||
"""create default schedules for new installations"""
|
"""create default schedules for new installations"""
|
||||||
self.stdout.write("[8] create initial schedules")
|
self.stdout.write("[8] create initial schedules")
|
||||||
@@ -262,7 +230,7 @@ class Command(BaseCommand):
|
|||||||
def _init_app_config(self) -> None:
|
def _init_app_config(self) -> None:
|
||||||
"""init default app config to ES"""
|
"""init default app config to ES"""
|
||||||
self.stdout.write("[10] Check AppConfig")
|
self.stdout.write("[10] Check AppConfig")
|
||||||
_, status_code = ElasticWrap("ta_config/_doc/appsettings").get()
|
response, status_code = ElasticWrap("ta_config/_doc/appsettings").get()
|
||||||
if status_code in [200, 201]:
|
if status_code in [200, 201]:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(" skip completed appsettings init")
|
self.style.SUCCESS(" skip completed appsettings init")
|
||||||
@@ -275,6 +243,13 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if status_code != 404:
|
||||||
|
message = " 🗙 ta_config index lookup failed"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
handler = AppConfig.__new__(AppConfig)
|
handler = AppConfig.__new__(AppConfig)
|
||||||
_, status_code = handler.sync_defaults()
|
_, status_code = handler.sync_defaults()
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
@@ -284,44 +259,18 @@ class Command(BaseCommand):
|
|||||||
self.style.SUCCESS(f" Status code: {status_code}")
|
self.style.SUCCESS(f" Status code: {status_code}")
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mig_channel_tags(self) -> None:
|
def _mig_fix_download_channel_indexed(self) -> None:
|
||||||
"""update from v0.4.13 to v0.5.0, migrate incorrect data types"""
|
"""migrate from v0.5.2 to 0.5.3, fix missing channel_indexed"""
|
||||||
self.stdout.write("[MIGRATION] fix incorrect channel tags types")
|
|
||||||
path = "ta_channel/_update_by_query"
|
|
||||||
data = {
|
|
||||||
"query": {"match": {"channel_tags": False}},
|
|
||||||
"script": {
|
|
||||||
"source": "ctx._source.channel_tags = []",
|
|
||||||
"lang": "painless",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
response, status_code = ElasticWrap(path).post(data)
|
|
||||||
if status_code in [200, 201]:
|
|
||||||
updated = response.get("updated")
|
|
||||||
if updated:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f" ✓ fixed {updated} channel tags")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(" no channel tags needed fixing")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
message = " 🗙 failed to fix channel tags"
|
|
||||||
self.stdout.write(self.style.ERROR(message))
|
|
||||||
self.stdout.write(response)
|
|
||||||
sleep(60)
|
|
||||||
raise CommandError(message)
|
|
||||||
|
|
||||||
def _mig_video_channel_tags(self) -> None:
|
|
||||||
"""update from v0.4.13 to v0.5.0, migrate incorrect data types"""
|
|
||||||
self.stdout.write("[MIGRATION] fix incorrect video channel tags types")
|
self.stdout.write("[MIGRATION] fix incorrect video channel tags types")
|
||||||
path = "ta_video/_update_by_query"
|
path = "ta_download/_update_by_query"
|
||||||
data = {
|
data = {
|
||||||
"query": {"match": {"channel.channel_tags": False}},
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must_not": [{"exists": {"field": "channel_indexed"}}]
|
||||||
|
}
|
||||||
|
},
|
||||||
"script": {
|
"script": {
|
||||||
"source": "ctx._source.channel.channel_tags = []",
|
"source": "ctx._source.channel_indexed = false",
|
||||||
"lang": "painless",
|
"lang": "painless",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -330,15 +279,11 @@ class Command(BaseCommand):
|
|||||||
updated = response.get("updated")
|
updated = response.get("updated")
|
||||||
if updated:
|
if updated:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(f" ✓ fixed {updated} queued videos")
|
||||||
f" ✓ fixed {updated} video channel tags"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.SUCCESS(
|
self.style.SUCCESS(" no queued videos to fix")
|
||||||
" no video channel tags needed fixing"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -347,3 +292,107 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(response)
|
self.stdout.write(response)
|
||||||
sleep(60)
|
sleep(60)
|
||||||
raise CommandError(message)
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _mig_add_default_playlist_sort(self) -> None:
|
||||||
|
"""migrate from 0.5.4 to 0.5.5 set default playlist sortorder"""
|
||||||
|
self.stdout.write("[MIGRATION] set default playlist sort order")
|
||||||
|
path = "ta_playlist/_update_by_query"
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must_not": [{"exists": {"field": "playlist_sort_order"}}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"source": "ctx._source.playlist_sort_order = 'top'",
|
||||||
|
"lang": "painless",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, status_code = ElasticWrap(path).post(data)
|
||||||
|
if status_code in [200, 201]:
|
||||||
|
updated = response.get("updated")
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ updated {updated} playlists")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" no playlists need updating")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = " 🗙 failed to set default playlist sort order"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _mig_set_channel_tabs(self) -> None:
|
||||||
|
"""migrate from 0.5.4 to 0.5.5 set initial channel tabs"""
|
||||||
|
self.stdout.write("[MIGRATION] set default channel_tabs")
|
||||||
|
|
||||||
|
path = "ta_channel/_update_by_query"
|
||||||
|
tabs = VideoTypeEnum.values_known()
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"bool": {"must_not": [{"exists": {"field": "channel_tabs"}}]}
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"source": f"ctx._source.channel_tabs = {tabs}",
|
||||||
|
"lang": "painless",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, status_code = ElasticWrap(path).post(data)
|
||||||
|
if status_code in [200, 201]:
|
||||||
|
updated = response.get("updated")
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ updated {updated} channels")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" no channels need updating")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = " 🗙 failed to set default channel_tabs"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|
||||||
|
def _mig_set_video_channel_tabs(self) -> None:
|
||||||
|
"""migrate from 0.5.4 to 0.5.5 set initial video channel tabs"""
|
||||||
|
self.stdout.write("[MIGRATION] set default channel_tabs for videos")
|
||||||
|
|
||||||
|
path = "ta_video/_update_by_query"
|
||||||
|
tabs = VideoTypeEnum.values_known()
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"bool": {
|
||||||
|
"must_not": [{"exists": {"field": "channel.channel_tabs"}}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"source": f"ctx._source.channel.channel_tabs = {tabs}",
|
||||||
|
"lang": "painless",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response, status_code = ElasticWrap(path).post(data)
|
||||||
|
if status_code in [200, 201]:
|
||||||
|
updated = response.get("updated")
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f" ✓ updated {updated} videos")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(" no videos need updating")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
message = " 🗙 failed to set default channel_tabs"
|
||||||
|
self.stdout.write(self.style.ERROR(message))
|
||||||
|
self.stdout.write(response)
|
||||||
|
sleep(60)
|
||||||
|
raise CommandError(message)
|
||||||
|
|||||||
@@ -104,99 +104,6 @@ TEMPLATES = [
|
|||||||
|
|
||||||
WSGI_APPLICATION = "config.wsgi.application"
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
if bool(environ.get("TA_LDAP")):
|
|
||||||
# pylint: disable=global-at-module-level
|
|
||||||
import ldap
|
|
||||||
from django_auth_ldap.config import LDAPSearch
|
|
||||||
|
|
||||||
global AUTH_LDAP_SERVER_URI
|
|
||||||
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
|
|
||||||
|
|
||||||
global AUTH_LDAP_BIND_DN
|
|
||||||
AUTH_LDAP_BIND_DN = environ.get("TA_LDAP_BIND_DN")
|
|
||||||
|
|
||||||
global AUTH_LDAP_BIND_PASSWORD
|
|
||||||
AUTH_LDAP_BIND_PASSWORD = environ.get("TA_LDAP_BIND_PASSWORD")
|
|
||||||
|
|
||||||
"""
|
|
||||||
Since these are new environment variables, taking the opporunity to use
|
|
||||||
more accurate env names.
|
|
||||||
Given Names are *_technically_* different from Personal names, as people
|
|
||||||
who change their names have different given names and personal names,
|
|
||||||
and they go by personal names. Additionally, "LastName" is actually
|
|
||||||
incorrect for many cultures, such as Korea, where the
|
|
||||||
family name comes first, and the personal name comes last.
|
|
||||||
|
|
||||||
But we all know people are going to try to guess at these, so still want
|
|
||||||
to include names that people will guess, hence using first/last as well.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Attribute mapping options
|
|
||||||
|
|
||||||
global AUTH_LDAP_USER_ATTR_MAP_USERNAME
|
|
||||||
AUTH_LDAP_USER_ATTR_MAP_USERNAME = (
|
|
||||||
environ.get("TA_LDAP_USER_ATTR_MAP_USERNAME")
|
|
||||||
or environ.get("TA_LDAP_USER_ATTR_MAP_UID")
|
|
||||||
or "uid"
|
|
||||||
)
|
|
||||||
|
|
||||||
global AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME
|
|
||||||
AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME = (
|
|
||||||
environ.get("TA_LDAP_USER_ATTR_MAP_PERSONALNAME")
|
|
||||||
or environ.get("TA_LDAP_USER_ATTR_MAP_FIRSTNAME")
|
|
||||||
or environ.get("TA_LDAP_USER_ATTR_MAP_GIVENNAME")
|
|
||||||
or "givenName"
|
|
||||||
)
|
|
||||||
|
|
||||||
global AUTH_LDAP_USER_ATTR_MAP_SURNAME
|
|
||||||
AUTH_LDAP_USER_ATTR_MAP_SURNAME = (
|
|
||||||
environ.get("TA_LDAP_USER_ATTR_MAP_SURNAME")
|
|
||||||
or environ.get("TA_LDAP_USER_ATTR_MAP_LASTNAME")
|
|
||||||
or environ.get("TA_LDAP_USER_ATTR_MAP_FAMILYNAME")
|
|
||||||
or "sn"
|
|
||||||
)
|
|
||||||
|
|
||||||
global AUTH_LDAP_USER_ATTR_MAP_EMAIL
|
|
||||||
AUTH_LDAP_USER_ATTR_MAP_EMAIL = (
|
|
||||||
environ.get("TA_LDAP_USER_ATTR_MAP_EMAIL")
|
|
||||||
or environ.get("TA_LDAP_USER_ATTR_MAP_MAIL")
|
|
||||||
or "mail"
|
|
||||||
)
|
|
||||||
|
|
||||||
global AUTH_LDAP_USER_BASE
|
|
||||||
AUTH_LDAP_USER_BASE = environ.get("TA_LDAP_USER_BASE")
|
|
||||||
|
|
||||||
global AUTH_LDAP_USER_FILTER
|
|
||||||
AUTH_LDAP_USER_FILTER = environ.get("TA_LDAP_USER_FILTER")
|
|
||||||
|
|
||||||
global AUTH_LDAP_USER_SEARCH
|
|
||||||
# pylint: disable=no-member
|
|
||||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
|
||||||
AUTH_LDAP_USER_BASE,
|
|
||||||
ldap.SCOPE_SUBTREE,
|
|
||||||
"(&("
|
|
||||||
+ AUTH_LDAP_USER_ATTR_MAP_USERNAME
|
|
||||||
+ "=%(user)s)"
|
|
||||||
+ AUTH_LDAP_USER_FILTER
|
|
||||||
+ ")",
|
|
||||||
)
|
|
||||||
|
|
||||||
global AUTH_LDAP_USER_ATTR_MAP
|
|
||||||
AUTH_LDAP_USER_ATTR_MAP = {
|
|
||||||
"username": AUTH_LDAP_USER_ATTR_MAP_USERNAME,
|
|
||||||
"first_name": AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME,
|
|
||||||
"last_name": AUTH_LDAP_USER_ATTR_MAP_SURNAME,
|
|
||||||
"email": AUTH_LDAP_USER_ATTR_MAP_EMAIL,
|
|
||||||
}
|
|
||||||
|
|
||||||
if bool(environ.get("TA_LDAP_DISABLE_CERT_CHECK")):
|
|
||||||
global AUTH_LDAP_GLOBAL_OPTIONS
|
|
||||||
AUTH_LDAP_GLOBAL_OPTIONS = {
|
|
||||||
ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER,
|
|
||||||
}
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||||
|
|
||||||
@@ -230,19 +137,41 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
AUTH_USER_MODEL = "user.Account"
|
AUTH_USER_MODEL = "user.Account"
|
||||||
|
|
||||||
# Forward-auth authentication
|
# Configure Authentication Backend Combinations
|
||||||
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
|
_login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold()
|
||||||
TA_AUTH_PROXY_USERNAME_HEADER = (
|
if _login_auth_mode == "local":
|
||||||
environ.get("TA_AUTH_PROXY_USERNAME_HEADER") or "HTTP_REMOTE_USER"
|
AUTHENTICATION_BACKENDS: tuple = (
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
)
|
)
|
||||||
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL")
|
elif _login_auth_mode == "ldap":
|
||||||
|
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
|
||||||
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
|
from .ldap_settings import * # noqa: F403 F401
|
||||||
|
elif _login_auth_mode == "forwardauth":
|
||||||
|
from .fwd_auth_settings import * # noqa: F403 F401
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = (
|
AUTHENTICATION_BACKENDS = (
|
||||||
"django.contrib.auth.backends.RemoteUserBackend",
|
"django.contrib.auth.backends.RemoteUserBackend",
|
||||||
)
|
)
|
||||||
|
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
|
||||||
|
elif _login_auth_mode == "ldap_local":
|
||||||
|
AUTHENTICATION_BACKENDS = (
|
||||||
|
"django_auth_ldap.backend.LDAPBackend",
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
)
|
||||||
|
from .ldap_settings import * # noqa: F403 F401
|
||||||
|
else:
|
||||||
|
# If none of these cases match, AUTHENTICATION_BACKENDS is unset, which
|
||||||
|
# means the ModelBackend should be used by default
|
||||||
|
if bool(environ.get("TA_LDAP")):
|
||||||
|
AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",)
|
||||||
|
from .ldap_settings import * # noqa: F403 F401
|
||||||
|
if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
|
||||||
|
from .fwd_auth_settings import * # noqa: F403 F401
|
||||||
|
|
||||||
|
AUTHENTICATION_BACKENDS = (
|
||||||
|
"django.contrib.auth.backends.RemoteUserBackend",
|
||||||
|
)
|
||||||
|
MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||||
@@ -294,7 +223,7 @@ 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.5.2"
|
TA_VERSION = "v0.5.8-unstable"
|
||||||
|
|
||||||
# API
|
# API
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
@@ -307,3 +236,21 @@ SPECTACULAR_SETTINGS = {
|
|||||||
"VERSION": TA_VERSION,
|
"VERSION": TA_VERSION,
|
||||||
"SERVE_INCLUDE_SCHEMA": False,
|
"SERVE_INCLUDE_SCHEMA": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"apprise": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": "DEBUG",
|
||||||
|
"propagate": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ class DownloadItemSerializer(serializers.Serializer):
|
|||||||
channel_indexed = serializers.BooleanField()
|
channel_indexed = serializers.BooleanField()
|
||||||
channel_name = serializers.CharField()
|
channel_name = serializers.CharField()
|
||||||
duration = serializers.CharField()
|
duration = serializers.CharField()
|
||||||
published = serializers.CharField()
|
published = serializers.CharField(allow_null=True)
|
||||||
status = serializers.ChoiceField(choices=["pending", "ignore"])
|
status = serializers.ChoiceField(choices=["pending", "ignore"])
|
||||||
timestamp = serializers.IntegerField()
|
timestamp = serializers.IntegerField(allow_null=True)
|
||||||
title = serializers.CharField()
|
title = serializers.CharField()
|
||||||
vid_thumb_url = serializers.CharField()
|
vid_thumb_url = serializers.CharField(allow_null=True)
|
||||||
vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values())
|
vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values())
|
||||||
youtube_id = serializers.CharField()
|
youtube_id = serializers.CharField()
|
||||||
message = serializers.CharField(required=False)
|
message = serializers.CharField(required=False)
|
||||||
@@ -42,14 +42,23 @@ class DownloadListQuerySerializer(
|
|||||||
filter = serializers.ChoiceField(
|
filter = serializers.ChoiceField(
|
||||||
choices=["pending", "ignore"], required=False
|
choices=["pending", "ignore"], required=False
|
||||||
)
|
)
|
||||||
|
vid_type = serializers.ChoiceField(
|
||||||
|
choices=VideoTypeEnum.values_known(), required=False
|
||||||
|
)
|
||||||
channel = serializers.CharField(required=False, help_text="channel ID")
|
channel = serializers.CharField(required=False, help_text="channel ID")
|
||||||
page = serializers.IntegerField(required=False)
|
page = serializers.IntegerField(required=False)
|
||||||
|
q = serializers.CharField(required=False, help_text="Search Query")
|
||||||
|
error = serializers.BooleanField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
|
class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
|
||||||
"""serialize bulk delete download queue query string"""
|
"""serialize bulk delete download queue query string"""
|
||||||
|
|
||||||
filter = serializers.ChoiceField(choices=["pending", "ignore"])
|
filter = serializers.ChoiceField(choices=["pending", "ignore"])
|
||||||
|
channel = serializers.CharField(required=False, help_text="channel ID")
|
||||||
|
vid_type = serializers.ChoiceField(
|
||||||
|
choices=VideoTypeEnum.values_known(), required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AddDownloadItemSerializer(serializers.Serializer):
|
class AddDownloadItemSerializer(serializers.Serializer):
|
||||||
@@ -69,6 +78,27 @@ class AddToDownloadQuerySerializer(serializers.Serializer):
|
|||||||
"""add to queue query serializer"""
|
"""add to queue query serializer"""
|
||||||
|
|
||||||
autostart = serializers.BooleanField(required=False)
|
autostart = serializers.BooleanField(required=False)
|
||||||
|
flat = serializers.BooleanField(required=False)
|
||||||
|
force = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateDowloadQuerySerializer(serializers.Serializer):
|
||||||
|
"""serialize bulk update query"""
|
||||||
|
|
||||||
|
filter = serializers.ChoiceField(choices=["pending", "ignore", "priority"])
|
||||||
|
channel = serializers.CharField(required=False)
|
||||||
|
vid_type = serializers.ChoiceField(
|
||||||
|
choices=VideoTypeEnum.values_known(), required=False
|
||||||
|
)
|
||||||
|
error = serializers.BooleanField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateDowloadDataSerializer(serializers.Serializer):
|
||||||
|
"""serialize data"""
|
||||||
|
|
||||||
|
status = serializers.ChoiceField(
|
||||||
|
choices=["pending", "ignore", "priority", "clear_error"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DownloadQueueItemUpdateSerializer(serializers.Serializer):
|
class DownloadQueueItemUpdateSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -4,16 +4,25 @@ Functionality:
|
|||||||
- linked with ta_dowload index
|
- linked with ta_dowload index
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from appsettings.src.config import AppConfig
|
from appsettings.src.config import AppConfig
|
||||||
|
from channel.src.index import YoutubeChannel
|
||||||
|
from channel.src.remote_query import get_last_channel_videos
|
||||||
from common.src.es_connect import ElasticWrap, IndexPaginate
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from common.src.helper import get_duration_str, is_shorts, rand_sleep
|
from common.src.helper import (
|
||||||
from download.src.subscriptions import ChannelSubscription
|
get_channels,
|
||||||
|
get_duration_str,
|
||||||
|
is_shorts,
|
||||||
|
rand_sleep,
|
||||||
|
)
|
||||||
|
from common.src.urlparser import ParsedURLType
|
||||||
|
from download.src.queue_interact import PendingInteract
|
||||||
from download.src.thumbnails import ThumbManager
|
from download.src.thumbnails import ThumbManager
|
||||||
from download.src.yt_dlp_base import YtWrap
|
|
||||||
from playlist.src.index import YoutubePlaylist
|
from playlist.src.index import YoutubePlaylist
|
||||||
from video.src.constants import VideoTypeEnum
|
from video.src.constants import VideoTypeEnum
|
||||||
|
from video.src.index import YoutubeVideo
|
||||||
|
|
||||||
|
|
||||||
class PendingIndex:
|
class PendingIndex:
|
||||||
@@ -61,11 +70,7 @@ class PendingIndex:
|
|||||||
"""get a list of all channels indexed"""
|
"""get a list of all channels indexed"""
|
||||||
self.all_channels = []
|
self.all_channels = []
|
||||||
self.channel_overwrites = {}
|
self.channel_overwrites = {}
|
||||||
data = {
|
channels = get_channels(subscribed_only=False)
|
||||||
"query": {"match_all": {}},
|
|
||||||
"sort": [{"channel_id": {"order": "asc"}}],
|
|
||||||
}
|
|
||||||
channels = IndexPaginate("ta_channel", data).get_results()
|
|
||||||
|
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
channel_id = channel["channel_id"]
|
channel_id = channel["channel_id"]
|
||||||
@@ -88,67 +93,6 @@ class PendingIndex:
|
|||||||
self.video_overwrites.update({video_id: overwrites})
|
self.video_overwrites.update({video_id: overwrites})
|
||||||
|
|
||||||
|
|
||||||
class PendingInteract:
|
|
||||||
"""interact with items in download queue"""
|
|
||||||
|
|
||||||
def __init__(self, youtube_id=False, status=False):
|
|
||||||
self.youtube_id = youtube_id
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
def delete_item(self):
|
|
||||||
"""delete single item from pending"""
|
|
||||||
path = f"ta_download/_doc/{self.youtube_id}"
|
|
||||||
_, _ = ElasticWrap(path).delete(refresh=True)
|
|
||||||
|
|
||||||
def delete_by_status(self):
|
|
||||||
"""delete all matching item by status"""
|
|
||||||
data = {"query": {"term": {"status": {"value": self.status}}}}
|
|
||||||
path = "ta_download/_delete_by_query"
|
|
||||||
_, _ = ElasticWrap(path).post(data=data)
|
|
||||||
|
|
||||||
def update_status(self):
|
|
||||||
"""update status of pending item"""
|
|
||||||
if self.status == "priority":
|
|
||||||
data = {
|
|
||||||
"doc": {
|
|
||||||
"status": "pending",
|
|
||||||
"auto_start": True,
|
|
||||||
"message": None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
data = {"doc": {"status": self.status}}
|
|
||||||
|
|
||||||
path = f"ta_download/_update/{self.youtube_id}/?refresh=true"
|
|
||||||
_, _ = ElasticWrap(path).post(data=data)
|
|
||||||
|
|
||||||
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"""
|
||||||
|
|
||||||
@@ -159,120 +103,390 @@ class PendingList(PendingIndex):
|
|||||||
"check_formats": None,
|
"check_formats": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, youtube_ids=False, task=False):
|
def __init__(
|
||||||
|
self,
|
||||||
|
youtube_ids: list[ParsedURLType],
|
||||||
|
task=None,
|
||||||
|
auto_start=False,
|
||||||
|
flat=False,
|
||||||
|
force=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.task = task
|
||||||
|
self.auto_start = auto_start
|
||||||
|
self.flat = flat
|
||||||
|
self.force = force
|
||||||
self.to_skip = False
|
self.to_skip = False
|
||||||
self.missing_videos = False
|
self.missing_videos: list[dict] = []
|
||||||
|
self.added = 0
|
||||||
|
|
||||||
def parse_url_list(self):
|
def parse_url_list(self, status="pending") -> int:
|
||||||
"""extract youtube ids from list"""
|
"""extract youtube ids from list"""
|
||||||
self.missing_videos = []
|
|
||||||
self.get_download()
|
self.get_download()
|
||||||
self.get_indexed()
|
self.get_indexed()
|
||||||
total = len(self.youtube_ids)
|
|
||||||
for idx, entry in enumerate(self.youtube_ids):
|
|
||||||
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):
|
|
||||||
"""process single entry from url list"""
|
|
||||||
vid_type = self._get_vid_type(entry)
|
|
||||||
if entry["type"] == "video":
|
|
||||||
self._add_video(entry["url"], vid_type)
|
|
||||||
elif entry["type"] == "channel":
|
|
||||||
self._parse_channel(entry["url"], vid_type)
|
|
||||||
elif entry["type"] == "playlist":
|
|
||||||
self._parse_playlist(entry["url"])
|
|
||||||
else:
|
|
||||||
raise ValueError(f"invalid url_type: {entry}")
|
|
||||||
|
|
||||||
@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"""
|
|
||||||
if url not in self.missing_videos and url not in self.to_skip:
|
|
||||||
self.missing_videos.append((url, vid_type))
|
|
||||||
else:
|
|
||||||
print(f"{url}: skipped adding already indexed video to download.")
|
|
||||||
|
|
||||||
def _parse_channel(self, url, vid_type):
|
|
||||||
"""add all videos of channel to list"""
|
|
||||||
video_results = ChannelSubscription().get_last_youtube_videos(
|
|
||||||
url, limit=False, query_filter=vid_type
|
|
||||||
)
|
|
||||||
for video_id, _, vid_type in video_results:
|
|
||||||
self._add_video(video_id, vid_type)
|
|
||||||
|
|
||||||
def _parse_playlist(self, url):
|
|
||||||
"""add all videos of playlist to list"""
|
|
||||||
playlist = YoutubePlaylist(url)
|
|
||||||
is_active = playlist.update_playlist()
|
|
||||||
if not is_active:
|
|
||||||
message = f"{playlist.youtube_id}: failed to extract metadata"
|
|
||||||
print(message)
|
|
||||||
raise ValueError(message)
|
|
||||||
|
|
||||||
entries = playlist.json_data["playlist_entries"]
|
|
||||||
to_add = [i["youtube_id"] for i in entries if not i["downloaded"]]
|
|
||||||
if not to_add:
|
|
||||||
return
|
|
||||||
|
|
||||||
for video_id in to_add:
|
|
||||||
# match vid_type later
|
|
||||||
self._add_video(video_id, VideoTypeEnum.UNKNOWN)
|
|
||||||
|
|
||||||
def add_to_pending(self, status="pending", auto_start=False):
|
|
||||||
"""add missing videos to pending list"""
|
|
||||||
self.get_channels()
|
self.get_channels()
|
||||||
|
total = len(self.youtube_ids)
|
||||||
|
for idx, entry in enumerate(self.youtube_ids, start=1):
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=[f"Extracting URL {idx}/{total}"],
|
||||||
|
progress=idx / total,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._process_entry(entry, idx, total)
|
||||||
|
|
||||||
|
if self.missing_videos:
|
||||||
|
self.added += self.add_to_pending(status)
|
||||||
|
self.missing_videos = []
|
||||||
|
|
||||||
total = len(self.missing_videos)
|
|
||||||
videos_added = []
|
|
||||||
for idx, (youtube_id, vid_type) in enumerate(self.missing_videos):
|
|
||||||
if self.task and self.task.is_stopped():
|
if self.task and self.task.is_stopped():
|
||||||
break
|
break
|
||||||
|
|
||||||
print(f"{youtube_id}: [{idx + 1}/{total}]: add to queue")
|
rand_sleep(self.config)
|
||||||
self._notify_add(idx, total)
|
|
||||||
video_details = self.get_youtube_details(youtube_id, vid_type)
|
return self.added
|
||||||
if not video_details:
|
|
||||||
rand_sleep(self.config)
|
def _process_entry(self, entry: ParsedURLType, idx: int, total: int):
|
||||||
|
"""process single entry from url list"""
|
||||||
|
if entry["type"] == "video":
|
||||||
|
to_add = self._add_video(entry["url"], entry["vid_type"])
|
||||||
|
if to_add:
|
||||||
|
self.__notify_add(
|
||||||
|
item_type="video",
|
||||||
|
name=to_add["title"],
|
||||||
|
idx=idx,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif entry["type"] == "channel":
|
||||||
|
self._parse_channel(entry)
|
||||||
|
elif entry["type"] == "playlist":
|
||||||
|
self._parse_playlist(entry["url"], entry.get("limit"))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"invalid url_type: {entry}")
|
||||||
|
|
||||||
|
def _add_video(self, url, vid_type) -> dict | None:
|
||||||
|
"""add video to list"""
|
||||||
|
if self.auto_start and url in set(
|
||||||
|
i["youtube_id"] for i in self.all_pending
|
||||||
|
):
|
||||||
|
PendingInteract(youtube_id=url, status="priority").update_status()
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not self.force and (
|
||||||
|
url in self.missing_videos or url in self.to_skip
|
||||||
|
):
|
||||||
|
print(f"{url}: skipped adding already indexed video to download.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.force and url in self.all_ignored or url in self.all_pending:
|
||||||
|
print(f"{url}: skipped adding force video already in queue.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
to_add = self._parse_video(url, vid_type)
|
||||||
|
if to_add:
|
||||||
|
self.missing_videos.append(to_add)
|
||||||
|
|
||||||
|
return to_add
|
||||||
|
|
||||||
|
def _parse_channel(self, entry):
|
||||||
|
"""parse channel"""
|
||||||
|
url = entry["url"]
|
||||||
|
vid_type = entry["vid_type"]
|
||||||
|
if isinstance(vid_type, str):
|
||||||
|
# lookup enum
|
||||||
|
vid_type = getattr(VideoTypeEnum, vid_type.upper())
|
||||||
|
|
||||||
|
limit = entry.get("limit")
|
||||||
|
video_results = get_last_channel_videos(
|
||||||
|
channel_id=url,
|
||||||
|
config=self.config,
|
||||||
|
limit=limit,
|
||||||
|
query_filter=vid_type,
|
||||||
|
)
|
||||||
|
if not video_results:
|
||||||
|
print(f"{url}: no videos to add from channel, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_handler = YoutubeChannel(url)
|
||||||
|
channel_handler.build_json(upload=False)
|
||||||
|
if not channel_handler.json_data:
|
||||||
|
print(f"{url}: channel metadata extraction failed, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
total = len(video_results)
|
||||||
|
for idx, video_data in enumerate(video_results, start=1):
|
||||||
|
to_add = self.__parse_channel_video(
|
||||||
|
video_data, vid_type, channel_handler.json_data
|
||||||
|
)
|
||||||
|
if self.task and self.task.is_stopped():
|
||||||
|
break
|
||||||
|
|
||||||
|
if not to_add:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
video_details.update(
|
self.missing_videos.append(to_add)
|
||||||
{
|
self.__notify_add(
|
||||||
"status": status,
|
item_type="channel",
|
||||||
"auto_start": auto_start,
|
name=channel_handler.json_data["channel_name"],
|
||||||
}
|
idx=idx,
|
||||||
|
total=total,
|
||||||
)
|
)
|
||||||
|
|
||||||
url = video_details["vid_thumb_url"]
|
def __parse_channel_video(
|
||||||
ThumbManager(youtube_id).download_video_thumb(url)
|
self, video_data, vid_type, channel_json
|
||||||
es_url = f"ta_download/_doc/{youtube_id}"
|
) -> dict | None:
|
||||||
_, _ = ElasticWrap(es_url).put(video_details)
|
"""parse video of channel"""
|
||||||
videos_added.append(youtube_id)
|
video_id = video_data["id"]
|
||||||
|
if video_id in self.to_skip:
|
||||||
|
return None
|
||||||
|
|
||||||
if idx != total:
|
# fallback
|
||||||
rand_sleep(self.config)
|
channel_name = channel_json["channel_name"]
|
||||||
|
channel_id = channel_json["channel_id"]
|
||||||
|
|
||||||
return videos_added
|
if self.flat:
|
||||||
|
if not video_data.get("channel"):
|
||||||
|
video_data["channel"] = channel_name
|
||||||
|
|
||||||
def _notify_add(self, idx, total):
|
if not video_data.get("channel_id"):
|
||||||
|
video_data["channel_id"] = channel_id
|
||||||
|
|
||||||
|
to_add = self._parse_entry(
|
||||||
|
youtube_id=video_id,
|
||||||
|
video_data=video_data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
to_add = self._parse_video(video_id, vid_type)
|
||||||
|
|
||||||
|
return to_add
|
||||||
|
|
||||||
|
def _parse_playlist(self, url: str, limit: int | None):
|
||||||
|
"""fast parse playlist"""
|
||||||
|
playlist = YoutubePlaylist(url)
|
||||||
|
playlist.update_playlist()
|
||||||
|
if not playlist.youtube_meta:
|
||||||
|
print(f"{url}: playlist metadata extraction failed, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
video_results = playlist.youtube_meta["entries"]
|
||||||
|
if limit:
|
||||||
|
video_results = video_results[:limit]
|
||||||
|
|
||||||
|
total = len(video_results)
|
||||||
|
for idx, video_data in enumerate(video_results, start=1):
|
||||||
|
video_id = video_data["id"]
|
||||||
|
if video_id in self.to_skip:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.task and self.task.is_stopped():
|
||||||
|
break
|
||||||
|
|
||||||
|
if self.flat:
|
||||||
|
if not video_data.get("channel"):
|
||||||
|
video_data["channel"] = playlist.youtube_meta["channel"]
|
||||||
|
|
||||||
|
if not video_data.get("channel_id"):
|
||||||
|
channel_id = playlist.youtube_meta["channel_id"]
|
||||||
|
video_data["channel_id"] = channel_id
|
||||||
|
|
||||||
|
to_add = self._parse_entry(video_id, video_data)
|
||||||
|
else:
|
||||||
|
to_add = self._parse_video(video_id, vid_type=None)
|
||||||
|
|
||||||
|
if not to_add:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.missing_videos.append(to_add)
|
||||||
|
self.__notify_add(
|
||||||
|
item_type="playlist",
|
||||||
|
name=playlist.json_data["playlist_name"],
|
||||||
|
idx=idx,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_video(self, url: str, vid_type) -> dict | None:
|
||||||
|
"""parse video when not flat, fetch from YT"""
|
||||||
|
video = YoutubeVideo(youtube_id=url)
|
||||||
|
video.get_from_youtube()
|
||||||
|
|
||||||
|
if not video.youtube_meta:
|
||||||
|
print(f"{url}: video metadata extraction failed, skipping")
|
||||||
|
if self.task:
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=[
|
||||||
|
"Video extraction failed.",
|
||||||
|
f"{video.error}",
|
||||||
|
],
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
video.youtube_meta["vid_type"] = vid_type
|
||||||
|
to_add = self._parse_entry(
|
||||||
|
youtube_id=url,
|
||||||
|
video_data=video.youtube_meta,
|
||||||
|
)
|
||||||
|
if not to_add:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ThumbManager(item_id=url).download_video_thumb(to_add["vid_thumb_url"])
|
||||||
|
rand_sleep(self.config)
|
||||||
|
|
||||||
|
return to_add
|
||||||
|
|
||||||
|
def _parse_entry(
|
||||||
|
self,
|
||||||
|
youtube_id: str,
|
||||||
|
video_data: dict,
|
||||||
|
) -> dict | None:
|
||||||
|
"""parse entry"""
|
||||||
|
if video_data.get("id") != youtube_id:
|
||||||
|
# skip premium videos with different id or redirects
|
||||||
|
print(f"{youtube_id}: skipping redirect, id not matching")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if video_data.get("live_status") in ["is_upcoming", "is_live"]:
|
||||||
|
print(f"{youtube_id}: skip is_upcoming or is_live")
|
||||||
|
return None
|
||||||
|
|
||||||
|
to_add = {
|
||||||
|
"youtube_id": video_data["id"],
|
||||||
|
"title": video_data["title"],
|
||||||
|
"vid_thumb_url": self.__extract_thumb(video_data),
|
||||||
|
"duration": get_duration_str(video_data.get("duration", 0)),
|
||||||
|
"published": self.__extract_published(video_data),
|
||||||
|
"timestamp": int(datetime.now().timestamp()),
|
||||||
|
"vid_type": self.__extract_vid_type(video_data),
|
||||||
|
"channel_name": video_data["channel"],
|
||||||
|
"channel_id": video_data["channel_id"],
|
||||||
|
"channel_indexed": video_data["channel_id"] in self.all_channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
return to_add
|
||||||
|
|
||||||
|
def __extract_thumb(self, video_data) -> str | None:
|
||||||
|
"""extract thumb"""
|
||||||
|
if "thumbnail" in video_data:
|
||||||
|
return video_data["thumbnail"]
|
||||||
|
|
||||||
|
if video_data.get("thumbnails"):
|
||||||
|
return video_data["thumbnails"][-1]["url"]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __extract_published(self, video_data) -> str | int | None:
|
||||||
|
"""build published date or timestamp"""
|
||||||
|
timestamp = video_data.get("timestamp")
|
||||||
|
if timestamp:
|
||||||
|
return timestamp
|
||||||
|
|
||||||
|
upload_date = video_data.get("upload_date")
|
||||||
|
if not upload_date:
|
||||||
|
return None
|
||||||
|
|
||||||
|
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
|
||||||
|
published = upload_date_time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
return published
|
||||||
|
|
||||||
|
def __extract_vid_type(self, video_data) -> str:
|
||||||
|
"""build vid type"""
|
||||||
|
if (
|
||||||
|
"vid_type" in video_data
|
||||||
|
and video_data["vid_type"]
|
||||||
|
and str(video_data["vid_type"]) in VideoTypeEnum.values_known()
|
||||||
|
):
|
||||||
|
return VideoTypeEnum(video_data["vid_type"]).value
|
||||||
|
|
||||||
|
if video_data.get("live_status") == "was_live":
|
||||||
|
return VideoTypeEnum.STREAMS.value
|
||||||
|
|
||||||
|
if video_data.get("width", 0) > video_data.get("height", 0):
|
||||||
|
return VideoTypeEnum.VIDEOS.value
|
||||||
|
|
||||||
|
duration = video_data.get("duration")
|
||||||
|
if duration and isinstance(duration, int):
|
||||||
|
if duration > 3 * 60:
|
||||||
|
return VideoTypeEnum.VIDEOS.value
|
||||||
|
|
||||||
|
if is_shorts(video_data["id"]):
|
||||||
|
return VideoTypeEnum.SHORTS.value
|
||||||
|
|
||||||
|
return VideoTypeEnum.VIDEOS.value
|
||||||
|
|
||||||
|
def add_to_pending(self, status="pending") -> int:
|
||||||
|
"""add missing videos to pending list"""
|
||||||
|
|
||||||
|
total = len(self.missing_videos)
|
||||||
|
|
||||||
|
if not self.missing_videos:
|
||||||
|
self._notify_empty()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self._notify_start(total)
|
||||||
|
bulk_list = []
|
||||||
|
for video_entry in self.missing_videos:
|
||||||
|
video_entry.update(
|
||||||
|
{
|
||||||
|
"status": status,
|
||||||
|
"auto_start": self.auto_start,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
video_id = video_entry["youtube_id"]
|
||||||
|
action = {"index": {"_index": "ta_download", "_id": video_id}}
|
||||||
|
bulk_list.append(json.dumps(action))
|
||||||
|
bulk_list.append(json.dumps(video_entry))
|
||||||
|
|
||||||
|
# add last newline
|
||||||
|
bulk_list.append("\n")
|
||||||
|
query_str = "\n".join(bulk_list)
|
||||||
|
_, status_code = ElasticWrap("_bulk").post(query_str, ndjson=True)
|
||||||
|
if status_code != 200:
|
||||||
|
self._notify_fail(status_code)
|
||||||
|
else:
|
||||||
|
self._notify_done(total)
|
||||||
|
|
||||||
|
return len(self.missing_videos)
|
||||||
|
|
||||||
|
def __notify_add(
|
||||||
|
self, item_type: str, name: str, idx: int, total: int
|
||||||
|
) -> None:
|
||||||
|
"""notify"""
|
||||||
|
if not self.task:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.flat:
|
||||||
|
lines = [
|
||||||
|
f"Bulk extracting {item_type.title()}: '{name}'.",
|
||||||
|
f"Fast adding item {idx}/{total}.",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines = [
|
||||||
|
f"Full extracting {item_type.title()}: '{name}'",
|
||||||
|
f"Parsing item {idx}/{total}.",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=lines,
|
||||||
|
progress=idx / total,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _notify_empty(self):
|
||||||
|
"""notify nothing to add"""
|
||||||
|
if not self.task:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=[
|
||||||
|
"Extracting videos completed.",
|
||||||
|
"No new videos found to add.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _notify_start(self, total):
|
||||||
"""send notification for adding videos to download queue"""
|
"""send notification for adding videos to download queue"""
|
||||||
if not self.task:
|
if not self.task:
|
||||||
return
|
return
|
||||||
@@ -280,75 +494,31 @@ class PendingList(PendingIndex):
|
|||||||
self.task.send_progress(
|
self.task.send_progress(
|
||||||
message_lines=[
|
message_lines=[
|
||||||
"Adding new videos to download queue.",
|
"Adding new videos to download queue.",
|
||||||
f"Extracting items {idx + 1}/{total}",
|
f"Bulk adding {total} videos",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _notify_done(self, total):
|
||||||
|
"""send done notification"""
|
||||||
|
if not self.task:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=[
|
||||||
|
"Adding new videos to the queue completed.",
|
||||||
|
f"Added {total} videos.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _notify_fail(self, status_code):
|
||||||
|
"""failed to add"""
|
||||||
|
if not self.task:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.task.send_progress(
|
||||||
|
message_lines=[
|
||||||
|
"Adding extracted videos failed.",
|
||||||
|
f"Status code: {status_code}",
|
||||||
],
|
],
|
||||||
progress=(idx + 1) / total,
|
level="error",
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_youtube_details(self, youtube_id, vid_type=VideoTypeEnum.VIDEOS):
|
|
||||||
"""get details from youtubedl for single pending video"""
|
|
||||||
vid = YtWrap(self.yt_obs, self.config).extract(youtube_id)
|
|
||||||
if not vid:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if vid.get("id") != youtube_id:
|
|
||||||
# skip premium videos with different id
|
|
||||||
print(f"{youtube_id}: skipping premium video, id not matching")
|
|
||||||
return False
|
|
||||||
# stop if video is streaming live now
|
|
||||||
if vid["live_status"] in ["is_upcoming", "is_live"]:
|
|
||||||
print(f"{youtube_id}: skip is_upcoming or is_live")
|
|
||||||
return False
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if not vid.get("channel"):
|
|
||||||
print(f"{youtube_id}: skip video not part of channel")
|
|
||||||
return False
|
|
||||||
|
|
||||||
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 > 3 * 60:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return is_shorts(vid["id"])
|
|
||||||
|
|
||||||
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
|
|
||||||
"""parse response"""
|
|
||||||
vid_id = vid.get("id")
|
|
||||||
published = datetime.strptime(vid["upload_date"], "%Y%m%d").strftime(
|
|
||||||
"%Y-%m-%d"
|
|
||||||
)
|
|
||||||
|
|
||||||
# build dict
|
|
||||||
youtube_details = {
|
|
||||||
"youtube_id": vid_id,
|
|
||||||
"channel_name": vid["channel"],
|
|
||||||
"vid_thumb_url": vid["thumbnail"],
|
|
||||||
"title": vid["title"],
|
|
||||||
"channel_id": vid["channel_id"],
|
|
||||||
"duration": get_duration_str(vid["duration"]),
|
|
||||||
"published": published,
|
|
||||||
"timestamp": int(datetime.now().timestamp()),
|
|
||||||
# Pulling enum value out so it is serializable
|
|
||||||
"vid_type": vid_type.value,
|
|
||||||
}
|
|
||||||
if self.all_channels:
|
|
||||||
youtube_details.update(
|
|
||||||
{"channel_indexed": vid["channel_id"] in self.all_channels}
|
|
||||||
)
|
|
||||||
return youtube_details
|
|
||||||
|
|||||||
115
backend/download/src/queue_interact.py
Normal file
115
backend/download/src/queue_interact.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""interact with queue items"""
|
||||||
|
|
||||||
|
from common.src.es_connect import ElasticWrap
|
||||||
|
|
||||||
|
|
||||||
|
class PendingInteract:
|
||||||
|
"""interact with items in download queue"""
|
||||||
|
|
||||||
|
def __init__(self, youtube_id=False, status=False):
|
||||||
|
self.youtube_id = youtube_id
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def delete_item(self):
|
||||||
|
"""delete single item from pending"""
|
||||||
|
path = f"ta_download/_doc/{self.youtube_id}"
|
||||||
|
_, _ = ElasticWrap(path).delete(refresh=True)
|
||||||
|
|
||||||
|
def delete_bulk(self, channel_id: str | None, vid_type: str | None):
|
||||||
|
"""delete all matching item by status"""
|
||||||
|
must_list = [{"term": {"status": {"value": self.status}}}]
|
||||||
|
if channel_id:
|
||||||
|
must_list.append({"term": {"channel_id": {"value": channel_id}}})
|
||||||
|
|
||||||
|
if vid_type:
|
||||||
|
must_list.append({"term": {"vid_type": {"value": vid_type}}})
|
||||||
|
|
||||||
|
data = {"query": {"bool": {"must": must_list}}}
|
||||||
|
|
||||||
|
path = "ta_download/_delete_by_query?refresh=true"
|
||||||
|
_, _ = ElasticWrap(path).post(data=data)
|
||||||
|
|
||||||
|
def update_bulk(
|
||||||
|
self,
|
||||||
|
channel_id: str | None,
|
||||||
|
vid_type: str | None,
|
||||||
|
new_status: str,
|
||||||
|
error: bool | None = None,
|
||||||
|
):
|
||||||
|
"""update status in bulk"""
|
||||||
|
must_list = [{"term": {"status": {"value": self.status}}}]
|
||||||
|
must_not_list = []
|
||||||
|
|
||||||
|
if channel_id:
|
||||||
|
must_list.append({"term": {"channel_id": {"value": channel_id}}})
|
||||||
|
|
||||||
|
if vid_type:
|
||||||
|
must_list.append({"term": {"vid_type": {"value": vid_type}}})
|
||||||
|
|
||||||
|
if error is not None:
|
||||||
|
exists = {"exists": {"field": "message"}}
|
||||||
|
if error:
|
||||||
|
must_list.append(exists) # type: ignore
|
||||||
|
else:
|
||||||
|
must_not_list.append(exists)
|
||||||
|
|
||||||
|
if new_status == "priority":
|
||||||
|
source = """
|
||||||
|
ctx._source.status = 'pending';
|
||||||
|
ctx._source.auto_start = true;
|
||||||
|
ctx._source.message = null;
|
||||||
|
"""
|
||||||
|
elif new_status == "clear_error":
|
||||||
|
source = "ctx._source.message = null"
|
||||||
|
else:
|
||||||
|
source = f"ctx._source.status = '{new_status}'"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"query": {"bool": {"must": must_list, "must_not": must_not_list}},
|
||||||
|
"script": {"source": source, "lang": "painless"},
|
||||||
|
}
|
||||||
|
|
||||||
|
path = "ta_download/_update_by_query?refresh=true"
|
||||||
|
_, _ = ElasticWrap(path).post(data)
|
||||||
|
|
||||||
|
def update_status(self):
|
||||||
|
"""update status of pending item"""
|
||||||
|
if self.status == "priority":
|
||||||
|
data = {
|
||||||
|
"doc": {
|
||||||
|
"status": "pending",
|
||||||
|
"auto_start": True,
|
||||||
|
"message": None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
data = {"doc": {"status": self.status}}
|
||||||
|
|
||||||
|
path = f"ta_download/_update/{self.youtube_id}/?refresh=true"
|
||||||
|
_, _ = ElasticWrap(path).post(data=data)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -6,324 +6,114 @@ Functionality:
|
|||||||
|
|
||||||
from appsettings.src.config import AppConfig
|
from appsettings.src.config import AppConfig
|
||||||
from channel.src.index import YoutubeChannel
|
from channel.src.index import YoutubeChannel
|
||||||
from common.src.es_connect import IndexPaginate
|
from channel.src.remote_query import VideoQueryBuilder
|
||||||
from common.src.helper import is_missing, rand_sleep
|
from common.src.helper import get_channels, get_playlists
|
||||||
from common.src.urlparser import Parser
|
from common.src.urlparser import ParsedURLType, Parser
|
||||||
from download.src.thumbnails import ThumbManager
|
from download.src.queue import PendingList
|
||||||
from download.src.yt_dlp_base import YtWrap
|
|
||||||
from playlist.src.index import YoutubePlaylist
|
from playlist.src.index import YoutubePlaylist
|
||||||
from video.src.constants import VideoTypeEnum
|
from video.src.constants import VideoTypeEnum
|
||||||
from video.src.index import YoutubeVideo
|
from video.src.index import YoutubeVideo
|
||||||
|
|
||||||
|
|
||||||
class ChannelSubscription:
|
class ChannelSubscription:
|
||||||
"""manage the list of channels subscribed"""
|
"""scan subscribed channels to find missing videos to add to pending"""
|
||||||
|
|
||||||
def __init__(self, task=False):
|
def __init__(self, task=None):
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
self.task = task
|
self.task = task
|
||||||
|
|
||||||
@staticmethod
|
def find_missing(self) -> int:
|
||||||
def get_channels(subscribed_only=True):
|
"""find missing videos from channel subscriptions"""
|
||||||
"""get a list of all channels subscribed to"""
|
if self.task:
|
||||||
data = {
|
self.task.send_progress(["Looking up channels."])
|
||||||
"sort": [{"channel_name.keyword": {"order": "asc"}}],
|
|
||||||
}
|
|
||||||
if subscribed_only:
|
|
||||||
data["query"] = {"term": {"channel_subscribed": {"value": True}}}
|
|
||||||
else:
|
|
||||||
data["query"] = {"match_all": {}}
|
|
||||||
|
|
||||||
all_channels = IndexPaginate("ta_channel", data).get_results()
|
all_channels = get_channels(
|
||||||
|
subscribed_only=True,
|
||||||
return all_channels
|
source=["channel_id", "channel_overwrites", "channel_tabs"],
|
||||||
|
)
|
||||||
def get_last_youtube_videos(
|
|
||||||
self,
|
|
||||||
channel_id,
|
|
||||||
limit=True,
|
|
||||||
query_filter=None,
|
|
||||||
channel_overwrites=None,
|
|
||||||
):
|
|
||||||
"""get a list of last videos from channel"""
|
|
||||||
query_handler = VideoQueryBuilder(self.config, channel_overwrites)
|
|
||||||
queries = query_handler.build_queries(query_filter)
|
|
||||||
last_videos = []
|
|
||||||
|
|
||||||
for vid_type_enum, limit_amount in queries:
|
|
||||||
obs = {
|
|
||||||
"skip_download": True,
|
|
||||||
"extract_flat": True,
|
|
||||||
}
|
|
||||||
vid_type = vid_type_enum.value
|
|
||||||
|
|
||||||
if limit:
|
|
||||||
obs["playlistend"] = limit_amount
|
|
||||||
|
|
||||||
url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
|
|
||||||
channel_query = YtWrap(obs, self.config).extract(url)
|
|
||||||
if not channel_query:
|
|
||||||
continue
|
|
||||||
|
|
||||||
last_videos.extend(
|
|
||||||
[
|
|
||||||
(i["id"], i["title"], vid_type)
|
|
||||||
for i in channel_query["entries"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return last_videos
|
|
||||||
|
|
||||||
def find_missing(self):
|
|
||||||
"""add missing videos from subscribed channels to pending"""
|
|
||||||
all_channels = self.get_channels()
|
|
||||||
if not all_channels:
|
if not all_channels:
|
||||||
return False
|
return 0
|
||||||
|
|
||||||
missing_videos = []
|
all_channel_urls = self._process_channel_urls(all_channels)
|
||||||
|
|
||||||
total = len(all_channels)
|
if self.task:
|
||||||
for idx, channel in enumerate(all_channels):
|
self.task.send_progress([f"Scanning {len(all_channels)} channels"])
|
||||||
channel_id = channel["channel_id"]
|
|
||||||
print(f"{channel_id}: find missing videos.")
|
|
||||||
last_videos = self.get_last_youtube_videos(
|
|
||||||
channel_id,
|
|
||||||
channel_overwrites=channel.get("channel_overwrites"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if last_videos:
|
pending_handler = PendingList(
|
||||||
ids_to_add = is_missing([i[0] for i in last_videos])
|
youtube_ids=all_channel_urls,
|
||||||
for video_id, _, vid_type in last_videos:
|
task=self.task,
|
||||||
if video_id in ids_to_add:
|
auto_start=self.config["subscriptions"].get("auto_start", False),
|
||||||
missing_videos.append((video_id, vid_type))
|
flat=self.config["subscriptions"].get("extract_flat", False),
|
||||||
|
)
|
||||||
|
added = pending_handler.parse_url_list()
|
||||||
|
|
||||||
if not self.task:
|
return added
|
||||||
|
|
||||||
|
def _process_channel_urls(self, all_channels: list[dict]):
|
||||||
|
"""process channels, build queries"""
|
||||||
|
|
||||||
|
all_channel_urls: list[ParsedURLType] = []
|
||||||
|
|
||||||
|
for channel in all_channels:
|
||||||
|
channel_tabs = channel["channel_tabs"]
|
||||||
|
if not channel_tabs:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.task.is_stopped():
|
enums = [getattr(VideoTypeEnum, i.upper()) for i in channel_tabs]
|
||||||
self.task.send_progress(["Received Stop signal."])
|
queries = VideoQueryBuilder(
|
||||||
break
|
config=self.config,
|
||||||
|
channel_overwrites=channel.get("channel_overwrites", {}),
|
||||||
|
).build_queries(video_type=enums)
|
||||||
|
|
||||||
self.task.send_progress(
|
for query in queries:
|
||||||
message_lines=[f"Scanning Channel {idx + 1}/{total}"],
|
all_channel_urls.append(
|
||||||
progress=(idx + 1) / total,
|
ParsedURLType(
|
||||||
)
|
type="channel",
|
||||||
rand_sleep(self.config)
|
url=channel["channel_id"],
|
||||||
|
vid_type=query[0],
|
||||||
|
limit=query[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return missing_videos
|
return all_channel_urls
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def change_subscribe(channel_id, channel_subscribed):
|
|
||||||
"""subscribe or unsubscribe from channel and update"""
|
|
||||||
channel = YoutubeChannel(channel_id)
|
|
||||||
channel.build_json()
|
|
||||||
channel.json_data["channel_subscribed"] = channel_subscribed
|
|
||||||
channel.upload_to_es()
|
|
||||||
channel.sync_to_videos()
|
|
||||||
|
|
||||||
return channel.json_data
|
|
||||||
|
|
||||||
|
|
||||||
class VideoQueryBuilder:
|
|
||||||
"""Build queries for yt-dlp."""
|
|
||||||
|
|
||||||
def __init__(self, config: dict, channel_overwrites: dict | None = None):
|
|
||||||
self.config = config
|
|
||||||
self.channel_overwrites = channel_overwrites or {}
|
|
||||||
|
|
||||||
def build_queries(
|
|
||||||
self, video_type: VideoTypeEnum | None, limit: bool = True
|
|
||||||
) -> list[tuple[VideoTypeEnum, int | None]]:
|
|
||||||
"""Build queries for all or specific video type."""
|
|
||||||
query_methods = {
|
|
||||||
VideoTypeEnum.VIDEOS: self.videos_query,
|
|
||||||
VideoTypeEnum.STREAMS: self.streams_query,
|
|
||||||
VideoTypeEnum.SHORTS: self.shorts_query,
|
|
||||||
}
|
|
||||||
|
|
||||||
if video_type:
|
|
||||||
# build query for specific type
|
|
||||||
query_method = query_methods.get(video_type)
|
|
||||||
if query_method:
|
|
||||||
query = query_method(limit)
|
|
||||||
if query[1] != 0:
|
|
||||||
return [query]
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Build and return queries for all video types
|
|
||||||
queries = []
|
|
||||||
for build_query in query_methods.values():
|
|
||||||
query = build_query(limit)
|
|
||||||
if query[1] != 0:
|
|
||||||
queries.append(query)
|
|
||||||
|
|
||||||
return queries
|
|
||||||
|
|
||||||
def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
|
||||||
"""Build query for videos."""
|
|
||||||
return self._build_generic_query(
|
|
||||||
video_type=VideoTypeEnum.VIDEOS,
|
|
||||||
overwrite_key="subscriptions_channel_size",
|
|
||||||
config_key="channel_size",
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
|
||||||
"""Build query for streams."""
|
|
||||||
return self._build_generic_query(
|
|
||||||
video_type=VideoTypeEnum.STREAMS,
|
|
||||||
overwrite_key="subscriptions_live_channel_size",
|
|
||||||
config_key="live_channel_size",
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
|
|
||||||
"""Build query for shorts."""
|
|
||||||
return self._build_generic_query(
|
|
||||||
video_type=VideoTypeEnum.SHORTS,
|
|
||||||
overwrite_key="subscriptions_shorts_channel_size",
|
|
||||||
config_key="shorts_channel_size",
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_generic_query(
|
|
||||||
self,
|
|
||||||
video_type: VideoTypeEnum,
|
|
||||||
overwrite_key: str,
|
|
||||||
config_key: str,
|
|
||||||
limit: bool,
|
|
||||||
) -> tuple[VideoTypeEnum, int | None]:
|
|
||||||
"""Generic query for video page scraping."""
|
|
||||||
if not limit:
|
|
||||||
return (video_type, None)
|
|
||||||
|
|
||||||
if (
|
|
||||||
overwrite_key in self.channel_overwrites
|
|
||||||
and self.channel_overwrites[overwrite_key] is not None
|
|
||||||
):
|
|
||||||
overwrite = self.channel_overwrites[overwrite_key]
|
|
||||||
return (video_type, overwrite)
|
|
||||||
|
|
||||||
if overwrite := self.config["subscriptions"].get(config_key):
|
|
||||||
return (video_type, overwrite)
|
|
||||||
|
|
||||||
return (video_type, 0)
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistSubscription:
|
class PlaylistSubscription:
|
||||||
"""manage the playlist download functionality"""
|
"""scan subscribed playlists for videos to add to pending"""
|
||||||
|
|
||||||
def __init__(self, task=False):
|
def __init__(self, task=None):
|
||||||
self.config = AppConfig().config
|
self.config = AppConfig().config
|
||||||
self.task = task
|
self.task = task
|
||||||
|
|
||||||
@staticmethod
|
def find_missing(self) -> int:
|
||||||
def get_playlists(subscribed_only=True):
|
"""find missing"""
|
||||||
"""get a list of all active playlists"""
|
all_playlists = get_playlists(
|
||||||
data = {
|
subscribed_only=True, source=["playlist_id"]
|
||||||
"sort": [{"playlist_channel.keyword": {"order": "desc"}}],
|
)
|
||||||
}
|
|
||||||
data["query"] = {
|
|
||||||
"bool": {"must": [{"term": {"playlist_active": {"value": True}}}]}
|
|
||||||
}
|
|
||||||
if subscribed_only:
|
|
||||||
data["query"]["bool"]["must"].append(
|
|
||||||
{"term": {"playlist_subscribed": {"value": True}}}
|
|
||||||
)
|
|
||||||
|
|
||||||
all_playlists = IndexPaginate("ta_playlist", data).get_results()
|
|
||||||
|
|
||||||
return all_playlists
|
|
||||||
|
|
||||||
def process_url_str(self, new_playlists, subscribed=True):
|
|
||||||
"""process playlist subscribe form url_str"""
|
|
||||||
for idx, playlist in enumerate(new_playlists):
|
|
||||||
playlist_id = playlist["url"]
|
|
||||||
if not playlist["type"] == "playlist":
|
|
||||||
print(f"{playlist_id} not a playlist, skipping...")
|
|
||||||
continue
|
|
||||||
|
|
||||||
playlist_h = YoutubePlaylist(playlist_id)
|
|
||||||
playlist_h.build_json()
|
|
||||||
if not playlist_h.json_data:
|
|
||||||
message = f"{playlist_h.youtube_id}: failed to extract data"
|
|
||||||
print(message)
|
|
||||||
raise ValueError(message)
|
|
||||||
|
|
||||||
playlist_h.json_data["playlist_subscribed"] = subscribed
|
|
||||||
playlist_h.upload_to_es()
|
|
||||||
playlist_h.add_vids_to_playlist()
|
|
||||||
self.channel_validate(playlist_h.json_data["playlist_channel_id"])
|
|
||||||
|
|
||||||
url = playlist_h.json_data["playlist_thumbnail"]
|
|
||||||
thumb = ThumbManager(playlist_id, item_type="playlist")
|
|
||||||
thumb.download_playlist_thumb(url)
|
|
||||||
|
|
||||||
if self.task:
|
|
||||||
self.task.send_progress(
|
|
||||||
message_lines=[
|
|
||||||
f"Processing {idx + 1} of {len(new_playlists)}"
|
|
||||||
],
|
|
||||||
progress=(idx + 1) / len(new_playlists),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def channel_validate(channel_id):
|
|
||||||
"""make sure channel of playlist is there"""
|
|
||||||
channel = YoutubeChannel(channel_id)
|
|
||||||
channel.build_json(upload=True)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def change_subscribe(playlist_id, subscribe_status):
|
|
||||||
"""change the subscribe status of a playlist"""
|
|
||||||
playlist = YoutubePlaylist(playlist_id)
|
|
||||||
playlist.build_json()
|
|
||||||
playlist.json_data["playlist_subscribed"] = subscribe_status
|
|
||||||
playlist.upload_to_es()
|
|
||||||
return playlist.json_data
|
|
||||||
|
|
||||||
def find_missing(self):
|
|
||||||
"""find videos in subscribed playlists not downloaded yet"""
|
|
||||||
all_playlists = [i["playlist_id"] for i in self.get_playlists()]
|
|
||||||
if not all_playlists:
|
if not all_playlists:
|
||||||
return False
|
return 0
|
||||||
|
|
||||||
missing_videos = []
|
size_limit = self.config["subscriptions"]["playlist_size"]
|
||||||
total = len(all_playlists)
|
all_playlist_urls: list[ParsedURLType] = []
|
||||||
for idx, playlist_id in enumerate(all_playlists):
|
for playlist in all_playlists:
|
||||||
playlist = YoutubePlaylist(playlist_id)
|
all_playlist_urls.append(
|
||||||
is_active = playlist.update_playlist()
|
ParsedURLType(
|
||||||
if not is_active:
|
type="playlist",
|
||||||
playlist.deactivate()
|
url=playlist["playlist_id"],
|
||||||
continue
|
vid_type=VideoTypeEnum.UNKNOWN,
|
||||||
|
limit=size_limit,
|
||||||
playlist_entries = playlist.json_data["playlist_entries"]
|
)
|
||||||
size_limit = self.config["subscriptions"]["channel_size"]
|
|
||||||
if size_limit:
|
|
||||||
del playlist_entries[size_limit:]
|
|
||||||
|
|
||||||
to_check = [
|
|
||||||
i["youtube_id"]
|
|
||||||
for i in playlist_entries
|
|
||||||
if i["downloaded"] is False
|
|
||||||
]
|
|
||||||
needs_downloading = is_missing(to_check)
|
|
||||||
missing_videos.extend(needs_downloading)
|
|
||||||
|
|
||||||
if not self.task:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if self.task.is_stopped():
|
|
||||||
self.task.send_progress(["Received Stop signal."])
|
|
||||||
break
|
|
||||||
|
|
||||||
self.task.send_progress(
|
|
||||||
message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
|
|
||||||
progress=(idx + 1) / total,
|
|
||||||
)
|
)
|
||||||
rand_sleep(self.config)
|
|
||||||
|
|
||||||
return missing_videos
|
pending_handler = PendingList(
|
||||||
|
youtube_ids=all_playlist_urls,
|
||||||
|
task=self.task,
|
||||||
|
auto_start=self.config["subscriptions"].get("auto_start", False),
|
||||||
|
flat=self.config["subscriptions"].get("extract_flat", False),
|
||||||
|
)
|
||||||
|
added = pending_handler.parse_url_list()
|
||||||
|
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionScanner:
|
class SubscriptionScanner:
|
||||||
@@ -339,40 +129,12 @@ class SubscriptionScanner:
|
|||||||
if self.task:
|
if self.task:
|
||||||
self.task.send_progress(["Rescanning channels and playlists."])
|
self.task.send_progress(["Rescanning channels and playlists."])
|
||||||
|
|
||||||
self.missing_videos = []
|
added = 0
|
||||||
self.scan_channels()
|
added += ChannelSubscription(task=self.task).find_missing()
|
||||||
if self.task and not self.task.is_stopped():
|
if self.task and not self.task.is_stopped():
|
||||||
self.scan_playlists()
|
added += PlaylistSubscription(task=self.task).find_missing()
|
||||||
|
|
||||||
return self.missing_videos
|
return added
|
||||||
|
|
||||||
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:
|
class SubscriptionHandler:
|
||||||
@@ -404,7 +166,8 @@ class SubscriptionHandler:
|
|||||||
f"expected {expected_type} url but got {item.get('type')}"
|
f"expected {expected_type} url but got {item.get('type')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
PlaylistSubscription().process_url_str([item])
|
playlist = YoutubePlaylist(item["url"])
|
||||||
|
playlist.change_subscribe(new_subscribe_state=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if item["type"] == "video":
|
if item["type"] == "video":
|
||||||
@@ -427,9 +190,7 @@ class SubscriptionHandler:
|
|||||||
|
|
||||||
def _subscribe(self, channel_id):
|
def _subscribe(self, channel_id):
|
||||||
"""subscribe to channel"""
|
"""subscribe to channel"""
|
||||||
_ = ChannelSubscription().change_subscribe(
|
YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=True)
|
||||||
channel_id, channel_subscribed=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def _notify(self, idx, item, total):
|
def _notify(self, idx, item, total):
|
||||||
"""send notification message to redis"""
|
"""send notification message to redis"""
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ class ValidatorCallback:
|
|||||||
def run(self):
|
def run(self):
|
||||||
"""run the task for page"""
|
"""run the task for page"""
|
||||||
print(f"{self.index_name}: validate artwork")
|
print(f"{self.index_name}: validate artwork")
|
||||||
if self.index_name == "ta_video":
|
if self.index_name in ["ta_video", "ta_download"]:
|
||||||
self._validate_videos()
|
self._validate_videos()
|
||||||
elif self.index_name == "ta_channel":
|
elif self.index_name == "ta_channel":
|
||||||
self._validate_channels()
|
self._validate_channels()
|
||||||
@@ -325,6 +325,13 @@ class ThumbValidator:
|
|||||||
},
|
},
|
||||||
"name": "ta_playlist",
|
"name": "ta_playlist",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"query": {"term": {"status": {"value": "pending"}}},
|
||||||
|
"_source": ["youtube_id", "vid_thumb_url"],
|
||||||
|
},
|
||||||
|
"name": "ta_download",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, task=False):
|
def __init__(self, task=False):
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class YtWrap:
|
|||||||
OBS_BASE = {
|
OBS_BASE = {
|
||||||
"default_search": "ytsearch",
|
"default_search": "ytsearch",
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"check_formats": "selected",
|
|
||||||
"socket_timeout": 10,
|
"socket_timeout": 10,
|
||||||
"extractor_retries": 3,
|
"extractor_retries": 3,
|
||||||
"retries": 10,
|
"retries": 10,
|
||||||
@@ -58,7 +57,7 @@ class YtWrap:
|
|||||||
"extractor_args": {
|
"extractor_args": {
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"po_token": [potoken],
|
"po_token": [potoken],
|
||||||
"player-client": ["web", "default"],
|
"player-client": ["mweb", "default"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +65,7 @@ class YtWrap:
|
|||||||
|
|
||||||
def download(self, url):
|
def download(self, url):
|
||||||
"""make download request"""
|
"""make download request"""
|
||||||
|
self.obs.update({"check_formats": "selected"})
|
||||||
with yt_dlp.YoutubeDL(self.obs) as ydl:
|
with yt_dlp.YoutubeDL(self.obs) as ydl:
|
||||||
try:
|
try:
|
||||||
ydl.download([url])
|
ydl.download([url])
|
||||||
@@ -80,30 +80,33 @@ class YtWrap:
|
|||||||
|
|
||||||
return True, True
|
return True, True
|
||||||
|
|
||||||
def extract(self, url):
|
def extract(self, url) -> tuple[dict | None, str | None]:
|
||||||
"""make extract request"""
|
"""
|
||||||
|
make extract request
|
||||||
|
returns response, error
|
||||||
|
"""
|
||||||
with yt_dlp.YoutubeDL(self.obs) as ydl:
|
with yt_dlp.YoutubeDL(self.obs) as ydl:
|
||||||
try:
|
try:
|
||||||
response = ydl.extract_info(url)
|
response = ydl.extract_info(url)
|
||||||
except cookiejar.LoadError as err:
|
except cookiejar.LoadError as err:
|
||||||
print(f"cookie file is invalid: {err}")
|
print(f"cookie file is invalid: {err}")
|
||||||
return False
|
return None, str(err)
|
||||||
except yt_dlp.utils.ExtractorError as err:
|
except yt_dlp.utils.ExtractorError as err:
|
||||||
print(f"{url}: failed to extract: {err}, continue...")
|
print(f"{url}: failed to extract: {err}, continue...")
|
||||||
return False
|
return None, str(err)
|
||||||
except yt_dlp.utils.DownloadError as err:
|
except yt_dlp.utils.DownloadError as err:
|
||||||
if "This channel does not have a" in str(err):
|
if "This channel does not have a" in str(err):
|
||||||
return False
|
return None, None
|
||||||
|
|
||||||
print(f"{url}: failed to get info from youtube: {err}")
|
print(f"{url}: failed to get info from youtube: {err}")
|
||||||
if "Temporary failure in name resolution" in str(err):
|
if "Temporary failure in name resolution" in str(err):
|
||||||
raise ConnectionError("lost the internet, abort!") from err
|
raise ConnectionError("lost the internet, abort!") from err
|
||||||
|
|
||||||
return False
|
return None, str(err)
|
||||||
|
|
||||||
self._validate_cookie()
|
self._validate_cookie()
|
||||||
|
|
||||||
return response
|
return response, None
|
||||||
|
|
||||||
def _validate_cookie(self):
|
def _validate_cookie(self):
|
||||||
"""check cookie and write it back for next use"""
|
"""check cookie and write it back for next use"""
|
||||||
@@ -146,7 +149,7 @@ class CookieHandler:
|
|||||||
AppConfig().update_config({"downloads": {"cookie_import": False}})
|
AppConfig().update_config({"downloads": {"cookie_import": False}})
|
||||||
print("[cookie]: revoked")
|
print("[cookie]: revoked")
|
||||||
|
|
||||||
def validate(self):
|
def validate(self) -> bool:
|
||||||
"""validate cookie using the liked videos playlist"""
|
"""validate cookie using the liked videos playlist"""
|
||||||
validation = RedisArchivist().get_message_dict("cookie:valid")
|
validation = RedisArchivist().get_message_dict("cookie:valid")
|
||||||
if validation:
|
if validation:
|
||||||
@@ -159,8 +162,8 @@ class CookieHandler:
|
|||||||
"extract_flat": True,
|
"extract_flat": True,
|
||||||
}
|
}
|
||||||
validator = YtWrap(obs_request, self.config)
|
validator = YtWrap(obs_request, self.config)
|
||||||
response = bool(validator.extract("LL"))
|
response, error = validator.extract("LL")
|
||||||
self.store_validation(response)
|
self.store_validation(bool(response))
|
||||||
|
|
||||||
# update in redis to avoid expiring
|
# update in redis to avoid expiring
|
||||||
modified = validator.obs["cookiefile"].getvalue().strip("\x00")
|
modified = validator.obs["cookiefile"].getvalue().strip("\x00")
|
||||||
@@ -173,15 +176,15 @@ class CookieHandler:
|
|||||||
"status": "message:download",
|
"status": "message:download",
|
||||||
"level": "error",
|
"level": "error",
|
||||||
"title": "Cookie validation failed, exiting...",
|
"title": "Cookie validation failed, exiting...",
|
||||||
"message": "",
|
"message": error,
|
||||||
}
|
}
|
||||||
RedisArchivist().set_message(
|
RedisArchivist().set_message(
|
||||||
"message:download", mess_dict, expire=4
|
"message:download", mess_dict, expire=4
|
||||||
)
|
)
|
||||||
print("[cookie]: validation failed, exiting...")
|
print("[cookie]: validation failed, exiting...")
|
||||||
|
|
||||||
print(f"[cookie]: validation success: {response}")
|
print(f"[cookie]: validation success: {bool(response)}")
|
||||||
return response
|
return bool(response)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def store_validation(response):
|
def store_validation(response):
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ from common.src.env_settings import EnvironmentSettings
|
|||||||
from common.src.es_connect import ElasticWrap, IndexPaginate
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from common.src.helper import (
|
from common.src.helper import (
|
||||||
get_channel_overwrites,
|
get_channel_overwrites,
|
||||||
|
get_playlists,
|
||||||
ignore_filelist,
|
ignore_filelist,
|
||||||
rand_sleep,
|
rand_sleep,
|
||||||
)
|
)
|
||||||
from common.src.ta_redis import RedisQueue
|
from common.src.ta_redis import RedisQueue
|
||||||
|
from common.src.urlparser import ParsedURLType
|
||||||
from download.src.queue import PendingList
|
from download.src.queue import PendingList
|
||||||
from download.src.subscriptions import PlaylistSubscription
|
|
||||||
from download.src.yt_dlp_base import YtWrap
|
from download.src.yt_dlp_base import YtWrap
|
||||||
from playlist.src.index import YoutubePlaylist
|
from playlist.src.index import YoutubePlaylist
|
||||||
from video.src.comments import CommentList
|
from video.src.comments import CommentList
|
||||||
@@ -347,7 +348,7 @@ class DownloadPostProcess(DownloaderBase):
|
|||||||
self._auto_delete_watched(data)
|
self._auto_delete_watched(data)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _auto_delete_watched(data):
|
def _auto_delete_watched(data) -> None:
|
||||||
"""delete watched videos after x days"""
|
"""delete watched videos after x days"""
|
||||||
to_delete = IndexPaginate("ta_video", data).get_results()
|
to_delete = IndexPaginate("ta_video", data).get_results()
|
||||||
if not to_delete:
|
if not to_delete:
|
||||||
@@ -359,10 +360,20 @@ class DownloadPostProcess(DownloaderBase):
|
|||||||
YoutubeVideo(youtube_id).delete_media_file()
|
YoutubeVideo(youtube_id).delete_media_file()
|
||||||
|
|
||||||
print("add deleted to ignore list")
|
print("add deleted to ignore list")
|
||||||
vids = [{"type": "video", "url": i["youtube_id"]} for i in to_delete]
|
|
||||||
pending = PendingList(youtube_ids=vids)
|
parsed_ids: list[ParsedURLType] = []
|
||||||
pending.parse_url_list()
|
|
||||||
_ = pending.add_to_pending(status="ignore")
|
for video_item in to_delete:
|
||||||
|
vid_type = getattr(VideoTypeEnum, video_item["vid_type"].upper())
|
||||||
|
parsed_ids.append(
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"url": video_item["youtube_id"],
|
||||||
|
"vid_type": vid_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PendingList(youtube_ids=parsed_ids).parse_url_list(status="ignore")
|
||||||
|
|
||||||
def refresh_playlist(self) -> None:
|
def refresh_playlist(self) -> None:
|
||||||
"""match videos with playlists"""
|
"""match videos with playlists"""
|
||||||
@@ -403,8 +414,8 @@ class DownloadPostProcess(DownloaderBase):
|
|||||||
|
|
||||||
def _add_playlist_sub(self):
|
def _add_playlist_sub(self):
|
||||||
"""add subscribed playlists to refresh"""
|
"""add subscribed playlists to refresh"""
|
||||||
subs = PlaylistSubscription().get_playlists()
|
playlists = get_playlists(subscribed_only=True, source=["playlist_id"])
|
||||||
to_add = [i["playlist_id"] for i in subs]
|
to_add = [i["playlist_id"] for i in playlists]
|
||||||
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
|
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
|
||||||
|
|
||||||
def _add_channel_playlists(self):
|
def _add_channel_playlists(self):
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from common.views_base import AdminOnly, ApiBaseView
|
|||||||
from download.serializers import (
|
from download.serializers import (
|
||||||
AddToDownloadListSerializer,
|
AddToDownloadListSerializer,
|
||||||
AddToDownloadQuerySerializer,
|
AddToDownloadQuerySerializer,
|
||||||
|
BulkUpdateDowloadDataSerializer,
|
||||||
|
BulkUpdateDowloadQuerySerializer,
|
||||||
DownloadAggsSerializer,
|
DownloadAggsSerializer,
|
||||||
DownloadItemSerializer,
|
DownloadItemSerializer,
|
||||||
DownloadListQuerySerializer,
|
DownloadListQuerySerializer,
|
||||||
@@ -15,7 +17,7 @@ from download.serializers import (
|
|||||||
DownloadListSerializer,
|
DownloadListSerializer,
|
||||||
DownloadQueueItemUpdateSerializer,
|
DownloadQueueItemUpdateSerializer,
|
||||||
)
|
)
|
||||||
from download.src.queue import PendingInteract
|
from download.src.queue_interact import PendingInteract
|
||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from task.tasks import download_pending, extrac_dl
|
from task.tasks import download_pending, extrac_dl
|
||||||
@@ -65,6 +67,22 @@ class DownloadApiListView(ApiBaseView):
|
|||||||
{"term": {"channel_id": {"value": filter_channel}}}
|
{"term": {"channel_id": {"value": filter_channel}}}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
vid_type_filter = validated_data.get("vid_type")
|
||||||
|
if vid_type_filter:
|
||||||
|
must_list.append(
|
||||||
|
{"term": {"vid_type": {"value": vid_type_filter}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
search_query = validated_data.get("q")
|
||||||
|
if search_query:
|
||||||
|
must_list.append({"match_phrase_prefix": {"title": search_query}})
|
||||||
|
|
||||||
|
if validated_data.get("error") is not None:
|
||||||
|
operator = "must" if validated_data["error"] else "must_not"
|
||||||
|
must_list.append(
|
||||||
|
{"bool": {operator: [{"exists": {"field": "message"}}]}}
|
||||||
|
)
|
||||||
|
|
||||||
self.data["query"] = {"bool": {"must": must_list}}
|
self.data["query"] = {"bool": {"must": must_list}}
|
||||||
|
|
||||||
self.get_document_list(request)
|
self.get_document_list(request)
|
||||||
@@ -99,12 +117,16 @@ class DownloadApiListView(ApiBaseView):
|
|||||||
validated_query = query_serializer.validated_data
|
validated_query = query_serializer.validated_data
|
||||||
|
|
||||||
auto_start = validated_query.get("autostart")
|
auto_start = validated_query.get("autostart")
|
||||||
print(f"auto_start: {auto_start}")
|
flat = validated_query.get("flat", False)
|
||||||
|
force = validated_query.get("force", False)
|
||||||
|
print(f"auto_start: {auto_start}, flat: {flat}, force: {force}")
|
||||||
to_add = validated_data["data"]
|
to_add = validated_data["data"]
|
||||||
|
|
||||||
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)
|
||||||
task = extrac_dl.delay(url_str, auto_start=auto_start)
|
task = extrac_dl.delay(
|
||||||
|
url_str, auto_start=auto_start, flat=flat, force=force
|
||||||
|
)
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
"message": "add to queue task started",
|
"message": "add to queue task started",
|
||||||
@@ -114,6 +136,39 @@ class DownloadApiListView(ApiBaseView):
|
|||||||
|
|
||||||
return Response(response_serializer.data)
|
return Response(response_serializer.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@extend_schema(
|
||||||
|
request=BulkUpdateDowloadDataSerializer(),
|
||||||
|
parameters=[BulkUpdateDowloadQuerySerializer()],
|
||||||
|
responses={204: OpenApiResponse(description="Status updated")},
|
||||||
|
)
|
||||||
|
def patch(request):
|
||||||
|
"""bulk update status"""
|
||||||
|
data_serializer = BulkUpdateDowloadDataSerializer(data=request.data)
|
||||||
|
data_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = data_serializer.validated_data
|
||||||
|
|
||||||
|
new_status = validated_data["status"]
|
||||||
|
|
||||||
|
query_serializer = BulkUpdateDowloadQuerySerializer(
|
||||||
|
data=request.query_params
|
||||||
|
)
|
||||||
|
query_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_query = query_serializer.validated_data
|
||||||
|
status_filter = validated_query.get("filter")
|
||||||
|
|
||||||
|
PendingInteract(status=status_filter).update_bulk(
|
||||||
|
channel_id=validated_query.get("channel"),
|
||||||
|
vid_type=validated_query.get("vid_type"),
|
||||||
|
new_status=validated_data["status"],
|
||||||
|
error=validated_query.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_status == "priority":
|
||||||
|
download_pending.delay(auto_only=True)
|
||||||
|
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
parameters=[DownloadListQueueDeleteQuerySerializer()],
|
parameters=[DownloadListQueueDeleteQuerySerializer()],
|
||||||
responses={
|
responses={
|
||||||
@@ -132,9 +187,18 @@ class DownloadApiListView(ApiBaseView):
|
|||||||
validated_query = serializer.validated_data
|
validated_query = serializer.validated_data
|
||||||
|
|
||||||
query_filter = validated_query["filter"]
|
query_filter = validated_query["filter"]
|
||||||
|
channel = validated_query.get("channel")
|
||||||
|
vid_type = validated_query.get("vid_type")
|
||||||
message = f"delete queue by status: {query_filter}"
|
message = f"delete queue by status: {query_filter}"
|
||||||
|
if channel:
|
||||||
|
message += f" - filter by channel: {channel}"
|
||||||
|
if vid_type:
|
||||||
|
message += f" - filter by vid_type: {vid_type}"
|
||||||
|
|
||||||
print(message)
|
print(message)
|
||||||
PendingInteract(status=query_filter).delete_by_status()
|
PendingInteract(status=query_filter).delete_bulk(
|
||||||
|
channel_id=channel, vid_type=vid_type
|
||||||
|
)
|
||||||
|
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class PlaylistSerializer(serializers.Serializer):
|
|||||||
playlist_last_refresh = serializers.CharField()
|
playlist_last_refresh = serializers.CharField()
|
||||||
playlist_name = serializers.CharField()
|
playlist_name = serializers.CharField()
|
||||||
playlist_subscribed = serializers.BooleanField()
|
playlist_subscribed = serializers.BooleanField()
|
||||||
|
playlist_sort_order = serializers.ChoiceField(choices=["top", "bottom"])
|
||||||
playlist_thumbnail = serializers.CharField()
|
playlist_thumbnail = serializers.CharField()
|
||||||
playlist_type = serializers.ChoiceField(choices=["regular", "custom"])
|
playlist_type = serializers.ChoiceField(choices=["regular", "custom"])
|
||||||
_index = serializers.CharField(required=False)
|
_index = serializers.CharField(required=False)
|
||||||
@@ -45,7 +46,7 @@ class PlaylistListQuerySerializer(serializers.Serializer):
|
|||||||
"""serialize playlist list query params"""
|
"""serialize playlist list query params"""
|
||||||
|
|
||||||
channel = serializers.CharField(required=False)
|
channel = serializers.CharField(required=False)
|
||||||
subscribed = serializers.BooleanField(required=False)
|
subscribed = serializers.BooleanField(required=False, allow_null=True)
|
||||||
type = serializers.ChoiceField(
|
type = serializers.ChoiceField(
|
||||||
choices=["regular", "custom"], required=False
|
choices=["regular", "custom"], required=False
|
||||||
)
|
)
|
||||||
@@ -68,7 +69,10 @@ class PlaylistBulkAddSerializer(serializers.Serializer):
|
|||||||
class PlaylistSingleUpdate(serializers.Serializer):
|
class PlaylistSingleUpdate(serializers.Serializer):
|
||||||
"""update state of single playlist"""
|
"""update state of single playlist"""
|
||||||
|
|
||||||
playlist_subscribed = serializers.BooleanField()
|
playlist_subscribed = serializers.BooleanField(required=False)
|
||||||
|
playlist_sort_order = serializers.ChoiceField(
|
||||||
|
choices=["top", "bottom"], required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlaylistListCustomPostSerializer(serializers.Serializer):
|
class PlaylistListCustomPostSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ functionality:
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from channel.src import index as channel
|
|
||||||
from common.src.env_settings import EnvironmentSettings
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from common.src.es_connect import ElasticWrap, IndexPaginate
|
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||||
from common.src.index_generic import YouTubeItem
|
from common.src.index_generic import YouTubeItem
|
||||||
@@ -36,11 +35,18 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
self.get_from_es()
|
self.get_from_es()
|
||||||
if self.json_data:
|
if self.json_data:
|
||||||
subscribed = self.json_data.get("playlist_subscribed")
|
subscribed = self.json_data.get("playlist_subscribed")
|
||||||
|
playlist_sort_order = self.json_data.get("playlist_sort_order")
|
||||||
else:
|
else:
|
||||||
subscribed = False
|
subscribed = False
|
||||||
|
playlist_sort_order = "top"
|
||||||
|
|
||||||
|
sort_order = 1 if playlist_sort_order == "top" else -1
|
||||||
|
playlist_items = f"::{sort_order}"
|
||||||
|
|
||||||
if scrape or not self.json_data:
|
if scrape or not self.json_data:
|
||||||
self.get_from_youtube()
|
self.get_from_youtube(
|
||||||
|
obs_overwrite={"playlist_items": playlist_items}
|
||||||
|
)
|
||||||
if not self.youtube_meta:
|
if not self.youtube_meta:
|
||||||
self.json_data = False
|
self.json_data = False
|
||||||
return
|
return
|
||||||
@@ -49,8 +55,13 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
self._ensure_channel()
|
self._ensure_channel()
|
||||||
ids_found = self.get_local_vids()
|
ids_found = self.get_local_vids()
|
||||||
self.get_entries(ids_found)
|
self.get_entries(ids_found)
|
||||||
self.json_data["playlist_entries"] = self.all_members
|
self.json_data.update(
|
||||||
self.json_data["playlist_subscribed"] = subscribed
|
{
|
||||||
|
"playlist_entries": self.all_members,
|
||||||
|
"playlist_subscribed": subscribed,
|
||||||
|
"playlist_sort_order": playlist_sort_order,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def process_youtube_meta(self):
|
def process_youtube_meta(self):
|
||||||
"""extract relevant fields from youtube"""
|
"""extract relevant fields from youtube"""
|
||||||
@@ -60,6 +71,9 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
print(f"{self.youtube_id}: thumbnail extraction failed")
|
print(f"{self.youtube_id}: thumbnail extraction failed")
|
||||||
playlist_thumbnail = False
|
playlist_thumbnail = False
|
||||||
|
|
||||||
|
if not self.youtube_meta.get("channel_id"):
|
||||||
|
raise ValueError("Failed to extract Channel ID for Playlist")
|
||||||
|
|
||||||
self.json_data = {
|
self.json_data = {
|
||||||
"playlist_id": self.youtube_id,
|
"playlist_id": self.youtube_id,
|
||||||
"playlist_active": True,
|
"playlist_active": True,
|
||||||
@@ -74,10 +88,24 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
|
|
||||||
def _ensure_channel(self):
|
def _ensure_channel(self):
|
||||||
"""make sure channel is indexed"""
|
"""make sure channel is indexed"""
|
||||||
|
from channel.src.index import YoutubeChannel
|
||||||
|
|
||||||
channel_id = self.json_data["playlist_channel_id"]
|
channel_id = self.json_data["playlist_channel_id"]
|
||||||
channel_handler = channel.YoutubeChannel(channel_id)
|
channel_handler = YoutubeChannel(channel_id)
|
||||||
channel_handler.build_json(upload=True)
|
channel_handler.build_json(upload=True)
|
||||||
|
|
||||||
|
def get_playlist_videos(self):
|
||||||
|
"""get all playlist videos"""
|
||||||
|
data = {
|
||||||
|
"query": {
|
||||||
|
"term": {"playlist.keyword": {"value": self.youtube_id}}
|
||||||
|
},
|
||||||
|
"_source": ["youtube_id"],
|
||||||
|
}
|
||||||
|
result = IndexPaginate("ta_video", data).get_results()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def get_local_vids(self) -> list[str]:
|
def get_local_vids(self) -> list[str]:
|
||||||
"""get local video ids from youtube entries"""
|
"""get local video ids from youtube entries"""
|
||||||
entries = self.youtube_meta["entries"]
|
entries = self.youtube_meta["entries"]
|
||||||
@@ -110,6 +138,13 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
url = self.json_data["playlist_thumbnail"]
|
url = self.json_data["playlist_thumbnail"]
|
||||||
ThumbManager(self.youtube_id, item_type="playlist").download(url)
|
ThumbManager(self.youtube_id, item_type="playlist").download(url)
|
||||||
|
|
||||||
|
def change_subscribe(self, new_subscribe_state: bool):
|
||||||
|
"""change subscribe status"""
|
||||||
|
self.build_json()
|
||||||
|
self.json_data["playlist_subscribed"] = new_subscribe_state
|
||||||
|
self.upload_to_es()
|
||||||
|
return self.json_data
|
||||||
|
|
||||||
def add_vids_to_playlist(self):
|
def add_vids_to_playlist(self):
|
||||||
"""sync the playlist id to videos"""
|
"""sync the playlist id to videos"""
|
||||||
script = (
|
script = (
|
||||||
@@ -143,14 +178,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
def remove_vids_from_playlist(self):
|
def remove_vids_from_playlist(self):
|
||||||
"""remove playlist ids from videos if needed"""
|
"""remove playlist ids from videos if needed"""
|
||||||
needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]]
|
needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]]
|
||||||
data = {
|
result = self.get_playlist_videos()
|
||||||
"query": {"match": {"playlist": self.youtube_id}},
|
|
||||||
"_source": ["youtube_id"],
|
|
||||||
}
|
|
||||||
data = {
|
|
||||||
"query": {"term": {"playlist.keyword": {"value": self.youtube_id}}}
|
|
||||||
}
|
|
||||||
result = IndexPaginate("ta_video", data).get_results()
|
|
||||||
to_remove = [
|
to_remove = [
|
||||||
i["youtube_id"] for i in result if i["youtube_id"] not in needed
|
i["youtube_id"] for i in result if i["youtube_id"] not in needed
|
||||||
]
|
]
|
||||||
@@ -189,6 +217,15 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
self.get_playlist_art()
|
self.get_playlist_art()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def change_sort_order(self, new_sort_order):
|
||||||
|
"""update sort order of playlist"""
|
||||||
|
playlist = YoutubePlaylist(self.youtube_id)
|
||||||
|
playlist.build_json()
|
||||||
|
playlist.json_data["playlist_sort_order"] = new_sort_order
|
||||||
|
playlist.upload_to_es()
|
||||||
|
|
||||||
|
return playlist.json_data
|
||||||
|
|
||||||
def build_nav(self, youtube_id):
|
def build_nav(self, youtube_id):
|
||||||
"""find next and previous in playlist of a given youtube_id"""
|
"""find next and previous in playlist of a given youtube_id"""
|
||||||
cache_root = EnvironmentSettings().get_cache_root()
|
cache_root = EnvironmentSettings().get_cache_root()
|
||||||
@@ -287,6 +324,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
self.delete_metadata()
|
self.delete_metadata()
|
||||||
|
|
||||||
def create(self, name):
|
def create(self, name):
|
||||||
|
"""create custom playlist"""
|
||||||
self.json_data = {
|
self.json_data = {
|
||||||
"playlist_id": self.youtube_id,
|
"playlist_id": self.youtube_id,
|
||||||
"playlist_active": False,
|
"playlist_active": False,
|
||||||
@@ -299,6 +337,7 @@ class YoutubePlaylist(YouTubeItem):
|
|||||||
"playlist_description": False,
|
"playlist_description": False,
|
||||||
"playlist_thumbnail": False,
|
"playlist_thumbnail": False,
|
||||||
"playlist_subscribed": False,
|
"playlist_subscribed": False,
|
||||||
|
"playlist_sort_order": "top",
|
||||||
}
|
}
|
||||||
self.upload_to_es()
|
self.upload_to_es()
|
||||||
self.get_playlist_art()
|
self.get_playlist_art()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class QueryBuilder:
|
|||||||
must_list.append({"match": {"playlist_channel_id": channel}})
|
must_list.append({"match": {"playlist_channel_id": channel}})
|
||||||
|
|
||||||
subscribed = self.request_params.get("subscribed")
|
subscribed = self.request_params.get("subscribed")
|
||||||
if subscribed:
|
if subscribed is not None:
|
||||||
must_list.append({"match": {"playlist_subscribed": subscribed}})
|
must_list.append({"match": {"playlist_subscribed": subscribed}})
|
||||||
|
|
||||||
playlist_type = self.request_params.get("type")
|
playlist_type = self.request_params.get("type")
|
||||||
@@ -45,7 +45,7 @@ class QueryBuilder:
|
|||||||
|
|
||||||
type_parsed = getattr(PlaylistTypesEnum, playlist_type.upper()).value
|
type_parsed = getattr(PlaylistTypesEnum, playlist_type.upper()).value
|
||||||
|
|
||||||
return {"match": {"playlist_type.keyword": type_parsed}}
|
return {"match": {"playlist_type": type_parsed}}
|
||||||
|
|
||||||
def parse_sort(self) -> dict:
|
def parse_sort(self) -> dict:
|
||||||
"""return sort"""
|
"""return sort"""
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ def test_parse_type():
|
|||||||
qb.parse_type("invalid")
|
qb.parse_type("invalid")
|
||||||
|
|
||||||
result = qb.parse_type("custom")
|
result = qb.parse_type("custom")
|
||||||
assert result == {"match": {"playlist_type.keyword": "custom"}}
|
assert result == {"match": {"playlist_type": "custom"}}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from common.serializers import (
|
|||||||
ErrorResponseSerializer,
|
ErrorResponseSerializer,
|
||||||
)
|
)
|
||||||
from common.views_base import AdminWriteOnly, ApiBaseView
|
from common.views_base import AdminWriteOnly, ApiBaseView
|
||||||
from download.src.subscriptions import PlaylistSubscription
|
|
||||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||||
from playlist.serializers import (
|
from playlist.serializers import (
|
||||||
PlaylistBulkAddSerializer,
|
PlaylistBulkAddSerializer,
|
||||||
@@ -240,9 +239,31 @@ class PlaylistApiView(ApiBaseView):
|
|||||||
error = ErrorResponseSerializer({"error": "playlist not found"})
|
error = ErrorResponseSerializer({"error": "playlist not found"})
|
||||||
return Response(error.data, status=404)
|
return Response(error.data, status=404)
|
||||||
|
|
||||||
subscribed = validated_data["playlist_subscribed"]
|
if self.response["playlist_type"] == "custom":
|
||||||
playlist_sub = PlaylistSubscription()
|
error = ErrorResponseSerializer(
|
||||||
json_data = playlist_sub.change_subscribe(playlist_id, subscribed)
|
{"error": f"playlist with ID {playlist_id} is custom"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
|
subscribed = validated_data.get("playlist_subscribed")
|
||||||
|
sort_order = validated_data.get("playlist_sort_order")
|
||||||
|
|
||||||
|
json_data = None
|
||||||
|
if subscribed is not None:
|
||||||
|
json_data = YoutubePlaylist(playlist_id).change_subscribe(
|
||||||
|
new_subscribe_state=subscribed
|
||||||
|
)
|
||||||
|
|
||||||
|
if sort_order:
|
||||||
|
json_data = YoutubePlaylist(playlist_id).change_sort_order(
|
||||||
|
new_sort_order=sort_order
|
||||||
|
)
|
||||||
|
|
||||||
|
if not json_data:
|
||||||
|
error = ErrorResponseSerializer(
|
||||||
|
{"error": "expect playlist_subscribed or playlist_sort_order"}
|
||||||
|
)
|
||||||
|
return Response(error.data, status=400)
|
||||||
|
|
||||||
response_serializer = PlaylistSerializer(json_data)
|
response_serializer = PlaylistSerializer(json_data)
|
||||||
return Response(response_serializer.data)
|
return Response(response_serializer.data)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
-r requirements.txt
|
|
||||||
ipython==9.2.0
|
|
||||||
pre-commit==4.2.0
|
|
||||||
pylint-django==2.6.1
|
|
||||||
pylint==3.3.7
|
|
||||||
pytest-django==4.11.1
|
|
||||||
pytest==8.3.5
|
|
||||||
python-dotenv==1.1.0
|
|
||||||
requirementscheck==0.0.6
|
|
||||||
types-requests==2.32.0.20250328
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
apprise==1.9.3
|
apprise==1.9.4
|
||||||
celery==5.5.2
|
celery==5.5.3
|
||||||
django-auth-ldap==5.2.0
|
django-auth-ldap==5.2.0
|
||||||
django-celery-beat==2.8.0
|
django-celery-beat==2.8.1
|
||||||
django-cors-headers==4.7.0
|
django-cors-headers==4.7.0
|
||||||
Django==5.2.1
|
Django==5.2.5
|
||||||
djangorestframework==3.16.0
|
djangorestframework==3.16.1
|
||||||
drf-spectacular==0.28.0
|
drf-spectacular==0.28.0
|
||||||
Pillow==11.2.1
|
Pillow==11.3.0
|
||||||
redis==6.0.0
|
redis==6.4.0
|
||||||
requests==2.32.3
|
requests==2.32.5
|
||||||
ryd-client==0.0.6
|
ryd-client==0.0.6
|
||||||
uvicorn==0.34.2
|
uvicorn==0.35.0
|
||||||
whitenoise==6.9.0
|
whitenoise==6.9.0
|
||||||
yt-dlp[default]==2025.4.30
|
yt-dlp[default]==2025.8.22
|
||||||
|
|||||||
@@ -91,3 +91,12 @@ class TaskNotificationPostSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
task_name = serializers.ChoiceField(choices=list(TASK_CONFIG))
|
task_name = serializers.ChoiceField(choices=list(TASK_CONFIG))
|
||||||
url = serializers.CharField(required=False)
|
url = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskNotificationTestSerializer(serializers.Serializer):
|
||||||
|
"""serialize task notification test POST"""
|
||||||
|
|
||||||
|
url = serializers.CharField()
|
||||||
|
task_name = serializers.ChoiceField(
|
||||||
|
choices=list(TASK_CONFIG), required=False
|
||||||
|
)
|
||||||
|
|||||||
@@ -32,6 +32,38 @@ class Notifications:
|
|||||||
|
|
||||||
apobj.notify(body=body, title=title)
|
apobj.notify(body=body, title=title)
|
||||||
|
|
||||||
|
def test(self, url) -> tuple[bool, str]:
|
||||||
|
"""send test notification"""
|
||||||
|
try:
|
||||||
|
apobj = apprise.Apprise()
|
||||||
|
|
||||||
|
if not apobj.add(url):
|
||||||
|
success = False
|
||||||
|
message = f"Invalid notification URL format: {url}"
|
||||||
|
return success, message
|
||||||
|
|
||||||
|
title = f"[TA] {self.task_name} process ended with SUCCESS"
|
||||||
|
body = "This is a test notification. Task completed successfully."
|
||||||
|
|
||||||
|
result = apobj.notify(body=body, title=title)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
success = True
|
||||||
|
message = "Test notification sent successfully"
|
||||||
|
return success, message
|
||||||
|
|
||||||
|
success = False
|
||||||
|
message = (
|
||||||
|
"Notification failed. "
|
||||||
|
"Please check container logs for more information."
|
||||||
|
)
|
||||||
|
return success, message
|
||||||
|
|
||||||
|
except Exception as err: # pylint: disable=broad-exception-caught
|
||||||
|
success = False
|
||||||
|
message = f"Notification error: {str(err)}"
|
||||||
|
return success, message
|
||||||
|
|
||||||
def _build_message(
|
def _build_message(
|
||||||
self, task_id: str, task_title: str
|
self, task_id: str, task_title: str
|
||||||
) -> tuple[str, str | None]:
|
) -> tuple[str, str | None]:
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ Functionality:
|
|||||||
from appsettings.src.backup import ElasticBackup
|
from appsettings.src.backup import ElasticBackup
|
||||||
from appsettings.src.config import ReleaseVersion
|
from appsettings.src.config import ReleaseVersion
|
||||||
from appsettings.src.filesystem import Scanner
|
from appsettings.src.filesystem import Scanner
|
||||||
from appsettings.src.index_setup import ElasitIndexWrap
|
from appsettings.src.index_setup import ElasticIndexWrap
|
||||||
from appsettings.src.manual import ImportFolderScanner
|
from appsettings.src.manual import ImportFolderScanner
|
||||||
from appsettings.src.reindex import Reindex, ReindexManual, ReindexPopulate
|
from appsettings.src.reindex import Reindex, ReindexManual, ReindexPopulate
|
||||||
from celery import Task, shared_task
|
from celery import Task, shared_task
|
||||||
from celery.exceptions import Retry
|
from celery.exceptions import Retry
|
||||||
from channel.src.index import YoutubeChannel
|
from channel.src.index import YoutubeChannel
|
||||||
from common.src.ta_redis import RedisArchivist
|
from common.src.ta_redis import RedisArchivist
|
||||||
from common.src.urlparser import Parser
|
from common.src.urlparser import ParsedURLType, Parser
|
||||||
from download.src.queue import PendingList
|
from download.src.queue import PendingList
|
||||||
from download.src.subscriptions import SubscriptionHandler, SubscriptionScanner
|
from download.src.subscriptions import SubscriptionHandler, SubscriptionScanner
|
||||||
from download.src.thumbnails import ThumbFilesystem, ThumbValidator
|
from download.src.thumbnails import ThumbFilesystem, ThumbValidator
|
||||||
@@ -39,10 +39,10 @@ class BaseTask(Task):
|
|||||||
RedisArchivist().set_message(key, message, expire=20)
|
RedisArchivist().set_message(key, message, expire=20)
|
||||||
|
|
||||||
def on_success(self, retval, task_id, args, kwargs):
|
def on_success(self, retval, task_id, args, kwargs):
|
||||||
"""callback task completed successfully"""
|
"""callback task completed"""
|
||||||
print(f"{task_id} success callback")
|
print(f"{task_id} success callback")
|
||||||
message, key = self._build_message()
|
message, key = self._build_message()
|
||||||
message.update({"messages": ["Task completed successfully"]})
|
message.update({"messages": ["Task completed"]})
|
||||||
RedisArchivist().set_message(key, message, expire=5)
|
RedisArchivist().set_message(key, message, expire=5)
|
||||||
|
|
||||||
def before_start(self, task_id, args, kwargs):
|
def before_start(self, task_id, args, kwargs):
|
||||||
@@ -58,9 +58,11 @@ class BaseTask(Task):
|
|||||||
task_title = TASK_CONFIG.get(self.name).get("title")
|
task_title = TASK_CONFIG.get(self.name).get("title")
|
||||||
Notifications(self.name).send(task_id, task_title)
|
Notifications(self.name).send(task_id, task_title)
|
||||||
|
|
||||||
def send_progress(self, message_lines, progress=False, title=False):
|
def send_progress(
|
||||||
|
self, message_lines, progress=False, title=False, level="info"
|
||||||
|
):
|
||||||
"""send progress message"""
|
"""send progress message"""
|
||||||
message, key = self._build_message()
|
message, key = self._build_message(level=level)
|
||||||
message.update(
|
message.update(
|
||||||
{
|
{
|
||||||
"messages": message_lines,
|
"messages": message_lines,
|
||||||
@@ -79,7 +81,7 @@ class BaseTask(Task):
|
|||||||
message.update({"level": level, "id": task_id})
|
message.update({"level": level, "id": task_id})
|
||||||
task_result = TaskManager().get_task(task_id)
|
task_result = TaskManager().get_task(task_id)
|
||||||
if task_result:
|
if task_result:
|
||||||
command = task_result.get("command", False)
|
command = task_result.get("command", None)
|
||||||
message.update({"command": command})
|
message.update({"command": command})
|
||||||
|
|
||||||
key = f"message:{message.get('group')}:{task_id.split('-')[0]}"
|
key = f"message:{message.get('group')}:{task_id.split('-')[0]}"
|
||||||
@@ -101,13 +103,13 @@ def update_subscribed(self):
|
|||||||
|
|
||||||
manager.init(self)
|
manager.init(self)
|
||||||
handler = SubscriptionScanner(task=self)
|
handler = SubscriptionScanner(task=self)
|
||||||
missing_videos = handler.scan()
|
added = handler.scan()
|
||||||
auto_start = handler.auto_start
|
auto_start = handler.auto_start
|
||||||
if missing_videos:
|
if added:
|
||||||
print(missing_videos)
|
if auto_start:
|
||||||
extrac_dl.delay(missing_videos, auto_start=auto_start)
|
download_pending.delay(auto_only=True)
|
||||||
message = f"Found {len(missing_videos)} videos to add to the queue."
|
|
||||||
return message
|
return f"Found {added} videos to add to the queue."
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -147,7 +149,14 @@ def download_pending(self, auto_only=False):
|
|||||||
|
|
||||||
|
|
||||||
@shared_task(name="extract_download", bind=True, base=BaseTask)
|
@shared_task(name="extract_download", bind=True, base=BaseTask)
|
||||||
def extrac_dl(self, youtube_ids, auto_start=False, status="pending"):
|
def extrac_dl(
|
||||||
|
self,
|
||||||
|
youtube_ids: str | list[ParsedURLType],
|
||||||
|
auto_start: bool = False,
|
||||||
|
flat: bool = False,
|
||||||
|
force: bool = False,
|
||||||
|
status: str = "pending",
|
||||||
|
) -> str | None:
|
||||||
"""parse list passed and add to pending"""
|
"""parse list passed and add to pending"""
|
||||||
TaskManager().init(self)
|
TaskManager().init(self)
|
||||||
if isinstance(youtube_ids, str):
|
if isinstance(youtube_ids, str):
|
||||||
@@ -155,17 +164,20 @@ def extrac_dl(self, youtube_ids, auto_start=False, status="pending"):
|
|||||||
else:
|
else:
|
||||||
to_add = youtube_ids
|
to_add = youtube_ids
|
||||||
|
|
||||||
pending_handler = PendingList(youtube_ids=to_add, task=self)
|
pending_handler = PendingList(
|
||||||
pending_handler.parse_url_list()
|
youtube_ids=to_add,
|
||||||
videos_added = pending_handler.add_to_pending(
|
task=self,
|
||||||
status=status, auto_start=auto_start
|
auto_start=auto_start,
|
||||||
|
flat=flat,
|
||||||
|
force=force,
|
||||||
)
|
)
|
||||||
|
videos_added = pending_handler.parse_url_list(status=status)
|
||||||
|
|
||||||
if auto_start:
|
if auto_start:
|
||||||
download_pending.delay(auto_only=True)
|
download_pending.delay(auto_only=True)
|
||||||
|
|
||||||
if videos_added:
|
if videos_added:
|
||||||
return f"added {len(videos_added)} Videos to Queue"
|
return f"added {videos_added} Videos to Queue"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -239,7 +251,7 @@ def run_restore_backup(self, filename):
|
|||||||
|
|
||||||
manager.init(self)
|
manager.init(self)
|
||||||
self.send_progress(["Reset your Index"])
|
self.send_progress(["Reset your Index"])
|
||||||
ElasitIndexWrap().reset()
|
ElasticIndexWrap().reset()
|
||||||
ElasticBackup(task=self).restore(filename)
|
ElasticBackup(task=self).restore(filename)
|
||||||
print("index restore finished")
|
print("index restore finished")
|
||||||
|
|
||||||
@@ -259,7 +271,7 @@ def rescan_filesystem(self):
|
|||||||
handler = Scanner(task=self)
|
handler = Scanner(task=self)
|
||||||
handler.scan()
|
handler.scan()
|
||||||
handler.apply()
|
handler.apply()
|
||||||
ThumbValidator(task=self).validate()
|
thumbnail_check.delay()
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True, name="thumbnail_check", base=BaseTask)
|
@shared_task(bind=True, name="thumbnail_check", base=BaseTask)
|
||||||
|
|||||||
@@ -34,4 +34,9 @@ urlpatterns = [
|
|||||||
views.ScheduleNotification.as_view(),
|
views.ScheduleNotification.as_view(),
|
||||||
name="api-schedule-notification",
|
name="api-schedule-notification",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"notification/test/",
|
||||||
|
views.NotificationTestView.as_view(),
|
||||||
|
name="api-schedule-notification-test",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from task.serializers import (
|
|||||||
TaskIDDataSerializer,
|
TaskIDDataSerializer,
|
||||||
TaskNotificationPostSerializer,
|
TaskNotificationPostSerializer,
|
||||||
TaskNotificationSerializer,
|
TaskNotificationSerializer,
|
||||||
|
TaskNotificationTestSerializer,
|
||||||
TaskResultSerializer,
|
TaskResultSerializer,
|
||||||
)
|
)
|
||||||
from task.src.config_schedule import CrontabValidator, ScheduleBuilder
|
from task.src.config_schedule import CrontabValidator, ScheduleBuilder
|
||||||
@@ -343,3 +344,34 @@ class ScheduleNotification(ApiBaseView):
|
|||||||
Notifications(task_name).remove_task()
|
Notifications(task_name).remove_task()
|
||||||
|
|
||||||
return Response(status=204)
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTestView(ApiBaseView):
|
||||||
|
"""resolves to /api/task/notification/test/
|
||||||
|
POST: test notification url
|
||||||
|
"""
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=TaskNotificationTestSerializer(),
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="test notification sent"),
|
||||||
|
400: OpenApiResponse(
|
||||||
|
ErrorResponseSerializer(), description="bad request"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
"""test notification"""
|
||||||
|
data_serializer = TaskNotificationTestSerializer(data=request.data)
|
||||||
|
data_serializer.is_valid(raise_exception=True)
|
||||||
|
validated_data = data_serializer.validated_data
|
||||||
|
|
||||||
|
url = validated_data["url"]
|
||||||
|
task_name = validated_data.get("task_name", "manual_test")
|
||||||
|
|
||||||
|
success, message = Notifications(task_name).test(url)
|
||||||
|
|
||||||
|
status = 200 if success else 400
|
||||||
|
return Response(
|
||||||
|
{"success": success, "message": message}, status=status
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
from common.src.helper import get_stylesheets
|
from common.src.helper import get_stylesheets
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from user.models import Account
|
from user.models import Account
|
||||||
from video.src.constants import OrderEnum, SortEnum
|
from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
class AccountSerializer(serializers.ModelSerializer):
|
class AccountSerializer(serializers.ModelSerializer):
|
||||||
@@ -31,15 +31,23 @@ class UserMeConfigSerializer(serializers.Serializer):
|
|||||||
page_size = serializers.IntegerField()
|
page_size = serializers.IntegerField()
|
||||||
sort_by = serializers.ChoiceField(choices=SortEnum.names())
|
sort_by = serializers.ChoiceField(choices=SortEnum.names())
|
||||||
sort_order = serializers.ChoiceField(choices=OrderEnum.values())
|
sort_order = serializers.ChoiceField(choices=OrderEnum.values())
|
||||||
view_style_home = serializers.ChoiceField(choices=["grid", "list"])
|
view_style_home = serializers.ChoiceField(
|
||||||
|
choices=["grid", "list", "table"]
|
||||||
|
)
|
||||||
view_style_channel = serializers.ChoiceField(choices=["grid", "list"])
|
view_style_channel = serializers.ChoiceField(choices=["grid", "list"])
|
||||||
view_style_downloads = serializers.ChoiceField(choices=["grid", "list"])
|
view_style_downloads = serializers.ChoiceField(choices=["grid", "list"])
|
||||||
view_style_playlist = serializers.ChoiceField(choices=["grid", "list"])
|
view_style_playlist = serializers.ChoiceField(choices=["grid", "list"])
|
||||||
|
vid_type_filter = serializers.ChoiceField(
|
||||||
|
choices=VideoTypeEnum.values_known(), allow_null=True
|
||||||
|
)
|
||||||
grid_items = serializers.IntegerField(max_value=7, min_value=3)
|
grid_items = serializers.IntegerField(max_value=7, min_value=3)
|
||||||
hide_watched = serializers.BooleanField()
|
hide_watched = serializers.BooleanField(allow_null=True)
|
||||||
|
hide_watched_channel = serializers.BooleanField(allow_null=True)
|
||||||
|
hide_watched_playlist = serializers.BooleanField(allow_null=True)
|
||||||
file_size_unit = serializers.ChoiceField(choices=["binary", "metric"])
|
file_size_unit = serializers.ChoiceField(choices=["binary", "metric"])
|
||||||
show_ignored_only = serializers.BooleanField()
|
show_ignored_only = serializers.BooleanField()
|
||||||
show_subed_only = serializers.BooleanField()
|
show_subed_only = serializers.BooleanField(allow_null=True)
|
||||||
|
show_subed_only_playlists = serializers.BooleanField(allow_null=True)
|
||||||
show_help_text = serializers.BooleanField()
|
show_help_text = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ class UserConfigType(TypedDict, total=False):
|
|||||||
view_style_channel: str
|
view_style_channel: str
|
||||||
view_style_downloads: str
|
view_style_downloads: str
|
||||||
view_style_playlist: str
|
view_style_playlist: str
|
||||||
|
vid_type_filter: str | None
|
||||||
grid_items: int
|
grid_items: int
|
||||||
hide_watched: bool
|
hide_watched: bool | None
|
||||||
|
hide_watched_channel: bool | None
|
||||||
|
hide_watched_playlist: bool | None
|
||||||
file_size_unit: str
|
file_size_unit: str
|
||||||
show_ignored_only: bool
|
show_ignored_only: bool
|
||||||
show_subed_only: bool
|
show_subed_only: bool | None
|
||||||
|
show_subed_only_playlists: bool | None
|
||||||
show_help_text: bool
|
show_help_text: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -43,11 +47,15 @@ class UserConfig:
|
|||||||
view_style_channel="list",
|
view_style_channel="list",
|
||||||
view_style_downloads="list",
|
view_style_downloads="list",
|
||||||
view_style_playlist="grid",
|
view_style_playlist="grid",
|
||||||
|
vid_type_filter=None,
|
||||||
grid_items=3,
|
grid_items=3,
|
||||||
hide_watched=False,
|
hide_watched=False,
|
||||||
|
hide_watched_channel=None,
|
||||||
|
hide_watched_playlist=None,
|
||||||
file_size_unit="binary",
|
file_size_unit="binary",
|
||||||
show_ignored_only=False,
|
show_ignored_only=False,
|
||||||
show_subed_only=False,
|
show_subed_only=None,
|
||||||
|
show_subed_only_playlists=None,
|
||||||
show_help_text=True,
|
show_help_text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class PlayerSerializer(serializers.Serializer):
|
|||||||
"""serialize player"""
|
"""serialize player"""
|
||||||
|
|
||||||
watched = serializers.BooleanField()
|
watched = serializers.BooleanField()
|
||||||
|
watched_date = serializers.IntegerField(required=False)
|
||||||
duration = serializers.IntegerField()
|
duration = serializers.IntegerField()
|
||||||
duration_str = serializers.CharField()
|
duration_str = serializers.CharField()
|
||||||
progress = serializers.FloatField(required=False)
|
progress = serializers.FloatField(required=False)
|
||||||
@@ -111,7 +112,7 @@ class VideoListQuerySerializer(serializers.Serializer):
|
|||||||
playlist = serializers.CharField(required=False)
|
playlist = serializers.CharField(required=False)
|
||||||
channel = serializers.CharField(required=False)
|
channel = serializers.CharField(required=False)
|
||||||
watch = serializers.ChoiceField(
|
watch = serializers.ChoiceField(
|
||||||
choices=WatchedEnum.values(), required=False
|
choices=WatchedEnum.values(), required=False, allow_null=True
|
||||||
)
|
)
|
||||||
sort = serializers.ChoiceField(choices=SortEnum.names(), required=False)
|
sort = serializers.ChoiceField(choices=SortEnum.names(), required=False)
|
||||||
order = serializers.ChoiceField(choices=OrderEnum.values(), required=False)
|
order = serializers.ChoiceField(choices=OrderEnum.values(), required=False)
|
||||||
@@ -119,6 +120,7 @@ class VideoListQuerySerializer(serializers.Serializer):
|
|||||||
choices=VideoTypeEnum.values_known(), required=False
|
choices=VideoTypeEnum.values_known(), required=False
|
||||||
)
|
)
|
||||||
page = serializers.IntegerField(required=False)
|
page = serializers.IntegerField(required=False)
|
||||||
|
height = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
class CommentThreadItemSerializer(serializers.Serializer):
|
class CommentThreadItemSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ class Comments:
|
|||||||
def get_yt_comments(self):
|
def get_yt_comments(self):
|
||||||
"""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, config=self.config).extract(self.youtube_id)
|
info_json, _ = YtWrap(yt_obs, config=self.config).extract(
|
||||||
|
self.youtube_id
|
||||||
|
)
|
||||||
if not info_json:
|
if not info_json:
|
||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class VideoTypeEnum(enum.Enum):
|
|||||||
SHORTS = "shorts"
|
SHORTS = "shorts"
|
||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def values(cls) -> list[str]:
|
def values(cls) -> list[str]:
|
||||||
"""value list"""
|
"""value list"""
|
||||||
@@ -31,6 +34,8 @@ class SortEnum(enum.Enum):
|
|||||||
LIKES = "stats.like_count"
|
LIKES = "stats.like_count"
|
||||||
DURATION = "player.duration"
|
DURATION = "player.duration"
|
||||||
MEDIASIZE = "media_size"
|
MEDIASIZE = "media_size"
|
||||||
|
WIDTH = "streams.width"
|
||||||
|
HEIGHT = "streams.height"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def values(cls) -> list[str]:
|
def values(cls) -> list[str]:
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ from datetime import datetime
|
|||||||
import requests
|
import requests
|
||||||
from channel.src import index as ta_channel
|
from channel.src import index as ta_channel
|
||||||
from common.src.env_settings import EnvironmentSettings
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from common.src.es_connect import ElasticWrap
|
|
||||||
from common.src.helper import get_duration_sec, get_duration_str, randomizor
|
from common.src.helper import get_duration_sec, get_duration_str, randomizor
|
||||||
from common.src.index_generic import YouTubeItem
|
from common.src.index_generic import YouTubeItem
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from download.src.thumbnails import ThumbManager
|
||||||
from playlist.src import index as ta_playlist
|
from playlist.src import index as ta_playlist
|
||||||
from ryd_client import ryd_client
|
from ryd_client import ryd_client
|
||||||
from user.src.user_config import UserConfig
|
from user.src.user_config import UserConfig
|
||||||
@@ -48,11 +48,28 @@ class SponsorBlock:
|
|||||||
|
|
||||||
def get_timestamps(self, youtube_id):
|
def get_timestamps(self, youtube_id):
|
||||||
"""get timestamps from the API"""
|
"""get timestamps from the API"""
|
||||||
url = f"{self.API}/skipSegments?videoID={youtube_id}"
|
url = f"{self.API}/skipSegments"
|
||||||
headers = {"User-Agent": self.user_agent}
|
headers = {"User-Agent": self.user_agent}
|
||||||
|
categories = [
|
||||||
|
"sponsor",
|
||||||
|
"selfpromo",
|
||||||
|
"interaction",
|
||||||
|
"intro",
|
||||||
|
"outro",
|
||||||
|
"preview",
|
||||||
|
"music_offtopic",
|
||||||
|
"poi_highlight",
|
||||||
|
"filler",
|
||||||
|
]
|
||||||
|
params = {
|
||||||
|
"videoID": youtube_id,
|
||||||
|
"category": categories,
|
||||||
|
}
|
||||||
print(f"{youtube_id}: get sponsorblock timestamps")
|
print(f"{youtube_id}: get sponsorblock timestamps")
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=headers, timeout=10)
|
response = requests.get(
|
||||||
|
url, headers=headers, params=params, timeout=10
|
||||||
|
)
|
||||||
except (requests.ReadTimeout, requests.ConnectionError) as err:
|
except (requests.ReadTimeout, requests.ConnectionError) as err:
|
||||||
print(f"{youtube_id}: sponsorblock API error: {str(err)}")
|
print(f"{youtube_id}: sponsorblock API error: {str(err)}")
|
||||||
return False
|
return False
|
||||||
@@ -75,9 +92,17 @@ class SponsorBlock:
|
|||||||
|
|
||||||
def _get_sponsor_dict(self, all_segments):
|
def _get_sponsor_dict(self, all_segments):
|
||||||
"""format and process response"""
|
"""format and process response"""
|
||||||
_ = [i.pop("description", None) for i in all_segments]
|
|
||||||
has_unlocked = not any(i.get("locked") for i in all_segments)
|
has_unlocked = not any(i.get("locked") for i in all_segments)
|
||||||
|
|
||||||
|
# Set only sponsor to skip (retain legacy behaviour)
|
||||||
|
categories_skip = ["sponsor"]
|
||||||
|
for segment in all_segments:
|
||||||
|
segment_category = segment["category"]
|
||||||
|
if segment_category in categories_skip:
|
||||||
|
segment["actionType"] = "skip"
|
||||||
|
else:
|
||||||
|
segment["actionType"] = "none"
|
||||||
|
|
||||||
sponsor_dict = {
|
sponsor_dict = {
|
||||||
"last_refresh": self.last_refresh,
|
"last_refresh": self.last_refresh,
|
||||||
"has_unlocked": has_unlocked,
|
"has_unlocked": has_unlocked,
|
||||||
@@ -173,21 +198,15 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||||||
self._validate_id()
|
self._validate_id()
|
||||||
# extract
|
# extract
|
||||||
self.channel_id = self.youtube_meta["channel_id"]
|
self.channel_id = self.youtube_meta["channel_id"]
|
||||||
upload_date = self.youtube_meta["upload_date"]
|
|
||||||
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
|
|
||||||
published = upload_date_time.strftime("%Y-%m-%d")
|
|
||||||
last_refresh = int(datetime.now().timestamp())
|
last_refresh = int(datetime.now().timestamp())
|
||||||
# base64_blur = ThumbManager().get_base64_blur(self.youtube_id)
|
|
||||||
base64_blur = False
|
|
||||||
# build json_data basics
|
# build json_data basics
|
||||||
self.json_data = {
|
self.json_data = {
|
||||||
"title": self.youtube_meta["title"],
|
"title": self.youtube_meta["title"],
|
||||||
"description": self.youtube_meta.get("description", ""),
|
"description": self.youtube_meta.get("description", ""),
|
||||||
"category": self.youtube_meta.get("categories", []),
|
"category": self.youtube_meta.get("categories", []),
|
||||||
"vid_thumb_url": self.youtube_meta["thumbnail"],
|
"vid_thumb_url": self.youtube_meta["thumbnail"],
|
||||||
"vid_thumb_base64": base64_blur,
|
|
||||||
"tags": self.youtube_meta.get("tags", []),
|
"tags": self.youtube_meta.get("tags", []),
|
||||||
"published": published,
|
"published": self._build_published(),
|
||||||
"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,
|
||||||
@@ -196,6 +215,18 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||||||
"active": True,
|
"active": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _build_published(self):
|
||||||
|
"""build published date or timestamp"""
|
||||||
|
timestamp = self.youtube_meta.get("timestamp")
|
||||||
|
if timestamp:
|
||||||
|
return timestamp
|
||||||
|
|
||||||
|
upload_date = self.youtube_meta["upload_date"]
|
||||||
|
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
|
||||||
|
published = upload_date_time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
return published
|
||||||
|
|
||||||
def _validate_id(self):
|
def _validate_id(self):
|
||||||
"""validate expected video ID, raise value error on mismatch"""
|
"""validate expected video ID, raise value error on mismatch"""
|
||||||
remote_id = self.youtube_meta["id"]
|
remote_id = self.youtube_meta["id"]
|
||||||
@@ -385,12 +416,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||||||
|
|
||||||
return subtitles
|
return subtitles
|
||||||
|
|
||||||
def update_media_url(self):
|
|
||||||
"""update only media_url in es for reindex channel rename"""
|
|
||||||
data = {"doc": {"media_url": self.json_data["media_url"]}}
|
|
||||||
path = f"{self.index_name}/_update/{self.youtube_id}"
|
|
||||||
_, _ = ElasticWrap(path).post(data=data)
|
|
||||||
|
|
||||||
|
|
||||||
def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
|
def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
|
||||||
"""combined classes to create new video in index"""
|
"""combined classes to create new video in index"""
|
||||||
@@ -400,5 +425,8 @@ def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
|
|||||||
raise ValueError("failed to get metadata for " + youtube_id)
|
raise ValueError("failed to get metadata for " + youtube_id)
|
||||||
|
|
||||||
video.check_subtitles()
|
video.check_subtitles()
|
||||||
|
url = video.json_data["vid_thumb_url"]
|
||||||
|
ThumbManager(item_id=video.youtube_id).download_video_thumb(url=url)
|
||||||
video.upload_to_es()
|
video.upload_to_es()
|
||||||
|
|
||||||
return video.json_data
|
return video.json_data
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""build query for video fetching"""
|
"""build query for video fetching"""
|
||||||
|
|
||||||
from common.src.ta_redis import RedisArchivist
|
from common.src.ta_redis import RedisArchivist
|
||||||
|
from playlist.src.index import YoutubePlaylist
|
||||||
from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum
|
from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ class QueryBuilder:
|
|||||||
must_list.append({"match": {"playlist.keyword": playlist}})
|
must_list.append({"match": {"playlist.keyword": playlist}})
|
||||||
|
|
||||||
watch = self.request_params.get("watch")
|
watch = self.request_params.get("watch")
|
||||||
if watch:
|
if watch is not None:
|
||||||
watch_must_list = self.parse_watch(watch)
|
watch_must_list = self.parse_watch(watch)
|
||||||
must_list.append(watch_must_list)
|
must_list.append(watch_must_list)
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ class QueryBuilder:
|
|||||||
type_list_list = self.parse_type(video_type)
|
type_list_list = self.parse_type(video_type)
|
||||||
must_list.append(type_list_list)
|
must_list.append(type_list_list)
|
||||||
|
|
||||||
|
height = self.request_params.get("height")
|
||||||
|
if height:
|
||||||
|
height_must = self.parse_height(height)
|
||||||
|
must_list.append(height_must)
|
||||||
|
|
||||||
query = {"bool": {"must": must_list}}
|
query = {"bool": {"must": must_list}}
|
||||||
|
|
||||||
return query
|
return query
|
||||||
@@ -82,8 +88,18 @@ class QueryBuilder:
|
|||||||
|
|
||||||
return {"match": {"vid_type": vid_type}}
|
return {"match": {"vid_type": vid_type}}
|
||||||
|
|
||||||
|
def parse_height(self, height: str):
|
||||||
|
"""parse height to int"""
|
||||||
|
|
||||||
|
return {"term": {"streams.height": {"value": height}}}
|
||||||
|
|
||||||
def parse_sort(self) -> dict | None:
|
def parse_sort(self) -> dict | None:
|
||||||
"""build sort key"""
|
"""build sort key"""
|
||||||
|
playlist = self.request_params.get("playlist")
|
||||||
|
if playlist:
|
||||||
|
# overwrite sort based on idx in playlist
|
||||||
|
return self._get_playlist_sort(playlist_id=playlist)
|
||||||
|
|
||||||
sort = self.request_params.get("sort")
|
sort = self.request_params.get("sort")
|
||||||
if not sort:
|
if not sort:
|
||||||
return None
|
return None
|
||||||
@@ -100,3 +116,39 @@ class QueryBuilder:
|
|||||||
order_by = getattr(OrderEnum, order.upper()).value
|
order_by = getattr(OrderEnum, order.upper()).value
|
||||||
|
|
||||||
return {"sort": [{sort_field: {"order": order_by}}]}
|
return {"sort": [{sort_field: {"order": order_by}}]}
|
||||||
|
|
||||||
|
def _get_playlist_sort(self, playlist_id: str):
|
||||||
|
"""get sort for playlist"""
|
||||||
|
playlist = YoutubePlaylist(playlist_id)
|
||||||
|
playlist.get_from_es()
|
||||||
|
if not playlist.json_data:
|
||||||
|
raise ValueError(f"playlist {playlist_id} not found")
|
||||||
|
|
||||||
|
sort_score = {
|
||||||
|
i["youtube_id"]: i["idx"]
|
||||||
|
for i in playlist.json_data["playlist_entries"]
|
||||||
|
if i["downloaded"]
|
||||||
|
}
|
||||||
|
script = (
|
||||||
|
"if(params.scores.containsKey(doc['youtube_id'].value)) "
|
||||||
|
+ "{return params.scores[doc['youtube_id'].value];} "
|
||||||
|
+ "return 100000;"
|
||||||
|
)
|
||||||
|
|
||||||
|
sort = {
|
||||||
|
"sort": [
|
||||||
|
{
|
||||||
|
"_script": {
|
||||||
|
"type": "number",
|
||||||
|
"script": {
|
||||||
|
"lang": "painless",
|
||||||
|
"source": script,
|
||||||
|
"params": {"scores": sort_score},
|
||||||
|
},
|
||||||
|
"order": "asc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return sort
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ functionality:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from operator import itemgetter
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from common.src.env_settings import EnvironmentSettings
|
from common.src.env_settings import EnvironmentSettings
|
||||||
from common.src.es_connect import ElasticWrap
|
from common.src.es_connect import ElasticWrap
|
||||||
from common.src.helper import requests_headers
|
from common.src.helper import rand_sleep, requests_headers
|
||||||
|
from yt_dlp.utils import orderedSet_from_options
|
||||||
|
|
||||||
|
|
||||||
class YoutubeSubtitle:
|
class YoutubeSubtitle:
|
||||||
@@ -35,85 +38,67 @@ class YoutubeSubtitle:
|
|||||||
# no subtitles
|
# no subtitles
|
||||||
return False
|
return False
|
||||||
|
|
||||||
relevant_subtitles = []
|
available_subtitles = self._get_all_subtitles("user")
|
||||||
for lang in self.languages:
|
if self.video.config["downloads"]["subtitle_source"] == "auto":
|
||||||
user_sub = self._get_user_subtitles(lang)
|
for lang, auto_cap in self._get_all_subtitles("auto").items():
|
||||||
if user_sub:
|
if lang not in available_subtitles:
|
||||||
relevant_subtitles.append(user_sub)
|
available_subtitles[lang] = auto_cap
|
||||||
continue
|
|
||||||
|
|
||||||
if self.video.config["downloads"]["subtitle_source"] == "auto":
|
all_sub_langs = tuple(available_subtitles.keys())
|
||||||
auto_cap = self._get_auto_caption(lang)
|
relevant_subtitles = False
|
||||||
if auto_cap:
|
try:
|
||||||
relevant_subtitles.append(auto_cap)
|
relevant_subtitles = [
|
||||||
|
available_subtitles[lang]
|
||||||
|
for lang in orderedSet_from_options(
|
||||||
|
self.languages, {"all": all_sub_langs}, use_regex=True
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except re.error as e:
|
||||||
|
raise ValueError(f"wrong regex in subtitle config: {e.pattern}")
|
||||||
|
|
||||||
return relevant_subtitles
|
return relevant_subtitles
|
||||||
|
|
||||||
def _get_auto_caption(self, lang):
|
def _get_all_subtitles(self, source):
|
||||||
"""get auto_caption subtitles"""
|
"""get video subtitles or automatic captions"""
|
||||||
print(f"{self.video.youtube_id}-{lang}: get auto generated subtitles")
|
print(f"{self.video.youtube_id}: get {source} subtitles")
|
||||||
all_subtitles = self.video.youtube_meta.get("automatic_captions")
|
youtube_meta_keys = {"user": "subtitles", "auto": "automatic_captions"}
|
||||||
|
if not (youtube_meta_key := youtube_meta_keys.get(source, None)):
|
||||||
|
raise ValueError(f"unknown subtitles source: {source}")
|
||||||
|
all_subtitles = self.video.youtube_meta.get(youtube_meta_key)
|
||||||
if not all_subtitles:
|
if not all_subtitles:
|
||||||
return False
|
return {}
|
||||||
|
|
||||||
video_media_url = self.video.json_data["media_url"]
|
candidate_subtitles = {}
|
||||||
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
|
for lang, all_formats in all_subtitles.items():
|
||||||
all_formats = all_subtitles.get(lang)
|
|
||||||
if not all_formats:
|
|
||||||
return False
|
|
||||||
|
|
||||||
subtitle_json3 = [i for i in all_formats if i["ext"] == "json3"]
|
|
||||||
if not subtitle_json3:
|
|
||||||
print(f"{self.video.youtube_id}-{lang}: json3 not processed")
|
|
||||||
return False
|
|
||||||
|
|
||||||
subtitle = subtitle_json3[0]
|
|
||||||
subtitle.update(
|
|
||||||
{"lang": lang, "source": "auto", "media_url": media_url}
|
|
||||||
)
|
|
||||||
|
|
||||||
return subtitle
|
|
||||||
|
|
||||||
def _normalize_lang(self):
|
|
||||||
"""normalize country specific language keys"""
|
|
||||||
all_subtitles = self.video.youtube_meta.get("subtitles")
|
|
||||||
if not all_subtitles:
|
|
||||||
return False
|
|
||||||
|
|
||||||
all_keys = list(all_subtitles.keys())
|
|
||||||
for key in all_keys:
|
|
||||||
lang = key.split("-")[0]
|
|
||||||
old = all_subtitles.pop(key)
|
|
||||||
if lang == "live_chat":
|
if lang == "live_chat":
|
||||||
|
# not supported yet
|
||||||
continue
|
continue
|
||||||
all_subtitles[lang] = old
|
|
||||||
|
|
||||||
return all_subtitles
|
video_media_url = self.video.json_data["media_url"]
|
||||||
|
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
|
||||||
|
if not all_formats:
|
||||||
|
# no subtitles found
|
||||||
|
continue
|
||||||
|
|
||||||
def _get_user_subtitles(self, lang):
|
subtitle_json3 = [i for i in all_formats if i["ext"] == "json3"]
|
||||||
"""get subtitles uploaded from channel owner"""
|
if not subtitle_json3:
|
||||||
print(f"{self.video.youtube_id}-{lang}: get user uploaded subtitles")
|
print(f"{self.video.youtube_id}-{lang}: json3 not processed")
|
||||||
all_subtitles = self._normalize_lang()
|
continue
|
||||||
if not all_subtitles:
|
|
||||||
return False
|
|
||||||
|
|
||||||
video_media_url = self.video.json_data["media_url"]
|
subtitle = subtitle_json3[0]
|
||||||
media_url = video_media_url.replace(".mp4", f".{lang}.vtt")
|
subtitle.update(
|
||||||
all_formats = all_subtitles.get(lang)
|
{"lang": lang, "source": source, "media_url": media_url}
|
||||||
if not all_formats:
|
)
|
||||||
# no user subtitles found
|
candidate_subtitles[lang] = subtitle
|
||||||
return False
|
|
||||||
|
|
||||||
subtitle = [i for i in all_formats if i["ext"] == "json3"][0]
|
return candidate_subtitles
|
||||||
subtitle.update(
|
|
||||||
{"lang": lang, "source": "user", "media_url": media_url}
|
|
||||||
)
|
|
||||||
|
|
||||||
return subtitle
|
|
||||||
|
|
||||||
def download_subtitles(self, relevant_subtitles):
|
def download_subtitles(self, relevant_subtitles):
|
||||||
"""download subtitle files to archive"""
|
"""download subtitle files to archive"""
|
||||||
|
subtitle_list = ", ".join(map(itemgetter("lang"), relevant_subtitles))
|
||||||
|
print(
|
||||||
|
f"{self.video.youtube_id}: downloading subtitles: {subtitle_list}"
|
||||||
|
)
|
||||||
videos_base = EnvironmentSettings.MEDIA_DIR
|
videos_base = EnvironmentSettings.MEDIA_DIR
|
||||||
indexed = []
|
indexed = []
|
||||||
for subtitle in relevant_subtitles:
|
for subtitle in relevant_subtitles:
|
||||||
@@ -124,17 +109,21 @@ class YoutubeSubtitle:
|
|||||||
subtitle["url"], headers=requests_headers(), timeout=30
|
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")
|
subtitle_key = f"{self.video.youtube_id}-{lang}"
|
||||||
|
print(f"{subtitle_key}: failed to download subtitle")
|
||||||
print(response.text)
|
print(response.text)
|
||||||
|
rand_sleep(self.video.config)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not response.text:
|
if not response.text:
|
||||||
print(f"{self.video.youtube_id}: skip empty subtitle")
|
print(f"{subtitle_key}: skip empty subtitle")
|
||||||
|
rand_sleep(self.video.config)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
parser = SubtitleParser(response.text, lang, source)
|
parser = SubtitleParser(response.text, lang, source)
|
||||||
parser.process()
|
parser.process()
|
||||||
if not parser.all_cues:
|
if not parser.all_cues:
|
||||||
|
rand_sleep(self.video.config)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
subtitle_str = parser.get_subtitle_str()
|
subtitle_str = parser.get_subtitle_str()
|
||||||
@@ -144,6 +133,7 @@ class YoutubeSubtitle:
|
|||||||
self._index_subtitle(query_str)
|
self._index_subtitle(query_str)
|
||||||
|
|
||||||
indexed.append(subtitle)
|
indexed.append(subtitle)
|
||||||
|
rand_sleep(self.video.config)
|
||||||
|
|
||||||
return indexed
|
return indexed
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ def test_build_data():
|
|||||||
qb = QueryBuilder(
|
qb = QueryBuilder(
|
||||||
user_id=1,
|
user_id=1,
|
||||||
channel="test_channel",
|
channel="test_channel",
|
||||||
playlist="test_playlist",
|
|
||||||
watch="watched",
|
watch="watched",
|
||||||
type="videos",
|
type="videos",
|
||||||
sort="published",
|
sort="published",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class VideoApiListView(ApiBaseView):
|
|||||||
- sort:enum=published|downloaded|views|likes|duration|filesize
|
- sort:enum=published|downloaded|views|likes|duration|filesize
|
||||||
- order:enum=asc|desc
|
- order:enum=asc|desc
|
||||||
- type:enum=videos|streams|shorts
|
- type:enum=videos|streams|shorts
|
||||||
|
- height:int=px
|
||||||
"""
|
"""
|
||||||
|
|
||||||
search_base = "ta_video/_search/"
|
search_base = "ta_video/_search/"
|
||||||
@@ -227,7 +228,8 @@ class VideoProgressView(ApiBaseView):
|
|||||||
expire = False
|
expire = False
|
||||||
|
|
||||||
current_progress.update({"watched": watched})
|
current_progress.update({"watched": watched})
|
||||||
redis_con.set_message(key, current_progress, expire=expire)
|
if position > 5:
|
||||||
|
redis_con.set_message(key, current_progress, expire=expire)
|
||||||
|
|
||||||
response_serializer = PlayerSerializer(current_progress)
|
response_serializer = PlayerSerializer(current_progress)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.5'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
tubearchivist:
|
tubearchivist:
|
||||||
container_name: tubearchivist
|
container_name: tubearchivist
|
||||||
@@ -40,7 +38,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.18.0
|
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.18.2
|
||||||
container_name: archivist-es
|
container_name: archivist-es
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
32
docker_assets/beat_auto_spawn.sh
Executable file
32
docker_assets/beat_auto_spawn.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# auto restart beat scheduler
|
||||||
|
# https://github.com/celery/django-celery-beat/issues/894
|
||||||
|
|
||||||
|
if [[ -n "$DJANGO_DEBUG" ]]; then
|
||||||
|
LOGLEVEL="DEBUG"
|
||||||
|
else
|
||||||
|
LOGLEVEL="INFO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMAND="celery -A task beat --loglevel=$LOGLEVEL --scheduler django_celery_beat.schedulers:DatabaseScheduler"
|
||||||
|
TIMEOUT=3600
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
echo "Starting process beat scheduler"
|
||||||
|
|
||||||
|
$COMMAND &
|
||||||
|
PID=$!
|
||||||
|
|
||||||
|
sleep $TIMEOUT
|
||||||
|
|
||||||
|
# Kill the process if still running
|
||||||
|
if kill -0 $PID 2>/dev/null; then
|
||||||
|
echo "Killing beat process after $TIMEOUT seconds"
|
||||||
|
kill $PID
|
||||||
|
# Wait a bit to allow graceful shutdown, then force kill if needed
|
||||||
|
sleep 10
|
||||||
|
kill -9 $PID 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Restarting beat..."
|
||||||
|
done
|
||||||
@@ -50,7 +50,17 @@ server {
|
|||||||
root /app/static;
|
root /app/static;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location ~* .(?:css|js)$ {
|
||||||
|
try_files $uri $uri/ /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control 'no-store';
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
add_header Cache-Control 'no-store';
|
||||||
try_files $uri $uri/ /index.html =404;
|
try_files $uri $uri/ /index.html =404;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
if [[ -n "$DJANGO_DEBUG" ]]; then
|
||||||
|
LOGLEVEL="DEBUG"
|
||||||
|
else
|
||||||
|
LOGLEVEL="INFO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# update yt-dlp if needed
|
||||||
|
if [[ "${TA_AUTO_UPDATE_YTDLP,,}" =~ ^(release|nightly)$ ]]; then
|
||||||
|
echo "Updating yt-dlp..."
|
||||||
|
preflag=$([[ "${TA_AUTO_UPDATE_YTDLP,,}" == "nightly" ]] && echo "--pre" || echo "")
|
||||||
|
python -m pip install --target=/root/.local/bin --upgrade $preflag "yt-dlp[default]" || {
|
||||||
|
echo "yt-dlp update failed"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
# stop on pending manual migration
|
# stop on pending manual migration
|
||||||
python manage.py ta_stop_on_error
|
python manage.py ta_stop_on_error
|
||||||
|
|
||||||
@@ -18,10 +33,11 @@ python manage.py ta_startup
|
|||||||
# start all tasks
|
# start all tasks
|
||||||
nginx &
|
nginx &
|
||||||
celery -A task.celery worker \
|
celery -A task.celery worker \
|
||||||
--loglevel=INFO \
|
--loglevel=$LOGLEVEL \
|
||||||
--concurrency 4 \
|
--concurrency 4 \
|
||||||
--max-tasks-per-child 5 \
|
--max-tasks-per-child 5 \
|
||||||
--max-memory-per-child 150000 &
|
--max-memory-per-child 150000 &
|
||||||
celery -A task beat --loglevel=INFO \
|
|
||||||
--scheduler django_celery_beat.schedulers:DatabaseScheduler &
|
./beat_auto_spawn.sh &
|
||||||
|
|
||||||
python backend_start.py
|
python backend_start.py
|
||||||
|
|||||||
2005
frontend/package-lock.json
generated
2005
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,27 +11,27 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.2.5",
|
"dompurify": "^3.2.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.6.0",
|
"react-router-dom": "^7.8.0",
|
||||||
"zustand": "^5.0.4"
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.1.3",
|
"@types/react": "^19.1.9",
|
||||||
"@types/react-dom": "^19.1.3",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.32.0",
|
"@typescript-eslint/eslint-plugin": "^8.39.0",
|
||||||
"@typescript-eslint/parser": "^8.32.0",
|
"@typescript-eslint/parser": "^8.39.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
"@vitejs/plugin-react-swc": "^4.0.0",
|
||||||
"eslint": "^9.26.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.1.0",
|
"globals": "^16.3.0",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.6.2",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.32.0",
|
"typescript-eslint": "^8.39.0",
|
||||||
"vite": ">=6.3.5",
|
"vite": ">=7.1.1",
|
||||||
"vite-plugin-checker": "^0.9.3"
|
"vite-plugin-checker": "^0.10.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
frontend/public/img/icon-filter.svg
Normal file
41
frontend/public/img/icon-filter.svg
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="Layer_1"
|
||||||
|
x="0px"
|
||||||
|
y="0px"
|
||||||
|
viewBox="131 -131 512 512"
|
||||||
|
style="enable-background:new 131 -131 512 512;"
|
||||||
|
xml:space="preserve"
|
||||||
|
sodipodi:docname="icon-filter.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs1" /><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.6130873"
|
||||||
|
inkscape:cx="122.12606"
|
||||||
|
inkscape:cy="215.73537"
|
||||||
|
inkscape:window-width="2532"
|
||||||
|
inkscape:window-height="1379"
|
||||||
|
inkscape:window-x="1932"
|
||||||
|
inkscape:window-y="45"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="Layer_1" />
|
||||||
|
<g
|
||||||
|
id="XMLID_2_"
|
||||||
|
transform="matrix(0.71904421,0,0,0.71904421,108.73394,35.132323)">
|
||||||
|
<path
|
||||||
|
id="XMLID_4_"
|
||||||
|
d="m 640.9,-116.5 c 3.7,10.2 2.8,18.6 -4.7,25.1 L 456.6,87.4 v 270 c 0,10.2 -4.7,17.7 -14,21.4 -2.8,0.9 -6.5,1.9 -9.3,1.9 -6.5,0 -12.1,-1.9 -16.8,-6.5 L 323.4,281 c -4.7,-4.7 -6.5,-10.2 -6.5,-16.8 V 87.4 L 138.2,-91.4 c -7.4,-7.4 -9.3,-15.8 -4.7,-25.1 3.7,-9.3 11.2,-14 21.4,-14 h 464.5 c 10.3,-0.9 16.8,3.8 21.5,14 z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
36
frontend/public/img/icon-multi-select.svg
Normal file
36
frontend/public/img/icon-multi-select.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
docname="icon-multi-select.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="2.0722656"
|
||||||
|
inkscape:cx="255.75872"
|
||||||
|
inkscape:cy="255.75872"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1407"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="33"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<!--! Font Awesome Free 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. -->
|
||||||
|
<path
|
||||||
|
d="m 55.494816,230.93685 c 0,-27.64778 22.43935,-50.12629 50.126294,-50.12629 h 50.1263 v 100.25259 c 0,41.51084 32.9737,75.18944 75.18944,75.18944 h 100.25259 v 50.1263 c 0,27.64778 -22.47851,50.12629 -50.12629,50.12629 H 105.62111 c -27.686944,0 -50.126294,-22.47851 -50.126294,-50.12629 z M 230.93685,331.18944 c -27.64778,0 -50.12629,-22.47851 -50.12629,-50.12629 V 105.62111 c 0,-27.686944 22.47851,-50.126294 50.12629,-50.126294 h 175.44204 c 27.64778,0 50.12629,22.43935 50.12629,50.126294 v 175.44204 c 0,27.64778 -22.47851,50.12629 -50.12629,50.12629 z"
|
||||||
|
id="path1"
|
||||||
|
style="stroke-width:0.783223" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
7
frontend/public/img/icon-tableview.svg
Normal file
7
frontend/public/img/icon-tableview.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" width="960pt" height="960pt" viewBox="0 0 960 960" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="translate(0,960)scale(.075,.075)">
|
||||||
|
<path id="path1" d="M 740 -10481 c -77 24 -152 94 -170 160 -6 23 -10 376 -10 946 l 0 910 24 50 c 28 59 66 97 121 122 38 17 268 18 5620 21 3923 1 5595 -1 5632 -8 70 -15 124 -56 157 -121 l 27 -54 0 -920 c 0 -637 -4 -932 -11 -958 -17 -58 -84 -122 -150 -141 -49 -15 -544 -16 -5635 -15 -3069 0 -5591 4 -5605 8 z M 9615 -7400 c -11 4 -31 20 -45 35 l -25 27 -3 889 c -3 978 -5 935 57 972 27 16 117 17 1253 17 1321 0 1252 3 1293 -54 20 -27 20 -43 20 -919 0 -860 -1 -893 -19 -920 -41 -60 37 -57 -1293 -56 -670 0 -1227 4 -1238 9 z M 3658 -7384 c -61 32 -58 -10 -58 959 0 973 -3 928 60 960 25 13 178 15 1250 15 1072 0 1225 -2 1250 -15 63 -32 60 13 60 -960 0 -973 3 -928 -60 -960 -25 -13 -178 -15 -1252 -15 -1062 1 -1227 3 -1250 16 z M 6551 -7374 c -17 14 -35 42 -41 62 -6 24 -10 339 -10 892 0 826 1 856 20 898 35 78 -60 73 1315 70 l 1225 -3 32 -33 33 -32 3 -895 c 2 -788 0 -899 -13 -925 -33 -64 48 -60 -1304 -60 l -1229 0 -31 26 z M 616 -7369 c -59 46 -56 0 -56 951 0 976 -4 924 70 960 33 17 113 18 1250 18 1187 0 1216 -1 1247 -20 66 -40 63 12 63 -955 0 -791 -2 -880 -16 -911 -33 -68 54 -64 -1302 -64 l -1229 0 -27 21 z M 9615 -4530 c -11 4 -31 20 -45 35 l -25 27 -3 901 c -2 891 -2 902 18 935 40 65 -32 62 1296 62 1335 0 1255 4 1292 -64 16 -29 17 -101 17 -921 0 -832 -1 -892 -18 -922 -36 -67 50 -63 -1294 -62 -670 0 -1227 4 -1238 9 z M 3664 -4514 c -68 33 -64 -26 -64 973 l 0 901 34 38 34 37 1242 0 1242 0 34 -37 34 -38 0 -900 c 0 -989 3 -942 -60 -975 -25 -13 -178 -15 -1247 -15 -1064 0 -1222 2 -1249 16 z M 6551 -4504 c -17 14 -35 42 -41 62 -14 51 -14 1733 0 1784 6 20 24 48 41 62 l 31 26 1237 0 c 1360 0 1263 5 1296 -60 23 -44 23 -1796 0 -1840 -33 -64 47 -60 -1304 -60 l -1229 0 -31 26 z M 615 -4498 c -57 44 -55 13 -55 963 0 844 1 877 19 913 11 21 31 43 46 50 20 9 323 12 1260 12 l 1233 0 31 -30 c 17 -17 33 -45 36 -63 3 -18 4 -429 3 -915 l -3 -884 -37 -34 -38 -34 -1234 0 -1233 0 -28 22 z " />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
@@ -6,10 +6,10 @@ type AppriseTaskNameType =
|
|||||||
| 'download_pending'
|
| 'download_pending'
|
||||||
| 'check_reindex';
|
| 'check_reindex';
|
||||||
|
|
||||||
const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType) => {
|
const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => {
|
||||||
return APIClient('/api/task/notification/', {
|
return APIClient('/api/task/notification/', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
body: { task_name: taskName },
|
body: { task_name: taskName, url: url },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import APIClient from '../../functions/APIClient';
|
|||||||
|
|
||||||
type FilterType = 'ignore' | 'pending';
|
type FilterType = 'ignore' | 'pending';
|
||||||
|
|
||||||
const deleteDownloadQueueByFilter = async (filter: FilterType) => {
|
const deleteDownloadQueueByFilter = async (
|
||||||
|
filter: FilterType,
|
||||||
|
channel: string | null,
|
||||||
|
vid_type: string | null,
|
||||||
|
) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
if (filter) searchParams.append('filter', filter);
|
if (filter) searchParams.append('filter', filter);
|
||||||
|
if (channel) searchParams.append('channel', channel);
|
||||||
|
if (vid_type) searchParams.append('vid_type', vid_type);
|
||||||
|
|
||||||
return APIClient(`/api/download/?${searchParams.toString()}`, {
|
return APIClient(`/api/download/?${searchParams.toString()}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import APIClient from '../../functions/APIClient';
|
|||||||
const deletePlaylist = async (playlistId: string, allVideos = false) => {
|
const deletePlaylist = async (playlistId: string, allVideos = false) => {
|
||||||
let params = '';
|
let params = '';
|
||||||
if (allVideos) {
|
if (allVideos) {
|
||||||
params = '?delete-videos=true';
|
params = '?delete_videos=true';
|
||||||
}
|
}
|
||||||
|
|
||||||
return APIClient(`/api/playlist/${playlistId}/${params}`, {
|
return APIClient(`/api/playlist/${playlistId}/${params}`, {
|
||||||
|
|||||||
16
frontend/src/api/actions/testAppriseNotificationUrl.ts
Normal file
16
frontend/src/api/actions/testAppriseNotificationUrl.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import APIClient from '../../functions/APIClient';
|
||||||
|
import { AppriseTaskNameType } from './createAppriseNotificationUrl';
|
||||||
|
|
||||||
|
export type TestNotificationResponseType = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const testAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => {
|
||||||
|
return APIClient<TestNotificationResponseType>('/api/task/notification/test/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { task_name: taskName, url },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default testAppriseNotificationUrl;
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import APIClient from '../../functions/APIClient';
|
import APIClient from '../../functions/APIClient';
|
||||||
|
|
||||||
const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) => {
|
type UpdateDownloadQueueType = {
|
||||||
|
youtubeIdStrings: string;
|
||||||
|
autostart?: boolean;
|
||||||
|
flat?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDownloadQueue = async (params: UpdateDownloadQueueType) => {
|
||||||
const urls = [];
|
const urls = [];
|
||||||
const containsMultiple = youtubeIdStrings.includes('\n');
|
const containsMultiple = params.youtubeIdStrings.includes('\n');
|
||||||
|
|
||||||
if (containsMultiple) {
|
if (containsMultiple) {
|
||||||
const youtubeIds = youtubeIdStrings.split('\n');
|
const youtubeIds = params.youtubeIdStrings.split('\n');
|
||||||
|
|
||||||
youtubeIds.forEach(youtubeId => {
|
youtubeIds.forEach(youtubeId => {
|
||||||
if (youtubeId.trim()) {
|
if (youtubeId.trim()) {
|
||||||
@@ -13,15 +20,16 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' });
|
urls.push({ youtube_id: params.youtubeIdStrings, status: 'pending' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let params = '';
|
const searchParams = new URLSearchParams();
|
||||||
if (autostart) {
|
if (params.autostart === true) searchParams.append('autostart', 'true');
|
||||||
params = '?autostart=true';
|
if (params.flat === true) searchParams.append('flat', 'true');
|
||||||
}
|
if (params.force === true) searchParams.append('force', 'true');
|
||||||
|
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|
||||||
return APIClient(`/api/download/${params}`, {
|
return APIClient(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { data: [...urls] },
|
body: { data: [...urls] },
|
||||||
});
|
});
|
||||||
|
|||||||
25
frontend/src/api/actions/updateDownloadQueueByFilter.ts
Normal file
25
frontend/src/api/actions/updateDownloadQueueByFilter.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import APIClient from '../../functions/APIClient';
|
||||||
|
|
||||||
|
type FilterType = 'ignore' | 'pending';
|
||||||
|
export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority' | 'clear_error';
|
||||||
|
|
||||||
|
const updateDownloadQueueByFilter = async (
|
||||||
|
filter: FilterType,
|
||||||
|
channel: string | null,
|
||||||
|
vid_type: string | null,
|
||||||
|
error: string | null,
|
||||||
|
status: DownloadQueueStatus,
|
||||||
|
) => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (filter) searchParams.append('filter', filter);
|
||||||
|
if (channel) searchParams.append('channel', channel);
|
||||||
|
if (vid_type) searchParams.append('vid_type', vid_type);
|
||||||
|
if (error) searchParams.append('error', error);
|
||||||
|
|
||||||
|
return APIClient(`/api/download/?${searchParams.toString()}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { status: status },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default updateDownloadQueueByFilter;
|
||||||
10
frontend/src/api/actions/updatePlaylistSortOrder.ts
Normal file
10
frontend/src/api/actions/updatePlaylistSortOrder.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import APIClient from '../../functions/APIClient';
|
||||||
|
|
||||||
|
const updatePlaylistSortOrder = async (playlistId: string, newSortOrder: 'top' | 'bottom') => {
|
||||||
|
return APIClient(`/api/playlist/${playlistId}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { playlist_sort_order: newSortOrder },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default updatePlaylistSortOrder;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home';
|
import { ViewStylesType } from '../../configuration/constants/ViewStyle';
|
||||||
import APIClient from '../../functions/APIClient';
|
import APIClient from '../../functions/APIClient';
|
||||||
|
import { SortByType, SortOrderType, VideoTypes } from '../loader/loadVideoListByPage';
|
||||||
|
|
||||||
export type ColourVariants =
|
export type ColourVariants =
|
||||||
| 'dark.css'
|
| 'dark.css'
|
||||||
@@ -8,6 +9,14 @@ export type ColourVariants =
|
|||||||
| 'midnight.css'
|
| 'midnight.css'
|
||||||
| 'custom.css';
|
| 'custom.css';
|
||||||
|
|
||||||
|
export const ColourConstant = {
|
||||||
|
Dark: 'dark.css',
|
||||||
|
Light: 'light.css',
|
||||||
|
Matrix: 'matrix.css',
|
||||||
|
Midnight: 'midnight.css',
|
||||||
|
Custom: 'custom.css',
|
||||||
|
};
|
||||||
|
|
||||||
export const FileSizeUnits = {
|
export const FileSizeUnits = {
|
||||||
Binary: 'binary',
|
Binary: 'binary',
|
||||||
Metric: 'metric',
|
Metric: 'metric',
|
||||||
@@ -18,15 +27,19 @@ export type UserConfigType = {
|
|||||||
page_size: number;
|
page_size: number;
|
||||||
sort_by: SortByType;
|
sort_by: SortByType;
|
||||||
sort_order: SortOrderType;
|
sort_order: SortOrderType;
|
||||||
view_style_home: ViewLayoutType;
|
view_style_home: ViewStylesType;
|
||||||
view_style_channel: ViewLayoutType;
|
view_style_channel: ViewStylesType;
|
||||||
view_style_downloads: ViewLayoutType;
|
view_style_downloads: ViewStylesType;
|
||||||
view_style_playlist: ViewLayoutType;
|
view_style_playlist: ViewStylesType;
|
||||||
|
vid_type_filter: VideoTypes | null;
|
||||||
grid_items: number;
|
grid_items: number;
|
||||||
hide_watched: boolean;
|
hide_watched: boolean | null;
|
||||||
|
hide_watched_channel: boolean | null;
|
||||||
|
hide_watched_playlist: boolean | null;
|
||||||
file_size_unit: 'binary' | 'metric';
|
file_size_unit: 'binary' | 'metric';
|
||||||
show_ignored_only: boolean;
|
show_ignored_only: boolean;
|
||||||
show_subed_only: boolean;
|
show_subed_only: boolean | null;
|
||||||
|
show_subed_only_playlists: boolean | null;
|
||||||
show_help_text: boolean;
|
show_help_text: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export type AppSettingsConfigType = {
|
|||||||
channel_size: number | null;
|
channel_size: number | null;
|
||||||
live_channel_size: number | null;
|
live_channel_size: number | null;
|
||||||
shorts_channel_size: number | null;
|
shorts_channel_size: number | null;
|
||||||
|
playlist_size: number | null;
|
||||||
auto_start: boolean;
|
auto_start: boolean;
|
||||||
|
extract_flat: boolean;
|
||||||
};
|
};
|
||||||
downloads: {
|
downloads: {
|
||||||
limit_speed: number | null;
|
limit_speed: number | null;
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ export type ChannelsListResponse = {
|
|||||||
config?: ConfigType;
|
config?: ConfigType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadChannelList = async (page: number, showSubscribed: boolean) => {
|
const loadChannelList = async (page: number, showSubscribed: boolean | null) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
if (page) searchParams.append('page', page.toString());
|
if (page) searchParams.append('page', page.toString());
|
||||||
if (showSubscribed) searchParams.append('filter', 'subscribed');
|
if (showSubscribed !== null) {
|
||||||
|
searchParams.append('filter', showSubscribed ? 'subscribed' : 'unsubscribed');
|
||||||
|
}
|
||||||
|
|
||||||
const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type ChannelNavResponseType = {
|
|||||||
has_shorts: boolean;
|
has_shorts: boolean;
|
||||||
has_playlists: boolean;
|
has_playlists: boolean;
|
||||||
has_pending: boolean;
|
has_pending: boolean;
|
||||||
|
has_ignored: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadChannelNav = async (youtubeChannelId: string) => {
|
const loadChannelNav = async (youtubeChannelId: string) => {
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import APIClient from '../../functions/APIClient';
|
import APIClient from '../../functions/APIClient';
|
||||||
import { DownloadResponseType } from '../../pages/Download';
|
import { DownloadResponseType } from '../../pages/Download';
|
||||||
|
|
||||||
const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => {
|
const loadDownloadQueue = async (
|
||||||
|
page: number,
|
||||||
|
channelId: string | null,
|
||||||
|
vid_type: string | null,
|
||||||
|
errorFilterFromUrl: string | null,
|
||||||
|
showIgnored: boolean,
|
||||||
|
search: string,
|
||||||
|
) => {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
if (page) searchParams.append('page', page.toString());
|
if (page) searchParams.append('page', page.toString());
|
||||||
if (channelId) searchParams.append('channel', channelId);
|
if (channelId) searchParams.append('channel', channelId);
|
||||||
|
if (vid_type) searchParams.append('vid_type', vid_type);
|
||||||
|
if (search) searchParams.append('q', encodeURIComponent(search));
|
||||||
|
if (errorFilterFromUrl !== null) searchParams.append('error', errorFilterFromUrl);
|
||||||
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
|
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
|
||||||
|
|
||||||
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type PlaylistType = {
|
|||||||
playlist_channel_id: string;
|
playlist_channel_id: string;
|
||||||
playlist_description: string;
|
playlist_description: string;
|
||||||
playlist_entries: PlaylistEntryType[];
|
playlist_entries: PlaylistEntryType[];
|
||||||
|
playlist_sort_order: 'top' | 'bottom';
|
||||||
playlist_id: string;
|
playlist_id: string;
|
||||||
playlist_last_refresh: string;
|
playlist_last_refresh: string;
|
||||||
playlist_name: string;
|
playlist_name: string;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type PlaylistCategoryType = 'regular' | 'custom';
|
|||||||
type LoadPlaylistListProps = {
|
type LoadPlaylistListProps = {
|
||||||
channel?: string;
|
channel?: string;
|
||||||
page?: number | undefined;
|
page?: number | undefined;
|
||||||
subscribed?: boolean;
|
subscribed?: boolean | null;
|
||||||
type?: PlaylistCategoryType;
|
type?: PlaylistCategoryType;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,7 +21,8 @@ const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylis
|
|||||||
|
|
||||||
if (channel) searchParams.append('channel', channel);
|
if (channel) searchParams.append('channel', channel);
|
||||||
if (page) searchParams.append('page', page.toString());
|
if (page) searchParams.append('page', page.toString());
|
||||||
if (subscribed) searchParams.append('subscribed', subscribed.toString());
|
if (subscribed !== undefined && subscribed !== null)
|
||||||
|
searchParams.append('subscribed', subscribed.toString());
|
||||||
if (type) searchParams.append('type', type);
|
if (type) searchParams.append('type', type);
|
||||||
|
|
||||||
const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user