New React Frontend, #build

Migration guide: https://gist.github.com/bbilly1/7b6abc52ab689f56671bf9011879379a

Changed:
- First testing build of the new React frontend
This commit is contained in:
Simon
2025-01-26 23:10:07 +07:00
440 changed files with 19300 additions and 12456 deletions

View File

@@ -18,4 +18,4 @@ venv/
assets/* assets/*
# for local testing only # for local testing only
testing.sh testing.sh

View File

@@ -1,17 +0,0 @@
'use strict';
module.exports = {
extends: ['eslint:recommended', 'eslint-config-prettier'],
parserOptions: {
ecmaVersion: 2020,
},
env: {
browser: true,
},
rules: {
strict: ['error', 'global'],
'no-unused-vars': ['error', { vars: 'local' }],
eqeqeq: ['error', 'always', { null: 'ignore' }],
curly: ['error', 'multi-line'],
'no-var': 'error',
},
};

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
docker_assets\run.sh eol=lf

2
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,3 @@
github: bbilly1 github: bbilly1
ko_fi: bbilly1 ko_fi: bbilly1
custom: https://paypal.me/bbilly1 custom: https://paypal.me/bbilly1

View File

@@ -6,7 +6,7 @@ body:
- type: checkboxes - type: checkboxes
id: block id: block
attributes: attributes:
label: "This project doesn't accept any new feature requests for the forseeable future. There is no shortage of ideas and the next development steps are clear for years to come." label: "This project doesn't accept any new feature requests for the foreseeable future. There is no shortage of ideas and the next development steps are clear for years to come."
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

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -1,22 +0,0 @@
name: lint_js
on:
push:
paths:
- '**/*.js'
pull_request:
paths:
- '**/*.js'
jobs:
check:
name: lint_js
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npm run lint
- run: npm run format -- --check

View File

@@ -1,42 +0,0 @@
name: lint_python
on:
push:
paths:
- '**/*.py'
pull_request:
paths:
- '**/*.py'
jobs:
lint_python:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y gcc libldap2-dev libsasl2-dev libssl-dev
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install python dependencies
run: |
python -m pip install --upgrade pip
pip install -r tubearchivist/requirements-dev.txt
- name: Run Linter
run: ./deploy.sh validate

47
.github/workflows/pre_commit.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Lint, Test, Build, and Push Docker Image
on:
push:
branches:
- '**'
tags:
- '**'
pull_request:
branches:
- '**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '23'
- name: Install frontend dependencies
run: |
cd frontend
npm install
- name: Cache pre-commit environment
uses: actions/cache@v3
with:
path: |
~/.cache/pre-commit
key: ${{ runner.os }}-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }}
restore-keys: |
${{ runner.os }}-pre-commit-
- name: Install dependencies
run: |
pip install pre-commit
pre-commit install
- name: Run pre-commit
run: |
pre-commit run --all-files

View File

@@ -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 tubearchivist/requirements-dev.txt pip install -r backend/requirements-dev.txt
- name: Run unit tests - name: Run unit tests
run: pytest tubearchivist run: pytest backend

5
.gitignore vendored
View File

@@ -2,8 +2,9 @@
__pycache__ __pycache__
.venv .venv
# django testing db # django testing
db.sqlite3 backend/static
backend/.env
# vscode custom conf # vscode custom conf
.vscode .vscode

49
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,49 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
alias: python
files: ^backend/
args: ["--line-length=79"]
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
alias: python
files: ^backend/
args: ["--profile", "black", "-l 79"]
- repo: https://github.com/pycqa/flake8
rev: 7.1.1
hooks:
- id: flake8
alias: python
files: ^backend/
args: ["--max-complexity=10", "--max-line-length=79"]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
exclude: ^frontend/package-lock.json
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.17.0
hooks:
- id: eslint
name: eslint
files: \.[jt]sx?$
types: [file]
entry: npm run --prefix ./frontend lint
pass_filenames: false
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
entry: npm run --prefix ./frontend format
pass_filenames: false
exclude: '.*(\.svg|/migrations/).*'

View File

@@ -138,9 +138,55 @@ The documentation available at [docs.tubearchivist.com](https://docs.tubearchivi
## Development Environment ## Development Environment
I have learned the hard way, that working on a dockerized application outside of docker is very error prone and in general not a good idea. So if you want to test your changes, it's best to run them in a docker testing environment. You might be able to run the application directly, but this document assumes you're using docker. 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.
### Instructions ### Native Instruction
For convenience, it's recommended to still run Redis and ES in a docker container. Make sure both containers can be reachable over the network.
Set up your virtual environment and install the requirements defined in `requirements-dev.txt`.
There are options built in to load environment variables from a file using `load_dotenv`. Example `.env` file to place in the same folder as `manage.py`:
```
TA_HOST="localhost"
TA_USERNAME=tubearchivist
TA_PASSWORD=verysecret
TA_MEDIA_DIR="static/volume/media"
TA_CACHE_DIR="static"
TA_APP_DIR="."
REDIS_CON=redis://localhost:6379
ES_URL="http://localhost:9200"
ELASTIC_PASSWORD=verysecret
TZ=America/New_York
DJANGO_DEBUG=True
```
Than from look at the container startup script `run.sh`, make sure all needed migrations and startup checks ran, then to start the dev backend server from the same folder as `manage.py` run:
```bash
python manage.py runserver
```
The backend will be available on [localhost:8000/api/](localhost:8000/api/).
You'll probably also want to have a Celery worker instance running, refer to `run.sh` for that. The Beat Scheduler might not be needed.
Then from the frontend folder, install the dependencies with:
```bash
npm install
```
Then to start the developlent server:
```bash
npm run dev
```
And the frontend should be available at [localhost:3000](localhost:3000).
### Docker Instructions
Set up docker on your development machine. Set up docker on your development machine.

View File

@@ -1,6 +1,17 @@
# 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 node-builder
# RUN npm config set registry https://registry.npmjs.org/
COPY ./frontend /frontend
WORKDIR /frontend
RUN npm i
RUN npm run build:deploy
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.8-slim-bookworm AS builder
@@ -9,7 +20,7 @@ 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
# install requirements # install requirements
COPY ./tubearchivist/requirements.txt /requirements.txt COPY ./backend/requirements.txt /requirements.txt
RUN pip install --user -r requirements.txt RUN pip install --user -r requirements.txt
# build ffmpeg # build ffmpeg
@@ -56,9 +67,11 @@ COPY docker_assets/nginx.conf /etc/nginx/sites-available/default
RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf RUN sed -i 's/^user www\-data\;$/user root\;/' /etc/nginx/nginx.conf
# copy application into container # copy application into container
COPY ./tubearchivist /app COPY ./backend /app
COPY ./docker_assets/run.sh /app COPY ./docker_assets/run.sh /app
COPY ./docker_assets/uwsgi.ini /app COPY ./docker_assets/backend_start.py /app
COPY --from=node-builder ./frontend/dist /app/static
# volumes # volumes
VOLUME /cache VOLUME /cache

View File

@@ -54,10 +54,10 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
| TA_USERNAME | Initial username when logging into TA | Required | | TA_USERNAME | Initial username when logging into TA | Required |
| TA_PASSWORD | Initial password when logging into TA | Required | | TA_PASSWORD | Initial password when logging into TA | Required |
| ELASTIC_PASSWORD | Password for ElasticSearch | Required | | ELASTIC_PASSWORD | Password for ElasticSearch | Required |
| REDIS_HOST | Hostname for Redis | Required | | REDIS_CON | Connection string to Redis | Required |
| TZ | Set your timezone for the scheduler | Required | | TZ | Set your timezone for the scheduler | Required |
| TA_PORT | Overwrite Nginx port | Optional | | TA_PORT | Overwrite Nginx port | Optional |
| TA_UWSGI_PORT | Overwrite container internal uwsgi port | Optional | | TA_BACKEND_PORT | Overwrite container internal backend server port | Optional |
| TA_ENABLE_AUTH_PROXY | Enables support for forwarding auth in reverse proxies | [Read more](https://docs.tubearchivist.com/configuration/forward-auth/) | | TA_ENABLE_AUTH_PROXY | Enables support for forwarding auth in reverse proxies | [Read more](https://docs.tubearchivist.com/configuration/forward-auth/) |
| TA_AUTH_PROXY_USERNAME_HEADER | Header containing username to log in | Optional | | TA_AUTH_PROXY_USERNAME_HEADER | Header containing username to log in | Optional |
| TA_AUTH_PROXY_LOGOUT_URL | Logout URL for forwarded auth | Optional | | TA_AUTH_PROXY_LOGOUT_URL | Logout URL for forwarded auth | Optional |
@@ -67,7 +67,6 @@ Take a look at the example [docker-compose.yml](https://github.com/tubearchivist
| HOST_GID | Allow TA to own the video files instead of container user | Optional | | HOST_GID | Allow TA to own the video files instead of container user | Optional |
| HOST_UID | Allow TA to own the video files instead of container user | Optional | | HOST_UID | Allow TA to own the video files instead of container user | Optional |
| ELASTIC_USER | Change the default ElasticSearch user | Optional | | ELASTIC_USER | Change the default ElasticSearch user | Optional |
| REDIS_PORT | Port that Redis runs on | Optional |
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) | | TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
| ENABLE_CAST | Enable casting support | [Read more](https://docs.tubearchivist.com/configuration/cast/) | | ENABLE_CAST | Enable casting support | [Read more](https://docs.tubearchivist.com/configuration/cast/) |
| DJANGO_DEBUG | Return additional error messages, for debug only | | | DJANGO_DEBUG | Return additional error messages, for debug only | |

86
backend/README.md Normal file
View File

@@ -0,0 +1,86 @@
# Django Setup
## Apps
The backend is split up into the following apps.
### config
Root Django App. Doesn't define any views.
- Has main `settings.py`
- Has main `urls.py` responsible for routing to other apps
### common
Functionality shared between apps.
Defines views on the root `/api/*` path. Has base views to inherit from.
- Connections to ES and Redis
- Searching
- URL parser
- Collection of helper functions
### appsettings
Responsible for functionality from the settings pages.
Defines views at `/api/appsettings/*`.
- Index setup
- Reindexing
- Snapshots
- Filesystem Scan
- Manual import
### channel
Responsible for Channel Indexing functionality.
Defines views at `/api/channel/*` path.
### download
Implements download functionality with yt-dlp.
Defines views at `/api/download/*`.
- Download videos
- Queue management
- Thumbnails
- Subscriptions
### playlist
Implements playlist functionality.
Defines views at `/api/playlist/*`.
- Index Playlists
- Manual Playlists
### stats
Builds aggregations views for the statistics dashboard.
Defines views at `/api/stats/*`.
### task
Defines tasks for Celery.
Defines views at `/api/task/*`.
- Has main `tasks.py` with all shared_task definitions
- Has `CustomPeriodicTask` model
- Implements apprise notifications links
- Implements schedule functionality
### user
Implements user and auth functionality.
Defines views at `/api/config/*`.
- Defines custom `Account` model
### video
Index functionality for videos.
Defines views at `/api/video/*`.
- Index videos
- Index comments
- Index/download subtitles
- Media stream parsing

View File

@@ -10,11 +10,10 @@ import os
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from home.models import CustomPeriodicTask from common.src.env_settings import EnvironmentSettings
from home.src.es.connect import ElasticWrap, IndexPaginate from common.src.es_connect import ElasticWrap, IndexPaginate
from home.src.ta.config import AppConfig from common.src.helper import get_mapping, ignore_filelist
from home.src.ta.helper import get_mapping, ignore_filelist from task.models import CustomPeriodicTask
from home.src.ta.settings import EnvironmentSettings
class ElasticBackup: class ElasticBackup:
@@ -24,8 +23,7 @@ class ElasticBackup:
CACHE_DIR = EnvironmentSettings.CACHE_DIR CACHE_DIR = EnvironmentSettings.CACHE_DIR
BACKUP_DIR = os.path.join(CACHE_DIR, "backup") BACKUP_DIR = os.path.join(CACHE_DIR, "backup")
def __init__(self, reason=False, task=False): def __init__(self, reason=False, task=False) -> None:
self.config = AppConfig().config
self.timestamp = datetime.now().strftime("%Y%m%d") self.timestamp = datetime.now().strftime("%Y%m%d")
self.index_config = get_mapping() self.index_config = get_mapping()
self.reason = reason self.reason = reason
@@ -139,6 +137,8 @@ class ElasticBackup:
elif len(file_split) == 3: elif len(file_split) == 3:
timestamp = file_split[1] timestamp = file_split[1]
reason = file_split[2].strip(".zip") reason = file_split[2].strip(".zip")
else:
raise ValueError
data = { data = {
"filename": filename, "filename": filename,

View File

@@ -0,0 +1,253 @@
"""
Functionality:
- read and write config
- load config variables into redis
"""
from random import randint
from time import sleep
from typing import Literal, TypedDict
import requests
from appsettings.src.snapshot import ElasticSnapshot
from common.src.es_connect import ElasticWrap
from common.src.ta_redis import RedisArchivist
from django.conf import settings
class SubscriptionsConfigType(TypedDict):
"""describes subscriptions config"""
channel_size: int
live_channel_size: int
shorts_channel_size: int
auto_start: bool
class DownloadsConfigType(TypedDict):
"""describes downloads config"""
limit_speed: int | None
sleep_interval: int | None
autodelete_days: int | None
format: str | None
format_sort: str | None
add_metadata: bool
add_thumbnail: bool
subtitle: str | None
subtitle_source: Literal["user", "auto"] | None
subtitle_index: bool
comment_max: str | None
comment_sort: Literal["top", "new"] | None
cookie_import: bool
potoken: bool
throttledratelimit: int | None
extractor_lang: str | None
integrate_ryd: bool
integrate_sponsorblock: bool
class ApplicationConfigType(TypedDict):
"""describes application config"""
enable_snapshot: bool
class AppConfigType(TypedDict):
"""combined app config type"""
subscriptions: SubscriptionsConfigType
downloads: DownloadsConfigType
application: ApplicationConfigType
class AppConfig:
"""handle application variables"""
ES_PATH = "ta_config/_doc/appsettings"
ES_UPDATE_PATH = "ta_config/_update/appsettings"
CONFIG_DEFAULTS: AppConfigType = {
"subscriptions": {
"channel_size": 50,
"live_channel_size": 50,
"shorts_channel_size": 50,
"auto_start": False,
},
"downloads": {
"limit_speed": None,
"sleep_interval": 10,
"autodelete_days": None,
"format": None,
"format_sort": None,
"add_metadata": False,
"add_thumbnail": False,
"subtitle": None,
"subtitle_source": None,
"subtitle_index": False,
"comment_max": None,
"comment_sort": "top",
"cookie_import": False,
"potoken": False,
"throttledratelimit": None,
"extractor_lang": None,
"integrate_ryd": False,
"integrate_sponsorblock": False,
},
"application": {"enable_snapshot": True},
}
def __init__(self):
self.config = self.get_config()
def get_config(self) -> AppConfigType:
"""get config from ES"""
response, status_code = ElasticWrap(self.ES_PATH).get()
if not status_code == 200:
raise ValueError(f"no config found at {self.ES_PATH}")
return response["_source"]
def update_config(self, data: dict) -> AppConfigType:
"""update single config value"""
for key, value in data.items():
key_map = key.split(".")
self._validate_key(key_map)
self.config[key_map[0]][key_map[1]] = value
response, status_code = ElasticWrap(self.ES_PATH).post(self.config)
if not status_code == 200:
print(response)
return self.config
def _update_config_dict(self, to_update) -> None:
"""none validated partial update for defaults sync"""
data = {"doc": to_update}
response, status_code = ElasticWrap(self.ES_UPDATE_PATH).post(data)
if not status_code == 200:
print(f"update failed: {response}, {status_code}")
def _validate_key(self, key_map: list[str]) -> None:
"""raise valueerror on invalid key"""
exists = key_map[1] in self.CONFIG_DEFAULTS.get(key_map[0], {}) # type: ignore # noqa: E501
if exists is None:
raise ValueError(f"trying to access invalid config key: {key_map}")
def post_process_updated(self, data: dict) -> None:
"""apply hooks for some config keys"""
for config_value, updated_value in data:
if config_value == "application.enable_snapshot" and updated_value:
ElasticSnapshot().setup()
@staticmethod
def _fail_message(message_line):
"""notify our failure"""
key = "message:setting"
message = {
"status": key,
"group": "setting:application",
"level": "error",
"title": "Cookie import failed",
"messages": [message_line],
"id": "0000",
}
RedisArchivist().set_message(key, message=message, expire=True)
def sync_defaults(self):
"""sync defaults at startup, needs to be called with __new__"""
return ElasticWrap(self.ES_PATH).post(self.CONFIG_DEFAULTS)
def add_new_defaults(self) -> list[str]:
"""add new default config values to ES, called at startup"""
updated = []
for key, value in self.CONFIG_DEFAULTS.items():
if key not in self.config:
# complete new key
self._update_config_dict({key: value})
updated.append(str({key: value}))
continue
for sub_key, sub_value in value.items(): # type: ignore
if sub_key not in self.config[key]:
# new partial key
to_update = {key: {sub_key: sub_value}}
self._update_config_dict(to_update)
updated.append(str(to_update))
return updated
class ReleaseVersion:
"""compare local version with remote version"""
REMOTE_URL = "https://www.tubearchivist.com/api/release/latest/"
NEW_KEY = "versioncheck:new"
def __init__(self) -> None:
self.local_version: str = settings.TA_VERSION
self.is_unstable: bool = settings.TA_VERSION.endswith("-unstable")
self.remote_version: str = ""
self.is_breaking: bool = False
def check(self) -> None:
"""check version"""
print(f"[{self.local_version}]: look for updates")
self.get_remote_version()
new_version = self._has_update()
if new_version:
message = {
"status": True,
"version": new_version,
"is_breaking": self.is_breaking,
}
RedisArchivist().set_message(self.NEW_KEY, message)
print(f"[{self.local_version}]: found new version {new_version}")
def get_local_version(self) -> str:
"""read version from local"""
return self.local_version
def get_remote_version(self) -> None:
"""read version from remote"""
sleep(randint(0, 60))
response = requests.get(self.REMOTE_URL, timeout=20).json()
self.remote_version = response["release_version"]
self.is_breaking = response["breaking_changes"]
def _has_update(self) -> str | bool:
"""check if there is an update"""
remote_parsed = self._parse_version(self.remote_version)
local_parsed = self._parse_version(self.local_version)
if remote_parsed > local_parsed:
return self.remote_version
if self.is_unstable and local_parsed == remote_parsed:
return self.remote_version
return False
@staticmethod
def _parse_version(version) -> tuple[int, ...]:
"""return version parts"""
clean = version.rstrip("-unstable").lstrip("v")
return tuple((int(i) for i in clean.split(".")))
def is_updated(self) -> str | bool:
"""check if update happened in the mean time"""
message = self.get_update()
if not message:
return False
local_parsed = self._parse_version(self.local_version)
message_parsed = self._parse_version(message.get("version"))
if local_parsed >= message_parsed:
RedisArchivist().del_message(self.NEW_KEY)
return settings.TA_VERSION
return False
def get_update(self) -> dict:
"""return new version dict if available"""
message = RedisArchivist().get_message_dict(self.NEW_KEY)
return message

View File

@@ -5,11 +5,11 @@ Functionality:
import os import os
from home.src.es.connect import ElasticWrap, IndexPaginate from common.src.env_settings import EnvironmentSettings
from home.src.index.comments import CommentList from common.src.es_connect import ElasticWrap, IndexPaginate
from home.src.index.video import YoutubeVideo, index_new_video from common.src.helper import ignore_filelist
from home.src.ta.helper import ignore_filelist from video.src.comments import CommentList
from home.src.ta.settings import EnvironmentSettings from video.src.index import YoutubeVideo, index_new_video
class Scanner: class Scanner:

View File

@@ -5,11 +5,11 @@ functionality:
- backup and restore metadata - backup and restore metadata
""" """
from home.src.es.backup import ElasticBackup from appsettings.src.backup import ElasticBackup
from home.src.es.connect import ElasticWrap from appsettings.src.config import AppConfig
from home.src.es.snapshot import ElasticSnapshot from appsettings.src.snapshot import ElasticSnapshot
from home.src.ta.config import AppConfig from common.src.es_connect import ElasticWrap
from home.src.ta.helper import get_mapping from common.src.helper import get_mapping
class ElasticIndex: class ElasticIndex:
@@ -111,6 +111,8 @@ class ElasticIndex:
elif method == "restore": elif method == "restore":
source = f"ta_{self.index_name}_backup" source = f"ta_{self.index_name}_backup"
destination = f"ta_{self.index_name}" destination = f"ta_{self.index_name}"
else:
raise ValueError("invalid method, expected 'backup' or 'restore'")
data = {"source": {"index": source}, "dest": {"index": destination}} data = {"source": {"index": source}, "dest": {"index": destination}}
_, _ = ElasticWrap("_reindex?refresh=true").post(data=data) _, _ = ElasticWrap("_reindex?refresh=true").post(data=data)

View File

@@ -11,13 +11,13 @@ import re
import shutil import shutil
import subprocess import subprocess
from home.src.download.thumbnails import ThumbManager from appsettings.src.config import AppConfig
from home.src.index.comments import CommentList from common.src.env_settings import EnvironmentSettings
from home.src.index.video import YoutubeVideo from common.src.helper import ignore_filelist
from home.src.ta.config import AppConfig from download.src.thumbnails import ThumbManager
from home.src.ta.helper import ignore_filelist
from home.src.ta.settings import EnvironmentSettings
from PIL import Image from PIL import Image
from video.src.comments import CommentList
from video.src.index import YoutubeVideo
from yt_dlp.utils import ISO639Utils from yt_dlp.utils import ISO639Utils
@@ -28,7 +28,6 @@ class ImportFolderScanner:
- convert if needed - convert if needed
""" """
CONFIG = AppConfig().config
CACHE_DIR = EnvironmentSettings.CACHE_DIR CACHE_DIR = EnvironmentSettings.CACHE_DIR
IMPORT_DIR = os.path.join(CACHE_DIR, "import") IMPORT_DIR = os.path.join(CACHE_DIR, "import")
@@ -129,6 +128,7 @@ class ImportFolderScanner:
def process_videos(self): def process_videos(self):
"""loop through all videos""" """loop through all videos"""
config = AppConfig().config
for idx, current_video in enumerate(self.to_import): for idx, current_video in enumerate(self.to_import):
if not current_video["media"]: if not current_video["media"]:
print(f"{current_video}: no matching media file found.") print(f"{current_video}: no matching media file found.")
@@ -144,7 +144,7 @@ class ImportFolderScanner:
self._convert_video(current_video) self._convert_video(current_video)
print(f"manual import: {current_video}") print(f"manual import: {current_video}")
ManualImport(current_video, self.CONFIG).run() ManualImport(current_video, config).run()
video_ids = [i["video_id"] for i in self.to_import] video_ids = [i["video_id"] for i in self.to_import]
comment_list = CommentList(task=self.task) comment_list = CommentList(task=self.task)
@@ -407,8 +407,11 @@ class ManualImport:
media_path=self.current_video["media"], media_path=self.current_video["media"],
) )
if not video.json_data: if not video.json_data:
print(f"{video_id}: manual import failed, and no metadata found.") message = (
raise ValueError f"{video_id}: manual import failed, and no metadata found."
)
print(message)
raise ValueError(message)
video.check_subtitles(subtitle_files=self.current_video["subtitle"]) video.check_subtitles(subtitle_files=self.current_video["subtitle"])
video.upload_to_es() video.upload_to_es()

View File

@@ -7,21 +7,21 @@ functionality:
import json import json
import os import os
from datetime import datetime from datetime import datetime
from time import sleep
from typing import Callable, TypedDict from typing import Callable, TypedDict
from home.models import CustomPeriodicTask from appsettings.src.config import AppConfig
from home.src.download.subscriptions import ChannelSubscription from channel.src.index import YoutubeChannel
from home.src.download.thumbnails import ThumbManager from common.src.env_settings import EnvironmentSettings
from home.src.download.yt_dlp_base import CookieHandler from common.src.es_connect import ElasticWrap, IndexPaginate
from home.src.es.connect import ElasticWrap, IndexPaginate from common.src.helper import rand_sleep
from home.src.index.channel import YoutubeChannel from common.src.ta_redis import RedisQueue
from home.src.index.comments import Comments from download.src.subscriptions import ChannelSubscription
from home.src.index.playlist import YoutubePlaylist from download.src.thumbnails import ThumbManager
from home.src.index.video import YoutubeVideo from download.src.yt_dlp_base import CookieHandler
from home.src.ta.config import AppConfig from playlist.src.index import YoutubePlaylist
from home.src.ta.settings import EnvironmentSettings from task.models import CustomPeriodicTask
from home.src.ta.ta_redis import RedisQueue from video.src.comments import Comments
from video.src.index import YoutubeVideo
class ReindexConfigType(TypedDict): class ReindexConfigType(TypedDict):
@@ -289,8 +289,7 @@ class Reindex(ReindexBase):
self._notify(name, total, idx) self._notify(name, total, idx)
reindex(youtube_id) reindex(youtube_id)
sleep_interval = self.config["downloads"].get("sleep_interval", 0) rand_sleep(self.config)
sleep(sleep_interval)
def _get_reindex_map(self, index_name: str) -> Callable: def _get_reindex_map(self, index_name: str) -> Callable:
"""return def to run for index""" """return def to run for index"""

View File

@@ -7,9 +7,9 @@ from datetime import datetime
from time import sleep from time import sleep
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from home.src.es.connect import ElasticWrap from common.src.env_settings import EnvironmentSettings
from home.src.ta.helper import get_mapping from common.src.es_connect import ElasticWrap
from home.src.ta.settings import EnvironmentSettings from common.src.helper import get_mapping
class ElasticSnapshot: class ElasticSnapshot:
@@ -150,7 +150,7 @@ class ElasticSnapshot:
if statuscode == 200: if statuscode == 200:
print(f"snapshot: executing now: {response}") print(f"snapshot: executing now: {response}")
if wait: if wait and "snapshot_name" in response:
self._wait_for_snapshot(response["snapshot_name"]) self._wait_for_snapshot(response["snapshot_name"])
return response return response
@@ -251,10 +251,9 @@ class ElasticSnapshot:
@staticmethod @staticmethod
def _date_converter(date_utc): def _date_converter(date_utc):
"""convert datetime string""" """convert datetime string"""
expected_format = "%Y-%m-%dT%H:%M:%S.%fZ" date = datetime.strptime(date_utc, "%Y-%m-%dT%H:%M:%S.%fZ")
date = datetime.strptime(date_utc, expected_format) utc_date = date.replace(tzinfo=ZoneInfo("UTC"))
local_datetime = date.replace(tzinfo=ZoneInfo("localtime")) converted = utc_date.astimezone(ZoneInfo(EnvironmentSettings.TZ))
converted = local_datetime.astimezone(ZoneInfo(EnvironmentSettings.TZ))
converted_str = converted.strftime("%Y-%m-%d %H:%M") converted_str = converted.strftime("%Y-%m-%d %H:%M")
return converted_str return converted_str

View File

@@ -0,0 +1,47 @@
"""all app settings API urls"""
from appsettings import views
from django.urls import path
urlpatterns = [
path(
"config/",
views.AppConfigApiView.as_view(),
name="api-config",
),
path(
"snapshot/",
views.SnapshotApiListView.as_view(),
name="api-snapshot-list",
),
path(
"snapshot/<slug:snapshot_id>/",
views.SnapshotApiView.as_view(),
name="api-snapshot",
),
path(
"backup/",
views.BackupApiListView.as_view(),
name="api-backup-list",
),
path(
"backup/<str:filename>/",
views.BackupApiView.as_view(),
name="api-backup",
),
path(
"cookie/",
views.CookieView.as_view(),
name="api-cookie",
),
path(
"potoken/",
views.POTokenView.as_view(),
name="api-potoken",
),
path(
"token/",
views.TokenView.as_view(),
name="api-token",
),
]

View File

@@ -0,0 +1,303 @@
"""all app settings API views"""
from appsettings.src.backup import ElasticBackup
from appsettings.src.config import AppConfig
from appsettings.src.snapshot import ElasticSnapshot
from common.src.ta_redis import RedisArchivist
from common.views_base import AdminOnly, ApiBaseView
from download.src.yt_dlp_base import CookieHandler, POTokenHandler
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
from task.src.task_manager import TaskCommand
from task.tasks import run_restore_backup
class AppConfigApiView(ApiBaseView):
"""resolves to /api/appsettings/config/
GET: return app settings
POST: update app settings
"""
permission_classes = [AdminOnly]
@staticmethod
def get(request):
"""get config"""
response = AppConfig().config
return Response(response)
@staticmethod
def post(request):
"""
update config values
data object where key is flatted CONFIG_DEFAULTS separated by '.', e.g.
{"subscriptions.channel_size": 5, "subscriptions.live_channel_size": 5}
"""
data = request.data
try:
config = AppConfig().update_config(data)
except ValueError as err:
return Response({"error": str(err)}, status=400)
return Response(config)
class SnapshotApiListView(ApiBaseView):
"""resolves to /api/appsettings/snapshot/
GET: returns snapshot config plus list of existing snapshots
POST: take snapshot now
"""
permission_classes = [AdminOnly]
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
snapshots = ElasticSnapshot().get_snapshot_stats()
return Response(snapshots)
@staticmethod
def post(request):
"""take snapshot now with post request"""
# pylint: disable=unused-argument
response = ElasticSnapshot().take_snapshot_now()
return Response(response)
class SnapshotApiView(ApiBaseView):
"""resolves to /api/appsettings/snapshot/<snapshot-id>/
GET: return a single snapshot
POST: restore snapshot
DELETE: delete a snapshot
"""
permission_classes = [AdminOnly]
@staticmethod
def get(request, snapshot_id):
"""handle get request"""
# pylint: disable=unused-argument
snapshot = ElasticSnapshot().get_single_snapshot(snapshot_id)
if not snapshot:
return Response({"message": "snapshot not found"}, status=404)
return Response(snapshot)
@staticmethod
def post(request, snapshot_id):
"""restore snapshot with post request"""
# pylint: disable=unused-argument
response = ElasticSnapshot().restore_all(snapshot_id)
if not response:
message = {"message": "failed to restore snapshot"}
return Response(message, status=400)
return Response(response)
@staticmethod
def delete(request, snapshot_id):
"""delete snapshot from index"""
# pylint: disable=unused-argument
response = ElasticSnapshot().delete_single_snapshot(snapshot_id)
if not response:
message = {"message": "failed to delete snapshot"}
return Response(message, status=400)
return Response(response)
class BackupApiListView(ApiBaseView):
"""resolves to /api/appsettings/backup/
GET: returns list of available zip backups
POST: take zip backup now
"""
permission_classes = [AdminOnly]
task_name = "run_backup"
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
backup_files = ElasticBackup().get_all_backup_files()
return Response(backup_files)
def post(self, request):
"""handle post request"""
# pylint: disable=unused-argument
response = TaskCommand().start(self.task_name)
message = {
"message": "backup task started",
"task_id": response["task_id"],
}
return Response(message)
class BackupApiView(ApiBaseView):
"""resolves to /api/appsettings/backup/<filename>/
GET: return a single backup
POST: restore backup
DELETE: delete backup
"""
permission_classes = [AdminOnly]
task_name = "restore_backup"
@staticmethod
def get(request, filename):
"""get single backup"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().build_backup_file_data(filename)
if not backup_file:
message = {"message": "file not found"}
return Response(message, status=404)
return Response(backup_file)
def post(self, request, filename):
"""restore backup file"""
# pylint: disable=unused-argument
task = run_restore_backup.delay(filename)
message = {
"message": "backup restore task started",
"filename": filename,
"task_id": task.id,
}
return Response(message)
@staticmethod
def delete(request, filename):
"""delete backup file"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().delete_file(filename)
if not backup_file:
message = {"message": "file not found"}
return Response(message, status=404)
message = {"message": f"file {filename} deleted"}
return Response(message)
class CookieView(ApiBaseView):
"""resolves to /api/appsettings/cookie/
GET: check if cookie is enabled
POST: verify validity of cookie
PUT: import cookie
DELETE: revoke the cookie
"""
permission_classes = [AdminOnly]
def get(self, request):
"""handle get request"""
# pylint: disable=unused-argument
validation = self._get_cookie_validation()
return Response(validation)
def post(self, request):
"""handle cookie validation request"""
# pylint: disable=unused-argument
config = AppConfig().config
_ = CookieHandler(config).validate()
validation = self._get_cookie_validation()
return Response(validation)
def put(self, request):
"""handle put request"""
# pylint: disable=unused-argument
config = AppConfig().config
cookie = request.data.get("cookie")
if not cookie:
message = "missing cookie key in request data"
print(message)
return Response({"message": message}, status=400)
print(f"cookie preview:\n\n{cookie[:300]}")
handler = CookieHandler(config)
handler.set_cookie(cookie)
validated = handler.validate()
if not validated:
handler.revoke()
print("cookie import failed, not valid")
status = 400
else:
status = 200
validation = self._get_cookie_validation()
return Response(validation, status=status)
def delete(self, request):
"""delete the cookie"""
config = AppConfig().config
handler = CookieHandler(config)
handler.revoke()
return Response({"cookie_enabled": False})
@staticmethod
def _get_cookie_validation():
"""get current cookie validation"""
config = AppConfig().config
validation = RedisArchivist().get_message_dict("cookie:valid")
is_enabled = {"cookie_enabled": config["downloads"]["cookie_import"]}
validation.update(is_enabled)
return validation
class POTokenView(ApiBaseView):
"""handle PO token"""
permission_classes = [AdminOnly]
def get(self, request):
"""get token"""
config = AppConfig().config
potoken = POTokenHandler(config).get()
return Response({"potoken": potoken})
def post(self, request):
"""post token"""
config = AppConfig().config
new_token = request.data.get("potoken")
if not new_token:
message = "missing potoken key in request data"
print(message)
return Response({"message": message}, status=400)
POTokenHandler(config).set_token(new_token)
return Response({"potoken": new_token})
def delete(self, request):
"""delete token"""
config = AppConfig().config
POTokenHandler(config).revoke_token()
return Response({"potoken": None})
class TokenView(ApiBaseView):
"""resolves to /api/appsettings/token/
DELETE: revoke the token
"""
permission_classes = [AdminOnly]
@staticmethod
def get(request):
"""get token"""
token, _ = Token.objects.get_or_create(user=request.user)
return Response({"token": token.key})
@staticmethod
def delete(request):
"""delete the token, new will get created automatically"""
print("revoke API token")
request.user.auth_token.delete()
return Response({"success": True})

View File

@@ -8,12 +8,12 @@ import json
import os import os
from datetime import datetime from datetime import datetime
from home.src.download.thumbnails import ThumbManager from common.src.env_settings import EnvironmentSettings
from home.src.download.yt_dlp_base import YtWrap from common.src.es_connect import ElasticWrap, IndexPaginate
from home.src.es.connect import ElasticWrap, IndexPaginate from common.src.index_generic import YouTubeItem
from home.src.index.generic import YouTubeItem from download.src.thumbnails import ThumbManager
from home.src.index.playlist import YoutubePlaylist from download.src.yt_dlp_base import YtWrap
from home.src.ta.settings import EnvironmentSettings from playlist.src.index import YoutubePlaylist
class YoutubeChannel(YouTubeItem): class YoutubeChannel(YouTubeItem):
@@ -42,6 +42,10 @@ class YoutubeChannel(YouTubeItem):
if not self.youtube_meta and fallback: if not self.youtube_meta and fallback:
self._video_fallback(fallback) self._video_fallback(fallback)
else: else:
if not self.json_data:
message = f"{self.youtube_id}: Failed to get metadata"
raise ValueError(message)
self.process_youtube_meta() self.process_youtube_meta()
self.get_channel_art() self.get_channel_art()
@@ -59,21 +63,13 @@ class YoutubeChannel(YouTubeItem):
"channel_name": self.youtube_meta["uploader"], "channel_name": self.youtube_meta["uploader"],
"channel_subs": self.youtube_meta.get("channel_follower_count", 0), "channel_subs": self.youtube_meta.get("channel_follower_count", 0),
"channel_subscribed": False, "channel_subscribed": False,
"channel_tags": self._parse_tags(self.youtube_meta.get("tags")), "channel_tags": self.youtube_meta.get("tags", []),
"channel_banner_url": self._get_banner_art(), "channel_banner_url": self._get_banner_art(),
"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,
} }
def _parse_tags(self, tags):
"""parse channel tags"""
if not tags:
return False
joined = " ".join(tags)
return [i.strip() for i in joined.split('"') if i and not i == " "]
def _get_thumb_art(self): def _get_thumb_art(self):
"""extract thumb art""" """extract thumb art"""
for i in self.youtube_meta["thumbnails"]: for i in self.youtube_meta["thumbnails"]:
@@ -290,6 +286,9 @@ class YoutubeChannel(YouTubeItem):
) )
obs = {"skip_download": True, "extract_flat": True} obs = {"skip_download": True, "extract_flat": True}
playlists = YtWrap(obs, self.config).extract(url) playlists = YtWrap(obs, self.config).extract(url)
if not playlists:
return
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]] all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
self.all_playlists = all_entries self.all_playlists = all_entries
@@ -326,22 +325,12 @@ class YoutubeChannel(YouTubeItem):
for key, value in overwrites.items(): for key, value in overwrites.items():
if key not in valid_keys: if key not in valid_keys:
raise ValueError(f"invalid overwrite key: {key}") raise ValueError(f"invalid overwrite key: {key}")
elif value == "disable":
to_write[key] = False if value is None and key in to_write:
to_write.pop(key)
continue continue
elif value == "0":
if key in to_write: to_write.update({key: value})
del to_write[key]
continue
elif value == "1":
to_write[key] = True
continue
elif isinstance(value, int) and int(value) < 0:
if key in to_write:
del to_write[key]
continue
elif value is not None and value != "":
to_write.update({key: value})
self.json_data["channel_overwrites"] = to_write self.json_data["channel_overwrites"] = to_write
@@ -353,3 +342,5 @@ def channel_overwrites(channel_id, overwrites):
channel.set_overwrites(overwrites) channel.set_overwrites(overwrites)
channel.upload_to_es() channel.upload_to_es()
channel.sync_to_videos() channel.sync_to_videos()
return channel.json_data

View File

@@ -0,0 +1,78 @@
"""build channel nav"""
from common.src.es_connect import ElasticWrap
class ChannelNav:
"""get all nav items"""
def __init__(self, channel_id):
self.channel_id = channel_id
def get_nav(self):
"""build nav items"""
nav = {
"has_pending": self._get_has_pending(),
"has_playlists": self._get_has_playlists(),
}
nav.update(self._get_vid_types())
return nav
def _get_vid_types(self):
"""get available vid_types in given channel"""
data = {
"size": 0,
"query": {
"term": {"channel.channel_id": {"value": self.channel_id}}
},
"aggs": {"unique_values": {"terms": {"field": "vid_type"}}},
}
response, _ = ElasticWrap("ta_video/_search").get(data)
buckets = response["aggregations"]["unique_values"]["buckets"]
type_nav = {
"has_videos": False,
"has_streams": False,
"has_shorts": False,
}
for bucket in buckets:
if bucket["key"] == "videos":
type_nav["has_videos"] = True
if bucket["key"] == "streams":
type_nav["has_streams"] = True
if bucket["key"] == "shorts":
type_nav["has_shorts"] = True
return type_nav
def _get_has_pending(self):
"""check if has pending videos in download queue"""
data = {
"size": 1,
"query": {
"bool": {
"must": [
{"term": {"status": {"value": "pending"}}},
{"term": {"channel_id": {"value": self.channel_id}}},
]
}
},
"_source": False,
}
response, _ = ElasticWrap("ta_download/_search").get(data=data)
return bool(response["hits"]["hits"])
def _get_has_playlists(self):
"""check if channel has playlists"""
path = "ta_playlist/_search"
data = {
"size": 1,
"query": {
"term": {"playlist_channel_id": {"value": self.channel_id}}
},
"_source": False,
}
response, _ = ElasticWrap(path).get(data=data)
return bool(response["hits"]["hits"])

32
backend/channel/urls.py Normal file
View File

@@ -0,0 +1,32 @@
"""all channel API urls"""
from channel import views
from django.urls import path
urlpatterns = [
path(
"",
views.ChannelApiListView.as_view(),
name="api-channel-list",
),
path(
"search/",
views.ChannelApiSearchView.as_view(),
name="api-channel-search",
),
path(
"<slug:channel_id>/",
views.ChannelApiView.as_view(),
name="api-channel",
),
path(
"<slug:channel_id>/aggs/",
views.ChannelAggsApiView.as_view(),
name="api-channel-aggs",
),
path(
"<slug:channel_id>/nav/",
views.ChannelNavApiView.as_view(),
name="api-channel-nav",
),
]

198
backend/channel/views.py Normal file
View File

@@ -0,0 +1,198 @@
"""all channel API views"""
from channel.src.index import YoutubeChannel, channel_overwrites
from channel.src.nav import ChannelNav
from common.src.urlparser import Parser
from common.views_base import AdminWriteOnly, ApiBaseView
from download.src.subscriptions import ChannelSubscription
from rest_framework.response import Response
from task.tasks import index_channel_playlists, subscribe_to
class ChannelApiListView(ApiBaseView):
"""resolves to /api/channel/
GET: returns list of channels
POST: edit a list of channels
"""
search_base = "ta_channel/_search/"
valid_filter = ["subscribed"]
permission_classes = [AdminWriteOnly]
def get(self, request):
"""get request"""
self.data.update(
{"sort": [{"channel_name.keyword": {"order": "asc"}}]}
)
query_filter = request.GET.get("filter", False)
must_list = []
if query_filter:
if query_filter not in self.valid_filter:
message = f"invalid url query filter: {query_filter}"
print(message)
return Response({"message": message}, status=400)
must_list.append({"term": {"channel_subscribed": {"value": True}}})
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
return Response(self.response)
def post(self, request):
"""subscribe/unsubscribe to list of channels"""
data = request.data
try:
to_add = data["data"]
except KeyError:
message = "missing expected data key"
print(message)
return Response({"message": message}, status=400)
pending = []
for channel_item in to_add:
channel_id = channel_item["channel_id"]
if channel_item["channel_subscribed"]:
pending.append(channel_id)
else:
self._unsubscribe(channel_id)
if pending:
url_str = " ".join(pending)
subscribe_to.delay(url_str, expected_type="channel")
return Response(data)
@staticmethod
def _unsubscribe(channel_id: str):
"""unsubscribe"""
print(f"[{channel_id}] unsubscribe from channel")
ChannelSubscription().change_subscribe(
channel_id, channel_subscribed=False
)
class ChannelApiView(ApiBaseView):
"""resolves to /api/channel/<channel_id>/
GET: returns metadata dict of channel
"""
search_base = "ta_channel/_doc/"
permission_classes = [AdminWriteOnly]
def get(self, request, channel_id):
# pylint: disable=unused-argument
"""get request"""
self.get_document(channel_id)
return Response(self.response, status=self.status_code)
def post(self, request, channel_id):
"""modify channel overwrites"""
self.get_document(channel_id)
if not self.response["data"]:
return Response({"error": "channel not found"}, status=404)
data = request.data
subscribed = data.get("channel_subscribed")
if subscribed is not None:
channel_sub = ChannelSubscription()
json_data = channel_sub.change_subscribe(channel_id, subscribed)
return Response(json_data, status=200)
if "channel_overwrites" not in data:
return Response({"error": "invalid payload"}, status=400)
overwrites = data["channel_overwrites"]
try:
json_data = channel_overwrites(channel_id, overwrites)
if overwrites.get("index_playlists"):
index_channel_playlists.delay(channel_id)
except ValueError as err:
return Response({"error": str(err)}, status=400)
return Response(json_data, status=200)
def delete(self, request, channel_id):
# pylint: disable=unused-argument
"""delete channel"""
message = {"channel": channel_id}
try:
YoutubeChannel(channel_id).delete_channel()
status_code = 200
message.update({"state": "delete"})
except FileNotFoundError:
status_code = 404
message.update({"state": "not found"})
return Response(message, status=status_code)
class ChannelAggsApiView(ApiBaseView):
"""resolves to /api/channel/<channel_id>/aggs/
GET: get channel aggregations
"""
search_base = "ta_video/_search"
def get(self, request, channel_id):
"""get aggs"""
self.data.update(
{
"query": {
"term": {"channel.channel_id": {"value": channel_id}}
},
"aggs": {
"total_items": {"value_count": {"field": "youtube_id"}},
"total_size": {"sum": {"field": "media_size"}},
"total_duration": {"sum": {"field": "player.duration"}},
},
}
)
self.get_aggs()
return Response(self.response)
class ChannelNavApiView(ApiBaseView):
"""resolves to /api/channel/<channel_id>/nav/
GET: get channel nav
"""
def get(self, request, channel_id):
"""get nav"""
nav = ChannelNav(channel_id).get_nav()
return Response(nav)
class ChannelApiSearchView(ApiBaseView):
"""resolves to /api/channel/search/
search for channel
"""
search_base = "ta_channel/_doc/"
def get(self, request):
"""handle get request, search with s parameter"""
query = request.GET.get("q")
if not query:
message = "missing expected q parameter"
return Response({"message": message, "data": False}, status=400)
try:
parsed = Parser(query).parse()[0]
except (ValueError, IndexError, AttributeError):
message = f"channel not found: {query}"
return Response({"message": message, "data": False}, status=404)
if not parsed["type"] == "channel":
message = "expected type channel"
return Response({"message": message, "data": False}, status=400)
self.get_document(parsed["url"])
return Response(self.response, status=self.status_code)

View File

@@ -6,6 +6,14 @@ Functionality:
from os import environ from os import environ
try:
from dotenv import load_dotenv
print("loading local dotenv")
load_dotenv(".env")
except ModuleNotFoundError:
pass
class EnvironmentSettings: class EnvironmentSettings:
""" """
@@ -19,7 +27,7 @@ class EnvironmentSettings:
ENABLE_CAST: bool = bool(environ.get("ENABLE_CAST")) ENABLE_CAST: bool = bool(environ.get("ENABLE_CAST"))
TZ: str = str(environ.get("TZ", "UTC")) TZ: str = str(environ.get("TZ", "UTC"))
TA_PORT: int = int(environ.get("TA_PORT", False)) TA_PORT: int = int(environ.get("TA_PORT", False))
TA_UWSGI_PORT: int = int(environ.get("TA_UWSGI_PORT", False)) TA_BACKEND_PORT: int = int(environ.get("TA_BACKEND_PORT", False))
TA_USERNAME: str = str(environ.get("TA_USERNAME")) TA_USERNAME: str = str(environ.get("TA_USERNAME"))
TA_PASSWORD: str = str(environ.get("TA_PASSWORD")) TA_PASSWORD: str = str(environ.get("TA_PASSWORD"))
@@ -29,8 +37,7 @@ class EnvironmentSettings:
CACHE_DIR: str = str(environ.get("TA_CACHE_DIR", "/cache")) CACHE_DIR: str = str(environ.get("TA_CACHE_DIR", "/cache"))
# Redis # Redis
REDIS_HOST: str = str(environ.get("REDIS_HOST")) REDIS_CON: str = str(environ.get("REDIS_CON"))
REDIS_PORT: int = int(environ.get("REDIS_PORT", 6379))
REDIS_NAME_SPACE: str = str(environ.get("REDIS_NAME_SPACE", "ta:")) REDIS_NAME_SPACE: str = str(environ.get("REDIS_NAME_SPACE", "ta:"))
# ElasticSearch # ElasticSearch
@@ -44,6 +51,20 @@ class EnvironmentSettings:
) )
ES_DISABLE_VERIFY_SSL: bool = bool(environ.get("ES_DISABLE_VERIFY_SSL")) ES_DISABLE_VERIFY_SSL: bool = bool(environ.get("ES_DISABLE_VERIFY_SSL"))
def get_cache_root(self):
"""get root for web server"""
if self.CACHE_DIR.startswith("/"):
return self.CACHE_DIR
return f"/{self.CACHE_DIR}"
def get_media_root(self):
"""get root for media folder"""
if self.MEDIA_DIR.startswith("/"):
return self.MEDIA_DIR
return f"/{self.MEDIA_DIR}"
def print_generic(self): def print_generic(self):
"""print generic env vars""" """print generic env vars"""
print( print(
@@ -53,7 +74,7 @@ class EnvironmentSettings:
TZ: {self.TZ} TZ: {self.TZ}
ENABLE_CAST: {self.ENABLE_CAST} ENABLE_CAST: {self.ENABLE_CAST}
TA_PORT: {self.TA_PORT} TA_PORT: {self.TA_PORT}
TA_UWSGI_PORT: {self.TA_UWSGI_PORT} TA_BACKEND_PORT: {self.TA_BACKEND_PORT}
TA_USERNAME: {self.TA_USERNAME} TA_USERNAME: {self.TA_USERNAME}
TA_PASSWORD: *****""" TA_PASSWORD: *****"""
) )
@@ -71,8 +92,7 @@ class EnvironmentSettings:
"""debug redis conf paths""" """debug redis conf paths"""
print( print(
f""" f"""
REDIS_HOST: {self.REDIS_HOST} REDIS_CON: {self.REDIS_CON}
REDIS_PORT: {self.REDIS_PORT}
REDIS_NAME_SPACE: {self.REDIS_NAME_SPACE}""" REDIS_NAME_SPACE: {self.REDIS_NAME_SPACE}"""
) )

View File

@@ -11,7 +11,7 @@ from typing import Any
import requests import requests
import urllib3 import urllib3
from home.src.ta.settings import EnvironmentSettings from common.src.env_settings import EnvironmentSettings
class ElasticWrap: class ElasticWrap:

View File

@@ -1,265 +1,272 @@
""" """
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
from typing import Any from time import sleep
from urllib.parse import urlparse from typing import Any
from urllib.parse import urlparse
import requests
from home.src.es.connect import IndexPaginate import requests
from home.src.ta.settings import EnvironmentSettings 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 requests_headers() -> dict[str, str]: def rand_sleep(config) -> None:
"""build header with random user agent for requests outside of yt-dlp""" """randomized sleep based on config"""
sleep_config = config["downloads"].get("sleep_interval")
chrome_versions = ( if not sleep_config:
"90.0.4430.212", return
"90.0.4430.24",
"90.0.4430.70", secs = random.randrange(int(sleep_config * 0.5), int(sleep_config * 1.5))
"90.0.4430.72", sleep(secs)
"90.0.4430.85",
"90.0.4430.93",
"91.0.4472.101", def requests_headers() -> dict[str, str]:
"91.0.4472.106", """build header with random user agent for requests outside of yt-dlp"""
"91.0.4472.114",
"91.0.4472.124", chrome_versions = (
"91.0.4472.164", "90.0.4430.212",
"91.0.4472.19", "90.0.4430.24",
"91.0.4472.77", "90.0.4430.70",
"92.0.4515.107", "90.0.4430.72",
"92.0.4515.115", "90.0.4430.85",
"92.0.4515.131", "90.0.4430.93",
"92.0.4515.159", "91.0.4472.101",
"92.0.4515.43", "91.0.4472.106",
"93.0.4556.0", "91.0.4472.114",
"93.0.4577.15", "91.0.4472.124",
"93.0.4577.63", "91.0.4472.164",
"93.0.4577.82", "91.0.4472.19",
"94.0.4606.41", "91.0.4472.77",
"94.0.4606.54", "92.0.4515.107",
"94.0.4606.61", "92.0.4515.115",
"94.0.4606.71", "92.0.4515.131",
"94.0.4606.81", "92.0.4515.159",
"94.0.4606.85", "92.0.4515.43",
"95.0.4638.17", "93.0.4556.0",
"95.0.4638.50", "93.0.4577.15",
"95.0.4638.54", "93.0.4577.63",
"95.0.4638.69", "93.0.4577.82",
"95.0.4638.74", "94.0.4606.41",
"96.0.4664.18", "94.0.4606.54",
"96.0.4664.45", "94.0.4606.61",
"96.0.4664.55", "94.0.4606.71",
"96.0.4664.93", "94.0.4606.81",
"97.0.4692.20", "94.0.4606.85",
) "95.0.4638.17",
template = ( "95.0.4638.50",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "95.0.4638.54",
+ "AppleWebKit/537.36 (KHTML, like Gecko) " "95.0.4638.69",
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36" "95.0.4638.74",
) "96.0.4664.18",
"96.0.4664.45",
return {"User-Agent": template} "96.0.4664.55",
"96.0.4664.93",
"97.0.4692.20",
def date_parser(timestamp: int | str) -> str: )
"""return formatted date string""" template = (
if isinstance(timestamp, int): "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
date_obj = datetime.fromtimestamp(timestamp) + "AppleWebKit/537.36 (KHTML, like Gecko) "
elif isinstance(timestamp, str): + f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
date_obj = datetime.strptime(timestamp, "%Y-%m-%d") )
else:
raise TypeError(f"invalid timestamp: {timestamp}") return {"User-Agent": template}
return date_obj.date().isoformat()
def date_parser(timestamp: int | str) -> str:
"""return formatted date string"""
def time_parser(timestamp: str) -> float: if isinstance(timestamp, int):
"""return seconds from timestamp, false on empty""" date_obj = datetime.fromtimestamp(timestamp)
if not timestamp: elif isinstance(timestamp, str):
return False date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
else:
if timestamp.isnumeric(): raise TypeError(f"invalid timestamp: {timestamp}")
return int(timestamp)
return date_obj.date().isoformat()
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
def time_parser(timestamp: str) -> float:
"""return seconds from timestamp, false on empty"""
def clear_dl_cache(cache_dir: str) -> int: if not timestamp:
"""clear leftover files from dl cache""" return False
print("clear download cache")
download_cache_dir = os.path.join(cache_dir, "download") if timestamp.isnumeric():
leftover_files = ignore_filelist(os.listdir(download_cache_dir)) return int(timestamp)
for cached in leftover_files:
to_delete = os.path.join(download_cache_dir, cached) hours, minutes, seconds = timestamp.split(":", maxsplit=3)
os.remove(to_delete) return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
return len(leftover_files)
def clear_dl_cache(cache_dir: str) -> int:
"""clear leftover files from dl cache"""
def get_mapping() -> dict: print("clear download cache")
"""read index_mapping.json and get expected mapping and settings""" download_cache_dir = os.path.join(cache_dir, "download")
with open("home/src/es/index_mapping.json", "r", encoding="utf-8") as f: leftover_files = ignore_filelist(os.listdir(download_cache_dir))
index_config: dict = json.load(f).get("index_config") for cached in leftover_files:
to_delete = os.path.join(download_cache_dir, cached)
return index_config os.remove(to_delete)
return len(leftover_files)
def is_shorts(youtube_id: str) -> bool:
"""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}" def get_mapping() -> dict:
cookies = {"SOCS": "CAI"} """read index_mapping.json and get expected mapping and settings"""
response = requests.head( with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10 index_config: dict = json.load(f).get("index_config")
)
return index_config
return response.status_code == 200
def is_shorts(youtube_id: str) -> bool:
def get_duration_sec(file_path: str) -> int: """check if youtube_id is a shorts video, bot not it it's not a shorts"""
"""get duration of media file from file path""" shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
cookies = {"SOCS": "CAI"}
duration = subprocess.run( response = requests.head(
[ shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
"ffprobe", )
"-v",
"error", return response.status_code == 200
"-show_entries",
"format=duration",
"-of", def get_duration_sec(file_path: str) -> int:
"default=noprint_wrappers=1:nokey=1", """get duration of media file from file path"""
file_path,
], duration = subprocess.run(
capture_output=True, [
check=True, "ffprobe",
) "-v",
duration_raw = duration.stdout.decode().strip() "error",
if duration_raw == "N/A": "-show_entries",
return 0 "format=duration",
"-of",
duration_sec = int(float(duration_raw)) "default=noprint_wrappers=1:nokey=1",
return duration_sec file_path,
],
capture_output=True,
def get_duration_str(seconds: int) -> str: check=True,
"""Return a human-readable duration string from seconds.""" )
if not seconds: duration_raw = duration.stdout.decode().strip()
return "NA" if duration_raw == "N/A":
return 0
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = [] duration_sec = int(float(duration_raw))
return duration_sec
for unit_label, unit_seconds in units:
if seconds >= unit_seconds:
unit_count, seconds = divmod(seconds, unit_seconds) def get_duration_str(seconds: int) -> str:
duration_parts.append(f"{unit_count:02}{unit_label}") """Return a human-readable duration string from seconds."""
if not seconds:
duration_parts[0] = duration_parts[0].lstrip("0") return "NA"
return " ".join(duration_parts) units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = []
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]: for unit_label, unit_seconds in units:
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS""" if seconds >= unit_seconds:
allowed_hosts: list[str] = [ unit_count, seconds = divmod(seconds, unit_seconds)
"localhost", duration_parts.append(f"{unit_count:02}{unit_label}")
"tubearchivist",
] duration_parts[0] = duration_parts[0].lstrip("0")
csrf_trusted_origins: list[str] = [
"http://localhost", return " ".join(duration_parts)
"http://tubearchivist",
]
for host in ta_host.split(): def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
host_clean = host.strip() """parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
if not host_clean.startswith("http"): allowed_hosts: list[str] = [
host_clean = f"http://{host_clean}" "localhost",
"tubearchivist",
parsed = urlparse(host_clean) ]
allowed_hosts.append(f"{parsed.hostname}") csrf_trusted_origins: list[str] = [
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}") "http://localhost",
"http://tubearchivist",
return allowed_hosts, csrf_trusted_origins ]
for host in ta_host.split():
host_clean = host.strip()
def get_stylesheets(): if not host_clean.startswith("http"):
"""Get all valid stylesheets from /static/css""" host_clean = f"http://{host_clean}"
app_root = EnvironmentSettings.APP_DIR
stylesheets = os.listdir(os.path.join(app_root, "static/css")) parsed = urlparse(host_clean)
stylesheets.remove("style.css") allowed_hosts.append(f"{parsed.hostname}")
stylesheets.sort() csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
stylesheets = list(filter(lambda x: x.endswith(".css"), stylesheets))
return stylesheets return allowed_hosts, csrf_trusted_origins
def check_stylesheet(stylesheet: str): def get_stylesheets() -> list:
"""Check if a stylesheet exists. Return dark.css as a fallback""" """Get all valid stylesheets from /static/css"""
if stylesheet in get_stylesheets():
return stylesheet stylesheets = ["dark.css", "light.css", "matrix.css", "midnight.css"]
return stylesheets
return "dark.css"
def check_stylesheet(stylesheet: str):
def is_missing( """Check if a stylesheet exists. Return dark.css as a fallback"""
to_check: str | list[str], if stylesheet in get_stylesheets():
index_name: str = "ta_video,ta_download", return stylesheet
on_key: str = "youtube_id",
) -> list[str]: return "dark.css"
"""id or list of ids that are missing from index_name"""
if isinstance(to_check, str):
to_check = [to_check] def is_missing(
to_check: str | list[str],
data = { index_name: str = "ta_video,ta_download",
"query": {"terms": {on_key: to_check}}, on_key: str = "youtube_id",
"_source": [on_key], ) -> list[str]:
} """id or list of ids that are missing from index_name"""
result = IndexPaginate(index_name, data=data).get_results() if isinstance(to_check, str):
existing_ids = [i[on_key] for i in result] to_check = [to_check]
dl = [i for i in to_check if i not in existing_ids]
data = {
return dl "query": {"terms": {on_key: to_check}},
"_source": [on_key],
}
def get_channel_overwrites() -> dict[str, dict[str, Any]]: result = IndexPaginate(index_name, data=data).get_results()
"""get overwrites indexed my channel_id""" existing_ids = [i[on_key] for i in result]
data = { dl = [i for i in to_check if i not in existing_ids]
"query": {
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]} return dl
},
"_source": ["channel_id", "channel_overwrites"],
} def get_channel_overwrites() -> dict[str, dict[str, Any]]:
result = IndexPaginate("ta_channel", data).get_results() """get overwrites indexed my channel_id"""
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result} data = {
"query": {
return overwrites "bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
},
"_source": ["channel_id", "channel_overwrites"],
}
result = IndexPaginate("ta_channel", data).get_results()
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
return overwrites

View File

@@ -5,10 +5,10 @@ functionality:
import math import math
from home.src.download.yt_dlp_base import YtWrap from appsettings.src.config import AppConfig
from home.src.es.connect import ElasticWrap from common.src.es_connect import ElasticWrap
from home.src.ta.config import AppConfig from download.src.yt_dlp_base import YtWrap
from home.src.ta.users import UserConfig from user.src.user_config import UserConfig
class YouTubeItem: class YouTubeItem:
@@ -104,8 +104,8 @@ class Pagination:
def first_guess(self): def first_guess(self):
"""build first guess before api call""" """build first guess before api call"""
page_get = self.page_get page_get = self.page_get
page_from = 0
if page_get in [0, 1]: if page_get in [0, 1]:
page_from = 0
prev_pages = False prev_pages = False
elif page_get > 1: elif page_get > 1:
page_from = (page_get - 1) * self.page_size page_from = (page_get - 1) * self.page_size

View File

@@ -6,19 +6,19 @@ Functionality:
import urllib.parse import urllib.parse
from home.src.download.thumbnails import ThumbManager from common.src.env_settings import EnvironmentSettings
from home.src.ta.helper import date_parser, get_duration_str from common.src.helper import date_parser, get_duration_str
from home.src.ta.settings import EnvironmentSettings from common.src.ta_redis import RedisArchivist
from download.src.thumbnails import ThumbManager
class SearchProcess: class SearchProcess:
"""process search results""" """process search results"""
CACHE_DIR = EnvironmentSettings.CACHE_DIR def __init__(self, response, match_video_user_progress: None | int = None):
def __init__(self, response):
self.response = response self.response = response
self.processed = False self.processed = False
self.position_index = self.get_user_progress(match_video_user_progress)
def process(self): def process(self):
"""detect type and process""" """detect type and process"""
@@ -35,6 +35,19 @@ class SearchProcess:
return self.processed return self.processed
def get_user_progress(self, match_video_user_progress) -> dict | None:
"""get user video watch progress"""
if not match_video_user_progress:
return None
query = f"{match_video_user_progress}:progress:*"
all_positions = RedisArchivist().list_items(query)
if not all_positions:
return None
pos_index = {i["youtube_id"]: i["position"] for i in all_positions}
return pos_index
def _process_result(self, result): def _process_result(self, result):
"""detect which type of data to process""" """detect which type of data to process"""
index = result["_index"] index = result["_index"]
@@ -66,7 +79,8 @@ class SearchProcess:
def _process_channel(channel_dict): def _process_channel(channel_dict):
"""run on single channel""" """run on single channel"""
channel_id = channel_dict["channel_id"] channel_id = channel_dict["channel_id"]
art_base = f"/cache/channels/{channel_id}" cache_root = EnvironmentSettings().get_cache_root()
art_base = f"{cache_root}/channels/{channel_id}"
date_str = date_parser(channel_dict["channel_last_refresh"]) date_str = date_parser(channel_dict["channel_last_refresh"])
channel_dict.update( channel_dict.update(
{ {
@@ -93,16 +107,31 @@ class SearchProcess:
url = video_dict["subtitles"][idx]["media_url"] url = video_dict["subtitles"][idx]["media_url"]
video_dict["subtitles"][idx]["media_url"] = f"/media/{url}" video_dict["subtitles"][idx]["media_url"] = f"/media/{url}"
cache_root = EnvironmentSettings().get_cache_root()
media_root = EnvironmentSettings().get_media_root()
video_dict.update( video_dict.update(
{ {
"channel": channel, "channel": channel,
"media_url": f"/media/{media_url}", "media_url": f"{media_root}/{media_url}",
"vid_last_refresh": vid_last_refresh, "vid_last_refresh": vid_last_refresh,
"published": published, "published": published,
"vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}", "vid_thumb_url": f"{cache_root}/{vid_thumb_url}",
} }
) )
if self.position_index:
player_position = self.position_index.get(video_id)
total = video_dict["player"].get("duration")
if player_position and total:
progress = 100 * (player_position / total)
video_dict["player"].update(
{
"progress": progress,
"position": player_position,
}
)
return dict(sorted(video_dict.items())) return dict(sorted(video_dict.items()))
@staticmethod @staticmethod
@@ -112,9 +141,11 @@ class SearchProcess:
playlist_last_refresh = date_parser( playlist_last_refresh = date_parser(
playlist_dict["playlist_last_refresh"] playlist_dict["playlist_last_refresh"]
) )
cache_root = EnvironmentSettings().get_cache_root()
playlist_thumbnail = f"{cache_root}/playlists/{playlist_id}.jpg"
playlist_dict.update( playlist_dict.update(
{ {
"playlist_thumbnail": f"/cache/playlists/{playlist_id}.jpg", "playlist_thumbnail": playlist_thumbnail,
"playlist_last_refresh": playlist_last_refresh, "playlist_last_refresh": playlist_last_refresh,
} }
) )
@@ -124,12 +155,13 @@ 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"] video_id = download_dict["youtube_id"]
cache_root = EnvironmentSettings().get_cache_root()
vid_thumb_url = ThumbManager(video_id).vid_thumb_path() vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
published = date_parser(download_dict["published"]) published = date_parser(download_dict["published"])
download_dict.update( download_dict.update(
{ {
"vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}", "vid_thumb_url": f"{cache_root}/{vid_thumb_url}",
"published": published, "published": published,
} }
) )

View File

@@ -6,8 +6,8 @@ Functionality:
- calculate pagination values - calculate pagination values
""" """
from api.src.search_processor import SearchProcess from common.src.es_connect import ElasticWrap
from home.src.es.connect import ElasticWrap from common.src.search_processor import SearchProcess
class SearchForm: class SearchForm:

View File

@@ -8,7 +8,7 @@ functionality:
import json import json
import redis import redis
from home.src.ta.settings import EnvironmentSettings from common.src.env_settings import EnvironmentSettings
class RedisBase: class RedisBase:
@@ -17,10 +17,8 @@ class RedisBase:
NAME_SPACE: str = EnvironmentSettings.REDIS_NAME_SPACE NAME_SPACE: str = EnvironmentSettings.REDIS_NAME_SPACE
def __init__(self): def __init__(self):
self.conn = redis.Redis( self.conn = redis.from_url(
host=EnvironmentSettings.REDIS_HOST, url=EnvironmentSettings.REDIS_CON, decode_responses=True
port=EnvironmentSettings.REDIS_PORT,
decode_responses=True,
) )
@@ -40,15 +38,15 @@ class RedisArchivist(RedisBase):
def set_message( def set_message(
self, self,
key: str, key: str,
message: dict, message: dict | str,
path: str = ".",
expire: bool | int = False, expire: bool | int = False,
save: bool = False, save: bool = False,
) -> None: ) -> None:
"""write new message to redis""" """write new message to redis"""
self.conn.execute_command( to_write = (
"JSON.SET", self.NAME_SPACE + key, path, json.dumps(message) json.dumps(message) if isinstance(message, dict) else message
) )
self.conn.execute_command("SET", self.NAME_SPACE + key, to_write)
if expire: if expire:
if isinstance(expire, bool): if isinstance(expire, bool):
@@ -67,8 +65,24 @@ class RedisArchivist(RedisBase):
except redis.exceptions.ResponseError: except redis.exceptions.ResponseError:
pass pass
def get_message(self, key: str) -> dict: def get_message_str(self, key: str) -> str | None:
"""get message dict from redis""" """get message string"""
reply = self.conn.execute_command("GET", self.NAME_SPACE + key)
return reply
def get_message_dict(self, key: str) -> dict:
"""get message dict"""
reply = self.conn.execute_command("GET", self.NAME_SPACE + key)
if not reply:
return {}
return json.loads(reply)
def get_message(self, key: str) -> dict | None:
"""
get message dict from redis
old json get message, only used for migration, to be removed later
"""
reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key) reply = self.conn.execute_command("JSON.GET", self.NAME_SPACE + key)
if reply: if reply:
return json.loads(reply) return json.loads(reply)
@@ -91,7 +105,7 @@ class RedisArchivist(RedisBase):
if not all_matches: if not all_matches:
return [] return []
return [self.get_message(i) for i in all_matches] return [self.get_message_dict(i) for i in all_matches]
def del_message(self, key: str) -> bool: def del_message(self, key: str) -> bool:
"""delete key from redis""" """delete key from redis"""

View File

@@ -6,8 +6,8 @@ Functionality:
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from home.src.download.yt_dlp_base import YtWrap from download.src.yt_dlp_base import YtWrap
from home.src.index.video_constants import VideoTypeEnum from video.src.constants import VideoTypeEnum
class Parser: class Parser:
@@ -67,6 +67,9 @@ class Parser:
if all_paths[0] == "channel": if all_paths[0] == "channel":
return self._validate_expected(all_paths[1], "channel") return self._validate_expected(all_paths[1], "channel")
if all_paths[0] == "live":
return self._validate_expected(all_paths[1], "video")
# detect channel # detect channel
channel_id = self._extract_channel_name(parsed.geturl()) channel_id = self._extract_channel_name(parsed.geturl())
return {"type": "channel", "url": channel_id} return {"type": "channel", "url": channel_id}
@@ -113,6 +116,9 @@ class Parser:
"playlistend": 0, "playlistend": 0,
} }
url_info = YtWrap(obs_request).extract(url) url_info = YtWrap(obs_request).extract(url)
if not url_info:
raise ValueError(f"failed to retrieve content from URL: {url}")
channel_id = url_info.get("channel_id", False) channel_id = url_info.get("channel_id", False)
if channel_id: if channel_id:
return channel_id return channel_id

View File

@@ -5,8 +5,8 @@ functionality:
from datetime import datetime from datetime import datetime
from home.src.es.connect import ElasticWrap from common.src.es_connect import ElasticWrap
from home.src.ta.urlparser import Parser from common.src.urlparser import Parser
class WatchState: class WatchState:

View File

@@ -8,4 +8,4 @@ import pytest
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def change_test_dir(request): def change_test_dir(request):
"""change directory to project folder""" """change directory to project folder"""
os.chdir(request.config.rootdir / "tubearchivist") os.chdir(request.config.rootdir / "backend")

View File

@@ -1,7 +1,7 @@
"""tests for helper functions""" """tests for helper functions"""
import pytest import pytest
from home.src.ta.helper import ( from common.src.helper import (
date_parser, date_parser,
get_duration_str, get_duration_str,
get_mapping, get_mapping,

View File

@@ -1,7 +1,7 @@
"""tests for url parser""" """tests for url parser"""
import pytest import pytest
from home.src.ta.urlparser import Parser from common.src.urlparser import Parser
# video id parsing # video id parsing
VIDEO_URL_IN = [ VIDEO_URL_IN = [
@@ -10,6 +10,7 @@ VIDEO_URL_IN = [
"https://www.youtube.com/watch?v=7DKv5H5Frt0&t=113&feature=shared", "https://www.youtube.com/watch?v=7DKv5H5Frt0&t=113&feature=shared",
"https://www.youtube.com/watch?v=7DKv5H5Frt0&list=PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5&index=1&pp=iAQB" # noqa: E501 "https://www.youtube.com/watch?v=7DKv5H5Frt0&list=PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5&index=1&pp=iAQB" # noqa: E501
"https://youtu.be/7DKv5H5Frt0", "https://youtu.be/7DKv5H5Frt0",
"https://www.youtube.com/live/7DKv5H5Frt0",
] ]
VIDEO_OUT = [{"type": "video", "url": "7DKv5H5Frt0", "vid_type": "unknown"}] VIDEO_OUT = [{"type": "video", "url": "7DKv5H5Frt0", "vid_type": "unknown"}]
VIDEO_TEST_CASES = [(i, VIDEO_OUT) for i in VIDEO_URL_IN] VIDEO_TEST_CASES = [(i, VIDEO_OUT) for i in VIDEO_URL_IN]

28
backend/common/urls.py Normal file
View File

@@ -0,0 +1,28 @@
"""all api urls"""
from common import views
from django.urls import path
urlpatterns = [
path("ping/", views.PingView.as_view(), name="ping"),
path(
"refresh/",
views.RefreshView.as_view(),
name="api-refresh",
),
path(
"watched/",
views.WatchedView.as_view(),
name="api-watched",
),
path(
"search/",
views.SearchView.as_view(),
name="api-search",
),
path(
"notification/",
views.NotificationView.as_view(),
name="api-notification",
),
]

116
backend/common/views.py Normal file
View File

@@ -0,0 +1,116 @@
"""all API views"""
from appsettings.src.config import ReleaseVersion
from appsettings.src.reindex import ReindexProgress
from common.src.searching import SearchForm
from common.src.ta_redis import RedisArchivist
from common.src.watched import WatchState
from common.views_base import AdminOnly, ApiBaseView
from rest_framework.response import Response
from task.tasks import check_reindex
class PingView(ApiBaseView):
"""resolves to /api/ping/
GET: test your connection
"""
@staticmethod
def get(request):
"""get pong"""
data = {
"response": "pong",
"user": request.user.id,
"version": ReleaseVersion().get_local_version(),
"ta_update": ReleaseVersion().get_update(),
}
return Response(data)
class RefreshView(ApiBaseView):
"""resolves to /api/refresh/
GET: get refresh progress
POST: start a manual refresh task
"""
permission_classes = [AdminOnly]
def get(self, request):
"""handle get request"""
request_type = request.GET.get("type")
request_id = request.GET.get("id")
if request_id and not request_type:
return Response({"status": "Bad Request"}, status=400)
try:
progress = ReindexProgress(
request_type=request_type, request_id=request_id
).get_progress()
except ValueError:
return Response({"status": "Bad Request"}, status=400)
return Response(progress)
def post(self, request):
"""handle post request"""
data = request.data
extract_videos = bool(request.GET.get("extract_videos", False))
check_reindex.delay(data=data, extract_videos=extract_videos)
return Response(data)
class WatchedView(ApiBaseView):
"""resolves to /api/watched/
POST: change watched state of video, channel or playlist
"""
def post(self, request):
"""change watched state"""
youtube_id = request.data.get("id")
is_watched = request.data.get("is_watched")
if not youtube_id or is_watched is None:
message = {"message": "missing id or is_watched"}
return Response(message, status=400)
WatchState(youtube_id, is_watched).change()
return Response({"message": "success"}, status=200)
class SearchView(ApiBaseView):
"""resolves to /api/search/
GET: run a search with the string in the ?query parameter
"""
@staticmethod
def get(request):
"""handle get request
search through all indexes"""
search_query = request.GET.get("query", None)
if search_query is None:
return Response(
{"message": "no search query specified"}, status=400
)
search_results = SearchForm().multi_search(search_query)
return Response(search_results)
class NotificationView(ApiBaseView):
"""resolves to /api/notification/
GET: returns a list of notifications
filter query to filter messages by group
"""
valid_filters = ["download", "settings", "channel"]
def get(self, request):
"""get all notifications"""
query = "message"
filter_by = request.GET.get("filter", None)
if filter_by in self.valid_filters:
query = f"{query}:{filter_by}"
return Response(RedisArchivist().list_items(query))

View File

@@ -0,0 +1,110 @@
"""base classes to inherit from"""
from appsettings.src.config import AppConfig
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.index_generic import Pagination
from common.src.search_processor import SearchProcess, process_aggs
from rest_framework import permissions
from rest_framework.authentication import (
SessionAuthentication,
TokenAuthentication,
)
from rest_framework.views import APIView
def check_admin(user):
"""check for admin permission for restricted views"""
return user.is_staff or user.groups.filter(name="admin").exists()
class AdminOnly(permissions.BasePermission):
"""allow only admin"""
def has_permission(self, request, view):
return check_admin(request.user)
class AdminWriteOnly(permissions.BasePermission):
"""allow only admin writes"""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return permissions.IsAuthenticated().has_permission(request, view)
return check_admin(request.user)
class ApiBaseView(APIView):
"""base view to inherit from"""
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [permissions.IsAuthenticated]
search_base = ""
data = ""
def __init__(self):
super().__init__()
self.response = {
"data": False,
"config": {
"enable_cast": EnvironmentSettings.ENABLE_CAST,
"downloads": AppConfig().config["downloads"],
},
}
self.data = {"query": {"match_all": {}}}
self.status_code = False
self.context = False
self.pagination_handler = False
def get_document(self, document_id, progress_match=None):
"""get single document from es"""
path = f"{self.search_base}{document_id}"
response, status_code = ElasticWrap(path).get()
try:
self.response["data"] = SearchProcess(
response, match_video_user_progress=progress_match
).process()
except KeyError:
print(f"item not found: {document_id}")
self.response["data"] = False
self.status_code = status_code
def initiate_pagination(self, request):
"""set initial pagination values"""
self.pagination_handler = Pagination(request)
self.data.update(
{
"size": self.pagination_handler.pagination["page_size"],
"from": self.pagination_handler.pagination["page_from"],
}
)
def get_document_list(self, request, pagination=True, progress_match=None):
"""get a list of results"""
if pagination:
self.initiate_pagination(request)
es_handler = ElasticWrap(self.search_base)
response, status_code = es_handler.get(data=self.data)
self.response["data"] = SearchProcess(
response, match_video_user_progress=progress_match
).process()
if self.response["data"]:
self.status_code = status_code
else:
self.status_code = 404
if pagination and response.get("hits"):
self.pagination_handler.validate(
response["hits"]["total"]["value"]
)
self.response["paginate"] = self.pagination_handler.pagination
def get_aggs(self):
"""get aggs alone"""
self.data["size"] = 0
response, _ = ElasticWrap(self.search_base).get(data=self.data)
process_aggs(response)
self.response = response.get("aggregations")

View File

@@ -0,0 +1,76 @@
"""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

View File

@@ -0,0 +1,89 @@
"""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}")
)

View File

@@ -6,10 +6,10 @@ Functionality:
from time import sleep from time import sleep
import requests import requests
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.ta_redis import RedisArchivist
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from home.src.es.connect import ElasticWrap
from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisArchivist
TOPIC = """ TOPIC = """

View File

@@ -8,10 +8,11 @@ Functionality:
import os import os
import re import re
from time import sleep
from common.src.env_settings import EnvironmentSettings
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from home.models import Account from user.models import Account
from home.src.ta.settings import EnvironmentSettings
LOGO = """ LOGO = """
@@ -60,9 +61,13 @@ EXPECTED_ENV_VARS = [
"ES_URL", "ES_URL",
"TA_HOST", "TA_HOST",
] ]
UNEXPECTED_ENV_VARS = {
"TA_UWSGI_PORT": "Has been replaced with 'TA_BACKEND_PORT'",
"REDIS_HOST": "Has been replaced with 'REDIS_CON' connection string",
"REDIS_PORT": "Has been consolidated in 'REDIS_CON' connection string",
}
INST = "https://github.com/tubearchivist/tubearchivist#installing-and-updating" INST = "https://github.com/tubearchivist/tubearchivist#installing-and-updating"
NGINX = "/etc/nginx/sites-available/default" NGINX = "/etc/nginx/sites-available/default"
UWSGI = "/app/uwsgi.ini"
class Command(BaseCommand): class Command(BaseCommand):
@@ -76,9 +81,10 @@ class Command(BaseCommand):
self.stdout.write(LOGO) self.stdout.write(LOGO)
self.stdout.write(TOPIC) self.stdout.write(TOPIC)
self._expected_vars() self._expected_vars()
self._unexpected_vars()
self._elastic_user_overwrite() self._elastic_user_overwrite()
self._ta_port_overwrite() self._ta_port_overwrite()
self._ta_uwsgi_overwrite() self._ta_backend_port_overwrite()
self._enable_cast_overwrite() self._enable_cast_overwrite()
self._create_superuser() self._create_superuser()
@@ -90,20 +96,41 @@ class Command(BaseCommand):
if not env.get(var): if not env.get(var):
message = f" 🗙 expected env var {var} not set\n {INST}" message = f" 🗙 expected env var {var} not set\n {INST}"
self.stdout.write(self.style.ERROR(message)) self.stdout.write(self.style.ERROR(message))
sleep(60)
raise CommandError(message) raise CommandError(message)
message = " ✓ all expected env vars are set" message = " ✓ all expected env vars are set"
self.stdout.write(self.style.SUCCESS(message)) self.stdout.write(self.style.SUCCESS(message))
def _unexpected_vars(self):
"""check for unexpected env vars"""
self.stdout.write("[2] checking for unexpected env vars")
for var, message in UNEXPECTED_ENV_VARS.items():
if not os.environ.get(var):
continue
message = (
f" 🗙 unexpected env var {var} found\n"
f" {message} \n"
" see release notes for a list of all changes."
)
self.stdout.write(self.style.ERROR(message))
sleep(60)
raise CommandError(message)
message = " ✓ no unexpected env vars found"
self.stdout.write(self.style.SUCCESS(message))
def _elastic_user_overwrite(self): def _elastic_user_overwrite(self):
"""check for ELASTIC_USER overwrite""" """check for ELASTIC_USER overwrite"""
self.stdout.write("[2] check ES user overwrite") self.stdout.write("[3] check ES user overwrite")
env = EnvironmentSettings.ES_USER env = EnvironmentSettings.ES_USER
self.stdout.write(self.style.SUCCESS(f" ✓ ES user is set to {env}")) self.stdout.write(self.style.SUCCESS(f" ✓ ES user is set to {env}"))
def _ta_port_overwrite(self): def _ta_port_overwrite(self):
"""set TA_PORT overwrite for nginx""" """set TA_PORT overwrite for nginx"""
self.stdout.write("[3] check TA_PORT overwrite") self.stdout.write("[4] check TA_PORT overwrite")
overwrite = EnvironmentSettings.TA_PORT overwrite = EnvironmentSettings.TA_PORT
if not overwrite: if not overwrite:
self.stdout.write(self.style.SUCCESS(" TA_PORT is not set")) self.stdout.write(self.style.SUCCESS(" TA_PORT is not set"))
@@ -119,35 +146,30 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(message)) self.stdout.write(self.style.SUCCESS(message))
def _ta_uwsgi_overwrite(self): def _ta_backend_port_overwrite(self):
"""set TA_UWSGI_PORT overwrite""" """set TA_BACKEND_PORT overwrite"""
self.stdout.write("[4] check TA_UWSGI_PORT overwrite") self.stdout.write("[5] check TA_BACKEND_PORT overwrite")
overwrite = EnvironmentSettings.TA_UWSGI_PORT overwrite = EnvironmentSettings.TA_BACKEND_PORT
if not overwrite: if not overwrite:
message = " TA_UWSGI_PORT is not set" message = " TA_BACKEND_PORT is not set"
self.stdout.write(self.style.SUCCESS(message)) self.stdout.write(self.style.SUCCESS(message))
return return
# nginx # modify nginx conf
regex = re.compile(r"uwsgi_pass localhost:[0-9]{1,5}") regex = re.compile(r"proxy_pass http://localhost:[0-9]{1,5}")
to_overwrite = f"uwsgi_pass localhost:{overwrite}" to_overwrite = f"proxy_pass http://localhost:{overwrite}"
changed = file_overwrite(NGINX, regex, to_overwrite) changed = file_overwrite(NGINX, regex, to_overwrite)
# uwsgi
regex = re.compile(r"socket = :[0-9]{1,5}")
to_overwrite = f"socket = :{overwrite}"
changed = file_overwrite(UWSGI, regex, to_overwrite)
if changed: if changed:
message = f" ✓ TA_UWSGI_PORT changed to {overwrite}" message = f" ✓ TA_BACKEND_PORT changed to {overwrite}"
else: else:
message = f" ✓ TA_UWSGI_PORT already set to {overwrite}" message = f" ✓ TA_BACKEND_PORT already set to {overwrite}"
self.stdout.write(self.style.SUCCESS(message)) self.stdout.write(self.style.SUCCESS(message))
def _enable_cast_overwrite(self): def _enable_cast_overwrite(self):
"""cast workaround, remove auth for static files in nginx""" """cast workaround, remove auth for static files in nginx"""
self.stdout.write("[5] check ENABLE_CAST overwrite") self.stdout.write("[6] check ENABLE_CAST overwrite")
overwrite = EnvironmentSettings.ENABLE_CAST overwrite = EnvironmentSettings.ENABLE_CAST
if not overwrite: if not overwrite:
self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set")) self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set"))
@@ -164,7 +186,7 @@ class Command(BaseCommand):
def _create_superuser(self): def _create_superuser(self):
"""create superuser if not exist""" """create superuser if not exist"""
self.stdout.write("[6] create superuser") self.stdout.write("[7] create superuser")
is_created = Account.objects.filter(is_superuser=True) is_created = Account.objects.filter(is_superuser=True)
if is_created: if is_created:
message = " superuser already created" message = " superuser already created"

View File

@@ -0,0 +1,281 @@
"""
Functionality:
- Application startup
- Apply migrations
"""
import os
from datetime import datetime
from random import randint
from time import sleep
from appsettings.src.config import AppConfig, ReleaseVersion
from appsettings.src.index_setup import ElasitIndexWrap
from appsettings.src.snapshot import ElasticSnapshot
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.helper import clear_dl_cache
from common.src.ta_redis import RedisArchivist
from django.core.management.base import BaseCommand, CommandError
from django.utils import dateformat
from django_celery_beat.models import CrontabSchedule, PeriodicTasks
from redis.exceptions import ResponseError
from task.models import CustomPeriodicTask
from task.src.config_schedule import ScheduleBuilder
from task.src.task_manager import TaskManager
from task.tasks import version_check
TOPIC = """
#######################
# Application Start #
#######################
"""
class Command(BaseCommand):
"""command framework"""
# pylint: disable=no-member
def handle(self, *args, **options):
"""run all commands"""
self.stdout.write(TOPIC)
self._make_folders()
self._clear_redis_keys()
self._clear_tasks()
self._clear_dl_cache()
self._version_check()
self._index_setup()
self._snapshot_check()
self._create_default_schedules()
self._update_schedule_tz()
self._init_app_config()
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:
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")
return
message = " 🗙 failed to migrate app config"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _make_folders(self):
"""make expected cache folders"""
self.stdout.write("[2] create expected cache folders")
folders = [
"backup",
"channels",
"download",
"import",
"playlists",
"videos",
]
cache_dir = EnvironmentSettings.CACHE_DIR
for folder in folders:
folder_path = os.path.join(cache_dir, folder)
os.makedirs(folder_path, exist_ok=True)
self.stdout.write(self.style.SUCCESS(" ✓ expected folders created"))
def _clear_redis_keys(self):
"""make sure there are no leftover locks or keys set in redis"""
self.stdout.write("[3] clear leftover keys in redis")
all_keys = [
"dl_queue_id",
"dl_queue",
"downloading",
"manual_import",
"reindex",
"rescan",
"run_backup",
"startup_check",
"reindex:ta_video",
"reindex:ta_channel",
"reindex:ta_playlist",
]
redis_con = RedisArchivist()
has_changed = False
for key in all_keys:
if redis_con.del_message(key):
self.stdout.write(
self.style.SUCCESS(f" ✓ cleared key {key}")
)
has_changed = True
if not has_changed:
self.stdout.write(self.style.SUCCESS(" no keys found"))
def _clear_tasks(self):
"""clear tasks and messages"""
self.stdout.write("[4] clear task leftovers")
TaskManager().fail_pending()
redis_con = RedisArchivist()
to_delete = redis_con.list_keys("message:")
if to_delete:
for key in to_delete:
redis_con.del_message(key)
self.stdout.write(
self.style.SUCCESS(f" ✓ cleared {len(to_delete)} messages")
)
def _clear_dl_cache(self):
"""clear leftover files from dl cache"""
self.stdout.write("[5] clear leftover files from dl cache")
leftover_files = clear_dl_cache(EnvironmentSettings.CACHE_DIR)
if leftover_files:
self.stdout.write(
self.style.SUCCESS(f" ✓ cleared {leftover_files} files")
)
else:
self.stdout.write(self.style.SUCCESS(" no files found"))
def _version_check(self):
"""remove new release key if updated now"""
self.stdout.write("[6] check for first run after update")
new_version = ReleaseVersion().is_updated()
if new_version:
self.stdout.write(
self.style.SUCCESS(f" ✓ update to {new_version} completed")
)
else:
self.stdout.write(self.style.SUCCESS(" no new update found"))
version_task = CustomPeriodicTask.objects.filter(name="version_check")
if not version_task.exists():
return
if not version_task.first().last_run_at:
self.style.SUCCESS(" ✓ send initial version check task")
version_check.delay()
def _index_setup(self):
"""migration: validate index mappings"""
self.stdout.write("[7] validate index mappings")
ElasitIndexWrap().setup()
def _snapshot_check(self):
"""migration setup snapshots"""
self.stdout.write("[8] setup snapshots")
ElasticSnapshot().setup()
def _create_default_schedules(self) -> None:
"""create default schedules for new installations"""
self.stdout.write("[9] create initial schedules")
init_has_run = CustomPeriodicTask.objects.filter(
name="version_check"
).exists()
if init_has_run:
self.stdout.write(
self.style.SUCCESS(
" schedule init already done, skipping..."
)
)
return
builder = ScheduleBuilder()
check_reindex = builder.get_set_task(
"check_reindex", schedule=builder.SCHEDULES["check_reindex"]
)
check_reindex.task_config.update({"days": 90})
check_reindex.last_run_at = dateformat.make_aware(datetime.now())
check_reindex.save()
self.stdout.write(
self.style.SUCCESS(
f" ✓ created new default schedule: {check_reindex}"
)
)
thumbnail_check = builder.get_set_task(
"thumbnail_check", schedule=builder.SCHEDULES["thumbnail_check"]
)
thumbnail_check.last_run_at = dateformat.make_aware(datetime.now())
thumbnail_check.save()
self.stdout.write(
self.style.SUCCESS(
f" ✓ created new default schedule: {thumbnail_check}"
)
)
daily_random = f"{randint(0, 59)} {randint(0, 23)} *"
version_check_task = builder.get_set_task(
"version_check", schedule=daily_random
)
self.stdout.write(
self.style.SUCCESS(
f" ✓ created new default schedule: {version_check_task}"
)
)
self.stdout.write(
self.style.SUCCESS(" ✓ all default schedules created")
)
def _update_schedule_tz(self) -> None:
"""update timezone for Schedule instances"""
self.stdout.write("[10] validate schedules TZ")
tz = EnvironmentSettings.TZ
to_update = CrontabSchedule.objects.exclude(timezone=tz)
if not to_update.exists():
self.stdout.write(
self.style.SUCCESS(" all schedules have correct TZ")
)
return
updated = to_update.update(timezone=tz)
self.stdout.write(
self.style.SUCCESS(f" ✓ updated {updated} schedules to {tz}.")
)
PeriodicTasks.update_changed()
def _init_app_config(self) -> None:
"""init default app config to ES"""
self.stdout.write("[11] Check AppConfig")
try:
_ = AppConfig().config
self.stdout.write(
self.style.SUCCESS(" skip completed appsettings init")
)
updated_defaults = AppConfig().add_new_defaults()
for new_default in updated_defaults:
self.stdout.write(
self.style.SUCCESS(f" added new default: {new_default}")
)
except ValueError:
handler = AppConfig.__new__(AppConfig)
_, status_code = handler.sync_defaults()
self.stdout.write(
self.style.SUCCESS(" ✓ Created default appsettings.")
)
self.stdout.write(
self.style.SUCCESS(f" Status code: {status_code}")
)

View File

@@ -0,0 +1,40 @@
"""stop on unexpected table"""
from time import sleep
from django.core.management.base import BaseCommand, CommandError
from django.db import connection
ERROR_MESSAGE = """
🗙 Database is incompatible, see latest release notes for instructions:
🗙 https://github.com/tubearchivist/tubearchivist/releases/tag/v0.5.0
"""
class Command(BaseCommand):
"""command framework"""
# pylint: disable=no-member
def handle(self, *args, **options):
"""handle"""
self.stdout.write("[MIGRATION] Confirming v0.5.0 table layout")
all_tables = self.list_tables()
for table in all_tables:
if table == "home_account":
self.stdout.write(self.style.ERROR(ERROR_MESSAGE))
sleep(60)
raise CommandError(ERROR_MESSAGE)
self.stdout.write(self.style.SUCCESS(" ✓ local DB is up-to-date."))
def list_tables(self):
"""raw list all tables"""
with connection.cursor() as cursor:
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table';"
)
tables = cursor.fetchall()
return [table[0] for table in tables]

View File

@@ -14,11 +14,17 @@ import hashlib
from os import environ, path from os import environ, path
from pathlib import Path from pathlib import Path
import ldap from common.src.env_settings import EnvironmentSettings
from common.src.helper import ta_host_parser
from corsheaders.defaults import default_headers from corsheaders.defaults import default_headers
from django_auth_ldap.config import LDAPSearch
from home.src.ta.helper import ta_host_parser try:
from home.src.ta.settings import EnvironmentSettings from dotenv import load_dotenv
load_dotenv(".env")
except ModuleNotFoundError:
pass
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -41,7 +47,6 @@ ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS = ta_host_parser(
INSTALLED_APPS = [ INSTALLED_APPS = [
"django_celery_beat", "django_celery_beat",
"home.apps.HomeConfig",
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
@@ -53,7 +58,15 @@ INSTALLED_APPS = [
"django.contrib.humanize", "django.contrib.humanize",
"rest_framework", "rest_framework",
"rest_framework.authtoken", "rest_framework.authtoken",
"api", "common",
"video",
"channel",
"playlist",
"download",
"task",
"appsettings",
"stats",
"user",
"config", "config",
] ]
@@ -67,7 +80,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"home.src.ta.health.HealthCheckMiddleware", "common.src.health.HealthCheckMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
@@ -92,6 +105,9 @@ WSGI_APPLICATION = "config.wsgi.application"
if bool(environ.get("TA_LDAP")): if bool(environ.get("TA_LDAP")):
# pylint: disable=global-at-module-level # pylint: disable=global-at-module-level
import ldap
from django_auth_ldap.config import LDAPSearch
global AUTH_LDAP_SERVER_URI global AUTH_LDAP_SERVER_URI
AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI") AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI")
@@ -211,7 +227,7 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
AUTH_USER_MODEL = "home.Account" AUTH_USER_MODEL = "user.Account"
# Forward-auth authentication # Forward-auth authentication
if bool(environ.get("TA_ENABLE_AUTH_PROXY")): if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
@@ -220,7 +236,7 @@ if bool(environ.get("TA_ENABLE_AUTH_PROXY")):
) )
TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL") TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL")
MIDDLEWARE.append("home.src.ta.auth.HttpRemoteUserMiddleware") MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware")
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.RemoteUserBackend", "django.contrib.auth.backends.RemoteUserBackend",
@@ -261,14 +277,18 @@ LOGOUT_REDIRECT_URL = "/login/"
# background.js makes the request so HTTP_ORIGIN will be from extension # background.js makes the request so HTTP_ORIGIN will be from extension
if environ.get("DISABLE_CORS"): if environ.get("DISABLE_CORS"):
# disable cors # disable cors
CORS_ORIGIN_ALLOW_ALL = True CORS_ALLOW_ALL_ORIGINS = True
else: else:
CORS_ALLOWED_ORIGIN_REGEXES = [ CORS_ALLOWED_ORIGIN_REGEXES = [
r"moz-extension://*", r"moz-extension://*",
r"chrome-extension://*", r"chrome-extension://*",
] ]
CORS_ORIGIN_WHITELIST = ["http://localhost:3000", "http://localhost:8000"]
CSRF_TRUSTED_ORIGINS = ["http://localhost:3000", "http://localhost:8000"]
CORS_ALLOWED_ORIGINS = ["http://localhost:3000"] CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = list(default_headers) + [ CORS_ALLOW_HEADERS = list(default_headers) + [
"mode", "mode",
@@ -276,4 +296,4 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings # TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.4.11" TA_VERSION = "v0.4.13"

View File

@@ -18,7 +18,14 @@ from django.contrib import admin
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("", include("home.urls")), path("api/", include("common.urls")),
path("api/", include("api.urls")), path("api/video/", include("video.urls")),
path("api/channel/", include("channel.urls")),
path("api/playlist/", include("playlist.urls")),
path("api/download/", include("download.urls")),
path("api/task/", include("task.urls")),
path("api/appsettings/", include("appsettings.urls")),
path("api/stats/", include("stats.urls")),
path("api/user/", include("user.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
] ]

View File

@@ -4,17 +4,16 @@ Functionality:
- linked with ta_dowload index - linked with ta_dowload index
""" """
import json
from datetime import datetime from datetime import datetime
from home.src.download.subscriptions import ChannelSubscription from appsettings.src.config import AppConfig
from home.src.download.thumbnails import ThumbManager from common.src.es_connect import ElasticWrap, IndexPaginate
from home.src.download.yt_dlp_base import YtWrap from common.src.helper import get_duration_str, is_shorts, rand_sleep
from home.src.es.connect import ElasticWrap, IndexPaginate from download.src.subscriptions import ChannelSubscription
from home.src.index.playlist import YoutubePlaylist from download.src.thumbnails import ThumbManager
from home.src.index.video_constants import VideoTypeEnum from download.src.yt_dlp_base import YtWrap
from home.src.ta.config import AppConfig from playlist.src.index import YoutubePlaylist
from home.src.ta.helper import get_duration_str, is_shorts from video.src.constants import VideoTypeEnum
class PendingIndex: class PendingIndex:
@@ -241,7 +240,6 @@ class PendingList(PendingIndex):
def add_to_pending(self, status="pending", auto_start=False): def add_to_pending(self, status="pending", auto_start=False):
"""add missing videos to pending list""" """add missing videos to pending list"""
self.get_channels() self.get_channels()
bulk_list = []
total = len(self.missing_videos) total = len(self.missing_videos)
videos_added = [] videos_added = []
@@ -253,6 +251,7 @@ class PendingList(PendingIndex):
self._notify_add(idx, total) self._notify_add(idx, total)
video_details = self.get_youtube_details(youtube_id, vid_type) video_details = self.get_youtube_details(youtube_id, vid_type)
if not video_details: if not video_details:
rand_sleep(self.config)
continue continue
video_details.update( video_details.update(
@@ -262,32 +261,17 @@ class PendingList(PendingIndex):
} }
) )
action = {"create": {"_id": youtube_id, "_index": "ta_download"}}
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(video_details))
url = video_details["vid_thumb_url"] url = video_details["vid_thumb_url"]
ThumbManager(youtube_id).download_video_thumb(url) ThumbManager(youtube_id).download_video_thumb(url)
es_url = f"ta_download/_doc/{youtube_id}"
_, _ = ElasticWrap(es_url).put(video_details)
videos_added.append(youtube_id) videos_added.append(youtube_id)
if len(bulk_list) >= 20: if idx != total:
self._ingest_bulk(bulk_list) rand_sleep(self.config)
bulk_list = []
self._ingest_bulk(bulk_list)
return videos_added return videos_added
def _ingest_bulk(self, bulk_list):
"""add items to queue in bulk"""
if not bulk_list:
return
# add last newline
bulk_list.append("\n")
query_str = "\n".join(bulk_list)
_, _ = ElasticWrap("_bulk?refresh=true").post(query_str, ndjson=True)
def _notify_add(self, idx, total): def _notify_add(self, idx, 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:

View File

@@ -4,16 +4,16 @@ Functionality:
- handle playlist subscriptions - handle playlist subscriptions
""" """
from home.src.download.thumbnails import ThumbManager from appsettings.src.config import AppConfig
from home.src.download.yt_dlp_base import YtWrap from channel.src.index import YoutubeChannel
from home.src.es.connect import IndexPaginate from common.src.es_connect import IndexPaginate
from home.src.index.channel import YoutubeChannel from common.src.helper import is_missing, rand_sleep
from home.src.index.playlist import YoutubePlaylist from common.src.urlparser import Parser
from home.src.index.video import YoutubeVideo from download.src.thumbnails import ThumbManager
from home.src.index.video_constants import VideoTypeEnum from download.src.yt_dlp_base import YtWrap
from home.src.ta.config import AppConfig from playlist.src.index import YoutubePlaylist
from home.src.ta.helper import is_missing from video.src.constants import VideoTypeEnum
from home.src.ta.urlparser import Parser from video.src.index import YoutubeVideo
class ChannelSubscription: class ChannelSubscription:
@@ -108,6 +108,7 @@ class ChannelSubscription:
message_lines=[f"Scanning Channel {idx + 1}/{total}"], message_lines=[f"Scanning Channel {idx + 1}/{total}"],
progress=(idx + 1) / total, progress=(idx + 1) / total,
) )
rand_sleep(self.config)
return missing_videos return missing_videos
@@ -120,6 +121,8 @@ class ChannelSubscription:
channel.upload_to_es() channel.upload_to_es()
channel.sync_to_videos() channel.sync_to_videos()
return channel.json_data
class VideoQueryBuilder: class VideoQueryBuilder:
"""Build queries for yt-dlp.""" """Build queries for yt-dlp."""
@@ -277,6 +280,7 @@ class PlaylistSubscription:
playlist.build_json() playlist.build_json()
playlist.json_data["playlist_subscribed"] = subscribe_status playlist.json_data["playlist_subscribed"] = subscribe_status
playlist.upload_to_es() playlist.upload_to_es()
return playlist.json_data
def find_missing(self): def find_missing(self):
"""find videos in subscribed playlists not downloaded yet""" """find videos in subscribed playlists not downloaded yet"""
@@ -317,6 +321,7 @@ class PlaylistSubscription:
message_lines=[f"Scanning Playlists {idx + 1}/{total}"], message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
progress=(idx + 1) / total, progress=(idx + 1) / total,
) )
rand_sleep(self.config)
return missing_videos return missing_videos
@@ -422,7 +427,7 @@ class SubscriptionHandler:
def _subscribe(self, channel_id): def _subscribe(self, channel_id):
"""subscribe to channel""" """subscribe to channel"""
ChannelSubscription().change_subscribe( _ = ChannelSubscription().change_subscribe(
channel_id, channel_subscribed=True channel_id, channel_subscribed=True
) )

View File

@@ -10,9 +10,9 @@ from io import BytesIO
from time import sleep from time import sleep
import requests import requests
from home.src.es.connect import ElasticWrap, IndexPaginate from common.src.env_settings import EnvironmentSettings
from home.src.ta.helper import is_missing from common.src.es_connect import ElasticWrap, IndexPaginate
from home.src.ta.settings import EnvironmentSettings from common.src.helper import is_missing
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError

View File

@@ -4,14 +4,14 @@ functionality:
- handle yt-dlp errors - handle yt-dlp errors
""" """
import os
from datetime import datetime from datetime import datetime
from http import cookiejar from http import cookiejar
from io import StringIO from io import StringIO
import yt_dlp import yt_dlp
from home.src.ta.settings import EnvironmentSettings from appsettings.src.config import AppConfig
from home.src.ta.ta_redis import RedisArchivist from common.src.ta_redis import RedisArchivist
from django.conf import settings
class YtWrap: class YtWrap:
@@ -36,14 +36,33 @@ class YtWrap:
self.obs = self.OBS_BASE.copy() self.obs = self.OBS_BASE.copy()
self.obs.update(self.obs_request) self.obs.update(self.obs_request)
if self.config: if self.config:
self.add_cookie() self._add_cookie()
self._add_potoken()
def add_cookie(self): if getattr(settings, "DEBUG", False):
print(self.obs)
def _add_cookie(self):
"""add cookie if enabled""" """add cookie if enabled"""
if self.config["downloads"]["cookie_import"]: if self.config["downloads"]["cookie_import"]:
cookie_io = CookieHandler(self.config).get() cookie_io = CookieHandler(self.config).get()
self.obs["cookiefile"] = cookie_io self.obs["cookiefile"] = cookie_io
def _add_potoken(self):
"""add potoken if enabled"""
if self.config["downloads"].get("potoken"):
potoken = POTokenHandler(self.config).get()
self.obs.update(
{
"extractor_args": {
"youtube": {
"po_token": [potoken],
"player-client": ["web", "default"],
},
}
}
)
def download(self, url): def download(self, url):
"""make download request""" """make download request"""
with yt_dlp.YoutubeDL(self.obs) as ydl: with yt_dlp.YoutubeDL(self.obs) as ydl:
@@ -56,6 +75,8 @@ class YtWrap:
return False, str(err) return False, str(err)
self._validate_cookie()
return True, True return True, True
def extract(self, url): def extract(self, url):
@@ -78,8 +99,21 @@ class YtWrap:
return False return False
self._validate_cookie()
return response return response
def _validate_cookie(self):
"""check cookie and write it back for next use"""
if not self.obs.get("cookiefile"):
return
new_cookie = self.obs["cookiefile"].read()
old_cookie = RedisArchivist().get_message_str("cookie")
if new_cookie and old_cookie != new_cookie:
print("refreshed stored cookie")
RedisArchivist().set_message("cookie", new_cookie, save=True)
class CookieHandler: class CookieHandler:
"""handle youtube cookie for yt-dlp""" """handle youtube cookie for yt-dlp"""
@@ -87,37 +121,17 @@ class CookieHandler:
def __init__(self, config): def __init__(self, config):
self.cookie_io = False self.cookie_io = False
self.config = config self.config = config
self.cache_dir = EnvironmentSettings.CACHE_DIR
def get(self): def get(self):
"""get cookie io stream""" """get cookie io stream"""
cookie = RedisArchivist().get_message("cookie") cookie = RedisArchivist().get_message_str("cookie")
self.cookie_io = StringIO(cookie) self.cookie_io = StringIO(cookie)
return self.cookie_io return self.cookie_io
def import_cookie(self):
"""import cookie from file"""
import_path = os.path.join(
self.cache_dir, "import", "cookies.google.txt"
)
try:
with open(import_path, encoding="utf-8") as cookie_file:
cookie = cookie_file.read()
except FileNotFoundError as err:
print(f"cookie: {import_path} file not found")
raise err
self.set_cookie(cookie)
os.remove(import_path)
print("cookie: import successful")
def set_cookie(self, cookie): def set_cookie(self, cookie):
"""set cookie str and activate in config""" """set cookie str and activate in config"""
RedisArchivist().set_message("cookie", cookie, save=True) RedisArchivist().set_message("cookie", cookie, save=True)
path = ".downloads.cookie_import" AppConfig().update_config({"downloads.cookie_import": True})
RedisArchivist().set_message("config", True, path=path, save=True)
self.config["downloads"]["cookie_import"] = True self.config["downloads"]["cookie_import"] = True
print("cookie: activated and stored in Redis") print("cookie: activated and stored in Redis")
@@ -126,9 +140,7 @@ class CookieHandler:
"""revoke cookie""" """revoke cookie"""
RedisArchivist().del_message("cookie") RedisArchivist().del_message("cookie")
RedisArchivist().del_message("cookie:valid") RedisArchivist().del_message("cookie:valid")
RedisArchivist().set_message( AppConfig().update_config({"downloads.cookie_import": False})
"config", False, path=".downloads.cookie_import"
)
print("cookie: revoked") print("cookie: revoked")
def validate(self): def validate(self):
@@ -171,3 +183,27 @@ class CookieHandler:
"validated_str": now.strftime("%Y-%m-%d %H:%M"), "validated_str": now.strftime("%Y-%m-%d %H:%M"),
} }
RedisArchivist().set_message("cookie:valid", message) RedisArchivist().set_message("cookie:valid", message)
class POTokenHandler:
"""handle po token"""
REDIS_KEY = "potoken"
def __init__(self, config):
self.config = config
def get(self) -> str | None:
"""get PO token"""
potoken = RedisArchivist().get_message_str(self.REDIS_KEY)
return potoken
def set_token(self, new_token: str) -> None:
"""set new PO token"""
RedisArchivist().set_message(self.REDIS_KEY, new_token)
AppConfig().update_config({"downloads.potoken": True})
def revoke_token(self) -> None:
"""revoke token"""
RedisArchivist().del_message(self.REDIS_KEY)
AppConfig().update_config({"downloads.potoken": False})

View File

@@ -10,19 +10,23 @@ import os
import shutil import shutil
from datetime import datetime from datetime import datetime
from home.src.download.queue import PendingList from appsettings.src.config import AppConfig
from home.src.download.subscriptions import PlaylistSubscription from channel.src.index import YoutubeChannel
from home.src.download.yt_dlp_base import YtWrap from common.src.env_settings import EnvironmentSettings
from home.src.es.connect import ElasticWrap, IndexPaginate from common.src.es_connect import ElasticWrap, IndexPaginate
from home.src.index.channel import YoutubeChannel from common.src.helper import (
from home.src.index.comments import CommentList get_channel_overwrites,
from home.src.index.playlist import YoutubePlaylist ignore_filelist,
from home.src.index.video import YoutubeVideo, index_new_video rand_sleep,
from home.src.index.video_constants import VideoTypeEnum )
from home.src.ta.config import AppConfig from common.src.ta_redis import RedisQueue
from home.src.ta.helper import get_channel_overwrites, ignore_filelist from download.src.queue import PendingList
from home.src.ta.settings import EnvironmentSettings from download.src.subscriptions import PlaylistSubscription
from home.src.ta.ta_redis import RedisQueue from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist
from video.src.comments import CommentList
from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo, index_new_video
class DownloaderBase: class DownloaderBase:
@@ -370,6 +374,7 @@ class DownloadPostProcess(DownloaderBase):
] ]
progress = idx / total progress = idx / total
self.task.send_progress(message, progress=progress) self.task.send_progress(message, progress=progress)
rand_sleep(self.config)
def add_playlists_to_refresh(self) -> None: def add_playlists_to_refresh(self) -> None:
"""add playlists to refresh""" """add playlists to refresh"""

18
backend/download/urls.py Normal file
View File

@@ -0,0 +1,18 @@
"""all download API urls"""
from django.urls import path
from download import views
urlpatterns = [
path("", views.DownloadApiListView.as_view(), name="api-download-list"),
path(
"aggs/",
views.DownloadAggsApiView.as_view(),
name="api-download-aggs",
),
path(
"<slug:video_id>/",
views.DownloadApiView.as_view(),
name="api-download",
),
]

170
backend/download/views.py Normal file
View File

@@ -0,0 +1,170 @@
"""all download API views"""
from common.views_base import AdminOnly, ApiBaseView
from download.src.queue import PendingInteract
from rest_framework.response import Response
from task.tasks import download_pending, extrac_dl
class DownloadApiListView(ApiBaseView):
"""resolves to /api/download/
GET: returns latest videos in the download queue
POST: add a list of videos to download queue
DELETE: remove items based on query filter
"""
search_base = "ta_download/_search/"
valid_filter = ["pending", "ignore"]
permission_classes = [AdminOnly]
def get(self, request):
"""get request"""
query_filter = request.GET.get("filter", False)
self.data.update({"sort": [{"timestamp": {"order": "asc"}}]})
must_list = []
if query_filter:
if query_filter not in self.valid_filter:
message = f"invalid url query filter: {query_filter}"
print(message)
return Response({"message": message}, status=400)
must_list.append({"term": {"status": {"value": query_filter}}})
filter_channel = request.GET.get("channel", False)
if filter_channel:
must_list.append(
{"term": {"channel_id": {"value": filter_channel}}}
)
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
return Response(self.response)
@staticmethod
def post(request):
"""add list of videos to download queue"""
data = request.data
auto_start = bool(request.GET.get("autostart"))
try:
to_add = data["data"]
except KeyError:
message = "missing expected data key"
print(message)
return Response({"message": message}, status=400)
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
url_str = " ".join(pending)
extrac_dl.delay(url_str, auto_start=auto_start)
return Response(data)
def delete(self, request):
"""delete download queue"""
query_filter = request.GET.get("filter", False)
if query_filter not in self.valid_filter:
message = f"invalid url query filter: {query_filter}"
print(message)
return Response({"message": message}, status=400)
message = f"delete queue by status: {query_filter}"
print(message)
PendingInteract(status=query_filter).delete_by_status()
return Response({"message": message})
class DownloadAggsApiView(ApiBaseView):
"""resolves to /api/download/aggs/
GET: get download aggregations
"""
search_base = "ta_download/_search"
valid_filter_view = ["ignore", "pending"]
def get(self, request):
"""get aggs"""
filter_view = request.GET.get("filter")
if filter_view:
if filter_view not in self.valid_filter_view:
message = f"invalid filter: {filter_view}"
return Response({"message": message}, status=400)
self.data.update(
{
"query": {"term": {"status": {"value": filter_view}}},
}
)
self.data.update(
{
"aggs": {
"channel_downloads": {
"multi_terms": {
"size": 30,
"terms": [
{"field": "channel_name.keyword"},
{"field": "channel_id"},
],
"order": {"_count": "desc"},
}
}
}
}
)
self.get_aggs()
return Response(self.response)
class DownloadApiView(ApiBaseView):
"""resolves to /api/download/<video_id>/
GET: returns metadata dict of an item in the download queue
POST: update status of item to pending or ignore
DELETE: forget from download queue
"""
search_base = "ta_download/_doc/"
valid_status = ["pending", "ignore", "ignore-force", "priority"]
permission_classes = [AdminOnly]
def get(self, request, video_id):
# pylint: disable=unused-argument
"""get request"""
self.get_document(video_id)
return Response(self.response, status=self.status_code)
def post(self, request, video_id):
"""post to video to change status"""
item_status = request.data.get("status")
if item_status not in self.valid_status:
message = f"{video_id}: invalid status {item_status}"
print(message)
return Response({"message": message}, status=400)
if item_status == "ignore-force":
extrac_dl.delay(video_id, status="ignore")
message = f"{video_id}: set status to ignore"
return Response(request.data)
_, status_code = PendingInteract(video_id).get_item()
if status_code == 404:
message = f"{video_id}: item not found {status_code}"
return Response({"message": message}, status=404)
print(f"{video_id}: change status to {item_status}")
PendingInteract(video_id, item_status).update_status()
if item_status == "priority":
download_pending.delay(auto_only=True)
return Response(request.data)
@staticmethod
def delete(request, video_id):
# pylint: disable=unused-argument
"""delete single video from queue"""
print(f"{video_id}: delete from queue")
PendingInteract(video_id).delete_item()
return Response({"success": True})

View File

View File

View File

@@ -0,0 +1,10 @@
"""playlist constants"""
import enum
class PlaylistTypesEnum(enum.Enum):
"""all playlist_type options"""
REGULAR = "regular"
CUSTOM = "custom"

View File

@@ -7,11 +7,12 @@ functionality:
import json import json
from datetime import datetime from datetime import datetime
from home.src.download.thumbnails import ThumbManager from channel.src import index as channel
from home.src.es.connect import ElasticWrap, IndexPaginate from common.src.env_settings import EnvironmentSettings
from home.src.index import channel from common.src.es_connect import ElasticWrap, IndexPaginate
from home.src.index.generic import YouTubeItem from common.src.index_generic import YouTubeItem
from home.src.index.video import YoutubeVideo from download.src.thumbnails import ThumbManager
from video.src import index as ta_video
class YoutubePlaylist(YouTubeItem): class YoutubePlaylist(YouTubeItem):
@@ -93,13 +94,10 @@ class YoutubePlaylist(YouTubeItem):
"""get all videos in playlist, match downloaded with ids_found""" """get all videos in playlist, match downloaded with ids_found"""
all_members = [] all_members = []
for idx, entry in enumerate(self.youtube_meta["entries"]): for idx, entry in enumerate(self.youtube_meta["entries"]):
if not entry["channel"]:
continue
to_append = { to_append = {
"youtube_id": entry["id"], "youtube_id": entry["id"],
"title": entry["title"], "title": entry["title"],
"uploader": entry["channel"], "uploader": entry.get("channel"),
"idx": idx, "idx": idx,
"downloaded": entry["id"] in ids_found, "downloaded": entry["id"] in ids_found,
} }
@@ -149,6 +147,9 @@ class YoutubePlaylist(YouTubeItem):
"query": {"match": {"playlist": self.youtube_id}}, "query": {"match": {"playlist": self.youtube_id}},
"_source": ["youtube_id"], "_source": ["youtube_id"],
} }
data = {
"query": {"term": {"playlist.keyword": {"value": self.youtube_id}}}
}
result = IndexPaginate("ta_video", data).get_results() 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
@@ -190,6 +191,7 @@ class YoutubePlaylist(YouTubeItem):
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()
all_entries_available = self.json_data["playlist_entries"] all_entries_available = self.json_data["playlist_entries"]
all_entries = [i for i in all_entries_available if i["downloaded"]] all_entries = [i for i in all_entries_available if i["downloaded"]]
current = [i for i in all_entries if i["youtube_id"] == youtube_id] current = [i for i in all_entries if i["youtube_id"] == youtube_id]
@@ -203,14 +205,16 @@ class YoutubePlaylist(YouTubeItem):
else: else:
previous_item = all_entries[current_idx - 1] previous_item = all_entries[current_idx - 1]
prev_id = previous_item["youtube_id"] prev_id = previous_item["youtube_id"]
previous_item["vid_thumb"] = ThumbManager(prev_id).vid_thumb_path() prev_thumb_path = ThumbManager(prev_id).vid_thumb_path()
previous_item["vid_thumb"] = f"{cache_root}/{prev_thumb_path}"
if current_idx == len(all_entries) - 1: if current_idx == len(all_entries) - 1:
next_item = False next_item = False
else: else:
next_item = all_entries[current_idx + 1] next_item = all_entries[current_idx + 1]
next_id = next_item["youtube_id"] next_id = next_item["youtube_id"]
next_item["vid_thumb"] = ThumbManager(next_id).vid_thumb_path() next_thumb_path = ThumbManager(next_id).vid_thumb_path()
next_item["vid_thumb"] = f"{cache_root}/{next_thumb_path}"
self.nav = { self.nav = {
"playlist_meta": { "playlist_meta": {
@@ -255,7 +259,7 @@ class YoutubePlaylist(YouTubeItem):
i = 0 i = 0
while i < len(playlist): while i < len(playlist):
video_id = playlist[i]["youtube_id"] video_id = playlist[i]["youtube_id"]
video = YoutubeVideo(video_id) video = ta_video.YoutubeVideo(video_id)
video.get_from_es() video.get_from_es()
if ( if (
channel_id is None channel_id is None
@@ -278,7 +282,7 @@ class YoutubePlaylist(YouTubeItem):
if i["downloaded"] if i["downloaded"]
] ]
for youtube_id in all_youtube_id: for youtube_id in all_youtube_id:
YoutubeVideo(youtube_id).delete_media_file() ta_video.YoutubeVideo(youtube_id).delete_media_file()
self.delete_metadata() self.delete_metadata()
@@ -312,7 +316,7 @@ class YoutubePlaylist(YouTubeItem):
) )
self.set_playlist_thumbnail() self.set_playlist_thumbnail()
self.upload_to_es() self.upload_to_es()
video = YoutubeVideo(video_id) video = ta_video.YoutubeVideo(video_id)
video.get_from_es() video.get_from_es()
if "playlist" not in video.json_data: if "playlist" not in video.json_data:
video.json_data["playlist"] = [] video.json_data["playlist"] = []
@@ -321,7 +325,7 @@ class YoutubePlaylist(YouTubeItem):
return True return True
def remove_playlist_from_video(self, video_id): def remove_playlist_from_video(self, video_id):
video = YoutubeVideo(video_id) video = ta_video.YoutubeVideo(video_id)
video.get_from_es() video.get_from_es()
if video.json_data is not None and "playlist" in video.json_data: if video.json_data is not None and "playlist" in video.json_data:
video.json_data["playlist"].remove(self.youtube_id) video.json_data["playlist"].remove(self.youtube_id)
@@ -410,7 +414,7 @@ class YoutubePlaylist(YouTubeItem):
) )
def get_video_is_watched(self, video_id): def get_video_is_watched(self, video_id):
video = YoutubeVideo(video_id) video = ta_video.YoutubeVideo(video_id)
video.get_from_es() video.get_from_es()
return video.json_data["player"]["watched"] return video.json_data["player"]["watched"]
@@ -426,7 +430,7 @@ class YoutubePlaylist(YouTubeItem):
self.get_playlist_art() self.get_playlist_art()
def get_video_metadata(self, video_id): def get_video_metadata(self, video_id):
video = YoutubeVideo(video_id) video = ta_video.YoutubeVideo(video_id)
video.get_from_es() video.get_from_es()
video_json_data = { video_json_data = {
"youtube_id": video.json_data["youtube_id"], "youtube_id": video.json_data["youtube_id"],

View File

@@ -0,0 +1,53 @@
"""build query for playlists"""
from playlist.src.constants import PlaylistTypesEnum
class QueryBuilder:
"""contain functionality"""
def __init__(self, **kwargs):
self.request_params = kwargs
def build_data(self) -> dict:
"""build data dict"""
data = {}
data["query"] = self.build_query()
if sort := self.parse_sort():
data.update(sort)
return data
def build_query(self) -> dict:
"""build query key"""
must_list = []
channel = self.request_params.get("channel")
if channel:
must_list.append({"match": {"playlist_channel_id": channel[0]}})
subscribed = self.request_params.get("subscribed")
if subscribed:
subed_bool = subscribed[0] == "true"
must_list.append({"match": {"playlist_subscribed": subed_bool}})
playlist_type = self.request_params.get("type")
if playlist_type:
type_list = self.parse_type(playlist_type[0])
must_list.append(type_list)
query = {"bool": {"must": must_list}}
return query
def parse_type(self, playlist_type: str) -> dict:
"""parse playlist type"""
if not hasattr(PlaylistTypesEnum, playlist_type.upper()):
raise ValueError(f"'{playlist_type}' not in PlaylistTypesEnum")
type_parsed = getattr(PlaylistTypesEnum, playlist_type.upper()).value
return {"match": {"playlist_type.keyword": type_parsed}}
def parse_sort(self) -> dict:
"""return sort"""
return {"sort": [{"playlist_name.keyword": {"order": "asc"}}]}

View File

View File

@@ -0,0 +1,30 @@
"""test playlist query building"""
import pytest
from playlist.src.query_building import QueryBuilder
def test_build_data():
"""test for correct key building"""
qb = QueryBuilder(
channel=["test_channel"],
subscribed=["true"],
type=["regular"],
)
result = qb.build_data()
must_list = result["query"]["bool"]["must"]
assert "query" in result
assert "sort" in result
assert result["sort"] == [{"playlist_name.keyword": {"order": "asc"}}]
assert {"match": {"playlist_channel_id": "test_channel"}} in must_list
assert {"match": {"playlist_subscribed": True}} in must_list
def test_parse_type():
"""validate type"""
qb = QueryBuilder(type=["regular"])
with pytest.raises(ValueError):
qb.parse_type("invalid")
result = qb.parse_type("custom")
assert result == {"match": {"playlist_type.keyword": "custom"}}

17
backend/playlist/urls.py Normal file
View File

@@ -0,0 +1,17 @@
"""all playlist API urls"""
from django.urls import path
from playlist import views
urlpatterns = [
path(
"",
views.PlaylistApiListView.as_view(),
name="api-playlist-list",
),
path(
"<slug:playlist_id>/",
views.PlaylistApiView.as_view(),
name="api-playlist",
),
]

138
backend/playlist/views.py Normal file
View File

@@ -0,0 +1,138 @@
"""all playlist API views"""
import uuid
from common.views_base import AdminWriteOnly, ApiBaseView
from download.src.subscriptions import PlaylistSubscription
from playlist.src.index import YoutubePlaylist
from playlist.src.query_building import QueryBuilder
from rest_framework import status
from rest_framework.response import Response
from task.tasks import subscribe_to
from user.src.user_config import UserConfig
class PlaylistApiListView(ApiBaseView):
"""resolves to /api/playlist/
GET: returns list of indexed playlists
params:
- channel:str=<channel-id>
- subscribed: bool
- type:enum=regular|custom
POST: change subscribe state
"""
search_base = "ta_playlist/_search/"
permission_classes = [AdminWriteOnly]
def get(self, request):
"""get request"""
try:
data = QueryBuilder(**request.GET).build_data()
except ValueError as err:
return Response({"error": str(err)}, status=400)
self.data = data
self.get_document_list(request)
return Response(self.response)
def post(self, request):
"""subscribe/unsubscribe to list of playlists"""
data = request.data
try:
to_add = data["data"]
except KeyError:
message = "missing expected data key"
print(message)
return Response({"message": message}, status=400)
data = data["data"]
if isinstance(data, dict):
custom_name = data.get("create")
if custom_name:
playlist_id = f"TA_playlist_{uuid.uuid4()}"
custom_playlist = YoutubePlaylist(playlist_id)
custom_playlist.create(custom_name)
return Response(custom_playlist.json_data)
pending = []
for playlist_item in to_add:
playlist_id = playlist_item["playlist_id"]
if playlist_item["playlist_subscribed"]:
pending.append(playlist_id)
else:
self._unsubscribe(playlist_id)
if pending:
url_str = " ".join(pending)
subscribe_to.delay(url_str, expected_type="playlist")
return Response(data)
@staticmethod
def _unsubscribe(playlist_id: str):
"""unsubscribe"""
print(f"[{playlist_id}] unsubscribe from playlist")
_ = PlaylistSubscription().change_subscribe(
playlist_id, subscribe_status=False
)
class PlaylistApiView(ApiBaseView):
"""resolves to /api/playlist/<playlist_id>/
GET: returns metadata dict of playlist
"""
search_base = "ta_playlist/_doc/"
permission_classes = [AdminWriteOnly]
valid_custom_actions = ["create", "remove", "up", "down", "top", "bottom"]
def get(self, request, playlist_id):
# pylint: disable=unused-argument
"""get request"""
self.get_document(playlist_id)
return Response(self.response, status=self.status_code)
def post(self, request, playlist_id):
"""post to custom playlist to add a video to list"""
self.get_document(playlist_id)
if not self.response["data"]:
return Response({"error": "playlist not found"}, status=404)
data = request.data
subscribed = data.get("playlist_subscribed")
if subscribed is not None:
playlist_sub = PlaylistSubscription()
json_data = playlist_sub.change_subscribe(playlist_id, subscribed)
return Response(json_data, status=200)
if not self.response["data"]["playlist_type"] == "custom":
message = f"playlist with ID {playlist_id} is not custom"
return Response({"message": message}, status=400)
action = request.data.get("action")
if action not in self.valid_custom_actions:
message = f"invalid action: {action}"
return Response({"message": message}, status=400)
playlist = YoutubePlaylist(playlist_id)
video_id = request.data.get("video_id")
if action == "create":
playlist.add_video_to_playlist(video_id)
else:
hide = UserConfig(request.user.id).get_value("hide_watched")
playlist.move_video(video_id, action, hide_watched=hide)
return Response({"success": True}, status=status.HTTP_201_CREATED)
def delete(self, request, playlist_id):
"""delete playlist"""
print(f"{playlist_id}: delete playlist")
delete_videos = request.GET.get("delete-videos", False)
if delete_videos:
YoutubePlaylist(playlist_id).delete_videos_playlist()
else:
YoutubePlaylist(playlist_id).delete_metadata()
return Response({"success": True})

View File

@@ -0,0 +1,10 @@
-r requirements.txt
ipython==8.31.0
pre-commit==4.1.0
pylint-django==2.6.1
pylint==3.3.3
pytest-django==4.9.0
pytest==8.3.4
python-dotenv==1.0.1
requirementscheck==0.0.5
types-requests==2.32.0.20241016

View File

@@ -1,14 +1,14 @@
apprise==1.9.0 apprise==1.9.2
celery==5.4.0 celery==5.4.0
django-auth-ldap==5.1.0 django-auth-ldap==5.1.0
django-celery-beat==2.7.0 django-celery-beat==2.7.0
django-cors-headers==4.6.0 django-cors-headers==4.6.0
Django==5.1.3 Django==5.1.5
djangorestframework==3.15.2 djangorestframework==3.15.2
Pillow==11.0.0 Pillow==11.1.0
redis==5.2.0 redis==5.2.1
requests==2.32.3 requests==2.32.3
ryd-client==0.0.6 ryd-client==0.0.6
uWSGI==2.0.28 uvicorn==0.34.0
whitenoise==6.8.2 whitenoise==6.8.2
yt-dlp[default]==2024.11.4 yt-dlp[default]==2025.1.26

View File

View File

View File

View File

@@ -1,8 +1,8 @@
"""aggregations""" """aggregations"""
from home.src.es.connect import ElasticWrap from common.src.env_settings import EnvironmentSettings
from home.src.ta.helper import get_duration_str from common.src.es_connect import ElasticWrap
from home.src.ta.settings import EnvironmentSettings from common.src.helper import get_duration_str
class AggBase: class AggBase:
@@ -55,6 +55,8 @@ class Video(AggBase):
def process(self): def process(self):
"""process aggregation""" """process aggregation"""
aggregations = self.get() aggregations = self.get()
if not aggregations:
return None
duration = int(aggregations["duration"]["value"]) duration = int(aggregations["duration"]["value"])
response = { response = {
@@ -109,6 +111,8 @@ class Channel(AggBase):
def process(self): def process(self):
"""process aggregation""" """process aggregation"""
aggregations = self.get() aggregations = self.get()
if not aggregations:
return None
response = { response = {
"doc_count": aggregations["channel_count"].get("value"), "doc_count": aggregations["channel_count"].get("value"),
@@ -140,6 +144,9 @@ class Playlist(AggBase):
def process(self): def process(self):
"""process aggregation""" """process aggregation"""
aggregations = self.get() aggregations = self.get()
if not aggregations:
return None
response = {"doc_count": aggregations["playlist_count"].get("value")} response = {"doc_count": aggregations["playlist_count"].get("value")}
for bucket in aggregations["playlist_active"]["buckets"]: for bucket in aggregations["playlist_active"]["buckets"]:
key = f"active_{bucket['key_as_string']}" key = f"active_{bucket['key_as_string']}"
@@ -171,6 +178,9 @@ class Download(AggBase):
"""process aggregation""" """process aggregation"""
aggregations = self.get() aggregations = self.get()
response = {} response = {}
if not aggregations:
return None
for bucket in aggregations["status"]["buckets"]: for bucket in aggregations["status"]["buckets"]:
response.update({bucket["key"]: bucket.get("doc_count")}) response.update({bucket["key"]: bucket.get("doc_count")})
@@ -209,9 +219,11 @@ class WatchProgress(AggBase):
def process(self): def process(self):
"""make the call""" """make the call"""
aggregations = self.get() aggregations = self.get()
buckets = aggregations[self.name]["buckets"]
response = {} response = {}
if not aggregations:
return None
buckets = aggregations[self.name]["buckets"]
all_duration = int(aggregations["total_duration"].get("value")) all_duration = int(aggregations["total_duration"].get("value"))
response.update( response.update(
{ {
@@ -287,6 +299,9 @@ class DownloadHist(AggBase):
def process(self): def process(self):
"""process query""" """process query"""
aggregations = self.get() aggregations = self.get()
if not aggregations:
return None
buckets = aggregations[self.name]["buckets"] buckets = aggregations[self.name]["buckets"]
response = [ response = [
@@ -334,6 +349,9 @@ class BiggestChannel(AggBase):
"""process aggregation, order_by validated in the view""" """process aggregation, order_by validated in the view"""
aggregations = self.get() aggregations = self.get()
if not aggregations:
return None
buckets = aggregations[self.name]["buckets"] buckets = aggregations[self.name]["buckets"]
response = [ response = [

42
backend/stats/urls.py Normal file
View File

@@ -0,0 +1,42 @@
"""all stats API urls"""
from django.urls import path
from stats import views
urlpatterns = [
path(
"video/",
views.StatVideoView.as_view(),
name="api-stats-video",
),
path(
"channel/",
views.StatChannelView.as_view(),
name="api-stats-channel",
),
path(
"playlist/",
views.StatPlaylistView.as_view(),
name="api-stats-playlist",
),
path(
"download/",
views.StatDownloadView.as_view(),
name="api-stats-download",
),
path(
"watch/",
views.StatWatchProgress.as_view(),
name="api-stats-watch",
),
path(
"downloadhist/",
views.StatDownloadHist.as_view(),
name="api-stats-downloadhist",
),
path(
"biggestchannels/",
views.StatBiggestChannel.as_view(),
name="api-stats-biggestchannels",
),
]

104
backend/stats/views.py Normal file
View File

@@ -0,0 +1,104 @@
"""all stats API views"""
from common.views_base import ApiBaseView
from rest_framework.response import Response
from stats.src.aggs import (
BiggestChannel,
Channel,
Download,
DownloadHist,
Playlist,
Video,
WatchProgress,
)
class StatVideoView(ApiBaseView):
"""resolves to /api/stats/video/
GET: return video stats
"""
def get(self, request):
"""get stats"""
# pylint: disable=unused-argument
return Response(Video().process())
class StatChannelView(ApiBaseView):
"""resolves to /api/stats/channel/
GET: return channel stats
"""
def get(self, request):
"""get stats"""
# pylint: disable=unused-argument
return Response(Channel().process())
class StatPlaylistView(ApiBaseView):
"""resolves to /api/stats/playlist/
GET: return playlist stats
"""
def get(self, request):
"""get stats"""
# pylint: disable=unused-argument
return Response(Playlist().process())
class StatDownloadView(ApiBaseView):
"""resolves to /api/stats/download/
GET: return download stats
"""
def get(self, request):
"""get stats"""
# pylint: disable=unused-argument
return Response(Download().process())
class StatWatchProgress(ApiBaseView):
"""resolves to /api/stats/watchprogress/
GET: return watch/unwatch progress stats
"""
def get(self, request):
"""handle get request"""
# pylint: disable=unused-argument
return Response(WatchProgress().process())
class StatDownloadHist(ApiBaseView):
"""resolves to /api/stats/downloadhist/
GET: return download video count histogram for last days
"""
def get(self, request):
"""handle get request"""
# pylint: disable=unused-argument
return Response(DownloadHist().process())
class StatBiggestChannel(ApiBaseView):
"""resolves to /api/stats/biggestchannels/
GET: return biggest channels
param: order
"""
order_choices = ["doc_count", "duration", "media_size"]
def get(self, request):
"""handle get request"""
order = request.GET.get("order", "doc_count")
if order and order not in self.order_choices:
message = {"message": f"invalid order parameter {order}"}
return Response(message, status=400)
return Response(BiggestChannel(order).process())

Some files were not shown because too many files have changed in this diff Show More