diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 54bc7311..bda94f5d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,5 @@ Thank you for taking the time to improve this project. Please take a look at the [How to make a Pull Request](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#how-to-make-a-pull-request) section to help get your contribution merged. +Last updated: 2026-02-06 + You can delete this text before submitting. diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 01190c31..b7c2595d 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.13' - name: Cache pip uses: actions/cache@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9311fd8..a01e0578 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,9 +71,12 @@ Focus for the foreseeable future is on improving and building on existing functi This is a quick checklist to help streamline the process: -- NEW: Make your PR against the [develop branch](https://github.com/tubearchivist/tubearchivist/tree/develop). That's where all active development happens. This simplifies the later merging into *master*, minimizes any conflicts and usually allows for easy and convenient *fast-forward* merging. +- Make your PR against the [develop branch](https://github.com/tubearchivist/tubearchivist/tree/develop). That's where all active development happens. This simplifies the later merging into *master*, minimizes any conflicts and usually allows for easy and convenient *fast-forward* merging. - Show off your progress, even if not yet complete, by creating a [draft](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests) PR first and switch it as *ready* when you are ready. - Make sure all your code is linted and formatted correctly, see below. +- Using a LLM or any tools based on LLM output is a mixed bag: + - If your PR introduces code generated by a LLM but it's not noticeable because the quality is indistinguishable from an experienced dev, then that is fine. + - If your PR is obviously just vibe coded, the PR will likely get rejected without a proper review. Limited maintainer time is better used elsewhere. ### Documentation Changes @@ -134,6 +137,7 @@ The documentation available at [docs.tubearchivist.com](https://docs.tubearchivi This codebase is set up to be developed natively outside of docker as well as in a docker container. Developing outside of a docker container can be convenient, as IDE and hot reload usually works out of the box. But testing inside of a container is still essential, as there are subtle differences, especially when working with the filesystem and networking between containers. Note: +- This project doesn't look for contributions to this to cover additional dev setup environments from new contributors. If you are a regular contributor and you see ways to improve this, please reach out on Discord first. - Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers in the container. ### Native Instruction diff --git a/Dockerfile b/Dockerfile index bd9bd791..293f2446 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ # multi stage to build tube archivist # build python wheel, download and extract ffmpeg, copy into final image -FROM node:lts-alpine AS npm-builder +FROM node:22.13.0-alpine AS npm-builder COPY frontend/package.json frontend/package-lock.json / RUN npm i -FROM node:lts-alpine AS node-builder +FROM node:22.13.0-alpine AS node-builder # RUN npm config set registry https://registry.npmjs.org/ @@ -18,17 +18,19 @@ RUN npm run build:deploy WORKDIR / # First stage to build python wheel -FROM python:3.11.13-slim-bookworm AS builder +FROM python:3.13.11-slim-trixie AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential gcc libldap2-dev libsasl2-dev libssl-dev git # install requirements COPY ./backend/requirements.txt /requirements.txt -RUN pip install --user -r requirements.txt +COPY ./backend/requirements.plugins.txt /requirements.plugins.txt +RUN pip install --user -r /requirements.txt \ + && python -m pip install --target /opt/yt_plugins/bgutil -r /requirements.plugins.txt # build ffmpeg -FROM python:3.11.13-slim-bookworm AS ffmpeg-builder +FROM python:3.13.11-slim-trixie AS ffmpeg-builder ARG TARGETPLATFORM @@ -36,7 +38,7 @@ COPY docker_assets/ffmpeg_download.py ffmpeg_download.py RUN python ffmpeg_download.py $TARGETPLATFORM # build final image -FROM python:3.11.13-slim-bookworm AS tubearchivist +FROM python:3.13.11-slim-trixie AS tubearchivist ARG INSTALL_DEBUG @@ -46,6 +48,7 @@ COPY --from=denoland/deno:bin /deno /usr/local/bin/deno # copy build requirements COPY --from=builder /root/.local /root/.local +COPY --from=builder /opt/yt_plugins /opt/yt_plugins ENV PATH=/root/.local/bin:$PATH # copy ffmpeg diff --git a/README.md b/README.md index 79a9db20..dc0f2af5 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ This is your time to shine, [read this](https://github.com/tubearchivist/tubearc * [tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State](https://github.com/tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State) Mark videos watched in Plex (through streaming not manually) through Tautulli back to TubeArchivist * [Dhs92/delete_shorts](https://github.com/Dhs92/delete_shorts): A script to delete ALL YouTube Shorts from TubeArchivist * [arisenfromtheashes/TA_DVR](https://github.com/arisenfromtheashes/TA_DVR): Scripts to assist in using Tube Archivist like a DVR +* [WreckingBANG/Self.Tube](https://codeberg.org/WreckingBANG/Self.Tube): Client app for Android and Linux phones written in Flutter. ## Donate diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json index 8014aef8..79c257bf 100644 --- a/backend/appsettings/index_mapping.json +++ b/backend/appsettings/index_mapping.json @@ -12,6 +12,28 @@ "channel_id": { "type": "keyword" }, + "channel_active": { + "type": "boolean" + }, + "channel_banner_url": { + "type": "keyword", + "index": false + }, + "channel_thumb_url": { + "type": "keyword", + "index": false + }, + "channel_tvart_url": { + "type": "keyword", + "index": false + }, + "channel_description": { + "type": "text" + }, + "channel_last_refresh": { + "type": "date", + "format": "epoch_second" + }, "channel_name": { "type": "text", "analyzer": "english", @@ -28,38 +50,6 @@ } } }, - "channel_banner_url": { - "type": "keyword", - "index": false - }, - "channel_tvart_url": { - "type": "keyword", - "index": false - }, - "channel_thumb_url": { - "type": "keyword", - "index": false - }, - "channel_description": { - "type": "text" - }, - "channel_last_refresh": { - "type": "date", - "format": "epoch_second" - }, - "channel_tags": { - "type": "text", - "analyzer": "english", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "channel_tabs": { - "type": "keyword" - }, "channel_overwrites": { "properties": { "download_format": { @@ -84,6 +74,25 @@ "type": "long" } } + }, + "channel_subs": { + "type": "long" + }, + "channel_subscribed": { + "type": "boolean" + }, + "channel_tags": { + "type": "text", + "analyzer": "english", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "channel_tabs": { + "type": "keyword" } }, "expected_set": { @@ -101,23 +110,45 @@ { "index_name": "video", "expected_map": { - "vid_thumb_url": { - "type": "text", - "index": false + "active": { + "type": "boolean" }, - "vid_thumb_base64": { + "category": { "type": "text", - "index": false - }, - "date_downloaded": { - "type": "date", - "format": "epoch_second" + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } }, "channel": { "properties": { "channel_id": { "type": "keyword" }, + "channel_active": { + "type": "boolean" + }, + "channel_banner_url": { + "type": "keyword", + "index": false + }, + "channel_thumb_url": { + "type": "keyword", + "index": false + }, + "channel_tvart_url": { + "type": "keyword", + "index": false + }, + "channel_description": { + "type": "text" + }, + "channel_last_refresh": { + "type": "date", + "format": "epoch_second" + }, "channel_name": { "type": "text", "analyzer": "english", @@ -134,38 +165,6 @@ } } }, - "channel_banner_url": { - "type": "keyword", - "index": false - }, - "channel_tvart_url": { - "type": "keyword", - "index": false - }, - "channel_thumb_url": { - "type": "keyword", - "index": false - }, - "channel_description": { - "type": "text" - }, - "channel_last_refresh": { - "type": "date", - "format": "epoch_second" - }, - "channel_tags": { - "type": "text", - "analyzer": "english", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "channel_tabs": { - "type": "keyword" - }, "channel_overwrites": { "properties": { "download_format": { @@ -190,87 +189,44 @@ "type": "long" } } - } - } - }, - "description": { - "type": "text" - }, - "media_url": { - "type": "keyword", - "index": false - }, - "media_size": { - "type": "long" - }, - "tags": { - "type": "text", - "analyzer": "english", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "title": { - "type": "text", - "analyzer": "english", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256, - "normalizer": "to_lower" }, - "search_as_you_type": { - "type": "search_as_you_type", - "doc_values": false, - "max_shingle_size": 3 - } - } - }, - "vid_last_refresh": { - "type": "date", - "format": "epoch_second" - }, - "youtube_id": { - "type": "keyword" - }, - "vid_type": { - "type": "keyword" - }, - "published": { - "type": "date", - "format": "epoch_second||strict_date_optional_time" - }, - "playlist": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256, - "normalizer": "to_lower" + "channel_subs": { + "type": "long" + }, + "channel_subscribed": { + "type": "boolean" + }, + "channel_tags": { + "type": "text", + "analyzer": "english", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "channel_tabs": { + "type": "keyword" } } }, "comment_count": { "type": "long" }, - "stats": { - "properties": { - "average_rating": { - "type": "float" - }, - "dislike_count": { - "type": "long" - }, - "like_count": { - "type": "long" - }, - "view_count": { - "type": "long" - } - } + "date_downloaded": { + "type": "date", + "format": "epoch_second" + }, + "description": { + "type": "text" + }, + "media_size": { + "type": "long" + }, + "media_url": { + "type": "keyword", + "index": false }, "player": { "properties": { @@ -290,68 +246,32 @@ } } }, - "subtitles": { - "properties": { - "ext": { + "playlist": { + "type": "text", + "fields": { + "keyword": { "type": "keyword", - "index": false - }, - "lang": { - "type": "keyword", - "index": false - }, - "media_url": { - "type": "keyword", - "index": false - }, - "name": { - "type": "keyword" - }, - "source": { - "type": "keyword" - }, - "url": { - "type": "keyword", - "index": false + "ignore_above": 256, + "normalizer": "to_lower" } } }, - "streams": { - "properties": { - "type": { - "type": "keyword", - "index": false - }, - "index": { - "type": "short", - "index": false - }, - "codec": { - "type": "text" - }, - "width": { - "type": "short" - }, - "height": { - "type": "short" - }, - "bitrate": { - "type": "integer" - } - } + "published": { + "type": "date", + "format": "epoch_second||strict_date_optional_time" }, "sponsorblock": { "properties": { - "last_refresh": { - "type": "date", - "format": "epoch_second" - }, "has_unlocked": { "type": "boolean" }, "is_enabled": { "type": "boolean" }, + "last_refresh": { + "type": "date", + "format": "epoch_second" + }, "segments": { "properties": { "UUID": { @@ -387,6 +307,112 @@ } } } + }, + "stats": { + "properties": { + "average_rating": { + "type": "float" + }, + "dislike_count": { + "type": "long" + }, + "like_count": { + "type": "long" + }, + "view_count": { + "type": "long" + } + } + }, + "streams": { + "properties": { + "bitrate": { + "type": "integer" + }, + "codec": { + "type": "text" + }, + "height": { + "type": "short" + }, + "index": { + "type": "short", + "index": false + }, + "type": { + "type": "keyword", + "index": false + }, + "width": { + "type": "short" + } + } + }, + "subtitles": { + "properties": { + "ext": { + "type": "keyword", + "index": false + }, + "lang": { + "type": "keyword", + "index": false + }, + "media_url": { + "type": "keyword", + "index": false + }, + "name": { + "type": "keyword" + }, + "source": { + "type": "keyword" + }, + "url": { + "type": "keyword", + "index": false + } + } + }, + "tags": { + "type": "text", + "analyzer": "english", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "title": { + "type": "text", + "analyzer": "english", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256, + "normalizer": "to_lower" + }, + "search_as_you_type": { + "type": "search_as_you_type", + "doc_values": false, + "max_shingle_size": 3 + } + } + }, + "vid_last_refresh": { + "type": "date", + "format": "epoch_second" + }, + "vid_thumb_url": { + "type": "text", + "index": false + }, + "vid_type": { + "type": "keyword" + }, + "youtube_id": { + "type": "keyword" } }, "expected_set": { @@ -404,13 +430,15 @@ { "index_name": "download", "expected_map": { - "timestamp": { - "type": "date", - "format": "epoch_second" + "auto_start": { + "type": "boolean" }, "channel_id": { "type": "keyword" }, + "channel_indexed": { + "type": "boolean" + }, "channel_name": { "type": "text", "fields": { @@ -421,9 +449,23 @@ } } }, + "duration": { + "type": "keyword" + }, + "message": { + "type": "text" + }, + "published": { + "type": "date", + "format": "epoch_second||strict_date_optional_time" + }, "status": { "type": "keyword" }, + "timestamp": { + "type": "date", + "format": "epoch_second" + }, "title": { "type": "text", "fields": { @@ -434,24 +476,14 @@ } } }, - "published": { - "type": "date", - "format": "epoch_second||strict_date_optional_time" - }, "vid_thumb_url": { "type": "keyword" }, - "youtube_id": { - "type": "keyword" - }, "vid_type": { "type": "keyword" }, - "auto_start": { - "type": "boolean" - }, - "message": { - "type": "text" + "youtube_id": { + "type": "keyword" } }, "expected_set": { @@ -469,37 +501,9 @@ { "index_name": "playlist", "expected_map": { - "playlist_id": { - "type": "keyword" - }, - "playlist_description": { - "type": "text" - }, - "playlist_subscribed": { - "type": "boolean" - }, - "playlist_type": { - "type": "keyword" - }, "playlist_active": { "type": "boolean" }, - "playlist_name": { - "type": "text", - "analyzer": "english", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256, - "normalizer": "to_lower" - }, - "search_as_you_type": { - "type": "search_as_you_type", - "doc_values": false, - "max_shingle_size": 3 - } - } - }, "playlist_channel": { "type": "text", "fields": { @@ -513,15 +517,8 @@ "playlist_channel_id": { "type": "keyword" }, - "playlist_thumbnail": { - "type": "keyword" - }, - "playlist_last_refresh": { - "type": "date", - "format": "epoch_second" - }, - "playlist_sort_order": { - "type": "keyword" + "playlist_description": { + "type": "text" }, "playlist_entries": { "properties": { @@ -557,6 +554,41 @@ "type": "keyword" } } + }, + "playlist_id": { + "type": "keyword" + }, + "playlist_last_refresh": { + "type": "date", + "format": "epoch_second" + }, + "playlist_name": { + "type": "text", + "analyzer": "english", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256, + "normalizer": "to_lower" + }, + "search_as_you_type": { + "type": "search_as_you_type", + "doc_values": false, + "max_shingle_size": 3 + } + } + }, + "playlist_sort_order": { + "type": "keyword" + }, + "playlist_subscribed": { + "type": "boolean" + }, + "playlist_thumbnail": { + "type": "keyword" + }, + "playlist_type": { + "type": "keyword" } }, "expected_set": { @@ -574,22 +606,6 @@ { "index_name": "subtitle", "expected_map": { - "youtube_id": { - "type": "keyword" - }, - "title": { - "type": "text", - "fields": { - "keyword": { - "type": "keyword", - "ignore_above": 256, - "normalizer": "to_lower" - } - } - }, - "subtitle_fragment_id": { - "type": "keyword" - }, "subtitle_channel": { "type": "text", "fields": { @@ -603,15 +619,11 @@ "subtitle_channel_id": { "type": "keyword" }, - "subtitle_start": { - "type": "text" - }, "subtitle_end": { "type": "text" }, - "subtitle_last_refresh": { - "type": "date", - "format": "epoch_second" + "subtitle_fragment_id": { + "type": "keyword" }, "subtitle_index": { "type": "long" @@ -619,12 +631,32 @@ "subtitle_lang": { "type": "keyword" }, - "subtitle_source": { - "type": "keyword" + "subtitle_last_refresh": { + "type": "date", + "format": "epoch_second" }, "subtitle_line": { "type": "text", "analyzer": "english" + }, + "subtitle_source": { + "type": "keyword" + }, + "subtitle_start": { + "type": "text" + }, + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256, + "normalizer": "to_lower" + } + } + }, + "youtube_id": { + "type": "keyword" } }, "expected_set": { @@ -642,37 +674,11 @@ { "index_name": "comment", "expected_map": { - "youtube_id": { - "type": "keyword" - }, - "comment_last_refresh": { - "type": "date", - "format": "epoch_second" - }, "comment_channel_id": { "type": "keyword" }, "comment_comments": { "properties": { - "comment_id": { - "type": "keyword" - }, - "comment_text": { - "type": "text" - }, - "comment_timestamp": { - "type": "date", - "format": "epoch_second" - }, - "comment_time_text": { - "type": "text" - }, - "comment_likecount": { - "type": "long" - }, - "comment_is_favorited": { - "type": "boolean" - }, "comment_author": { "type": "text", "fields": { @@ -686,16 +692,42 @@ "comment_author_id": { "type": "keyword" }, - "comment_author_thumbnail": { - "type": "keyword" - }, "comment_author_is_uploader": { "type": "boolean" }, + "comment_author_thumbnail": { + "type": "keyword" + }, + "comment_id": { + "type": "keyword" + }, + "comment_is_favorited": { + "type": "boolean" + }, + "comment_likecount": { + "type": "long" + }, "comment_parent": { "type": "keyword" + }, + "comment_text": { + "type": "text" + }, + "comment_time_text": { + "type": "text" + }, + "comment_timestamp": { + "type": "date", + "format": "epoch_second" } } + }, + "comment_last_refresh": { + "type": "date", + "format": "epoch_second" + }, + "youtube_id": { + "type": "keyword" } }, "expected_set": { diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py index 70564465..f26bc73d 100644 --- a/backend/appsettings/serializers.py +++ b/backend/appsettings/serializers.py @@ -44,7 +44,6 @@ class AppConfigDownloadsSerializer( format = serializers.CharField(allow_null=True) format_sort = serializers.CharField(allow_null=True) add_metadata = serializers.BooleanField() - add_thumbnail = serializers.BooleanField() subtitle = serializers.CharField(allow_null=True) subtitle_source = serializers.ChoiceField( choices=["auto", "user"], allow_null=True @@ -55,7 +54,7 @@ class AppConfigDownloadsSerializer( choices=["top", "new"], allow_null=True ) cookie_import = serializers.BooleanField() - potoken = serializers.BooleanField() + pot_provider_url = serializers.CharField(allow_null=True) throttledratelimit = serializers.IntegerField(allow_null=True) extractor_lang = serializers.CharField(allow_null=True) integrate_ryd = serializers.BooleanField() @@ -94,10 +93,18 @@ class CookieUpdateSerializer(serializers.Serializer): cookie = serializers.CharField() -class PoTokenSerializer(serializers.Serializer): - """serialize PO token""" +class RescanFileSystemConfig(serializers.Serializer): + """serialize rescan filesystem config""" - potoken = serializers.CharField() + ignore_error = serializers.BooleanField() + prefer_local = serializers.BooleanField() + + +class ManualImportConfig(serializers.Serializer): + """serialize for manual import task""" + + ignore_error = serializers.BooleanField() + prefer_local = serializers.BooleanField() class SnapshotItemSerializer(serializers.Serializer): diff --git a/backend/appsettings/serializers_mb.py b/backend/appsettings/serializers_mb.py index 18bbba4f..330fc96d 100644 --- a/backend/appsettings/serializers_mb.py +++ b/backend/appsettings/serializers_mb.py @@ -29,3 +29,4 @@ class MembershipProfileSerializer(serializers.Serializer): sponsor_tier = SponsortierSerializer() subscription_count = serializers.IntegerField() subscription_is_max = serializers.BooleanField() + is_connected = serializers.BooleanField() diff --git a/backend/appsettings/src/backup.py b/backend/appsettings/src/backup.py index d977e521..c4569196 100644 --- a/backend/appsettings/src/backup.py +++ b/backend/appsettings/src/backup.py @@ -7,6 +7,7 @@ Functionality: import json import os +import re import zipfile from datetime import datetime @@ -19,7 +20,10 @@ from task.models import CustomPeriodicTask class ElasticBackup: """dump index to nd-json files for later bulk import""" - INDEX_SPLIT = ["comment"] + INDEX_SIZE_CONF = { + "comment": 100, + "subtitle": 10000, + } CACHE_DIR = EnvironmentSettings.CACHE_DIR BACKUP_DIR = os.path.join(CACHE_DIR, "backup") @@ -60,10 +64,11 @@ class ElasticBackup: "callback": BackupCallback, "task": self.task, "total": self._get_total(index_name), + "timeout": 30, } - if index_name in self.INDEX_SPLIT: - paginate_kwargs.update({"size": 200}) + if size_overwrite := self.INDEX_SIZE_CONF.get(index_name): + paginate_kwargs.update({"size": size_overwrite}) paginate = IndexPaginate(f"ta_{index_name}", **paginate_kwargs) _ = paginate.get_results() @@ -98,8 +103,7 @@ class ElasticBackup: def post_bulk_restore(self, file_name): """send bulk to es""" - file_path = os.path.join(self.CACHE_DIR, file_name) - with open(file_path, "r", encoding="utf-8") as f: + with open(file_name, "r", encoding="utf-8") as f: data = f.read() if not data.strip(): @@ -156,6 +160,7 @@ class ElasticBackup: call reset from ElasticIndexWrap first to start blank """ zip_content = self._unpack_zip_backup(filename) + zip_content.sort() self._restore_json_files(zip_content) def _unpack_zip_backup(self, filename): @@ -252,7 +257,7 @@ class BackupCallback: for document in self.source: document_id = document["_id"] - es_index = document["_index"] + es_index = re.sub(r"_v\d+$", "", document["_index"]) # remove _v action = {"index": {"_index": es_index, "_id": document_id}} source = document["_source"] bulk_list.append(json.dumps(action)) diff --git a/backend/appsettings/src/config.py b/backend/appsettings/src/config.py index 2fb9247a..8bd81579 100644 --- a/backend/appsettings/src/config.py +++ b/backend/appsettings/src/config.py @@ -35,14 +35,13 @@ class DownloadsConfigType(TypedDict): 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 + pot_provider_url: str | None throttledratelimit: int | None extractor_lang: str | None integrate_ryd: bool @@ -85,14 +84,13 @@ class AppConfig: "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, + "pot_provider_url": None, "throttledratelimit": None, "extractor_lang": None, "integrate_ryd": False, @@ -179,6 +177,34 @@ class AppConfig: return updated + def clear_old_keys(self) -> list[str]: + """clear old unused keys""" + cleared = [] + for key in list(self.config.keys()): + if key not in self.CONFIG_DEFAULTS: + # complete key removed + value = self.config.pop(key) + cleared.append(str({key: value})) + continue + + expected_keys = set( + self.CONFIG_DEFAULTS[key].keys() # type: ignore + ) + is_keys = set(list(self.config[key].keys())) + + for to_delete in is_keys - expected_keys: + self.config[key].pop(to_delete) + cleared.append(f"{key}.{to_delete}") + + if not cleared: + return [] + + response, status_code = ElasticWrap(self.ES_PATH).post(self.config) + if not status_code == 200: + print(response) + + return cleared + class ReleaseVersion: """compare local version with remote version""" diff --git a/backend/appsettings/src/filesystem.py b/backend/appsettings/src/filesystem.py index 1cf02cb0..13324331 100644 --- a/backend/appsettings/src/filesystem.py +++ b/backend/appsettings/src/filesystem.py @@ -5,11 +5,13 @@ Functionality: import os +from appsettings.src.config import AppConfig from common.src.env_settings import EnvironmentSettings from common.src.es_connect import IndexPaginate -from common.src.helper import ignore_filelist -from video.src.comments import CommentList +from common.src.helper import ignore_filelist, rand_sleep +from video.src.comments import Comments from video.src.index import YoutubeVideo, index_new_video +from video.src.meta_embed import IndexFromEmbed class Scanner: @@ -17,19 +19,27 @@ class Scanner: VIDEOS: str = EnvironmentSettings.MEDIA_DIR - def __init__(self, task=False) -> None: + def __init__( + self, + task=False, + ignore_error: bool = False, + prefer_local: bool = False, + ) -> None: self.task = task - self.to_delete: set[str] = set() - self.to_index: set[str] = set() + self.ignore_error = ignore_error + self.prefer_local = prefer_local + self.config = None + self.to_delete: set[tuple[str, str]] = set() + self.to_index: set[tuple[str, str]] = set() def scan(self) -> None: """scan the filesystem""" - downloaded: set[str] = self._get_downloaded() - indexed: set[str] = self._get_indexed() + downloaded = self._get_downloaded() + indexed = self._get_indexed() self.to_index = downloaded - indexed self.to_delete = indexed - downloaded - def _get_downloaded(self) -> set[str]: + def _get_downloaded(self) -> set[tuple[str, str]]: """get downloaded ids""" if self.task: self.task.send_progress(["Scan your filesystem for videos."]) @@ -39,28 +49,40 @@ class Scanner: for channel in channels: folder = os.path.join(self.VIDEOS, channel) files = ignore_filelist(os.listdir(folder)) - downloaded.update({i.split(".")[0] for i in files}) + downloaded.update( + { + (i.split(".")[0], f"{channel}/{i}") + for i in files + if i.endswith(".mp4") + } + ) return downloaded - def _get_indexed(self) -> set: + def _get_indexed(self) -> set[tuple[str, str]]: """get all indexed ids""" if self.task: self.task.send_progress(["Get all videos indexed."]) - data = {"query": {"match_all": {}}, "_source": ["youtube_id"]} + data = { + "query": {"match_all": {}}, + "_source": ["youtube_id", "media_url"], + } response = IndexPaginate("ta_video", data).get_results() - return {i["youtube_id"] for i in response} + return {(i["youtube_id"], i["media_url"]) for i in response} def apply(self) -> None: """apply all changes""" + if not self.config: + self.config = AppConfig().config + self.delete() self.index() def delete(self) -> None: """delete videos from index""" if not self.to_delete: - print("nothing to delete") + print("[scanner] nothing to delete") return if self.task: @@ -68,26 +90,69 @@ class Scanner: [f"Remove {len(self.to_delete)} videos from index."] ) - for youtube_id in self.to_delete: + for youtube_id, _ in self.to_delete: YoutubeVideo(youtube_id).delete_media_file() def index(self) -> None: """index new""" if not self.to_index: - print("nothing to index") + print("[scanner] nothing to index") return total = len(self.to_index) - for idx, youtube_id in enumerate(self.to_index): - if self.task: - self.task.send_progress( - message_lines=[ - f"Index missing video {youtube_id}, {idx + 1}/{total}" - ], - progress=(idx + 1) / total, - ) - index_new_video(youtube_id) + for idx, (youtube_id, media_url) in enumerate(self.to_index): + self._notify(total, youtube_id, idx) - comment_list = CommentList(task=self.task) - comment_list.add(video_ids=list(self.to_index)) - comment_list.index() + file_path = os.path.join(self.VIDEOS, media_url) + if self.prefer_local: + # try index from embed + json_data = IndexFromEmbed( + file_path, use_user_conf=True, config=self.config + ).run_index() + if json_data: + continue + + try: + # try index from remote + json_data = index_new_video(youtube_id) + Comments(youtube_id).build_json(upload=True) + YoutubeVideo(youtube_id).embed_metadata() + rand_sleep(self.config) + except ValueError as err: + # fallback from index from embed + json_data = IndexFromEmbed( + file_path, use_user_conf=True, config=self.config + ).run_index() + if json_data: + continue + + if self.ignore_error: + self._notify_error(youtube_id) + rand_sleep(self.config) + continue + + raise ValueError from err + + def _notify(self, total, youtube_id, idx): + """send notification""" + if not self.task: + return + + self.task.send_progress( + message_lines=[ + f"Index missing video {youtube_id}, {idx + 1}/{total}" + ], + progress=(idx + 1) / total, + ) + + def _notify_error(self, youtube_id): + """notify error""" + if not self.task: + return + + message = f"[scanner] Failed to index {youtube_id}, no metadata" + print(f"[scanner] {message}") + self.task.send_progress( + message_lines=[message, "Continue..."], + level="error", + ) diff --git a/backend/appsettings/src/index_setup.py b/backend/appsettings/src/index_setup.py index ae1a1438..c8e680e5 100644 --- a/backend/appsettings/src/index_setup.py +++ b/backend/appsettings/src/index_setup.py @@ -5,88 +5,117 @@ functionality: - backup and restore metadata """ +from enum import Enum, auto + 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 +from deepdiff import DeepDiff +from deepdiff.model import DiffLevel +from django.conf import settings + + +class MappingAction(Enum): + """index action options""" + + NOOP = auto() + PUT_MAPPING = auto() + REINDEX = auto() class ElasticIndex: """interact with a single index""" + REINDEX_KEYS = { + "type", + "analyzer", + "search_analyzer", + "normalizer", + "index", + "doc_values", + "norms", + "ignore_above", + "enabled", + "format", + } + def __init__(self, index_name, expected_map=False, expected_set=False): self.index_name = index_name self.expected_map = expected_map self.expected_set = expected_set self.exists, self.details = self.index_exists() + @property + def index_namespace(self) -> str: + """namespaced index""" + return f"ta_{self.index_name}" + def index_exists(self): """check if index already exists and return mapping if it does""" - response, status_code = ElasticWrap(f"ta_{self.index_name}").get() + response, status_code = ElasticWrap(self.index_namespace).get() exists = status_code == 200 - details = response.get(f"ta_{self.index_name}", False) + if not exists: + return False, False + + index_key = f"{self.index_namespace}" + current_version = self.get_current_version() + if current_version: + index_key += f"_v{current_version}" + + details = response.get(index_key, False) return exists, details - def validate(self): + def get_current_version(self) -> None | int: + """get current version from aliases of index""" + response, _ = ElasticWrap(f"{self.index_namespace}/_alias").get() + if not response: + raise ValueError("failed to fetch aliases: ", response) + + alias_name = list(response.keys()) + if not alias_name: + return None + + version_str = alias_name[0].lstrip(f"{self.index_namespace}_v") + if not version_str: + # is initial version + return None + + if not version_str.isdigit(): + raise ValueError("unexpected version_str: ", version_str) + + return int(version_str) + + def validate(self) -> tuple[MappingAction, set[str]]: """ check if all expected mappings and settings match returns True when rebuild is needed """ - - if self.expected_map or self.expected_map == {}: - rebuild = self.validate_mappings() - if rebuild: - return rebuild + mapping_diff = self._get_mapping_diff() + removed_fields = self._get_fields_to_delete(diff=mapping_diff) if self.expected_set: - rebuild = self.validate_settings() - if rebuild: - return rebuild + settings_diff = self._validate_settings() + if settings_diff: + # treat settings diff as full reindex + return MappingAction.REINDEX, removed_fields - return False + if self.expected_map or self.expected_map == {}: + action = self._classify_mapping_diff(diff=mapping_diff) + return action, removed_fields - def validate_mappings(self): - """check if all mappings are as expected""" - now_map = self.details["mappings"].get("properties", {}) + return MappingAction.NOOP, removed_fields - for key, value in self.expected_map.items(): - # nested - if list(value.keys()) == ["properties"]: - for key_n, value_n in value["properties"].items(): - if key not in now_map: - print(f"detected mapping change: {key_n}, {value_n}") - return True - if key_n not in now_map[key]["properties"].keys(): - print(f"detected mapping change: {key_n}, {value_n}") - return True - if not value_n == now_map[key]["properties"][key_n]: - print(f"detected mapping change: {key_n}, {value_n}") - return True - - continue - - # not nested - if key not in now_map.keys(): - print(f"detected mapping change: {key}, {value}") - return True - if not value == now_map[key]: - print(f"detected mapping change: {key}, {value}") - return True - - # simple doc store - if self.expected_map == {} and now_map != self.expected_map: - return True - - return False - - def validate_settings(self): + def _validate_settings(self): """check if all settings are as expected""" now_set = self.details["settings"]["index"] for key, value in self.expected_set.items(): + if key == "number_of_replicas": + continue if key not in now_set.keys(): print(key, value) return True @@ -97,44 +126,107 @@ class ElasticIndex: return False - def rebuild_index(self): + def _get_mapping_diff(self) -> DeepDiff: + """check if all mappings are as expected""" + now_map = self.details.get("mappings", {}).get("properties", {}) + diff = DeepDiff( + now_map, + self.expected_map, + ignore_order=True, + report_repetition=True, + view="tree", + ) + if diff: + print(f"[{self.index_namespace}] detected mapping change") + if settings.DEBUG: + print(f"[{self.index_namespace}] mapping change: {diff}") + + return diff + + def _classify_mapping_diff(self, diff: DeepDiff) -> MappingAction: + """use diff to detect what to do""" + if not diff: + return MappingAction.NOOP + + if diff.get("type_changes"): + # always incompatible, needs reindex + return MappingAction.REINDEX + + added = diff.get("dictionary_item_added", []) + reindex_from_added = self._needs_reindex(diff_items=added) + if reindex_from_added: + return MappingAction.REINDEX + + removed = diff.get("dictionary_item_removed", []) + reindex_from_removed = self._needs_reindex(diff_items=removed) + if reindex_from_removed: + return MappingAction.REINDEX + + changed = diff.get("values_changed", []) + reindex_from_changed = self._needs_reindex(diff_items=changed) + if reindex_from_changed: + return MappingAction.REINDEX + + if added or changed: + return MappingAction.PUT_MAPPING + + return MappingAction.NOOP + + def _needs_reindex(self, diff_items: list[DiffLevel]) -> bool: + """check if diff has fields that need reindex""" + for item in diff_items: + path = item.path(output_format="list") + if not path: + return False + + if path[-1] in self.REINDEX_KEYS: + return True + + return False + + def _get_fields_to_delete(self, diff: DeepDiff) -> set[str]: + """fields to remove during next reindex""" + removed_fields = set() + for item in diff.get("dictionary_item_removed", []): + value = item.t1 or {} + is_field_definition = "type" in value or "properties" in value + if not is_field_definition: + continue + + path = item.path(output_format="list") + removed_fields.add(".".join(path)) + + return removed_fields + + def rebuild_index(self, removed_fields: set[str]): """rebuild with new mapping""" - print(f"applying new mappings to index ta_{self.index_name}...") - self.create_blank(for_backup=True) - self.reindex("backup") - self.delete_index(backup=False) - self.create_blank() - self.reindex("restore") - self.delete_index() + print(f"[{self.index_namespace}] applying new mappings to index") + current_version = self.get_current_version() - def reindex(self, method): - """create on elastic search""" - if method == "backup": - source = f"ta_{self.index_name}" - destination = f"ta_{self.index_name}_backup" - 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'") + new_version = current_version + 1 if current_version else 2 - data = {"source": {"index": source}, "dest": {"index": destination}} - _, _ = ElasticWrap("_reindex?refresh=true").post(data=data) + self.create_blank(new_version=new_version) + self.reindex(new_version=new_version, removed_fields=removed_fields) + self.delete_index(by_version=current_version) + self.create_alias(new_version=new_version) - def delete_index(self, backup=True): + def delete_index(self, by_version: int | None): """delete index passed as argument""" - path = f"ta_{self.index_name}" - if backup: - path = path + "_backup" + path = self.index_namespace + if by_version is not None: + path += f"_v{by_version}" - _, _ = ElasticWrap(path).delete() + print(f"[{path}] delete index") + response, status_code = ElasticWrap(path).delete() + if status_code not in [200, 201]: + print(f"{status_code}: {response}") + raise ValueError("index delete failed") - def create_blank(self, for_backup=False): - """apply new mapping and settings for blank new index""" - print(f"create new blank index with name ta_{self.index_name}...") - path = f"ta_{self.index_name}" - if for_backup: - path = f"{path}_backup" + def create_blank(self, new_version: int | None = None): + """create blank""" + path = self.index_namespace + if new_version is not None: + path += f"_v{new_version}" data = {} if self.expected_set: @@ -145,7 +237,88 @@ class ElasticIndex: # no indexing for config data["mappings"]["dynamic"] = False - _, _ = ElasticWrap(path).put(data) + print(f"[{path}] create new blank index") + if settings.DEBUG: + print(f"[{path}] creat new blank index with data: {data}") + + response, status_code = ElasticWrap(path).put(data) + if status_code not in [200, 201]: + print(f"{status_code}: {response}") + raise ValueError(f"create blank index {path} failed") + + def reindex(self, new_version: int, removed_fields: set[str]): + """reindex to versioned new index after creating""" + source = self.index_namespace + dest = f"{self.index_namespace}_v{new_version}" + data: dict = {"source": {"index": source}, "dest": {"index": dest}} + + if removed_fields: + script = "\n".join( + f"ctx._source.remove('{i}');" for i in removed_fields + ) + data["script"] = {"lang": "painless", "source": script} + + msg = f"[{self.index_namespace}] reindex from {source} to {dest}" + if removed_fields: + msg += f", remove unexpected fields: {removed_fields}" + + print(msg) + + if settings.DEBUG: + print(f"send data: {data}") + + path = "_reindex?refresh=true" + response, status_code = ElasticWrap(path).post(data=data) + if status_code not in [200, 201]: + print(f"{status_code}: {response}") + raise ValueError("reindex failed failed") + + def create_alias(self, new_version: int): + """create aliast for moved index""" + index_new = f"{self.index_namespace}_v{new_version}" + index_old = None + + data: dict = { + "actions": [ + { + "add": { + "index": index_new, + "alias": self.index_namespace, + "is_write_index": True, + }, + }, + ] + } + + message = f"create new alias {index_new}" + if index_old: + message += f", remove old alias {index_old}" + + print(f"[{self.index_namespace}] {message}") + if settings.DEBUG: + print(f"create alias with data: {data}") + + response, status_code = ElasticWrap("_aliases").post(data=data) + if status_code not in [200, 201]: + print(f"{status_code}: {response}") + raise ValueError("alias update failed") + + def mapping_update(self): + """simple mapping update only, use migrations for defaults""" + current_version = self.get_current_version() + path = self.index_namespace + if current_version is not None: + path += f"_v{current_version}" + + data = {"properties": self.expected_map} + print(f"[{path}] update mapping") + if settings.DEBUG: + print(f"[{path}] update mapping with data: {data}") + + response, status_code = ElasticWrap(f"{path}/_mapping").put(data) + if status_code not in [200, 201]: + print(f"{status_code}: {response}") + raise ValueError(f"create blank index {path} failed") class ElasticIndexWrap: @@ -164,14 +337,22 @@ class ElasticIndexWrap: handler.create_blank() continue - rebuild = handler.validate() - if rebuild: + action, removed_fields = handler.validate() + if action == MappingAction.REINDEX: self._check_backup() - handler.rebuild_index() + handler.rebuild_index(removed_fields) continue - # else all good - print(f"ta_{index_name} index is created and up to date...") + if action == MappingAction.PUT_MAPPING: + handler.mapping_update() + + if removed_fields: + print( + f"[ta_{index_name}] skip removing unexpected fields:" + + f" {removed_fields}" + ) + else: + print(f"[ta_{index_name}] index status is as expected.") def reset(self): """reset all indexes to blank""" @@ -180,11 +361,15 @@ class ElasticIndexWrap: def delete_all(self): """delete all indexes""" - print("reset elastic index") for index in self.index_config: index_name, _, _ = self._config_split(index) + print(f"[ta_{index_name}] reset elastic index") handler = ElasticIndex(index_name) - handler.delete_index(backup=False) + if not handler.exists: + continue + + current_version = handler.get_current_version() + handler.delete_index(by_version=current_version) def create_all_blank(self): """create all blank indexes""" diff --git a/backend/appsettings/src/manual.py b/backend/appsettings/src/manual.py index 08e5b8e3..36c3c62b 100644 --- a/backend/appsettings/src/manual.py +++ b/backend/appsettings/src/manual.py @@ -16,8 +16,9 @@ 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.comments import Comments from video.src.index import YoutubeVideo +from video.src.meta_embed import IndexFromEmbed from yt_dlp.utils import ISO639Utils @@ -41,9 +42,16 @@ class ImportFolderScanner: "subtitle": [".vtt"], } - def __init__(self, task=False): + def __init__( + self, + task=False, + ignore_error: bool = False, + prefer_local: bool = False, + ): self.task = task self.to_import = False + self.ignore_error = ignore_error + self.prefer_local = prefer_local def scan(self): """scan and match media files""" @@ -142,14 +150,14 @@ class ImportFolderScanner: self._convert_thumb(current_video) self._get_subtitles(current_video) self._convert_video(current_video) + print(f"manual import: {current_video}") - - ManualImport(current_video, config).run() - - video_ids = [i["video_id"] for i in self.to_import] - comment_list = CommentList(task=self.task) - comment_list.add(video_ids=video_ids) - comment_list.index() + ManualImport( + current_video, + config, + ignore_error=self.ignore_error, + prefer_local=self.prefer_local, + ).run() def _notify(self, idx, current_video): """send notification back to task""" @@ -185,17 +193,27 @@ class ImportFolderScanner: expects filename ending in []. """ base_name, _ = os.path.splitext(file_name) + + # yt-dlp default like [youtubeid] id_search = re.search(r"\[([a-zA-Z0-9_-]{11})\]$", base_name) if id_search: youtube_id = id_search.group(1) return youtube_id + file_name_search = re.search(r"([a-zA-Z0-9_-]{11})$", base_name) + if file_name_search: + youtube_id = file_name_search.group(1) + return youtube_id + print(f"id extraction failed from filename: {file_name}") return False - def _extract_id_from_json(self, json_file): + def _extract_id_from_json(self, json_file: str | bool) -> str | None: """open json file and extract id""" + if not json_file or not isinstance(json_file, str): + return None + json_path = os.path.join(self.CACHE_DIR, "import", json_file) with open(json_path, "r", encoding="utf-8") as f: json_content = f.read() @@ -388,17 +406,49 @@ class ImportFolderScanner: class ManualImport: """import single identified video""" - def __init__(self, current_video, config): + def __init__( + self, current_video, config, ignore_error: bool, prefer_local: bool + ): self.current_video = current_video self.config = config + self.ignore_error: bool = ignore_error + self.prefer_local: bool = prefer_local def run(self): """run all""" - json_data = self.index_metadata() - self._move_to_archive(json_data) - self._cleanup(json_data) + json_data = None + if self.prefer_local: + # embedded first + json_data = IndexFromEmbed( + self.current_video["media"], + use_user_conf=False, + config=self.config, + ).run_index() + if json_data: + self._cleanup() + return - def index_metadata(self): + try: + json_data = self.index_metadata() + except ValueError as err: + json_data = IndexFromEmbed( + self.current_video["media"], + use_user_conf=False, + config=self.config, + ).run_index() + if not json_data and not self.ignore_error: + raise ValueError from err + + if not json_data: + return + + self._move_to_archive(json_data) + self._cleanup() + + Comments(self.current_video["video_id"]).build_json(upload=True) + YoutubeVideo(self.current_video["video_id"]).embed_metadata() + + def index_metadata(self) -> dict | None: """get metadata from yt or json""" video_id = self.current_video["video_id"] video = YoutubeVideo(video_id) @@ -411,6 +461,9 @@ class ManualImport: f"{video_id}: manual import failed, and no metadata found." ) print(message) + if self.ignore_error: + return None + raise ValueError(message) video.check_subtitles(subtitle_files=self.current_video["subtitle"]) @@ -463,7 +516,7 @@ class ManualImport: new_path = f"{base_name}.{lang}.vtt" shutil.move(old_path, new_path, copy_function=shutil.copyfile) - def _cleanup(self, json_data): + def _cleanup(self): """cleanup leftover files""" meta_data = self.current_video["metadata"] if meta_data and os.path.exists(meta_data): @@ -476,11 +529,3 @@ class ManualImport: for subtitle_file in self.current_video["subtitle"]: if os.path.exists(subtitle_file): os.remove(subtitle_file) - - channel_info = os.path.join( - EnvironmentSettings.CACHE_DIR, - "import", - f"{json_data['channel']['channel_id']}.info.json", - ) - if os.path.exists(channel_info): - os.remove(channel_info) diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py index 234fd7fa..dacd6f6b 100644 --- a/backend/appsettings/src/reindex.py +++ b/backend/appsettings/src/reindex.py @@ -7,7 +7,7 @@ functionality: import json import os from datetime import datetime -from typing import Callable, TypedDict +from typing import TypedDict from appsettings.src.config import AppConfig from channel.src.index import YoutubeChannel @@ -277,7 +277,6 @@ class Reindex(ReindexBase): def reindex_type(self, name: str, index_config: ReindexConfigType) -> None: """reindex all of a single index""" - reindex = self._get_reindex_map(index_config["index_name"]) queue = RedisQueue(index_config["queue_name"]) while True: total = queue.max_score() @@ -288,44 +287,48 @@ class Reindex(ReindexBase): if self.task: self._notify(name, total, idx) - reindex(youtube_id) + index_name = index_config["index_name"] + if index_name == "ta_video": + video = self.reindex_single_video(youtube_id) + if video: + self._reindex_video_related(video) + + elif index_name == "ta_channel": + self._reindex_single_channel(channel_id=youtube_id) + elif index_name == "ta_playlist": + self._reindex_single_playlist(playlist_id=youtube_id) + rand_sleep(self.config) - def _get_reindex_map(self, index_name: str) -> Callable: - """return def to run for index""" - def_map = { - "ta_video": self._reindex_single_video, - "ta_channel": self._reindex_single_channel, - "ta_playlist": self._reindex_single_playlist, - } - - return def_map[index_name] - def _notify(self, name: str, total: int, idx: int) -> None: """send notification back to task""" message = [f"Reindexing {name.title()}s {idx}/{total}"] progress = idx / total self.task.send_progress(message, progress=progress) - def _reindex_single_video(self, youtube_id: str) -> None: + def reindex_single_video(self, youtube_id: str) -> YoutubeVideo | None: """refresh data for single video""" video = YoutubeVideo(youtube_id) # read current state video.get_from_es() if not video.json_data: - return + return None es_meta = video.json_data.copy() # get new - media_url = os.path.join( + media_url: str | bool = os.path.join( EnvironmentSettings.MEDIA_DIR, es_meta["media_url"] ) + if not os.path.exists(media_url): + # fallback to cache path + media_url = False + video.build_json(media_path=media_url) if not video.youtube_meta: video.deactivate() - return + return None video.delete_subtitles(subtitles=es_meta.get("subtitles")) video.check_subtitles() @@ -339,14 +342,19 @@ class Reindex(ReindexBase): video.json_data["playlist"] = es_meta.get("playlist") video.upload_to_es() + self.processed["videos"] += 1 - thumb_handler = ThumbManager(youtube_id) + return video + + def _reindex_video_related(self, video: YoutubeVideo) -> None: + """reindex video related metadata and fields""" + thumb_handler = ThumbManager(video.youtube_id) thumb_handler.delete_video_thumb() thumb_handler.download_video_thumb(video.json_data["vid_thumb_url"]) - Comments(youtube_id, config=self.config).reindex_comments() + Comments(video.youtube_id, config=self.config).reindex_comments() + video.get_from_es() video.embed_metadata() - self.processed["videos"] += 1 def _reindex_single_channel(self, channel_id: str) -> None: """refresh channel data and sync to videos""" diff --git a/backend/appsettings/src/snapshot.py b/backend/appsettings/src/snapshot.py index 49c7eccd..a0739854 100644 --- a/backend/appsettings/src/snapshot.py +++ b/backend/appsettings/src/snapshot.py @@ -261,10 +261,15 @@ class ElasticSnapshot: def restore_all(self, snapshot_name): """restore snapshot by name""" for index in self.all_indices: - _, _ = ElasticWrap(index).delete() + response, status_code = ElasticWrap(index).get() + if status_code == 404: + continue + + index_alias = list(response.keys())[0] + _, _ = ElasticWrap(index_alias).delete() path = f"_snapshot/{self.REPO}/{snapshot_name}/_restore" - data = {"indices": "*"} + data = {"indices": "*,-.*"} response, statuscode = ElasticWrap(path).post(data=data) if statuscode == 200: print(f"snapshot: executing now: {response}") diff --git a/backend/appsettings/urls.py b/backend/appsettings/urls.py index 4dde4188..104376ad 100644 --- a/backend/appsettings/urls.py +++ b/backend/appsettings/urls.py @@ -34,16 +34,21 @@ urlpatterns = [ 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", ), + path( + "rescan-filesystem/", + views.RescanFileSystem.as_view(), + name="api-rescan-filesystem", + ), + path( + "manual-import/", + views.ManualImportView.as_view(), + name="api-manual-import", + ), path( "membership/profile/", views_mb.MembershipProfileView.as_view(), diff --git a/backend/appsettings/views.py b/backend/appsettings/views.py index edb87ca3..91b6b89d 100644 --- a/backend/appsettings/views.py +++ b/backend/appsettings/views.py @@ -5,7 +5,8 @@ from appsettings.serializers import ( BackupFileSerializer, CookieUpdateSerializer, CookieValidationSerializer, - PoTokenSerializer, + ManualImportConfig, + RescanFileSystemConfig, SnapshotCreateResponseSerializer, SnapshotItemSerializer, SnapshotListSerializer, @@ -22,7 +23,7 @@ from common.serializers import ( from common.src.ta_redis import RedisArchivist from common.views_base import AdminOnly, AdminWriteOnly, ApiBaseView from django.conf import settings -from download.src.yt_dlp_base import CookieHandler, POTokenHandler +from download.src.yt_dlp_base import CookieHandler from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.authtoken.models import Token from rest_framework.response import Response @@ -290,69 +291,6 @@ class CookieView(ApiBaseView): return validation -class POTokenView(ApiBaseView): - """handle PO token""" - - permission_classes = [AdminOnly] - - @extend_schema( - responses={ - 200: OpenApiResponse(PoTokenSerializer()), - 404: OpenApiResponse( - ErrorResponseSerializer(), description="PO token not found" - ), - } - ) - def get(self, request): - """get PO token""" - config = AppConfig().config - potoken = POTokenHandler(config).get() - if not potoken: - error = ErrorResponseSerializer({"error": "PO token not found"}) - return Response(error.data, status=404) - - serializer = PoTokenSerializer(data={"potoken": potoken}) - serializer.is_valid(raise_exception=True) - - return Response(serializer.data) - - @extend_schema( - responses={ - 200: OpenApiResponse(PoTokenSerializer()), - 400: OpenApiResponse( - ErrorResponseSerializer(), description="Bad request" - ), - } - ) - def post(self, request): - """Update PO token""" - serializer = PoTokenSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - validated_data = serializer.validated_data - if not validated_data: - error = ErrorResponseSerializer( - {"error": "missing PO token key in request data"} - ) - return Response(error.data, status=400) - - config = AppConfig().config - new_token = validated_data["potoken"] - - POTokenHandler(config).set_token(new_token) - return Response(serializer.data) - - @extend_schema( - responses={ - 204: OpenApiResponse(description="PO token revoked"), - }, - ) - def delete(self, request): - """delete PO token""" - config = AppConfig().config - POTokenHandler(config).revoke_token() - return Response(status=204) - - class SnapshotApiListView(ApiBaseView): """resolves to /api/appsettings/snapshot/ GET: returns snapshot config plus list of existing snapshots @@ -388,6 +326,58 @@ class SnapshotApiListView(ApiBaseView): return Response(serializer.data) +class RescanFileSystem(ApiBaseView): + """resolves to /api/appsettings/rescan-filesystem/ + POST: start new rescan filesystem task + """ + + permission_classes = [AdminOnly] + + @staticmethod + @extend_schema( + request=RescanFileSystemConfig, + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + }, + ) + def post(request): + """start new task rescan filesystem task""" + data_serializer = RescanFileSystemConfig(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + message = TaskCommand().start("rescan_filesystem", validated_data) + serializer = AsyncTaskResponseSerializer(message) + + return Response(serializer.data) + + +class ManualImportView(ApiBaseView): + """resolves to /api/appsettings/manual-import/ + POST: start new manual import task + """ + + permission_classes = [AdminOnly] + + @staticmethod + @extend_schema( + request=ManualImportConfig, + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + }, + ) + def post(request): + """manual import""" + data_serializer = ManualImportConfig(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + message = TaskCommand().start("manual_import", validated_data) + serializer = AsyncTaskResponseSerializer(message) + + return Response(serializer.data) + + class SnapshotApiView(ApiBaseView): """resolves to /api/appsettings/snapshot// GET: return a single snapshot diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py index 1f364a97..d2dc4184 100644 --- a/backend/channel/serializers.py +++ b/backend/channel/serializers.py @@ -34,10 +34,12 @@ class ChannelSerializer(serializers.Serializer): channel_id = serializers.CharField() channel_active = serializers.BooleanField() - channel_banner_url = serializers.CharField() - channel_thumb_url = serializers.CharField() - channel_tvart_url = serializers.CharField() - channel_description = serializers.CharField() + channel_banner_url = serializers.CharField(allow_null=True, required=False) + channel_thumb_url = serializers.CharField(allow_null=True, required=False) + channel_tvart_url = serializers.CharField(allow_null=True, required=False) + channel_description = serializers.CharField( + allow_null=True, required=False + ) channel_last_refresh = serializers.CharField() channel_name = serializers.CharField() channel_overwrites = ChannelOverwriteSerializer(required=False) @@ -49,7 +51,6 @@ class ChannelSerializer(serializers.Serializer): channel_tabs = serializers.ListField( child=serializers.ChoiceField(VideoTypeEnum.values_known()) ) - channel_views = serializers.IntegerField() _index = serializers.CharField(required=False) _score = serializers.IntegerField(required=False) diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py index 37f2e625..390f9023 100644 --- a/backend/channel/src/index.py +++ b/backend/channel/src/index.py @@ -57,54 +57,56 @@ class YoutubeChannel(YouTubeItem): """extract relevant fields""" self.youtube_meta["thumbnails"].reverse() channel_name = self.youtube_meta["uploader"] or self.youtube_meta["id"] + description = self.youtube_meta.get("description") or None self.json_data = { "channel_active": True, - "channel_description": self.youtube_meta.get("description", ""), + "channel_description": description, "channel_id": self.youtube_id, "channel_last_refresh": int(datetime.now().timestamp()), "channel_name": channel_name, - "channel_subs": self.youtube_meta.get("channel_follower_count", 0), + "channel_subs": self.youtube_meta.get("channel_follower_count") + or 0, "channel_subscribed": False, "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, "channel_tabs": self.get_channel_tabs(), } - def _get_thumb_art(self): + self._get_thumb_art() + self._get_tv_art() + self._get_banner_art() + + def _get_thumb_art(self) -> None: """extract thumb art""" for i in self.youtube_meta["thumbnails"]: if not i.get("width"): continue if i.get("width") == i.get("height"): - return i["url"] + self.json_data["channel_thumb_url"] = i["url"] + return - return False - - def _get_tv_art(self): + def _get_tv_art(self) -> None: """extract tv artwork""" for i in self.youtube_meta["thumbnails"]: if i.get("id") == "banner_uncropped": - return i["url"] + self.json_data["channel_tvart_url"] = i["url"] + return for i in self.youtube_meta["thumbnails"]: if not i.get("width"): continue if i["width"] // i["height"] < 2 and not i["width"] == i["height"]: - return i["url"] + self.json_data["channel_tvart_url"] = i["url"] + return - return False + return - def _get_banner_art(self): + def _get_banner_art(self) -> None: """extract banner artwork""" for i in self.youtube_meta["thumbnails"]: if not i.get("width"): continue if i["width"] // i["height"] > 5: - return i["url"] - - return False + self.json_data["channel_banner_url"] = i["url"] + return def get_channel_tabs(self) -> list[str]: """get channel tabs""" @@ -132,51 +134,39 @@ class YoutubeChannel(YouTubeItem): self.json_data = { "channel_active": False, "channel_last_refresh": int(datetime.now().timestamp()), - "channel_subs": fallback.get("channel_follower_count", 0), + "channel_subs": fallback.get("channel_follower_count") or 0, "channel_name": fallback["uploader"], - "channel_banner_url": False, - "channel_tvart_url": False, "channel_id": self.youtube_id, "channel_subscribed": False, "channel_tags": [], - "channel_description": "", - "channel_thumb_url": False, - "channel_views": 0, } def get_channel_art(self): """download channel art for new channels""" urls = ( - self.json_data["channel_thumb_url"], - self.json_data["channel_banner_url"], - self.json_data["channel_tvart_url"], + self.json_data.get("channel_thumb_url"), + self.json_data.get("channel_banner_url"), + self.json_data.get("channel_tvart_url"), ) ThumbManager(self.youtube_id, item_type="channel").download(urls) def sync_to_videos(self): """sync new channel_dict to all videos of channel""" - # add ingest pipeline - processors = [] - for field, value in self.json_data.items(): - if value is None: - line = { - "script": { - "lang": "painless", - "source": f"ctx['{field}'] = null;", - } - } - else: - line = {"set": {"field": "channel." + field, "value": value}} - - processors.append(line) - - data = {"description": self.youtube_id, "processors": processors} - ingest_path = f"_ingest/pipeline/{self.youtube_id}" - _, _ = ElasticWrap(ingest_path).put(data) - # apply pipeline - data = {"query": {"match": {"channel.channel_id": self.youtube_id}}} - update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}" - _, _ = ElasticWrap(update_path).post(data) + data = { + "query": { + "term": {"channel.channel_id": {"value": self.youtube_id}}, + }, + "script": { + "lang": "painless", + "params": {"channel": self.json_data}, + "source": "ctx._source.channel = params.channel", + }, + } + update_path = "ta_video/_update_by_query" + response, status_code = ElasticWrap(update_path).post(data) + if status_code not in [200, 201]: + print(f"sync to videos failed with status code {status_code}") + print(response) def change_subscribe(self, new_subscribe_state: bool): """change subscribe status""" diff --git a/backend/common/src/env_settings.py b/backend/common/src/env_settings.py index 6133aaa2..db4c86c5 100644 --- a/backend/common/src/env_settings.py +++ b/backend/common/src/env_settings.py @@ -4,7 +4,7 @@ Functionality: - encapsulate persistence of application properties """ -from os import environ +from os import environ, path try: from dotenv import load_dotenv @@ -15,6 +15,32 @@ except ModuleNotFoundError: pass +def get_password_from_file(env_var_name) -> str: + """get password from file""" + + env_var_file: str = env_var_name + "_FILE" + + env_var_name_val = environ.get(env_var_name) + env_var_path_val = environ.get(env_var_file) + + if env_var_name_val is not None: + return str(env_var_name_val) + + if env_var_path_val is None: + print(f"either {env_var_name} or {env_var_file} must be set") + return "" + + is_path = path.isfile(env_var_path_val) + if not is_path: + print(f"{env_var_path_val} is not a path") + return "" + + with open(env_var_path_val, "r", encoding="utf-8") as f: + file_content = f.read().strip() + + return file_content + + class EnvironmentSettings: """ Handle settings for the application that are driven from the environment. @@ -29,7 +55,7 @@ class EnvironmentSettings: TA_PORT: int = int(environ.get("TA_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")) + TA_PASSWORD: str = get_password_from_file("TA_PASSWORD") # Application Paths MEDIA_DIR: str = str(environ.get("TA_MEDIA_DIR", "/youtube")) @@ -42,7 +68,7 @@ class EnvironmentSettings: # ElasticSearch ES_URL: str = str(environ.get("ES_URL")) - ES_PASS: str = str(environ.get("ELASTIC_PASSWORD")) + ES_PASS: str = get_password_from_file("ELASTIC_PASSWORD") ES_USER: str = str(environ.get("ELASTIC_USER", "elastic")) ES_SNAPSHOT_DIR: str = str( environ.get( diff --git a/backend/common/src/es_connect.py b/backend/common/src/es_connect.py index b2d11635..3ef6f9a6 100644 --- a/backend/common/src/es_connect.py +++ b/backend/common/src/es_connect.py @@ -148,6 +148,8 @@ class IndexPaginate: - callback: obj, Class implementing run method callback for every loop - task: task object to send notification - total: int, total items in index for progress message + - timeout: int, overwrite timeout in get request + - pit_keep_alive: int, overwrite pit valid """ DEFAULT_SIZE = 500 @@ -168,7 +170,8 @@ class IndexPaginate: def get_pit(self): """get pit for index""" - path = f"{self.index_name}/_pit?keep_alive=10m" + keep_alive = self.kwargs.get("pit_keep_alive", 15) + path = f"{self.index_name}/_pit?keep_alive={keep_alive}m" response, _ = ElasticWrap(path).post() self.pit_id = response["id"] @@ -184,14 +187,18 @@ class IndexPaginate: self.data.update({"sort": [{"_doc": {"order": "desc"}}]}) self.data["size"] = self.kwargs.get("size") or self.DEFAULT_SIZE - self.data["pit"] = {"id": self.pit_id, "keep_alive": "10m"} + self.data["pit"] = {"id": self.pit_id, "keep_alive": "15m"} def run_loop(self): """loop through results until last hit""" all_results = [] counter = 0 while True: - response, _ = ElasticWrap("_search").get(data=self.data) + get_kwargs = {"data": self.data} + if timeout_overwrite := self.kwargs.get("timeout"): + get_kwargs.update({"timeout": timeout_overwrite}) + + response, _ = ElasticWrap("_search").get(**get_kwargs) all_hits = response["hits"]["hits"] if not all_hits: break diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py index dbf238ce..c6f0f06c 100644 --- a/backend/common/src/helper.py +++ b/backend/common/src/helper.py @@ -103,13 +103,17 @@ def requests_headers() -> dict[str, str]: return {"User-Agent": template} -def date_parser(timestamp: int | str | None) -> str | None: +def date_parser(timestamp: int | float | str | None) -> str | None: """return formatted date string""" if timestamp is None: return None if isinstance(timestamp, int): date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc) + elif isinstance(timestamp, float): + date_obj = datetime.fromtimestamp(int(timestamp), tz=timezone.utc) + elif isinstance(timestamp, str) and timestamp.isdigit(): + date_obj = datetime.fromtimestamp(int(timestamp), tz=timezone.utc) elif isinstance(timestamp, str): date_obj = datetime.strptime(timestamp, "%Y-%m-%d") date_obj = date_obj.replace(tzinfo=timezone.utc) @@ -131,6 +135,19 @@ def time_parser(timestamp: str) -> float: return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds) +def deep_merge(target: dict, source: dict) -> None: + """inplace nested dict merge, recursive""" + for key, value in source.items(): + if ( + key in target + and isinstance(target[key], dict) + and isinstance(value, dict) + ): + deep_merge(target[key], value) + else: + target[key] = value + + def clear_dl_cache(cache_dir: str) -> int: """clear leftover files from dl cache""" print("clear download cache") diff --git a/backend/common/src/index_generic.py b/backend/common/src/index_generic.py index 354f83ea..25c098b9 100644 --- a/backend/common/src/index_generic.py +++ b/backend/common/src/index_generic.py @@ -53,11 +53,11 @@ class YouTubeItem: obs_request, self.config ).extract(url) - def get_from_es(self): + def get_from_es(self, print_error: bool = True) -> None: """get indexed data from elastic search""" print(f"{self.youtube_id}: get metadata from es") - response, _ = ElasticWrap(f"{self.es_path}").get() - source = response.get("_source") + resp, _ = ElasticWrap(f"{self.es_path}").get(print_error=print_error) + source = resp.get("_source") self.json_data = source def upload_to_es(self): diff --git a/backend/common/src/search_processor.py b/backend/common/src/search_processor.py index e5c0cac4..f952547c 100644 --- a/backend/common/src/search_processor.py +++ b/backend/common/src/search_processor.py @@ -56,17 +56,17 @@ class SearchProcess: """detect which type of data to process""" index = result["_index"] processed = False - if index == "ta_video": + if index.startswith("ta_video"): processed = self._process_video(result["_source"]) - if index == "ta_channel": + if index.startswith("ta_channel"): processed = self._process_channel(result["_source"]) - if index == "ta_playlist": + if index.startswith("ta_playlist"): processed = self._process_playlist(result["_source"]) - if index == "ta_download": + if index.startswith("ta_download"): processed = self._process_download(result["_source"]) - if index == "ta_comment": + if index.startswith("ta_comment"): processed = self._process_comment(result["_source"]) - if index == "ta_subtitle": + if index.startswith("ta_subtitle"): processed = self._process_subtitle(result) if isinstance(processed, dict): @@ -89,12 +89,25 @@ class SearchProcess: channel_dict.update( { "channel_last_refresh": date_str, - "channel_banner_url": f"{art_base}_banner.jpg", - "channel_thumb_url": f"{art_base}_thumb.jpg", - "channel_tvart_url": f"{art_base}_tvart.jpg", + "channel_description": channel_dict.get("channel_description"), } ) + if channel_dict.get("channel_banner_url"): + channel_dict["channel_banner_url"] = f"{art_base}_banner.jpg" + else: + channel_dict["channel_banner_url"] = None + + if channel_dict.get("channel_thumb_url"): + channel_dict["channel_thumb_url"] = f"{art_base}_thumb.jpg" + else: + channel_dict["channel_thumb_url"] = None + + if channel_dict.get("channel_tvart_url"): + channel_dict["channel_tvart_url"] = f"{art_base}_tvart.jpg" + else: + channel_dict["channel_tvart_url"] = None + return dict(sorted(channel_dict.items())) def _process_video(self, video_dict): @@ -125,6 +138,7 @@ class SearchProcess: "vid_last_refresh": vid_last_refresh, "published": published, "vid_thumb_url": f"{cache_root}/{vid_thumb_url}", + "description": video_dict.get("description"), } ) @@ -154,10 +168,12 @@ class SearchProcess: ) cache_root = EnvironmentSettings().get_cache_root() playlist_thumbnail = f"{cache_root}/playlists/{playlist_id}.jpg" + description = playlist_dict.get("playlist_description") playlist_dict.update( { "playlist_thumbnail": playlist_thumbnail, "playlist_last_refresh": playlist_last_refresh, + "playlist_description": description, } ) @@ -182,17 +198,23 @@ class SearchProcess: def _process_comment(self, comment_dict): """run on all comments, create reply thread""" - all_comments = comment_dict["comment_comments"] - processed_comments = [] + comment_tree = [] + lookup = {} + for comment in comment_dict["comment_comments"]: + comment = comment.copy() + comment["comment_replies"] = [] + lookup[comment["comment_id"]] = comment - for comment in all_comments: - if comment["comment_parent"] == "root": - comment.update({"comment_replies": []}) - processed_comments.append(comment) + for comment in lookup.values(): + parent = comment.get("comment_parent") + if parent == "root": + comment_tree.append(comment) else: - processed_comments[-1]["comment_replies"].append(comment) + parent_node = lookup.get(parent) + if parent_node: + parent_node["comment_replies"].append(comment) - return processed_comments + return comment_tree def _process_subtitle(self, result): """take complete result dict to extract highlight""" diff --git a/backend/common/src/searching.py b/backend/common/src/searching.py index 17c164b1..7348fddc 100644 --- a/backend/common/src/searching.py +++ b/backend/common/src/searching.py @@ -31,13 +31,13 @@ class SearchForm: fulltext_results = [] if search_results: for result in search_results: - if result["_index"] == "ta_video": + if result["_index"].startswith("ta_video"): video_results.append(result) - elif result["_index"] == "ta_channel": + elif result["_index"].startswith("ta_channel"): channel_results.append(result) - elif result["_index"] == "ta_playlist": + elif result["_index"].startswith("ta_playlist"): playlist_results.append(result) - elif result["_index"] == "ta_subtitle": + elif result["_index"].startswith("ta_subtitle"): fulltext_results.append(result) all_results = { @@ -113,6 +113,7 @@ class SearchParser: "index": "ta_subtitle", "lang": [], "source": [], + "channel": [], }, } @@ -345,6 +346,22 @@ class QueryBuilder: """build query for fulltext search""" must_list = [] + if (channel := self.query_map.get("channel")) is not None: + must_list.append( + { + "multi_match": { + "query": channel, + "type": "bool_prefix", + "fuzziness": self._get_fuzzy(), + "operator": "and", + "fields": [ + "subtitle_channel", + "subtitle_channel.keyword", + ], + } + } + ) + if (term := self.query_map.get("term")) is not None: must_list.append( { diff --git a/backend/common/tests/test_src/test_helper.py b/backend/common/tests/test_src/test_helper.py index 013e774b..acc81e46 100644 --- a/backend/common/tests/test_src/test_helper.py +++ b/backend/common/tests/test_src/test_helper.py @@ -26,6 +26,20 @@ def test_date_parser_with_int(): assert date_parser(timestamp) == expected_date +def test_date_parser_with_digit(): + """unix timestamp""" + timestamp = "1621539600" + expected_date = "2021-05-20T19:40:00+00:00" + assert date_parser(timestamp) == expected_date + + +def test_date_parser_with_float(): + """iso timestamp""" + date_float = 1766210400.0 + expected_date = "2025-12-20T06:00:00+00:00" + assert date_parser(date_float) == expected_date + + def test_date_parser_with_str(): """iso timestamp""" date_str = "2021-05-21" diff --git a/backend/config/management/commands/ta_envcheck.py b/backend/config/management/commands/ta_envcheck.py index 2b284d7c..245bb9c1 100644 --- a/backend/config/management/commands/ta_envcheck.py +++ b/backend/config/management/commands/ta_envcheck.py @@ -61,6 +61,10 @@ EXPECTED_ENV_VARS = [ "ES_URL", "TA_HOST", ] +FILE_FALLBACK = [ + "ELASTIC_PASSWORD", + "TA_PASSWORD", +] UNEXPECTED_ENV_VARS = { "TA_UWSGI_PORT": "Has been replaced with 'TA_BACKEND_PORT'", "REDIS_HOST": "Has been replaced with 'REDIS_CON' connection string", @@ -139,11 +143,16 @@ class Command(BaseCommand): self.stdout.write("[1] checking expected env vars") env = os.environ for var in EXPECTED_ENV_VARS: - if not env.get(var): - message = f" 🗙 expected env var {var} not set\n {INST}" - self.stdout.write(self.style.ERROR(message)) - sleep(60) - raise CommandError(message) + if var in env: + continue + + if var in FILE_FALLBACK and f"{var}_FILE" in env: + continue + + 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)) diff --git a/backend/config/management/commands/ta_index_channel_tabs.py b/backend/config/management/commands/ta_index_channel_tabs.py deleted file mode 100644 index 339843e8..00000000 --- a/backend/config/management/commands/ta_index_channel_tabs.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -migration for 0.5.4 to 0.5.5 -index channel_tabs for subscribed channels -""" - -import time - -from channel.src.index import YoutubeChannel -from common.src.helper import get_channels -from django.core.management.base import BaseCommand - - -class Command(BaseCommand): - """command""" - - def handle(self, *args, **kwargs): - """handle task""" - - self.stdout.write("channel tags initial index") - - channels = get_channels(subscribed_only=True, source=["channel_id"]) - - for es_channel in channels: - channel = YoutubeChannel(es_channel["channel_id"]) - channel.get_from_es() - channel_name = channel.json_data["channel_name"] - channel_tabs = channel.get_channel_tabs() - channel.json_data["channel_tabs"] = channel_tabs - channel.upload_to_es() - channel.sync_to_videos() - self.stdout.write( - self.style.SUCCESS( - f" ✓ updated '{channel_name}' tabs: {channel_tabs}" - ) - ) - time.sleep(5) diff --git a/backend/config/management/commands/ta_startup.py b/backend/config/management/commands/ta_startup.py index a0eb6863..e65a701a 100644 --- a/backend/config/management/commands/ta_startup.py +++ b/backend/config/management/commands/ta_startup.py @@ -12,10 +12,12 @@ from time import sleep from appsettings.src.config import AppConfig, ReleaseVersion from appsettings.src.index_setup import ElasticIndexWrap from appsettings.src.snapshot import ElasticSnapshot +from channel.src.index import YoutubeChannel 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.es_connect import ElasticWrap, IndexPaginate +from common.src.helper import clear_dl_cache, get_channels from common.src.ta_redis import RedisArchivist +from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.utils import dateformat from django_celery_beat.models import CrontabSchedule, PeriodicTasks @@ -24,6 +26,7 @@ from task.src.config_schedule import ScheduleBuilder from task.src.task_manager import TaskManager from task.tasks import version_check from video.src.constants import VideoTypeEnum +from video.src.index import YoutubeVideo TOPIC = """ @@ -37,8 +40,6 @@ TOPIC = """ class Command(BaseCommand): """command framework""" - # pylint: disable=no-member - def handle(self, *args, **options): """run all commands""" self.stdout.write(TOPIC) @@ -53,10 +54,46 @@ class Command(BaseCommand): self._update_schedule_tz() self._init_app_config() self._set_ta_startup_time() - self._mig_fix_download_channel_indexed() + + if self.skip_migrations: + return + self._mig_add_default_playlist_sort() self._mig_set_channel_tabs() self._mig_set_video_channel_tabs() + self._mig_fix_playlist_description() + self._mig_fix_missing_stats() + self._mig_fix_channel_art_types() + self._mig_fix_channel_description() + self._mig_fix_video_description() + + @property + def skip_migrations(self) -> bool: + """ + check if migrations should be skipped. + Experimental, might get replaced in the future. + """ + current_version = settings.TA_VERSION.rstrip("-unstable").upper() + env_var = f"TA_MIG_SKIP_{current_version}" + skipping = bool(os.environ.get(env_var)) + + self.stdout.write("[MIGRATION] check, experimental") + if skipping: + self.stdout.write( + self.style.SUCCESS( + f" {env_var} is set, skipping migration check" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + " Running migrations. " + + "If migrations have run for this release, " + + f"you can set {env_var} to skip the check" + ) + ) + + return skipping def _make_folders(self): """make expected cache folders""" @@ -68,6 +105,7 @@ class Command(BaseCommand): "import", "playlists", "videos", + "ytdlp", ] cache_dir = EnvironmentSettings.CACHE_DIR for folder in folders: @@ -242,6 +280,12 @@ class Command(BaseCommand): self.style.SUCCESS(f" added new default: {new_default}") ) + cleared = AppConfig().clear_old_keys() + for removed_key in cleared: + self.stdout.write( + self.style.SUCCESS(f" removed old key: {removed_key}") + ) + return if status_code != 404: @@ -271,139 +315,192 @@ class Command(BaseCommand): self.style.SUCCESS(f" ✓ set timestamp to {message}.") ) - def _mig_fix_download_channel_indexed(self) -> None: - """migrate from v0.5.2 to 0.5.3, fix missing channel_indexed""" - self.stdout.write("[MIGRATION] fix incorrect video channel tags types") - path = "ta_download/_update_by_query" - data = { - "query": { - "bool": { - "must_not": [{"exists": {"field": "channel_indexed"}}] - } - }, - "script": { - "source": "ctx._source.channel_indexed = false", - "lang": "painless", - }, - } - response, status_code = ElasticWrap(path).post(data) - if status_code in [200, 201]: - updated = response.get("updated") - if updated: - self.stdout.write( - self.style.SUCCESS(f" ✓ fixed {updated} queued videos") - ) - else: - self.stdout.write( - self.style.SUCCESS(" no queued videos to fix") - ) - return - - message = " 🗙 failed to fix video channel tags" - self.stdout.write(self.style.ERROR(message)) - self.stdout.write(response) - sleep(60) - raise CommandError(message) - def _mig_add_default_playlist_sort(self) -> None: """migrate from 0.5.4 to 0.5.5 set default playlist sortorder""" - self.stdout.write("[MIGRATION] set default playlist sort order") - path = "ta_playlist/_update_by_query" - data = { - "query": { + self._run_migration( + index_name="ta_playlist", + desc="set default playlist sort order", + query={ "bool": { "must_not": [{"exists": {"field": "playlist_sort_order"}}] } }, - "script": { + script={ "source": "ctx._source.playlist_sort_order = 'top'", "lang": "painless", }, - } - response, status_code = ElasticWrap(path).post(data) - if status_code in [200, 201]: - updated = response.get("updated") - if updated: - self.stdout.write( - self.style.SUCCESS(f" ✓ updated {updated} playlists") - ) - else: - self.stdout.write( - self.style.SUCCESS(" no playlists need updating") - ) - return - - message = " 🗙 failed to set default playlist sort order" - self.stdout.write(self.style.ERROR(message)) - self.stdout.write(response) - sleep(60) - raise CommandError(message) + ) def _mig_set_channel_tabs(self) -> None: """migrate from 0.5.4 to 0.5.5 set initial channel tabs""" - self.stdout.write("[MIGRATION] set default channel_tabs") - - path = "ta_channel/_update_by_query" tabs = VideoTypeEnum.values_known() - data = { - "query": { + self._run_migration( + index_name="ta_channel", + desc="set default channel_tabs in channel index", + query={ "bool": {"must_not": [{"exists": {"field": "channel_tabs"}}]} }, - "script": { + script={ "source": f"ctx._source.channel_tabs = {tabs}", "lang": "painless", }, - } - response, status_code = ElasticWrap(path).post(data) - if status_code in [200, 201]: - updated = response.get("updated") - if updated: - self.stdout.write( - self.style.SUCCESS(f" ✓ updated {updated} channels") - ) - else: - self.stdout.write( - self.style.SUCCESS(" no channels need updating") - ) - return - - message = " 🗙 failed to set default channel_tabs" - self.stdout.write(self.style.ERROR(message)) - self.stdout.write(response) - sleep(60) - raise CommandError(message) + ) def _mig_set_video_channel_tabs(self) -> None: """migrate from 0.5.4 to 0.5.5 set initial video channel tabs""" - self.stdout.write("[MIGRATION] set default channel_tabs for videos") - - path = "ta_video/_update_by_query" tabs = VideoTypeEnum.values_known() - data = { - "query": { + self._run_migration( + index_name="ta_video", + desc="set default channel_tabs for videos", + query={ "bool": { "must_not": [{"exists": {"field": "channel.channel_tabs"}}] } }, - "script": { + script={ "source": f"ctx._source.channel.channel_tabs = {tabs}", "lang": "painless", }, - } + ) + + def _mig_fix_playlist_description(self) -> None: + """migrate from 0.5.8 to 0.5.9 fix playlist desc null data type""" + self._run_migration( + index_name="ta_playlist", + desc="fix playlist description data type", + query={"term": {"playlist_description": {"value": False}}}, + script={ + "source": "ctx._source.remove('playlist_description')", + "lang": "painless", + }, + ) + + def _mig_fix_missing_stats(self) -> None: + """migrate from 0.5.8 to 0.5.9, fix missing stats values""" + fields = [ + "like_count", + "average_rating", + "view_count", + "dislike_count", + ] + for field in fields: + self._run_migration( + index_name="ta_video", + desc=f"fix missing stats field {field}", + query={ + "bool": { + "must_not": [{"exists": {"field": f"stats.{field}"}}] + } + }, + script={ + "source": f"ctx._source.stats.{field} = 0", + "lang": "painless", + }, + ) + + def _mig_fix_channel_art_types(self) -> None: + """migrate from 0.5.8 to 0.5.9, fix channel artwork types""" + fields = [ + "channel_banner_url", + "channel_thumb_url", + "channel_tvart_url", + ] + for field in fields: + self._run_migration( + index_name="ta_channel", + desc=f"fix missing data type for field {field}", + query={"term": {field: {"value": False}}}, + script={ + "source": f"ctx._source.remove('{field}')", + "lang": "painless", + }, + ) + source = f""" + if (ctx._source.containsKey('channel')) + {{ctx._source.channel.remove('{field}');}} + """ + self._run_migration( + index_name="ta_video", + desc=f"fix missing data type for field channel.{field}", + query={"term": {f"channel.{field}": {"value": False}}}, + script={"source": source, "lang": "painless"}, + ) + + def _mig_fix_channel_description(self) -> None: + """migrate from 0.5.8 to 0.5.9, fix channel desc null value""" + desc = "fix channel description null value" + self.stdout.write(f"[MIGRATION] run {desc}") + channels = get_channels( + subscribed_only=False, source=["channel_description", "channel_id"] + ) + counter = 0 + for channel_response in channels: + if not channel_response.get("channel_description") == "": + continue + + channel = YoutubeChannel(youtube_id=channel_response["channel_id"]) + channel.get_from_es() + channel.json_data.pop("channel_description") + channel.upload_to_es() + channel.sync_to_videos() + counter += 1 + + if counter: + suc_msg = f" ✓ updated {counter} channels with videos" + self.stdout.write(self.style.SUCCESS(suc_msg)) + else: + noop_msg = " no items needed updating" + self.stdout.write(self.style.SUCCESS(noop_msg)) + + def _mig_fix_video_description(self) -> None: + """migrate from 0.5.8 to 0.5.9, fix video desc null value""" + desc = "fix video description null value" + self.stdout.write(f"[MIGRATION] run {desc}") + + data = {"_source": ["youtube_id", "description"]} + videos = IndexPaginate("ta_video", data=data).get_results() + + counter = 0 + for video_response in videos: + if not video_response.get("description") == "": + continue + + video = YoutubeVideo(youtube_id=video_response["youtube_id"]) + video.get_from_es() + video.json_data.pop("description") + video.upload_to_es() + + counter += 1 + + if counter: + suc_msg = f" ✓ updated {counter} videos" + self.stdout.write(self.style.SUCCESS(suc_msg)) + else: + noop_msg = " no items needed updating" + self.stdout.write(self.style.SUCCESS(noop_msg)) + + def _run_migration( + self, index_name: str, desc: str, query: dict, script: dict + ): + """run migration""" + self.stdout.write(f"[MIGRATION] run {desc}") + path = f"{index_name}/_update_by_query?wait_for_completion=true" + data = {"query": query, "script": script} response, status_code = ElasticWrap(path).post(data) if status_code in [200, 201]: updated = response.get("updated") if updated: - self.stdout.write( - self.style.SUCCESS(f" ✓ updated {updated} videos") - ) + suc_msg = f" ✓ updated {updated} docs in {index_name}" + self.stdout.write(self.style.SUCCESS(suc_msg)) + + # ensure index consistency + ElasticWrap(f"{index_name}/_refresh").post() else: - self.stdout.write( - self.style.SUCCESS(" no videos need updating") - ) + noop_msg = f" no items in {index_name} need updating" + self.stdout.write(self.style.SUCCESS(noop_msg)) return - message = " 🗙 failed to set default channel_tabs" + message = f" 🗙 failed to run {desc} on index {index_name}" self.stdout.write(self.style.ERROR(message)) self.stdout.write(response) sleep(60) diff --git a/backend/config/settings.py b/backend/config/settings.py index e072f6c7..bc21600d 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -221,7 +221,7 @@ CORS_EXPOSE_HEADERS = ["X-Start-Timestamp"] # TA application settings TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" -TA_VERSION = "v0.5.9-unstable" +TA_VERSION = "v0.5.10-unstable" try: TA_START = RedisArchivist().get_message_str("STARTTIMESTAMP") except ValueError: diff --git a/backend/download/serializers.py b/backend/download/serializers.py index f34a4ce7..f5d89058 100644 --- a/backend/download/serializers.py +++ b/backend/download/serializers.py @@ -10,19 +10,21 @@ from video.src.constants import VideoTypeEnum class DownloadItemSerializer(serializers.Serializer): """serialize download item""" - auto_start = serializers.BooleanField() + auto_start = serializers.BooleanField(required=False) channel_id = serializers.CharField() channel_indexed = serializers.BooleanField() channel_name = serializers.CharField() duration = serializers.CharField() + message = serializers.CharField(required=False) published = serializers.CharField(allow_null=True) - status = serializers.ChoiceField(choices=["pending", "ignore"]) + status = serializers.ChoiceField( + choices=["pending", "ignore"], required=False + ) timestamp = serializers.IntegerField(allow_null=True) title = serializers.CharField() vid_thumb_url = serializers.CharField(allow_null=True) vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values()) youtube_id = serializers.CharField() - message = serializers.CharField(required=False) _index = serializers.CharField(required=False) _score = serializers.IntegerField(required=False) diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py index e03a8871..81ada735 100644 --- a/backend/download/src/queue.py +++ b/backend/download/src/queue.py @@ -20,6 +20,7 @@ from common.src.helper import ( rand_sleep, ) from common.src.urlparser import ParsedURLType +from download.serializers import DownloadItemSerializer from download.src.queue_interact import PendingInteract from download.src.thumbnails import ThumbManager from playlist.src.index import YoutubePlaylist @@ -368,17 +369,23 @@ class PendingList(PendingIndex): return None to_add = { - "youtube_id": video_data["id"], - "title": video_data["title"], - "vid_thumb_url": self._extract_thumb(video_data), + "channel_id": video_data["channel_id"], + "channel_indexed": video_data["channel_id"] in self.all_channels, + "channel_name": video_data["channel"], "duration": get_duration_str(video_data.get("duration", 0)), "published": self._extract_published(video_data), "timestamp": int(datetime.now().timestamp()), + "title": video_data["title"], + "vid_thumb_url": self._extract_thumb(video_data), "vid_type": self._extract_vid_type(video_data), - "channel_name": video_data["channel"], - "channel_id": video_data["channel_id"], - "channel_indexed": video_data["channel_id"] in self.all_channels, + "youtube_id": video_data["id"], } + serializer = DownloadItemSerializer(data=to_add) + is_valid = serializer.is_valid() + if not is_valid: + print(f"{youtube_id}: serializer failed: {serializer.errors}") + self._notify_fail(403, youtube_id) + return None return to_add @@ -393,18 +400,34 @@ class PendingList(PendingIndex): return None @staticmethod - def _extract_published(video_data) -> str | int | None: + def _extract_published(video_data) -> int | None: """build published date or timestamp""" timestamp = video_data.get("timestamp") - if timestamp: + if timestamp and isinstance(timestamp, int): return timestamp + if timestamp and isinstance(timestamp, float): + return int(timestamp) + + if timestamp and isinstance(timestamp, str): + try: + # scientific string + return int(float(timestamp)) + except (TypeError, ValueError): + pass + upload_date = video_data.get("upload_date") if upload_date: - upload_date_time = datetime.strptime(upload_date, "%Y%m%d") - return upload_date_time.replace( - tzinfo=ZoneInfo(EnvironmentSettings.TZ) - ).timestamp() + try: + upload_date_time = datetime.strptime(upload_date, "%Y%m%d") + except ValueError: + youtube_id = video_data["id"] + print(f"{youtube_id}: published date extraction failed.") + return None + + tz = ZoneInfo(EnvironmentSettings.TZ) + timestamp = int(upload_date_time.replace(tzinfo=tz).timestamp()) + return timestamp return None diff --git a/backend/download/src/thumbnails.py b/backend/download/src/thumbnails.py index 2d9d4739..5004f932 100644 --- a/backend/download/src/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -4,9 +4,7 @@ functionality: - check for missing thumbnails """ -import base64 import os -from io import BytesIO from time import sleep import requests @@ -14,7 +12,7 @@ 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 +from PIL import Image, ImageFile, UnidentifiedImageError ImageFile.LOAD_TRUNCATED_IMAGES = True @@ -23,6 +21,7 @@ class ThumbManagerBase: """base class for thumbnail management""" CACHE_DIR = EnvironmentSettings.CACHE_DIR + MEDIA_DIR = EnvironmentSettings.MEDIA_DIR VIDEO_DIR = os.path.join(CACHE_DIR, "videos") CHANNEL_DIR = os.path.join(CACHE_DIR, "channels") PLAYLIST_DIR = os.path.join(CACHE_DIR, "playlists") @@ -217,6 +216,63 @@ class ThumbManager(ThumbManagerBase): img_raw = img_raw.resize((336, 189)) img_raw.convert("RGB").save(thumb_path) + def embed_video_art(self, json_data: dict): + """embed video artwork""" + file_path = os.path.join(self.MEDIA_DIR, json_data["media_url"]) + if not os.path.exists(file_path): + print(f"{self.item_id}: skip art embed, file not found") + return + + video = MP4(file_path) + + thumb_path = self.vid_thumb_path(absolute=True) + if os.path.exists(thumb_path): + with open(thumb_path, "rb") as f: + cover_data = f.read() + + video["covr"] = [ + MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + channel_id = json_data["channel"]["channel_id"] + banner_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_banner.jpg" + ) + self._embed_art_item(video, "channel_banner", art_path=banner_path) + + channel_icon_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_thumb.jpg" + ) + self._embed_art_item(video, "channel_icon", art_path=channel_icon_path) + + channel_tv_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_tvart.jpg" + ) + self._embed_art_item(video, "channel_tv", art_path=channel_tv_path) + + playlist_ids = json_data.get("playlist", []) + for plalyist_id in playlist_ids: + playlist_path = os.path.join( + self.PLAYLIST_DIR, f"{plalyist_id}.jpg" + ) + self._embed_art_item( + video, f"playlist_{plalyist_id}", art_path=playlist_path + ) + + video.save() + + def _embed_art_item(self, video, key, art_path): + """embed single item""" + if not os.path.exists(art_path): + return + + with open(art_path, "rb") as f: + art_data = f.read() + + video[f"----:com.tubearchivist:{key}"] = [ + MP4Cover(art_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + def delete_video_thumb(self): """delete video thumbnail if exists""" thumb_path = self.vid_thumb_path() @@ -228,10 +284,13 @@ class ThumbManager(ThumbManagerBase): """delete all artwork of channel""" thumb = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_thumb.jpg") banner = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_banner.jpg") + tv = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_tvart.jpg") if os.path.exists(thumb): os.remove(thumb) if os.path.exists(banner): os.remove(banner) + if os.path.exists(tv): + os.remove(tv) def delete_playlist_thumb(self): """delete playlist thumbnail""" @@ -239,20 +298,6 @@ class ThumbManager(ThumbManagerBase): if os.path.exists(thumb_path): os.remove(thumb_path) - def get_vid_base64_blur(self): - """return base64 encoded placeholder""" - file_path = os.path.join(self.CACHE_DIR, self.vid_thumb_path()) - img_raw = Image.open(file_path) - img_raw.thumbnail((img_raw.width // 20, img_raw.height // 20)) - img_blur = img_raw.filter(ImageFilter.BLUR) - buffer = BytesIO() - img_blur.save(buffer, format="JPEG") - img_data = buffer.getvalue() - img_base64 = base64.b64encode(img_data).decode() - data_url = f"data:image/jpg;base64,{img_base64}" - - return data_url - class ValidatorCallback: """handle callback validate thumbnails page by page""" @@ -283,9 +328,9 @@ class ValidatorCallback: """check if all channel artwork is there""" for channel in self.source: urls = ( - channel["_source"]["channel_thumb_url"], - channel["_source"]["channel_banner_url"], - channel["_source"].get("channel_tvart_url", False), + channel["_source"].get("channel_thumb_url"), + channel["_source"].get("channel_banner_url"), + channel["_source"].get("channel_tvart_url"), ) handler = ThumbManager(channel["_source"]["channel_id"]) handler.download_channel_art(urls, skip_existing=True) @@ -458,12 +503,12 @@ class ThumbFilesystem: """entry point""" data = { "query": {"match_all": {}}, - "_source": ["media_url", "youtube_id"], + "_source": ["media_url", "youtube_id", "channel.channel_id"], } paginate = IndexPaginate( index_name=self.INDEX_NAME, data=data, - size=200, + size=100, callback=EmbedCallback, task=self.task, total=self._get_total(), @@ -481,9 +526,7 @@ class ThumbFilesystem: class EmbedCallback: """callback class to embed thumbnails""" - CACHE_DIR = EnvironmentSettings.CACHE_DIR MEDIA_DIR = EnvironmentSettings.MEDIA_DIR - FORMAT = MP4Cover.FORMAT_JPEG def __init__(self, source, index_name, counter=0): self.source = source @@ -494,19 +537,4 @@ class EmbedCallback: """run embed""" for video in self.source: video_id = video["_source"]["youtube_id"] - media_url = os.path.join( - self.MEDIA_DIR, video["_source"]["media_url"] - ) - thumb_path = os.path.join( - self.CACHE_DIR, ThumbManager(video_id).vid_thumb_path() - ) - if os.path.exists(thumb_path): - self.embed(media_url, thumb_path) - - def embed(self, media_url, thumb_path): - """embed thumb in single media file""" - video = MP4(media_url) - with open(thumb_path, "rb") as f: - video["covr"] = [MP4Cover(f.read(), imageformat=self.FORMAT)] - - video.save() + ThumbManager(video_id).embed_video_art(video["_source"]) diff --git a/backend/download/src/yt_dlp_base.py b/backend/download/src/yt_dlp_base.py index 909931c0..ea65ca77 100644 --- a/backend/download/src/yt_dlp_base.py +++ b/backend/download/src/yt_dlp_base.py @@ -7,9 +7,12 @@ functionality: from datetime import datetime from http import cookiejar from io import StringIO +from os import path import yt_dlp from appsettings.src.config import AppConfig +from common.src.env_settings import EnvironmentSettings +from common.src.helper import deep_merge, rand_sleep from common.src.ta_redis import RedisArchivist from django.conf import settings @@ -17,12 +20,21 @@ from django.conf import settings class YtWrap: """wrap calls to yt""" + BOT_MESSAGES = [ + "not a bot", + ] + BOT_ERROR_LOG = "YouTube bot detection, abort!" + OBS_BASE = { "default_search": "ytsearch", "quiet": True, "socket_timeout": 10, "extractor_retries": 3, "retries": 10, + "cachedir": path.abspath( + path.join(EnvironmentSettings.CACHE_DIR, "ytdlp") + ), + "plugin_dirs": [], } def __init__(self, obs_request, config=False): @@ -33,10 +45,10 @@ class YtWrap: def build_obs(self): """build yt-dlp obs""" self.obs = self.OBS_BASE.copy() - self.obs.update(self.obs_request) + deep_merge(self.obs, self.obs_request) if self.config: self._add_cookie() - self._add_potoken() + self._add_potoken_url() if getattr(settings, "DEBUG", False): del self.obs["quiet"] @@ -46,22 +58,29 @@ class YtWrap: """add cookie if enabled""" if self.config["downloads"]["cookie_import"]: cookie_io = CookieHandler(self.config).get() - self.obs["cookiefile"] = cookie_io + else: + cookie_io = CookieHandler(self.config).get("cookie_temp") - def _add_potoken(self): - """add potoken if enabled""" - if self.config["downloads"].get("potoken"): - potoken = POTokenHandler(self.config).get() - self.obs.update( + self.obs["cookiefile"] = cookie_io + + def _add_potoken_url(self): + """add bgutils token url""" + if pot_provider_url := self.config["downloads"].get( + "pot_provider_url" + ): + deep_merge( + self.obs, { "extractor_args": { - "youtube": { - "po_token": [potoken], - "player-client": ["mweb", "default"], - }, + "youtubepot-bgutilhttp": { + "base_url": [pot_provider_url] + } } - } + }, ) + if EnvironmentSettings.APP_DIR == "/app": + # container internal only + self.obs["plugin_dirs"].append("/opt/yt_plugins/bgutil") def download(self, url): """make download request""" @@ -73,6 +92,10 @@ class YtWrap: print(f"{url}: failed to download with message {err}") if "Temporary failure in name resolution" in str(err): raise ConnectionError("lost the internet, abort!") from err + if any(m in str(err) for m in self.BOT_MESSAGES): + print(self.BOT_ERROR_LOG) + rand_sleep(self.config) + raise ConnectionError(self.BOT_ERROR_LOG) from err return False, str(err) @@ -101,6 +124,10 @@ class YtWrap: print(f"{url}: failed to get info from youtube: {err}") if "Temporary failure in name resolution" in str(err): raise ConnectionError("lost the internet, abort!") from err + if any(m in str(err) for m in self.BOT_MESSAGES): + print(self.BOT_ERROR_LOG) + rand_sleep(self.config) + raise ConnectionError(self.BOT_ERROR_LOG) from err return None, str(err) @@ -111,26 +138,40 @@ class YtWrap: def _validate_cookie(self): """check cookie and write it back for next use""" if not self.obs.get("cookiefile"): + # empty in tests return - new_cookie = self.obs["cookiefile"].read() - old_cookie = RedisArchivist().get_message_str("cookie") + self.obs["cookiefile"].seek(0) + new_cookie = self.obs["cookiefile"].read().strip("\x00") + + if self.config["downloads"]["cookie_import"]: + cookie_key = "cookie" + expire = False + else: + cookie_key = "cookie_temp" + expire = 60 * 30 # 30 min + + old_cookie = RedisArchivist().get_message_str(cookie_key) if new_cookie and old_cookie != new_cookie: - print("refreshed stored cookie") - RedisArchivist().set_message("cookie", new_cookie, save=True) + print(f"refreshed stored {cookie_key}") + RedisArchivist().set_message( + cookie_key, new_cookie, expire=expire, save=True + ) class CookieHandler: """handle youtube cookie for yt-dlp""" + COOKIE_EMPTY = "# Netscape HTTP Cookie File\n" + def __init__(self, config): self.cookie_io = False self.config = config - def get(self): + def get(self, message_str: str = "cookie"): """get cookie io stream""" - cookie = RedisArchivist().get_message_str("cookie") - self.cookie_io = StringIO(cookie) + cookie = RedisArchivist().get_message_str(message_str) + self.cookie_io = StringIO(cookie or self.COOKIE_EMPTY) return self.cookie_io def set_cookie(self, cookie): @@ -196,27 +237,3 @@ class CookieHandler: "validated_str": now.strftime("%Y-%m-%d %H:%M"), } RedisArchivist().set_message("cookie:valid", message, expire=3600) - - -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/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index a5702a4f..8aaf0234 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -196,15 +196,6 @@ class VideoDownloader(DownloaderBase): } ) - if self.config["downloads"]["add_thumbnail"]: - postprocessors.append( - { - "key": "EmbedThumbnail", - "already_have_thumbnail": True, - } - ) - self.obs["writethumbnail"] = True - self.obs["postprocessors"] = postprocessors def _set_overwrites(self, obs: dict, channel_id: str) -> None: @@ -326,6 +317,9 @@ class DownloadPostProcess(DownloaderBase): for channel_id, value in self.channel_overwrites.items(): if "autodelete_days" in value: autodelete_days = value.get("autodelete_days") + if autodelete_days is None: + continue + print(f"{channel_id}: delete older than {autodelete_days}d") now_lte = str(self.now - autodelete_days * 24 * 60 * 60) must_list = [ @@ -435,7 +429,7 @@ class DownloadPostProcess(DownloaderBase): channel = YoutubeChannel(channel_id) channel.get_from_es() overwrites = channel.get_overwrites() - if "index_playlists" in overwrites: + if overwrites.get("index_playlists"): channel.get_all_playlists() to_add = [i[0] for i in channel.all_playlists] RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add) diff --git a/backend/download/tests/test_src/test_pending_list.py b/backend/download/tests/test_src/test_pending_list.py index f8656c7c..4c821959 100644 --- a/backend/download/tests/test_src/test_pending_list.py +++ b/backend/download/tests/test_src/test_pending_list.py @@ -5,6 +5,18 @@ from datetime import datetime, timezone from download.src.queue import PendingList +def test_returns_scientific_timestamp_if_present(): + video_data = {"timestamp": 1.5135732e9} + result = PendingList._extract_published(video_data) + assert result == 1513573200 + + +def test_returns_scientific_timestamp_string_if_present(): + video_data = {"timestamp": "1.5135732e9"} + result = PendingList._extract_published(video_data) + assert result == 1513573200 + + def test_returns_timestamp_if_present(): video_data = {"timestamp": 1508457600} result = PendingList._extract_published(video_data) diff --git a/backend/playlist/serializers.py b/backend/playlist/serializers.py index cb2a12c7..8ab3a052 100644 --- a/backend/playlist/serializers.py +++ b/backend/playlist/serializers.py @@ -11,7 +11,7 @@ class PlaylistEntrySerializer(serializers.Serializer): youtube_id = serializers.CharField() title = serializers.CharField() - uploader = serializers.CharField() + uploader = serializers.CharField(allow_null=True) idx = serializers.IntegerField() downloaded = serializers.BooleanField() @@ -22,7 +22,9 @@ class PlaylistSerializer(serializers.Serializer): playlist_active = serializers.BooleanField() playlist_channel = serializers.CharField() playlist_channel_id = serializers.CharField() - playlist_description = serializers.CharField() + playlist_description = serializers.CharField( + allow_null=True, required=False + ) playlist_entries = PlaylistEntrySerializer(many=True) playlist_id = serializers.CharField() playlist_last_refresh = serializers.CharField() diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py index d2e6e301..5c1c2d33 100644 --- a/backend/playlist/src/index.py +++ b/backend/playlist/src/index.py @@ -81,10 +81,13 @@ class YoutubePlaylist(YouTubeItem): "playlist_channel": self.youtube_meta["channel"], "playlist_channel_id": self.youtube_meta["channel_id"], "playlist_thumbnail": playlist_thumbnail, - "playlist_description": self.youtube_meta["description"] or False, "playlist_last_refresh": int(datetime.now().timestamp()), "playlist_type": "regular", } + if self.youtube_meta.get("description"): + self.json_data["playlist_description"] = self.youtube_meta[ + "description" + ] def _ensure_channel(self): """make sure channel is indexed""" @@ -311,7 +314,10 @@ class YoutubePlaylist(YouTubeItem): self.del_in_es() def is_custom_playlist(self): - self.get_from_es() + """check if is custom playlist""" + if not self.json_data: + self.get_from_es() + return self.json_data["playlist_type"] == "custom" def delete_videos_metadata(self, channel_id=None): @@ -389,13 +395,16 @@ class YoutubePlaylist(YouTubeItem): return True def remove_playlist_from_video(self, video_id): + """remove playlist id from video metadata""" 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) - video.upload_to_es() + if self.youtube_id in video.json_data["playlist"]: + video.json_data["playlist"].remove(self.youtube_id) + video.upload_to_es() def move_video(self, video_id, action, hide_watched=False): + """move video within custion playlist based on action""" self.get_from_es() video_index = self.get_video_index(video_id) playlist = self.json_data["playlist_entries"] diff --git a/backend/requirements.plugins.txt b/backend/requirements.plugins.txt new file mode 100644 index 00000000..eaaf09fd --- /dev/null +++ b/backend/requirements.plugins.txt @@ -0,0 +1,2 @@ +# install plugins in separate folder for runtime whitelisting +bgutil-ytdlp-pot-provider==1.3.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 329dd967..3f370dac 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,14 +1,15 @@ -apprise==1.9.5 -celery==5.5.3 -django-auth-ldap==5.2.0 -django-celery-beat==2.8.1 +apprise==1.9.9 +celery==5.6.3 +deepdiff==8.6.2 +django-auth-ldap==5.3.0 +django-celery-beat==2.9.0 django-cors-headers==4.9.0 -Django==5.2.8 -djangorestframework==3.16.1 -drf-spectacular==0.29.0 -Pillow==12.0.0 -redis==7.0.1 -requests==2.32.5 +Django==6.0.3 +djangorestframework==3.17.1 +drf-spectacular==0.28.0 # rc:ignore +Pillow==12.1.1 +redis==7.4.0 +requests==2.33.0 ryd-client==0.0.6 -uvicorn==0.38.0 -yt-dlp[default]==2025.11.12 +uvicorn==0.42.0 +yt-dlp[default]==2026.3.17 diff --git a/backend/task/src/task_config.py b/backend/task/src/task_config.py index 9fd494e9..6442dd22 100644 --- a/backend/task/src/task_config.py +++ b/backend/task/src/task_config.py @@ -48,7 +48,7 @@ CHECK_REINDEX: TaskItemConfig = { MANUAL_IMPORT: TaskItemConfig = { "title": "Manual video import", "group": "setting:import", - "api_start": True, + "api_start": False, "api_stop": False, } @@ -69,7 +69,7 @@ RESTORE_BACKUP: TaskItemConfig = { RESCAN_FILESYSTEM: TaskItemConfig = { "title": "Rescan your Filesystem", "group": "setting:filesystemscan", - "api_start": True, + "api_start": False, "api_stop": False, } @@ -80,13 +80,6 @@ THUMBNAIL_CHECK: TaskItemConfig = { "api_stop": False, } -RESYNC_THUMBS: TaskItemConfig = { - "title": "Sync Thumbnails to Media Files", - "group": "setting:thumbnailsync", - "api_start": True, - "api_stop": False, -} - RESYNC_METADATA: TaskItemConfig = { "title": "Sync Metadata to Media Files", "group": "setting:thumbnailsync", @@ -125,7 +118,6 @@ TASK_CONFIG: dict[str, TaskItemConfig] = { "restore_backup": RESTORE_BACKUP, "rescan_filesystem": RESCAN_FILESYSTEM, "thumbnail_check": THUMBNAIL_CHECK, - "resync_thumbs": RESYNC_THUMBS, "resync_metadata": RESYNC_METADATA, "index_playlists": INDEX_PLAYLISTS, "subscribe_to": SUBSCRIBE_TO, diff --git a/backend/task/src/task_manager.py b/backend/task/src/task_manager.py index ea48e5e8..81b612ec 100644 --- a/backend/task/src/task_manager.py +++ b/backend/task/src/task_manager.py @@ -84,9 +84,9 @@ class TaskManager: class TaskCommand: """run commands on task""" - def start(self, task_name): + def start(self, task_name, kwargs: dict | None = None): """start task by task_name, only pass task that don't take args""" - task = celery_app.tasks.get(task_name).delay() + task = celery_app.tasks.get(task_name).delay(**(kwargs or {})) message = { "task_id": task.id, "status": task.status, diff --git a/backend/task/tasks.py b/backend/task/tasks.py index 995e1c85..1b4293b3 100644 --- a/backend/task/tasks.py +++ b/backend/task/tasks.py @@ -19,7 +19,7 @@ from common.src.ta_redis import RedisArchivist from common.src.urlparser import ParsedURLType, Parser from download.src.queue import PendingList from download.src.subscriptions import SubscriptionHandler, SubscriptionScanner -from download.src.thumbnails import ThumbFilesystem, ThumbValidator +from download.src.thumbnails import ThumbValidator from download.src.yt_dlp_handler import VideoDownloader from task.src.notify import Notifications from task.src.task_config import TASK_CONFIG @@ -216,7 +216,7 @@ def check_reindex(self, data=False, extract_videos=False): @shared_task(bind=True, name="manual_import", base=BaseTask) -def run_manual_import(self): +def manual_import(self, ignore_error, prefer_local): """called from settings page, to go through import folder""" manager = TaskManager() if manager.is_pending(self): @@ -225,7 +225,9 @@ def run_manual_import(self): return manager.init(self) - ImportFolderScanner(task=self).scan() + ImportFolderScanner( + task=self, ignore_error=ignore_error, prefer_local=prefer_local + ).scan() @shared_task(bind=True, name="run_backup", base=BaseTask) @@ -260,7 +262,7 @@ def run_restore_backup(self, filename): @shared_task(bind=True, name="rescan_filesystem", base=BaseTask) -def rescan_filesystem(self): +def rescan_filesystem(self, ignore_error, prefer_local): """check the media folder for mismatches""" manager = TaskManager() if manager.is_pending(self): @@ -269,7 +271,9 @@ def rescan_filesystem(self): return manager.init(self) - handler = Scanner(task=self) + handler = Scanner( + task=self, ignore_error=ignore_error, prefer_local=prefer_local + ) handler.scan() handler.apply() thumbnail_check.delay() @@ -290,19 +294,6 @@ def thumbnail_check(self): thumbnail.clean_up() -@shared_task(bind=True, name="resync_thumbs", base=BaseTask) -def re_sync_thumbs(self): - """sync thumbnails to mediafiles""" - manager = TaskManager() - if manager.is_pending(self): - print(f"[task][{self.name}] thumb re-embed is already running") - self.send_progress(["Thumbnail re-embed is already running."]) - return - - manager.init(self) - ThumbFilesystem(task=self).embed() - - @shared_task(bind=True, name="resync_metadata", base=BaseTask) def re_sync_metadata(self): """resync metadata to media files""" diff --git a/backend/user/migrations/0001_initial.py b/backend/user/migrations/0001_initial.py index 7933db76..3b965203 100644 --- a/backend/user/migrations/0001_initial.py +++ b/backend/user/migrations/0001_initial.py @@ -39,7 +39,9 @@ class Migration(migrations.Migration): "is_superuser", models.BooleanField( default=False, - help_text="Designates that this user has all permissions without explicitly assigning them.", + help_text=( + "Designates that this user has all permissions without explicitly assigning them." + ), verbose_name="superuser status", ), ), @@ -49,7 +51,9 @@ class Migration(migrations.Migration): "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.", + 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", diff --git a/backend/user/src/user_config.py b/backend/user/src/user_config.py index 8cd5c01d..b4543b3a 100644 --- a/backend/user/src/user_config.py +++ b/backend/user/src/user_config.py @@ -40,7 +40,7 @@ class UserConfig: _DEFAULT_USER_SETTINGS = UserConfigType( stylesheet="dark.css", - page_size=12, + page_size=25, sort_by="published", sort_order="desc", view_style_home="grid", diff --git a/backend/video/serializers.py b/backend/video/serializers.py index 55d229f0..2182272a 100644 --- a/backend/video/serializers.py +++ b/backend/video/serializers.py @@ -4,6 +4,7 @@ from channel.serializers import ChannelSerializer from common.serializers import PaginationSerializer +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum, WatchedEnum @@ -15,6 +16,7 @@ class PlayerSerializer(serializers.Serializer): watched_date = serializers.IntegerField(required=False) duration = serializers.IntegerField() duration_str = serializers.CharField() + progress = serializers.FloatField(required=False) position = serializers.FloatField(required=False) @@ -43,32 +45,49 @@ class SponsorBlockSerializer(serializers.Serializer): class StatsSerializer(serializers.Serializer): """serialize stats""" - like_count = serializers.IntegerField(required=False) - average_rating = serializers.FloatField(required=False) - view_count = serializers.IntegerField(required=False) - dislike_count = serializers.IntegerField(required=False) + like_count = serializers.IntegerField() + average_rating = serializers.FloatField() + view_count = serializers.IntegerField() + dislike_count = serializers.IntegerField() class StreamItemSerializer(serializers.Serializer): """serialize stream item""" - index = serializers.IntegerField() - codec = serializers.CharField() bitrate = serializers.IntegerField() + codec = serializers.CharField() + height = serializers.IntegerField(required=False) + index = serializers.IntegerField() type = serializers.ChoiceField(choices=["video", "audio"]) width = serializers.IntegerField(required=False) - height = serializers.IntegerField(required=False) + + +class SubtitleFragmentSerializer(serializers.Serializer): + """serialize subtitle fragment""" + + subtitle_channel = serializers.CharField() + subtitle_channel_id = serializers.CharField() + subtitle_end = serializers.CharField() + subtitle_fragment_id = serializers.CharField() + subtitle_index = serializers.IntegerField() + subtitle_lang = serializers.CharField() + subtitle_last_refresh = serializers.IntegerField() + subtitle_line = serializers.CharField() + subtitle_source = serializers.ChoiceField(choices=["user", "auto"]) + subtitle_start = serializers.CharField() + title = serializers.CharField() + youtube_id = serializers.CharField() class SubtitleItemSerializer(serializers.Serializer): """serialize subtitle item""" - ext = serializers.ChoiceField(choices=["json3"]) - name = serializers.CharField() - source = serializers.ChoiceField(choices=["user", "auto"]) + ext = serializers.ChoiceField(choices=["json3", "vtt"]) lang = serializers.CharField() media_url = serializers.CharField() - url = serializers.URLField() + name = serializers.CharField() + source = serializers.ChoiceField(choices=["user", "auto"]) + url = serializers.URLField(allow_null=True) class VideoSerializer(serializers.Serializer): @@ -76,19 +95,21 @@ class VideoSerializer(serializers.Serializer): active = serializers.BooleanField() category = serializers.ListField(child=serializers.CharField()) - channel = ChannelSerializer() - comment_count = serializers.IntegerField(allow_null=True) + channel = ChannelSerializer(required=False) + comment_count = serializers.IntegerField(allow_null=True, required=False) date_downloaded = serializers.IntegerField() - description = serializers.CharField() + description = serializers.CharField(allow_null=True, required=False) media_size = serializers.IntegerField() media_url = serializers.CharField() player = PlayerSerializer() - playlist = serializers.ListField(child=serializers.CharField()) + playlist = serializers.ListField( + child=serializers.CharField(), required=False + ) published = serializers.CharField() - sponsorblock = SponsorBlockSerializer(allow_null=True) + sponsorblock = SponsorBlockSerializer(allow_null=True, required=False) stats = StatsSerializer() streams = StreamItemSerializer(many=True) - subtitles = SubtitleItemSerializer(many=True) + subtitles = SubtitleItemSerializer(many=True, required=False) tags = serializers.ListField(child=serializers.CharField()) title = serializers.CharField() vid_last_refresh = serializers.CharField() @@ -123,37 +144,38 @@ class VideoListQuerySerializer(serializers.Serializer): height = serializers.IntegerField(required=False) -class CommentThreadItemSerializer(serializers.Serializer): - """serialize comment thread item""" - - comment_id = serializers.CharField() - comment_text = serializers.CharField() - comment_timestamp = serializers.IntegerField() - comment_time_text = serializers.CharField() - comment_likecount = serializers.IntegerField() - comment_is_favorited = serializers.BooleanField() - comment_author = serializers.CharField() - comment_author_id = serializers.CharField() - comment_author_thumbnail = serializers.URLField() - comment_author_is_uploader = serializers.BooleanField() - comment_parent = serializers.CharField() - - class CommentItemSerializer(serializers.Serializer): """serialize comment item""" - comment_id = serializers.CharField() - comment_text = serializers.CharField() - comment_timestamp = serializers.IntegerField() - comment_time_text = serializers.CharField() - comment_likecount = serializers.IntegerField() - comment_is_favorited = serializers.BooleanField() comment_author = serializers.CharField() comment_author_id = serializers.CharField() - comment_author_thumbnail = serializers.URLField() comment_author_is_uploader = serializers.BooleanField() + comment_author_thumbnail = serializers.URLField() + comment_id = serializers.CharField() + comment_is_favorited = serializers.BooleanField() + comment_likecount = serializers.IntegerField() comment_parent = serializers.CharField() - comment_replies = CommentThreadItemSerializer(many=True) + comment_text = serializers.CharField() + comment_time_text = serializers.CharField() + comment_timestamp = serializers.IntegerField() + + comment_replies = serializers.SerializerMethodField() + + @extend_schema_field(serializers.ListField()) + def get_comment_replies(self, obj): + """recursive replies""" + return CommentItemSerializer( + obj.get("comment_replies", []), many=True + ).data + + +class CommentsSerializer(serializers.Serializer): + """serialize comments as indexed""" + + comment_channel_id = serializers.CharField() + comment_comments = CommentItemSerializer(many=True) + comment_last_refresh = serializers.IntegerField() + youtube_id = serializers.CharField() class PlaylistNavMetaSerializer(serializers.Serializer): diff --git a/backend/video/src/comments.py b/backend/video/src/comments.py index 4b145a75..5aeff6ba 100644 --- a/backend/video/src/comments.py +++ b/backend/video/src/comments.py @@ -25,7 +25,7 @@ class Comments: self.is_activated = False self.comments_format = False - def build_json(self): + def build_json(self, upload: bool = False): """build json document for es""" print(f"{self.youtube_id}: get comments") self.check_config() @@ -39,11 +39,13 @@ class Comments: self.format_comments(comments_raw) self.json_data = { - "youtube_id": self.youtube_id, - "comment_last_refresh": int(datetime.now().timestamp()), "comment_channel_id": channel_id, "comment_comments": self.comments_format, + "comment_last_refresh": int(datetime.now().timestamp()), + "youtube_id": self.youtube_id, } + if upload: + self.upload_comments() def check_config(self): """read config if not attached""" @@ -122,34 +124,33 @@ class Comments: if not comment.get("author"): comment["author"] = comment.get("author_id", "Unknown") + is_uploader = comment.get("author_is_uploader", False) + cleaned_comment = { - "comment_id": comment["id"], - "comment_text": comment["text"].replace("\xa0", ""), - "comment_timestamp": comment["timestamp"], - "comment_time_text": time_text, - "comment_likecount": comment.get("like_count", None), - "comment_is_favorited": comment.get("is_favorited", False), "comment_author": comment["author"], "comment_author_id": comment["author_id"], + "comment_author_is_uploader": is_uploader, "comment_author_thumbnail": comment["author_thumbnail"], - "comment_author_is_uploader": comment.get( - "author_is_uploader", False - ), + "comment_id": comment["id"], + "comment_is_favorited": comment.get("is_favorited", False), + "comment_likecount": comment.get("like_count", None), "comment_parent": comment["parent"], + "comment_text": comment["text"].replace("\xa0", ""), + "comment_time_text": time_text, + "comment_timestamp": comment["timestamp"], } return cleaned_comment def upload_comments(self): """upload comments to es""" - if not self.is_activated: - return - print(f"{self.youtube_id}: upload comments") _, _ = ElasticWrap(self.es_path).put(self.json_data) vid_path = f"ta_video/_update/{self.youtube_id}" - data = {"doc": {"comment_count": len(self.comments_format)}} + data = { + "doc": {"comment_count": len(self.json_data["comment_comments"])} + } _, _ = ElasticWrap(vid_path).post(data=data) def delete_comments(self): diff --git a/backend/video/src/index.py b/backend/video/src/index.py index 0dbb2aae..00250d17 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -15,7 +15,7 @@ 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 download.src.thumbnails import ThumbManager -from mutagen.mp4 import MP4 +from mutagen.mp4 import MP4, MP4MetadataError from playlist.src import index as ta_playlist from ryd_client import ryd_client from user.src.user_config import UserConfig @@ -198,31 +198,40 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def process_youtube_meta(self): """extract relevant fields from youtube""" self._validate_id() - # extract self.channel_id = self.youtube_meta["channel_id"] last_refresh = int(datetime.now().timestamp()) - # build json_data basics self.json_data = { - "title": self.youtube_meta["title"], - "description": self.youtube_meta.get("description", ""), - "category": self.youtube_meta.get("categories", []), - "vid_thumb_url": self.youtube_meta["thumbnail"], - "tags": self.youtube_meta.get("tags", []), - "published": self._build_published(), - "vid_last_refresh": last_refresh, - "date_downloaded": last_refresh, - "youtube_id": self.youtube_id, - # Using .value to make json encodable - "vid_type": self.video_type.value, "active": True, + "category": self.youtube_meta.get("categories", []), + "date_downloaded": last_refresh, + "published": self._build_published(), + "tags": self.youtube_meta.get("tags", []), + "title": self.youtube_meta["title"], + "vid_last_refresh": last_refresh, + "vid_thumb_url": self.youtube_meta["thumbnail"], + "vid_type": self.video_type.value, + "youtube_id": self.youtube_id, } - def _build_published(self): + if description := self.youtube_meta.get("description"): + self.json_data["description"] = description + + def _build_published(self) -> int | str: """build published date or timestamp""" timestamp = self.youtube_meta.get("timestamp") - if timestamp: + if timestamp and isinstance(timestamp, int): return timestamp + if timestamp and isinstance(timestamp, float): + return int(timestamp) + + if timestamp and isinstance(timestamp, str): + try: + # scientific string + return int(float(timestamp)) + except (TypeError, ValueError): + pass + upload_date = self.youtube_meta["upload_date"] if not upload_date: raise ValueError( @@ -255,10 +264,10 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def _add_stats(self): """add stats dicst to json_data""" stats = { - "view_count": self.youtube_meta.get("view_count", 0), - "like_count": self.youtube_meta.get("like_count", 0), - "dislike_count": self.youtube_meta.get("dislike_count", 0), - "average_rating": self.youtube_meta.get("average_rating", 0), + "view_count": self.youtube_meta.get("view_count") or 0, + "like_count": self.youtube_meta.get("like_count") or 0, + "dislike_count": self.youtube_meta.get("dislike_count") or 0, + "average_rating": self.youtube_meta.get("average_rating") or 0, } self.json_data.update({"stats": stats}) @@ -288,9 +297,9 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): self.json_data.update( { "player": { - "watched": False, "duration": duration, "duration_str": get_duration_str(duration), + "watched": False, } } ) @@ -379,8 +388,8 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): return dislikes = { - "dislike_count": result.get("dislikes", 0), - "average_rating": result.get("rating", 0), + "dislike_count": result.get("dislikes") or 0, + "average_rating": result.get("rating") or 0, } self.json_data["stats"].update(dislikes) @@ -397,6 +406,10 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): self.json_data["subtitles"] = indexed return + if not self.youtube_meta: + print(f"{self.youtube_id}: skip subtitle check without metadata") + return + handler = YoutubeSubtitle(self) subtitles = handler.get_subtitles() if subtitles: @@ -412,7 +425,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): subtitle_media_url = f"{base_name}.{lang}.vtt" to_add = { "ext": "vtt", - "url": False, + "url": None, "name": lang, "lang": lang, "source": "file", @@ -424,10 +437,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def embed_metadata(self): """embed metadata for video""" - if not self.config["downloads"].get("add_metadata"): - return - - print(f"{self.youtube_id}: embed metadata") if not self.json_data: self.get_from_es() @@ -435,6 +444,16 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): print(f"{self.youtube_id}: skip embed, video not indexed") return + if self.config["downloads"].get("add_metadata"): + try: + self._embed_text_data() + self._embed_artwork() + except MP4MetadataError as err: + print(f"{self.youtube_id}: embed failed: '{str(err)}'") + + def _embed_text_data(self): + """embed text metadata""" + print(f"{self.youtube_id}: embed metadata") video_base = EnvironmentSettings.MEDIA_DIR media_url = self.json_data.get("media_url") file_path = os.path.join(video_base, media_url) @@ -444,14 +463,16 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): title = self.json_data["title"] artist = self.json_data["channel"]["channel_name"] - description = self.json_data["description"] + description = self.json_data.get("description", "") to_embed = self._get_to_embed() video = MP4(file_path) video["\xa9nam"] = [title] # title video["\xa9ART"] = [artist] # artist - video["desc"] = [description] # description - video["ldes"] = [description] # synopsis + if description: + video["desc"] = [description] # description + video["ldes"] = [description] # synopsis + video["----:com.tubearchivist:ta"] = [to_embed.encode("utf-8")] video.save() @@ -479,16 +500,30 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): "comments": comments, "subtitles": subtitles, "playlists": playlists, + "version": settings.TA_VERSION, } ) return to_embed + def _embed_artwork(self): + """embed artwork""" + print(f"{self.youtube_id}: embed artwork") + ThumbManager(self.youtube_id).embed_video_art(self.json_data) + def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS): """combined classes to create new video in index""" + from appsettings.src.reindex import Reindex + video = YoutubeVideo(youtube_id, video_type=video_type) - video.build_json() + video.get_from_es(print_error=False) + if video.json_data: + # reindex only for force redownload + video = Reindex().reindex_single_video(youtube_id=youtube_id) + else: + video.build_json() + if not video.json_data: raise ValueError("failed to get metadata for " + youtube_id) diff --git a/backend/video/src/media_streams.py b/backend/video/src/media_streams.py index 0d8c1823..93e1f983 100644 --- a/backend/video/src/media_streams.py +++ b/backend/video/src/media_streams.py @@ -12,7 +12,7 @@ class MediaStreamExtractor: self.media_path = media_path self.metadata = [] - def extract_metadata(self): + def extract_metadata(self) -> list[dict]: """entry point to extract metadata""" cmd = [ @@ -38,17 +38,15 @@ class MediaStreamExtractor: return self.metadata - def process_stream(self, stream): + def process_stream(self, stream) -> None: """parse stream to metadata""" codec_type = stream.get("codec_type") if codec_type == "video": self._extract_video_metadata(stream) elif codec_type == "audio": self._extract_audio_metadata(stream) - else: - return - def _extract_video_metadata(self, stream): + def _extract_video_metadata(self, stream) -> None: """parse video metadata""" if "bit_rate" not in stream: # is probably thumbnail @@ -56,26 +54,26 @@ class MediaStreamExtractor: self.metadata.append( { - "type": "video", - "index": stream["index"], + "bitrate": int(stream.get("bit_rate", 0)), "codec": stream["codec_name"], - "width": stream["width"], "height": stream["height"], - "bitrate": int(stream["bit_rate"]), + "index": stream["index"], + "type": "video", + "width": stream["width"], } ) - def _extract_audio_metadata(self, stream): + def _extract_audio_metadata(self, stream) -> None: """extract audio metadata""" self.metadata.append( { - "type": "audio", - "index": stream["index"], - "codec": stream.get("codec_name", "undefined"), "bitrate": int(stream.get("bit_rate", 0)), + "codec": stream.get("codec_name", "undefined"), + "index": stream["index"], + "type": "audio", } ) - def get_file_size(self): + def get_file_size(self) -> int: """get filesize in bytes""" return stat(self.media_path).st_size diff --git a/backend/video/src/meta_embed.py b/backend/video/src/meta_embed.py index 0a7f62e1..e90ac66a 100644 --- a/backend/video/src/meta_embed.py +++ b/backend/video/src/meta_embed.py @@ -1,7 +1,30 @@ -"""bulk metadata embedding""" +""" +Functionality: +- bulk metadata embedding +- restore from embedded metadata +""" +import json +import os +import shutil + +from appsettings.src.config import AppConfig, AppConfigType +from channel.serializers import ChannelSerializer +from channel.src.index import YoutubeChannel +from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate +from download.src.thumbnails import ThumbManager +from mutagen.mp4 import MP4, MP4FreeForm +from playlist.serializers import PlaylistSerializer +from playlist.src.index import YoutubePlaylist +from video.serializers import ( + CommentsSerializer, + SubtitleFragmentSerializer, + VideoSerializer, +) +from video.src.comments import Comments from video.src.index import YoutubeVideo +from video.src.subtitle import SubtitleParser, YoutubeSubtitle class MetadataEmbed: @@ -21,10 +44,11 @@ class MetadataEmbed: paginate = IndexPaginate( index_name=self.INDEX_NAME, data=data, - size=200, + size=100, callback=MetadataEmbedCallback, task=self.task, total=self._get_total(), + pit_keep_alive=1000, ) _ = paginate.get_results() @@ -49,3 +73,366 @@ class MetadataEmbedCallback: for video in self.source: youtube_id = video["_source"]["youtube_id"] YoutubeVideo(youtube_id).embed_metadata() + + +class IndexFromEmbed: + """restore from embedded metadata, potential untrusted""" + + VIDEOS_BASE = EnvironmentSettings.MEDIA_DIR + CACHE_DIR = EnvironmentSettings.CACHE_DIR + HOST_UID = EnvironmentSettings.HOST_UID + HOST_GID = EnvironmentSettings.HOST_GID + + def __init__( + self, + file_path: str, + use_user_conf: bool = True, + config: AppConfigType | None = None, + ): + self.file_path = file_path + self.use_user_conf = use_user_conf + self.config = config + + def run_index(self) -> None | dict: + """run index""" + if not self.config: + self.config = AppConfig().config + + json_embed = self._get_embedded() + if not json_embed: + return None + + channel_data_clean = self.index_channel(json_embed) + video = self.index_video(json_embed, channel_data_clean) + self.index_subtitles(json_embed, video) + self.index_comments(json_embed) + self.restore_artwork(video) + self.index_playlists(json_embed, video) + self.archive_video(video) + + return video.json_data + + def _get_embedded(self) -> dict | None: + """get embedded metadata""" + video_mutagen = MP4(self.file_path) + ta_data = video_mutagen.get("----:com.tubearchivist:ta") + if not ta_data: + return None + + if not isinstance(ta_data, list): + raise ValueError(f"[{self.file_path}] unexpected embedded data") + + to_decode = ta_data[0] + + if not isinstance(to_decode, MP4FreeForm): + raise ValueError(f"[{self.file_path}] unexpected embedded data") + + try: + json_embed = json.loads(to_decode.decode()) + except Exception as exc: # pylint: disable=broad-exception-caught + err = f"[{self.file_path}] embedded decoding failed: {str(exc)}" + raise ValueError(err) from exc + + if not json_embed.get("video"): + err = f"[{self.file_path}] embedded does not contain video key" + raise ValueError(err) + + return json_embed + + def index_channel(self, json_embed): + """index channel""" + channel_data = json_embed["video"].get("channel") + if not channel_data: + raise ValueError(f"[{self.file_path}] missing channel metadata") + + serializer = ChannelSerializer(data=channel_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] channel serializer failed: {err}" + ) + + channel_data_clean = dict(serializer.data) + if not self.use_user_conf: + if "channel_overwrites" in channel_data_clean: + channel_data_clean.pop("channel_overwrites") + + channel_data_clean["channel_subscribed"] = False + + channel = YoutubeChannel(youtube_id=channel_data_clean["channel_id"]) + channel.get_from_es() + if not channel.json_data: + channel.json_data = channel_data_clean + channel.upload_to_es() + + return channel.json_data + + def index_video(self, json_embed, channel_data_clean): + """index video""" + video_data = json_embed["video"] + video_data.pop("channel") + + serializer = VideoSerializer(data=video_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] video serializer failed: {err}" + ) + + video_data_clean = dict(serializer.data) + if not self.use_user_conf: + video_data_clean["player"]["watched"] = False + + video_data_clean["channel"] = channel_data_clean + video = YoutubeVideo(youtube_id=video_data_clean["youtube_id"]) + video.get_from_es() + if not video.json_data: + video.json_data = video_data_clean + video.upload_to_es() + + return video + + def archive_video(self, video): + """archive video file""" + channel_id = video.json_data["channel"]["channel_id"] + folder = os.path.join(self.VIDEOS_BASE, channel_id) + if not os.path.exists(folder): + os.makedirs(folder) + if self.HOST_UID and self.HOST_GID: + os.chown(folder, self.HOST_UID, self.HOST_GID) + + new_path = os.path.join(folder, f"{video.youtube_id}.mp4") + if self.file_path == new_path: + # already archived + return + + shutil.move(self.file_path, new_path, copy_function=shutil.copyfile) + if self.HOST_UID and self.HOST_GID: + os.chown(new_path, self.HOST_UID, self.HOST_GID) + + def index_playlists(self, json_embed, video): + """index playlists""" + playlist_data = json_embed.get("playlists") + if not playlist_data or not isinstance(playlist_data, list): + return + + serializer = PlaylistSerializer(data=playlist_data, many=True) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] playlist serializer failed: {err}" + ) + + expected = video.json_data.get("playlist", []) + video_mutagen = MP4(self.file_path) + + for playlist_data in serializer.data: + playlist_id = playlist_data["playlist_id"] + if playlist_id not in expected: + continue + + json_data = self._process_embedded_playlist(playlist_data) + if not json_data: + continue + + playlist_art = os.path.join( + self.CACHE_DIR, "playlists", f"{playlist_id}.jpg" + ) + self._restore_art_item( + video_mutagen, + "----:com.tubearchivist:playlist_{plalyist_id}", + playlist_art, + ) + + def _process_embedded_playlist(self, playlist_data) -> dict | None: + """process single embedded playlist""" + if not self.use_user_conf: + if playlist_data["playlist_type"] == "custom": + # custom playlist is user conf + return None + + playlist = YoutubePlaylist(youtube_id=playlist_data["playlist_id"]) + playlist.get_from_es() + if playlist.json_data: + # already indexed + return None + + playlist_data_clean = dict(playlist_data) + if not self.use_user_conf: + playlist_data_clean["playlist_subscribed"] = False + playlist_data_clean["playlist_sort_order"] = "top" + + playlist.json_data = playlist_data_clean + playlist.upload_to_es() + + return playlist.json_data + + def restore_artwork(self, video): + """restore artwork if needed""" + video_mutagen = MP4(self.file_path) + + thumb = ThumbManager(video.youtube_id).vid_thumb_path(absolute=True) + self._restore_art_item(video_mutagen, "covr", thumb) + + channel_id = video.json_data["channel"]["channel_id"] + banner_path = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_banner.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_banner", banner_path + ) + + channel_icon = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_thumb.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_icon", channel_icon + ) + + tv_art_path = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_tvart.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_tv", tv_art_path + ) + + def _restore_art_item( + self, video_mutagen, mutagen_key: str, target_path: str + ) -> None: + """restore single art item""" + if os.path.exists(target_path): + # don't overwrite + return + + art_item = video_mutagen.get(mutagen_key) + if not art_item and not isinstance(art_item, list): + # is not embedded + return + + art_folder = os.path.dirname(target_path) + if not os.path.exists(art_folder): + os.mkdir(art_folder) + + with open(target_path, "wb") as f: + f.write(bytes(art_item[0])) + + def index_subtitles(self, json_embed, video): + """index subtitles""" + subtitle_data = json_embed.get("subtitles") + if not subtitle_data: + return + + serializer = SubtitleFragmentSerializer(data=subtitle_data, many=True) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] subtitle serializer failed: {err}" + ) + + self._process_embedded_subs(video, subtitle_data=serializer.data) + + def _process_embedded_subs(self, video, subtitle_data): + """process single embedded subtitle""" + embedded_subs = { + (i["subtitle_lang"], i["subtitle_source"]) for i in subtitle_data + } + subs = YoutubeSubtitle(video) + response = subs.get_es_subtitles() + indexed = { + (i["subtitle_lang"], i["subtitle_source"]) for i in response + } + + for embedded_lang, embedded_source in embedded_subs: + needs_processing = self._process_subtitle( + indexed, embedded_lang, embedded_source + ) + if not needs_processing: + continue + + segments = [ + i + for i in subtitle_data + if i["subtitle_lang"] == embedded_lang + and i["subtitle_source"] == embedded_source + ] + to_index = sorted(segments, key=lambda d: d["subtitle_index"]) + parser = SubtitleParser( + subtitle_str="{}", lang=embedded_lang, source=embedded_source + ) + + for segment in to_index: + parser.all_cues.append( + { + "start": segment["subtitle_start"], + "end": segment["subtitle_end"], + "text": segment["subtitle_line"], + "idx": segment["subtitle_index"], + } + ) + + subtitle_str = parser.get_subtitle_str() + query_str = parser.create_bulk_import(to_index) + subs.index_subtitle(query_str) + + media_url = subs.get_media_url(lang=embedded_lang) + dest_path = os.path.join(self.VIDEOS_BASE, media_url) + subs.write_subtitle_file(dest_path, subtitle_str) + + def _process_subtitle( + self, indexed, embedded_lang, embedded_source + ) -> bool: + """check if subtitle should be processed""" + for sub_indexed in indexed: + if ( + sub_indexed.get("lang") == embedded_lang + and sub_indexed.get("source") == embedded_source + ): + # already indexed + return False + + if not self.use_user_conf: + return True + + if not self.config: + return False + + langs = self.config["downloads"]["subtitle"] + source = self.config["downloads"]["subtitle_source"] + if not langs or not source: + return False + + lang_codes = [i.strip() for i in langs.split(",")] + if embedded_lang not in lang_codes: + return True + + return False + + def index_comments(self, json_embed): + """index comments""" + comment_data = json_embed.get("comments") + if not comment_data: + return + + serializer = CommentsSerializer(data=comment_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] comments serializer failed: {err}" + ) + + if self.use_user_conf: + if self.config and not self.config["downloads"]["comment_max"]: + return + + comments = Comments(youtube_id=serializer.data["youtube_id"]) + existing = comments.get_es_comments() + if existing: + return + + comments.json_data = dict(serializer.data) + comments.upload_comments() diff --git a/backend/video/src/subtitle.py b/backend/video/src/subtitle.py index 42e686c9..1fdd0919 100644 --- a/backend/video/src/subtitle.py +++ b/backend/video/src/subtitle.py @@ -10,14 +10,25 @@ import os import re from datetime import datetime from operator import itemgetter +from typing import TypedDict import requests from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.helper import rand_sleep, requests_headers +from download.src.yt_dlp_base import CookieHandler from yt_dlp.utils import orderedSet_from_options +class SubtitleCue(TypedDict): + """describe single subtitle queue""" + + start: str + end: str + text: str + idx: int + + class YoutubeSubtitle: """handle video subtitle functionality""" @@ -54,7 +65,9 @@ class YoutubeSubtitle: ) ] except re.error as e: - raise ValueError(f"wrong regex in subtitle config: {e.pattern}") + raise ValueError( + f"wrong regex in subtitle config: {e.pattern}" + ) from e return relevant_subtitles @@ -74,8 +87,7 @@ class YoutubeSubtitle: # not supported yet continue - video_media_url = self.video.json_data["media_url"] - media_url = video_media_url.replace(".mp4", f".{lang}.vtt") + media_url = self.get_media_url(lang) if not all_formats: # no subtitles found continue @@ -93,6 +105,12 @@ class YoutubeSubtitle: return candidate_subtitles + def get_media_url(self, lang: str) -> str: + """get media url""" + video_media_url = self.video.json_data["media_url"] + media_url = video_media_url.replace(".mp4", f".{lang}.vtt") + return media_url + def get_es_subtitles(self) -> list[dict]: """get subtitles from elastic""" data = { @@ -113,39 +131,75 @@ class YoutubeSubtitle: dest_path = os.path.join(videos_base, subtitle["media_url"]) source = subtitle["source"] lang = subtitle.get("lang") - response = requests.get( - subtitle["url"], headers=requests_headers(), timeout=30 - ) - if not response.ok: - subtitle_key = f"{self.video.youtube_id}-{lang}" - print(f"{subtitle_key}: failed to download subtitle") - print(response.text) - rand_sleep(self.video.config) + + response_text = self._make_request(subtitle["url"], lang) + if not response_text: continue - if not response.text: - print(f"{subtitle_key}: skip empty subtitle") - rand_sleep(self.video.config) - continue - - parser = SubtitleParser(response.text, lang, source) + parser = SubtitleParser(response_text, lang, source) parser.process() if not parser.all_cues: rand_sleep(self.video.config) continue subtitle_str = parser.get_subtitle_str() - self._write_subtitle_file(dest_path, subtitle_str) + self.write_subtitle_file(dest_path, subtitle_str) if self.video.config["downloads"]["subtitle_index"]: - query_str = parser.create_bulk_import(self.video, source) - self._index_subtitle(query_str) + documents = parser.create_documents(self.video, source) + query_str = parser.create_bulk_import(documents) + self.index_subtitle(query_str) - indexed.append(subtitle) + indexed.append( + { + "ext": "json3", + "name": subtitle["name"], + "source": subtitle["source"], + "lang": subtitle["lang"], + "media_url": subtitle["media_url"], + "url": subtitle["url"], + } + ) rand_sleep(self.video.config) return indexed - def _write_subtitle_file(self, dest_path, subtitle_str): + def _make_request(self, url: str, lang: str | None) -> str | None: + """make the request""" + request_kwargs: dict = { + "timeout": 30, + "headers": requests_headers(), + } + if self.video.config["downloads"].get("cookie_import"): + cookie = CookieHandler(self.video.config).get() + if cookie: + cookies_txt = cookie.read() + jar = requests.cookies.RequestsCookieJar() + for line in cookies_txt.split("\n"): + words = line.split() + if (len(words) == 7) and (words[0] != "#"): + jar.set( + words[5], words[6], domain=words[0], path=words[2] + ) + + request_kwargs["cookies"] = jar + + response = requests.get(url, **request_kwargs) + + if not response.ok: + subtitle_key = f"{self.video.youtube_id}-{lang}" + print(f"{subtitle_key}: failed to download subtitle") + print(response.text) + rand_sleep(self.video.config) + return None + + if not response.text: + print(f"{subtitle_key}: skip empty subtitle") + rand_sleep(self.video.config) + return None + + return response.text + + def write_subtitle_file(self, dest_path, subtitle_str): """write subtitle file to disk""" # create folder here for first video of channel os.makedirs(os.path.split(dest_path)[0], exist_ok=True) @@ -158,7 +212,7 @@ class YoutubeSubtitle: os.chown(dest_path, host_uid, host_gid) @staticmethod - def _index_subtitle(query_str): + def index_subtitle(query_str): """send subtitle to es for indexing""" _, _ = ElasticWrap("_bulk").post(data=query_str, ndjson=True) @@ -190,15 +244,14 @@ class YoutubeSubtitle: class SubtitleParser: """parse subtitle str from youtube""" - def __init__(self, subtitle_str, lang, source): + def __init__(self, subtitle_str: str, lang: str, source: str) -> None: self.subtitle_raw = json.loads(subtitle_str) self.lang = lang self.source = source - self.all_cues = False + self.all_cues: list[SubtitleCue] = [] - def process(self): + def process(self) -> None: """extract relevant que data""" - self.all_cues = [] all_events = self.subtitle_raw.get("events") if not all_events: @@ -213,12 +266,12 @@ class SubtitleParser: print(f"skipping subtitle event without content: {event}") continue - cue = { - "start": self._ms_conv(event["tStartMs"]), - "end": self._ms_conv(event["tStartMs"] + event["dDurationMs"]), - "text": "".join([i.get("utf8") for i in event["segs"]]), - "idx": idx + 1, - } + cue = SubtitleCue( + start=self._ms_conv(event["tStartMs"]), + end=self._ms_conv(event["tStartMs"] + event["dDurationMs"]), + text="".join([i.get("utf8") for i in event["segs"]]), + idx=idx + 1, + ) self.all_cues.append(cue) @staticmethod @@ -272,9 +325,8 @@ class SubtitleParser: return subtitle_str - def create_bulk_import(self, video, source): + def create_bulk_import(self, documents): """subtitle lines for es import""" - documents = self._create_documents(video, source) bulk_list = [] for document in documents: @@ -288,18 +340,18 @@ class SubtitleParser: return query_str - def _create_documents(self, video, source): + def create_documents(self, video, source): """process documents""" documents = self._chunk_list(video.youtube_id) channel = video.json_data.get("channel") meta_dict = { - "youtube_id": video.youtube_id, - "title": video.json_data.get("title"), "subtitle_channel": channel.get("channel_name"), "subtitle_channel_id": channel.get("channel_id"), - "subtitle_last_refresh": int(datetime.now().timestamp()), "subtitle_lang": self.lang, + "subtitle_last_refresh": int(datetime.now().timestamp()), "subtitle_source": source, + "title": video.json_data.get("title"), + "youtube_id": video.youtube_id, } _ = [i.update(meta_dict) for i in documents] diff --git a/docker-compose.yml b/docker-compose.yml index 21855bfa..41c2ecd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: depends_on: - archivist-es archivist-es: - image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.18.2 + image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.19.0 container_name: archivist-es restart: unless-stopped environment: diff --git a/docker_assets/nginx.conf b/docker_assets/nginx.conf index ee2841ab..75014784 100644 --- a/docker_assets/nginx.conf +++ b/docker_assets/nginx.conf @@ -57,14 +57,13 @@ server { location = /index.html { add_header Cache-Control "no-store, no-cache, must-revalidate"; add_header Pragma "no-cache"; - add_header Expires 0; expires 0; } location / { add_header Cache-Control "no-store, no-cache, must-revalidate"; add_header Pragma "no-cache"; - add_header Expires 0; + expires 0; try_files $uri $uri/ /index.html =404; } } diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 00000000..6fa8dec4 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22.13.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7267a7c2..2787f921 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,38 +8,38 @@ "name": "tubearchivist-frontend", "version": "0.5.1", "dependencies": { - "dompurify": "^3.2.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.8.0", - "zustand": "^5.0.7" + "dompurify": "^3.3.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router-dom": "^7.11.0", + "zustand": "^5.0.9" }, "devDependencies": { - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", - "@vitejs/plugin-react-swc": "^4.0.0", - "eslint": "^9.33.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.3.0", - "prettier": "3.6.2", - "typescript": "^5.9.2", - "typescript-eslint": "^8.39.0", - "vite": ">=7.1.1", - "vite-plugin-checker": "^0.10.2" + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^17.0.0", + "prettier": "3.7.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.51.0", + "vite": "^7.3.0", + "vite-plugin-checker": "^0.12.0" } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -47,6 +47,153 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -57,10 +204,98 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -75,9 +310,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -92,9 +327,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -109,9 +344,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -126,9 +361,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -143,9 +378,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -160,9 +395,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -177,9 +412,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -194,9 +429,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -211,9 +446,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -228,9 +463,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -245,9 +480,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -262,9 +497,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -279,9 +514,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -296,9 +531,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -313,9 +548,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -330,9 +565,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -347,9 +582,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -364,9 +599,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -381,9 +616,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -398,9 +633,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -415,9 +650,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -432,9 +667,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -449,9 +684,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -466,9 +701,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -483,9 +718,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -500,9 +735,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -529,24 +764,31 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/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, + "license": "MIT" + }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -555,9 +797,9 @@ } }, "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==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -594,20 +836,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "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", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -617,10 +859,17 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/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, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -652,9 +901,9 @@ } }, "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==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -665,9 +914,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -753,55 +1002,67 @@ "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==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "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==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -813,9 +1074,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -827,9 +1088,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -841,9 +1102,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -855,9 +1116,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -869,9 +1130,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -883,9 +1144,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], @@ -897,9 +1158,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], @@ -911,9 +1172,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], @@ -925,9 +1186,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], @@ -939,9 +1200,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "cpu": [ "loong64" ], @@ -953,9 +1228,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", "cpu": [ "ppc64" ], @@ -967,9 +1256,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], @@ -981,9 +1270,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], @@ -995,9 +1284,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], @@ -1009,9 +1298,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], @@ -1023,9 +1312,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], @@ -1036,10 +1325,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "cpu": [ "arm64" ], @@ -1051,9 +1354,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -1065,9 +1368,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -1079,9 +1382,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "cpu": [ "x64" ], @@ -1093,9 +1396,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -1107,9 +1410,9 @@ ] }, "node_modules/@swc/core": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.2.tgz", - "integrity": "sha512-OQm+yJdXxvSjqGeaWhP6Ia264ogifwAO7Q12uTDVYj/Ks4jBTI4JknlcjDRAXtRhqbWsfbZyK/5RtuIPyptk3w==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.21.tgz", + "integrity": "sha512-fkk7NJcBscrR3/F8jiqlMptRHP650NxqDnspBMrRe5d8xOoCy9MLL5kOBLFXjFLfMo3KQQHhk+/jUULOMlR1uQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1125,16 +1428,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.2", - "@swc/core-darwin-x64": "1.15.2", - "@swc/core-linux-arm-gnueabihf": "1.15.2", - "@swc/core-linux-arm64-gnu": "1.15.2", - "@swc/core-linux-arm64-musl": "1.15.2", - "@swc/core-linux-x64-gnu": "1.15.2", - "@swc/core-linux-x64-musl": "1.15.2", - "@swc/core-win32-arm64-msvc": "1.15.2", - "@swc/core-win32-ia32-msvc": "1.15.2", - "@swc/core-win32-x64-msvc": "1.15.2" + "@swc/core-darwin-arm64": "1.15.21", + "@swc/core-darwin-x64": "1.15.21", + "@swc/core-linux-arm-gnueabihf": "1.15.21", + "@swc/core-linux-arm64-gnu": "1.15.21", + "@swc/core-linux-arm64-musl": "1.15.21", + "@swc/core-linux-ppc64-gnu": "1.15.21", + "@swc/core-linux-s390x-gnu": "1.15.21", + "@swc/core-linux-x64-gnu": "1.15.21", + "@swc/core-linux-x64-musl": "1.15.21", + "@swc/core-win32-arm64-msvc": "1.15.21", + "@swc/core-win32-ia32-msvc": "1.15.21", + "@swc/core-win32-x64-msvc": "1.15.21" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -1146,9 +1451,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.2.tgz", - "integrity": "sha512-Ghyz4RJv4zyXzrUC1B2MLQBbppIB5c4jMZJybX2ebdEQAvryEKp3gq1kBksCNsatKGmEgXul88SETU19sMWcrw==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.21.tgz", + "integrity": "sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==", "cpu": [ "arm64" ], @@ -1163,9 +1468,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.2.tgz", - "integrity": "sha512-7n/PGJOcL2QoptzL42L5xFFfXY5rFxLHnuz1foU+4ruUTG8x2IebGhtwVTpaDN8ShEv2UZObBlT1rrXTba15Zw==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz", + "integrity": "sha512-//fOVntgowz9+V90lVsNCtyyrtbHp3jWH6Rch7MXHXbcvbLmbCTmssl5DeedUWLLGiAAW1wksBdqdGYOTjaNLw==", "cpu": [ "x64" ], @@ -1180,9 +1485,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.2.tgz", - "integrity": "sha512-ZUQVCfRJ9wimuxkStRSlLwqX4TEDmv6/J+E6FicGkQ6ssLMWoKDy0cAo93HiWt/TWEee5vFhFaSQYzCuBEGO6A==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.21.tgz", + "integrity": "sha512-meNI4Sh6h9h8DvIfEc0l5URabYMSuNvyisLmG6vnoYAS43s8ON3NJR8sDHvdP7NJTrLe0q/x2XCn6yL/BeHcZg==", "cpu": [ "arm" ], @@ -1197,9 +1502,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.2.tgz", - "integrity": "sha512-GZh3pYBmfnpQ+JIg+TqLuz+pM+Mjsk5VOzi8nwKn/m+GvQBsxD5ectRtxuWUxMGNG8h0lMy4SnHRqdK3/iJl7A==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.21.tgz", + "integrity": "sha512-QrXlNQnHeXqU2EzLlnsPoWEh8/GtNJLvfMiPsDhk+ht6Xv8+vhvZ5YZ/BokNWSIZiWPKLAqR0M7T92YF5tmD3g==", "cpu": [ "arm64" ], @@ -1214,9 +1519,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.2.tgz", - "integrity": "sha512-5av6VYZZeneiYIodwzGMlnyVakpuYZryGzFIbgu1XP8wVylZxduEzup4eP8atiMDFmIm+s4wn8GySJmYqeJC0A==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.21.tgz", + "integrity": "sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==", "cpu": [ "arm64" ], @@ -1230,10 +1535,44 @@ "node": ">=10" } }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.21.tgz", + "integrity": "sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.21.tgz", + "integrity": "sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.2.tgz", - "integrity": "sha512-1nO/UfdCLuT/uE/7oB3EZgTeZDCIa6nL72cFEpdegnqpJVNDI6Qb8U4g/4lfVPkmHq2lvxQ0L+n+JdgaZLhrRA==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.21.tgz", + "integrity": "sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==", "cpu": [ "x64" ], @@ -1248,9 +1587,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.2.tgz", - "integrity": "sha512-Ksfrb0Tx310kr+TLiUOvB/I80lyZ3lSOp6cM18zmNRT/92NB4mW8oX2Jo7K4eVEI2JWyaQUAFubDSha2Q+439A==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.21.tgz", + "integrity": "sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==", "cpu": [ "x64" ], @@ -1265,9 +1604,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.2.tgz", - "integrity": "sha512-IzUb5RlMUY0r1A9IuJrQ7Tbts1wWb73/zXVXT8VhewbHGoNlBKE0qUhKMED6Tv4wDF+pmbtUJmKXDthytAvLmg==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.21.tgz", + "integrity": "sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==", "cpu": [ "arm64" ], @@ -1282,9 +1621,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.2.tgz", - "integrity": "sha512-kCATEzuY2LP9AlbU2uScjcVhgnCAkRdu62vbce17Ro5kxEHxYWcugkveyBRS3AqZGtwAKYbMAuNloer9LS/hpw==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.21.tgz", + "integrity": "sha512-IkSZj8PX/N4HcaFhMQtzmkV8YSnuNoJ0E6OvMwFiOfejPhiKXvl7CdDsn1f4/emYEIDO3fpgZW9DTaCRMDxaDA==", "cpu": [ "ia32" ], @@ -1299,9 +1638,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.2.tgz", - "integrity": "sha512-iJaHeYCF4jTn7OEKSa3KRiuVFIVYts8jYjNmCdyz1u5g8HRyTDISD76r8+ljEOgm36oviRQvcXaw6LFp1m0yyA==", + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.21.tgz", + "integrity": "sha512-zUyWso7OOENB6e1N1hNuNn8vbvLsTdKQ5WKLgt/JcBNfJhKy/6jmBmqI3GXk/MyvQKd5SLvP7A0F36p7TeDqvw==", "cpu": [ "x64" ], @@ -1323,9 +1662,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", - "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1347,14 +1686,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", - "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { @@ -1375,21 +1713,20 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1399,24 +1736,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1426,20 +1762,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1453,14 +1789,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1471,9 +1807,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", "dev": true, "license": "MIT", "engines": { @@ -1488,17 +1824,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1508,14 +1844,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", "dev": true, "license": "MIT", "engines": { @@ -1527,22 +1863,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "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.1.0" + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1556,16 +1891,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1575,19 +1910,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1598,42 +1933,41 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", - "integrity": "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.0.tgz", + "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.47", - "@swc/core": "^1.13.5" + "@rolldown/pluginutils": "1.0.0-rc.7", + "@swc/core": "^1.15.11" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4 || ^5 || ^6 || ^7" + "vite": "^4 || ^5 || ^6 || ^7 || ^8" } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1652,9 +1986,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1668,19 +2002,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1705,33 +2026,73 @@ "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, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": "18 || 20 || >=22" } }, - "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==", + "node_modules/baseline-browser-mapping": { + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=8" + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, "node_modules/callsites": { @@ -1744,6 +2105,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1804,13 +2186,24 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -1829,9 +2222,9 @@ } }, "node_modules/csstype": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz", - "integrity": "sha512-98XGutrXoh75MlgLihlNxAGbUuFQc7l1cqcnEZlLNKc0UrVdPndgmaDmYTDDh929VS/eqTZV0rozmhu2qqT1/g==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, "license": "MIT" }, @@ -1861,18 +2254,25 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", - "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC" + }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1883,32 +2283,42 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/escape-string-regexp": { @@ -1925,26 +2335,25 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -1963,7 +2372,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2002,22 +2411,29 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "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.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2054,10 +2470,17 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/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, + "license": "MIT" + }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2089,9 +2512,9 @@ } }, "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==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2133,9 +2556,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2185,36 +2608,6 @@ "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", @@ -2229,14 +2622,22 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/file-entry-cache": { @@ -2252,19 +2653,6 @@ "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", @@ -2297,9 +2685,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2318,6 +2706,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2332,9 +2730,9 @@ } }, "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", "dev": true, "license": "MIT", "engines": { @@ -2344,13 +2742,6 @@ "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, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2361,6 +2752,23 @@ "node": ">=8" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2421,16 +2829,6 @@ "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", @@ -2458,6 +2856,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2479,6 +2890,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2526,41 +2950,27 @@ "dev": true, "license": "MIT" }, - "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==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2599,6 +3009,13 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -2720,22 +3137,22 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -2772,9 +3189,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { @@ -2797,54 +3214,31 @@ "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.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.4" } }, "node_modules/react-router": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", - "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", + "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2864,12 +3258,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", - "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "version": "7.13.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", + "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", "license": "MIT", "dependencies": { - "react-router": "7.9.6" + "react-router": "7.13.2" }, "engines": { "node": ">=20.0.0" @@ -2903,21 +3297,10 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2931,55 +3314,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "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.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2987,9 +3349,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -3038,22 +3400,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3104,55 +3450,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "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.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -3181,7 +3482,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3191,16 +3491,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", - "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.4", - "@typescript-eslint/parser": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4" + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3210,7 +3510,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -3227,6 +3527,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3238,14 +3569,13 @@ } }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -3314,9 +3644,9 @@ } }, "node_modules/vite-plugin-checker": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.10.3.tgz", - "integrity": "sha512-f4sekUcDPF+T+GdbbE8idb1i2YplBAoH+SfRS0e/WRBWb2rYb1Jf5Pimll0Rj+3JgIYWwG2K5LtBPCXxoibkLg==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz", + "integrity": "sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -3325,22 +3655,22 @@ "npm-run-path": "^6.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.3", - "strip-ansi": "^7.1.0", "tiny-invariant": "^1.3.3", - "tinyglobby": "^0.2.14", + "tinyglobby": "^0.2.15", "vscode-uri": "^3.1.0" }, "engines": { - "node": ">=14.16" + "node": ">=16.11" }, "peerDependencies": { "@biomejs/biome": ">=1.7", - "eslint": ">=7", + "eslint": ">=9.39.1", "meow": "^13.2.0", "optionator": "^0.9.4", + "oxlint": ">=1", "stylelint": ">=16", "typescript": "*", - "vite": ">=2.0.0", + "vite": ">=5.4.21", "vls": "*", "vti": "*", "vue-tsc": "~2.2.10 || ^3.0.0" @@ -3358,6 +3688,9 @@ "optionator": { "optional": true }, + "oxlint": { + "optional": true + }, "stylelint": { "optional": true }, @@ -3375,51 +3708,6 @@ } } }, - "node_modules/vite-plugin-checker/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -3453,6 +3741,13 @@ "node": ">=0.10.0" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3466,10 +3761,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index bc8a1229..d2b2733b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,27 +11,27 @@ "preview": "vite preview" }, "dependencies": { - "dompurify": "^3.2.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.8.0", - "zustand": "^5.0.7" + "dompurify": "^3.3.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router-dom": "^7.11.0", + "zustand": "^5.0.9" }, "devDependencies": { - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", - "@vitejs/plugin-react-swc": "^4.0.0", - "eslint": "^9.33.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.51.0", + "@typescript-eslint/parser": "^8.51.0", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.3.0", - "prettier": "3.6.2", - "typescript": "^5.9.2", - "typescript-eslint": "^8.39.0", - "vite": ">=7.1.1", - "vite-plugin-checker": "^0.10.2" + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^17.0.0", + "prettier": "3.7.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.51.0", + "vite": "^7.3.0", + "vite-plugin-checker": "^0.12.0" } } diff --git a/frontend/src/api/actions/createTaskSchedule.ts b/frontend/src/api/actions/createTaskSchedule.ts index 346ad3e2..0d2e1348 100644 --- a/frontend/src/api/actions/createTaskSchedule.ts +++ b/frontend/src/api/actions/createTaskSchedule.ts @@ -10,7 +10,6 @@ export type TaskScheduleNameType = | 'restore_backup' | 'rescan_filesystem' | 'thumbnail_check' - | 'resync_thumbs' | 'index_playlists' | 'subscribe_to' | 'version_check'; diff --git a/frontend/src/api/actions/deletePoToken.ts b/frontend/src/api/actions/deletePoToken.ts deleted file mode 100644 index b8ffc8c5..00000000 --- a/frontend/src/api/actions/deletePoToken.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/queueManualImport.ts b/frontend/src/api/actions/queueManualImport.ts new file mode 100644 index 00000000..57663c85 --- /dev/null +++ b/frontend/src/api/actions/queueManualImport.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const queueManualImport = async (ignore_error: boolean, prefer_local: boolean) => { + return APIClient('/api/appsettings/manual-import/', { + method: 'POST', + body: { ignore_error, prefer_local }, + }); +}; +export default queueManualImport; diff --git a/frontend/src/api/actions/queueStartFilesystemRescan.ts b/frontend/src/api/actions/queueStartFilesystemRescan.ts new file mode 100644 index 00000000..e6eeab0b --- /dev/null +++ b/frontend/src/api/actions/queueStartFilesystemRescan.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const queueStartFilesystemRescan = async (ignore_error: boolean, prefer_local: boolean) => { + return APIClient('/api/appsettings/rescan-filesystem/', { + method: 'POST', + body: { ignore_error, prefer_local }, + }); +}; +export default queueStartFilesystemRescan; diff --git a/frontend/src/api/actions/updatePoToken.ts b/frontend/src/api/actions/updatePoToken.ts deleted file mode 100644 index 1c6355ef..00000000 --- a/frontend/src/api/actions/updatePoToken.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 index 7053dca9..a55f25d9 100644 --- a/frontend/src/api/actions/updateTaskByName.ts +++ b/frontend/src/api/actions/updateTaskByName.ts @@ -1,12 +1,6 @@ import APIClient from '../../functions/APIClient'; -type TaskNamesType = - | 'download_pending' - | 'update_subscribed' - | 'manual_import' - | 'resync_thumbs' - | 'resync_metadata' - | 'rescan_filesystem'; +type TaskNamesType = 'download_pending' | 'update_subscribed' | 'resync_metadata'; const updateTaskByName = async (taskName: TaskNamesType) => { return APIClient(`/api/task/by-name/${taskName}/`, { diff --git a/frontend/src/api/loader/loadAppsettingsConfig.ts b/frontend/src/api/loader/loadAppsettingsConfig.ts index 19091533..47b1ba3a 100644 --- a/frontend/src/api/loader/loadAppsettingsConfig.ts +++ b/frontend/src/api/loader/loadAppsettingsConfig.ts @@ -16,14 +16,13 @@ export type AppSettingsConfigType = { 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; + pot_provider_url: string | null; throttledratelimit: number | null; extractor_lang: string | null; integrate_ryd: boolean; diff --git a/frontend/src/api/loader/loadPlaylistById.ts b/frontend/src/api/loader/loadPlaylistById.ts index 5a339baa..63a79184 100644 --- a/frontend/src/api/loader/loadPlaylistById.ts +++ b/frontend/src/api/loader/loadPlaylistById.ts @@ -12,7 +12,7 @@ export type PlaylistType = { playlist_active: boolean; playlist_channel: string; playlist_channel_id: string; - playlist_description: string; + playlist_description: string | null; playlist_entries: PlaylistEntryType[]; playlist_sort_order: 'top' | 'bottom'; playlist_id: string; diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx index f7f71379..f2df2b9b 100644 --- a/frontend/src/components/CommentBox.tsx +++ b/frontend/src/components/CommentBox.tsx @@ -6,20 +6,6 @@ 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; @@ -32,16 +18,17 @@ export type CommentsType = { comment_author_thumbnail: string; comment_author_is_uploader: boolean; comment_parent: string; - comment_replies?: CommentReplyType[]; + comment_replies: CommentsType[]; }; type CommentBoxProps = { comment: CommentsType; + showThread?: boolean; onTimestampClick?: (seconds: number) => void; }; -const CommentBox = ({ comment, onTimestampClick }: CommentBoxProps) => { - const [showSubComments, setShowSubComments] = useState(false); +const CommentBox = ({ comment, showThread = false, onTimestampClick }: CommentBoxProps) => { + const [showSubComments, setShowSubComments] = useState(showThread); const hasSubComments = comment.comment_replies !== undefined && comment.comment_replies.length > 0; @@ -93,10 +80,14 @@ const CommentBox = ({ comment, onTimestampClick }: CommentBoxProps) => {
{showSubComments && - comment.comment_replies?.map(comment => { + comment.comment_replies.map(comment => { return ( - + ); })} diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx index 95f3c8f9..24bc0999 100644 --- a/frontend/src/components/Filterbar.tsx +++ b/frontend/src/components/Filterbar.tsx @@ -60,6 +60,7 @@ const Filterbar = ({ } if (currentViewStyle === ViewStylesEnum.Table) { + // eslint-disable-next-line react-hooks/set-state-in-effect setShowHidden(true); } else { setShowHidden(false); diff --git a/frontend/src/components/MembershipAppsettings.tsx b/frontend/src/components/MembershipAppsettings.tsx index 9474c689..d2899da0 100644 --- a/frontend/src/components/MembershipAppsettings.tsx +++ b/frontend/src/components/MembershipAppsettings.tsx @@ -24,6 +24,7 @@ type ProfileResponseType = { sponsor_tier: SponsorTierType; subscription_count: number; subscription_is_max: boolean; + is_connected: boolean; }; export default function MembershipAppsettings({ show_help_text }: { show_help_text: boolean }) { @@ -136,6 +137,13 @@ export default function MembershipAppsettings({ show_help_text }: { show_help_te .
  • Click on validate to verify everything is working.
  • +
  • + Setup the{' '} + + Client Container + + . +
  • If you are subscribed to less channels than your sponsor tier allows, you can directly sync all your subscriptions here. @@ -206,6 +214,19 @@ export default function MembershipAppsettings({ show_help_text }: { show_help_te
    Subscriptions: {profileResponse.subscription_count}/ {profileResponse.sponsor_tier.max_subs} +
    + Socket:{' '} + {profileResponse.is_connected ? ( + 'Established' + ) : ( + <> + Not established. Make sure the{' '} + + Client Container + {' '} + is connected. + + )}

    )} diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx index 1c181c6e..a4822b61 100644 --- a/frontend/src/components/SearchExampleQueries.tsx +++ b/frontend/src/components/SearchExampleQueries.tsx @@ -104,6 +104,10 @@ const SearchExampleQueries = () => { auto-generated subtitles only, or user to search through user-uploaded subtitles only
  • +
  • + channel: — limit subtitle search to a specific channel name (for + example: full:javascript channel:corey schafer) +
  • diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index f0ef79d6..b4660dac 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -146,6 +146,7 @@ const VideoPlayer = ({ } if (setSeekToTimestamp) setSeekToTimestamp(undefined); window.scroll(0, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [seekToTimestamp]); const [searchParams] = useSearchParams(); @@ -389,6 +390,7 @@ const VideoPlayer = ({ } else if (!theaterModePressed) { setTheaterModeKeyPressed(false); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [theaterModePressed, isTheaterMode, theaterModeKeyPressed]); useEffect(() => { @@ -401,6 +403,7 @@ const VideoPlayer = ({ infoDialog('Normal mode'); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [escapePressed, isTheaterMode]); return ( diff --git a/frontend/src/pages/ChannelAbout.tsx b/frontend/src/pages/ChannelAbout.tsx index 54898b92..3e966182 100644 --- a/frontend/src/pages/ChannelAbout.tsx +++ b/frontend/src/pages/ChannelAbout.tsx @@ -8,7 +8,6 @@ import Routes from '../configuration/routes/RouteList'; import queueReindex, { ReindexType, ReindexTypeEnum } from '../api/actions/queueReindex'; import formatDate from '../functions/formatDates'; import PaginationDummy from '../components/PaginationDummy'; -import FormattedNumber from '../components/FormattedNumber'; import Button from '../components/Button'; import updateChannelOverwrites from '../api/actions/updateChannelOverwrite'; import useIsAdmin from '../functions/useIsAdmin'; @@ -145,10 +144,6 @@ const ChannelAbout = () => {
    - {channel.channel_views > 0 && ( - - )} - {isAdmin && ( <>
    diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 2a6fb5f3..e5b7212d 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -41,7 +41,6 @@ export type ChannelType = { channel_tags?: string[]; channel_thumb_url: string; channel_tvart_url: string; - channel_views: number; }; const Channels = () => { diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index d01a96d4..09ac57f9 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -89,7 +89,6 @@ export type DownloadsType = { format: boolean; format_sort: boolean; add_metadata: boolean; - add_thumbnail: boolean; subtitle: boolean; subtitle_source: boolean; subtitle_index: boolean; diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx index 910b3205..31cf00ef 100644 --- a/frontend/src/pages/Playlist.tsx +++ b/frontend/src/pages/Playlist.tsx @@ -108,7 +108,6 @@ const Playlist = () => { } setRefresh(false); })(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ playlistId, userConfig.hide_watched_playlist, @@ -331,7 +330,7 @@ const Playlist = () => {
    - {playlist.playlist_description !== 'False' && ( + {playlist.playlist_description !== null && (

    { const gridView = isGridView ? `boxed-${gridItems}` : ''; const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; + const fetchResults = async (searchQuery: string) => { + const searchResults = await loadSearch(searchQuery); + + setSearchResults(searchResults); + setRefresh(false); + }; + useEffect(() => { const handler = setTimeout(() => { setDebouncedSearchTerm(searchTerm); @@ -77,19 +84,13 @@ const Search = () => { useEffect(() => { if (debouncedSearchTerm.trim() !== '') { + // eslint-disable-next-line react-hooks/set-state-in-effect fetchResults(debouncedSearchTerm); } else { setSearchResults(EmptySearchResponse); } }, [debouncedSearchTerm, refresh, videoId]); - const fetchResults = async (searchQuery: string) => { - const searchResults = await loadSearch(searchQuery); - - setSearchResults(searchResults); - setRefresh(false); - }; - return ( <> TubeArchivist diff --git a/frontend/src/pages/SettingsActions.tsx b/frontend/src/pages/SettingsActions.tsx index a226019d..81a8eaa8 100644 --- a/frontend/src/pages/SettingsActions.tsx +++ b/frontend/src/pages/SettingsActions.tsx @@ -7,16 +7,22 @@ import restoreBackup from '../api/actions/restoreBackup'; import Notifications from '../components/Notifications'; import Button from '../components/Button'; import { ApiResponseType } from '../functions/APIClient'; +import ToggleConfig from '../components/ToggleConfig'; +import queueStartFilesystemRescan from '../api/actions/queueStartFilesystemRescan'; +import queueManualImport from '../api/actions/queueManualImport'; const SettingsActions = () => { const [deleteIgnored, setDeleteIgnored] = useState(false); const [deletePending, setDeletePending] = useState(false); const [processingImports, setProcessingImports] = useState(false); - const [reEmbed, setReEmbed] = useState(false); const [reSyncMeta, setReSyncMeta] = useState(false); const [backupStarted, setBackupStarted] = useState(false); const [isRestoringBackup, setIsRestoringBackup] = useState(false); const [reScanningFileSystem, setReScanningFileSystem] = useState(false); + const [rescanIgnoreErrors, setRescanIgnoreErrors] = useState(false); + const [rescanPreferLocal, setRescanPreferLocal] = useState(false); + const [manualPreferLocal, setManualPreferLocal] = useState(false); + const [manualIgnoreErrors, setManualIgnoreErrors] = useState(false); const [backupListResponse, setBackupListResponse] = useState>(); @@ -44,7 +50,6 @@ const SettingsActions = () => { deleteIgnored || deletePending || processingImports || - reEmbed || reSyncMeta || backupStarted || isRestoringBackup || @@ -54,7 +59,6 @@ const SettingsActions = () => { setDeleteIgnored(false); setDeletePending(false); setProcessingImports(false); - setReEmbed(false); setReSyncMeta(false); setBackupStarted(false); setIsRestoringBackup(false); @@ -69,44 +73,48 @@ const SettingsActions = () => {

    Manual media files import.

    Add files to the cache/import folder. Make - sure to follow the instructions in the Github{' '} + sure to follow the instructions in the{' '} - Wiki + Docs .

    +
    +
    +

    Prefer embedded metadata

    +
    + setManualPreferLocal(!manualPreferLocal)} + /> +
    +
    +
    +

    Ignore missing metadata errors

    +
    + setManualIgnoreErrors(!manualIgnoreErrors)} + /> +
    {processingImports &&

    Processing import

    } {!processingImports && (
    -
    -

    Embed thumbnails into media file.

    -

    Set extracted youtube thumbnail as cover art of the media file.

    -
    - {reEmbed &&

    Processing thumbnails

    } - {!reEmbed && ( -
    -

    Embed metadata into media file

    Embed metadata into media files as mp4 tags.

    @@ -196,23 +204,42 @@ const SettingsActions = () => { deleted videos from the filesystem.

    - Rescan your media folder looking for missing videos and clean up index. More info on the - Github{' '} + Rescan your media folder looking for missing videos and clean up index. More info on the{' '} - Wiki + Docs .

    +
    +
    +

    Prefer embedded metadata

    +
    + setRescanPreferLocal(!rescanPreferLocal)} + /> +
    +
    +
    +

    Ignore missing metadata errors

    +
    + setRescanIgnoreErrors(!rescanIgnoreErrors)} + /> +
    {reScanningFileSystem &&

    File system scan in progress

    } {!reScanningFileSystem && (
    -
    -
    -

    Embed Thumbnail

    -
    - -

    Subtitles

    @@ -630,17 +608,17 @@ const SettingsApplication = () => { Download and index comments. Browsable on the video detail page. Example:
    • - all,100,all,30: Get 100 - max-parents and 30 max-replies-per-thread. + all,100,all,30,all: Get 100 + max-parents and 30 max-replies-per-thread at any depth.
    • - 1000,all,all,50: Get a total of - 1000 comments over all, 50 replies per thread. + 1000,all,all,50,2: Get a total + of 1000 comments over all, 50 replies per thread, only 2 levels of depth.
    • Values are in the format:{' '} - max-comments,max-parents,max-replies,max-replies-per-thread + max-comments,max-parents,max-replies,max-replies-per-thread,max-depth .
    • @@ -702,13 +680,13 @@ const SettingsApplication = () => { .
    • - The PO Token (Proof of origin token) can authenticate your request. - Make sure to read the{' '} + The PO Token Provider URL running external to tubearchivist. Make sure to + review{' '} - PO guide + User Guide
    @@ -763,52 +741,16 @@ const SettingsApplication = () => {
    -

    Add PO Token

    -
    -
    - {response?.appSettingsConfig?.downloads.potoken ? ( - <> -

    PO Token enabled.

    - - - ) : ( -

    PO Token disabled

    - )} - {showPoTokenForm ? ( -
    - { - setPoTokenFormData(e.target.value); - }} - /> - {poTokenFormData !== 'web+' && ( - - )} - -
    - ) : ( -
    - -
    - )} +

    PO Token Provider URL

    +
    @@ -974,10 +916,9 @@ const SettingsApplication = () => {


    {restoringSnapshot &&

    Snapshot restore started

    } - {!restoringSnapshot && - snapshots.snapshots && - snapshots.snapshots.map(snapshot => { - return ( + {!restoringSnapshot && snapshots.snapshots && ( + <> + {snapshots.snapshots?.slice(0, visibleSnapshotCount).map(snapshot => (

    diff --git a/frontend/src/pages/SettingsScheduling.tsx b/frontend/src/pages/SettingsScheduling.tsx index 5d8084da..a5a0dd9b 100644 --- a/frontend/src/pages/SettingsScheduling.tsx +++ b/frontend/src/pages/SettingsScheduling.tsx @@ -103,6 +103,7 @@ const SettingsScheduling = () => { }, [refresh]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setRefresh(true); }, []); diff --git a/frontend/src/stores/AppSettingsStore.ts b/frontend/src/stores/AppSettingsStore.ts index 844285e5..1f8654e2 100644 --- a/frontend/src/stores/AppSettingsStore.ts +++ b/frontend/src/stores/AppSettingsStore.ts @@ -23,14 +23,13 @@ export const useAppSettingsStore = create(set => ({ format: null, format_sort: null, add_metadata: false, - add_thumbnail: false, subtitle: null, subtitle_source: null, subtitle_index: false, comment_max: null, comment_sort: 'asc', cookie_import: false, - potoken: false, + pot_provider_url: null, throttledratelimit: null, extractor_lang: null, integrate_ryd: false, diff --git a/requirements-dev.txt b/requirements-dev.txt index d03272ae..454f59d1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,11 @@ +-r backend/requirements.plugins.txt -r backend/requirements.txt -ipython==9.7.0 -pre-commit==4.4.0 -pylint-django==2.6.1 -pylint==3.3.9 -pytest-django==4.11.1 -pytest==9.0.1 -python-dotenv==1.2.1 +ipython==9.12.0 +pre-commit==4.5.1 +pylint-django==2.7.0 +pylint==4.0.5 +pytest-django==4.12.0 +pytest==9.0.2 +python-dotenv==1.2.2 requirementscheck==0.1.0 -types-requests==2.32.4.20250913 +types-requests==2.33.0.20260327