diff --git a/.dockerignore b/.dockerignore index f6357418..66ada3ec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,4 +18,4 @@ venv/ assets/* # for local testing only -testing.sh \ No newline at end of file +testing.sh diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 1c044e19..00000000 --- a/.eslintrc.js +++ /dev/null @@ -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', - }, -}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1a7002e3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docker_assets\run.sh eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index de69a854..9e651e5d 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ github: bbilly1 ko_fi: bbilly1 -custom: https://paypal.me/bbilly1 \ No newline at end of file +custom: https://paypal.me/bbilly1 diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml index 879e6864..fbdc7998 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -6,7 +6,7 @@ body: - type: checkboxes id: block 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: - label: I understand that this issue will be closed without comment. required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ba13e0c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/workflows/lint_js.yml b/.github/workflows/lint_js.yml deleted file mode 100644 index de2650b3..00000000 --- a/.github/workflows/lint_js.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/lint_python.yml b/.github/workflows/lint_python.yml deleted file mode 100644 index 0aae9597..00000000 --- a/.github/workflows/lint_python.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml new file mode 100644 index 00000000..b9e0507d --- /dev/null +++ b/.github/workflows/pre_commit.yml @@ -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 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 33f3bd5f..d9109fcd 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -37,7 +37,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r tubearchivist/requirements-dev.txt + pip install -r backend/requirements-dev.txt - name: Run unit tests - run: pytest tubearchivist + run: pytest backend diff --git a/.gitignore b/.gitignore index 682dd836..c5d22b51 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ __pycache__ .venv -# django testing db -db.sqlite3 +# django testing +backend/static +backend/.env # vscode custom conf .vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..b2430617 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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/).*' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d86d2047..f771c767 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,9 +138,55 @@ The documentation available at [docs.tubearchivist.com](https://docs.tubearchivi ## 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. diff --git a/Dockerfile b/Dockerfile index f9c4ccb0..00cc1b84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,17 @@ # multi stage to build tube archivist # 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 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 # install requirements -COPY ./tubearchivist/requirements.txt /requirements.txt +COPY ./backend/requirements.txt /requirements.txt RUN pip install --user -r requirements.txt # 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 # copy application into container -COPY ./tubearchivist /app +COPY ./backend /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 VOLUME /cache diff --git a/README.md b/README.md index 4d724072..ba85de13 100644 --- a/README.md +++ b/README.md @@ -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_PASSWORD | Initial password when logging into TA | 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 | | 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_AUTH_PROXY_USERNAME_HEADER | Header containing username to log in | 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_UID | Allow TA to own the video files instead of container user | Optional | | ELASTIC_USER | Change the default ElasticSearch user | Optional | -| REDIS_PORT | Port that Redis runs on | Optional | | TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) | | ENABLE_CAST | Enable casting support | [Read more](https://docs.tubearchivist.com/configuration/cast/) | | DJANGO_DEBUG | Return additional error messages, for debug only | | diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..a3de2312 --- /dev/null +++ b/backend/README.md @@ -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 diff --git a/tubearchivist/api/__init__.py b/backend/appsettings/__init__.py similarity index 100% rename from tubearchivist/api/__init__.py rename to backend/appsettings/__init__.py diff --git a/tubearchivist/home/src/es/index_mapping.json b/backend/appsettings/index_mapping.json similarity index 99% rename from tubearchivist/home/src/es/index_mapping.json rename to backend/appsettings/index_mapping.json index 1635b6b1..236890a2 100644 --- a/tubearchivist/home/src/es/index_mapping.json +++ b/backend/appsettings/index_mapping.json @@ -684,4 +684,4 @@ } } ] -} \ No newline at end of file +} diff --git a/tubearchivist/api/migrations/__init__.py b/backend/appsettings/migrations/__init__.py similarity index 100% rename from tubearchivist/api/migrations/__init__.py rename to backend/appsettings/migrations/__init__.py diff --git a/tubearchivist/api/src/__init__.py b/backend/appsettings/src/__init__.py similarity index 100% rename from tubearchivist/api/src/__init__.py rename to backend/appsettings/src/__init__.py diff --git a/tubearchivist/home/src/es/backup.py b/backend/appsettings/src/backup.py similarity index 96% rename from tubearchivist/home/src/es/backup.py rename to backend/appsettings/src/backup.py index 3f134472..a26eb838 100644 --- a/tubearchivist/home/src/es/backup.py +++ b/backend/appsettings/src/backup.py @@ -10,11 +10,10 @@ import os import zipfile from datetime import datetime -from home.models import CustomPeriodicTask -from home.src.es.connect import ElasticWrap, IndexPaginate -from home.src.ta.config import AppConfig -from home.src.ta.helper import get_mapping, ignore_filelist -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.helper import get_mapping, ignore_filelist +from task.models import CustomPeriodicTask class ElasticBackup: @@ -24,8 +23,7 @@ class ElasticBackup: CACHE_DIR = EnvironmentSettings.CACHE_DIR BACKUP_DIR = os.path.join(CACHE_DIR, "backup") - def __init__(self, reason=False, task=False): - self.config = AppConfig().config + def __init__(self, reason=False, task=False) -> None: self.timestamp = datetime.now().strftime("%Y%m%d") self.index_config = get_mapping() self.reason = reason @@ -139,6 +137,8 @@ class ElasticBackup: elif len(file_split) == 3: timestamp = file_split[1] reason = file_split[2].strip(".zip") + else: + raise ValueError data = { "filename": filename, diff --git a/backend/appsettings/src/config.py b/backend/appsettings/src/config.py new file mode 100644 index 00000000..58632ace --- /dev/null +++ b/backend/appsettings/src/config.py @@ -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 diff --git a/tubearchivist/home/src/index/filesystem.py b/backend/appsettings/src/filesystem.py similarity index 93% rename from tubearchivist/home/src/index/filesystem.py rename to backend/appsettings/src/filesystem.py index 484271a8..64aad6aa 100644 --- a/tubearchivist/home/src/index/filesystem.py +++ b/backend/appsettings/src/filesystem.py @@ -5,11 +5,11 @@ Functionality: import os -from home.src.es.connect import ElasticWrap, IndexPaginate -from home.src.index.comments import CommentList -from home.src.index.video import YoutubeVideo, index_new_video -from home.src.ta.helper import ignore_filelist -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.helper import ignore_filelist +from video.src.comments import CommentList +from video.src.index import YoutubeVideo, index_new_video class Scanner: diff --git a/tubearchivist/home/src/es/index_setup.py b/backend/appsettings/src/index_setup.py similarity index 95% rename from tubearchivist/home/src/es/index_setup.py rename to backend/appsettings/src/index_setup.py index c913da75..d22f07d3 100644 --- a/tubearchivist/home/src/es/index_setup.py +++ b/backend/appsettings/src/index_setup.py @@ -5,11 +5,11 @@ functionality: - backup and restore metadata """ -from home.src.es.backup import ElasticBackup -from home.src.es.connect import ElasticWrap -from home.src.es.snapshot import ElasticSnapshot -from home.src.ta.config import AppConfig -from home.src.ta.helper import get_mapping +from appsettings.src.backup import ElasticBackup +from appsettings.src.config import AppConfig +from appsettings.src.snapshot import ElasticSnapshot +from common.src.es_connect import ElasticWrap +from common.src.helper import get_mapping class ElasticIndex: @@ -111,6 +111,8 @@ class ElasticIndex: elif method == "restore": source = f"ta_{self.index_name}_backup" destination = f"ta_{self.index_name}" + else: + raise ValueError("invalid method, expected 'backup' or 'restore'") data = {"source": {"index": source}, "dest": {"index": destination}} _, _ = ElasticWrap("_reindex?refresh=true").post(data=data) diff --git a/tubearchivist/home/src/index/manual.py b/backend/appsettings/src/manual.py similarity index 96% rename from tubearchivist/home/src/index/manual.py rename to backend/appsettings/src/manual.py index aa65ebdb..08e5b8e3 100644 --- a/tubearchivist/home/src/index/manual.py +++ b/backend/appsettings/src/manual.py @@ -11,13 +11,13 @@ import re import shutil import subprocess -from home.src.download.thumbnails import ThumbManager -from home.src.index.comments import CommentList -from home.src.index.video import YoutubeVideo -from home.src.ta.config import AppConfig -from home.src.ta.helper import ignore_filelist -from home.src.ta.settings import EnvironmentSettings +from appsettings.src.config import AppConfig +from common.src.env_settings import EnvironmentSettings +from common.src.helper import ignore_filelist +from download.src.thumbnails import ThumbManager from PIL import Image +from video.src.comments import CommentList +from video.src.index import YoutubeVideo from yt_dlp.utils import ISO639Utils @@ -28,7 +28,6 @@ class ImportFolderScanner: - convert if needed """ - CONFIG = AppConfig().config CACHE_DIR = EnvironmentSettings.CACHE_DIR IMPORT_DIR = os.path.join(CACHE_DIR, "import") @@ -129,6 +128,7 @@ class ImportFolderScanner: def process_videos(self): """loop through all videos""" + config = AppConfig().config for idx, current_video in enumerate(self.to_import): if not current_video["media"]: print(f"{current_video}: no matching media file found.") @@ -144,7 +144,7 @@ class ImportFolderScanner: self._convert_video(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] comment_list = CommentList(task=self.task) @@ -407,8 +407,11 @@ class ManualImport: media_path=self.current_video["media"], ) if not video.json_data: - print(f"{video_id}: manual import failed, and no metadata found.") - raise ValueError + message = ( + 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.upload_to_es() diff --git a/tubearchivist/home/src/index/reindex.py b/backend/appsettings/src/reindex.py similarity index 95% rename from tubearchivist/home/src/index/reindex.py rename to backend/appsettings/src/reindex.py index 51563998..bb4b26e2 100644 --- a/tubearchivist/home/src/index/reindex.py +++ b/backend/appsettings/src/reindex.py @@ -7,21 +7,21 @@ functionality: import json import os from datetime import datetime -from time import sleep from typing import Callable, TypedDict -from home.models import CustomPeriodicTask -from home.src.download.subscriptions import ChannelSubscription -from home.src.download.thumbnails import ThumbManager -from home.src.download.yt_dlp_base import CookieHandler -from home.src.es.connect import ElasticWrap, IndexPaginate -from home.src.index.channel import YoutubeChannel -from home.src.index.comments import Comments -from home.src.index.playlist import YoutubePlaylist -from home.src.index.video import YoutubeVideo -from home.src.ta.config import AppConfig -from home.src.ta.settings import EnvironmentSettings -from home.src.ta.ta_redis import RedisQueue +from appsettings.src.config import AppConfig +from channel.src.index import YoutubeChannel +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.helper import rand_sleep +from common.src.ta_redis import RedisQueue +from download.src.subscriptions import ChannelSubscription +from download.src.thumbnails import ThumbManager +from download.src.yt_dlp_base import CookieHandler +from playlist.src.index import YoutubePlaylist +from task.models import CustomPeriodicTask +from video.src.comments import Comments +from video.src.index import YoutubeVideo class ReindexConfigType(TypedDict): @@ -289,8 +289,7 @@ class Reindex(ReindexBase): self._notify(name, total, idx) reindex(youtube_id) - sleep_interval = self.config["downloads"].get("sleep_interval", 0) - sleep(sleep_interval) + rand_sleep(self.config) def _get_reindex_map(self, index_name: str) -> Callable: """return def to run for index""" diff --git a/tubearchivist/home/src/es/snapshot.py b/backend/appsettings/src/snapshot.py similarity index 95% rename from tubearchivist/home/src/es/snapshot.py rename to backend/appsettings/src/snapshot.py index 0cff51e6..49c7eccd 100644 --- a/tubearchivist/home/src/es/snapshot.py +++ b/backend/appsettings/src/snapshot.py @@ -7,9 +7,9 @@ from datetime import datetime from time import sleep from zoneinfo import ZoneInfo -from home.src.es.connect import ElasticWrap -from home.src.ta.helper import get_mapping -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap +from common.src.helper import get_mapping class ElasticSnapshot: @@ -150,7 +150,7 @@ class ElasticSnapshot: if statuscode == 200: print(f"snapshot: executing now: {response}") - if wait: + if wait and "snapshot_name" in response: self._wait_for_snapshot(response["snapshot_name"]) return response @@ -251,10 +251,9 @@ class ElasticSnapshot: @staticmethod def _date_converter(date_utc): """convert datetime string""" - expected_format = "%Y-%m-%dT%H:%M:%S.%fZ" - date = datetime.strptime(date_utc, expected_format) - local_datetime = date.replace(tzinfo=ZoneInfo("localtime")) - converted = local_datetime.astimezone(ZoneInfo(EnvironmentSettings.TZ)) + date = datetime.strptime(date_utc, "%Y-%m-%dT%H:%M:%S.%fZ") + utc_date = date.replace(tzinfo=ZoneInfo("UTC")) + converted = utc_date.astimezone(ZoneInfo(EnvironmentSettings.TZ)) converted_str = converted.strftime("%Y-%m-%d %H:%M") return converted_str diff --git a/backend/appsettings/urls.py b/backend/appsettings/urls.py new file mode 100644 index 00000000..14d1c416 --- /dev/null +++ b/backend/appsettings/urls.py @@ -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//", + views.SnapshotApiView.as_view(), + name="api-snapshot", + ), + path( + "backup/", + views.BackupApiListView.as_view(), + name="api-backup-list", + ), + path( + "backup//", + 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", + ), +] diff --git a/backend/appsettings/views.py b/backend/appsettings/views.py new file mode 100644 index 00000000..d1ccd972 --- /dev/null +++ b/backend/appsettings/views.py @@ -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// + 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// + 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}) diff --git a/tubearchivist/config/__init__.py b/backend/channel/__init__.py similarity index 100% rename from tubearchivist/config/__init__.py rename to backend/channel/__init__.py diff --git a/tubearchivist/config/management/__init__.py b/backend/channel/migrations/__init__.py similarity index 100% rename from tubearchivist/config/management/__init__.py rename to backend/channel/migrations/__init__.py diff --git a/tubearchivist/config/management/commands/__init__.py b/backend/channel/src/__init__.py similarity index 100% rename from tubearchivist/config/management/commands/__init__.py rename to backend/channel/src/__init__.py diff --git a/tubearchivist/home/src/index/channel.py b/backend/channel/src/index.py similarity index 90% rename from tubearchivist/home/src/index/channel.py rename to backend/channel/src/index.py index d55e46c4..bc3b6853 100644 --- a/tubearchivist/home/src/index/channel.py +++ b/backend/channel/src/index.py @@ -8,12 +8,12 @@ import json import os from datetime import datetime -from home.src.download.thumbnails import ThumbManager -from home.src.download.yt_dlp_base import YtWrap -from home.src.es.connect import ElasticWrap, IndexPaginate -from home.src.index.generic import YouTubeItem -from home.src.index.playlist import YoutubePlaylist -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.index_generic import YouTubeItem +from download.src.thumbnails import ThumbManager +from download.src.yt_dlp_base import YtWrap +from playlist.src.index import YoutubePlaylist class YoutubeChannel(YouTubeItem): @@ -42,6 +42,10 @@ class YoutubeChannel(YouTubeItem): if not self.youtube_meta and fallback: self._video_fallback(fallback) else: + if not self.json_data: + message = f"{self.youtube_id}: Failed to get metadata" + raise ValueError(message) + self.process_youtube_meta() self.get_channel_art() @@ -59,21 +63,13 @@ class YoutubeChannel(YouTubeItem): "channel_name": self.youtube_meta["uploader"], "channel_subs": self.youtube_meta.get("channel_follower_count", 0), "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_thumb_url": self._get_thumb_art(), "channel_tvart_url": self._get_tv_art(), "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): """extract thumb art""" for i in self.youtube_meta["thumbnails"]: @@ -290,6 +286,9 @@ class YoutubeChannel(YouTubeItem): ) obs = {"skip_download": True, "extract_flat": True} playlists = YtWrap(obs, self.config).extract(url) + if not playlists: + return + all_entries = [(i["id"], i["title"]) for i in playlists["entries"]] self.all_playlists = all_entries @@ -326,22 +325,12 @@ class YoutubeChannel(YouTubeItem): for key, value in overwrites.items(): if key not in valid_keys: 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 - elif value == "0": - if key in to_write: - 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}) + + to_write.update({key: value}) self.json_data["channel_overwrites"] = to_write @@ -353,3 +342,5 @@ def channel_overwrites(channel_id, overwrites): channel.set_overwrites(overwrites) channel.upload_to_es() channel.sync_to_videos() + + return channel.json_data diff --git a/backend/channel/src/nav.py b/backend/channel/src/nav.py new file mode 100644 index 00000000..6ff3e047 --- /dev/null +++ b/backend/channel/src/nav.py @@ -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"]) diff --git a/backend/channel/urls.py b/backend/channel/urls.py new file mode 100644 index 00000000..4b4ec53e --- /dev/null +++ b/backend/channel/urls.py @@ -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( + "/", + views.ChannelApiView.as_view(), + name="api-channel", + ), + path( + "/aggs/", + views.ChannelAggsApiView.as_view(), + name="api-channel-aggs", + ), + path( + "/nav/", + views.ChannelNavApiView.as_view(), + name="api-channel-nav", + ), +] diff --git a/backend/channel/views.py b/backend/channel/views.py new file mode 100644 index 00000000..7fe0860a --- /dev/null +++ b/backend/channel/views.py @@ -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// + 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//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//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) diff --git a/tubearchivist/home/migrations/__init__.py b/backend/common/__init__.py similarity index 100% rename from tubearchivist/home/migrations/__init__.py rename to backend/common/__init__.py diff --git a/tubearchivist/home/src/__init__.py b/backend/common/migrations/__init__.py similarity index 100% rename from tubearchivist/home/src/__init__.py rename to backend/common/migrations/__init__.py diff --git a/tubearchivist/home/src/download/__init__.py b/backend/common/src/__init__.py similarity index 100% rename from tubearchivist/home/src/download/__init__.py rename to backend/common/src/__init__.py diff --git a/tubearchivist/home/src/ta/settings.py b/backend/common/src/env_settings.py similarity index 79% rename from tubearchivist/home/src/ta/settings.py rename to backend/common/src/env_settings.py index 16f36994..5969a06f 100644 --- a/tubearchivist/home/src/ta/settings.py +++ b/backend/common/src/env_settings.py @@ -6,6 +6,14 @@ Functionality: from os import environ +try: + from dotenv import load_dotenv + + print("loading local dotenv") + load_dotenv(".env") +except ModuleNotFoundError: + pass + class EnvironmentSettings: """ @@ -19,7 +27,7 @@ class EnvironmentSettings: ENABLE_CAST: bool = bool(environ.get("ENABLE_CAST")) TZ: str = str(environ.get("TZ", "UTC")) 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_PASSWORD: str = str(environ.get("TA_PASSWORD")) @@ -29,8 +37,7 @@ class EnvironmentSettings: CACHE_DIR: str = str(environ.get("TA_CACHE_DIR", "/cache")) # Redis - REDIS_HOST: str = str(environ.get("REDIS_HOST")) - REDIS_PORT: int = int(environ.get("REDIS_PORT", 6379)) + REDIS_CON: str = str(environ.get("REDIS_CON")) REDIS_NAME_SPACE: str = str(environ.get("REDIS_NAME_SPACE", "ta:")) # ElasticSearch @@ -44,6 +51,20 @@ class EnvironmentSettings: ) 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): """print generic env vars""" print( @@ -53,7 +74,7 @@ class EnvironmentSettings: TZ: {self.TZ} ENABLE_CAST: {self.ENABLE_CAST} 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_PASSWORD: *****""" ) @@ -71,8 +92,7 @@ class EnvironmentSettings: """debug redis conf paths""" print( f""" - REDIS_HOST: {self.REDIS_HOST} - REDIS_PORT: {self.REDIS_PORT} + REDIS_CON: {self.REDIS_CON} REDIS_NAME_SPACE: {self.REDIS_NAME_SPACE}""" ) diff --git a/tubearchivist/home/src/es/connect.py b/backend/common/src/es_connect.py similarity index 99% rename from tubearchivist/home/src/es/connect.py rename to backend/common/src/es_connect.py index 7f2fe3fc..f7a59e3a 100644 --- a/tubearchivist/home/src/es/connect.py +++ b/backend/common/src/es_connect.py @@ -11,7 +11,7 @@ from typing import Any import requests import urllib3 -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings class ElasticWrap: diff --git a/tubearchivist/home/src/ta/health.py b/backend/common/src/health.py similarity index 100% rename from tubearchivist/home/src/ta/health.py rename to backend/common/src/health.py diff --git a/tubearchivist/home/src/ta/helper.py b/backend/common/src/helper.py similarity index 89% rename from tubearchivist/home/src/ta/helper.py rename to backend/common/src/helper.py index 767b4a00..3a487e63 100644 --- a/tubearchivist/home/src/ta/helper.py +++ b/backend/common/src/helper.py @@ -1,265 +1,272 @@ -""" -Loose collection of helper functions -- don't import AppConfig class here to avoid circular imports -""" - -import json -import os -import random -import string -import subprocess -from datetime import datetime -from typing import Any -from urllib.parse import urlparse - -import requests -from home.src.es.connect import IndexPaginate -from home.src.ta.settings import EnvironmentSettings - - -def ignore_filelist(filelist: list[str]) -> list[str]: - """ignore temp files for os.listdir sanitizer""" - to_ignore = [ - "@eaDir", - "Icon\r\r", - "Network Trash Folder", - "Temporary Items", - ] - cleaned: list[str] = [] - for file_name in filelist: - if file_name.startswith(".") or file_name in to_ignore: - continue - - cleaned.append(file_name) - - return cleaned - - -def randomizor(length: int) -> str: - """generate random alpha numeric string""" - pool: str = string.digits + string.ascii_letters - return "".join(random.choice(pool) for i in range(length)) - - -def requests_headers() -> dict[str, str]: - """build header with random user agent for requests outside of yt-dlp""" - - chrome_versions = ( - "90.0.4430.212", - "90.0.4430.24", - "90.0.4430.70", - "90.0.4430.72", - "90.0.4430.85", - "90.0.4430.93", - "91.0.4472.101", - "91.0.4472.106", - "91.0.4472.114", - "91.0.4472.124", - "91.0.4472.164", - "91.0.4472.19", - "91.0.4472.77", - "92.0.4515.107", - "92.0.4515.115", - "92.0.4515.131", - "92.0.4515.159", - "92.0.4515.43", - "93.0.4556.0", - "93.0.4577.15", - "93.0.4577.63", - "93.0.4577.82", - "94.0.4606.41", - "94.0.4606.54", - "94.0.4606.61", - "94.0.4606.71", - "94.0.4606.81", - "94.0.4606.85", - "95.0.4638.17", - "95.0.4638.50", - "95.0.4638.54", - "95.0.4638.69", - "95.0.4638.74", - "96.0.4664.18", - "96.0.4664.45", - "96.0.4664.55", - "96.0.4664.93", - "97.0.4692.20", - ) - template = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - + "AppleWebKit/537.36 (KHTML, like Gecko) " - + f"Chrome/{random.choice(chrome_versions)} Safari/537.36" - ) - - return {"User-Agent": template} - - -def date_parser(timestamp: int | str) -> str: - """return formatted date string""" - if isinstance(timestamp, int): - date_obj = datetime.fromtimestamp(timestamp) - elif isinstance(timestamp, str): - date_obj = datetime.strptime(timestamp, "%Y-%m-%d") - else: - raise TypeError(f"invalid timestamp: {timestamp}") - - return date_obj.date().isoformat() - - -def time_parser(timestamp: str) -> float: - """return seconds from timestamp, false on empty""" - if not timestamp: - return False - - if timestamp.isnumeric(): - return int(timestamp) - - hours, minutes, seconds = timestamp.split(":", maxsplit=3) - return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds) - - -def clear_dl_cache(cache_dir: str) -> int: - """clear leftover files from dl cache""" - print("clear download cache") - download_cache_dir = os.path.join(cache_dir, "download") - leftover_files = ignore_filelist(os.listdir(download_cache_dir)) - for cached in leftover_files: - to_delete = os.path.join(download_cache_dir, cached) - os.remove(to_delete) - - return len(leftover_files) - - -def get_mapping() -> dict: - """read index_mapping.json and get expected mapping and settings""" - with open("home/src/es/index_mapping.json", "r", encoding="utf-8") as f: - index_config: dict = json.load(f).get("index_config") - - return index_config - - -def is_shorts(youtube_id: str) -> bool: - """check if youtube_id is a shorts video, bot not it it's not a shorts""" - shorts_url = f"https://www.youtube.com/shorts/{youtube_id}" - cookies = {"SOCS": "CAI"} - response = requests.head( - shorts_url, cookies=cookies, headers=requests_headers(), timeout=10 - ) - - return response.status_code == 200 - - -def get_duration_sec(file_path: str) -> int: - """get duration of media file from file path""" - - duration = subprocess.run( - [ - "ffprobe", - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - file_path, - ], - capture_output=True, - check=True, - ) - duration_raw = duration.stdout.decode().strip() - if duration_raw == "N/A": - return 0 - - duration_sec = int(float(duration_raw)) - return duration_sec - - -def get_duration_str(seconds: int) -> str: - """Return a human-readable duration string from seconds.""" - if not seconds: - return "NA" - - units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)] - duration_parts = [] - - for unit_label, unit_seconds in units: - if seconds >= unit_seconds: - unit_count, seconds = divmod(seconds, unit_seconds) - duration_parts.append(f"{unit_count:02}{unit_label}") - - duration_parts[0] = duration_parts[0].lstrip("0") - - return " ".join(duration_parts) - - -def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]: - """parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS""" - allowed_hosts: list[str] = [ - "localhost", - "tubearchivist", - ] - csrf_trusted_origins: list[str] = [ - "http://localhost", - "http://tubearchivist", - ] - for host in ta_host.split(): - host_clean = host.strip() - if not host_clean.startswith("http"): - host_clean = f"http://{host_clean}" - - parsed = urlparse(host_clean) - allowed_hosts.append(f"{parsed.hostname}") - csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}") - - return allowed_hosts, csrf_trusted_origins - - -def get_stylesheets(): - """Get all valid stylesheets from /static/css""" - app_root = EnvironmentSettings.APP_DIR - stylesheets = os.listdir(os.path.join(app_root, "static/css")) - stylesheets.remove("style.css") - stylesheets.sort() - stylesheets = list(filter(lambda x: x.endswith(".css"), stylesheets)) - return stylesheets - - -def check_stylesheet(stylesheet: str): - """Check if a stylesheet exists. Return dark.css as a fallback""" - if stylesheet in get_stylesheets(): - return stylesheet - - return "dark.css" - - -def is_missing( - to_check: str | list[str], - index_name: str = "ta_video,ta_download", - on_key: str = "youtube_id", -) -> list[str]: - """id or list of ids that are missing from index_name""" - if isinstance(to_check, str): - to_check = [to_check] - - data = { - "query": {"terms": {on_key: to_check}}, - "_source": [on_key], - } - result = IndexPaginate(index_name, data=data).get_results() - existing_ids = [i[on_key] for i in result] - dl = [i for i in to_check if i not in existing_ids] - - return dl - - -def get_channel_overwrites() -> dict[str, dict[str, Any]]: - """get overwrites indexed my channel_id""" - data = { - "query": { - "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 +""" +Loose collection of helper functions +- don't import AppConfig class here to avoid circular imports +""" + +import json +import os +import random +import string +import subprocess +from datetime import datetime +from time import sleep +from typing import Any +from urllib.parse import urlparse + +import requests +from common.src.es_connect import IndexPaginate + + +def ignore_filelist(filelist: list[str]) -> list[str]: + """ignore temp files for os.listdir sanitizer""" + to_ignore = [ + "@eaDir", + "Icon\r\r", + "Network Trash Folder", + "Temporary Items", + ] + cleaned: list[str] = [] + for file_name in filelist: + if file_name.startswith(".") or file_name in to_ignore: + continue + + cleaned.append(file_name) + + return cleaned + + +def randomizor(length: int) -> str: + """generate random alpha numeric string""" + pool: str = string.digits + string.ascii_letters + return "".join(random.choice(pool) for i in range(length)) + + +def rand_sleep(config) -> None: + """randomized sleep based on config""" + sleep_config = config["downloads"].get("sleep_interval") + if not sleep_config: + return + + secs = random.randrange(int(sleep_config * 0.5), int(sleep_config * 1.5)) + sleep(secs) + + +def requests_headers() -> dict[str, str]: + """build header with random user agent for requests outside of yt-dlp""" + + chrome_versions = ( + "90.0.4430.212", + "90.0.4430.24", + "90.0.4430.70", + "90.0.4430.72", + "90.0.4430.85", + "90.0.4430.93", + "91.0.4472.101", + "91.0.4472.106", + "91.0.4472.114", + "91.0.4472.124", + "91.0.4472.164", + "91.0.4472.19", + "91.0.4472.77", + "92.0.4515.107", + "92.0.4515.115", + "92.0.4515.131", + "92.0.4515.159", + "92.0.4515.43", + "93.0.4556.0", + "93.0.4577.15", + "93.0.4577.63", + "93.0.4577.82", + "94.0.4606.41", + "94.0.4606.54", + "94.0.4606.61", + "94.0.4606.71", + "94.0.4606.81", + "94.0.4606.85", + "95.0.4638.17", + "95.0.4638.50", + "95.0.4638.54", + "95.0.4638.69", + "95.0.4638.74", + "96.0.4664.18", + "96.0.4664.45", + "96.0.4664.55", + "96.0.4664.93", + "97.0.4692.20", + ) + template = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + f"Chrome/{random.choice(chrome_versions)} Safari/537.36" + ) + + return {"User-Agent": template} + + +def date_parser(timestamp: int | str) -> str: + """return formatted date string""" + if isinstance(timestamp, int): + date_obj = datetime.fromtimestamp(timestamp) + elif isinstance(timestamp, str): + date_obj = datetime.strptime(timestamp, "%Y-%m-%d") + else: + raise TypeError(f"invalid timestamp: {timestamp}") + + return date_obj.date().isoformat() + + +def time_parser(timestamp: str) -> float: + """return seconds from timestamp, false on empty""" + if not timestamp: + return False + + if timestamp.isnumeric(): + return int(timestamp) + + hours, minutes, seconds = timestamp.split(":", maxsplit=3) + return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds) + + +def clear_dl_cache(cache_dir: str) -> int: + """clear leftover files from dl cache""" + print("clear download cache") + download_cache_dir = os.path.join(cache_dir, "download") + leftover_files = ignore_filelist(os.listdir(download_cache_dir)) + for cached in leftover_files: + to_delete = os.path.join(download_cache_dir, cached) + os.remove(to_delete) + + return len(leftover_files) + + +def get_mapping() -> dict: + """read index_mapping.json and get expected mapping and settings""" + with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f: + index_config: dict = json.load(f).get("index_config") + + return index_config + + +def is_shorts(youtube_id: str) -> bool: + """check if youtube_id is a shorts video, bot not it it's not a shorts""" + shorts_url = f"https://www.youtube.com/shorts/{youtube_id}" + cookies = {"SOCS": "CAI"} + response = requests.head( + shorts_url, cookies=cookies, headers=requests_headers(), timeout=10 + ) + + return response.status_code == 200 + + +def get_duration_sec(file_path: str) -> int: + """get duration of media file from file path""" + + duration = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + file_path, + ], + capture_output=True, + check=True, + ) + duration_raw = duration.stdout.decode().strip() + if duration_raw == "N/A": + return 0 + + duration_sec = int(float(duration_raw)) + return duration_sec + + +def get_duration_str(seconds: int) -> str: + """Return a human-readable duration string from seconds.""" + if not seconds: + return "NA" + + units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)] + duration_parts = [] + + for unit_label, unit_seconds in units: + if seconds >= unit_seconds: + unit_count, seconds = divmod(seconds, unit_seconds) + duration_parts.append(f"{unit_count:02}{unit_label}") + + duration_parts[0] = duration_parts[0].lstrip("0") + + return " ".join(duration_parts) + + +def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]: + """parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS""" + allowed_hosts: list[str] = [ + "localhost", + "tubearchivist", + ] + csrf_trusted_origins: list[str] = [ + "http://localhost", + "http://tubearchivist", + ] + for host in ta_host.split(): + host_clean = host.strip() + if not host_clean.startswith("http"): + host_clean = f"http://{host_clean}" + + parsed = urlparse(host_clean) + allowed_hosts.append(f"{parsed.hostname}") + csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}") + + return allowed_hosts, csrf_trusted_origins + + +def get_stylesheets() -> list: + """Get all valid stylesheets from /static/css""" + + stylesheets = ["dark.css", "light.css", "matrix.css", "midnight.css"] + return stylesheets + + +def check_stylesheet(stylesheet: str): + """Check if a stylesheet exists. Return dark.css as a fallback""" + if stylesheet in get_stylesheets(): + return stylesheet + + return "dark.css" + + +def is_missing( + to_check: str | list[str], + index_name: str = "ta_video,ta_download", + on_key: str = "youtube_id", +) -> list[str]: + """id or list of ids that are missing from index_name""" + if isinstance(to_check, str): + to_check = [to_check] + + data = { + "query": {"terms": {on_key: to_check}}, + "_source": [on_key], + } + result = IndexPaginate(index_name, data=data).get_results() + existing_ids = [i[on_key] for i in result] + dl = [i for i in to_check if i not in existing_ids] + + return dl + + +def get_channel_overwrites() -> dict[str, dict[str, Any]]: + """get overwrites indexed my channel_id""" + data = { + "query": { + "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 diff --git a/tubearchivist/home/src/index/generic.py b/backend/common/src/index_generic.py similarity index 95% rename from tubearchivist/home/src/index/generic.py rename to backend/common/src/index_generic.py index 8a502bb5..3daeefe7 100644 --- a/tubearchivist/home/src/index/generic.py +++ b/backend/common/src/index_generic.py @@ -5,10 +5,10 @@ functionality: import math -from home.src.download.yt_dlp_base import YtWrap -from home.src.es.connect import ElasticWrap -from home.src.ta.config import AppConfig -from home.src.ta.users import UserConfig +from appsettings.src.config import AppConfig +from common.src.es_connect import ElasticWrap +from download.src.yt_dlp_base import YtWrap +from user.src.user_config import UserConfig class YouTubeItem: @@ -104,8 +104,8 @@ class Pagination: def first_guess(self): """build first guess before api call""" page_get = self.page_get + page_from = 0 if page_get in [0, 1]: - page_from = 0 prev_pages = False elif page_get > 1: page_from = (page_get - 1) * self.page_size diff --git a/tubearchivist/api/src/search_processor.py b/backend/common/src/search_processor.py similarity index 73% rename from tubearchivist/api/src/search_processor.py rename to backend/common/src/search_processor.py index a7891c96..9fc1f2b1 100644 --- a/tubearchivist/api/src/search_processor.py +++ b/backend/common/src/search_processor.py @@ -6,19 +6,19 @@ Functionality: import urllib.parse -from home.src.download.thumbnails import ThumbManager -from home.src.ta.helper import date_parser, get_duration_str -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings +from common.src.helper import date_parser, get_duration_str +from common.src.ta_redis import RedisArchivist +from download.src.thumbnails import ThumbManager class SearchProcess: """process search results""" - CACHE_DIR = EnvironmentSettings.CACHE_DIR - - def __init__(self, response): + def __init__(self, response, match_video_user_progress: None | int = None): self.response = response self.processed = False + self.position_index = self.get_user_progress(match_video_user_progress) def process(self): """detect type and process""" @@ -35,6 +35,19 @@ class SearchProcess: 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): """detect which type of data to process""" index = result["_index"] @@ -66,7 +79,8 @@ class SearchProcess: def _process_channel(channel_dict): """run on single channel""" 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"]) channel_dict.update( { @@ -93,16 +107,31 @@ class SearchProcess: url = video_dict["subtitles"][idx]["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( { "channel": channel, - "media_url": f"/media/{media_url}", + "media_url": f"{media_root}/{media_url}", "vid_last_refresh": vid_last_refresh, "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())) @staticmethod @@ -112,9 +141,11 @@ class SearchProcess: playlist_last_refresh = date_parser( playlist_dict["playlist_last_refresh"] ) + cache_root = EnvironmentSettings().get_cache_root() + playlist_thumbnail = f"{cache_root}/playlists/{playlist_id}.jpg" playlist_dict.update( { - "playlist_thumbnail": f"/cache/playlists/{playlist_id}.jpg", + "playlist_thumbnail": playlist_thumbnail, "playlist_last_refresh": playlist_last_refresh, } ) @@ -124,12 +155,13 @@ class SearchProcess: def _process_download(self, download_dict): """run on single download item""" video_id = download_dict["youtube_id"] + cache_root = EnvironmentSettings().get_cache_root() vid_thumb_url = ThumbManager(video_id).vid_thumb_path() published = date_parser(download_dict["published"]) download_dict.update( { - "vid_thumb_url": f"{self.CACHE_DIR}/{vid_thumb_url}", + "vid_thumb_url": f"{cache_root}/{vid_thumb_url}", "published": published, } ) diff --git a/tubearchivist/home/src/frontend/searching.py b/backend/common/src/searching.py similarity index 99% rename from tubearchivist/home/src/frontend/searching.py rename to backend/common/src/searching.py index 932aa3b9..17c164b1 100644 --- a/tubearchivist/home/src/frontend/searching.py +++ b/backend/common/src/searching.py @@ -6,8 +6,8 @@ Functionality: - calculate pagination values """ -from api.src.search_processor import SearchProcess -from home.src.es.connect import ElasticWrap +from common.src.es_connect import ElasticWrap +from common.src.search_processor import SearchProcess class SearchForm: diff --git a/tubearchivist/home/src/ta/ta_redis.py b/backend/common/src/ta_redis.py similarity index 86% rename from tubearchivist/home/src/ta/ta_redis.py rename to backend/common/src/ta_redis.py index 1feb0232..e4296d9f 100644 --- a/tubearchivist/home/src/ta/ta_redis.py +++ b/backend/common/src/ta_redis.py @@ -8,7 +8,7 @@ functionality: import json import redis -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings class RedisBase: @@ -17,10 +17,8 @@ class RedisBase: NAME_SPACE: str = EnvironmentSettings.REDIS_NAME_SPACE def __init__(self): - self.conn = redis.Redis( - host=EnvironmentSettings.REDIS_HOST, - port=EnvironmentSettings.REDIS_PORT, - decode_responses=True, + self.conn = redis.from_url( + url=EnvironmentSettings.REDIS_CON, decode_responses=True ) @@ -40,15 +38,15 @@ class RedisArchivist(RedisBase): def set_message( self, key: str, - message: dict, - path: str = ".", + message: dict | str, expire: bool | int = False, save: bool = False, ) -> None: """write new message to redis""" - self.conn.execute_command( - "JSON.SET", self.NAME_SPACE + key, path, json.dumps(message) + to_write = ( + json.dumps(message) if isinstance(message, dict) else message ) + self.conn.execute_command("SET", self.NAME_SPACE + key, to_write) if expire: if isinstance(expire, bool): @@ -67,8 +65,24 @@ class RedisArchivist(RedisBase): except redis.exceptions.ResponseError: pass - def get_message(self, key: str) -> dict: - """get message dict from redis""" + def get_message_str(self, key: str) -> str | None: + """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) if reply: return json.loads(reply) @@ -91,7 +105,7 @@ class RedisArchivist(RedisBase): if not all_matches: 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: """delete key from redis""" diff --git a/tubearchivist/home/src/ta/urlparser.py b/backend/common/src/urlparser.py similarity index 93% rename from tubearchivist/home/src/ta/urlparser.py rename to backend/common/src/urlparser.py index 924961ac..ae314791 100644 --- a/tubearchivist/home/src/ta/urlparser.py +++ b/backend/common/src/urlparser.py @@ -6,8 +6,8 @@ Functionality: from urllib.parse import parse_qs, urlparse -from home.src.download.yt_dlp_base import YtWrap -from home.src.index.video_constants import VideoTypeEnum +from download.src.yt_dlp_base import YtWrap +from video.src.constants import VideoTypeEnum class Parser: @@ -67,6 +67,9 @@ class Parser: if all_paths[0] == "channel": return self._validate_expected(all_paths[1], "channel") + if all_paths[0] == "live": + return self._validate_expected(all_paths[1], "video") + # detect channel channel_id = self._extract_channel_name(parsed.geturl()) return {"type": "channel", "url": channel_id} @@ -113,6 +116,9 @@ class Parser: "playlistend": 0, } 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) if channel_id: return channel_id diff --git a/tubearchivist/home/src/frontend/watched.py b/backend/common/src/watched.py similarity index 97% rename from tubearchivist/home/src/frontend/watched.py rename to backend/common/src/watched.py index ceb4870e..ad5a671a 100644 --- a/tubearchivist/home/src/frontend/watched.py +++ b/backend/common/src/watched.py @@ -5,8 +5,8 @@ functionality: from datetime import datetime -from home.src.es.connect import ElasticWrap -from home.src.ta.urlparser import Parser +from common.src.es_connect import ElasticWrap +from common.src.urlparser import Parser class WatchState: diff --git a/tubearchivist/home/src/es/__init__.py b/backend/common/tests/__init__.py similarity index 100% rename from tubearchivist/home/src/es/__init__.py rename to backend/common/tests/__init__.py diff --git a/tubearchivist/home/tests/conftest.py b/backend/common/tests/conftest.py similarity index 75% rename from tubearchivist/home/tests/conftest.py rename to backend/common/tests/conftest.py index 5e5cb7f4..fd0861ca 100644 --- a/tubearchivist/home/tests/conftest.py +++ b/backend/common/tests/conftest.py @@ -8,4 +8,4 @@ import pytest @pytest.fixture(scope="session", autouse=True) def change_test_dir(request): """change directory to project folder""" - os.chdir(request.config.rootdir / "tubearchivist") + os.chdir(request.config.rootdir / "backend") diff --git a/tubearchivist/home/src/frontend/__init__.py b/backend/common/tests/test_src/__init__.py similarity index 100% rename from tubearchivist/home/src/frontend/__init__.py rename to backend/common/tests/test_src/__init__.py diff --git a/tubearchivist/home/tests/test_ta/test_helper.py b/backend/common/tests/test_src/test_helper.py similarity index 98% rename from tubearchivist/home/tests/test_ta/test_helper.py rename to backend/common/tests/test_src/test_helper.py index 7c1c4bbf..e99808cd 100644 --- a/tubearchivist/home/tests/test_ta/test_helper.py +++ b/backend/common/tests/test_src/test_helper.py @@ -1,7 +1,7 @@ """tests for helper functions""" import pytest -from home.src.ta.helper import ( +from common.src.helper import ( date_parser, get_duration_str, get_mapping, diff --git a/tubearchivist/home/tests/test_ta/test_urlparser.py b/backend/common/tests/test_src/test_urlparser.py similarity index 97% rename from tubearchivist/home/tests/test_ta/test_urlparser.py rename to backend/common/tests/test_src/test_urlparser.py index 6d4ab2f4..2f54544e 100644 --- a/tubearchivist/home/tests/test_ta/test_urlparser.py +++ b/backend/common/tests/test_src/test_urlparser.py @@ -1,7 +1,7 @@ """tests for url parser""" import pytest -from home.src.ta.urlparser import Parser +from common.src.urlparser import Parser # video id parsing 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&list=PL96C35uN7xGJu6skU4TBYrIWxggkZBrF5&index=1&pp=iAQB" # noqa: E501 "https://youtu.be/7DKv5H5Frt0", + "https://www.youtube.com/live/7DKv5H5Frt0", ] VIDEO_OUT = [{"type": "video", "url": "7DKv5H5Frt0", "vid_type": "unknown"}] VIDEO_TEST_CASES = [(i, VIDEO_OUT) for i in VIDEO_URL_IN] diff --git a/backend/common/urls.py b/backend/common/urls.py new file mode 100644 index 00000000..e8a9fe94 --- /dev/null +++ b/backend/common/urls.py @@ -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", + ), +] diff --git a/backend/common/views.py b/backend/common/views.py new file mode 100644 index 00000000..20c12804 --- /dev/null +++ b/backend/common/views.py @@ -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)) diff --git a/backend/common/views_base.py b/backend/common/views_base.py new file mode 100644 index 00000000..324ecb05 --- /dev/null +++ b/backend/common/views_base.py @@ -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") diff --git a/tubearchivist/home/src/index/__init__.py b/backend/config/__init__.py similarity index 100% rename from tubearchivist/home/src/index/__init__.py rename to backend/config/__init__.py diff --git a/tubearchivist/config/asgi.py b/backend/config/asgi.py similarity index 100% rename from tubearchivist/config/asgi.py rename to backend/config/asgi.py diff --git a/tubearchivist/home/src/ta/__init__.py b/backend/config/management/__init__.py similarity index 100% rename from tubearchivist/home/src/ta/__init__.py rename to backend/config/management/__init__.py diff --git a/tubearchivist/home/templatetags/__init__.py b/backend/config/management/commands/__init__.py similarity index 100% rename from tubearchivist/home/templatetags/__init__.py rename to backend/config/management/commands/__init__.py diff --git a/backend/config/management/commands/ta_config_backup.py b/backend/config/management/commands/ta_config_backup.py new file mode 100644 index 00000000..40802ddd --- /dev/null +++ b/backend/config/management/commands/ta_config_backup.py @@ -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 diff --git a/backend/config/management/commands/ta_config_restore.py b/backend/config/management/commands/ta_config_restore.py new file mode 100644 index 00000000..0918627a --- /dev/null +++ b/backend/config/management/commands/ta_config_restore.py @@ -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}") + ) diff --git a/tubearchivist/config/management/commands/ta_connection.py b/backend/config/management/commands/ta_connection.py similarity index 97% rename from tubearchivist/config/management/commands/ta_connection.py rename to backend/config/management/commands/ta_connection.py index 94b5e0dc..1b8158e6 100644 --- a/tubearchivist/config/management/commands/ta_connection.py +++ b/backend/config/management/commands/ta_connection.py @@ -6,10 +6,10 @@ Functionality: from time import sleep 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 home.src.es.connect import ElasticWrap -from home.src.ta.settings import EnvironmentSettings -from home.src.ta.ta_redis import RedisArchivist TOPIC = """ diff --git a/tubearchivist/config/management/commands/ta_envcheck.py b/backend/config/management/commands/ta_envcheck.py similarity index 74% rename from tubearchivist/config/management/commands/ta_envcheck.py rename to backend/config/management/commands/ta_envcheck.py index 76c5ba10..bdc591b5 100644 --- a/tubearchivist/config/management/commands/ta_envcheck.py +++ b/backend/config/management/commands/ta_envcheck.py @@ -8,10 +8,11 @@ Functionality: import os import re +from time import sleep +from common.src.env_settings import EnvironmentSettings from django.core.management.base import BaseCommand, CommandError -from home.models import Account -from home.src.ta.settings import EnvironmentSettings +from user.models import Account LOGO = """ @@ -60,9 +61,13 @@ EXPECTED_ENV_VARS = [ "ES_URL", "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" NGINX = "/etc/nginx/sites-available/default" -UWSGI = "/app/uwsgi.ini" class Command(BaseCommand): @@ -76,9 +81,10 @@ class Command(BaseCommand): self.stdout.write(LOGO) self.stdout.write(TOPIC) self._expected_vars() + self._unexpected_vars() self._elastic_user_overwrite() self._ta_port_overwrite() - self._ta_uwsgi_overwrite() + self._ta_backend_port_overwrite() self._enable_cast_overwrite() self._create_superuser() @@ -90,20 +96,41 @@ class Command(BaseCommand): if not env.get(var): message = f" 🗙 expected env var {var} not set\n {INST}" self.stdout.write(self.style.ERROR(message)) + sleep(60) raise CommandError(message) message = " ✓ all expected env vars are set" 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): """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 self.stdout.write(self.style.SUCCESS(f" ✓ ES user is set to {env}")) def _ta_port_overwrite(self): """set TA_PORT overwrite for nginx""" - self.stdout.write("[3] check TA_PORT overwrite") + self.stdout.write("[4] check TA_PORT overwrite") overwrite = EnvironmentSettings.TA_PORT if not overwrite: 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)) - def _ta_uwsgi_overwrite(self): - """set TA_UWSGI_PORT overwrite""" - self.stdout.write("[4] check TA_UWSGI_PORT overwrite") - overwrite = EnvironmentSettings.TA_UWSGI_PORT + def _ta_backend_port_overwrite(self): + """set TA_BACKEND_PORT overwrite""" + self.stdout.write("[5] check TA_BACKEND_PORT overwrite") + overwrite = EnvironmentSettings.TA_BACKEND_PORT if not overwrite: - message = " TA_UWSGI_PORT is not set" + message = " TA_BACKEND_PORT is not set" self.stdout.write(self.style.SUCCESS(message)) return - # nginx - regex = re.compile(r"uwsgi_pass localhost:[0-9]{1,5}") - to_overwrite = f"uwsgi_pass localhost:{overwrite}" + # modify nginx conf + regex = re.compile(r"proxy_pass http://localhost:[0-9]{1,5}") + to_overwrite = f"proxy_pass http://localhost:{overwrite}" changed = file_overwrite(NGINX, regex, to_overwrite) - # uwsgi - regex = re.compile(r"socket = :[0-9]{1,5}") - to_overwrite = f"socket = :{overwrite}" - changed = file_overwrite(UWSGI, regex, to_overwrite) - if changed: - message = f" ✓ TA_UWSGI_PORT changed to {overwrite}" + message = f" ✓ TA_BACKEND_PORT changed to {overwrite}" 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)) def _enable_cast_overwrite(self): """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 if not overwrite: self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set")) @@ -164,7 +186,7 @@ class Command(BaseCommand): def _create_superuser(self): """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) if is_created: message = " superuser already created" diff --git a/backend/config/management/commands/ta_startup.py b/backend/config/management/commands/ta_startup.py new file mode 100644 index 00000000..7e852141 --- /dev/null +++ b/backend/config/management/commands/ta_startup.py @@ -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}") + ) diff --git a/backend/config/management/commands/ta_stop_on_error.py b/backend/config/management/commands/ta_stop_on_error.py new file mode 100644 index 00000000..d76de618 --- /dev/null +++ b/backend/config/management/commands/ta_stop_on_error.py @@ -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] diff --git a/tubearchivist/config/settings.py b/backend/config/settings.py similarity index 90% rename from tubearchivist/config/settings.py rename to backend/config/settings.py index ad046850..66e55f11 100644 --- a/tubearchivist/config/settings.py +++ b/backend/config/settings.py @@ -14,11 +14,17 @@ import hashlib from os import environ, 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 django_auth_ldap.config import LDAPSearch -from home.src.ta.helper import ta_host_parser -from home.src.ta.settings import EnvironmentSettings + +try: + from dotenv import load_dotenv + + load_dotenv(".env") +except ModuleNotFoundError: + pass + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -41,7 +47,6 @@ ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS = ta_host_parser( INSTALLED_APPS = [ "django_celery_beat", - "home.apps.HomeConfig", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -53,7 +58,15 @@ INSTALLED_APPS = [ "django.contrib.humanize", "rest_framework", "rest_framework.authtoken", - "api", + "common", + "video", + "channel", + "playlist", + "download", + "task", + "appsettings", + "stats", + "user", "config", ] @@ -67,7 +80,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "home.src.ta.health.HealthCheckMiddleware", + "common.src.health.HealthCheckMiddleware", ] ROOT_URLCONF = "config.urls" @@ -92,6 +105,9 @@ WSGI_APPLICATION = "config.wsgi.application" if bool(environ.get("TA_LDAP")): # pylint: disable=global-at-module-level + import ldap + from django_auth_ldap.config import LDAPSearch + global AUTH_LDAP_SERVER_URI AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI") @@ -211,7 +227,7 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] -AUTH_USER_MODEL = "home.Account" +AUTH_USER_MODEL = "user.Account" # Forward-auth authentication 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") - MIDDLEWARE.append("home.src.ta.auth.HttpRemoteUserMiddleware") + MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware") AUTHENTICATION_BACKENDS = ( "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 if environ.get("DISABLE_CORS"): # disable cors - CORS_ORIGIN_ALLOW_ALL = True + CORS_ALLOW_ALL_ORIGINS = True else: CORS_ALLOWED_ORIGIN_REGEXES = [ r"moz-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_ALLOW_CREDENTIALS = True + CORS_ALLOW_HEADERS = list(default_headers) + [ "mode", @@ -276,4 +296,4 @@ CORS_ALLOW_HEADERS = list(default_headers) + [ # TA application settings TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" -TA_VERSION = "v0.4.11" +TA_VERSION = "v0.4.13" diff --git a/tubearchivist/config/urls.py b/backend/config/urls.py similarity index 63% rename from tubearchivist/config/urls.py rename to backend/config/urls.py index 6b373657..2fbf84b3 100644 --- a/tubearchivist/config/urls.py +++ b/backend/config/urls.py @@ -18,7 +18,14 @@ from django.contrib import admin from django.urls import include, path urlpatterns = [ - path("", include("home.urls")), - path("api/", include("api.urls")), + path("api/", include("common.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), ] diff --git a/tubearchivist/config/wsgi.py b/backend/config/wsgi.py similarity index 100% rename from tubearchivist/config/wsgi.py rename to backend/config/wsgi.py diff --git a/tubearchivist/home/tests/__init__.py b/backend/download/__init__.py similarity index 100% rename from tubearchivist/home/tests/__init__.py rename to backend/download/__init__.py diff --git a/tubearchivist/home/tests/test_ta/__init__.py b/backend/download/migrations/__init__.py similarity index 100% rename from tubearchivist/home/tests/test_ta/__init__.py rename to backend/download/migrations/__init__.py diff --git a/tubearchivist/api/serializers.py b/backend/download/src/__init__.py similarity index 100% rename from tubearchivist/api/serializers.py rename to backend/download/src/__init__.py diff --git a/tubearchivist/home/src/download/queue.py b/backend/download/src/queue.py similarity index 91% rename from tubearchivist/home/src/download/queue.py rename to backend/download/src/queue.py index 5f0a86bb..d2dc4fe3 100644 --- a/tubearchivist/home/src/download/queue.py +++ b/backend/download/src/queue.py @@ -4,17 +4,16 @@ Functionality: - linked with ta_dowload index """ -import json from datetime import datetime -from home.src.download.subscriptions import ChannelSubscription -from home.src.download.thumbnails import ThumbManager -from home.src.download.yt_dlp_base import YtWrap -from home.src.es.connect import ElasticWrap, IndexPaginate -from home.src.index.playlist import YoutubePlaylist -from home.src.index.video_constants import VideoTypeEnum -from home.src.ta.config import AppConfig -from home.src.ta.helper import get_duration_str, is_shorts +from appsettings.src.config import AppConfig +from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.helper import get_duration_str, is_shorts, rand_sleep +from download.src.subscriptions import ChannelSubscription +from download.src.thumbnails import ThumbManager +from download.src.yt_dlp_base import YtWrap +from playlist.src.index import YoutubePlaylist +from video.src.constants import VideoTypeEnum class PendingIndex: @@ -241,7 +240,6 @@ class PendingList(PendingIndex): def add_to_pending(self, status="pending", auto_start=False): """add missing videos to pending list""" self.get_channels() - bulk_list = [] total = len(self.missing_videos) videos_added = [] @@ -253,6 +251,7 @@ class PendingList(PendingIndex): self._notify_add(idx, total) video_details = self.get_youtube_details(youtube_id, vid_type) if not video_details: + rand_sleep(self.config) continue 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"] 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) - if len(bulk_list) >= 20: - self._ingest_bulk(bulk_list) - bulk_list = [] - - self._ingest_bulk(bulk_list) + if idx != total: + rand_sleep(self.config) 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): """send notification for adding videos to download queue""" if not self.task: diff --git a/tubearchivist/home/src/download/subscriptions.py b/backend/download/src/subscriptions.py similarity index 95% rename from tubearchivist/home/src/download/subscriptions.py rename to backend/download/src/subscriptions.py index 34cd87e2..abc0a4eb 100644 --- a/tubearchivist/home/src/download/subscriptions.py +++ b/backend/download/src/subscriptions.py @@ -4,16 +4,16 @@ Functionality: - handle playlist subscriptions """ -from home.src.download.thumbnails import ThumbManager -from home.src.download.yt_dlp_base import YtWrap -from home.src.es.connect import IndexPaginate -from home.src.index.channel import YoutubeChannel -from home.src.index.playlist import YoutubePlaylist -from home.src.index.video import YoutubeVideo -from home.src.index.video_constants import VideoTypeEnum -from home.src.ta.config import AppConfig -from home.src.ta.helper import is_missing -from home.src.ta.urlparser import Parser +from appsettings.src.config import AppConfig +from channel.src.index import YoutubeChannel +from common.src.es_connect import IndexPaginate +from common.src.helper import is_missing, rand_sleep +from common.src.urlparser import Parser +from download.src.thumbnails import ThumbManager +from download.src.yt_dlp_base import YtWrap +from playlist.src.index import YoutubePlaylist +from video.src.constants import VideoTypeEnum +from video.src.index import YoutubeVideo class ChannelSubscription: @@ -108,6 +108,7 @@ class ChannelSubscription: message_lines=[f"Scanning Channel {idx + 1}/{total}"], progress=(idx + 1) / total, ) + rand_sleep(self.config) return missing_videos @@ -120,6 +121,8 @@ class ChannelSubscription: channel.upload_to_es() channel.sync_to_videos() + return channel.json_data + class VideoQueryBuilder: """Build queries for yt-dlp.""" @@ -277,6 +280,7 @@ class PlaylistSubscription: playlist.build_json() playlist.json_data["playlist_subscribed"] = subscribe_status playlist.upload_to_es() + return playlist.json_data def find_missing(self): """find videos in subscribed playlists not downloaded yet""" @@ -317,6 +321,7 @@ class PlaylistSubscription: message_lines=[f"Scanning Playlists {idx + 1}/{total}"], progress=(idx + 1) / total, ) + rand_sleep(self.config) return missing_videos @@ -422,7 +427,7 @@ class SubscriptionHandler: def _subscribe(self, channel_id): """subscribe to channel""" - ChannelSubscription().change_subscribe( + _ = ChannelSubscription().change_subscribe( channel_id, channel_subscribed=True ) diff --git a/tubearchivist/home/src/download/thumbnails.py b/backend/download/src/thumbnails.py similarity index 99% rename from tubearchivist/home/src/download/thumbnails.py rename to backend/download/src/thumbnails.py index cf4c485f..50af3887 100644 --- a/tubearchivist/home/src/download/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -10,9 +10,9 @@ from io import BytesIO from time import sleep import requests -from home.src.es.connect import ElasticWrap, IndexPaginate -from home.src.ta.helper import is_missing -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.helper import is_missing from mutagen.mp4 import MP4, MP4Cover from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError diff --git a/tubearchivist/home/src/download/yt_dlp_base.py b/backend/download/src/yt_dlp_base.py similarity index 66% rename from tubearchivist/home/src/download/yt_dlp_base.py rename to backend/download/src/yt_dlp_base.py index d95a2972..365238fc 100644 --- a/tubearchivist/home/src/download/yt_dlp_base.py +++ b/backend/download/src/yt_dlp_base.py @@ -4,14 +4,14 @@ functionality: - handle yt-dlp errors """ -import os from datetime import datetime from http import cookiejar from io import StringIO import yt_dlp -from home.src.ta.settings import EnvironmentSettings -from home.src.ta.ta_redis import RedisArchivist +from appsettings.src.config import AppConfig +from common.src.ta_redis import RedisArchivist +from django.conf import settings class YtWrap: @@ -36,14 +36,33 @@ class YtWrap: self.obs = self.OBS_BASE.copy() self.obs.update(self.obs_request) 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""" if self.config["downloads"]["cookie_import"]: cookie_io = CookieHandler(self.config).get() 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): """make download request""" with yt_dlp.YoutubeDL(self.obs) as ydl: @@ -56,6 +75,8 @@ class YtWrap: return False, str(err) + self._validate_cookie() + return True, True def extract(self, url): @@ -78,8 +99,21 @@ class YtWrap: return False + self._validate_cookie() + 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: """handle youtube cookie for yt-dlp""" @@ -87,37 +121,17 @@ class CookieHandler: def __init__(self, config): self.cookie_io = False self.config = config - self.cache_dir = EnvironmentSettings.CACHE_DIR def get(self): """get cookie io stream""" - cookie = RedisArchivist().get_message("cookie") + cookie = RedisArchivist().get_message_str("cookie") self.cookie_io = StringIO(cookie) 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): """set cookie str and activate in config""" RedisArchivist().set_message("cookie", cookie, save=True) - path = ".downloads.cookie_import" - RedisArchivist().set_message("config", True, path=path, save=True) + AppConfig().update_config({"downloads.cookie_import": True}) self.config["downloads"]["cookie_import"] = True print("cookie: activated and stored in Redis") @@ -126,9 +140,7 @@ class CookieHandler: """revoke cookie""" RedisArchivist().del_message("cookie") RedisArchivist().del_message("cookie:valid") - RedisArchivist().set_message( - "config", False, path=".downloads.cookie_import" - ) + AppConfig().update_config({"downloads.cookie_import": False}) print("cookie: revoked") def validate(self): @@ -171,3 +183,27 @@ class CookieHandler: "validated_str": now.strftime("%Y-%m-%d %H:%M"), } 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}) diff --git a/tubearchivist/home/src/download/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py similarity index 95% rename from tubearchivist/home/src/download/yt_dlp_handler.py rename to backend/download/src/yt_dlp_handler.py index 5c4ad48d..8a07164f 100644 --- a/tubearchivist/home/src/download/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -10,19 +10,23 @@ import os import shutil from datetime import datetime -from home.src.download.queue import PendingList -from home.src.download.subscriptions import PlaylistSubscription -from home.src.download.yt_dlp_base import YtWrap -from home.src.es.connect import ElasticWrap, IndexPaginate -from home.src.index.channel import YoutubeChannel -from home.src.index.comments import CommentList -from home.src.index.playlist import YoutubePlaylist -from home.src.index.video import YoutubeVideo, index_new_video -from home.src.index.video_constants import VideoTypeEnum -from home.src.ta.config import AppConfig -from home.src.ta.helper import get_channel_overwrites, ignore_filelist -from home.src.ta.settings import EnvironmentSettings -from home.src.ta.ta_redis import RedisQueue +from appsettings.src.config import AppConfig +from channel.src.index import YoutubeChannel +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.helper import ( + get_channel_overwrites, + ignore_filelist, + rand_sleep, +) +from common.src.ta_redis import RedisQueue +from download.src.queue import PendingList +from download.src.subscriptions import PlaylistSubscription +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: @@ -370,6 +374,7 @@ class DownloadPostProcess(DownloaderBase): ] progress = idx / total self.task.send_progress(message, progress=progress) + rand_sleep(self.config) def add_playlists_to_refresh(self) -> None: """add playlists to refresh""" diff --git a/backend/download/urls.py b/backend/download/urls.py new file mode 100644 index 00000000..94e96343 --- /dev/null +++ b/backend/download/urls.py @@ -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( + "/", + views.DownloadApiView.as_view(), + name="api-download", + ), +] diff --git a/backend/download/views.py b/backend/download/views.py new file mode 100644 index 00000000..fd1fc7a2 --- /dev/null +++ b/backend/download/views.py @@ -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// + 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}) diff --git a/tubearchivist/manage.py b/backend/manage.py similarity index 100% rename from tubearchivist/manage.py rename to backend/manage.py diff --git a/tubearchivist/home/settings.py b/backend/playlist/__init__.py similarity index 100% rename from tubearchivist/home/settings.py rename to backend/playlist/__init__.py diff --git a/backend/playlist/migrations/__init__.py b/backend/playlist/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/playlist/src/__init__.py b/backend/playlist/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/playlist/src/constants.py b/backend/playlist/src/constants.py new file mode 100644 index 00000000..3cec371d --- /dev/null +++ b/backend/playlist/src/constants.py @@ -0,0 +1,10 @@ +"""playlist constants""" + +import enum + + +class PlaylistTypesEnum(enum.Enum): + """all playlist_type options""" + + REGULAR = "regular" + CUSTOM = "custom" diff --git a/tubearchivist/home/src/index/playlist.py b/backend/playlist/src/index.py similarity index 92% rename from tubearchivist/home/src/index/playlist.py rename to backend/playlist/src/index.py index a7cef0f0..387c213c 100644 --- a/tubearchivist/home/src/index/playlist.py +++ b/backend/playlist/src/index.py @@ -7,11 +7,12 @@ functionality: import json from datetime import datetime -from home.src.download.thumbnails import ThumbManager -from home.src.es.connect import ElasticWrap, IndexPaginate -from home.src.index import channel -from home.src.index.generic import YouTubeItem -from home.src.index.video import YoutubeVideo +from channel.src import index as channel +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.index_generic import YouTubeItem +from download.src.thumbnails import ThumbManager +from video.src import index as ta_video class YoutubePlaylist(YouTubeItem): @@ -93,13 +94,10 @@ class YoutubePlaylist(YouTubeItem): """get all videos in playlist, match downloaded with ids_found""" all_members = [] for idx, entry in enumerate(self.youtube_meta["entries"]): - if not entry["channel"]: - continue - to_append = { "youtube_id": entry["id"], "title": entry["title"], - "uploader": entry["channel"], + "uploader": entry.get("channel"), "idx": idx, "downloaded": entry["id"] in ids_found, } @@ -149,6 +147,9 @@ class YoutubePlaylist(YouTubeItem): "query": {"match": {"playlist": self.youtube_id}}, "_source": ["youtube_id"], } + data = { + "query": {"term": {"playlist.keyword": {"value": self.youtube_id}}} + } result = IndexPaginate("ta_video", data).get_results() to_remove = [ 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): """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 = [i for i in all_entries_available if i["downloaded"]] current = [i for i in all_entries if i["youtube_id"] == youtube_id] @@ -203,14 +205,16 @@ class YoutubePlaylist(YouTubeItem): else: previous_item = all_entries[current_idx - 1] 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: next_item = False else: next_item = all_entries[current_idx + 1] 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 = { "playlist_meta": { @@ -255,7 +259,7 @@ class YoutubePlaylist(YouTubeItem): i = 0 while i < len(playlist): video_id = playlist[i]["youtube_id"] - video = YoutubeVideo(video_id) + video = ta_video.YoutubeVideo(video_id) video.get_from_es() if ( channel_id is None @@ -278,7 +282,7 @@ class YoutubePlaylist(YouTubeItem): if i["downloaded"] ] for youtube_id in all_youtube_id: - YoutubeVideo(youtube_id).delete_media_file() + ta_video.YoutubeVideo(youtube_id).delete_media_file() self.delete_metadata() @@ -312,7 +316,7 @@ class YoutubePlaylist(YouTubeItem): ) self.set_playlist_thumbnail() self.upload_to_es() - video = YoutubeVideo(video_id) + video = ta_video.YoutubeVideo(video_id) video.get_from_es() if "playlist" not in video.json_data: video.json_data["playlist"] = [] @@ -321,7 +325,7 @@ class YoutubePlaylist(YouTubeItem): return True def remove_playlist_from_video(self, video_id): - video = YoutubeVideo(video_id) + video = ta_video.YoutubeVideo(video_id) video.get_from_es() if video.json_data is not None and "playlist" in video.json_data: video.json_data["playlist"].remove(self.youtube_id) @@ -410,7 +414,7 @@ class YoutubePlaylist(YouTubeItem): ) def get_video_is_watched(self, video_id): - video = YoutubeVideo(video_id) + video = ta_video.YoutubeVideo(video_id) video.get_from_es() return video.json_data["player"]["watched"] @@ -426,7 +430,7 @@ class YoutubePlaylist(YouTubeItem): self.get_playlist_art() def get_video_metadata(self, video_id): - video = YoutubeVideo(video_id) + video = ta_video.YoutubeVideo(video_id) video.get_from_es() video_json_data = { "youtube_id": video.json_data["youtube_id"], diff --git a/backend/playlist/src/query_building.py b/backend/playlist/src/query_building.py new file mode 100644 index 00000000..4751144f --- /dev/null +++ b/backend/playlist/src/query_building.py @@ -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"}}]} diff --git a/backend/playlist/tests/__init__.py b/backend/playlist/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/playlist/tests/test_src/__init__.py b/backend/playlist/tests/test_src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/playlist/tests/test_src/test_query_building.py b/backend/playlist/tests/test_src/test_query_building.py new file mode 100644 index 00000000..40404f12 --- /dev/null +++ b/backend/playlist/tests/test_src/test_query_building.py @@ -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"}} diff --git a/backend/playlist/urls.py b/backend/playlist/urls.py new file mode 100644 index 00000000..c144e2e0 --- /dev/null +++ b/backend/playlist/urls.py @@ -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( + "/", + views.PlaylistApiView.as_view(), + name="api-playlist", + ), +] diff --git a/backend/playlist/views.py b/backend/playlist/views.py new file mode 100644 index 00000000..79f6ea09 --- /dev/null +++ b/backend/playlist/views.py @@ -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= + - 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// + 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}) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 00000000..bd641c5c --- /dev/null +++ b/backend/requirements-dev.txt @@ -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 diff --git a/tubearchivist/requirements.txt b/backend/requirements.txt similarity index 63% rename from tubearchivist/requirements.txt rename to backend/requirements.txt index 41cfea40..b4c6c09e 100644 --- a/tubearchivist/requirements.txt +++ b/backend/requirements.txt @@ -1,14 +1,14 @@ -apprise==1.9.0 +apprise==1.9.2 celery==5.4.0 django-auth-ldap==5.1.0 django-celery-beat==2.7.0 django-cors-headers==4.6.0 -Django==5.1.3 +Django==5.1.5 djangorestframework==3.15.2 -Pillow==11.0.0 -redis==5.2.0 +Pillow==11.1.0 +redis==5.2.1 requests==2.32.3 ryd-client==0.0.6 -uWSGI==2.0.28 +uvicorn==0.34.0 whitenoise==6.8.2 -yt-dlp[default]==2024.11.4 +yt-dlp[default]==2025.1.26 diff --git a/backend/stats/__init__.py b/backend/stats/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/stats/migrations/__init__.py b/backend/stats/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/stats/src/__init__.py b/backend/stats/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tubearchivist/api/src/aggs.py b/backend/stats/src/aggs.py similarity index 95% rename from tubearchivist/api/src/aggs.py rename to backend/stats/src/aggs.py index e2c65dbd..c4c29f53 100644 --- a/tubearchivist/api/src/aggs.py +++ b/backend/stats/src/aggs.py @@ -1,8 +1,8 @@ """aggregations""" -from home.src.es.connect import ElasticWrap -from home.src.ta.helper import get_duration_str -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap +from common.src.helper import get_duration_str class AggBase: @@ -55,6 +55,8 @@ class Video(AggBase): def process(self): """process aggregation""" aggregations = self.get() + if not aggregations: + return None duration = int(aggregations["duration"]["value"]) response = { @@ -109,6 +111,8 @@ class Channel(AggBase): def process(self): """process aggregation""" aggregations = self.get() + if not aggregations: + return None response = { "doc_count": aggregations["channel_count"].get("value"), @@ -140,6 +144,9 @@ class Playlist(AggBase): def process(self): """process aggregation""" aggregations = self.get() + if not aggregations: + return None + response = {"doc_count": aggregations["playlist_count"].get("value")} for bucket in aggregations["playlist_active"]["buckets"]: key = f"active_{bucket['key_as_string']}" @@ -171,6 +178,9 @@ class Download(AggBase): """process aggregation""" aggregations = self.get() response = {} + if not aggregations: + return None + for bucket in aggregations["status"]["buckets"]: response.update({bucket["key"]: bucket.get("doc_count")}) @@ -209,9 +219,11 @@ class WatchProgress(AggBase): def process(self): """make the call""" aggregations = self.get() - buckets = aggregations[self.name]["buckets"] - response = {} + if not aggregations: + return None + + buckets = aggregations[self.name]["buckets"] all_duration = int(aggregations["total_duration"].get("value")) response.update( { @@ -287,6 +299,9 @@ class DownloadHist(AggBase): def process(self): """process query""" aggregations = self.get() + if not aggregations: + return None + buckets = aggregations[self.name]["buckets"] response = [ @@ -334,6 +349,9 @@ class BiggestChannel(AggBase): """process aggregation, order_by validated in the view""" aggregations = self.get() + if not aggregations: + return None + buckets = aggregations[self.name]["buckets"] response = [ diff --git a/backend/stats/urls.py b/backend/stats/urls.py new file mode 100644 index 00000000..35fa89d4 --- /dev/null +++ b/backend/stats/urls.py @@ -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", + ), +] diff --git a/backend/stats/views.py b/backend/stats/views.py new file mode 100644 index 00000000..c5d83678 --- /dev/null +++ b/backend/stats/views.py @@ -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()) diff --git a/tubearchivist/home/__init__.py b/backend/task/__init__.py similarity index 72% rename from tubearchivist/home/__init__.py rename to backend/task/__init__.py index 2e00ac76..bcbeaf0c 100644 --- a/tubearchivist/home/__init__.py +++ b/backend/task/__init__.py @@ -2,6 +2,6 @@ from __future__ import absolute_import, unicode_literals -from home.celery import app as celery_app +from task.celery import app as celery_app __all__ = ("celery_app",) diff --git a/tubearchivist/home/celery.py b/backend/task/celery.py similarity index 59% rename from tubearchivist/home/celery.py rename to backend/task/celery.py index 1f3369f8..b7af1ba0 100644 --- a/tubearchivist/home/celery.py +++ b/backend/task/celery.py @@ -3,16 +3,13 @@ import os from celery import Celery -from home.src.ta.settings import EnvironmentSettings - -REDIS_HOST = EnvironmentSettings.REDIS_HOST -REDIS_PORT = EnvironmentSettings.REDIS_PORT +from common.src.env_settings import EnvironmentSettings os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") app = Celery( "tasks", - broker=f"redis://{REDIS_HOST}:{REDIS_PORT}", - backend=f"redis://{REDIS_HOST}:{REDIS_PORT}", + broker=EnvironmentSettings.REDIS_CON, + backend=EnvironmentSettings.REDIS_CON, result_extended=True, ) app.config_from_object( diff --git a/backend/task/migrations/0001_initial.py b/backend/task/migrations/0001_initial.py new file mode 100644 index 00000000..38b9dac8 --- /dev/null +++ b/backend/task/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.0.7 on 2024-07-22 18:39 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("django_celery_beat", "0018_improve_crontab_helptext"), + ] + + operations = [ + migrations.CreateModel( + name="CustomPeriodicTask", + fields=[ + ( + "periodictask_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="django_celery_beat.periodictask", + ), + ), + ("task_config", models.JSONField(default=dict)), + ], + bases=("django_celery_beat.periodictask",), + ), + ] diff --git a/backend/task/migrations/__init__.py b/backend/task/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/task/models.py b/backend/task/models.py new file mode 100644 index 00000000..0bcd699b --- /dev/null +++ b/backend/task/models.py @@ -0,0 +1,19 @@ +"""task model""" + +from django.db import models +from django_celery_beat.models import PeriodicTask, cronexp + + +class CustomPeriodicTask(PeriodicTask): + """add custom metadata to task""" + + task_config = models.JSONField(default=dict) + + @property + def schedule_parsed(self): + """parse schedule""" + minute = cronexp(self.crontab.minute) + hour = cronexp(self.crontab.hour) + day_of_week = cronexp(self.crontab.day_of_week) + + return f"{minute} {hour} {day_of_week}" diff --git a/backend/task/serializers.py b/backend/task/serializers.py new file mode 100644 index 00000000..6c16f633 --- /dev/null +++ b/backend/task/serializers.py @@ -0,0 +1,23 @@ +"""serializer for tasks""" + +from rest_framework import serializers +from task.models import CustomPeriodicTask + + +class CustomPeriodicTaskSerializer(serializers.ModelSerializer): + """serialize CustomPeriodicTask""" + + schedule = serializers.CharField(source="schedule_parsed") + schedule_human = serializers.CharField(source="crontab.human_readable") + last_run_at = serializers.DateTimeField() + config = serializers.DictField(source="task_config") + + class Meta: + model = CustomPeriodicTask + fields = [ + "name", + "schedule", + "schedule_human", + "last_run_at", + "config", + ] diff --git a/backend/task/src/__init__.py b/backend/task/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/task/src/config_schedule.py b/backend/task/src/config_schedule.py new file mode 100644 index 00000000..4111d41b --- /dev/null +++ b/backend/task/src/config_schedule.py @@ -0,0 +1,141 @@ +""" +Functionality: +- Handle scheduler config update +""" + +from datetime import datetime + +from appsettings.src.config import AppConfig +from celery.schedules import crontab +from common.src.env_settings import EnvironmentSettings +from django.utils import dateformat +from django_celery_beat.models import CrontabSchedule +from task.models import CustomPeriodicTask +from task.src.task_config import TASK_CONFIG + + +class ScheduleBuilder: + """build schedule dicts for beat""" + + SCHEDULES = { + "update_subscribed": "0 8 *", + "download_pending": "0 16 *", + "check_reindex": "0 12 *", + "thumbnail_check": "0 17 *", + "run_backup": "0 18 0", + "version_check": "0 11 *", + } + MSG = "message:setting" + + def __init__(self): + self.config = AppConfig().config + + def update_schedule( + self, task_name: str, cron_schedule: str, schedule_conf: dict | None + ) -> None: + """update schedule""" + if cron_schedule == "auto": + cron_schedule = self.SCHEDULES[task_name] + + if cron_schedule: + _ = self.get_set_task(task_name, cron_schedule) + + if schedule_conf: + for key, value in schedule_conf.items(): + self.set_config(task_name, key, value) + + def get_set_task(self, task_name, schedule=False): + """get task""" + try: + task = CustomPeriodicTask.objects.get(name=task_name) + except CustomPeriodicTask.DoesNotExist: + description = TASK_CONFIG[task_name].get("title") + task = CustomPeriodicTask( + name=task_name, + task=task_name, + description=description, + ) + + if schedule: + task_crontab = self.get_set_cron_tab(schedule) + task.crontab = task_crontab + task.last_run_at = dateformat.make_aware(datetime.now()) + task.save() + + return task + + @staticmethod + def get_set_cron_tab(schedule: str) -> CrontabSchedule: + """needs to be validated before""" + kwargs = dict(zip(["minute", "hour", "day_of_week"], schedule.split())) + kwargs.update({"timezone": EnvironmentSettings.TZ}) + task_crontab, _ = CrontabSchedule.objects.get_or_create(**kwargs) + + return task_crontab + + def set_config(self, task_name: str, key: str, value) -> None: + """set task_config, validate before""" + try: + task = CustomPeriodicTask.objects.get(name=task_name) + task.task_config.update({key: value}) + task.save() + except CustomPeriodicTask.DoesNotExist: + pass + + +class CrontabValidator: + """validate crontab""" + + CONFIG = { + "check_reindex": ["days"], + "run_backup": ["rotate"], + } + + @staticmethod + def validate_fields(cron_fields: str) -> None: + """expect 3 cron fields""" + if not len(cron_fields) == 3: + raise ValueError("expected three cron schedule fields") + + @staticmethod + def validate_minute(minute_field: str): + """expect minute int""" + if not minute_field.isdigit(): + raise ValueError("Invalid value for minutes. Must be an integer.") + + minutes = int(minute_field) + if not 0 <= minutes <= 59: + raise ValueError("Invalid minutes. Must be between 0 and 59.") + + @staticmethod + def validate_cron_tab(minute, hour, day_of_week): + """check if crontab can be created""" + try: + crontab(minute=minute, hour=hour, day_of_week=day_of_week) + except ValueError as err: + raise ValueError(f"invalid crontab: {err}") from err + + def validate_cron(self, cron_expression): + """create crontab schedule""" + if not cron_expression or cron_expression == "auto": + return + + cron_fields = cron_expression.split() + self.validate_fields(cron_fields) + + minute, hour, day_of_week = cron_fields + self.validate_minute(minute) + self.validate_cron_tab(minute, hour, day_of_week) + + def validate_config(self, task_name: str, schedule_config: dict): + """validate config for given task""" + if not schedule_config: + return + + config_keys = self.CONFIG.get(task_name) + if not config_keys: + raise ValueError(f"task '{task_name}' doesn't take config") + + for key in schedule_config: + if key not in config_keys: + raise ValueError(f"invalid config key for task '{task_name}'") diff --git a/tubearchivist/home/src/ta/notify.py b/backend/task/src/notify.py similarity index 96% rename from tubearchivist/home/src/ta/notify.py rename to backend/task/src/notify.py index 63140775..3a9b1f23 100644 --- a/tubearchivist/home/src/ta/notify.py +++ b/backend/task/src/notify.py @@ -1,9 +1,9 @@ """send notifications using apprise""" import apprise -from home.src.es.connect import ElasticWrap -from home.src.ta.task_config import TASK_CONFIG -from home.src.ta.task_manager import TaskManager +from common.src.es_connect import ElasticWrap +from task.src.task_config import TASK_CONFIG +from task.src.task_manager import TaskManager class Notifications: diff --git a/tubearchivist/home/src/ta/task_config.py b/backend/task/src/task_config.py similarity index 100% rename from tubearchivist/home/src/ta/task_config.py rename to backend/task/src/task_config.py diff --git a/tubearchivist/home/src/ta/task_manager.py b/backend/task/src/task_manager.py similarity index 86% rename from tubearchivist/home/src/ta/task_manager.py rename to backend/task/src/task_manager.py index 0813ccb5..ea48e5e8 100644 --- a/tubearchivist/home/src/ta/task_manager.py +++ b/backend/task/src/task_manager.py @@ -4,9 +4,8 @@ functionality: - handle threads and locks """ -from home.celery import app as celery_app -from home.src.ta.ta_redis import RedisArchivist, TaskRedis -from home.src.ta.task_config import TASK_CONFIG +from common.src.ta_redis import TaskRedis +from task.celery import app as celery_app class TaskManager: @@ -96,20 +95,13 @@ class TaskCommand: return message - def stop(self, task_id, message_key): + def stop(self, task_id): """ send stop signal to task_id, needs to be implemented in task to take effect """ print(f"[task][{task_id}]: received STOP signal.") - handler = TaskRedis() - - task = handler.get_single(task_id) - if not task["name"] in TASK_CONFIG: - raise ValueError - - handler.set_command(task_id, "STOP") - RedisArchivist().set_message(message_key, "STOP", path=".command") + TaskRedis().set_command(task_id, "STOP") def kill(self, task_id): """send kill signal to task_id""" diff --git a/tubearchivist/home/tasks.py b/backend/task/tasks.py similarity index 91% rename from tubearchivist/home/tasks.py rename to backend/task/tasks.py index c890fa64..ff3f0278 100644 --- a/tubearchivist/home/tasks.py +++ b/backend/task/tasks.py @@ -6,27 +6,24 @@ Functionality: - handle task locking """ +from appsettings.src.backup import ElasticBackup +from appsettings.src.config import ReleaseVersion +from appsettings.src.filesystem import Scanner +from appsettings.src.index_setup import ElasitIndexWrap +from appsettings.src.manual import ImportFolderScanner +from appsettings.src.reindex import Reindex, ReindexManual, ReindexPopulate from celery import Task, shared_task from celery.exceptions import Retry -from home.src.download.queue import PendingList -from home.src.download.subscriptions import ( - SubscriptionHandler, - SubscriptionScanner, -) -from home.src.download.thumbnails import ThumbFilesystem, ThumbValidator -from home.src.download.yt_dlp_handler import VideoDownloader -from home.src.es.backup import ElasticBackup -from home.src.es.index_setup import ElasitIndexWrap -from home.src.index.channel import YoutubeChannel -from home.src.index.filesystem import Scanner -from home.src.index.manual import ImportFolderScanner -from home.src.index.reindex import Reindex, ReindexManual, ReindexPopulate -from home.src.ta.config import ReleaseVersion -from home.src.ta.notify import Notifications -from home.src.ta.ta_redis import RedisArchivist -from home.src.ta.task_config import TASK_CONFIG -from home.src.ta.task_manager import TaskManager -from home.src.ta.urlparser import Parser +from channel.src.index import YoutubeChannel +from common.src.ta_redis import RedisArchivist +from common.src.urlparser import Parser +from download.src.queue import PendingList +from download.src.subscriptions import SubscriptionHandler, SubscriptionScanner +from download.src.thumbnails import ThumbFilesystem, ThumbValidator +from download.src.yt_dlp_handler import VideoDownloader +from task.src.notify import Notifications +from task.src.task_config import TASK_CONFIG +from task.src.task_manager import TaskManager class BaseTask(Task): diff --git a/backend/task/tests/__init__.py b/backend/task/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/task/tests/test_src/__init__.py b/backend/task/tests/test_src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/task/tests/test_src/test_config_schedule.py b/backend/task/tests/test_src/test_config_schedule.py new file mode 100644 index 00000000..637e8884 --- /dev/null +++ b/backend/task/tests/test_src/test_config_schedule.py @@ -0,0 +1,68 @@ +"""test schedule parsing""" + +# flake8: noqa: E402 + +import os + +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +django.setup() + +import pytest +from task.src.config_schedule import CrontabValidator + +INCORRECT_CRONTAB = [ + "0 0 * * *", + "0 0", + "0", +] + + +@pytest.mark.parametrize("invalid_value", INCORRECT_CRONTAB) +def test_invalid_len(invalid_value): + """raise error on invalid crontab""" + validator = CrontabValidator() + with pytest.raises(ValueError, match="three cron schedule fields"): + validator.validate_cron(invalid_value) + + +NONE_INT_MINUTE = [ + "* * *", + "0,30 * *", + "0,1,2 * *", + "-1 * *", +] + + +@pytest.mark.parametrize("invalid_value", NONE_INT_MINUTE) +def test_none_int_crontabs(invalid_value): + """raise error on invalid crontab""" + validator = CrontabValidator() + with pytest.raises(ValueError, match="Must be an integer."): + validator.validate_cron(invalid_value) + + +INVALID_MINUTE = ["60 * *", "61 * *"] + + +@pytest.mark.parametrize("invalid_value", INVALID_MINUTE) +def test_invalid_minute(invalid_value): + """raise error on invalid crontab""" + validator = CrontabValidator() + with pytest.raises(ValueError, match="Must be between 0 and 59."): + validator.validate_cron(invalid_value) + + +INVALID_CRONTAB = [ + "0 /1 *", + "0 0/1 *", +] + + +@pytest.mark.parametrize("invalid_value", INVALID_CRONTAB) +def test_invalid_crontab(invalid_value): + """raise error on invalid crontab""" + validator = CrontabValidator() + with pytest.raises(ValueError, match="invalid crontab"): + validator.validate_cron(invalid_value) diff --git a/backend/task/urls.py b/backend/task/urls.py new file mode 100644 index 00000000..66f985c6 --- /dev/null +++ b/backend/task/urls.py @@ -0,0 +1,37 @@ +"""all tasks api URLs""" + +from django.urls import path +from task import views + +urlpatterns = [ + path( + "by-name/", + views.TaskListView.as_view(), + name="api-task-list", + ), + path( + "by-name//", + views.TaskNameListView.as_view(), + name="api-task-name-list", + ), + path( + "by-id//", + views.TaskIDView.as_view(), + name="api-task-id", + ), + path( + "schedule/", + views.ScheduleListView.as_view(), + name="api-schedule-list", + ), + path( + "schedule//", + views.ScheduleView.as_view(), + name="api-schedule", + ), + path( + "notification/", + views.ScheduleNotification.as_view(), + name="api-schedule-notification", + ), +] diff --git a/backend/task/views.py b/backend/task/views.py new file mode 100644 index 00000000..61d03f7f --- /dev/null +++ b/backend/task/views.py @@ -0,0 +1,223 @@ +"""all task API views""" + +from common.views_base import AdminOnly, ApiBaseView +from django.shortcuts import get_object_or_404 +from rest_framework.response import Response +from task.models import CustomPeriodicTask +from task.serializers import CustomPeriodicTaskSerializer +from task.src.config_schedule import CrontabValidator, ScheduleBuilder +from task.src.notify import Notifications, get_all_notifications +from task.src.task_config import TASK_CONFIG +from task.src.task_manager import TaskCommand, TaskManager + + +class TaskListView(ApiBaseView): + """resolves to /api/task/by-name/ + GET: return a list of all stored task results + """ + + permission_classes = [AdminOnly] + + def get(self, request): + """handle get request""" + # pylint: disable=unused-argument + all_results = TaskManager().get_all_results() + + return Response(all_results) + + +class TaskNameListView(ApiBaseView): + """resolves to /api/task/by-name// + GET: return a list of stored results of task + POST: start new background process + """ + + permission_classes = [AdminOnly] + + def get(self, request, task_name): + """handle get request""" + # pylint: disable=unused-argument + if task_name not in TASK_CONFIG: + message = {"message": "invalid task name"} + return Response(message, status=404) + + all_results = TaskManager().get_tasks_by_name(task_name) + + return Response(all_results) + + def post(self, request, task_name): + """ + handle post request + 404 for invalid task_name + 400 if task can't be started here without argument + """ + # pylint: disable=unused-argument + task_config = TASK_CONFIG.get(task_name) + if not task_config: + message = {"message": "invalid task name"} + return Response(message, status=404) + + if not task_config.get("api_start"): + message = {"message": "can not start task through this endpoint"} + return Response(message, status=400) + + message = TaskCommand().start(task_name) + + return Response({"message": message}) + + +class TaskIDView(ApiBaseView): + """resolves to /api/task/by-id// + GET: return details of task id + POST: send command to task by id + """ + + valid_commands = ["stop", "kill"] + permission_classes = [AdminOnly] + + def get(self, request, task_id): + """handle get request""" + # pylint: disable=unused-argument + task_result = TaskManager().get_task(task_id) + if not task_result: + message = {"message": "task id not found"} + return Response(message, status=404) + + return Response(task_result) + + def post(self, request, task_id): + """post command to task""" + command = request.data.get("command") + if not command or command not in self.valid_commands: + message = {"message": "no valid command found"} + return Response(message, status=400) + + task_result = TaskManager().get_task(task_id) + if not task_result: + message = {"message": "task id not found"} + return Response(message, status=404) + + task_conf = TASK_CONFIG.get(task_result.get("name")) + if command == "stop": + if not task_conf.get("api_stop"): + message = {"message": "task can not be stopped"} + return Response(message, status=400) + + TaskCommand().stop(task_id) + if command == "kill": + if not task_conf.get("api_stop"): + message = {"message": "task can not be killed"} + return Response(message, status=400) + + TaskCommand().kill(task_id) + + return Response({"message": "command sent"}) + + +class ScheduleListView(ApiBaseView): + """resolves to /api/task/schedule/ + GET: list all schedules + """ + + permission_classes = [AdminOnly] + + def get(self, request): + """get all schedules""" + tasks = CustomPeriodicTask.objects.all() + response = CustomPeriodicTaskSerializer(tasks, many=True).data + return Response(response) + + +class ScheduleView(ApiBaseView): + """resolves to /api/task/schedule// + POST: create/update schedule for task with config + - example: {"schedule": "0 0 *", "config": {"days": 90}} + DEL: delete schedule for task + """ + + permission_classes = [AdminOnly] + + def get(self, request, task_name): + """get single schedule by task_name""" + task = get_object_or_404(CustomPeriodicTask, name=task_name) + response = CustomPeriodicTaskSerializer(task).data + return Response(response) + + def post(self, request, task_name): + """create/update schedule for task""" + cron_schedule = request.data.get("schedule") + schedule_config = request.data.get("config") + if not cron_schedule and not schedule_config: + message = {"message": "expected schedule or config key"} + return Response(message, status=400) + + try: + validator = CrontabValidator() + validator.validate_cron(cron_schedule) + validator.validate_config(task_name, schedule_config) + except ValueError as err: + return Response({"message": str(err)}, status=400) + + ScheduleBuilder().update_schedule( + task_name, cron_schedule, schedule_config + ) + message = f"update schedule for task {task_name}" + if schedule_config: + message += f" with config {schedule_config}" + + return Response({"message": message}) + + def delete(self, request, task_name): + """delete schedule by task_name query""" + task = get_object_or_404(CustomPeriodicTask, name=task_name) + _ = task.delete() + + return Response({"success": True}) + + +class ScheduleNotification(ApiBaseView): + """resolves to /api/task/notification/ + GET: get all schedule notifications + POST: add notification url to task + DEL: delete notification + """ + + def get(self, request): + """handle get request""" + + return Response(get_all_notifications()) + + def post(self, request): + """handle create notification""" + task_name = request.data.get("task_name") + url = request.data.get("url") + + if not TASK_CONFIG.get(task_name): + message = {"message": "task_name not found"} + return Response(message, status=404) + + if not url: + message = {"message": "missing url key"} + return Response(message, status=400) + + Notifications(task_name).add_url(url) + message = {"task_name": task_name, "url": url} + + return Response(message) + + def delete(self, request): + """handle delete""" + + task_name = request.data.get("task_name") + url = request.data.get("url") + + if not TASK_CONFIG.get(task_name): + message = {"message": "task_name not found"} + return Response(message, status=404) + + if url: + response, status_code = Notifications(task_name).remove_url(url) + else: + response, status_code = Notifications(task_name).remove_task() + + return Response({"response": response, "status_code": status_code}) diff --git a/backend/user/__init__.py b/backend/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tubearchivist/home/admin.py b/backend/user/admin.py similarity index 97% rename from tubearchivist/home/admin.py rename to backend/user/admin.py index 3c6e83c5..662a4626 100644 --- a/tubearchivist/home/admin.py +++ b/backend/user/admin.py @@ -3,8 +3,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django_celery_beat import models as BeatModels - -from .models import Account +from user.models import Account class HomeAdmin(BaseUserAdmin): diff --git a/backend/user/migrations/0001_initial.py b/backend/user/migrations/0001_initial.py new file mode 100644 index 00000000..7933db76 --- /dev/null +++ b/backend/user/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.0.7 on 2024-07-22 19:26 + +import user.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="Account", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "password", + models.CharField(max_length=128, verbose_name="password"), + ), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ("name", models.CharField(max_length=150, unique=True)), + ("is_staff", models.BooleanField(default=False)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "abstract": False, + }, + managers=[ + ("objects", user.models.AccountManager()), + ], + ), + ] diff --git a/backend/user/migrations/__init__.py b/backend/user/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tubearchivist/home/models.py b/backend/user/models.py similarity index 90% rename from tubearchivist/home/models.py rename to backend/user/models.py index 3f0c376b..2dacd0c7 100644 --- a/tubearchivist/home/models.py +++ b/backend/user/models.py @@ -6,7 +6,6 @@ from django.contrib.auth.models import ( PermissionsMixin, ) from django.db import models -from django_celery_beat.models import PeriodicTask class AccountManager(BaseUserManager): @@ -53,9 +52,3 @@ class Account(AbstractBaseUser, PermissionsMixin): USERNAME_FIELD = "name" REQUIRED_FIELDS = ["password"] - - -class CustomPeriodicTask(PeriodicTask): - """add custom metadata to to task""" - - task_config = models.JSONField(default=dict) diff --git a/backend/user/serializers.py b/backend/user/serializers.py new file mode 100644 index 00000000..e87a10c0 --- /dev/null +++ b/backend/user/serializers.py @@ -0,0 +1,20 @@ +"""serializer for account model""" + +from rest_framework import serializers +from user.models import Account + + +class AccountSerializer(serializers.ModelSerializer): + """serialize account""" + + class Meta: + model = Account + fields = ( + "id", + "name", + "is_superuser", + "is_staff", + "groups", + "user_permissions", + "last_login", + ) diff --git a/backend/user/src/__init__.py b/backend/user/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tubearchivist/home/src/ta/auth.py b/backend/user/src/remote_user_auth.py similarity index 100% rename from tubearchivist/home/src/ta/auth.py rename to backend/user/src/remote_user_auth.py diff --git a/tubearchivist/home/src/ta/users.py b/backend/user/src/user_config.py similarity index 70% rename from tubearchivist/home/src/ta/users.py rename to backend/user/src/user_config.py index 7181b3fe..42dfa2ef 100644 --- a/tubearchivist/home/src/ta/users.py +++ b/backend/user/src/user_config.py @@ -6,8 +6,8 @@ Functionality: from typing import TypedDict -from home.src.es.connect import ElasticWrap -from home.src.ta.helper import get_stylesheets +from common.src.es_connect import ElasticWrap +from common.src.helper import get_stylesheets class UserConfigType(TypedDict, total=False): @@ -25,7 +25,7 @@ class UserConfigType(TypedDict, total=False): hide_watched: bool show_ignored_only: bool show_subed_only: bool - sponsorblock_id: str + show_help_text: bool class UserConfig: @@ -44,7 +44,7 @@ class UserConfig: hide_watched=False, show_ignored_only=False, show_subed_only=False, - sponsorblock_id=None, + show_help_text=True, ) VALID_STYLESHEETS = get_stylesheets() @@ -64,29 +64,33 @@ class UserConfig: self._user_id: str = user_id self._config: UserConfigType = self.get_config() + @property + def es_url(self) -> str: + """es URL""" + return f"ta_config/_doc/user_{self._user_id}" + + @property + def es_update_url(self) -> str: + """es update URL""" + return f"ta_config/_update/user_{self._user_id}" + def get_value(self, key: str): """Get the given key from the users configuration - Throws a KeyError if the requested Key is not a permitted value""" if key not in self._DEFAULT_USER_SETTINGS: raise KeyError(f"Unable to read config for unknown key '{key}'") - return self._config.get(key) or self._DEFAULT_USER_SETTINGS.get(key) + return self._config.get(key) def set_value(self, key: str, value: str | bool | int): """Set or replace a configuration value for the user""" self._validate(key, value) - old = self.get_value(key) - self._config[key] = value - - # Upsert this property (creating a record if not exists) - es_payload = {"doc": {"config": {key: value}}, "doc_as_upsert": True} - es_document_path = f"ta_config/_update/user_{self._user_id}" - response, status = ElasticWrap(es_document_path).post(es_payload) + data = {"doc": {"config": {key: value}}} + response, status = ElasticWrap(self.es_update_url).post(data) if status < 200 or status > 299: raise ValueError(f"Failed storing user value {status}: {response}") - print(f"User {self._user_id} value '{key}' change: {old} -> {value}") + print(f"User {self._user_id} value '{key}' change: to {value}") def _validate(self, key, value): """validate key and value""" @@ -111,6 +115,7 @@ class UserConfig: "hide_watched": bool, "show_ignored_only": bool, "show_subed_only": bool, + "show_help_text": bool, } validation_value = valid_values.get(key) @@ -127,16 +132,29 @@ class UserConfig: def get_config(self) -> UserConfigType: """get config from ES or load from the application defaults""" if not self._user_id: - # this is for a non logged-in user so use all the defaults - return {} + raise ValueError("no user_id passed") - # Does this user have configuration stored in ES - es_document_path = f"ta_config/_doc/user_{self._user_id}" - response, status = ElasticWrap(es_document_path).get(print_error=False) - if status == 200 and "_source" in response.keys(): - source = response.get("_source") - if "config" in source.keys(): - return source.get("config") + response, status = ElasticWrap(self.es_url).get(print_error=False) + if status == 404: + self.sync_defaults() + config = self._DEFAULT_USER_SETTINGS + else: + config = self.sync_new_defaults(response["_source"]["config"]) - # There is no config in ES - return {} + return config + + def sync_defaults(self): + """set initial defaults on 404""" + response, _ = ElasticWrap(self.es_url).post( + {"config": self._DEFAULT_USER_SETTINGS} + ) + print(f"set default config for user {self._user_id}: {response}") + + def sync_new_defaults(self, config): + """sync new defaults""" + for key, value in self._DEFAULT_USER_SETTINGS.items(): + if key not in config: + self.set_value(key, value) + config.update({key: value}) + + return config diff --git a/backend/user/urls.py b/backend/user/urls.py new file mode 100644 index 00000000..437f66b7 --- /dev/null +++ b/backend/user/urls.py @@ -0,0 +1,10 @@ +"""all user API urls""" + +from django.urls import path +from user import views + +urlpatterns = [ + path("login/", views.LoginApiView.as_view(), name="api-user-login"), + path("logout/", views.LogoutApiView.as_view(), name="api-user-logout"), + path("me/", views.UserConfigView.as_view(), name="api-user-me"), +] diff --git a/backend/user/views.py b/backend/user/views.py new file mode 100644 index 00000000..4cbb4d71 --- /dev/null +++ b/backend/user/views.py @@ -0,0 +1,95 @@ +"""all user api views""" + +from common.views import ApiBaseView +from django.contrib.auth import authenticate, login, logout +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from user.models import Account +from user.serializers import AccountSerializer +from user.src.user_config import UserConfig + + +class UserConfigView(ApiBaseView): + """resolves to /api/user/me/ + GET: return current user config + POST: update user config + """ + + def get(self, request): + """get config""" + user_id = request.user.id + account = Account.objects.get(id=user_id) + serializer = AccountSerializer(account) + response = serializer.data.copy() + + config = UserConfig(user_id).get_config() + response.update({"config": config}) + + return Response(response) + + def post(self, request): + """update config""" + user_id = request.user.id + data = request.data + + data_config = data.get("config") + if not data_config: + message = { + "status": "Bad Request", + "message": "missing config key", + } + return Response(message, status=400) + + user_conf = UserConfig(user_id) + for key, value in data_config.items(): + try: + user_conf.set_value(key, value) + except ValueError as err: + message = { + "status": "Bad Request", + "message": f"failed updating {key} to '{value}', {err}", + } + return Response(message, status=400) + + response = user_conf.get_config() + response.update({"user_id": user_id}) + + return Response(response) + + +@method_decorator(csrf_exempt, name="dispatch") +class LoginApiView(APIView): + """resolves to /api/user/login/ + POST: return token and username after successful login + """ + + permission_classes = [AllowAny] + + def post(self, request, *args, **kwargs): + """post data""" + # pylint: disable=no-member + + username = request.data.get("username") + password = request.data.get("password") + + user = authenticate(request, username=username, password=password) + + if user is not None: + login(request, user) # Creates a session for the user + return Response({"message": "Login successful"}, status=200) + + return Response({"message": "Invalid credentials"}, status=400) + + +class LogoutApiView(ApiBaseView): + """resolves to /api/user/logout/ + POST: handle logout + """ + + def post(self, request, *args, **kwargs): + """logout on post request""" + logout(request) + return Response({"message": "Successfully logged out."}, status=200) diff --git a/backend/video/__init__.py b/backend/video/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/video/migrations/__init__.py b/backend/video/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/video/src/__init__.py b/backend/video/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tubearchivist/home/src/index/comments.py b/backend/video/src/comments.py similarity index 96% rename from tubearchivist/home/src/index/comments.py rename to backend/video/src/comments.py index 794cbc35..2d13448c 100644 --- a/tubearchivist/home/src/index/comments.py +++ b/backend/video/src/comments.py @@ -7,10 +7,11 @@ Functionality: from datetime import datetime -from home.src.download.yt_dlp_base import YtWrap -from home.src.es.connect import ElasticWrap -from home.src.ta.config import AppConfig -from home.src.ta.ta_redis import RedisQueue +from appsettings.src.config import AppConfig +from common.src.es_connect import ElasticWrap +from common.src.helper import rand_sleep +from common.src.ta_redis import RedisQueue +from download.src.yt_dlp_base import YtWrap class Comments: @@ -69,7 +70,6 @@ class Comments: "youtube": { "max_comments": max_comments_list, "comment_sort": [comment_sort], - "player_client": ["ios", "web"], # workaround yt-dlp #9554 } }, } @@ -220,6 +220,8 @@ class CommentList: if comment.json_data: comment.upload_comments() + rand_sleep(self.config) + def notify(self, idx, total_videos): """send notification on task""" message = [f"Add comments for new videos {idx}/{total_videos}"] diff --git a/backend/video/src/constants.py b/backend/video/src/constants.py new file mode 100644 index 00000000..39dcfff3 --- /dev/null +++ b/backend/video/src/constants.py @@ -0,0 +1,30 @@ +"""video constants""" + +import enum + + +class VideoTypeEnum(enum.Enum): + """all vid_type fields""" + + VIDEOS = "videos" + STREAMS = "streams" + SHORTS = "shorts" + UNKNOWN = "unknown" + + +class SortEnum(enum.Enum): + """all sort by options""" + + PUBLISHED = "published" + DOWNLOADED = "date_downloaded" + VIEWS = "stats.view_count" + LIKES = "stats.like_count" + DURATION = "player.duration" + MEDIASIZE = "media_size" + + +class OrderEnum(enum.Enum): + """all order by options""" + + ASC = "asc" + DESC = "desc" diff --git a/tubearchivist/home/src/index/video.py b/backend/video/src/index.py similarity index 95% rename from tubearchivist/home/src/index/video.py rename to backend/video/src/index.py index 22efc088..ff90b142 100644 --- a/tubearchivist/home/src/index/video.py +++ b/backend/video/src/index.py @@ -8,19 +8,19 @@ import os from datetime import datetime import requests +from channel.src import index as ta_channel +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap +from common.src.helper import get_duration_sec, get_duration_str, randomizor +from common.src.index_generic import YouTubeItem from django.conf import settings -from home.src.es.connect import ElasticWrap -from home.src.index import channel as ta_channel -from home.src.index import comments as ta_comments -from home.src.index import playlist as ta_playlist -from home.src.index.generic import YouTubeItem -from home.src.index.subtitle import YoutubeSubtitle -from home.src.index.video_constants import VideoTypeEnum -from home.src.index.video_streams import MediaStreamExtractor -from home.src.ta.helper import get_duration_sec, get_duration_str, randomizor -from home.src.ta.settings import EnvironmentSettings -from home.src.ta.users import UserConfig +from playlist.src import index as ta_playlist from ryd_client import ryd_client +from user.src.user_config import UserConfig +from video.src.comments import Comments +from video.src.constants import VideoTypeEnum +from video.src.media_streams import MediaStreamExtractor +from video.src.subtitle import YoutubeSubtitle class SponsorBlock: @@ -323,7 +323,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def delete_comments(self): """delete comments from es""" - comments = ta_comments.Comments(self.youtube_id, config=self.config) + comments = Comments(self.youtube_id, config=self.config) comments.check_config() if comments.is_activated: comments.delete_comments() diff --git a/tubearchivist/home/src/index/video_streams.py b/backend/video/src/media_streams.py similarity index 100% rename from tubearchivist/home/src/index/video_streams.py rename to backend/video/src/media_streams.py diff --git a/backend/video/src/query_building.py b/backend/video/src/query_building.py new file mode 100644 index 00000000..da98cd34 --- /dev/null +++ b/backend/video/src/query_building.py @@ -0,0 +1,99 @@ +"""build query for video fetching""" + +from common.src.ta_redis import RedisArchivist +from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum + + +class QueryBuilder: + """contain functionality""" + + WATCH_OPTIONS = ["watched", "unwatched", "continue"] + + def __init__(self, user_id: int, **kwargs): + self.user_id = user_id + 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": {"channel.channel_id": channel[0]}}) + + playlist = self.request_params.get("playlist") + if playlist: + must_list.append({"match": {"playlist.keyword": playlist[0]}}) + + watch = self.request_params.get("watch") + if watch: + watch_must_list = self.parse_watch(watch[0]) + must_list.append(watch_must_list) + + video_type = self.request_params.get("type") + if video_type: + type_list_list = self.parse_type(video_type[0]) + must_list.append(type_list_list) + + query = {"bool": {"must": must_list}} + + return query + + def parse_watch(self, watch: str) -> dict: + """build query""" + if watch not in self.WATCH_OPTIONS: + raise ValueError(f"'{watch}' not in {self.WATCH_OPTIONS}") + + if watch == "continue": + continue_must = self._build_continue_must() + return continue_must + + return {"match": {"player.watched": watch == "watched"}} + + def _build_continue_must(self): + results = RedisArchivist().list_items(f"{self.user_id}:progress:") + if not results: + return None + + ids = [{"match": {"youtube_id": i.get("youtube_id")}} for i in results] + continue_ids = {"bool": {"should": ids}} + + return continue_ids + + def parse_type(self, video_type: str): + """parse video type""" + if not hasattr(VideoTypeEnum, video_type.upper()): + raise ValueError(f"'{video_type}' not in VideoTypeEnum") + + vid_type = getattr(VideoTypeEnum, video_type.upper()).value + + return {"match": {"vid_type": vid_type}} + + def parse_sort(self) -> dict | None: + """build sort key""" + sort = self.request_params.get("sort") + if not sort: + return None + + sort = sort[0] + if not hasattr(SortEnum, sort.upper()): + raise ValueError(f"'{sort}' not in SortEnum") + + sort_field = getattr(SortEnum, sort.upper()).value + + order = self.request_params.get("order", ["desc"]) + order = order[0] + if not hasattr(OrderEnum, order.upper()): + raise ValueError(f"'{order}' not in OrderEnum") + + order_by = getattr(OrderEnum, order.upper()).value + + return {"sort": [{sort_field: {"order": order_by}}]} diff --git a/tubearchivist/home/src/index/subtitle.py b/backend/video/src/subtitle.py similarity index 98% rename from tubearchivist/home/src/index/subtitle.py rename to backend/video/src/subtitle.py index 56973519..38df1783 100644 --- a/tubearchivist/home/src/index/subtitle.py +++ b/backend/video/src/subtitle.py @@ -10,9 +10,9 @@ import os from datetime import datetime import requests -from home.src.es.connect import ElasticWrap -from home.src.ta.helper import requests_headers -from home.src.ta.settings import EnvironmentSettings +from common.src.env_settings import EnvironmentSettings +from common.src.es_connect import ElasticWrap +from common.src.helper import requests_headers class YoutubeSubtitle: diff --git a/backend/video/tests/__init__.py b/backend/video/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/video/tests/test_src/__init__.py b/backend/video/tests/test_src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/video/tests/test_src/test_query_building.py b/backend/video/tests/test_src/test_query_building.py new file mode 100644 index 00000000..aeb2c781 --- /dev/null +++ b/backend/video/tests/test_src/test_query_building.py @@ -0,0 +1,68 @@ +"""test video query building""" + +import pytest +from video.src.query_building import QueryBuilder + + +def test_initialization(): + """init constructor""" + qb = QueryBuilder(user_id=1) + assert qb.user_id == 1 + assert not qb.request_params + + +def test_build_data(): + """test for correct key building""" + qb = QueryBuilder( + user_id=1, + channel=["test_channel"], + playlist=["test_playlist"], + watch=["watched"], + type=["videos"], + sort=["published"], + order=["desc"], + ) + result = qb.build_data() + assert "query" in result + assert "sort" in result + assert result["sort"] == [{"published": {"order": "desc"}}] + + +def test_parse_watch(): + """watched query building""" + qb = QueryBuilder(user_id=1, watch=["watched"]) + result = qb.parse_watch("watched") + assert result == {"match": {"player.watched": True}} + + result = qb.parse_watch("unwatched") + assert result == {"match": {"player.watched": False}} + + with pytest.raises(ValueError): + qb.parse_watch("invalid") + + +def test_parse_type(): + """test type is parsed""" + qb = QueryBuilder(user_id=1, type=["videos"]) + with pytest.raises(ValueError): + qb.parse_type("invalid") + + result = qb.parse_type("videos") + assert result == {"match": {"vid_type": "videos"}} + + +def test_parse_sort(): + """test sort and order""" + qb = QueryBuilder(user_id=1, sort=["views"], order=["desc"]) + result = qb.parse_sort() + assert result == {"sort": [{"stats.view_count": {"order": "desc"}}]} + + with pytest.raises(ValueError): + qb = QueryBuilder(user_id=1, sort=["invalid"]) + qb.parse_sort() + + with pytest.raises(ValueError): + qb = QueryBuilder( + user_id=1, sort=["stats.view_count"], order=["invalid"] + ) + qb.parse_sort() diff --git a/backend/video/urls.py b/backend/video/urls.py new file mode 100644 index 00000000..f520fb0b --- /dev/null +++ b/backend/video/urls.py @@ -0,0 +1,33 @@ +"""all video API urls""" + +from django.urls import path +from video import views + +urlpatterns = [ + path("", views.VideoApiListView.as_view(), name="api-video-list"), + path( + "/", + views.VideoApiView.as_view(), + name="api-video", + ), + path( + "/nav/", + views.VideoApiNavView.as_view(), + name="api-video-nav", + ), + path( + "/progress/", + views.VideoProgressView.as_view(), + name="api-video-progress", + ), + path( + "/comment/", + views.VideoCommentView.as_view(), + name="api-video-comment", + ), + path( + "/similar/", + views.VideoSimilarView.as_view(), + name="api-video-similar", + ), +] diff --git a/backend/video/views.py b/backend/video/views.py new file mode 100644 index 00000000..08f484ab --- /dev/null +++ b/backend/video/views.py @@ -0,0 +1,160 @@ +"""all API views for video endpoints""" + +from common.src.ta_redis import RedisArchivist +from common.views_base import AdminWriteOnly, ApiBaseView +from playlist.src.index import YoutubePlaylist +from rest_framework.response import Response +from video.src.index import YoutubeVideo +from video.src.query_building import QueryBuilder + + +class VideoApiListView(ApiBaseView): + """resolves to /api/video/ + GET: returns list of videos + params: + - playlist:str= + - channel:str= + - watch:enum=watched|unwatched|continue + - sort:enum=published|downloaded|views|likes|duration|filesize + - order:enum=asc|desc + - type:enum=videos|streams|shorts + """ + + search_base = "ta_video/_search/" + + def get(self, request): + """get request""" + try: + data = QueryBuilder(request.user.id, **request.GET).build_data() + except ValueError as err: + return Response({"error": str(err)}, status=400) + + if data == {"query": {"bool": {"must": [None]}}}: + # skip empty lookup + return Response([]) + + self.data = data + self.get_document_list(request, progress_match=request.user.id) + + return Response(self.response) + + +class VideoApiView(ApiBaseView): + """resolves to /api/video// + GET: returns metadata dict of video + """ + + search_base = "ta_video/_doc/" + permission_classes = [AdminWriteOnly] + + def get(self, request, video_id): + # pylint: disable=unused-argument + """get request""" + self.get_document(video_id, progress_match=request.user.id) + return Response(self.response, status=self.status_code) + + def delete(self, request, video_id): + # pylint: disable=unused-argument + """delete single video""" + message = {"video": video_id} + try: + YoutubeVideo(video_id).delete_media_file() + status_code = 200 + message.update({"state": "delete"}) + except FileNotFoundError: + status_code = 404 + message.update({"state": "not found"}) + + return Response(message, status=status_code) + + +class VideoApiNavView(ApiBaseView): + """resolves to /api/video//nav/ + GET: returns playlist nav + """ + + search_base = "ta_video/_doc/" + + def get(self, request, video_id): + # pylint: disable=unused-argument + """get request""" + self.get_document(video_id) + if self.status_code != 200: + return Response(status=self.status_code) + + playlist_nav = [] + + if not self.response["data"].get("playlist"): + return Response(playlist_nav) + + for playlist_id in self.response["data"]["playlist"]: + playlist = YoutubePlaylist(playlist_id) + playlist.get_from_es() + playlist.build_nav(video_id) + if playlist.nav: + playlist_nav.append(playlist.nav) + + return Response(playlist_nav, status=self.status_code) + + +class VideoProgressView(ApiBaseView): + """resolves to /api/video//progress/ + handle progress status for video + """ + + def post(self, request, video_id): + """set progress position in redis""" + position = request.data.get("position", 0) + key = f"{request.user.id}:progress:{video_id}" + message = {"position": position, "youtube_id": video_id} + RedisArchivist().set_message(key, message) + self.response = request.data + return Response(self.response) + + def delete(self, request, video_id): + """delete progress position""" + key = f"{request.user.id}:progress:{video_id}" + RedisArchivist().del_message(key) + self.response = {"progress-reset": video_id} + + return Response(self.response) + + +class VideoCommentView(ApiBaseView): + """resolves to /api/video//comment/ + handle video comments + GET: return all comments from video with reply threads + """ + + search_base = "ta_comment/_doc/" + + def get(self, request, video_id): + """get video comments""" + # pylint: disable=unused-argument + self.get_document(video_id) + + return Response(self.response, status=200) + + +class VideoSimilarView(ApiBaseView): + """resolves to /api/video//similar/ + GET: return max 6 videos similar to this + """ + + search_base = "ta_video/_search/" + + def get(self, request, video_id): + """get similar videos""" + self.data = { + "size": 6, + "query": { + "more_like_this": { + "fields": ["tags", "title"], + "like": {"_id": video_id}, + "min_term_freq": 1, + "max_query_terms": 25, + } + }, + } + self.get_document_list(request, pagination=False) + return Response(self.response, status=200) diff --git a/deploy.sh b/deploy.sh index 6087b03f..10c8e0d1 100755 --- a/deploy.sh +++ b/deploy.sh @@ -51,6 +51,9 @@ function sync_test { --exclude "**/cache" \ --exclude "**/__pycache__/" \ --exclude "**/.pytest_cache/" \ + --exclude "**/static/" \ + --exclude "**/node_modules/" \ + --exclude "**/.env" \ --exclude ".venv" \ --exclude "db.sqlite3" \ --exclude ".mypy_cache" \ @@ -92,12 +95,12 @@ function validate { echo "running black" black --force-exclude "migrations/*" --diff --color --check -l 79 "$check_path" echo "running codespell" - codespell --skip="./.git,./.venv,./package.json,./package-lock.json,./node_modules,./.mypy_cache" "$check_path" + codespell --skip="./.git,./.venv,./package.json,./package-lock.json,**/node_modules,./.mypy_cache,**/static/volume" "$check_path" echo "running flake8" - flake8 "$check_path" --exclude "migrations,.venv" --count --max-complexity=10 \ + flake8 "$check_path" --exclude "migrations,.venv,frontend" --count --max-complexity=10 \ --max-line-length=79 --show-source --statistics echo "running isort" - isort --skip "migrations" --skip ".venv" --check-only --diff --profile black -l 79 "$check_path" + isort --skip "migrations" --skip ".venv" --skip "frontend" --check-only --diff --profile black -l 79 "$check_path" printf " \n> all validations passed\n" } @@ -157,7 +160,7 @@ function sync_docker { fi echo "latest tags:" - git tag | tail -n 5 | sort -r + git tag | sort -rV | head -n 5 printf "\ncreate new version:\n" read -r VERSION @@ -189,7 +192,7 @@ function sync_docker_old { fi echo "latest tags:" - git tag | tail -n 5 | sort -r + git tag | sort -rV | head -n 5 printf "\ncreate new version:\n" read -r VERSION diff --git a/docker-compose.yml b/docker-compose.yml index f7e4dd9b..00ece157 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - cache:/cache environment: - ES_URL=http://archivist-es:9200 # needs protocol e.g. http and port - - REDIS_HOST=archivist-redis # don't add protocol + - REDIS_CON=redis://archivist-redis:6379 - HOST_UID=1000 - HOST_GID=1000 - TA_HOST=tubearchivist.local # set your host name @@ -40,7 +40,7 @@ services: depends_on: - archivist-es archivist-es: - image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.14.3 + image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.16.0 container_name: archivist-es restart: unless-stopped environment: diff --git a/docker_assets/backend_start.py b/docker_assets/backend_start.py new file mode 100755 index 00000000..997e2781 --- /dev/null +++ b/docker_assets/backend_start.py @@ -0,0 +1,18 @@ +"""start backend python application, read env var""" + +from os import environ + +import uvicorn + +LOG_LEVEL = "info" if environ.get("DJANGO_DEBUG") else "error" +PORT = int(environ.get("TA_BACKEND_PORT", 8080)) + +if __name__ == "__main__": + uvicorn.run( + "config.asgi:application", + host="0.0.0.0", + port=PORT, + workers=4, + log_level=LOG_LEVEL, + reload=False, + ) diff --git a/docker_assets/nginx.conf b/docker_assets/nginx.conf index a9702490..6168cdf6 100644 --- a/docker_assets/nginx.conf +++ b/docker_assets/nginx.conf @@ -24,10 +24,33 @@ server { text/vtt vtt; } } - - location / { - include uwsgi_params; - uwsgi_pass localhost:8080; + + location /youtube/ { + auth_request /api/ping/; + alias /youtube/; + types { + video/mp4 mp4; + } } -} \ No newline at end of file + location /api { + include proxy_params; + proxy_pass http://localhost:8080; + } + + location /admin { + include proxy_params; + proxy_pass http://localhost:8080; + } + + location /static/ { + alias /app/staticfiles/; + } + + root /app/static; + index index.html; + + location / { + try_files $uri $uri/ /index.html =404; + } +} diff --git a/docker_assets/run.sh b/docker_assets/run.sh index 8cda03c0..283b70f0 100644 --- a/docker_assets/run.sh +++ b/docker_assets/run.sh @@ -3,6 +3,9 @@ set -e +# stop on pending manual migration +python manage.py ta_stop_on_error + # django setup python manage.py migrate @@ -17,7 +20,7 @@ python manage.py ta_startup # start all tasks nginx & -celery -A home.celery worker --loglevel=INFO --max-tasks-per-child 10 & -celery -A home beat --loglevel=INFO \ +celery -A task.celery worker --loglevel=INFO --max-tasks-per-child 10 & +celery -A task beat --loglevel=INFO \ --scheduler django_celery_beat.schedulers:DatabaseScheduler & -uwsgi --ini uwsgi.ini +python backend_start.py diff --git a/docker_assets/uwsgi.ini b/docker_assets/uwsgi.ini deleted file mode 100644 index 436d398c..00000000 --- a/docker_assets/uwsgi.ini +++ /dev/null @@ -1,11 +0,0 @@ -[uwsgi] -module = config.wsgi:application -master = True -pidfile = /tmp/project-master.pid -vacuum = True -max-requests = 5000 -socket = :8080 -buffer-size = 8192 -log-5xx = true -log-4xx = true -disable-logging = true diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 00000000..965e0326 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,5 @@ +# Ignore artifacts: +build +dist +coverage +node_modules diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 00000000..3e4b9fa0 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 100 +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..275fc390 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,17 @@ +# Tubearchivist Frontend React + +# Folder structure + +``` +src ┐ + ├───api + │ ├───action // Functions that do write (POST,DELETE) calls to the backend + │ └───loader // Functions that do read-only (GET,HEAD) calls to the backend + ├───components // React components to be used in pages + ├───configuration // Application configuration. + │ ├───colours // Css loader for themes + │ ├───constants // global constants that have no good place + │ └───routes // Routes definitions used in Links and react-router-dom configuration + ├───functions // Useful functions + └───pages // React components that define a page/route +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..f164e0e4 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,27 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import prettier from 'eslint-config-prettier'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + prettier: prettier, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..6396d71a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + TubeArchivist + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..feea96ae --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3009 @@ +{ + "name": "tubearchivist-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tubearchivist-frontend", + "version": "0.1.0", + "dependencies": { + "dompurify": "^3.2.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/parser": "^8.19.1", + "@vitejs/plugin-react-swc": "^3.7.2", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "prettier": "3.4.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.19.1", + "vite": "^6.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", + "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.5", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", + "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", + "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz", + "integrity": "sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", + "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", + "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", + "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", + "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", + "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", + "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", + "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", + "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", + "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", + "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", + "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", + "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", + "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", + "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", + "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", + "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", + "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", + "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", + "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.0.tgz", + "integrity": "sha512-+CuuTCmQFfzaNGg1JmcZvdUVITQXJk9sMnl1C2TiDLzOSVOJRwVD4dNo5dljX/qxpMAN+2BIYlwjlSkoGi6grg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.17" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.10.0", + "@swc/core-darwin-x64": "1.10.0", + "@swc/core-linux-arm-gnueabihf": "1.10.0", + "@swc/core-linux-arm64-gnu": "1.10.0", + "@swc/core-linux-arm64-musl": "1.10.0", + "@swc/core-linux-x64-gnu": "1.10.0", + "@swc/core-linux-x64-musl": "1.10.0", + "@swc/core-win32-arm64-msvc": "1.10.0", + "@swc/core-win32-ia32-msvc": "1.10.0", + "@swc/core-win32-x64-msvc": "1.10.0" + }, + "peerDependencies": { + "@swc/helpers": "*" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.0.tgz", + "integrity": "sha512-wCeUpanqZyzvgqWRtXIyhcFK3CqukAlYyP+fJpY2gWc/+ekdrenNIfZMwY7tyTFDkXDYEKzvn3BN/zDYNJFowQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.0.tgz", + "integrity": "sha512-0CZPzqTynUBO+SHEl/qKsFSahp2Jv/P2ZRjFG0gwZY5qIcr1+B/v+o74/GyNMBGz9rft+F2WpU31gz2sJwyF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.0.tgz", + "integrity": "sha512-oq+DdMu5uJOFPtRkeiITc4kxmd+QSmK+v+OBzlhdGkSgoH3yRWZP+H2ao0cBXo93ZgCr2LfjiER0CqSKhjGuNA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.0.tgz", + "integrity": "sha512-Y6+PC8knchEViRxiCUj3j8wsGXaIhuvU+WqrFqV834eiItEMEI9+Vh3FovqJMBE3L7d4E4ZQtgImHCXjrHfxbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.0.tgz", + "integrity": "sha512-EbrX9A5U4cECCQQfky7945AW9GYnTXtCUXElWTkTYmmyQK87yCyFfY8hmZ9qMFIwxPOH6I3I2JwMhzdi8Qoz7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.0.tgz", + "integrity": "sha512-TaxpO6snTjjfLXFYh5EjZ78se69j2gDcqEM8yB9gguPYwkCHi2Ylfmh7iVaNADnDJFtjoAQp0L41bTV/Pfq9Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.0.tgz", + "integrity": "sha512-IEGvDd6aEEKEyZFZ8oCKuik05G5BS7qwG5hO5PEMzdGeh8JyFZXxsfFXbfeAqjue4UaUUrhnoX+Ze3M2jBVMHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.0.tgz", + "integrity": "sha512-UkQ952GSpY+Z6XONj9GSW8xGSkF53jrCsuLj0nrcuw7Dvr1a816U/9WYZmmcYS8tnG2vHylhpm6csQkyS8lpCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.0.tgz", + "integrity": "sha512-a2QpIZmTiT885u/mUInpeN2W9ClCnqrV2LnMqJR1/Fgx1Afw/hAtiDZPtQ0SqS8yDJ2VR5gfNZo3gpxWMrqdVA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.0.tgz", + "integrity": "sha512-tZcCmMwf483nwsEBfUk5w9e046kMa1iSik4bP9Kwi2FGtOfHuDfIcwW4jek3hdcgF5SaBW1ktnK/lgQLDi5AtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", + "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.3.tgz", + "integrity": "sha512-UavfHguIjnnuq9O67uXfgy/h3SRJbidAYvNjLceB+2RIKVRBzVsh0QO+Pw6BCSQqFS9xwzKfwstXx0m6AbAREA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz", + "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz", + "integrity": "sha512-tJzcVyvvb9h/PB96g30MpxACd9IrunT7GF9wfA9/0TJ1LxGOJx1TdPzSbBBnNED7K9Ka8ybJsnEpiXPktolTLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.19.1", + "@typescript-eslint/type-utils": "8.19.1", + "@typescript-eslint/utils": "8.19.1", + "@typescript-eslint/visitor-keys": "8.19.1", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.1.tgz", + "integrity": "sha512-67gbfv8rAwawjYx3fYArwldTQKoYfezNUT4D5ioWetr/xCrxXxvleo3uuiFuKfejipvq+og7mjz3b0G2bVyUCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.19.1", + "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/typescript-estree": "8.19.1", + "@typescript-eslint/visitor-keys": "8.19.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.1.tgz", + "integrity": "sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/visitor-keys": "8.19.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.1.tgz", + "integrity": "sha512-Rp7k9lhDKBMRJB/nM9Ksp1zs4796wVNyihG9/TU9R6KCJDNkQbc2EOKjrBtLYh3396ZdpXLtr/MkaSEmNMtykw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.19.1", + "@typescript-eslint/utils": "8.19.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.1.tgz", + "integrity": "sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.1.tgz", + "integrity": "sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/visitor-keys": "8.19.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.1.tgz", + "integrity": "sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.19.1", + "@typescript-eslint/types": "8.19.1", + "@typescript-eslint/typescript-estree": "8.19.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.1.tgz", + "integrity": "sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.19.1", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.2.tgz", + "integrity": "sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/core": "^1.7.26" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/dompurify": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz", + "integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.17.0", + "@eslint/plugin-kit": "^0.2.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", + "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.16.tgz", + "integrity": "sha512-slterMlxAhov/DZO8NScf6mEeMBBXodFUolijDvrtTxyezyLoTQaa73FyYus/VbTdftd8wBgBxPMRk3poleXNQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", + "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-router": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.1.tgz", + "integrity": "sha512-39sXJkftkKWRZ2oJtHhCxmoCrBCULr/HAH4IT5DHlgu/Q0FCPV0S4Lx+abjDTx/74xoZzNYDYbOZWlJjruyuDQ==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.1.tgz", + "integrity": "sha512-vSrQHWlJ5DCfyrhgo0k6zViOe9ToK8uT5XGSmnuC2R3/g261IdIMpZVqfjD6vWSXdnf5Czs4VA/V60oVR6/jnA==", + "license": "MIT", + "dependencies": { + "react-router": "7.1.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", + "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.28.1", + "@rollup/rollup-android-arm64": "4.28.1", + "@rollup/rollup-darwin-arm64": "4.28.1", + "@rollup/rollup-darwin-x64": "4.28.1", + "@rollup/rollup-freebsd-arm64": "4.28.1", + "@rollup/rollup-freebsd-x64": "4.28.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", + "@rollup/rollup-linux-arm-musleabihf": "4.28.1", + "@rollup/rollup-linux-arm64-gnu": "4.28.1", + "@rollup/rollup-linux-arm64-musl": "4.28.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", + "@rollup/rollup-linux-riscv64-gnu": "4.28.1", + "@rollup/rollup-linux-s390x-gnu": "4.28.1", + "@rollup/rollup-linux-x64-gnu": "4.28.1", + "@rollup/rollup-linux-x64-musl": "4.28.1", + "@rollup/rollup-win32-arm64-msvc": "4.28.1", + "@rollup/rollup-win32-ia32-msvc": "4.28.1", + "@rollup/rollup-win32-x64-msvc": "4.28.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", + "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.19.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.19.1.tgz", + "integrity": "sha512-LKPUQpdEMVOeKluHi8md7rwLcoXHhwvWp3x+sJkMuq3gGm9yaYJtPo8sRZSblMFJ5pcOGCAak/scKf1mvZDlQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.19.1", + "@typescript-eslint/parser": "8.19.1", + "@typescript-eslint/utils": "8.19.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz", + "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.4.49", + "rollup": "^4.23.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.2.tgz", + "integrity": "sha512-8qNdnJVJlHlrKXi50LDqqUNmUbuBjoKLrYQBnoChIbVph7vni+sY+YpvdjXG9YLd/Bxr6scMcR+rm5H3aSqPaw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..208532bc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "tubearchivist-frontend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "build:deploy": "vite build", + "lint": "eslint .", + "format": "prettier --write .", + "preview": "vite preview" + }, + "dependencies": { + "dompurify": "^3.2.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/parser": "^8.19.1", + "@vitejs/plugin-react-swc": "^3.7.2", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "prettier": "3.4.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.19.1", + "vite": "^6.0.7" + } +} diff --git a/tubearchivist/static/favicon/android-chrome-192x192.png b/frontend/public/favicon/android-chrome-192x192.png similarity index 100% rename from tubearchivist/static/favicon/android-chrome-192x192.png rename to frontend/public/favicon/android-chrome-192x192.png diff --git a/tubearchivist/static/favicon/android-chrome-512x512.png b/frontend/public/favicon/android-chrome-512x512.png similarity index 100% rename from tubearchivist/static/favicon/android-chrome-512x512.png rename to frontend/public/favicon/android-chrome-512x512.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-114x114-precomposed.png b/frontend/public/favicon/apple-touch-icon-114x114-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-114x114-precomposed.png rename to frontend/public/favicon/apple-touch-icon-114x114-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-114x114.png b/frontend/public/favicon/apple-touch-icon-114x114.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-114x114.png rename to frontend/public/favicon/apple-touch-icon-114x114.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-120x120-precomposed.png b/frontend/public/favicon/apple-touch-icon-120x120-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-120x120-precomposed.png rename to frontend/public/favicon/apple-touch-icon-120x120-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-120x120.png b/frontend/public/favicon/apple-touch-icon-120x120.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-120x120.png rename to frontend/public/favicon/apple-touch-icon-120x120.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-144x144-precomposed.png b/frontend/public/favicon/apple-touch-icon-144x144-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-144x144-precomposed.png rename to frontend/public/favicon/apple-touch-icon-144x144-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-144x144.png b/frontend/public/favicon/apple-touch-icon-144x144.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-144x144.png rename to frontend/public/favicon/apple-touch-icon-144x144.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-152x152-precomposed.png b/frontend/public/favicon/apple-touch-icon-152x152-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-152x152-precomposed.png rename to frontend/public/favicon/apple-touch-icon-152x152-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-152x152.png b/frontend/public/favicon/apple-touch-icon-152x152.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-152x152.png rename to frontend/public/favicon/apple-touch-icon-152x152.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-180x180-precomposed.png b/frontend/public/favicon/apple-touch-icon-180x180-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-180x180-precomposed.png rename to frontend/public/favicon/apple-touch-icon-180x180-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-180x180.png b/frontend/public/favicon/apple-touch-icon-180x180.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-180x180.png rename to frontend/public/favicon/apple-touch-icon-180x180.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-57x57-precomposed.png b/frontend/public/favicon/apple-touch-icon-57x57-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-57x57-precomposed.png rename to frontend/public/favicon/apple-touch-icon-57x57-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-57x57.png b/frontend/public/favicon/apple-touch-icon-57x57.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-57x57.png rename to frontend/public/favicon/apple-touch-icon-57x57.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-60x60-precomposed.png b/frontend/public/favicon/apple-touch-icon-60x60-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-60x60-precomposed.png rename to frontend/public/favicon/apple-touch-icon-60x60-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-60x60.png b/frontend/public/favicon/apple-touch-icon-60x60.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-60x60.png rename to frontend/public/favicon/apple-touch-icon-60x60.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-72x72-precomposed.png b/frontend/public/favicon/apple-touch-icon-72x72-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-72x72-precomposed.png rename to frontend/public/favicon/apple-touch-icon-72x72-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-72x72.png b/frontend/public/favicon/apple-touch-icon-72x72.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-72x72.png rename to frontend/public/favicon/apple-touch-icon-72x72.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-76x76-precomposed.png b/frontend/public/favicon/apple-touch-icon-76x76-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-76x76-precomposed.png rename to frontend/public/favicon/apple-touch-icon-76x76-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-76x76.png b/frontend/public/favicon/apple-touch-icon-76x76.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-76x76.png rename to frontend/public/favicon/apple-touch-icon-76x76.png diff --git a/tubearchivist/static/favicon/apple-touch-icon-precomposed.png b/frontend/public/favicon/apple-touch-icon-precomposed.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon-precomposed.png rename to frontend/public/favicon/apple-touch-icon-precomposed.png diff --git a/tubearchivist/static/favicon/apple-touch-icon.png b/frontend/public/favicon/apple-touch-icon.png similarity index 100% rename from tubearchivist/static/favicon/apple-touch-icon.png rename to frontend/public/favicon/apple-touch-icon.png diff --git a/tubearchivist/static/favicon/browserconfig.xml b/frontend/public/favicon/browserconfig.xml similarity index 100% rename from tubearchivist/static/favicon/browserconfig.xml rename to frontend/public/favicon/browserconfig.xml diff --git a/tubearchivist/static/favicon/favicon-16x16.png b/frontend/public/favicon/favicon-16x16.png similarity index 100% rename from tubearchivist/static/favicon/favicon-16x16.png rename to frontend/public/favicon/favicon-16x16.png diff --git a/tubearchivist/static/favicon/favicon-32x32.png b/frontend/public/favicon/favicon-32x32.png similarity index 100% rename from tubearchivist/static/favicon/favicon-32x32.png rename to frontend/public/favicon/favicon-32x32.png diff --git a/tubearchivist/static/favicon/favicon.ico b/frontend/public/favicon/favicon.ico similarity index 100% rename from tubearchivist/static/favicon/favicon.ico rename to frontend/public/favicon/favicon.ico diff --git a/tubearchivist/static/favicon/mstile-150x150.png b/frontend/public/favicon/mstile-150x150.png similarity index 100% rename from tubearchivist/static/favicon/mstile-150x150.png rename to frontend/public/favicon/mstile-150x150.png diff --git a/tubearchivist/static/favicon/safari-pinned-tab.svg b/frontend/public/favicon/safari-pinned-tab.svg similarity index 100% rename from tubearchivist/static/favicon/safari-pinned-tab.svg rename to frontend/public/favicon/safari-pinned-tab.svg diff --git a/frontend/public/favicon/site.webmanifest b/frontend/public/favicon/site.webmanifest new file mode 100644 index 00000000..19fd1381 --- /dev/null +++ b/frontend/public/favicon/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "TubeArchivist", + "short_name": "TubeArchivist", + "icons": [ + { + "src": "/static/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#01202e", + "background_color": "#01202e", + "display": "standalone" +} diff --git a/tubearchivist/static/font/OFL_License.txt b/frontend/public/font/OFL_License.txt similarity index 97% rename from tubearchivist/static/font/OFL_License.txt rename to frontend/public/font/OFL_License.txt index 2fde9c97..f01dfdf7 100644 --- a/tubearchivist/static/font/OFL_License.txt +++ b/frontend/public/font/OFL_License.txt @@ -1,94 +1,94 @@ -Copyright (c) 2015, Kosal Sen, Philatype (), -with Reserved Font Name Sen. - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. +Copyright (c) 2015, Kosal Sen, Philatype (), +with Reserved Font Name Sen. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/tubearchivist/static/font/Sen-Bold.woff b/frontend/public/font/Sen-Bold.woff similarity index 100% rename from tubearchivist/static/font/Sen-Bold.woff rename to frontend/public/font/Sen-Bold.woff diff --git a/tubearchivist/static/font/Sen-Regular.woff b/frontend/public/font/Sen-Regular.woff similarity index 100% rename from tubearchivist/static/font/Sen-Regular.woff rename to frontend/public/font/Sen-Regular.woff diff --git a/tubearchivist/static/img/banner-tube-archivist-dark.png b/frontend/public/img/banner-tube-archivist-dark.png similarity index 100% rename from tubearchivist/static/img/banner-tube-archivist-dark.png rename to frontend/public/img/banner-tube-archivist-dark.png diff --git a/tubearchivist/static/img/banner-tube-archivist-light.png b/frontend/public/img/banner-tube-archivist-light.png similarity index 100% rename from tubearchivist/static/img/banner-tube-archivist-light.png rename to frontend/public/img/banner-tube-archivist-light.png diff --git a/tubearchivist/static/img/default-channel-art.jpg b/frontend/public/img/default-channel-art.jpg similarity index 100% rename from tubearchivist/static/img/default-channel-art.jpg rename to frontend/public/img/default-channel-art.jpg diff --git a/tubearchivist/static/img/default-channel-banner.jpg b/frontend/public/img/default-channel-banner.jpg similarity index 100% rename from tubearchivist/static/img/default-channel-banner.jpg rename to frontend/public/img/default-channel-banner.jpg diff --git a/tubearchivist/static/img/default-channel-icon.jpg b/frontend/public/img/default-channel-icon.jpg similarity index 100% rename from tubearchivist/static/img/default-channel-icon.jpg rename to frontend/public/img/default-channel-icon.jpg diff --git a/tubearchivist/static/img/default-playlist-thumb.jpg b/frontend/public/img/default-playlist-thumb.jpg similarity index 100% rename from tubearchivist/static/img/default-playlist-thumb.jpg rename to frontend/public/img/default-playlist-thumb.jpg diff --git a/tubearchivist/static/img/default-video-thumb.jpg b/frontend/public/img/default-video-thumb.jpg similarity index 100% rename from tubearchivist/static/img/default-video-thumb.jpg rename to frontend/public/img/default-video-thumb.jpg diff --git a/tubearchivist/static/img/icon-add.svg b/frontend/public/img/icon-add.svg similarity index 100% rename from tubearchivist/static/img/icon-add.svg rename to frontend/public/img/icon-add.svg diff --git a/tubearchivist/static/img/icon-arrow-bottom.svg b/frontend/public/img/icon-arrow-bottom.svg similarity index 100% rename from tubearchivist/static/img/icon-arrow-bottom.svg rename to frontend/public/img/icon-arrow-bottom.svg diff --git a/tubearchivist/static/img/icon-arrow-down.svg b/frontend/public/img/icon-arrow-down.svg similarity index 100% rename from tubearchivist/static/img/icon-arrow-down.svg rename to frontend/public/img/icon-arrow-down.svg diff --git a/tubearchivist/static/img/icon-arrow-top.svg b/frontend/public/img/icon-arrow-top.svg similarity index 100% rename from tubearchivist/static/img/icon-arrow-top.svg rename to frontend/public/img/icon-arrow-top.svg diff --git a/tubearchivist/static/img/icon-arrow-up.svg b/frontend/public/img/icon-arrow-up.svg similarity index 100% rename from tubearchivist/static/img/icon-arrow-up.svg rename to frontend/public/img/icon-arrow-up.svg diff --git a/tubearchivist/static/img/icon-close.svg b/frontend/public/img/icon-close.svg similarity index 100% rename from tubearchivist/static/img/icon-close.svg rename to frontend/public/img/icon-close.svg diff --git a/tubearchivist/static/img/icon-dot-menu.svg b/frontend/public/img/icon-dot-menu.svg similarity index 100% rename from tubearchivist/static/img/icon-dot-menu.svg rename to frontend/public/img/icon-dot-menu.svg diff --git a/tubearchivist/static/img/icon-download.svg b/frontend/public/img/icon-download.svg similarity index 100% rename from tubearchivist/static/img/icon-download.svg rename to frontend/public/img/icon-download.svg diff --git a/tubearchivist/static/img/icon-exit.svg b/frontend/public/img/icon-exit.svg similarity index 100% rename from tubearchivist/static/img/icon-exit.svg rename to frontend/public/img/icon-exit.svg diff --git a/tubearchivist/static/img/icon-eye.svg b/frontend/public/img/icon-eye.svg similarity index 98% rename from tubearchivist/static/img/icon-eye.svg rename to frontend/public/img/icon-eye.svg index 92ba61c8..d4153d18 100644 --- a/tubearchivist/static/img/icon-eye.svg +++ b/frontend/public/img/icon-eye.svg @@ -1,15 +1,15 @@ - - - - - - - - + + + + + + + + diff --git a/tubearchivist/static/img/icon-gear.svg b/frontend/public/img/icon-gear.svg similarity index 100% rename from tubearchivist/static/img/icon-gear.svg rename to frontend/public/img/icon-gear.svg diff --git a/tubearchivist/static/img/icon-gridview.svg b/frontend/public/img/icon-gridview.svg similarity index 100% rename from tubearchivist/static/img/icon-gridview.svg rename to frontend/public/img/icon-gridview.svg diff --git a/tubearchivist/static/img/icon-heart.svg b/frontend/public/img/icon-heart.svg similarity index 98% rename from tubearchivist/static/img/icon-heart.svg rename to frontend/public/img/icon-heart.svg index 8a26e163..803e2805 100644 --- a/tubearchivist/static/img/icon-heart.svg +++ b/frontend/public/img/icon-heart.svg @@ -1,8 +1,8 @@ - - - - - + + + + + diff --git a/tubearchivist/static/img/icon-listview.svg b/frontend/public/img/icon-listview.svg similarity index 100% rename from tubearchivist/static/img/icon-listview.svg rename to frontend/public/img/icon-listview.svg diff --git a/tubearchivist/static/img/icon-play.svg b/frontend/public/img/icon-play.svg similarity index 100% rename from tubearchivist/static/img/icon-play.svg rename to frontend/public/img/icon-play.svg diff --git a/tubearchivist/static/img/icon-remove.svg b/frontend/public/img/icon-remove.svg similarity index 100% rename from tubearchivist/static/img/icon-remove.svg rename to frontend/public/img/icon-remove.svg diff --git a/tubearchivist/static/img/icon-rescan.svg b/frontend/public/img/icon-rescan.svg similarity index 100% rename from tubearchivist/static/img/icon-rescan.svg rename to frontend/public/img/icon-rescan.svg diff --git a/tubearchivist/static/img/icon-search.svg b/frontend/public/img/icon-search.svg similarity index 100% rename from tubearchivist/static/img/icon-search.svg rename to frontend/public/img/icon-search.svg diff --git a/tubearchivist/static/img/icon-seen.svg b/frontend/public/img/icon-seen.svg similarity index 100% rename from tubearchivist/static/img/icon-seen.svg rename to frontend/public/img/icon-seen.svg diff --git a/tubearchivist/static/img/icon-sort.svg b/frontend/public/img/icon-sort.svg similarity index 100% rename from tubearchivist/static/img/icon-sort.svg rename to frontend/public/img/icon-sort.svg diff --git a/tubearchivist/static/img/icon-star-empty.svg b/frontend/public/img/icon-star-empty.svg similarity index 100% rename from tubearchivist/static/img/icon-star-empty.svg rename to frontend/public/img/icon-star-empty.svg diff --git a/tubearchivist/static/img/icon-star-full.svg b/frontend/public/img/icon-star-full.svg similarity index 100% rename from tubearchivist/static/img/icon-star-full.svg rename to frontend/public/img/icon-star-full.svg diff --git a/tubearchivist/static/img/icon-star-half.svg b/frontend/public/img/icon-star-half.svg similarity index 100% rename from tubearchivist/static/img/icon-star-half.svg rename to frontend/public/img/icon-star-half.svg diff --git a/tubearchivist/static/img/icon-stop.svg b/frontend/public/img/icon-stop.svg similarity index 100% rename from tubearchivist/static/img/icon-stop.svg rename to frontend/public/img/icon-stop.svg diff --git a/tubearchivist/static/img/icon-substract.svg b/frontend/public/img/icon-substract.svg similarity index 100% rename from tubearchivist/static/img/icon-substract.svg rename to frontend/public/img/icon-substract.svg diff --git a/tubearchivist/static/img/icon-thumb.svg b/frontend/public/img/icon-thumb.svg similarity index 99% rename from tubearchivist/static/img/icon-thumb.svg rename to frontend/public/img/icon-thumb.svg index a31320e9..91867bcf 100644 --- a/tubearchivist/static/img/icon-thumb.svg +++ b/frontend/public/img/icon-thumb.svg @@ -1,21 +1,21 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/tubearchivist/static/img/icon-unseen.svg b/frontend/public/img/icon-unseen.svg similarity index 100% rename from tubearchivist/static/img/icon-unseen.svg rename to frontend/public/img/icon-unseen.svg diff --git a/tubearchivist/static/img/logo-tube-archivist-dark.png b/frontend/public/img/logo-tube-archivist-dark.png similarity index 100% rename from tubearchivist/static/img/logo-tube-archivist-dark.png rename to frontend/public/img/logo-tube-archivist-dark.png diff --git a/tubearchivist/static/img/logo-tube-archivist-light.png b/frontend/public/img/logo-tube-archivist-light.png similarity index 100% rename from tubearchivist/static/img/logo-tube-archivist-light.png rename to frontend/public/img/logo-tube-archivist-light.png diff --git a/frontend/src/api/actions/createAppriseNotificationUrl.ts b/frontend/src/api/actions/createAppriseNotificationUrl.ts new file mode 100644 index 00000000..cfb1d254 --- /dev/null +++ b/frontend/src/api/actions/createAppriseNotificationUrl.ts @@ -0,0 +1,16 @@ +import APIClient from '../../functions/APIClient'; + +export type AppriseTaskNameType = + | 'update_subscribed' + | 'extract_download' + | 'download_pending' + | 'check_reindex'; + +const createAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => { + return APIClient('/api/task/notification/', { + method: 'POST', + body: { task_name: taskName, url }, + }); +}; + +export default createAppriseNotificationUrl; diff --git a/frontend/src/api/actions/createCustomPlaylist.ts b/frontend/src/api/actions/createCustomPlaylist.ts new file mode 100644 index 00000000..76717070 --- /dev/null +++ b/frontend/src/api/actions/createCustomPlaylist.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; + +const createCustomPlaylist = async (playlistId: string) => { + return APIClient('/api/playlist/', { + method: 'POST', + body: { data: { create: playlistId } }, + }); +}; + +export default createCustomPlaylist; diff --git a/frontend/src/api/actions/createTaskSchedule.ts b/frontend/src/api/actions/createTaskSchedule.ts new file mode 100644 index 00000000..346ad3e2 --- /dev/null +++ b/frontend/src/api/actions/createTaskSchedule.ts @@ -0,0 +1,33 @@ +import APIClient from '../../functions/APIClient'; + +export type TaskScheduleNameType = + | 'update_subscribed' + | 'download_pending' + | 'extract_download' + | 'check_reindex' + | 'manual_import' + | 'run_backup' + | 'restore_backup' + | 'rescan_filesystem' + | 'thumbnail_check' + | 'resync_thumbs' + | 'index_playlists' + | 'subscribe_to' + | 'version_check'; + +type ScheduleConfigType = { + schedule?: string; + config?: { + days?: number; + rotate?: number; + }; +}; + +const createTaskSchedule = async (taskName: TaskScheduleNameType, schedule: ScheduleConfigType) => { + return APIClient(`/api/task/schedule/${taskName}/`, { + method: 'POST', + body: schedule, + }); +}; + +export default createTaskSchedule; diff --git a/frontend/src/api/actions/deleteApiToken.ts b/frontend/src/api/actions/deleteApiToken.ts new file mode 100644 index 00000000..76512416 --- /dev/null +++ b/frontend/src/api/actions/deleteApiToken.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const deleteApiToken = async () => { + return APIClient('/api/appsettings/token/', { + method: 'DELETE', + }); +}; + +export default deleteApiToken; diff --git a/frontend/src/api/actions/deleteAppriseNotificationUrl.ts b/frontend/src/api/actions/deleteAppriseNotificationUrl.ts new file mode 100644 index 00000000..5018b79f --- /dev/null +++ b/frontend/src/api/actions/deleteAppriseNotificationUrl.ts @@ -0,0 +1,16 @@ +import APIClient from '../../functions/APIClient'; + +type AppriseTaskNameType = + | 'update_subscribed' + | 'extract_download' + | 'download_pending' + | 'check_reindex'; + +const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType) => { + return APIClient('/api/task/notification/', { + method: 'DELETE', + body: { task_name: taskName }, + }); +}; + +export default deleteAppriseNotificationUrl; diff --git a/frontend/src/api/actions/deleteChannel.ts b/frontend/src/api/actions/deleteChannel.ts new file mode 100644 index 00000000..e2046a83 --- /dev/null +++ b/frontend/src/api/actions/deleteChannel.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const deleteChannel = async (channelId: string) => { + return APIClient(`/api/channel/${channelId}/`, { + method: 'DELETE', + }); +}; + +export default deleteChannel; diff --git a/frontend/src/api/actions/deleteCookie.ts b/frontend/src/api/actions/deleteCookie.ts new file mode 100644 index 00000000..c42b8764 --- /dev/null +++ b/frontend/src/api/actions/deleteCookie.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; +import { CookieStateType } from '../loader/loadCookie'; + +const deleteCookie = async (): Promise => { + return APIClient('/api/appsettings/cookie/', { + method: 'DELETE', + }); +}; + +export default deleteCookie; diff --git a/frontend/src/api/actions/deleteDownloadById.ts b/frontend/src/api/actions/deleteDownloadById.ts new file mode 100644 index 00000000..f9e8f147 --- /dev/null +++ b/frontend/src/api/actions/deleteDownloadById.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const deleteDownloadById = async (youtubeId: string) => { + return APIClient(`/api/download/${youtubeId}/`, { + method: 'DELETE', + }); +}; + +export default deleteDownloadById; diff --git a/frontend/src/api/actions/deleteDownloadQueueByFilter.ts b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts new file mode 100644 index 00000000..65a01a3c --- /dev/null +++ b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts @@ -0,0 +1,14 @@ +import APIClient from '../../functions/APIClient'; + +type FilterType = 'ignore' | 'pending'; + +const deleteDownloadQueueByFilter = async (filter: FilterType) => { + const searchParams = new URLSearchParams(); + if (filter) searchParams.append('filter', filter); + + return APIClient(`/api/download/?${searchParams.toString()}`, { + method: 'DELETE', + }); +}; + +export default deleteDownloadQueueByFilter; diff --git a/frontend/src/api/actions/deletePlaylist.ts b/frontend/src/api/actions/deletePlaylist.ts new file mode 100644 index 00000000..ce60e8cd --- /dev/null +++ b/frontend/src/api/actions/deletePlaylist.ts @@ -0,0 +1,14 @@ +import APIClient from '../../functions/APIClient'; + +const deletePlaylist = async (playlistId: string, allVideos = false) => { + let params = ''; + if (allVideos) { + params = '?delete-videos=true'; + } + + return APIClient(`/api/playlist/${playlistId}/${params}`, { + method: 'DELETE', + }); +}; + +export default deletePlaylist; diff --git a/frontend/src/api/actions/deletePoToken.ts b/frontend/src/api/actions/deletePoToken.ts new file mode 100644 index 00000000..b8ffc8c5 --- /dev/null +++ b/frontend/src/api/actions/deletePoToken.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const deletePoToken = async () => { + return APIClient('/api/appsettings/potoken/', { + method: 'DELETE', + }); +}; + +export default deletePoToken; diff --git a/frontend/src/api/actions/deleteTaskSchedule.ts b/frontend/src/api/actions/deleteTaskSchedule.ts new file mode 100644 index 00000000..ac873681 --- /dev/null +++ b/frontend/src/api/actions/deleteTaskSchedule.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; +import { TaskScheduleNameType } from './createTaskSchedule'; + +const deleteTaskSchedule = async (taskName: TaskScheduleNameType) => { + return APIClient(`/api/task/schedule/${taskName}/`, { + method: 'DELETE', + }); +}; + +export default deleteTaskSchedule; diff --git a/frontend/src/api/actions/deleteVideo.ts b/frontend/src/api/actions/deleteVideo.ts new file mode 100644 index 00000000..a4ef87a2 --- /dev/null +++ b/frontend/src/api/actions/deleteVideo.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const deleteVideo = async (videoId: string) => { + return APIClient(`/api/video/${videoId}/`, { + method: 'DELETE', + }); +}; + +export default deleteVideo; diff --git a/frontend/src/api/actions/deleteVideoProgressById.ts b/frontend/src/api/actions/deleteVideoProgressById.ts new file mode 100644 index 00000000..72275f18 --- /dev/null +++ b/frontend/src/api/actions/deleteVideoProgressById.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const deleteVideoProgressById = async (youtubeId: string) => { + return APIClient(`/api/video/${youtubeId}/progress/`, { + method: 'DELETE', + }); +}; + +export default deleteVideoProgressById; diff --git a/frontend/src/api/actions/logOut.ts b/frontend/src/api/actions/logOut.ts new file mode 100644 index 00000000..e5dbaefa --- /dev/null +++ b/frontend/src/api/actions/logOut.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const logOut = async () => { + return APIClient('/api/user/logout/', { + method: 'POST', + }); +}; + +export default logOut; diff --git a/frontend/src/api/actions/queueBackup.ts b/frontend/src/api/actions/queueBackup.ts new file mode 100644 index 00000000..4896122d --- /dev/null +++ b/frontend/src/api/actions/queueBackup.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const queueBackup = async () => { + return APIClient('/api/appsettings/backup/', { + method: 'POST', + }); +}; + +export default queueBackup; diff --git a/frontend/src/api/actions/queueReindex.ts b/frontend/src/api/actions/queueReindex.ts new file mode 100644 index 00000000..18a611a1 --- /dev/null +++ b/frontend/src/api/actions/queueReindex.ts @@ -0,0 +1,23 @@ +import APIClient from '../../functions/APIClient'; + +export type ReindexType = 'channel' | 'video' | 'playlist'; + +export const ReindexTypeEnum = { + channel: 'channel', + video: 'video', + playlist: 'playlist', +}; + +const queueReindex = async (id: string, type: ReindexType, reindexVideos = false) => { + let params = ''; + if (reindexVideos) { + params = '?extract_videos=true'; + } + + return APIClient(`/api/refresh/${params}`, { + method: 'POST', + body: { [type]: [id] }, + }); +}; + +export default queueReindex; diff --git a/frontend/src/api/actions/queueSnapshot.ts b/frontend/src/api/actions/queueSnapshot.ts new file mode 100644 index 00000000..cba692ad --- /dev/null +++ b/frontend/src/api/actions/queueSnapshot.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const queueSnapshot = async () => { + return APIClient('/api/appsettings/snapshot/', { + method: 'POST', + }); +}; + +export default queueSnapshot; diff --git a/frontend/src/api/actions/restoreBackup.ts b/frontend/src/api/actions/restoreBackup.ts new file mode 100644 index 00000000..95f15d42 --- /dev/null +++ b/frontend/src/api/actions/restoreBackup.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const restoreBackup = async (fileName: string) => { + return APIClient(`/api/appsettings/backup/${fileName}/`, { + method: 'POST', + }); +}; + +export default restoreBackup; diff --git a/frontend/src/api/actions/restoreSnapshot.ts b/frontend/src/api/actions/restoreSnapshot.ts new file mode 100644 index 00000000..c33141f9 --- /dev/null +++ b/frontend/src/api/actions/restoreSnapshot.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const restoreSnapshot = async (snapshotId: string) => { + return APIClient(`/api/appsettings/snapshot/${snapshotId}/`, { + method: 'POST', + }); +}; + +export default restoreSnapshot; diff --git a/frontend/src/api/actions/signIn.ts b/frontend/src/api/actions/signIn.ts new file mode 100644 index 00000000..6df51b43 --- /dev/null +++ b/frontend/src/api/actions/signIn.ts @@ -0,0 +1,40 @@ +import defaultHeaders from '../../configuration/defaultHeaders'; +import getApiUrl from '../../configuration/getApiUrl'; +import getFetchCredentials from '../../configuration/getFetchCredentials'; +import getCookie from '../../functions/getCookie'; + +export type LoginResponseType = { + token?: string; + user_id: number; + is_superuser: boolean; + is_staff: boolean; + user_groups: []; +}; + +const signIn = async (username: string, password: string, saveLogin: boolean) => { + // works differently, response status is checked + const apiUrl = getApiUrl(); + const csrfCookie = getCookie('csrftoken'); + + const response = await fetch(`${apiUrl}/api/user/login/`, { + method: 'POST', + headers: { + ...defaultHeaders, + 'X-CSRFToken': csrfCookie || '', + }, + credentials: getFetchCredentials(), + body: JSON.stringify({ + username, + password, + remember_me: saveLogin ? 'on' : 'off', + }), + }); + + if (response.status === 403) { + console.log('Might be already logged in.', await response.json()); + } + + return response; +}; + +export default signIn; diff --git a/frontend/src/api/actions/stopTaskByName.ts b/frontend/src/api/actions/stopTaskByName.ts new file mode 100644 index 00000000..7103879a --- /dev/null +++ b/frontend/src/api/actions/stopTaskByName.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; + +const stopTaskByName = async (taskId: string) => { + APIClient(`/api/task/by-id/${taskId}/`, { + method: 'POST', + body: { command: 'stop' }, + }); +}; + +export default stopTaskByName; diff --git a/frontend/src/api/actions/updateAppsettingsConfig.ts b/frontend/src/api/actions/updateAppsettingsConfig.ts new file mode 100644 index 00000000..462ca3ed --- /dev/null +++ b/frontend/src/api/actions/updateAppsettingsConfig.ts @@ -0,0 +1,13 @@ +import APIClient from '../../functions/APIClient'; + +const updateAppsettingsConfig = async ( + configKey: string, + configValue: string | boolean | number | null, +) => { + return APIClient('/api/appsettings/config/', { + method: 'POST', + body: { [configKey]: configValue }, + }); +}; + +export default updateAppsettingsConfig; diff --git a/frontend/src/api/actions/updateBulkChannelSubscriptions.ts b/frontend/src/api/actions/updateBulkChannelSubscriptions.ts new file mode 100644 index 00000000..a49f9599 --- /dev/null +++ b/frontend/src/api/actions/updateBulkChannelSubscriptions.ts @@ -0,0 +1,23 @@ +import APIClient from '../../functions/APIClient'; + +const updateBulkChannelSubscriptions = async (channelIds: string, status: boolean) => { + const channels = []; + const containsMultiple = channelIds.includes('\n'); + + if (containsMultiple) { + const youtubeChannelIds = channelIds.split('\n'); + + youtubeChannelIds.forEach(channelId => { + channels.push({ channel_id: channelId, channel_subscribed: status }); + }); + } else { + channels.push({ channel_id: channelIds, channel_subscribed: status }); + } + + return APIClient('/api/channel/', { + method: 'POST', + body: { data: [...channels] }, + }); +}; + +export default updateBulkChannelSubscriptions; diff --git a/frontend/src/api/actions/updateBulkPlaylistSubscriptions.ts b/frontend/src/api/actions/updateBulkPlaylistSubscriptions.ts new file mode 100644 index 00000000..d189496f --- /dev/null +++ b/frontend/src/api/actions/updateBulkPlaylistSubscriptions.ts @@ -0,0 +1,23 @@ +import APIClient from '../../functions/APIClient'; + +const updateBulkPlaylistSubscriptions = async (playlistIds: string, status: boolean) => { + const playlists = []; + const containsMultiple = playlistIds.includes('\n'); + + if (containsMultiple) { + const youtubePlaylistIds = playlistIds.split('\n'); + + youtubePlaylistIds.forEach(playlistId => { + playlists.push({ playlist_id: playlistId, playlist_subscribed: status }); + }); + } else { + playlists.push({ playlist_id: playlistIds, playlist_subscribed: status }); + } + + return APIClient('/api/playlist/', { + method: 'POST', + body: { data: [...playlists] }, + }); +}; + +export default updateBulkPlaylistSubscriptions; diff --git a/frontend/src/api/actions/updateChannelOverwrite.ts b/frontend/src/api/actions/updateChannelOverwrite.ts new file mode 100644 index 00000000..f97d21fd --- /dev/null +++ b/frontend/src/api/actions/updateChannelOverwrite.ts @@ -0,0 +1,20 @@ +import APIClient from '../../functions/APIClient'; + +const updateChannelOverwrites = async ( + channelId: string, + configKey: string, + configValue: string | boolean | number | null, +) => { + const data = { + channel_overwrites: { + [configKey]: configValue, + }, + }; + + return APIClient(`/api/channel/${channelId}/`, { + method: 'POST', + body: data, + }); +}; + +export default updateChannelOverwrites; diff --git a/frontend/src/api/actions/updateChannelSubscription.ts b/frontend/src/api/actions/updateChannelSubscription.ts new file mode 100644 index 00000000..184f182a --- /dev/null +++ b/frontend/src/api/actions/updateChannelSubscription.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; + +const updateChannelSubscription = async (channelId: string, status: boolean) => { + return APIClient(`/api/channel/${channelId}/`, { + method: 'POST', + body: { channel_subscribed: status }, + }); +}; + +export default updateChannelSubscription; diff --git a/frontend/src/api/actions/updateCookie.ts b/frontend/src/api/actions/updateCookie.ts new file mode 100644 index 00000000..7eee5a22 --- /dev/null +++ b/frontend/src/api/actions/updateCookie.ts @@ -0,0 +1,11 @@ +import APIClient from '../../functions/APIClient'; +import { CookieStateType } from '../loader/loadCookie'; + +const updateCookie = async (cookie: string): Promise => { + return APIClient('/api/appsettings/cookie/', { + method: 'PUT', + body: { cookie }, + }); +}; + +export default updateCookie; diff --git a/frontend/src/api/actions/updateCustomPlaylist.ts b/frontend/src/api/actions/updateCustomPlaylist.ts new file mode 100644 index 00000000..bef8403d --- /dev/null +++ b/frontend/src/api/actions/updateCustomPlaylist.ts @@ -0,0 +1,16 @@ +import APIClient from '../../functions/APIClient'; + +type CustomPlaylistActionType = 'create' | 'up' | 'down' | 'top' | 'bottom' | 'remove'; + +const updateCustomPlaylist = async ( + action: CustomPlaylistActionType, + playlistId: string, + videoId: string, +) => { + return APIClient(`/api/playlist/${playlistId}/`, { + method: 'POST', + body: { action, video_id: videoId }, + }); +}; + +export default updateCustomPlaylist; diff --git a/frontend/src/api/actions/updateDownloadQueue.ts b/frontend/src/api/actions/updateDownloadQueue.ts new file mode 100644 index 00000000..a96e71cb --- /dev/null +++ b/frontend/src/api/actions/updateDownloadQueue.ts @@ -0,0 +1,28 @@ +import APIClient from '../../functions/APIClient'; + +const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) => { + const urls = []; + const containsMultiple = youtubeIdStrings.includes('\n'); + + if (containsMultiple) { + const youtubeIds = youtubeIdStrings.split('\n'); + + youtubeIds.forEach(youtubeId => { + urls.push({ youtube_id: youtubeId, status: 'pending' }); + }); + } else { + urls.push({ youtube_id: youtubeIdStrings, status: 'pending' }); + } + + let params = ''; + if (autostart) { + params = '?autostart=true'; + } + + return APIClient(`/api/download/${params}`, { + method: 'POST', + body: { data: [...urls] }, + }); +}; + +export default updateDownloadQueue; diff --git a/frontend/src/api/actions/updateDownloadQueueStatusById.ts b/frontend/src/api/actions/updateDownloadQueueStatusById.ts new file mode 100644 index 00000000..9673aab7 --- /dev/null +++ b/frontend/src/api/actions/updateDownloadQueueStatusById.ts @@ -0,0 +1,12 @@ +import APIClient from '../../functions/APIClient'; + +export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority'; + +const updateDownloadQueueStatusById = async (youtubeId: string, status: DownloadQueueStatus) => { + return APIClient(`/api/download/${youtubeId}/`, { + method: 'POST', + body: { status: status }, + }); +}; + +export default updateDownloadQueueStatusById; diff --git a/frontend/src/api/actions/updatePlaylistSubscription.ts b/frontend/src/api/actions/updatePlaylistSubscription.ts new file mode 100644 index 00000000..3f2425f4 --- /dev/null +++ b/frontend/src/api/actions/updatePlaylistSubscription.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; + +const updatePlaylistSubscription = async (playlistId: string, status: boolean) => { + return APIClient(`/api/playlist/${playlistId}/`, { + method: 'POST', + body: { playlist_subscribed: status }, + }); +}; + +export default updatePlaylistSubscription; diff --git a/frontend/src/api/actions/updatePoToken.ts b/frontend/src/api/actions/updatePoToken.ts new file mode 100644 index 00000000..1c6355ef --- /dev/null +++ b/frontend/src/api/actions/updatePoToken.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; + +const updatePoToken = async (potoken: string) => { + return APIClient('/api/appsettings/potoken/', { + method: 'POST', + body: { potoken }, + }); +}; + +export default updatePoToken; diff --git a/frontend/src/api/actions/updateTaskByName.ts b/frontend/src/api/actions/updateTaskByName.ts new file mode 100644 index 00000000..786c1a26 --- /dev/null +++ b/frontend/src/api/actions/updateTaskByName.ts @@ -0,0 +1,16 @@ +import APIClient from '../../functions/APIClient'; + +type TaskNamesType = + | 'download_pending' + | 'update_subscribed' + | 'manual_import' + | 'resync_thumbs' + | 'rescan_filesystem'; + +const updateTaskByName = async (taskName: TaskNamesType) => { + return APIClient(`/api/task/by-name/${taskName}/`, { + method: 'POST', + }); +}; + +export default updateTaskByName; diff --git a/frontend/src/api/actions/updateUserConfig.ts b/frontend/src/api/actions/updateUserConfig.ts new file mode 100644 index 00000000..c091741e --- /dev/null +++ b/frontend/src/api/actions/updateUserConfig.ts @@ -0,0 +1,40 @@ +import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home'; +import APIClient from '../../functions/APIClient'; + +export type UserMeType = { + id: number; + name: string; + is_superuser: boolean; + is_staff: boolean; + groups: []; + user_permissions: []; + last_login: string; + config: UserConfigType; +}; + +export type ColourVariants = 'dark.css' | 'light.css' | 'matrix.css' | 'midnight.css'; + +export type UserConfigType = { + stylesheet: ColourVariants; + page_size: number; + sort_by: SortByType; + sort_order: SortOrderType; + view_style_home: ViewLayoutType; + view_style_channel: ViewLayoutType; + view_style_downloads: ViewLayoutType; + view_style_playlist: ViewLayoutType; + grid_items: number; + hide_watched: boolean; + show_ignored_only: boolean; + show_subed_only: boolean; + show_help_text: boolean; +}; + +const updateUserConfig = async (config: Partial): Promise => { + return APIClient('/api/user/me/', { + method: 'POST', + body: { config: config }, + }); +}; + +export default updateUserConfig; diff --git a/frontend/src/api/actions/updateVideoProgressById.ts b/frontend/src/api/actions/updateVideoProgressById.ts new file mode 100644 index 00000000..8ef6b7ff --- /dev/null +++ b/frontend/src/api/actions/updateVideoProgressById.ts @@ -0,0 +1,15 @@ +import APIClient from '../../functions/APIClient'; + +type VideoProgressProp = { + youtubeId: string; + currentProgress: number; +}; + +const updateVideoProgressById = async ({ youtubeId, currentProgress }: VideoProgressProp) => { + return APIClient(`/api/video/${youtubeId}/progress/`, { + method: 'POST', + body: { position: currentProgress }, + }); +}; + +export default updateVideoProgressById; diff --git a/frontend/src/api/actions/updateWatchedState.ts b/frontend/src/api/actions/updateWatchedState.ts new file mode 100644 index 00000000..08deddff --- /dev/null +++ b/frontend/src/api/actions/updateWatchedState.ts @@ -0,0 +1,20 @@ +import APIClient from '../../functions/APIClient'; +import deleteVideoProgressById from './deleteVideoProgressById'; + +export type Watched = { + id: string; + is_watched: boolean; +}; + +const updateWatchedState = async (watched: Watched) => { + if (watched.is_watched) { + await deleteVideoProgressById(watched.id); + } + + return APIClient('/api/watched/', { + method: 'POST', + body: watched, + }); +}; + +export default updateWatchedState; diff --git a/frontend/src/api/actions/validateCookie.ts b/frontend/src/api/actions/validateCookie.ts new file mode 100644 index 00000000..2faa2f92 --- /dev/null +++ b/frontend/src/api/actions/validateCookie.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; +import { CookieStateType } from '../loader/loadCookie'; + +const validateCookie = async (): Promise => { + return APIClient('/api/appsettings/cookie/', { + method: 'POST', + }); +}; + +export default validateCookie; diff --git a/frontend/src/api/loader/loadApiToken.ts b/frontend/src/api/loader/loadApiToken.ts new file mode 100644 index 00000000..86f9f455 --- /dev/null +++ b/frontend/src/api/loader/loadApiToken.ts @@ -0,0 +1,11 @@ +import APIClient from '../../functions/APIClient'; + +type ApiTokenResponse = { + token: string; +}; + +const loadApiToken = async (): Promise => { + return APIClient('/api/appsettings/token/'); +}; + +export default loadApiToken; diff --git a/frontend/src/api/loader/loadAppriseNotification.ts b/frontend/src/api/loader/loadAppriseNotification.ts new file mode 100644 index 00000000..4f276dd0 --- /dev/null +++ b/frontend/src/api/loader/loadAppriseNotification.ts @@ -0,0 +1,26 @@ +import APIClient from '../../functions/APIClient'; + +export type AppriseNotificationType = { + check_reindex?: { + urls: string[]; + title: string; + }; + download_pending?: { + urls: string[]; + title: string; + }; + extract_download?: { + urls: string[]; + title: string; + }; + update_subscribed?: { + urls: string[]; + title: string; + }; +}; + +const loadAppriseNotification = async (): Promise => { + return APIClient('/api/task/notification/'); +}; + +export default loadAppriseNotification; diff --git a/frontend/src/api/loader/loadAppsettingsConfig.ts b/frontend/src/api/loader/loadAppsettingsConfig.ts new file mode 100644 index 00000000..34ba3e38 --- /dev/null +++ b/frontend/src/api/loader/loadAppsettingsConfig.ts @@ -0,0 +1,39 @@ +import APIClient from '../../functions/APIClient'; + +export type AppSettingsConfigType = { + subscriptions: { + channel_size: number | null; + live_channel_size: number | null; + shorts_channel_size: number | null; + auto_start: boolean; + }; + downloads: { + limit_speed: number | null; + sleep_interval: number | null; + autodelete_days: number | null; + format: string | null; + format_sort: string | null; + add_metadata: boolean; + add_thumbnail: boolean; + subtitle: string | null; + subtitle_source: string | null; + subtitle_index: boolean; + comment_max: string | null; + comment_sort: string; + cookie_import: boolean; + potoken: boolean; + throttledratelimit: number | null; + extractor_lang: string | null; + integrate_ryd: boolean; + integrate_sponsorblock: boolean; + }; + application: { + enable_snapshot: boolean; + }; +}; + +const loadAppsettingsConfig = async (): Promise => { + return APIClient('/api/appsettings/config/'); +}; + +export default loadAppsettingsConfig; diff --git a/frontend/src/api/loader/loadAuth.ts b/frontend/src/api/loader/loadAuth.ts new file mode 100644 index 00000000..dc0b24d9 --- /dev/null +++ b/frontend/src/api/loader/loadAuth.ts @@ -0,0 +1,22 @@ +import defaultHeaders from '../../configuration/defaultHeaders'; +import getApiUrl from '../../configuration/getApiUrl'; +import getFetchCredentials from '../../configuration/getFetchCredentials'; +import getCookie from '../../functions/getCookie'; + +const loadAuth = async () => { + // works differently, return response to check for status in main.tsx + const apiUrl = getApiUrl(); + const csrfCookie = getCookie('csrftoken'); + + const response = await fetch(`${apiUrl}/api/ping/`, { + headers: { + ...defaultHeaders, + 'X-CSRFToken': csrfCookie || '', + }, + credentials: getFetchCredentials(), + }); + + return response; +}; + +export default loadAuth; diff --git a/frontend/src/api/loader/loadBackupList.ts b/frontend/src/api/loader/loadBackupList.ts new file mode 100644 index 00000000..64d11951 --- /dev/null +++ b/frontend/src/api/loader/loadBackupList.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadBackupList = async () => { + return APIClient('/api/appsettings/backup/'); +}; + +export default loadBackupList; diff --git a/frontend/src/api/loader/loadChannelAggs.ts b/frontend/src/api/loader/loadChannelAggs.ts new file mode 100644 index 00000000..435cdba3 --- /dev/null +++ b/frontend/src/api/loader/loadChannelAggs.ts @@ -0,0 +1,20 @@ +import APIClient from '../../functions/APIClient'; + +export type ChannelAggsType = { + total_items: { + value: number; + }; + total_size: { + value: number; + }; + total_duration: { + value: number; + value_str: string; + }; +}; + +const loadChannelAggs = async (channelId: string): Promise => { + return APIClient(`/api/channel/${channelId}/aggs/`); +}; + +export default loadChannelAggs; diff --git a/frontend/src/api/loader/loadChannelById.ts b/frontend/src/api/loader/loadChannelById.ts new file mode 100644 index 00000000..05c72722 --- /dev/null +++ b/frontend/src/api/loader/loadChannelById.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadChannelById = async (youtubeChannelId: string) => { + return APIClient(`/api/channel/${youtubeChannelId}/`); +}; + +export default loadChannelById; diff --git a/frontend/src/api/loader/loadChannelList.ts b/frontend/src/api/loader/loadChannelList.ts new file mode 100644 index 00000000..42cda1fd --- /dev/null +++ b/frontend/src/api/loader/loadChannelList.ts @@ -0,0 +1,14 @@ +import APIClient from '../../functions/APIClient'; + +const loadChannelList = async (page: number, showSubscribed: boolean) => { + const searchParams = new URLSearchParams(); + + if (page) searchParams.append('page', page.toString()); + if (showSubscribed) searchParams.append('filter', 'subscribed'); + + const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + return APIClient(endpoint); +}; + +export default loadChannelList; diff --git a/frontend/src/api/loader/loadChannelNav.ts b/frontend/src/api/loader/loadChannelNav.ts new file mode 100644 index 00000000..ee0aff16 --- /dev/null +++ b/frontend/src/api/loader/loadChannelNav.ts @@ -0,0 +1,14 @@ +import APIClient from '../../functions/APIClient'; + +export type ChannelNavResponseType = { + has_streams: boolean; + has_shorts: boolean; + has_playlists: boolean; + has_pending: boolean; +}; + +const loadChannelNav = async (youtubeChannelId: string): Promise => { + return APIClient(`/api/channel/${youtubeChannelId}/nav/`); +}; + +export default loadChannelNav; diff --git a/frontend/src/api/loader/loadCommentsbyVideoId.ts b/frontend/src/api/loader/loadCommentsbyVideoId.ts new file mode 100644 index 00000000..5d5484b1 --- /dev/null +++ b/frontend/src/api/loader/loadCommentsbyVideoId.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadCommentsbyVideoId = async (youtubeId: string) => { + return APIClient(`/api/video/${youtubeId}/comment/`); +}; + +export default loadCommentsbyVideoId; diff --git a/frontend/src/api/loader/loadCookie.ts b/frontend/src/api/loader/loadCookie.ts new file mode 100644 index 00000000..8b94b977 --- /dev/null +++ b/frontend/src/api/loader/loadCookie.ts @@ -0,0 +1,14 @@ +import APIClient from '../../functions/APIClient'; + +export type CookieStateType = { + cookie_enabled: boolean; + status?: boolean; + validated?: number; + validated_str?: string; +}; + +const loadCookie = async (): Promise => { + return APIClient('/api/appsettings/cookie/'); +}; + +export default loadCookie; diff --git a/frontend/src/api/loader/loadDownloadAggs.ts b/frontend/src/api/loader/loadDownloadAggs.ts new file mode 100644 index 00000000..113fa5d2 --- /dev/null +++ b/frontend/src/api/loader/loadDownloadAggs.ts @@ -0,0 +1,21 @@ +import APIClient from '../../functions/APIClient'; + +type DownloadAggsBucket = { + key: string[]; + key_as_string: string; + doc_count: number; +}; + +export type DownloadAggsType = { + channel_downloads: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: DownloadAggsBucket[]; + }; +}; + +const loadDownloadAggs = async (): Promise => { + return APIClient('/api/download/aggs/'); +}; + +export default loadDownloadAggs; diff --git a/frontend/src/api/loader/loadDownloadQueue.ts b/frontend/src/api/loader/loadDownloadQueue.ts new file mode 100644 index 00000000..8aa28d90 --- /dev/null +++ b/frontend/src/api/loader/loadDownloadQueue.ts @@ -0,0 +1,20 @@ +import APIClient from '../../functions/APIClient'; +import { DownloadResponseType } from '../../pages/Download'; + +const loadDownloadQueue = async ( + page: number, + channelId: string | null, + showIgnored: boolean, +): Promise => { + const searchParams = new URLSearchParams(); + + if (page) searchParams.append('page', page.toString()); + if (channelId) searchParams.append('channel', channelId); + searchParams.append('filter', showIgnored ? 'ignore' : 'pending'); + + const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + return APIClient(endpoint); +}; + +export default loadDownloadQueue; diff --git a/frontend/src/api/loader/loadNotifications.ts b/frontend/src/api/loader/loadNotifications.ts new file mode 100644 index 00000000..a8fb9974 --- /dev/null +++ b/frontend/src/api/loader/loadNotifications.ts @@ -0,0 +1,16 @@ +import APIClient from '../../functions/APIClient'; + +export type NotificationPages = 'download' | 'settings' | 'channel' | 'all'; + +const loadNotifications = async (pageName: NotificationPages, includeReindex = false) => { + const searchParams = new URLSearchParams(); + + if (!includeReindex && pageName !== 'all') { + searchParams.append('filter', pageName); + } + + const endpoint = `/api/notification/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + return APIClient(endpoint); +}; + +export default loadNotifications; diff --git a/frontend/src/api/loader/loadPlaylistById.ts b/frontend/src/api/loader/loadPlaylistById.ts new file mode 100644 index 00000000..ad31fcf6 --- /dev/null +++ b/frontend/src/api/loader/loadPlaylistById.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadPlaylistById = async (playlistId: string | undefined) => { + return APIClient(`/api/playlist/${playlistId}/`); +}; + +export default loadPlaylistById; diff --git a/frontend/src/api/loader/loadPlaylistList.ts b/frontend/src/api/loader/loadPlaylistList.ts new file mode 100644 index 00000000..c8779e6a --- /dev/null +++ b/frontend/src/api/loader/loadPlaylistList.ts @@ -0,0 +1,24 @@ +import APIClient from '../../functions/APIClient'; + +type PlaylistType = 'regular' | 'custom'; + +type LoadPlaylistListProps = { + channel?: string; + page?: number | undefined; + subscribed?: boolean; + type?: PlaylistType; +}; + +const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylistListProps) => { + const searchParams = new URLSearchParams(); + + if (channel) searchParams.append('channel', channel); + if (page) searchParams.append('page', page.toString()); + if (subscribed) searchParams.append('subscribed', subscribed.toString()); + if (type) searchParams.append('type', type); + + const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + return APIClient(endpoint); +}; + +export default loadPlaylistList; diff --git a/frontend/src/api/loader/loadSchedule.ts b/frontend/src/api/loader/loadSchedule.ts new file mode 100644 index 00000000..ae32c992 --- /dev/null +++ b/frontend/src/api/loader/loadSchedule.ts @@ -0,0 +1,20 @@ +import APIClient from '../../functions/APIClient'; + +type ScheduleType = { + name: string; + schedule: string; + schedule_human: string; + last_run_at: string; + config: { + days?: number; + rotate?: number; + }; +}; + +export type ScheduleResponseType = ScheduleType[]; + +const loadSchedule = async (): Promise => { + return APIClient('/api/task/schedule/'); +}; + +export default loadSchedule; diff --git a/frontend/src/api/loader/loadSearch.ts b/frontend/src/api/loader/loadSearch.ts new file mode 100644 index 00000000..fe11c969 --- /dev/null +++ b/frontend/src/api/loader/loadSearch.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadSearch = async (query: string) => { + return APIClient(`/api/search/?query=${query}`); +}; + +export default loadSearch; diff --git a/frontend/src/api/loader/loadSimmilarVideosById.ts b/frontend/src/api/loader/loadSimmilarVideosById.ts new file mode 100644 index 00000000..36b1d8be --- /dev/null +++ b/frontend/src/api/loader/loadSimmilarVideosById.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadSimmilarVideosById = async (youtubeId: string) => { + return APIClient(`/api/video/${youtubeId}/similar/`); +}; + +export default loadSimmilarVideosById; diff --git a/frontend/src/api/loader/loadSnapshots.ts b/frontend/src/api/loader/loadSnapshots.ts new file mode 100644 index 00000000..39593af2 --- /dev/null +++ b/frontend/src/api/loader/loadSnapshots.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadSnapshots = async () => { + return APIClient('/api/appsettings/snapshot/'); +}; + +export default loadSnapshots; diff --git a/frontend/src/api/loader/loadStatsBiggestChannels.ts b/frontend/src/api/loader/loadStatsBiggestChannels.ts new file mode 100644 index 00000000..ec522277 --- /dev/null +++ b/frontend/src/api/loader/loadStatsBiggestChannels.ts @@ -0,0 +1,12 @@ +import APIClient from '../../functions/APIClient'; + +type BiggestChannelsOrderType = 'doc_count' | 'duration' | 'media_size'; + +const loadStatsBiggestChannels = async (order: BiggestChannelsOrderType) => { + const searchParams = new URLSearchParams(); + searchParams.append('order', order); + + return APIClient(`/api/stats/biggestchannels/?${searchParams.toString()}`); +}; + +export default loadStatsBiggestChannels; diff --git a/frontend/src/api/loader/loadStatsChannel.ts b/frontend/src/api/loader/loadStatsChannel.ts new file mode 100644 index 00000000..e149a658 --- /dev/null +++ b/frontend/src/api/loader/loadStatsChannel.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadStatsChannel = async () => { + return APIClient('/api/stats/channel/'); +}; + +export default loadStatsChannel; diff --git a/frontend/src/api/loader/loadStatsDownload.ts b/frontend/src/api/loader/loadStatsDownload.ts new file mode 100644 index 00000000..cec35971 --- /dev/null +++ b/frontend/src/api/loader/loadStatsDownload.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadStatsDownload = async () => { + return APIClient('/api/stats/download/'); +}; + +export default loadStatsDownload; diff --git a/frontend/src/api/loader/loadStatsDownloadHistory.ts b/frontend/src/api/loader/loadStatsDownloadHistory.ts new file mode 100644 index 00000000..095504ab --- /dev/null +++ b/frontend/src/api/loader/loadStatsDownloadHistory.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadStatsDownloadHistory = async () => { + return APIClient('/api/stats/downloadhist/'); +}; + +export default loadStatsDownloadHistory; diff --git a/frontend/src/api/loader/loadStatsPlaylist.ts b/frontend/src/api/loader/loadStatsPlaylist.ts new file mode 100644 index 00000000..22f4dd01 --- /dev/null +++ b/frontend/src/api/loader/loadStatsPlaylist.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadStatsPlaylist = async () => { + return APIClient('/api/stats/playlist/'); +}; + +export default loadStatsPlaylist; diff --git a/frontend/src/api/loader/loadStatsVideo.ts b/frontend/src/api/loader/loadStatsVideo.ts new file mode 100644 index 00000000..d1812e21 --- /dev/null +++ b/frontend/src/api/loader/loadStatsVideo.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadStatsVideo = async () => { + return APIClient('/api/stats/video/'); +}; + +export default loadStatsVideo; diff --git a/frontend/src/api/loader/loadStatsWatchProgress.ts b/frontend/src/api/loader/loadStatsWatchProgress.ts new file mode 100644 index 00000000..3fdf58f9 --- /dev/null +++ b/frontend/src/api/loader/loadStatsWatchProgress.ts @@ -0,0 +1,7 @@ +import APIClient from '../../functions/APIClient'; + +const loadStatsWatchProgress = async () => { + return APIClient('/api/stats/watch/'); +}; + +export default loadStatsWatchProgress; diff --git a/frontend/src/api/loader/loadUserConfig.ts b/frontend/src/api/loader/loadUserConfig.ts new file mode 100644 index 00000000..3cd89736 --- /dev/null +++ b/frontend/src/api/loader/loadUserConfig.ts @@ -0,0 +1,8 @@ +import { UserMeType } from '../actions/updateUserConfig'; +import APIClient from '../../functions/APIClient'; + +const loadUserMeConfig = async (): Promise => { + return APIClient('/api/user/me/'); +}; + +export default loadUserMeConfig; diff --git a/frontend/src/api/loader/loadVideoById.ts b/frontend/src/api/loader/loadVideoById.ts new file mode 100644 index 00000000..ed01831c --- /dev/null +++ b/frontend/src/api/loader/loadVideoById.ts @@ -0,0 +1,8 @@ +import APIClient from '../../functions/APIClient'; +import { VideoResponseType } from '../../pages/Video'; + +const loadVideoById = async (youtubeId: string): Promise => { + return APIClient(`/api/video/${youtubeId}/`); +}; + +export default loadVideoById; diff --git a/frontend/src/api/loader/loadVideoListByPage.ts b/frontend/src/api/loader/loadVideoListByPage.ts new file mode 100644 index 00000000..c2c019da --- /dev/null +++ b/frontend/src/api/loader/loadVideoListByPage.ts @@ -0,0 +1,45 @@ +import { ConfigType, SortByType, SortOrderType, VideoType } from '../../pages/Home'; +import { PaginationType } from '../../components/Pagination'; +import APIClient from '../../functions/APIClient'; + +export type VideoListByFilterResponseType = { + data?: VideoType[]; + config?: ConfigType; + paginate?: PaginationType; +}; + +type WatchTypes = 'watched' | 'unwatched' | 'continue'; +export type VideoTypes = 'videos' | 'streams' | 'shorts'; + +type FilterType = { + page?: number; + playlist?: string; + channel?: string; + watch?: WatchTypes; + sort?: SortByType; + order?: SortOrderType; + type?: VideoTypes; +}; + +const loadVideoListByFilter = async ( + filter: FilterType, +): Promise => { + const searchParams = new URLSearchParams(); + + if (filter.playlist) { + searchParams.append('playlist', filter.playlist); + } else if (filter.channel) { + searchParams.append('channel', filter.channel); + } + + if (filter.page) searchParams.append('page', filter.page.toString()); + if (filter.watch) searchParams.append('watch', filter.watch); + if (filter.sort) searchParams.append('sort', filter.sort); + if (filter.order) searchParams.append('order', filter.order); + if (filter.type) searchParams.append('type', filter.type); + + const endpoint = `/api/video/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + return APIClient(endpoint); +}; + +export default loadVideoListByFilter; diff --git a/frontend/src/api/loader/loadVideoNav.ts b/frontend/src/api/loader/loadVideoNav.ts new file mode 100644 index 00000000..46cee633 --- /dev/null +++ b/frontend/src/api/loader/loadVideoNav.ts @@ -0,0 +1,32 @@ +import APIClient from '../../functions/APIClient'; + +export type VideoNavResponseType = { + playlist_meta: { + current_idx: number; + playlist_id: string; + playlist_name: string; + playlist_channel: string; + }; + playlist_previous: { + youtube_id: string; + title: string; + uploader: string; + idx: number; + downloaded: boolean; + vid_thumb: string; + }; + playlist_next: { + youtube_id: string; + title: string; + uploader: string; + idx: number; + downloaded: boolean; + vid_thumb: string; + }; +}; + +const loadVideoNav = async (youtubeVideoId: string): Promise => { + return APIClient(`/api/video/${youtubeVideoId}/nav/`); +}; + +export default loadVideoNav; diff --git a/frontend/src/components/ApplicationStats.tsx b/frontend/src/components/ApplicationStats.tsx new file mode 100644 index 00000000..eaddfbd9 --- /dev/null +++ b/frontend/src/components/ApplicationStats.tsx @@ -0,0 +1,57 @@ +import { Fragment } from 'react'; +import StatsInfoBoxItem from './StatsInfoBoxItem'; +import formatNumbers from '../functions/formatNumbers'; +import { ChannelStatsType, PlaylistStatsType, DownloadStatsType } from '../pages/SettingsDashboard'; + +type ApplicationStatsProps = { + channelStats?: ChannelStatsType; + playlistStats?: PlaylistStatsType; + downloadStats?: DownloadStatsType; +}; + +const ApplicationStats = ({ + channelStats, + playlistStats, + downloadStats, +}: ApplicationStatsProps) => { + if (!channelStats || !playlistStats || !downloadStats) { + return

Loading...

; + } + + const cards = [ + { + title: 'Channels: ', + data: { + Subscribed: formatNumbers(channelStats.subscribed_true || 0), + Active: formatNumbers(channelStats.active_true || 0), + Total: formatNumbers(channelStats.doc_count || 0), + }, + }, + { + title: 'Playlists: ', + data: { + Subscribed: formatNumbers(playlistStats.subscribed_true || 0), + Active: formatNumbers(playlistStats.active_true || 0), + Total: formatNumbers(playlistStats.doc_count || 0), + }, + }, + { + title: `Downloads Pending: ${downloadStats.pending || 0}`, + data: { + Videos: formatNumbers(downloadStats.pending_videos || 0), + Shorts: formatNumbers(downloadStats.pending_shorts || 0), + Streams: formatNumbers(downloadStats.pending_streams || 0), + }, + }, + ]; + + return cards.map(card => { + return ( + + + + ); + }); +}; + +export default ApplicationStats; diff --git a/frontend/src/components/BiggestChannelsStats.tsx b/frontend/src/components/BiggestChannelsStats.tsx new file mode 100644 index 00000000..6358ede5 --- /dev/null +++ b/frontend/src/components/BiggestChannelsStats.tsx @@ -0,0 +1,108 @@ +import humanFileSize from '../functions/humanFileSize'; +import formatNumbers from '../functions/formatNumbers'; +import { Link } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import { BiggestChannelsStatsType } from '../pages/SettingsDashboard'; + +type BiggestChannelsStatsProps = { + biggestChannelsStatsByCount?: BiggestChannelsStatsType; + biggestChannelsStatsByDuration?: BiggestChannelsStatsType; + biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType; + useSI: boolean; +}; + +const BiggestChannelsStats = ({ + biggestChannelsStatsByCount, + biggestChannelsStatsByDuration, + biggestChannelsStatsByMediaSize, + useSI, +}: BiggestChannelsStatsProps) => { + if ( + !biggestChannelsStatsByCount && + !biggestChannelsStatsByDuration && + !biggestChannelsStatsByMediaSize + ) { + return

Loading...

; + } + + return ( + <> +
+ + + + + + + + + + {biggestChannelsStatsByCount && + biggestChannelsStatsByCount.map(({ id, name, doc_count }) => { + return ( + + + + + ); + })} + +
NameVideos
+ {name} + {formatNumbers(doc_count)}
+
+ +
+ + + + + + + + + + {biggestChannelsStatsByDuration && + biggestChannelsStatsByDuration.map(({ id, name, duration_str }) => { + return ( + + + + + ); + })} + +
NameDuration
+ {name} + {duration_str}
+
+ +
+ + + + + + + + + + {biggestChannelsStatsByMediaSize && + biggestChannelsStatsByMediaSize.map(({ id, name, media_size }) => { + return ( + + + + + ); + })} + +
NameMedia Size
+ {name} + {humanFileSize(media_size, useSI)}
+
+ + ); +}; + +export default BiggestChannelsStats; diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 00000000..0712d07d --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react'; + +export interface ButtonProps { + id?: string; + name?: string; + className?: string; + type?: 'submit' | 'reset' | 'button' | undefined; + label?: string | ReactNode | ReactNode[]; + children?: string | ReactNode | ReactNode[]; + value?: string; + title?: string; + onClick?: () => void; +} + +const Button = ({ + id, + name, + className, + type, + label, + children, + value, + title, + onClick, +}: ButtonProps) => { + return ( + + ); +}; + +export default Button; diff --git a/frontend/src/components/ChannelBanner.tsx b/frontend/src/components/ChannelBanner.tsx new file mode 100644 index 00000000..513f6336 --- /dev/null +++ b/frontend/src/components/ChannelBanner.tsx @@ -0,0 +1,22 @@ +import getApiUrl from '../configuration/getApiUrl'; +import defaultChannelImage from '/img/default-channel-banner.jpg'; + +type ChannelIconProps = { + channelId: string; + channelBannerUrl: string | undefined; +}; + +const ChannelBanner = ({ channelId, channelBannerUrl }: ChannelIconProps) => { + return ( + {`${channelId}-banner`} { + currentTarget.onerror = null; // prevents looping + currentTarget.src = defaultChannelImage; + }} + /> + ); +}; + +export default ChannelBanner; diff --git a/frontend/src/components/ChannelIcon.tsx b/frontend/src/components/ChannelIcon.tsx new file mode 100644 index 00000000..6579af68 --- /dev/null +++ b/frontend/src/components/ChannelIcon.tsx @@ -0,0 +1,22 @@ +import getApiUrl from '../configuration/getApiUrl'; +import defaultChannelIcon from '/img/default-channel-icon.jpg'; + +type ChannelIconProps = { + channelId: string; + channelThumbUrl: string | undefined; +}; + +const ChannelIcon = ({ channelId, channelThumbUrl }: ChannelIconProps) => { + return ( + {`${channelId}-thumb`} { + currentTarget.onerror = null; // prevents looping + currentTarget.src = defaultChannelIcon; + }} + /> + ); +}; + +export default ChannelIcon; diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx new file mode 100644 index 00000000..538f3e8c --- /dev/null +++ b/frontend/src/components/ChannelList.tsx @@ -0,0 +1,92 @@ +import { Link } from 'react-router-dom'; +import { ChannelType } from '../pages/Channels'; +import Routes from '../configuration/routes/RouteList'; +import updateChannelSubscription from '../api/actions/updateChannelSubscription'; +import formatDate from '../functions/formatDates'; +import FormattedNumber from './FormattedNumber'; +import Button from './Button'; +import ChannelIcon from './ChannelIcon'; +import ChannelBanner from './ChannelBanner'; +import { useUserConfigStore } from '../stores/UserConfigStore'; + +type ChannelListProps = { + channelList: ChannelType[] | undefined; + refreshChannelList: (refresh: boolean) => void; +}; + +const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => { + const { userConfig } = useUserConfigStore(); + const viewLayout = userConfig.config.view_style_channel; + + if (!channelList || channelList.length === 0) { + return

No channels found.

; + } + + return ( + <> + {channelList.map(channel => { + return ( +
+
+ + + +
+
+
+
+ + + +
+
+

+ {channel.channel_name} +

+ +
+
+
+
+

Last refreshed: {formatDate(channel.channel_last_refresh)}

+ {channel.channel_subscribed && ( +
+
+
+
+ ); + })} + + ); +}; + +export default ChannelList; diff --git a/frontend/src/components/ChannelOverview.tsx b/frontend/src/components/ChannelOverview.tsx new file mode 100644 index 00000000..1caecdbf --- /dev/null +++ b/frontend/src/components/ChannelOverview.tsx @@ -0,0 +1,75 @@ +import { Link } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import updateChannelSubscription from '../api/actions/updateChannelSubscription'; +import FormattedNumber from './FormattedNumber'; +import Button from './Button'; +import ChannelIcon from './ChannelIcon'; +import useIsAdmin from '../functions/useIsAdmin'; + +type ChannelOverviewProps = { + channelId: string; + channelname: string; + channelSubs: number; + channelSubscribed: boolean; + channelThumbUrl: string; + setRefresh: (status: boolean) => void; +}; + +const ChannelOverview = ({ + channelId, + channelSubs, + channelSubscribed, + channelname, + channelThumbUrl, + setRefresh, +}: ChannelOverviewProps) => { + const isAdmin = useIsAdmin(); + + return ( + <> +
+
+ + + +
+
+

+ {channelname} +

+ + + + {isAdmin && ( + <> + {channelSubscribed ? ( +
+
+ + ); +}; + +export default ChannelOverview; diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx new file mode 100644 index 00000000..2b0c61a7 --- /dev/null +++ b/frontend/src/components/CommentBox.tsx @@ -0,0 +1,106 @@ +import iconThumb from '/img/icon-thumb.svg'; +import iconHeart from '/img/icon-heart.svg'; +import formatDate from '../functions/formatDates'; +import { Fragment, useState } from 'react'; +import Linkify from './Linkify'; +import formatNumbers from '../functions/formatNumbers'; +import Button from './Button'; + +export type CommentReplyType = { + comment_id: string; + comment_text: string; + comment_timestamp: number; + comment_time_text: string; + comment_likecount: number; + comment_is_favorited: false; + comment_author: string; + comment_author_id: string; + comment_author_thumbnail: string; + comment_author_is_uploader: boolean; + comment_parent: string; +}; + +export type CommentsType = { + comment_id: string; + comment_text: string; + comment_timestamp: number; + comment_time_text: string; + comment_likecount: number; + comment_is_favorited: boolean; + comment_author: string; + comment_author_id: string; + comment_author_thumbnail: string; + comment_author_is_uploader: boolean; + comment_parent: string; + comment_replies?: CommentReplyType[]; +}; + +type CommentBoxProps = { + comment: CommentsType; +}; + +const CommentBox = ({ comment }: CommentBoxProps) => { + const [showSubComments, setShowSubComments] = useState(false); + + const hasSubComments = + comment.comment_replies !== undefined && comment.comment_replies.length > 0; + + return ( +
+

+ {comment.comment_author} +

+

+ {comment.comment_text} +

+ +
+ {formatDate(comment.comment_timestamp * 1000)} + + | + + + {' '} + {formatNumbers(comment.comment_likecount, { notation: 'compact' })} + + + {comment.comment_is_favorited && ( + <> + | + + + + + )} +
+ + {hasSubComments && ( + <> + + +
+ {showSubComments && + comment.comment_replies?.map(comment => { + return ( + + + + ); + })} +
+ + )} +
+ ); +}; + +export default CommentBox; diff --git a/frontend/src/components/DownloadHistoryStats.tsx b/frontend/src/components/DownloadHistoryStats.tsx new file mode 100644 index 00000000..935fa314 --- /dev/null +++ b/frontend/src/components/DownloadHistoryStats.tsx @@ -0,0 +1,41 @@ +import humanFileSize from '../functions/humanFileSize'; +import formatDate from '../functions/formatDates'; +import formatNumbers from '../functions/formatNumbers'; +import { DownloadHistoryStatsType } from '../pages/SettingsDashboard'; + +type DownloadHistoryStatsProps = { + downloadHistoryStats?: DownloadHistoryStatsType; + useSI: boolean; +}; + +const DownloadHistoryStats = ({ downloadHistoryStats, useSI }: DownloadHistoryStatsProps) => { + if (!downloadHistoryStats) { + return

Loading...

; + } + + if (downloadHistoryStats.length === 0) { + return ( +
+

No recent downloads

+
+ ); + } + + return downloadHistoryStats.map(({ date, count, media_size }) => { + const videoText = count === 1 ? 'Video' : 'Videos'; + const intlDate = formatDate(date); + + return ( +
+

{intlDate}

+

+ +{formatNumbers(count)} {videoText} +
+ {humanFileSize(media_size, useSI)} +

+
+ ); + }); +}; + +export default DownloadHistoryStats; diff --git a/frontend/src/components/DownloadListItem.tsx b/frontend/src/components/DownloadListItem.tsx new file mode 100644 index 00000000..a16b7507 --- /dev/null +++ b/frontend/src/components/DownloadListItem.tsx @@ -0,0 +1,134 @@ +import { Link } from 'react-router-dom'; +import Download from '../pages/Download'; +import Routes from '../configuration/routes/RouteList'; +import formatDate from '../functions/formatDates'; +import Button from './Button'; +import deleteDownloadById from '../api/actions/deleteDownloadById'; +import updateDownloadQueueStatusById from '../api/actions/updateDownloadQueueStatusById'; +import { useState } from 'react'; +import getApiUrl from '../configuration/getApiUrl'; +import { useUserConfigStore } from '../stores/UserConfigStore'; + +type DownloadListItemProps = { + download: Download; + setRefresh: (status: boolean) => void; +}; + +const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => { + const { userConfig } = useUserConfigStore(); + const view = userConfig.config.view_style_downloads; + const showIgnored = userConfig.config.show_ignored_only; + + const [hideDownload, setHideDownload] = useState(false); + + return ( +
+
+
+ video_thumb + +
+ {showIgnored && ignored} + + {!showIgnored && queued} + + {download.vid_type} + + {download.auto_start && auto} +
+
+
+ +
+
+ {download.channel_indexed && ( + {download.channel_name} + )} + + {!download.channel_indexed && {download.channel_name}} + + +

{download.title}

+
+
+ +

+ Published: {formatDate(download.published)} | Duration: {download.duration} |{' '} + {download.youtube_id} +

+ + {download.message &&

{download.message}

} + +
+ {showIgnored && ( + <> +
+
+ +
+
+ + )} + {!showIgnored && ( + <> +
+
+ + {!hideDownload && ( +
+
+ )} + + )} + + {download.message && ( +
+
+ )} +
+
+
+ ); +}; + +export default DownloadListItem; diff --git a/frontend/src/components/EmbeddableVideoPlayer.tsx b/frontend/src/components/EmbeddableVideoPlayer.tsx new file mode 100644 index 00000000..5476cce7 --- /dev/null +++ b/frontend/src/components/EmbeddableVideoPlayer.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react'; +import { VideoResponseType } from '../pages/Video'; +import VideoPlayer from './VideoPlayer'; +import loadVideoById from '../api/loader/loadVideoById'; +import iconClose from '/img/icon-close.svg'; +import iconEye from '/img/icon-eye.svg'; +import iconThumb from '/img/icon-thumb.svg'; +import WatchedCheckBox from './WatchedCheckBox'; +import GoogleCast from './GoogleCast'; +import updateWatchedState from '../api/actions/updateWatchedState'; +import formatNumbers from '../functions/formatNumbers'; +import { Link, useSearchParams } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import loadPlaylistById from '../api/loader/loadPlaylistById'; + +type Playlist = { + id: string; + name: string; +}; +type PlaylistList = Playlist[]; + +type EmbeddableVideoPlayerProps = { + videoId: string; +}; + +const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { + const [, setSearchParams] = useSearchParams(); + + const [refresh, setRefresh] = useState(false); + const [loading, setLoading] = useState(false); + + const [videoResponse, setVideoResponse] = useState(); + const [playlists, setPlaylists] = useState(); + + useEffect(() => { + (async () => { + setLoading(true); + const videoResponse = await loadVideoById(videoId); + + const playlistIds = videoResponse.data.playlist; + if (playlistIds !== undefined) { + const playlists = await Promise.all( + playlistIds.map(async playlistid => { + const playlistResponse = await loadPlaylistById(playlistid); + + return playlistResponse.data; + }), + ); + + const playlistsFiltered = playlists + .filter(playlist => { + return playlist.playlist_subscribed; + }) + .map(playlist => { + return { + id: playlist.playlist_id, + name: playlist.playlist_name, + }; + }); + + setPlaylists(playlistsFiltered); + } + + setVideoResponse(videoResponse); + + const inlinePlayer = document.getElementById('inline-player'); + inlinePlayer?.scrollIntoView(); + + setRefresh(false); + setLoading(false); + })(); + }, [videoId, refresh]); + + if (videoResponse === undefined) { + return []; + } + + const video = videoResponse.data; + const name = video.title; + const channelId = video.channel.channel_id; + const channelName = video.channel.channel_name; + const watched = video.player.watched; + const sponsorblock = video.sponsorblock; + const views = formatNumbers(video.stats.view_count); + const hasLikes = video.stats.like_count; + const likes = formatNumbers(video.stats.like_count); + const hasDislikes = video.stats.dislike_count > 0 && videoResponse.config.downloads.integrate_ryd; + const dislikes = formatNumbers(video.stats.dislike_count); + const config = videoResponse.config; + const cast = config.enable_cast; + + return ( + <> +
+
+ {!loading && ( + + )} + +
+ close-icon { + setSearchParams({}); + }} + /> + { + await updateWatchedState({ + id: videoId, + is_watched: status, + }); + }} + onDone={() => { + setRefresh(true); + }} + /> + {cast && ( + { + setRefresh(true); + }} + /> + )} + +
+ views icon + {views} + {hasLikes && ( + <> + | + thumbs-up + {likes} + + )} + {hasDislikes && ( + <> + | + thumbs-down + {dislikes} + + )} +
+ +
+

+ {channelName} +

+ + {playlists?.map(({ id, name }) => { + return ( +
+ {name} +
+ ); + })} +
+ + +

{name}

+ +
+
+
+ + ); +}; + +export default EmbeddableVideoPlayer; diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx new file mode 100644 index 00000000..568f9d58 --- /dev/null +++ b/frontend/src/components/Filterbar.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import iconSort from '/img/icon-sort.svg'; +import iconAdd from '/img/icon-add.svg'; +import iconSubstract from '/img/icon-substract.svg'; +import iconGridView from '/img/icon-gridview.svg'; +import iconListView from '/img/icon-listview.svg'; +import { SortByType, SortOrderType } from '../pages/Home'; +import { useUserConfigStore } from '../stores/UserConfigStore'; +import { ViewStyles } from '../configuration/constants/ViewStyle'; + +type FilterbarProps = { + hideToggleText: string; + viewStyleName: string; + setRefresh?: (status: boolean) => void; +}; + +const Filterbar = ({ hideToggleText, viewStyleName, setRefresh }: FilterbarProps) => { + const { userConfig, setPartialConfig } = useUserConfigStore(); + const [showHidden, setShowHidden] = useState(false); + const isGridView = userConfig.config.view_style_home === ViewStyles.grid; + + return ( +
+
+ {hideToggleText} +
+ { + setRefresh?.(true); + setPartialConfig({ hide_watched: !userConfig.config.hide_watched }); + }} + /> + + {userConfig.config.hide_watched ? ( + + ) : ( + + )} +
+
+ + {showHidden && ( +
+
+ Sort by: + + +
+
+ )} + +
+ {setShowHidden && ( + sort-icon { + setShowHidden?.(!showHidden); + }} + id="animate-icon" + /> + )} + + {userConfig.config.grid_items !== undefined && isGridView && ( +
+ {userConfig.config.grid_items < 7 && ( + { + setPartialConfig({ grid_items: userConfig.config.grid_items + 1 }); + }} + alt="grid plus row" + /> + )} + {userConfig.config.grid_items > 3 && ( + { + setPartialConfig({ grid_items: userConfig.config.grid_items - 1 }); + }} + alt="grid minus row" + /> + )} +
+ )} + { + setPartialConfig({ [viewStyleName]: 'grid' }); + }} + alt="grid view" + /> + { + setPartialConfig({ [viewStyleName]: 'list' }); + }} + alt="list view" + /> +
+
+ ); +}; + +export default Filterbar; diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 00000000..c3503ad5 --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,54 @@ +import { Link } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import { useAuthStore } from '../stores/AuthDataStore'; + +const Footer = () => { + const currentYear = new Date().getFullYear(); + const { auth } = useAuthStore(); + const version = auth?.version; + const taUpdate = auth?.ta_update; + + return ( +
+
+ © 2021 - {currentYear} + TubeArchivist + {version} + {taUpdate?.version && ( + <> + + {taUpdate.version} available + {taUpdate.is_breaking && Breaking Changes!} + {' '} + + + Release Page + {' '} + |{' '} + + + )} + + About |{' '} + + GitHub + {' '} + |{' '} + + Docker Hub + {' '} + |{' '} + + Discord + {' '} + | Reddit + +
+
+ ); +}; + +export default Footer; diff --git a/frontend/src/components/FormattedNumber.tsx b/frontend/src/components/FormattedNumber.tsx new file mode 100644 index 00000000..92d437d5 --- /dev/null +++ b/frontend/src/components/FormattedNumber.tsx @@ -0,0 +1,27 @@ +import formatNumbers from '../functions/formatNumbers'; + +type FormattedNumberProps = { + text: string; + number: number; +}; + +const FormattedNumber = ({ text, number }: FormattedNumberProps) => { + let options = {}; + + if (number >= 1000000) { + options = { + notation: 'compact', + compactDisplay: 'long', + }; + } + + return ( + <> +

+ {text} {formatNumbers(number, options)} +

+ + ); +}; + +export default FormattedNumber; diff --git a/frontend/src/components/GoogleCast.tsx b/frontend/src/components/GoogleCast.tsx new file mode 100644 index 00000000..f75175e4 --- /dev/null +++ b/frontend/src/components/GoogleCast.tsx @@ -0,0 +1,226 @@ +import { useCallback, useEffect, useState } from 'react'; +import { VideoType } from '../pages/Home'; +import updateWatchedState from '../api/actions/updateWatchedState'; +import updateVideoProgressById from '../api/actions/updateVideoProgressById'; +import watchedThreshold from '../functions/watchedThreshold'; + +const getURL = () => { + return window.location.origin; +}; + +function shiftCurrentTime(contentCurrentTime: number | undefined) { + console.log(contentCurrentTime); + if (contentCurrentTime === undefined) { + return 0; + } + + // Shift media back 3 seconds to prevent missing some of the content + if (contentCurrentTime > 5) { + return contentCurrentTime - 3; + } else { + return 0; + } +} + +async function castVideoProgress( + player: { + mediaInfo: { contentId: string | string[] }; + currentTime: number; + duration: number; + }, + video: VideoType | undefined, +) { + if (!video) { + console.log('castVideoProgress: Video to cast not found...'); + return; + } + const videoId = video.youtube_id; + + if (player.mediaInfo.contentId.includes(videoId)) { + const currentTime = player.currentTime; + const duration = player.duration; + + if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) { + // Check progress every 10 seconds or else progress is checked a few times a second + await updateVideoProgressById({ + youtubeId: videoId, + currentProgress: currentTime, + }); + + if (!video.player.watched) { + // Check if video is already marked as watched + if (watchedThreshold(currentTime, duration)) { + await updateWatchedState({ + id: videoId, + is_watched: true, + }); + } + } + } + } +} + +async function castVideoPaused( + player: { + currentTime: number; + duration: number; + mediaInfo: { contentId: string | string[] } | null; + }, + video: VideoType | undefined, +) { + if (!video) { + console.log('castVideoPaused: Video to cast not found...'); + return; + } + + const videoId = video?.youtube_id; + + const currentTime = player.currentTime; + const duration = player.duration; + + if (player.mediaInfo != null) { + if (player.mediaInfo.contentId.includes(videoId)) { + if (currentTime !== 0 && duration !== 0) { + await updateVideoProgressById({ + youtubeId: videoId, + currentProgress: currentTime, + }); + } + } + } +} + +type GoogleCastProps = { + video?: VideoType; + setRefresh?: () => void; +}; + +const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => { + const [isConnected, setIsConnected] = useState(false); + + const setup = useCallback(() => { + const cast = globalThis.cast; + const chrome = globalThis.chrome; + + cast.framework.CastContext.getInstance().setOptions({ + receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee. + autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, + }); + + const player = new cast.framework.RemotePlayer(); + + const playerController = new cast.framework.RemotePlayerController(player); + + // Add event listerner to check if a connection to a cast device is initiated + playerController.addEventListener( + cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, + function () { + setIsConnected(player.isConnected); + }, + ); + playerController.addEventListener( + cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED, + function () { + castVideoProgress(player, video); + }, + ); + playerController.addEventListener( + cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED, + function () { + castVideoPaused(player, video); + setRefresh?.(); + }, + ); + }, [setRefresh, video]); + + const startPlayback = useCallback(() => { + const chrome = globalThis.chrome; + const cast = globalThis.cast; + const castSession = cast.framework.CastContext.getInstance().getCurrentSession(); + + const mediaUrl = video?.media_url; + const vidThumbUrl = video?.vid_thumb_url; + const contentTitle = video?.title; + const contentId = `${getURL()}${mediaUrl}`; + const contentImage = `${getURL()}${vidThumbUrl}`; + const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded + + const contentSubtitles = []; + const videoSubtitles = video?.subtitles; // Array of subtitles + if (typeof videoSubtitles !== 'undefined') { + for (let i = 0; i < videoSubtitles.length; i++) { + const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT); + + subtitle.trackContentId = videoSubtitles[i].media_url; + subtitle.trackContentType = 'text/vtt'; + subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES; + subtitle.name = videoSubtitles[i].name; + subtitle.language = videoSubtitles[i].lang; + subtitle.customData = null; + + contentSubtitles.push(subtitle); + } + } + + const mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type + // mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER + mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo + mediaInfo.metadata.title = contentTitle?.replace('&', '&'); // Set the video title + mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail + // mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle(); + mediaInfo.tracks = contentSubtitles; + + const request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo. + // request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support. + request.currentTime = shiftCurrentTime(video?.player?.position); // Set video start position based on the browser video position + // request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player + + castSession.loadMedia(request).then( + function () { + console.log('media loaded'); + }, + function (error: { code: string }) { + console.log('Error', error, 'Error code: ' + error.code); + }, + ); // Send request to cast device + + // Do not add videoProgress?.position, this will cause loops! + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]); + + useEffect(() => { + // @ts-expect-error __onGCastApiAvailable is the google cast window hook ( source: https://developers.google.com/cast/docs/web_sender/integrate ) + window['__onGCastApiAvailable'] = function (isAvailable: boolean) { + if (isAvailable) { + setup(); + } + }; + }, [setup]); + + useEffect(() => { + console.log('isConnected', isConnected); + if (isConnected) { + startPlayback(); + } + }, [isConnected, startPlayback]); + + if (!video) { + return

Video for cast not found...

; + } + + return ( + <> + <> + + + {/* @ts-expect-error React does not know what to do with the google-cast-launcher, but it works. */} + + + + ); +}; + +export default GoogleCast; diff --git a/frontend/src/components/InputConfig.tsx b/frontend/src/components/InputConfig.tsx new file mode 100644 index 00000000..a378a5fb --- /dev/null +++ b/frontend/src/components/InputConfig.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react'; + +type InputTextProps = { + type: 'text' | 'number'; + name: string; + value: string | number | null; + setValue: + | React.Dispatch> + | React.Dispatch>; + oldValue: string | number | null; + updateCallback: (arg0: string, arg1: string | boolean | number | null) => void; +}; + +const InputConfig = ({ type, name, value, setValue, oldValue, updateCallback }: InputTextProps) => { + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + const handleChange = (e: React.ChangeEvent) => { + if (type === 'number') { + const inputValue = e.target.value; + + if (inputValue === '') { + setValue(null); + } else { + const numericValue = Number(inputValue); + (setValue as React.Dispatch>)(numericValue); + } + } else { + (setValue as React.Dispatch>)(e.target.value); + } + }; + + const handleUpdate = async (name: string, value: string | boolean | number | null) => { + setLoading(true); + setSuccess(false); + updateCallback(name, value); + setLoading(false); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + }; + + return ( +
+ +
+ {value !== null && value !== oldValue && ( + <> + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + )} + {oldValue !== null && } + {loading && ( + <> +
+
+
+ + )} + {success && } +
+
+ ); +}; + +export default InputConfig; diff --git a/frontend/src/components/Linkify.tsx b/frontend/src/components/Linkify.tsx new file mode 100644 index 00000000..a9d1d839 --- /dev/null +++ b/frontend/src/components/Linkify.tsx @@ -0,0 +1,34 @@ +import DOMPurify from 'dompurify'; + +type LinkifyProps = { + children: string; + ignoreLineBreak?: boolean; +}; + +// source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/ +const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => { + const isUrl = (word: string) => { + const urlPattern = /(https?:\/\/[^\s]+)/g; + return word.match(urlPattern); + }; + + const addMarkup = (word: string) => { + return isUrl(word) ? `${word}` : word; + }; + + let workingText = children; + + if (!ignoreLineBreak) { + workingText = workingText.replaceAll('\n', '
'); + } + + const words = workingText.split(' '); + + const formatedWords = words.map(w => addMarkup(w)); + + const html = DOMPurify.sanitize(formatedWords.join(' ')); + + return ; +}; + +export default Linkify; diff --git a/frontend/src/components/MoveVideoMenu.tsx b/frontend/src/components/MoveVideoMenu.tsx new file mode 100644 index 00000000..4853cc1b --- /dev/null +++ b/frontend/src/components/MoveVideoMenu.tsx @@ -0,0 +1,94 @@ +import iconClose from '/img/icon-close.svg'; +import iconArrowTop from '/img/icon-arrow-top.svg'; +import iconArrowUp from '/img/icon-arrow-up.svg'; +import iconArrowDown from '/img/icon-arrow-down.svg'; +import iconArrowBottom from '/img/icon-arrow-bottom.svg'; +import iconRemove from '/img/icon-remove.svg'; +import updateCustomPlaylist from '../api/actions/updateCustomPlaylist'; + +type MoveVideoMenuProps = { + playlistId?: string; + videoId: string; + setCloseMenu: (status: boolean) => void; + setRefresh: (status: boolean) => void; +}; + +const MoveVideoMenu = ({ playlistId, videoId, setCloseMenu, setRefresh }: MoveVideoMenuProps) => { + if (playlistId === undefined) { + return []; + } + + return ( + <> +
+ setCloseMenu(true)} + /> +

Move Video

+ + { + await updateCustomPlaylist('top', playlistId, videoId); + + setRefresh(true); + }} + src={iconArrowTop} + title="Move to top" + /> + { + await updateCustomPlaylist('up', playlistId, videoId); + + setRefresh(true); + }} + src={iconArrowUp} + title="Move up" + /> + { + await updateCustomPlaylist('down', playlistId, videoId); + + setRefresh(true); + }} + src={iconArrowDown} + title="Move down" + /> + + { + await updateCustomPlaylist('bottom', playlistId, videoId); + + setRefresh(true); + }} + src={iconArrowBottom} + title="Move to bottom" + /> + + { + await updateCustomPlaylist('remove', playlistId, videoId); + + setRefresh(true); + }} + src={iconRemove} + title="Remove from playlist" + /> +
+ + ); +}; + +export default MoveVideoMenu; diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx new file mode 100644 index 00000000..05fbe976 --- /dev/null +++ b/frontend/src/components/Navigation.tsx @@ -0,0 +1,52 @@ +import { Link, useNavigate } from 'react-router-dom'; +import iconSearch from '/img/icon-search.svg'; +import iconGear from '/img/icon-gear.svg'; +import iconExit from '/img/icon-exit.svg'; +import Routes from '../configuration/routes/RouteList'; +import NavigationItem from './NavigationItem'; +import logOut from '../api/actions/logOut'; +import useIsAdmin from '../functions/useIsAdmin'; + +const Navigation = () => { + const isAdmin = useIsAdmin(); + const navigate = useNavigate(); + const handleLogout = async (event: { preventDefault: () => void }) => { + event.preventDefault(); + await logOut(); + navigate(Routes.Login); + }; + + return ( +
+ +
+ +
+
+ + + + + {isAdmin && } +
+
+ + search-icon + + + gear-icon + + exit-icon +
+
+
+ ); +}; + +export default Navigation; diff --git a/frontend/src/components/NavigationItem.tsx b/frontend/src/components/NavigationItem.tsx new file mode 100644 index 00000000..8f8e8ceb --- /dev/null +++ b/frontend/src/components/NavigationItem.tsx @@ -0,0 +1,16 @@ +import { Link } from 'react-router-dom'; + +interface NavigationItemProps { + navigateTo: string; + label: string; +} + +const NavigationItem = ({ label, navigateTo }: NavigationItemProps) => { + return ( + +
{label}
+ + ); +}; + +export default NavigationItem; diff --git a/frontend/src/components/Notifications.tsx b/frontend/src/components/Notifications.tsx new file mode 100644 index 00000000..bfc107da --- /dev/null +++ b/frontend/src/components/Notifications.tsx @@ -0,0 +1,101 @@ +import { Fragment, useEffect, useState } from 'react'; +import loadNotifications, { NotificationPages } from '../api/loader/loadNotifications'; +import iconStop from '/img/icon-stop.svg'; +import stopTaskByName from '../api/actions/stopTaskByName'; + +type NotificationType = { + title: string; + group: string; + api_stop: boolean; + level: string; + id: string; + command: boolean | string; + messages: string[]; + progress: number; +}; + +type NotificationResponseType = NotificationType[]; + +type NotificationsProps = { + pageName: NotificationPages; + includeReindex?: boolean; + update?: boolean; + setShouldRefresh?: (isDone: boolean) => void; +}; + +const Notifications = ({ + pageName, + includeReindex = false, + update, + setShouldRefresh, +}: NotificationsProps) => { + const [notificationResponse, setNotificationResponse] = useState([]); + + useEffect(() => { + const intervalId = setInterval(async () => { + const notifications = await loadNotifications(pageName, includeReindex); + + if (notifications.length === 0) { + setNotificationResponse(notifications); + clearInterval(intervalId); + setShouldRefresh?.(true); + return; + } else { + setShouldRefresh?.(false); + } + + setNotificationResponse(notifications); + }, 500); + + return () => { + clearInterval(intervalId); + }; + }, [pageName, update, setShouldRefresh, includeReindex]); + + if (notificationResponse.length === 0) { + return []; + } + + return ( + <> + {notificationResponse.map(notification => ( +
+

{notification.title}

+

+ {notification.messages.map?.(message => { + return ( + + {message} +
+
+ ); + }) || notification.messages} +

+
+ {notification['api_stop'] && notification.command !== 'STOP' && ( + stop icon { + await stopTaskByName(notification.id); + }} + /> + )} +
+
+
+ ))} + + ); +}; + +export default Notifications; diff --git a/frontend/src/components/OverviewStats.tsx b/frontend/src/components/OverviewStats.tsx new file mode 100644 index 00000000..deced848 --- /dev/null +++ b/frontend/src/components/OverviewStats.tsx @@ -0,0 +1,53 @@ +import { Fragment } from 'react'; +import humanFileSize from '../functions/humanFileSize'; +import StatsInfoBoxItem from './StatsInfoBoxItem'; +import formatNumbers from '../functions/formatNumbers'; +import { VideoStatsType } from '../pages/SettingsDashboard'; + +type OverviewStatsProps = { + videoStats?: VideoStatsType; + useSI: boolean; +}; + +const OverviewStats = ({ videoStats, useSI }: OverviewStatsProps) => { + if (!videoStats) { + return

Loading...

; + } + + const cards = [ + { + title: 'All: ', + data: { + Videos: formatNumbers(videoStats?.doc_count || 0), + ['Media Size']: humanFileSize(videoStats?.media_size || 0, useSI), + Duration: videoStats?.duration_str, + }, + }, + { + title: 'Active: ', + data: { + Videos: formatNumbers(videoStats?.active_true?.doc_count || 0), + ['Media Size']: humanFileSize(videoStats?.active_true?.media_size || 0, useSI), + Duration: videoStats?.active_true?.duration_str || 'NA', + }, + }, + { + title: 'Inactive: ', + data: { + Videos: formatNumbers(videoStats?.active_false?.doc_count || 0), + ['Media Size']: humanFileSize(videoStats?.active_false?.media_size || 0, useSI), + Duration: videoStats?.active_false?.duration_str || 'NA', + }, + }, + ]; + + return cards.map(card => { + return ( + + + + ); + }); +}; + +export default OverviewStats; diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 00000000..979a92cd --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,213 @@ +import { Link } from 'react-router-dom'; +import { Fragment } from 'react/jsx-runtime'; +import Routes from '../configuration/routes/RouteList'; +import { useCallback, useEffect } from 'react'; + +export type PaginationType = { + page_size?: number; + page_from?: number; + prev_pages?: false | number[]; + current_page: number; + max_hits?: boolean; + params?: string; + last_page?: number; + next_pages?: []; + total_hits?: number; +}; + +interface Props { + pagination: PaginationType; + setPage: (page: number) => void; +} + +const Pagination = ({ pagination, setPage }: Props) => { + const { total_hits, params, prev_pages, current_page, next_pages, last_page, max_hits } = + pagination; + + const totalHits = Number(total_hits); + const currentPage = Number(current_page); + const hasMaxHits = Number(max_hits) > 0; + const lastPage = Number(last_page); + + let hasParams = false; + + if (params) { + hasParams = params.length > 0; + } + + const handleKeyEvent = useCallback( + (event: KeyboardEvent) => { + const { code } = event; + + if (code === 'ArrowRight') { + if (currentPage === 0 && totalHits > 1) { + setPage(2); + return; + } + + if (currentPage > lastPage) { + return; + } + + setPage(currentPage + 1); + } + + if (code === 'ArrowLeft') { + if (currentPage === 0) { + return; + } + + if (currentPage === 2) { + setPage(0); + return; + } + + setPage(currentPage - 1); + } + }, + [currentPage, lastPage, setPage, totalHits], + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyEvent); + + return () => { + window.removeEventListener('keydown', handleKeyEvent); + }; + }, [handleKeyEvent]); + + return ( +
+
+ {totalHits > 1 && ( + <> + {currentPage > 1 && ( + <> + { + event.preventDefault(); + setPage(0); + }} + > + First + {' '} + + )} + + {prev_pages !== false && + prev_pages && + prev_pages.map((page: number) => { + if (hasParams) { + return ( + + { + event.preventDefault(); + setPage(page); + }} + > + {page} + {' '} + + ); + } else { + return ( + + { + event.preventDefault(); + setPage(page); + }} + > + {page} + {' '} + + ); + } + })} + + {currentPage > 0 && {`< Page ${currentPage} `}} + + {next_pages && next_pages.length > 0 && ( + <> + {'>'}{' '} + {next_pages.map(page => { + if (hasParams) { + return ( + + { + event.preventDefault(); + setPage(page); + }} + > + {page} + {' '} + + ); + } else { + return ( + + { + event.preventDefault(); + setPage(page); + }} + > + {page} + {' '} + + ); + } + })} + + )} + + {lastPage > 0 && ( + <> + {hasParams && ( + { + event.preventDefault(); + setPage(lastPage || 0); + }} + > + {hasMaxHits && `Max (${lastPage})`} + {!hasMaxHits && `Last (${lastPage})`} + + )} + + {!hasParams && ( + { + event.preventDefault(); + setPage(lastPage || 0); + }} + > + {hasMaxHits && `Max (${lastPage})`} + {!hasMaxHits && `Last (${lastPage})`} + + )} + + )} + + )} +
+ ); +}; + +export default Pagination; diff --git a/frontend/src/components/PaginationDummy.tsx b/frontend/src/components/PaginationDummy.tsx new file mode 100644 index 00000000..bea1c0c9 --- /dev/null +++ b/frontend/src/components/PaginationDummy.tsx @@ -0,0 +1,9 @@ +const PaginationDummy = () => { + return ( +
+
{/** dummy pagination for consistent padding */}
+
+ ); +}; + +export default PaginationDummy; diff --git a/frontend/src/components/PlaylistList.tsx b/frontend/src/components/PlaylistList.tsx new file mode 100644 index 00000000..84c01525 --- /dev/null +++ b/frontend/src/components/PlaylistList.tsx @@ -0,0 +1,85 @@ +import { Link } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import { PlaylistType } from '../pages/Playlist'; +import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription'; +import formatDate from '../functions/formatDates'; +import Button from './Button'; +import PlaylistThumbnail from './PlaylistThumbnail'; +import { useUserConfigStore } from '../stores/UserConfigStore'; + +type PlaylistListProps = { + playlistList: PlaylistType[] | undefined; + setRefresh: (status: boolean) => void; +}; + +const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => { + const { userConfig } = useUserConfigStore(); + const viewLayout = userConfig.config.view_style_playlist; + + if (!playlistList || playlistList.length === 0) { + return

No playlists found.

; + } + + return ( + <> + {playlistList.map((playlist: PlaylistType) => { + return ( +
+
+ + + +
+
+ {playlist.playlist_type != 'custom' && ( + +

{playlist.playlist_channel}

+ + )} + + +

{playlist.playlist_name}

+ + +

Last refreshed: {formatDate(playlist.playlist_last_refresh)}

+ + {playlist.playlist_type != 'custom' && ( + <> + {playlist.playlist_subscribed && ( +
+
+ ); + })} + + ); +}; + +export default PlaylistList; diff --git a/frontend/src/components/PlaylistThumbnail.tsx b/frontend/src/components/PlaylistThumbnail.tsx new file mode 100644 index 00000000..2871e76b --- /dev/null +++ b/frontend/src/components/PlaylistThumbnail.tsx @@ -0,0 +1,22 @@ +import getApiUrl from '../configuration/getApiUrl'; +import defaultPlaylistThumbnail from '/img/default-playlist-thumb.jpg'; + +type PlaylistThumbnailProps = { + playlistId: string; + playlistThumbnail: string | undefined; +}; + +const PlaylistThumbnail = ({ playlistId, playlistThumbnail }: PlaylistThumbnailProps) => { + return ( + {`${playlistId}-thumbnail`} { + currentTarget.onerror = null; // prevents looping + currentTarget.src = defaultPlaylistThumbnail; + }} + /> + ); +}; + +export default PlaylistThumbnail; diff --git a/frontend/src/components/ScrollToTop.tsx b/frontend/src/components/ScrollToTop.tsx new file mode 100644 index 00000000..91c71768 --- /dev/null +++ b/frontend/src/components/ScrollToTop.tsx @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; + +const ScrollToTopOnNavigate = () => { + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); + + const page = searchParams.get('page'); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname, page]); + + return null; +}; + +export default ScrollToTopOnNavigate; diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx new file mode 100644 index 00000000..2e17792e --- /dev/null +++ b/frontend/src/components/SearchExampleQueries.tsx @@ -0,0 +1,116 @@ +const SearchExampleQueries = () => { + return ( +
+
+

Example queries

+
    +
  • + music video — basic search +
  • +
  • + video: active: + no — all videos deleted from YouTube +
  • +
  • + video: + learn javascript + channel: + corey schafer + active: + yes +
  • +
  • + channel: + linux + subscribed: + yes +
  • +
  • + playlist: + backend engineering + active: + yes + subscribed: + yes +
  • +
+
+
+

Keywords cheatsheet

+

+ For detailed usage check{' '} + + wiki + + . +

+
+
    +
  • + simple: (implied) — search in video titles, channel names and playlist + titles +
  • +
  • + video: — search in video titles, tags and category field +
      +
    • + channel: — channel name +
    • +
    • + active: + yes/no — whether the video is still active on + YouTube +
    • +
    +
  • +
  • + channel: — search in channel name and channel description +
      +
    • + subscribed: + yes/no — whether you are subscribed to the channel +
    • +
    • + active: + yes/no — whether the video is still active on + YouTube +
    • +
    +
  • +
  • + playlist: — search in channel name and channel description +
      +
    • + subscribed: + yes/no — whether you are subscribed to the channel +
    • +
    • + active: + yes/no — whether the video is still active on + YouTube +
    • +
    +
  • +
  • + full: — search in video subtitles +
      +
    • + lang: — subtitles language (use two-letter ISO country code, same as + the one from settings page) +
    • +
    • + source: + auto/userauto to search though + auto-generated subtitles only, or user to search through user-uploaded + subtitles only +
    • +
    +
  • +
+
+
+
+ ); +}; + +export default SearchExampleQueries; diff --git a/frontend/src/components/SettingsNavigation.tsx b/frontend/src/components/SettingsNavigation.tsx new file mode 100644 index 00000000..5475614b --- /dev/null +++ b/frontend/src/components/SettingsNavigation.tsx @@ -0,0 +1,36 @@ +import { Link } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import useIsAdmin from '../functions/useIsAdmin'; + +const SettingsNavigation = () => { + const isAdmin = useIsAdmin(); + + return ( + <> +
+ +

Dashboard

+ + +

User

+ + + {isAdmin && ( + <> + +

Application

+ + +

Scheduling

+ + +

Actions

+ + + )} +
+ + ); +}; + +export default SettingsNavigation; diff --git a/frontend/src/components/StatsInfoBoxItem.tsx b/frontend/src/components/StatsInfoBoxItem.tsx new file mode 100644 index 00000000..d25e15b4 --- /dev/null +++ b/frontend/src/components/StatsInfoBoxItem.tsx @@ -0,0 +1,26 @@ +type StatsInfoBoxItemType = { + title: string; + card: Record; +}; + +const StatsInfoBoxItem = ({ title, card }: StatsInfoBoxItemType) => { + return ( +
+

{title}

+ + + {Object.entries(card).map(([key, value]) => { + return ( + + + + + ); + })} + +
{key}: {value}
+
+ ); +}; + +export default StatsInfoBoxItem; diff --git a/frontend/src/components/SubtitleList.tsx b/frontend/src/components/SubtitleList.tsx new file mode 100644 index 00000000..bee073ff --- /dev/null +++ b/frontend/src/components/SubtitleList.tsx @@ -0,0 +1,92 @@ +import { Link, useSearchParams } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import iconPlay from '/img/icon-play.svg'; +import Linkify from './Linkify'; +import getApiUrl from '../configuration/getApiUrl'; + +type SubtitleListType = { + subtitle_index: number; + subtitle_line: string; + subtitle_start: string; + subtitle_fragment_id: string; + subtitle_end: string; + youtube_id: string; + title: string; + subtitle_channel: string; + subtitle_channel_id: string; + subtitle_last_refresh: number; + subtitle_lang: string; + subtitle_source: string; + vid_thumb_url: string; + _index: string; + _score: number; +}; + +type SubtitleListProps = { + subtitleList: SubtitleListType[] | undefined; +}; + +const stripNanoSecs = (time: string) => { + return time.split('.').shift(); +}; + +const SubtitleList = ({ subtitleList }: SubtitleListProps) => { + const [, setSearchParams] = useSearchParams(); + + if (!subtitleList || subtitleList.length === 0) { + return

No fulltext results found.

; + } + + return ( + <> + {subtitleList.map(subtitle => { + return ( +
+ { + setSearchParams({ + videoId: subtitle.youtube_id, + t: stripNanoSecs(subtitle.subtitle_start) || '00:00:00', + }); + }} + > +
+
+ video-thumb +
+
+ play-icon +
+
+
+
+
+ +

{subtitle.subtitle_channel}

+ + +

{subtitle.title}

+ +
+

+ {stripNanoSecs(subtitle.subtitle_start)} - {stripNanoSecs(subtitle.subtitle_end)} +

+

+ {subtitle.subtitle_line} +

+ Score: {subtitle._score} +
+
+ ); + })} + + ); +}; + +export default SubtitleList; diff --git a/frontend/src/components/ToggleConfig.tsx b/frontend/src/components/ToggleConfig.tsx new file mode 100644 index 00000000..adaf69a7 --- /dev/null +++ b/frontend/src/components/ToggleConfig.tsx @@ -0,0 +1,49 @@ +type ToggleConfigProps = { + name: string; + value: boolean; + text?: string; + updateCallback: (name: string, value: boolean) => void; + resetCallback?: (arg0: boolean) => void; + onValue?: boolean | string; + offValue?: boolean | string; +}; + +const ToggleConfig = ({ + name, + value, + text, + updateCallback, + resetCallback = undefined, +}: ToggleConfigProps) => { + return ( +
+ {text &&

{text}

} +
+ { + updateCallback(name, event.target.checked); + }} + /> + + {!value && ( + + )} + + {value && ( + + )} +
+ + {resetCallback !== undefined && } +
+ ); +}; + +export default ToggleConfig; diff --git a/frontend/src/components/VideoList.tsx b/frontend/src/components/VideoList.tsx new file mode 100644 index 00000000..440ed6bc --- /dev/null +++ b/frontend/src/components/VideoList.tsx @@ -0,0 +1,41 @@ +import { VideoType, ViewLayoutType } from '../pages/Home'; +import VideoListItem from './VideoListItem'; + +type VideoListProps = { + videoList: VideoType[] | undefined; + viewLayout: ViewLayoutType; + playlistId?: string; + showReorderButton?: boolean; + refreshVideoList: (refresh: boolean) => void; +}; + +const VideoList = ({ + videoList, + viewLayout, + playlistId, + showReorderButton = false, + refreshVideoList, +}: VideoListProps) => { + if (!videoList || videoList.length === 0) { + return

No videos found.

; + } + + return ( + <> + {videoList.map(video => { + return ( + + ); + })} + + ); +}; + +export default VideoList; diff --git a/frontend/src/components/VideoListItem.tsx b/frontend/src/components/VideoListItem.tsx new file mode 100644 index 00000000..f6deb65d --- /dev/null +++ b/frontend/src/components/VideoListItem.tsx @@ -0,0 +1,127 @@ +import { Link, useSearchParams } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import { VideoType, ViewLayoutType } from '../pages/Home'; +import iconPlay from '/img/icon-play.svg'; +import iconDotMenu from '/img/icon-dot-menu.svg'; +import defaultVideoThumb from '/img/default-video-thumb.jpg'; +import updateWatchedState from '../api/actions/updateWatchedState'; +import formatDate from '../functions/formatDates'; +import WatchedCheckBox from './WatchedCheckBox'; +import MoveVideoMenu from './MoveVideoMenu'; +import { useState } from 'react'; +import getApiUrl from '../configuration/getApiUrl'; + +type VideoListItemProps = { + video: VideoType; + viewLayout: ViewLayoutType; + playlistId?: string; + showReorderButton?: boolean; + refreshVideoList: (refresh: boolean) => void; +}; + +const VideoListItem = ({ + video, + viewLayout, + playlistId, + showReorderButton = false, + refreshVideoList, +}: VideoListItemProps) => { + const [, setSearchParams] = useSearchParams(); + + const [showReorderMenu, setShowReorderMenu] = useState(false); + + if (!video) { + return

No video found.

; + } + + return ( +
+ { + setSearchParams({ videoId: video.youtube_id }); + }} + > +
+
+ + video-thumb + + + + {video.player.progress && ( +
+ )} + {!video.player.progress && ( +
+ )} +
+
+ play-icon +
+
+
+
+
+ { + await updateWatchedState({ + id: video.youtube_id, + is_watched: status, + }); + }} + onDone={() => { + refreshVideoList(true); + }} + /> + + {formatDate(video.published)} | {video.player.duration_str} + +
+
+
+ +

{video.channel.channel_name}

+ + +

{video.title}

+ +
+ + {showReorderButton && !showReorderMenu && ( + dot-menu-icon { + setShowReorderMenu(true); + }} + /> + )} +
+ + {showReorderButton && showReorderMenu && ( + setShowReorderMenu(!status)} + setRefresh={refreshVideoList} + /> + )} +
+
+ ); +}; + +export default VideoListItem; diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx new file mode 100644 index 00000000..d217b118 --- /dev/null +++ b/frontend/src/components/VideoPlayer.tsx @@ -0,0 +1,259 @@ +import updateVideoProgressById from '../api/actions/updateVideoProgressById'; +import updateWatchedState from '../api/actions/updateWatchedState'; +import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video'; +import watchedThreshold from '../functions/watchedThreshold'; +import { Dispatch, Fragment, SetStateAction, SyntheticEvent, useState } from 'react'; +import formatTime from '../functions/formatTime'; +import { useSearchParams } from 'react-router-dom'; +import getApiUrl from '../configuration/getApiUrl'; + +type VideoTag = SyntheticEvent; + +export type SkippedSegmentType = { + from: number; + to: number; +}; + +export type SponsorSegmentsSkippedType = Record; + +type Subtitle = { + name: string; + source: string; + lang: string; + media_url: string; +}; + +type SubtitlesProp = { + subtitles: Subtitle[]; +}; + +const Subtitles = ({ subtitles }: SubtitlesProp) => { + return subtitles.map((subtitle: Subtitle) => { + let label = subtitle.name; + + if (subtitle.source === 'auto') { + label += ' - auto'; + } + + return ( + + ); + }); +}; + +const handleTimeUpdate = + ( + youtubeId: string, + duration: number, + watched: boolean, + sponsorBlock?: SponsorBlockType, + setSponsorSegmentSkipped?: Dispatch>, + ) => + async (videoTag: VideoTag) => { + const currentTime = Number(videoTag.currentTarget.currentTime); + + if (sponsorBlock && sponsorBlock.segments) { + sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => { + const [from, to] = segment.segment; + + if (currentTime >= from && currentTime <= from + 0.3) { + videoTag.currentTarget.currentTime = to; + + setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { + return { ...segments, [segment.UUID]: { from, to } }; + }); + } + + if (currentTime > to + 10) { + setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { + return { ...segments, [segment.UUID]: { from: 0, to: 0 } }; + }); + } + }); + } + + if (currentTime < 10) return; + if (Number((currentTime % 10).toFixed(1)) <= 0.2) { + // Check progress every 10 seconds or else progress is checked a few times a second + await updateVideoProgressById({ + youtubeId, + currentProgress: currentTime, + }); + + if (!watched) { + // Check if video is already marked as watched + if (watchedThreshold(currentTime, duration)) { + await updateWatchedState({ + id: youtubeId, + is_watched: true, + }); + } + } + } + }; + +type VideoPlayerProps = { + video: VideoResponseType; + sponsorBlock?: SponsorBlockType; + embed?: boolean; + autoplay?: boolean; + onVideoEnd?: () => void; +}; + +const VideoPlayer = ({ + video, + sponsorBlock, + embed, + autoplay = false, + onVideoEnd, +}: VideoPlayerProps) => { + const [searchParams] = useSearchParams(); + const searchParamVideoProgress = searchParams.get('t'); + + const [skippedSegments, setSkippedSegments] = useState({}); + + const videoId = video.data.youtube_id; + const videoUrl = video.data.media_url; + const videoThumbUrl = video.data.vid_thumb_url; + const watched = video.data.player.watched; + const duration = video.data.player.duration; + const videoSubtitles = video.data.subtitles; + + let videoSrcProgress = + Number(video.data.player?.position) > 0 ? Number(video.data.player?.position) : ''; + + if (searchParamVideoProgress !== null) { + videoSrcProgress = searchParamVideoProgress; + } + + const handleVideoEnd = + ( + youtubeId: string, + watched: boolean, + setSponsorSegmentSkipped?: Dispatch>, + ) => + async () => { + if (!watched) { + // Check if video is already marked as watched + await updateWatchedState({ id: youtubeId, is_watched: true }); + } + + setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { + const keys = Object.keys(segments); + + keys.forEach(uuid => { + segments[uuid] = { from: 0, to: 0 }; + }); + + return segments; + }); + + onVideoEnd?.(); + }; + + return ( + <> +
+
+ +
+
+
+ {sponsorBlock?.is_enabled && ( + <> + {sponsorBlock.segments.length == 0 && ( +

+ This video doesn't have any sponsor segments added. To add a segment go to{' '} + + this video on YouTube + {' '} + and add a segment using the{' '} + + SponsorBlock + {' '} + extension. +

+ )} + {sponsorBlock.has_unlocked && ( +

+ This video has unlocked sponsor segments. Go to{' '} + + this video on YouTube + {' '} + and vote on the segments using the{' '} + + SponsorBlock + {' '} + extension. +

+ )} + + {Object.values(skippedSegments).map(({ from, to }, index) => { + return ( + + {from !== 0 && to !== 0 && ( +

+ Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}. +

+ )} +
+ ); + })} + + )} +
+ + ); +}; + +export default VideoPlayer; diff --git a/frontend/src/components/VideoTypeStats.tsx b/frontend/src/components/VideoTypeStats.tsx new file mode 100644 index 00000000..1aacfecf --- /dev/null +++ b/frontend/src/components/VideoTypeStats.tsx @@ -0,0 +1,53 @@ +import { Fragment } from 'react'; +import humanFileSize from '../functions/humanFileSize'; +import StatsInfoBoxItem from './StatsInfoBoxItem'; +import formatNumbers from '../functions/formatNumbers'; +import { VideoStatsType } from '../pages/SettingsDashboard'; + +type VideoTypeStatsProps = { + videoStats?: VideoStatsType; + useSI: boolean; +}; + +const VideoTypeStats = ({ videoStats, useSI }: VideoTypeStatsProps) => { + if (!videoStats) { + return

Loading...

; + } + + const cards = [ + { + title: 'Regular Videos: ', + data: { + Videos: formatNumbers(videoStats?.type_videos?.doc_count || 0), + ['Media Size']: humanFileSize(videoStats?.type_videos?.media_size || 0, useSI), + Duration: videoStats?.type_videos?.duration_str || 'NA', + }, + }, + { + title: 'Shorts: ', + data: { + Videos: formatNumbers(videoStats?.type_shorts?.doc_count || 0), + ['Media Size']: humanFileSize(videoStats?.type_shorts?.media_size || 0, useSI), + Duration: videoStats?.type_shorts?.duration_str || 'NA', + }, + }, + { + title: 'Streams: ', + data: { + Videos: formatNumbers(videoStats?.type_streams?.doc_count || 0), + ['Media Size']: humanFileSize(videoStats?.type_streams?.media_size || 0, useSI), + Duration: videoStats?.type_streams?.duration_str || 'NA', + }, + }, + ]; + + return cards.map(card => { + return ( + + + + ); + }); +}; + +export default VideoTypeStats; diff --git a/frontend/src/components/WatchProgressStats.tsx b/frontend/src/components/WatchProgressStats.tsx new file mode 100644 index 00000000..afa43752 --- /dev/null +++ b/frontend/src/components/WatchProgressStats.tsx @@ -0,0 +1,65 @@ +import { Fragment } from 'react'; +import StatsInfoBoxItem from './StatsInfoBoxItem'; +import formatNumbers from '../functions/formatNumbers'; +import { WatchProgressStatsType } from '../pages/SettingsDashboard'; + +const formatProgress = (progress: number) => { + return (Number(progress) * 100).toFixed(2) ?? '0'; +}; + +const formatTitle = (title: string, progress: number, progressFormatted: string) => { + const hasProgess = !!progress; + + return hasProgess ? `${progressFormatted}% ${title}` : title; +}; + +type WatchProgressStatsProps = { + watchProgressStats?: WatchProgressStatsType; +}; + +const WatchProgressStats = ({ watchProgressStats }: WatchProgressStatsProps) => { + if (!watchProgressStats) { + return

Loading...

; + } + + const titleWatched = formatTitle( + 'Watched', + watchProgressStats?.watched?.progress, + formatProgress(watchProgressStats?.watched?.progress), + ); + + const titleUnwatched = formatTitle( + 'Unwatched', + watchProgressStats?.unwatched?.progress, + formatProgress(watchProgressStats?.unwatched?.progress), + ); + + const cards = [ + { + title: titleWatched, + data: { + Videos: formatNumbers(watchProgressStats?.watched?.items ?? 0), + Seconds: formatNumbers(watchProgressStats?.watched?.duration ?? 0), + Duration: watchProgressStats?.watched?.duration_str ?? '0s', + }, + }, + { + title: titleUnwatched, + data: { + Videos: formatNumbers(watchProgressStats?.unwatched?.items ?? 0), + Seconds: formatNumbers(watchProgressStats?.unwatched?.duration ?? 0), + Duration: watchProgressStats?.unwatched?.duration_str ?? '0s', + }, + }, + ]; + + return cards.map(card => { + return ( + + + + ); + }); +}; + +export default WatchProgressStats; diff --git a/frontend/src/components/WatchedCheckBox.tsx b/frontend/src/components/WatchedCheckBox.tsx new file mode 100644 index 00000000..490bb3c3 --- /dev/null +++ b/frontend/src/components/WatchedCheckBox.tsx @@ -0,0 +1,68 @@ +import iconUnseen from '/img/icon-unseen.svg'; +import iconSeen from '/img/icon-seen.svg'; +import { useEffect, useState } from 'react'; + +type WatchedCheckBoxProps = { + watched: boolean; + onClick?: (status: boolean) => void; + onDone?: (status: boolean) => void; +}; + +const WatchedCheckBox = ({ watched, onClick, onDone }: WatchedCheckBoxProps) => { + const [loading, setLoading] = useState(false); + const [state, setState] = useState(false); + + useEffect(() => { + if (loading) { + onClick?.(state); + + const timeout = setTimeout(() => { + onDone?.(state); + setLoading(false); + }, 1000); + + return () => { + clearTimeout(timeout); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading]); + + return ( + <> + {loading && ( + <> +
+
+
+ + )} + {!loading && watched && ( + seen-icon { + setState(false); + setLoading(true); + }} + /> + )} + {!loading && !watched && ( + unseen-icon { + setState(true); + setLoading(true); + }} + /> + )} + + ); +}; + +export default WatchedCheckBox; diff --git a/frontend/src/configuration/colours/components/Dark.tsx b/frontend/src/configuration/colours/components/Dark.tsx new file mode 100644 index 00000000..126f0f7d --- /dev/null +++ b/frontend/src/configuration/colours/components/Dark.tsx @@ -0,0 +1,7 @@ +import './css/dark.css'; + +const DarkStylesheet = () => { + return <>; +}; + +export default DarkStylesheet; diff --git a/frontend/src/configuration/colours/components/Light.tsx b/frontend/src/configuration/colours/components/Light.tsx new file mode 100644 index 00000000..cdbeeda2 --- /dev/null +++ b/frontend/src/configuration/colours/components/Light.tsx @@ -0,0 +1,7 @@ +import './css/light.css'; + +const LightStylesheet = () => { + return <>; +}; + +export default LightStylesheet; diff --git a/frontend/src/configuration/colours/components/Matrix.tsx b/frontend/src/configuration/colours/components/Matrix.tsx new file mode 100644 index 00000000..50db1d95 --- /dev/null +++ b/frontend/src/configuration/colours/components/Matrix.tsx @@ -0,0 +1,7 @@ +import './css/matrix.css'; + +const MatrixStylesheet = () => { + return <>; +}; + +export default MatrixStylesheet; diff --git a/frontend/src/configuration/colours/components/Midnight.tsx b/frontend/src/configuration/colours/components/Midnight.tsx new file mode 100644 index 00000000..cf777736 --- /dev/null +++ b/frontend/src/configuration/colours/components/Midnight.tsx @@ -0,0 +1,7 @@ +import './css/midnight.css'; + +const MidnightStylesheet = () => { + return <>; +}; + +export default MidnightStylesheet; diff --git a/frontend/src/configuration/colours/components/css/dark.css b/frontend/src/configuration/colours/components/css/dark.css new file mode 100644 index 00000000..1ac16ca6 --- /dev/null +++ b/frontend/src/configuration/colours/components/css/dark.css @@ -0,0 +1,16 @@ +:root { + --main-bg: #00202f; + --highlight-bg: #00293b; + --highlight-error: #990202; + --highlight-error-light: #c44343; + --highlight-bg-transparent: #00293baf; + --main-font: #eeeeee; + --accent-font-dark: #259485; + --accent-font-light: #97d4c8; + --img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%) + contrast(90%); + --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%) + contrast(120%); + --banner: url('/img/banner-tube-archivist-dark.png'); + --logo: url('/img/logo-tube-archivist-dark.png'); +} diff --git a/frontend/src/configuration/colours/components/css/light.css b/frontend/src/configuration/colours/components/css/light.css new file mode 100644 index 00000000..d5834c93 --- /dev/null +++ b/frontend/src/configuration/colours/components/css/light.css @@ -0,0 +1,16 @@ +:root { + --main-bg: #eeeeee; + --highlight-bg: #d9e0d9; + --highlight-error: #990202; + --highlight-error-light: #c44343; + --highlight-bg-transparent: #00293baf; + --main-font: #00202f; + --accent-font-dark: #259485; + --accent-font-light: #35b399; + --img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%) + contrast(90%); + --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%) + contrast(120%); + --banner: url('/img/banner-tube-archivist-light.png'); + --logo: url('/img/logo-tube-archivist-light.png'); +} diff --git a/frontend/src/configuration/colours/components/css/matrix.css b/frontend/src/configuration/colours/components/css/matrix.css new file mode 100644 index 00000000..9a8c47b4 --- /dev/null +++ b/frontend/src/configuration/colours/components/css/matrix.css @@ -0,0 +1,69 @@ +:root { + --main-bg: #000000; + --highlight-bg: #080808; + --highlight-error: #880000; + --highlight-error-light: #aa0000; + --highlight-bg-transparent: #0c0c0caf; + --main-font: #00aa00; + --accent-font-dark: #007700; + --accent-font-light: #00aa00; + --img-filter: brightness(0) saturate(100%) invert(45%) sepia(100%) saturate(3710%) + hue-rotate(96deg) brightness(100%) contrast(102%); + --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%) + contrast(120%); + --banner: url('/img/banner-tube-archivist-dark.png'); + --logo: url('/img/logo-tube-archivist-dark.png'); + --outline: 1px solid green; + --filter: hue-rotate(310deg); +} + +.settings-group { + outline: var(--outline); +} + +.info-box-item { + outline: var(--outline); +} + +.footer { + outline: var(--outline); +} + +.top-banner img { + filter: var(--filter); +} + +.icon-text { + outline: var(--outline); +} + +.video-item { + outline: var(--outline); +} + +.channel-banner { + outline: var(--outline); +} + +.description-box { + outline: var(--outline); +} + +.video-player { + outline: var(--outline); +} + +#notification { + outline: var(--outline); +} + +textarea { + background-color: var(--highlight-bg); + outline: var(--outline); + color: var(--main-font); +} + +input { + background-color: var(--highlight-bg); + color: var(--main-font); +} diff --git a/frontend/src/configuration/colours/components/css/midnight.css b/frontend/src/configuration/colours/components/css/midnight.css new file mode 100644 index 00000000..47f67f08 --- /dev/null +++ b/frontend/src/configuration/colours/components/css/midnight.css @@ -0,0 +1,16 @@ +:root { + --main-bg: #000000; + --highlight-bg: #0c0c0c; + --highlight-error: #220000; + --highlight-error-light: #330000; + --highlight-bg-transparent: #0c0c0caf; + --main-font: #888888; + --accent-font-dark: #555555; + --accent-font-light: #999999; + --img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%) + contrast(90%); + --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%) + contrast(120%); + --banner: url('/img/banner-tube-archivist-dark.png'); + --logo: url('/img/logo-tube-archivist-dark.png'); +} diff --git a/frontend/src/configuration/colours/useColours.ts b/frontend/src/configuration/colours/useColours.ts new file mode 100644 index 00000000..27b2acba --- /dev/null +++ b/frontend/src/configuration/colours/useColours.ts @@ -0,0 +1,32 @@ +import { useUserConfigStore } from '../../stores/UserConfigStore'; + +export const ColourConstant = { + Dark: 'dark.css', + Light: 'light.css', + Matrix: 'matrix.css', + Midnight: 'midnight.css', +}; + +const useColours = () => { + const { userConfig } = useUserConfigStore(); + const stylesheet = userConfig?.config.stylesheet; + + switch (stylesheet) { + case ColourConstant.Dark: + return import('./components/Dark'); + + case ColourConstant.Matrix: + return import('./components/Matrix'); + + case ColourConstant.Midnight: + return import('./components/Midnight'); + + case ColourConstant.Light: + return import('./components/Light'); + + default: + return import('./components/Dark'); + } +}; + +export default useColours; diff --git a/frontend/src/configuration/constants/ViewStyle.ts b/frontend/src/configuration/constants/ViewStyle.ts new file mode 100644 index 00000000..cafe55cc --- /dev/null +++ b/frontend/src/configuration/constants/ViewStyle.ts @@ -0,0 +1,11 @@ +export const ViewStyleNames = { + home: 'view_style_home', + channel: 'view_style_channel', + downloads: 'view_style_downloads', + playlist: 'view_style_playlist', +}; + +export const ViewStyles = { + grid: 'grid', + list: 'list', +}; diff --git a/frontend/src/configuration/defaultHeaders.ts b/frontend/src/configuration/defaultHeaders.ts new file mode 100644 index 00000000..169dc17e --- /dev/null +++ b/frontend/src/configuration/defaultHeaders.ts @@ -0,0 +1 @@ +export default { 'Content-Type': 'application/json' }; diff --git a/frontend/src/configuration/getApiUrl.ts b/frontend/src/configuration/getApiUrl.ts new file mode 100644 index 00000000..09ceffce --- /dev/null +++ b/frontend/src/configuration/getApiUrl.ts @@ -0,0 +1,14 @@ +const DEV_API_URL = 'http://localhost:8000'; +const PROD_API_URL = window.location.origin; + +const getApiUrl = () => { + let url = PROD_API_URL; + + if (import.meta.env.DEV) { + url = DEV_API_URL; + } + + return url; +}; + +export default getApiUrl; diff --git a/frontend/src/configuration/getFetchCredentials.ts b/frontend/src/configuration/getFetchCredentials.ts new file mode 100644 index 00000000..6f4346d4 --- /dev/null +++ b/frontend/src/configuration/getFetchCredentials.ts @@ -0,0 +1,9 @@ +import isDevEnvironment from '../functions/isDevEnvironment'; + +const getFetchCredentials = () => { + const isDevEnv = isDevEnvironment(); + + return isDevEnv ? 'include' : 'same-origin'; +}; + +export default getFetchCredentials; diff --git a/frontend/src/configuration/routes/RouteList.ts b/frontend/src/configuration/routes/RouteList.ts new file mode 100644 index 00000000..41f36558 --- /dev/null +++ b/frontend/src/configuration/routes/RouteList.ts @@ -0,0 +1,26 @@ +const Routes = { + Home: '/', + Channels: '/channel/', + Channel: (id: string) => `/channel/${id}`, + ChannelVideo: (id: string) => `/channel/${id}`, + ChannelStream: (id: string) => `/channel/${id}/streams/`, + ChannelShorts: (id: string) => `/channel/${id}/shorts/`, + ChannelPlaylist: (id: string) => `/channel/${id}/playlist/`, + ChannelAbout: (id: string) => `/channel/${id}/about/`, + Playlists: '/playlist/', + Playlist: (id: string) => `/playlist/${id}`, + Downloads: '/downloads/', + DownloadsByChannelId: (channelId: string) => `/downloads/?channel=${channelId}`, + Search: '/search/', + SettingsDashboard: '/settings/', + SettingsUser: '/settings/user/', + SettingsApplication: '/settings/application/', + SettingsScheduling: '/settings/scheduling/', + SettingsActions: '/settings/actions/', + Login: '/login/', + Video: (id: string) => `/video/${id}`, + VideoAtTimestamp: (id: string, timestamp: string) => `/video/${id}/?t=${timestamp}`, + About: '/about/', +}; + +export default Routes; diff --git a/frontend/src/functions/APIClient.ts b/frontend/src/functions/APIClient.ts new file mode 100644 index 00000000..21a5e734 --- /dev/null +++ b/frontend/src/functions/APIClient.ts @@ -0,0 +1,61 @@ +import defaultHeaders from '../configuration/defaultHeaders'; +import getApiUrl from '../configuration/getApiUrl'; +import getFetchCredentials from '../configuration/getFetchCredentials'; +import logOut from '../api/actions/logOut'; +import getCookie from './getCookie'; +import Routes from '../configuration/routes/RouteList'; + +export interface ApiClientOptions extends Omit { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + body?: Record | string; +} + +const APIClient = async ( + endpoint: string, + { method = 'GET', body, headers = {}, ...options }: ApiClientOptions = {}, +) => { + const apiUrl = getApiUrl(); + const csrfToken = getCookie('csrftoken'); + + const response = await fetch(`${apiUrl}${endpoint}`, { + method, + headers: { + ...defaultHeaders, + ...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}), + ...headers, + }, + credentials: getFetchCredentials(), + body: body ? JSON.stringify(body) : undefined, + ...options, + }); + + // Handle common errors + if (response.status === 401) { + logOut(); + window.location.href = Routes.Login; + throw new Error('Unauthorized: Redirecting to login.'); + } + + if (response.status === 403) { + logOut(); + window.location.href = Routes.Login; + throw new Error('Forbidden: Access denied.'); + } + + // Try parsing response data + let data; + try { + data = await response.json(); + } catch (error) { + data = null; + console.error(`error fetching data: ${error}`); + } + + if (!response.ok) { + throw new Error(data?.detail || 'An error occurred while processing the request.'); + } + + return data; +}; + +export default APIClient; diff --git a/frontend/src/functions/capitalizeFirstLetter.ts b/frontend/src/functions/capitalizeFirstLetter.ts new file mode 100644 index 00000000..cc4123df --- /dev/null +++ b/frontend/src/functions/capitalizeFirstLetter.ts @@ -0,0 +1,6 @@ +function capitalizeFirstLetter(word: string) { + // source: https://stackoverflow.com/a/1026087 + return word.charAt(0).toUpperCase() + word.slice(1); +} + +export default capitalizeFirstLetter; diff --git a/frontend/src/functions/convertStarRating.ts b/frontend/src/functions/convertStarRating.ts new file mode 100644 index 00000000..ba9b3985 --- /dev/null +++ b/frontend/src/functions/convertStarRating.ts @@ -0,0 +1,24 @@ +const convertStarRating = (averageRating: number | undefined) => { + if (!averageRating) { + return []; + } + + let rating = averageRating; + const stars: string[] = []; + + [1, 2, 3, 4, 5].forEach(() => { + if (rating >= 0.75) { + stars.push('full'); + } else if (0.25 < rating && rating < 0.75) { + stars.push('half'); + } else { + stars.push('empty'); + } + + rating -= 1; + }); + + return stars; +}; + +export default convertStarRating; diff --git a/frontend/src/functions/formatDates.ts b/frontend/src/functions/formatDates.ts new file mode 100644 index 00000000..dde6fb6d --- /dev/null +++ b/frontend/src/functions/formatDates.ts @@ -0,0 +1,6 @@ +const formatDate = (date: string | number | Date) => { + const dateObj = new Date(date); + return Intl.DateTimeFormat(navigator.language).format(dateObj); +}; + +export default formatDate; diff --git a/frontend/src/functions/formatNumbers.ts b/frontend/src/functions/formatNumbers.ts new file mode 100644 index 00000000..6fedbb05 --- /dev/null +++ b/frontend/src/functions/formatNumbers.ts @@ -0,0 +1,6 @@ +const formatNumbers = (number: number, options?: Intl.NumberFormatOptions) => { + const formatNumber = Intl.NumberFormat(navigator.language, options); + return formatNumber.format(number); +}; + +export default formatNumbers; diff --git a/frontend/src/functions/formatTime.ts b/frontend/src/functions/formatTime.ts new file mode 100644 index 00000000..1f10108e --- /dev/null +++ b/frontend/src/functions/formatTime.ts @@ -0,0 +1,32 @@ +// Formats times in seconds for frontend +function formatTime(time: number) { + const hoursUnformatted = time / 3600; + const minutesUnformatted = (time % 3600) / 60; + const secondsUnformatted = time % 60; + + const hoursFormatted = Math.trunc(hoursUnformatted); + let minutesFormatted; + + if (minutesUnformatted < 10 && hoursFormatted > 0) { + minutesFormatted = '0' + Math.trunc(minutesUnformatted); + } else { + minutesFormatted = Math.trunc(minutesUnformatted).toString(); + } + + let secondsFormatted; + if (secondsUnformatted < 10) { + secondsFormatted = '0' + Math.trunc(secondsUnformatted); + } else { + secondsFormatted = Math.trunc(secondsUnformatted).toString(); + } + + let timeUnformatted = ''; + if (hoursFormatted > 0) { + timeUnformatted = hoursFormatted + ':'; + } + + const timeFormatted = timeUnformatted.concat(minutesFormatted, ':', secondsFormatted); + return timeFormatted; +} + +export default formatTime; diff --git a/frontend/src/functions/getCookie.ts b/frontend/src/functions/getCookie.ts new file mode 100644 index 00000000..30549432 --- /dev/null +++ b/frontend/src/functions/getCookie.ts @@ -0,0 +1,21 @@ +// source: https://docs.djangoproject.com/en/4.0/ref/csrf/ +function getCookie(name: string) { + let cookieValue = null; + + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === name + '=') { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +export default getCookie; diff --git a/frontend/src/functions/humanFileSize.ts b/frontend/src/functions/humanFileSize.ts new file mode 100644 index 00000000..c53b8bc3 --- /dev/null +++ b/frontend/src/functions/humanFileSize.ts @@ -0,0 +1,35 @@ +/** + * Format bytes as human-readable text. + * + * @param bytes Number of bytes. + * @param si True to use metric (SI) units, aka powers of 1000. False to use + * binary (IEC), aka powers of 1024. + * @param dp Number of decimal places to display. + * + * @return Formatted string. + * + * + * source: https://stackoverflow.com/a/14919494 + */ +function humanFileSize(bytes: number, si = false, dp = 1) { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10 ** dp; + + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + return bytes.toFixed(dp) + ' ' + units[u]; +} + +export default humanFileSize; diff --git a/frontend/src/functions/isDevEnvironment.ts b/frontend/src/functions/isDevEnvironment.ts new file mode 100644 index 00000000..7f54e652 --- /dev/null +++ b/frontend/src/functions/isDevEnvironment.ts @@ -0,0 +1,7 @@ +const isDevEnvironment = () => { + const { DEV } = import.meta.env; + + return DEV; +}; + +export default isDevEnvironment; diff --git a/frontend/src/functions/useIsAdmin.ts b/frontend/src/functions/useIsAdmin.ts new file mode 100644 index 00000000..7fb5661b --- /dev/null +++ b/frontend/src/functions/useIsAdmin.ts @@ -0,0 +1,10 @@ +import { useUserConfigStore } from '../stores/UserConfigStore'; + +const useIsAdmin = () => { + const { userConfig } = useUserConfigStore(); + const isAdmin = userConfig?.is_staff || userConfig?.is_superuser; + + return isAdmin; +}; + +export default useIsAdmin; diff --git a/frontend/src/functions/watchedThreshold.ts b/frontend/src/functions/watchedThreshold.ts new file mode 100644 index 00000000..06b4c0bb --- /dev/null +++ b/frontend/src/functions/watchedThreshold.ts @@ -0,0 +1,21 @@ +function watchedThreshold(currentTime: number, duration: number) { + let watched = false; + + if (duration <= 1800) { + // If video is less than 30 min + if (currentTime / duration >= 0.9) { + // Mark as watched at 90% + watched = true; + } + } else { + // If video is more than 30 min + if (currentTime >= duration - 120) { + // Mark as watched if there is two minutes left + watched = true; + } + } + + return watched; +} + +export default watchedThreshold; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..f3f37e89 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,144 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom/client'; +import { createBrowserRouter, redirect, RouterProvider } from 'react-router-dom'; +import Routes from './configuration/routes/RouteList'; +import './style.css'; +import Base from './pages/Base'; +import About from './pages/About'; +import Channels from './pages/Channels'; +import ErrorPage from './pages/ErrorPage'; +import Home from './pages/Home'; +import Playlist from './pages/Playlist'; +import Playlists from './pages/Playlists'; +import Search from './pages/Search'; +import SettingsDashboard from './pages/SettingsDashboard'; +import Video from './pages/Video'; +import Login from './pages/Login'; +import SettingsActions from './pages/SettingsActions'; +import SettingsApplication from './pages/SettingsApplication'; +import SettingsScheduling from './pages/SettingsScheduling'; +import SettingsUser from './pages/SettingsUser'; +import loadUserMeConfig from './api/loader/loadUserConfig'; +import loadAuth from './api/loader/loadAuth'; +import ChannelBase from './pages/ChannelBase'; +import ChannelVideo from './pages/ChannelVideo'; +import ChannelPlaylist from './pages/ChannelPlaylist'; +import ChannelAbout from './pages/ChannelAbout'; +import Download from './pages/Download'; + +const router = createBrowserRouter( + [ + { + path: Routes.Home, + loader: async () => { + console.log('------------ after reload'); + + const auth = await loadAuth(); + if (auth.status === 403) { + return redirect(Routes.Login); + } + + const authData = await auth.json(); + + const userConfig = await loadUserMeConfig(); + + return { userConfig, auth: authData }; + }, + element: , + errorElement: , + children: [ + { + index: true, + element: , + }, + { + path: Routes.Video(':videoId'), + element:
+ + + + ); +}; + +export default ChannelAbout; diff --git a/frontend/src/pages/ChannelBase.tsx b/frontend/src/pages/ChannelBase.tsx new file mode 100644 index 00000000..2b98d116 --- /dev/null +++ b/frontend/src/pages/ChannelBase.tsx @@ -0,0 +1,105 @@ +import { Link, Outlet, useOutletContext, useParams } from 'react-router-dom'; +import Routes from '../configuration/routes/RouteList'; +import { ChannelType } from './Channels'; +import { ConfigType } from './Home'; +import { OutletContextType } from './Base'; +import Notifications from '../components/Notifications'; +import { useEffect, useState } from 'react'; +import ChannelBanner from '../components/ChannelBanner'; +import loadChannelNav, { ChannelNavResponseType } from '../api/loader/loadChannelNav'; +import loadChannelById from '../api/loader/loadChannelById'; +import useIsAdmin from '../functions/useIsAdmin'; + +type ChannelParams = { + channelId: string; +}; + +export type ChannelResponseType = { + data: ChannelType; + config: ConfigType; +}; + +const ChannelBase = () => { + const { channelId } = useParams() as ChannelParams; + const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; + const isAdmin = useIsAdmin(); + + const [channelResponse, setChannelResponse] = useState(); + const [channelNav, setChannelNav] = useState(); + const [startNotification, setStartNotification] = useState(false); + + const channel = channelResponse?.data; + const { has_streams, has_shorts, has_playlists, has_pending } = channelNav || {}; + + useEffect(() => { + (async () => { + const channelNavResponse = await loadChannelNav(channelId); + const channelResponse = await loadChannelById(channelId); + + setChannelResponse(channelResponse); + setChannelNav(channelNavResponse); + })(); + }, [channelId]); + + if (!channelId) { + return []; + } + + return ( + <> +
+
+ + + +
+
+ +

Videos

+ + {has_streams && ( + +

Streams

+ + )} + {has_shorts && ( + +

Shorts

+ + )} + {has_playlists && ( + +

Playlists

+ + )} + +

About

+ + {has_pending && isAdmin && ( + +

Downloads

+ + )} +
+ + setStartNotification(false)} + /> +
+ + + + ); +}; + +export default ChannelBase; diff --git a/frontend/src/pages/ChannelPlaylist.tsx b/frontend/src/pages/ChannelPlaylist.tsx new file mode 100644 index 00000000..3f4846a1 --- /dev/null +++ b/frontend/src/pages/ChannelPlaylist.tsx @@ -0,0 +1,104 @@ +import { useOutletContext, useParams } from 'react-router-dom'; +import Notifications from '../components/Notifications'; +import PlaylistList from '../components/PlaylistList'; +import { useEffect, useState } from 'react'; +import { OutletContextType } from './Base'; +import Pagination from '../components/Pagination'; +import ScrollToTopOnNavigate from '../components/ScrollToTop'; +import loadPlaylistList from '../api/loader/loadPlaylistList'; +import { PlaylistsResponseType } from './Playlists'; +import iconGridView from '/img/icon-gridview.svg'; +import iconListView from '/img/icon-listview.svg'; +import { useUserConfigStore } from '../stores/UserConfigStore'; + +const ChannelPlaylist = () => { + const { channelId } = useParams(); + const { userConfig, setPartialConfig } = useUserConfigStore(); + const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; + + const [refreshPlaylists, setRefreshPlaylists] = useState(false); + + const [playlistsResponse, setPlaylistsResponse] = useState(); + + const playlistList = playlistsResponse?.data; + const pagination = playlistsResponse?.paginate; + + const view = userConfig.config.view_style_playlist; + const showSubedOnly = userConfig.config.show_subed_only; + + useEffect(() => { + (async () => { + const playlists = await loadPlaylistList({ + channel: channelId, + subscribed: showSubedOnly, + }); + + setPlaylistsResponse(playlists); + setRefreshPlaylists(false); + })(); + }, [channelId, refreshPlaylists, showSubedOnly, currentPage]); + + return ( + <> + TA | Channel: Playlists + +
+ + +
+
+ Show subscribed only: +
+ { + setPartialConfig({ show_subed_only: !showSubedOnly }); + setRefreshPlaylists(true); + }} + type="checkbox" + /> + {!showSubedOnly && ( + + )} + {showSubedOnly && ( + + )} +
+
+
+ { + setPartialConfig({ view_style_playlist: 'grid' }); + }} + alt="grid view" + /> + { + setPartialConfig({ view_style_playlist: 'list' }); + }} + alt="list view" + /> +
+
+
+ +
+
+ +
+
+ +
+ {pagination && } +
+ + ); +}; + +export default ChannelPlaylist; diff --git a/frontend/src/pages/ChannelVideo.tsx b/frontend/src/pages/ChannelVideo.tsx new file mode 100644 index 00000000..a5f20fd4 --- /dev/null +++ b/frontend/src/pages/ChannelVideo.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react'; +import { Link, useOutletContext, useParams, useSearchParams } from 'react-router-dom'; +import { OutletContextType } from './Base'; +import VideoList from '../components/VideoList'; +import Routes from '../configuration/routes/RouteList'; +import Pagination from '../components/Pagination'; +import Filterbar from '../components/Filterbar'; +import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle'; +import ChannelOverview from '../components/ChannelOverview'; +import loadChannelById from '../api/loader/loadChannelById'; +import { ChannelResponseType } from './ChannelBase'; +import ScrollToTopOnNavigate from '../components/ScrollToTop'; +import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer'; +import updateWatchedState from '../api/actions/updateWatchedState'; +import Button from '../components/Button'; +import loadVideoListByFilter, { + VideoListByFilterResponseType, + VideoTypes, +} from '../api/loader/loadVideoListByPage'; +import loadChannelAggs, { ChannelAggsType } from '../api/loader/loadChannelAggs'; +import humanFileSize from '../functions/humanFileSize'; +import { useUserConfigStore } from '../stores/UserConfigStore'; + +type ChannelParams = { + channelId: string; +}; + +type ChannelVideoProps = { + videoType: VideoTypes; +}; + +const ChannelVideo = ({ videoType }: ChannelVideoProps) => { + const { channelId } = useParams() as ChannelParams; + const { userConfig } = useUserConfigStore(); + const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; + const [searchParams] = useSearchParams(); + const videoId = searchParams.get('videoId'); + + const [refresh, setRefresh] = useState(false); + + const [channelResponse, setChannelResponse] = useState(); + const [videoResponse, setVideoReponse] = useState(); + const [videoAggsResponse, setVideoAggsResponse] = useState(); + + const channel = channelResponse?.data; + const videoList = videoResponse?.data; + const pagination = videoResponse?.paginate; + + const hasVideos = videoResponse?.data?.length !== 0; + const showEmbeddedVideo = videoId !== null; + + const view = userConfig.config.view_style_home; + const isGridView = view === ViewStyles.grid; + const gridView = isGridView ? `boxed-${userConfig.config.grid_items}` : ''; + const gridViewGrid = isGridView ? `grid-${userConfig.config.grid_items}` : ''; + + useEffect(() => { + (async () => { + const channelResponse = await loadChannelById(channelId); + const videos = await loadVideoListByFilter({ + channel: channelId, + page: currentPage, + watch: userConfig.config.hide_watched ? 'unwatched' : undefined, + sort: userConfig.config.sort_by, + order: userConfig.config.sort_order, + type: videoType, + }); + const channelAggs = await loadChannelAggs(channelId); + + setChannelResponse(channelResponse); + setVideoReponse(videos); + setVideoAggsResponse(channelAggs); + setRefresh(false); + })(); + }, [ + refresh, + userConfig.config.sort_by, + userConfig.config.sort_order, + userConfig.config.hide_watched, + currentPage, + channelId, + pagination?.current_page, + videoType, + ]); + + if (!channel) { + return ( +
+
+

Channel {channelId} not found!

+
+ ); + } + + return ( + <> + {`TA | Channel: ${channel.channel_name}`} + +
+
+ +
+ {videoAggsResponse && ( + <> +

+ {videoAggsResponse.total_items.value} videos{' '} + |{' '} + {videoAggsResponse.total_duration.value_str} playback{' '} + | Total size{' '} + {humanFileSize(videoAggsResponse.total_size.value, true)} +

+
+
+ + )} +
+
+
+
+ +
+ {showEmbeddedVideo && } +
+
+ {!hasVideos && ( + <> +

No videos found...

+

+ Try going to the downloads page to start the scan + and download tasks. +

+ + )} + + +
+
+ {pagination && ( +
+ +
+ )} + + ); +}; + +export default ChannelVideo; diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx new file mode 100644 index 00000000..7d9b9f99 --- /dev/null +++ b/frontend/src/pages/Channels.tsx @@ -0,0 +1,207 @@ +import { useOutletContext } from 'react-router-dom'; +import loadChannelList from '../api/loader/loadChannelList'; +import iconGridView from '/img/icon-gridview.svg'; +import iconListView from '/img/icon-listview.svg'; +import iconAdd from '/img/icon-add.svg'; +import { useEffect, useState } from 'react'; +import Pagination, { PaginationType } from '../components/Pagination'; +import { ConfigType } from './Home'; +import { OutletContextType } from './Base'; +import ChannelList from '../components/ChannelList'; +import ScrollToTopOnNavigate from '../components/ScrollToTop'; +import Notifications from '../components/Notifications'; +import Button from '../components/Button'; +import updateBulkChannelSubscriptions from '../api/actions/updateBulkChannelSubscriptions'; +import useIsAdmin from '../functions/useIsAdmin'; +import { useUserConfigStore } from '../stores/UserConfigStore'; + +type ChannelOverwritesType = { + download_format?: string; + autodelete_days?: number; + index_playlists?: boolean; + integrate_sponsorblock?: boolean | null; + subscriptions_channel_size?: number; + subscriptions_live_channel_size?: number; + subscriptions_shorts_channel_size?: number; +}; + +export type ChannelType = { + channel_active: boolean; + channel_banner_url: string; + channel_description: string; + channel_id: string; + channel_last_refresh: string; + channel_name: string; + channel_overwrites?: ChannelOverwritesType; + channel_subs: number; + channel_subscribed: boolean; + channel_tags: string[]; + channel_thumb_url: string; + channel_tvart_url: string; + channel_views: number; +}; + +type ChannelsListResponse = { + data: ChannelType[]; + paginate: PaginationType; + config?: ConfigType; +}; + +const Channels = () => { + const { userConfig, setPartialConfig } = useUserConfigStore(); + const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; + const isAdmin = useIsAdmin(); + + const [channelListResponse, setChannelListResponse] = useState(); + const [showAddForm, setShowAddForm] = useState(false); + const [refresh, setRefresh] = useState(true); + const [showNotification, setShowNotification] = useState(false); + const [channelsToSubscribeTo, setChannelsToSubscribeTo] = useState(''); + + const channels = channelListResponse?.data; + const pagination = channelListResponse?.paginate; + const channelCount = pagination?.total_hits; + const hasChannels = channels?.length !== 0; + + useEffect(() => { + (async () => { + if (refresh) { + const channelListResponse = await loadChannelList( + currentPage, + userConfig.config.show_subed_only, + ); + + setChannelListResponse(channelListResponse); + setShowNotification(false); + setRefresh(false); + } + })(); + }, [refresh, userConfig.config.show_subed_only, currentPage, pagination?.current_page]); + + return ( + <> + TA | Channels + +
+
+
+

Channels

+
+ {isAdmin && ( +
+ { + setShowAddForm(!showAddForm); + }} + src={iconAdd} + alt="add-icon" + title="Subscribe to Channels" + /> + + {showAddForm && ( +
+
+ + +