diff --git a/Dockerfile b/Dockerfile index 29f75c40..950be92b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ FROM python:3.11.8-slim-bookworm AS tubearchivist ARG INSTALL_DEBUG -ENV PYTHONUNBUFFERED 1 +ENV PYTHONUNBUFFERED=1 # copy build requirements COPY --from=builder /root/.local /root/.local diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py new file mode 100644 index 00000000..4ac45f93 --- /dev/null +++ b/backend/appsettings/serializers.py @@ -0,0 +1,132 @@ +"""appsettings erializers""" + +# pylint: disable=abstract-method + +from common.serializers import ValidateUnknownFieldsMixin +from rest_framework import serializers + + +class BackupFileSerializer(serializers.Serializer): + """serialize backup file""" + + filename = serializers.CharField() + file_path = serializers.CharField() + file_size = serializers.IntegerField() + timestamp = serializers.CharField() + reason = serializers.CharField() + + +class AppConfigSubSerializer( + ValidateUnknownFieldsMixin, serializers.Serializer +): + """serialize app config subscriptions""" + + channel_size = serializers.IntegerField(required=False) + live_channel_size = serializers.IntegerField(required=False) + shorts_channel_size = serializers.IntegerField(required=False) + auto_start = serializers.BooleanField(required=False) + + +class AppConfigDownloadsSerializer( + ValidateUnknownFieldsMixin, serializers.Serializer +): + """serialize app config downloads config""" + + limit_speed = serializers.IntegerField(allow_null=True) + sleep_interval = serializers.IntegerField(allow_null=True) + autodelete_days = serializers.IntegerField(allow_null=True) + 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 + ) + subtitle_index = serializers.BooleanField() + comment_max = serializers.CharField(allow_null=True) + comment_sort = serializers.ChoiceField( + choices=["top", "new"], allow_null=True + ) + cookie_import = serializers.BooleanField() + potoken = serializers.BooleanField() + throttledratelimit = serializers.IntegerField(allow_null=True) + extractor_lang = serializers.CharField(allow_null=True) + integrate_ryd = serializers.BooleanField() + integrate_sponsorblock = serializers.BooleanField() + + +class AppConfigAppSerializer( + ValidateUnknownFieldsMixin, serializers.Serializer +): + """serialize app config""" + + enable_snapshot = serializers.BooleanField() + + +class AppConfigSerializer(ValidateUnknownFieldsMixin, serializers.Serializer): + """serialize appconfig""" + + subscriptions = AppConfigSubSerializer(required=False) + downloads = AppConfigDownloadsSerializer(required=False) + application = AppConfigAppSerializer(required=False) + + +class CookieValidationSerializer(serializers.Serializer): + """serialize cookie validation response""" + + cookie_enabled = serializers.BooleanField() + status = serializers.BooleanField(required=False) + validated = serializers.IntegerField(required=False) + validated_str = serializers.CharField(required=False) + + +class CookieUpdateSerializer(serializers.Serializer): + """serialize cookie to update""" + + cookie = serializers.CharField() + + +class PoTokenSerializer(serializers.Serializer): + """serialize PO token""" + + potoken = serializers.CharField() + + +class SnapshotItemSerializer(serializers.Serializer): + """serialize snapshot response""" + + id = serializers.CharField() + state = serializers.CharField() + es_version = serializers.CharField() + start_date = serializers.CharField() + end_date = serializers.CharField() + end_stamp = serializers.IntegerField() + duration_s = serializers.IntegerField() + + +class SnapshotListSerializer(serializers.Serializer): + """serialize snapshot list response""" + + next_exec = serializers.IntegerField() + next_exec_str = serializers.CharField() + expire_after = serializers.CharField() + snapshots = SnapshotItemSerializer(many=True) + + +class SnapshotCreateResponseSerializer(serializers.Serializer): + """serialize new snapshot creating response""" + + snapshot_name = serializers.CharField() + + +class SnapshotRestoreResponseSerializer(serializers.Serializer): + """serialize snapshot restore response""" + + accepted = serializers.BooleanField() + + +class TokenResponseSerializer(serializers.Serializer): + """serialize token response""" + + token = serializers.CharField() diff --git a/backend/appsettings/src/config.py b/backend/appsettings/src/config.py index 58632ace..34631774 100644 --- a/backend/appsettings/src/config.py +++ b/backend/appsettings/src/config.py @@ -109,29 +109,24 @@ class AppConfig: def update_config(self, data: dict) -> AppConfigType: """update single config value""" + new_config = self.config.copy() for key, value in data.items(): - key_map = key.split(".") - self._validate_key(key_map) - self.config[key_map[0]][key_map[1]] = value + if ( + isinstance(value, dict) + and key in new_config + and isinstance(new_config[key], dict) + ): + new_config[key].update(value) + else: + new_config[key] = value - response, status_code = ElasticWrap(self.ES_PATH).post(self.config) + response, status_code = ElasticWrap(self.ES_PATH).post(new_config) if not status_code == 200: print(response) - return self.config + self.config = new_config - def _update_config_dict(self, to_update) -> None: - """none validated partial update for defaults sync""" - data = {"doc": to_update} - response, status_code = ElasticWrap(self.ES_UPDATE_PATH).post(data) - if not status_code == 200: - print(f"update failed: {response}, {status_code}") - - def _validate_key(self, key_map: list[str]) -> None: - """raise valueerror on invalid key""" - exists = key_map[1] in self.CONFIG_DEFAULTS.get(key_map[0], {}) # type: ignore # noqa: E501 - if exists is None: - raise ValueError(f"trying to access invalid config key: {key_map}") + return new_config def post_process_updated(self, data: dict) -> None: """apply hooks for some config keys""" @@ -163,7 +158,7 @@ class AppConfig: for key, value in self.CONFIG_DEFAULTS.items(): if key not in self.config: # complete new key - self._update_config_dict({key: value}) + self.update_config({key: value}) updated.append(str({key: value})) continue @@ -171,7 +166,7 @@ class AppConfig: if sub_key not in self.config[key]: # new partial key to_update = {key: {sub_key: sub_value}} - self._update_config_dict(to_update) + self.update_config(to_update) updated.append(str(to_update)) return updated @@ -247,7 +242,7 @@ class ReleaseVersion: return False - def get_update(self) -> dict: + def get_update(self) -> dict | None: """return new version dict if available""" message = RedisArchivist().get_message_dict(self.NEW_KEY) - return message + return message or None diff --git a/backend/appsettings/src/filesystem.py b/backend/appsettings/src/filesystem.py index 64aad6aa..1cf02cb0 100644 --- a/backend/appsettings/src/filesystem.py +++ b/backend/appsettings/src/filesystem.py @@ -6,7 +6,7 @@ Functionality: import os from common.src.env_settings import EnvironmentSettings -from common.src.es_connect import ElasticWrap, IndexPaginate +from common.src.es_connect import IndexPaginate from common.src.helper import ignore_filelist from video.src.comments import CommentList from video.src.index import YoutubeVideo, index_new_video @@ -56,7 +56,6 @@ class Scanner: """apply all changes""" self.delete() self.index() - self.url_fix() def delete(self) -> None: """delete videos from index""" @@ -92,35 +91,3 @@ class Scanner: comment_list = CommentList(task=self.task) comment_list.add(video_ids=list(self.to_index)) comment_list.index() - - def url_fix(self) -> None: - """ - update path v0.3.6 to v0.3.7 - fix url not matching channel-videoid pattern - """ - bool_must = ( - "doc['media_url'].value == " - + "(doc['channel.channel_id'].value + '/' + " - + "doc['youtube_id'].value) + '.mp4'" - ) - to_update = ( - "ctx._source['media_url'] = " - + "ctx._source.channel['channel_id'] + '/' + " - + "ctx._source['youtube_id'] + '.mp4'" - ) - data = { - "query": { - "bool": { - "must_not": [{"script": {"script": {"source": bool_must}}}] - } - }, - "script": {"source": to_update}, - } - response, _ = ElasticWrap("ta_video/_update_by_query").post(data=data) - updated = response.get("updates") - if updated: - print(f"updated {updated} bad media_url") - if self.task: - self.task.send_progress( - [f"Updated {updated} wrong media urls."] - ) diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py index bb4b26e2..603ebe8b 100644 --- a/backend/appsettings/src/reindex.py +++ b/backend/appsettings/src/reindex.py @@ -333,6 +333,7 @@ class Reindex(ReindexBase): # add back video.json_data["player"] = es_meta.get("player") video.json_data["date_downloaded"] = es_meta.get("date_downloaded") + video.json_data["video_type"] = es_meta.get("video_type") video.json_data["channel"] = es_meta.get("channel") if es_meta.get("playlist"): video.json_data["playlist"] = es_meta.get("playlist") diff --git a/backend/appsettings/views.py b/backend/appsettings/views.py index 95791a39..31978b61 100644 --- a/backend/appsettings/views.py +++ b/backend/appsettings/views.py @@ -1,116 +1,35 @@ """all app settings API views""" +from appsettings.serializers import ( + AppConfigSerializer, + BackupFileSerializer, + CookieUpdateSerializer, + CookieValidationSerializer, + PoTokenSerializer, + SnapshotCreateResponseSerializer, + SnapshotItemSerializer, + SnapshotListSerializer, + SnapshotRestoreResponseSerializer, + TokenResponseSerializer, +) from appsettings.src.backup import ElasticBackup from appsettings.src.config import AppConfig from appsettings.src.snapshot import ElasticSnapshot +from common.serializers import ( + AsyncTaskResponseSerializer, + ErrorResponseSerializer, +) from common.src.ta_redis import RedisArchivist from common.views_base import AdminOnly, ApiBaseView from django.conf import settings from download.src.yt_dlp_base import CookieHandler, POTokenHandler +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.authtoken.models import Token from rest_framework.response import Response from task.src.task_manager import TaskCommand from task.tasks import run_restore_backup -class AppConfigApiView(ApiBaseView): - """resolves to /api/appsettings/config/ - GET: return app settings - POST: update app settings - """ - - permission_classes = [AdminOnly] - - @staticmethod - def get(request): - """get config""" - response = AppConfig().config - return Response(response) - - @staticmethod - def post(request): - """ - update config values - data object where key is flatted CONFIG_DEFAULTS separated by '.', e.g. - {"subscriptions.channel_size": 5, "subscriptions.live_channel_size": 5} - """ - data = request.data - try: - config = AppConfig().update_config(data) - except ValueError as err: - return Response({"error": str(err)}, status=400) - - return Response(config) - - -class SnapshotApiListView(ApiBaseView): - """resolves to /api/appsettings/snapshot/ - GET: returns snapshot config plus list of existing snapshots - POST: take snapshot now - """ - - permission_classes = [AdminOnly] - - @staticmethod - def get(request): - """handle get request""" - # pylint: disable=unused-argument - snapshots = ElasticSnapshot().get_snapshot_stats() - - return Response(snapshots) - - @staticmethod - def post(request): - """take snapshot now with post request""" - # pylint: disable=unused-argument - response = ElasticSnapshot().take_snapshot_now() - - return Response(response) - - -class SnapshotApiView(ApiBaseView): - """resolves to /api/appsettings/snapshot// - GET: return a single snapshot - POST: restore snapshot - DELETE: delete a snapshot - """ - - permission_classes = [AdminOnly] - - @staticmethod - def get(request, snapshot_id): - """handle get request""" - # pylint: disable=unused-argument - snapshot = ElasticSnapshot().get_single_snapshot(snapshot_id) - - if not snapshot: - return Response({"message": "snapshot not found"}, status=404) - - return Response(snapshot) - - @staticmethod - def post(request, snapshot_id): - """restore snapshot with post request""" - # pylint: disable=unused-argument - response = ElasticSnapshot().restore_all(snapshot_id) - if not response: - message = {"message": "failed to restore snapshot"} - return Response(message, status=400) - - return Response(response) - - @staticmethod - def delete(request, snapshot_id): - """delete snapshot from index""" - # pylint: disable=unused-argument - response = ElasticSnapshot().delete_single_snapshot(snapshot_id) - if not response: - message = {"message": "failed to delete snapshot"} - return Response(message, status=400) - - return Response(response) - - class BackupApiListView(ApiBaseView): """resolves to /api/appsettings/backup/ GET: returns list of available zip backups @@ -121,22 +40,34 @@ class BackupApiListView(ApiBaseView): task_name = "run_backup" @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(BackupFileSerializer(many=True)), + }, + ) def get(request): - """handle get request""" + """get list of available backup files""" # pylint: disable=unused-argument backup_files = ElasticBackup().get_all_backup_files() - return Response(backup_files) + serializer = BackupFileSerializer(backup_files, many=True) + return Response(serializer.data) + @extend_schema( + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + }, + ) def post(self, request): - """handle post request""" + """start new backup file task""" # pylint: disable=unused-argument response = TaskCommand().start(self.task_name) message = { "message": "backup task started", "task_id": response["task_id"], } + serializer = AsyncTaskResponseSerializer(message) - return Response(message) + return Response(serializer.data) class BackupApiView(ApiBaseView): @@ -150,19 +81,42 @@ class BackupApiView(ApiBaseView): task_name = "restore_backup" @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(BackupFileSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="file not found" + ), + } + ) def get(request, filename): """get single backup""" # pylint: disable=unused-argument backup_file = ElasticBackup().build_backup_file_data(filename) if not backup_file: - message = {"message": "file not found"} - return Response(message, status=404) + error = ErrorResponseSerializer({"error": "file not found"}) + return Response(error.data, status=404) - return Response(backup_file) + serializer = BackupFileSerializer(backup_file) + return Response(serializer.data) + + @extend_schema( + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="file not found" + ), + } + ) def post(self, request, filename): - """restore backup file""" + """start new task to restore backup file""" # pylint: disable=unused-argument + backup_file = ElasticBackup().build_backup_file_data(filename) + if not backup_file: + error = ErrorResponseSerializer({"error": "file not found"}) + return Response(error.data, status=404) + task = run_restore_backup.delay(filename) message = { "message": "backup restore task started", @@ -172,17 +126,64 @@ class BackupApiView(ApiBaseView): return Response(message) @staticmethod + @extend_schema( + responses={ + 204: OpenApiResponse(description="file deleted"), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="file not found" + ), + } + ) def delete(request, filename): """delete backup file""" # pylint: disable=unused-argument backup_file = ElasticBackup().delete_file(filename) if not backup_file: - message = {"message": "file not found"} - return Response(message, status=404) + error = ErrorResponseSerializer({"error": "file not found"}) + return Response(error.data, status=404) - message = {"message": f"file {filename} deleted"} - return Response(message) + return Response(status=204) + + +class AppConfigApiView(ApiBaseView): + """resolves to /api/appsettings/config/ + GET: return app settings + POST: update app settings + """ + + permission_classes = [AdminOnly] + + @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(AppConfigSerializer()), + } + ) + def get(request): + """get app config""" + response = AppConfig().config + serializer = AppConfigSerializer(response) + return Response(serializer.data) + + @staticmethod + @extend_schema( + request=AppConfigSerializer(), + responses={ + 200: OpenApiResponse(AppConfigSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) + def post(request): + """update config values, allows partial update""" + serializer = AppConfigSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + updated_config = AppConfig().update_config(validated_data) + updated_serializer = AppConfigSerializer(updated_config) + return Response(updated_serializer.data) class CookieView(ApiBaseView): @@ -195,54 +196,88 @@ class CookieView(ApiBaseView): permission_classes = [AdminOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(CookieValidationSerializer()), + } + ) def get(self, request): - """handle get request""" + """get cookie validation status""" # pylint: disable=unused-argument validation = self._get_cookie_validation() + serializer = CookieValidationSerializer(validation) - return Response(validation) + return Response(serializer.data) + @extend_schema( + responses={ + 200: OpenApiResponse(CookieValidationSerializer()), + } + ) def post(self, request): - """handle cookie validation request""" + """validate cookie""" # pylint: disable=unused-argument config = AppConfig().config _ = CookieHandler(config).validate() validation = self._get_cookie_validation() + serializer = CookieValidationSerializer(validation) - return Response(validation) + return Response(serializer.data) + @extend_schema( + request=CookieUpdateSerializer(), + responses={ + 200: OpenApiResponse(CookieValidationSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) def put(self, request): """handle put request""" # pylint: disable=unused-argument - config = AppConfig().config - cookie = request.data.get("cookie") + + serializer = CookieUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + cookie = validated_data.get("cookie") if not cookie: message = "missing cookie key in request data" print(message) - return Response({"message": message}, status=400) + error = ErrorResponseSerializer({"error": message}) + return Response(error.data, status=400) if settings.DEBUG: print(f"[cookie] preview:\n\n{cookie[:300]}") + config = AppConfig().config handler = CookieHandler(config) handler.set_cookie(cookie) validated = handler.validate() if not validated: + message = "[cookie]: import failed, not valid" + print(message) + error = ErrorResponseSerializer({"error": message}) handler.revoke() - print("[cookie]: import failed, not valid") - status = 400 - else: - status = 200 + return Response(error.data, status=400) validation = self._get_cookie_validation() - return Response(validation, status=status) + serializer = CookieValidationSerializer(validation) + return Response(serializer.data) + + @extend_schema( + responses={ + 204: OpenApiResponse(description="Cookie revoked"), + }, + ) def delete(self, request): """delete the cookie""" config = AppConfig().config handler = CookieHandler(config) handler.revoke() - return Response({"cookie_enabled": False}) + return Response(status=204) @staticmethod def _get_cookie_validation(): @@ -260,47 +295,199 @@ class POTokenView(ApiBaseView): permission_classes = [AdminOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(PoTokenSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="PO token not found" + ), + } + ) def get(self, request): - """get token""" + """get PO token""" config = AppConfig().config potoken = POTokenHandler(config).get() - return Response({"potoken": potoken}) + 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): - """post token""" + """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 = request.data.get("potoken") - if not new_token: - message = "missing potoken key in request data" - print(message) - return Response({"message": message}, status=400) + new_token = validated_data["potoken"] POTokenHandler(config).set_token(new_token) - return Response({"potoken": new_token}) + return Response(serializer.data) + @extend_schema( + responses={ + 204: OpenApiResponse(description="PO token revoked"), + }, + ) def delete(self, request): - """delete token""" + """delete PO token""" config = AppConfig().config POTokenHandler(config).revoke_token() - return Response({"potoken": None}) + return Response(status=204) + + +class SnapshotApiListView(ApiBaseView): + """resolves to /api/appsettings/snapshot/ + GET: returns snapshot config plus list of existing snapshots + POST: take snapshot now + """ + + permission_classes = [AdminOnly] + + @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(SnapshotListSerializer()), + } + ) + def get(request): + """get available snapshots with metadata""" + # pylint: disable=unused-argument + snapshots = ElasticSnapshot().get_snapshot_stats() + serializer = SnapshotListSerializer(snapshots) + return Response(serializer.data) + + @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(SnapshotCreateResponseSerializer()), + } + ) + def post(request): + """take snapshot now""" + # pylint: disable=unused-argument + response = ElasticSnapshot().take_snapshot_now() + serializer = SnapshotCreateResponseSerializer(response) + return Response(serializer.data) + + +class SnapshotApiView(ApiBaseView): + """resolves to /api/appsettings/snapshot// + GET: return a single snapshot + POST: restore snapshot + DELETE: delete a snapshot + """ + + permission_classes = [AdminOnly] + + @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(SnapshotItemSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="snapshot not found" + ), + } + ) + def get(request, snapshot_id): + """handle get request""" + # pylint: disable=unused-argument + snapshot = ElasticSnapshot().get_single_snapshot(snapshot_id) + + if not snapshot: + error = ErrorResponseSerializer({"error": "snapshot not found"}) + return Response(error.data, status=404) + + serializer = SnapshotItemSerializer(snapshot) + return Response(serializer.data) + + @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(SnapshotRestoreResponseSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + } + ) + def post(request, snapshot_id): + """restore snapshot""" + # pylint: disable=unused-argument + response = ElasticSnapshot().restore_all(snapshot_id) + if not response: + error = ErrorResponseSerializer( + {"error": "failed to restore snapshot"} + ) + return Response(error.data, status=400) + + serializer = SnapshotRestoreResponseSerializer(response) + + return Response(serializer.data) + + @staticmethod + @extend_schema( + responses={ + 204: OpenApiResponse(description="delete snapshot from index"), + } + ) + def delete(request, snapshot_id): + """delete snapshot from index""" + # pylint: disable=unused-argument + response = ElasticSnapshot().delete_single_snapshot(snapshot_id) + if not response: + error = ErrorResponseSerializer( + {"error": "failed to delete snapshot"} + ) + return Response(error.data, status=400) + + return Response(status=204) class TokenView(ApiBaseView): """resolves to /api/appsettings/token/ + GET: get API token DELETE: revoke the token """ permission_classes = [AdminOnly] @staticmethod + @extend_schema( + responses={ + 200: OpenApiResponse(TokenResponseSerializer()), + } + ) def get(request): - """get token""" + """get your API token""" token, _ = Token.objects.get_or_create(user=request.user) - return Response({"token": token.key}) + serializer = TokenResponseSerializer({"token": token.key}) + return Response(serializer.data) @staticmethod + @extend_schema( + responses={ + 204: OpenApiResponse(description="delete token"), + } + ) def delete(request): - """delete the token, new will get created automatically""" + """delete your API token, new will get created on next get""" print("revoke API token") request.user.auth_token.delete() - return Response({"success": True}) + return Response(status=204) diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py new file mode 100644 index 00000000..2144ea3f --- /dev/null +++ b/backend/channel/serializers.py @@ -0,0 +1,99 @@ +"""channel serializers""" + +# pylint: disable=abstract-method + +from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin +from rest_framework import serializers + + +class ChannelOverwriteSerializer( + ValidateUnknownFieldsMixin, serializers.Serializer +): + """serialize channel overwrites""" + + download_format = serializers.CharField(required=False, allow_null=True) + autodelete_days = serializers.IntegerField(required=False, allow_null=True) + index_playlists = serializers.BooleanField(required=False, allow_null=True) + integrate_sponsorblock = serializers.BooleanField( + required=False, allow_null=True + ) + subscriptions_channel_size = serializers.IntegerField( + required=False, allow_null=True + ) + subscriptions_live_channel_size = serializers.IntegerField( + required=False, allow_null=True + ) + subscriptions_shorts_channel_size = serializers.IntegerField( + required=False, allow_null=True + ) + + +class ChannelSerializer(serializers.Serializer): + """serialize channel""" + + 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_last_refresh = serializers.CharField() + channel_name = serializers.CharField() + channel_overwrites = ChannelOverwriteSerializer(required=False) + channel_subs = serializers.IntegerField() + channel_subscribed = serializers.BooleanField() + channel_tags = serializers.ListField(child=serializers.CharField()) + channel_views = serializers.IntegerField() + _index = serializers.CharField(required=False) + _score = serializers.IntegerField(required=False) + + +class ChannelListSerializer(serializers.Serializer): + """serialize channel list""" + + data = ChannelSerializer(many=True) + paginate = PaginationSerializer() + + +class ChannelListQuerySerializer(serializers.Serializer): + """serialize list query""" + + filter = serializers.ChoiceField(choices=["subscribed"], required=False) + + +class ChannelUpdateSerializer(serializers.Serializer): + """update channel""" + + channel_subscribed = serializers.BooleanField(required=False) + channel_overwrites = ChannelOverwriteSerializer(required=False) + + +class ChannelAggBucketSerializer(serializers.Serializer): + """serialize channel agg bucket""" + + value = serializers.IntegerField() + value_str = serializers.CharField(required=False) + + +class ChannelAggSerializer(serializers.Serializer): + """serialize channel aggregation""" + + total_items = ChannelAggBucketSerializer() + total_size = ChannelAggBucketSerializer() + total_duration = ChannelAggBucketSerializer() + + +class ChannelNavSerializer(serializers.Serializer): + """serialize channel navigation""" + + has_pending = serializers.BooleanField() + has_playlists = serializers.BooleanField() + has_videos = serializers.BooleanField() + has_streams = serializers.BooleanField() + has_shorts = serializers.BooleanField() + + +class ChannelSearchQuerySerializer(serializers.Serializer): + """serialize query parameters for searching""" + + q = serializers.CharField() diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py index f6e96ee4..b2aa688c 100644 --- a/backend/channel/src/index.py +++ b/backend/channel/src/index.py @@ -23,7 +23,7 @@ class YoutubeChannel(YouTubeItem): index_name = "ta_channel" yt_base = "https://www.youtube.com/channel/" yt_obs = { - "playlist_items": "1,0", + "playlist_items": "0,0", "skip_download": True, } @@ -115,7 +115,7 @@ class YoutubeChannel(YouTubeItem): "channel_tvart_url": False, "channel_id": self.youtube_id, "channel_subscribed": False, - "channel_tags": False, + "channel_tags": [], "channel_description": False, "channel_thumb_url": False, "channel_views": 0, diff --git a/backend/channel/views.py b/backend/channel/views.py index 7fe0860a..5e01320e 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -1,10 +1,25 @@ """all channel API views""" +from channel.serializers import ( + ChannelAggSerializer, + ChannelListQuerySerializer, + ChannelListSerializer, + ChannelNavSerializer, + ChannelSearchQuerySerializer, + ChannelSerializer, + ChannelUpdateSerializer, +) from channel.src.index import YoutubeChannel, channel_overwrites from channel.src.nav import ChannelNav +from common.serializers import ErrorResponseSerializer from common.src.urlparser import Parser from common.views_base import AdminWriteOnly, ApiBaseView from download.src.subscriptions import ChannelSubscription +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, +) from rest_framework.response import Response from task.tasks import index_channel_playlists, subscribe_to @@ -19,26 +34,38 @@ class ChannelApiListView(ApiBaseView): valid_filter = ["subscribed"] permission_classes = [AdminWriteOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(ChannelListSerializer()), + }, + parameters=[ + OpenApiParameter( + name="filter", + description="Filter by Subscribed", + type=ChannelListQuerySerializer(), + ), + ], + ) def get(self, request): """get request""" self.data.update( {"sort": [{"channel_name.keyword": {"order": "asc"}}]} ) - query_filter = request.GET.get("filter", False) - must_list = [] - if query_filter: - if query_filter not in self.valid_filter: - message = f"invalid url query filter: {query_filter}" - print(message) - return Response({"message": message}, status=400) + serializer = ChannelListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + must_list = [] + query_filter = validated_data.get("filter") + if query_filter: must_list.append({"term": {"channel_subscribed": {"value": True}}}) self.data["query"] = {"bool": {"must": must_list}} self.get_document_list(request) + serializer = ChannelListSerializer(self.response) - return Response(self.response) + return Response(serializer.data) def post(self, request): """subscribe/unsubscribe to list of channels""" @@ -81,53 +108,79 @@ class ChannelApiView(ApiBaseView): search_base = "ta_channel/_doc/" permission_classes = [AdminWriteOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(ChannelSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="Channel not found" + ), + } + ) def get(self, request, channel_id): # pylint: disable=unused-argument - """get request""" + """get channel detail""" self.get_document(channel_id) - return Response(self.response, status=self.status_code) + if not self.response: + error = ErrorResponseSerializer({"error": "channel not found"}) + return Response(error.data, status=404) + response_serializer = ChannelSerializer(self.response) + return Response(response_serializer.data, status=self.status_code) + + @extend_schema( + request=ChannelUpdateSerializer(), + responses={ + 200: OpenApiResponse(ChannelUpdateSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="Channel not found" + ), + }, + ) def post(self, request, channel_id): - """modify channel overwrites""" + """modify channel""" self.get_document(channel_id) - if not self.response["data"]: - return Response({"error": "channel not found"}, status=404) + if not self.response: + error = ErrorResponseSerializer({"error": "channel not found"}) + return Response(error.data, status=404) - data = request.data - subscribed = data.get("channel_subscribed") + serializer = ChannelUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + subscribed = validated_data.get("channel_subscribed") if subscribed is not None: - channel_sub = ChannelSubscription() - json_data = channel_sub.change_subscribe(channel_id, subscribed) - return Response(json_data, status=200) + ChannelSubscription().change_subscribe(channel_id, subscribed) - if "channel_overwrites" not in data: - return Response({"error": "invalid payload"}, status=400) - - overwrites = data["channel_overwrites"] - - try: - json_data = channel_overwrites(channel_id, overwrites) + overwrites = validated_data.get("channel_overwrites") + if overwrites: + channel_overwrites(channel_id, overwrites) if overwrites.get("index_playlists"): index_channel_playlists.delay(channel_id) - except ValueError as err: - return Response({"error": str(err)}, status=400) - - return Response(json_data, status=200) + return Response(serializer.data, status=200) + @extend_schema( + responses={ + 204: OpenApiResponse(description="Channel deleted"), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="Channel not found" + ), + }, + ) def delete(self, request, channel_id): # pylint: disable=unused-argument """delete channel""" - message = {"channel": channel_id} try: YoutubeChannel(channel_id).delete_channel() - status_code = 200 - message.update({"state": "delete"}) + return Response(status=204) except FileNotFoundError: - status_code = 404 - message.update({"state": "not found"}) + pass - return Response(message, status=status_code) + error = ErrorResponseSerializer({"error": "channel not found"}) + return Response(error.data, status=404) class ChannelAggsApiView(ApiBaseView): @@ -137,8 +190,13 @@ class ChannelAggsApiView(ApiBaseView): search_base = "ta_video/_search" + @extend_schema( + responses={ + 200: OpenApiResponse(ChannelAggSerializer()), + }, + ) def get(self, request, channel_id): - """get aggs""" + """get channel aggregations""" self.data.update( { "query": { @@ -152,8 +210,9 @@ class ChannelAggsApiView(ApiBaseView): } ) self.get_aggs() + serializer = ChannelAggSerializer(self.response) - return Response(self.response) + return Response(serializer.data) class ChannelNavApiView(ApiBaseView): @@ -161,11 +220,17 @@ class ChannelNavApiView(ApiBaseView): GET: get channel nav """ + @extend_schema( + responses={ + 200: OpenApiResponse(ChannelNavSerializer()), + }, + ) def get(self, request, channel_id): - """get nav""" + """get navigation""" nav = ChannelNav(channel_id).get_nav() - return Response(nav) + serializer = ChannelNavSerializer(nav) + return Response(serializer.data) class ChannelApiSearchView(ApiBaseView): @@ -175,10 +240,31 @@ class ChannelApiSearchView(ApiBaseView): search_base = "ta_channel/_doc/" + @extend_schema( + responses={ + 200: OpenApiResponse(ChannelSerializer()), + 400: OpenApiResponse(description="Bad Request"), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="Channel not found" + ), + }, + parameters=[ + OpenApiParameter( + name="q", + description="Search query string", + required=True, + type=str, + ), + ], + ) def get(self, request): - """handle get request, search with s parameter""" + """search for local channel ID""" - query = request.GET.get("q") + serializer = ChannelSearchQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + query = validated_data.get("q") if not query: message = "missing expected q parameter" return Response({"message": message, "data": False}, status=400) @@ -186,13 +272,16 @@ class ChannelApiSearchView(ApiBaseView): try: parsed = Parser(query).parse()[0] except (ValueError, IndexError, AttributeError): - message = f"channel not found: {query}" - return Response({"message": message, "data": False}, status=404) + error = ErrorResponseSerializer( + {"error": f"channel not found: {query}"} + ) + return Response(error.data, status=404) if not parsed["type"] == "channel": - message = "expected type channel" - return Response({"message": message, "data": False}, status=400) + error = ErrorResponseSerializer({"error": "expected channel data"}) + return Response(error.data, status=400) self.get_document(parsed["url"]) + serializer = ChannelSerializer(self.response) - return Response(self.response, status=self.status_code) + return Response(serializer.data, status=self.status_code) diff --git a/backend/common/serializers.py b/backend/common/serializers.py new file mode 100644 index 00000000..a22eac5e --- /dev/null +++ b/backend/common/serializers.py @@ -0,0 +1,142 @@ +"""common serializers""" + +# pylint: disable=abstract-method + +from rest_framework import serializers + + +class ValidateUnknownFieldsMixin: + """ + Mixin to validate and reject unknown fields in a serializer. + """ + + def to_internal_value(self, data): + """check expected keys""" + allowed_fields = set(self.fields.keys()) + input_fields = set(data.keys()) + + # Find unknown fields + unknown_fields = input_fields - allowed_fields + if unknown_fields: + raise serializers.ValidationError( + {"error": f"Unknown fields: {', '.join(unknown_fields)}"} + ) + + return super().to_internal_value(data) + + +class ErrorResponseSerializer(serializers.Serializer): + """error message""" + + error = serializers.CharField() + + +class PaginationSerializer(serializers.Serializer): + """serialize paginate response""" + + page_size = serializers.IntegerField() + page_from = serializers.IntegerField() + prev_pages = serializers.ListField( + child=serializers.IntegerField(), allow_null=True + ) + current_page = serializers.IntegerField() + max_hits = serializers.BooleanField() + params = serializers.CharField() + last_page = serializers.IntegerField() + next_pages = serializers.ListField( + child=serializers.IntegerField(), allow_null=True + ) + total_hits = serializers.IntegerField() + + +class AsyncTaskResponseSerializer(serializers.Serializer): + """serialize new async task""" + + message = serializers.CharField(required=False) + task_id = serializers.CharField() + status = serializers.CharField(required=False) + filename = serializers.CharField(required=False) + + +class NotificationSerializer(serializers.Serializer): + """serialize notification messages""" + + id = serializers.CharField() + title = serializers.CharField() + group = serializers.CharField() + api_start = serializers.BooleanField() + api_stop = serializers.BooleanField() + level = serializers.ChoiceField(choices=["info", "error"]) + messages = serializers.ListField(child=serializers.CharField()) + progress = serializers.FloatField(required=False) + + +class NotificationQueryFilterSerializer(serializers.Serializer): + """serialize notification query filter""" + + filter = serializers.ChoiceField( + choices=["download", "settings", "channel"], required=False + ) + + +class PingUpdateSerializer(serializers.Serializer): + """serialize update notification""" + + status = serializers.BooleanField() + version = serializers.CharField() + is_breaking = serializers.BooleanField() + + +class PingSerializer(serializers.Serializer): + """serialize ping response""" + + response = serializers.ChoiceField(choices=["pong"]) + user = serializers.IntegerField() + version = serializers.CharField() + ta_update = PingUpdateSerializer(required=False) + + +class WatchedDataSerializer(serializers.Serializer): + """mark as watched serializer""" + + id = serializers.CharField() + is_watched = serializers.BooleanField() + + +class RefreshQuerySerializer(serializers.Serializer): + """refresh query filtering""" + + type = serializers.ChoiceField( + choices=["video", "channel", "playlist"], required=False + ) + id = serializers.CharField(required=False) + + +class RefreshResponseSerializer(serializers.Serializer): + """serialize refresh response""" + + state = serializers.ChoiceField( + choices=["running", "queued", "empty", False] + ) + total_queued = serializers.IntegerField() + in_queue_name = serializers.CharField(required=False) + + +class RefreshAddQuerySerializer(serializers.Serializer): + """serialize add to refresh queue""" + + extract_videos = serializers.BooleanField(required=False) + + +class RefreshAddDataSerializer(serializers.Serializer): + """add to refresh queue serializer""" + + video = serializers.ListField( + child=serializers.CharField(), required=False + ) + channel = serializers.ListField( + child=serializers.CharField(), required=False + ) + playlist = serializers.ListField( + child=serializers.CharField(), required=False + ) diff --git a/backend/common/src/env_settings.py b/backend/common/src/env_settings.py index 5969a06f..7335682e 100644 --- a/backend/common/src/env_settings.py +++ b/backend/common/src/env_settings.py @@ -25,6 +25,7 @@ class EnvironmentSettings: HOST_UID: int = int(environ.get("HOST_UID", False)) HOST_GID: int = int(environ.get("HOST_GID", False)) ENABLE_CAST: bool = bool(environ.get("ENABLE_CAST")) + DISABLE_STATIC_AUTH: bool = bool(environ.get("DISABLE_STATIC_AUTH")) TZ: str = str(environ.get("TZ", "UTC")) TA_PORT: int = int(environ.get("TA_PORT", False)) TA_BACKEND_PORT: int = int(environ.get("TA_BACKEND_PORT", False)) @@ -73,6 +74,7 @@ class EnvironmentSettings: HOST_GID: {self.HOST_GID} TZ: {self.TZ} ENABLE_CAST: {self.ENABLE_CAST} + DISABLE_STATIC_AUTH: {self.DISABLE_STATIC_AUTH} TA_PORT: {self.TA_PORT} TA_BACKEND_PORT: {self.TA_BACKEND_PORT} TA_USERNAME: {self.TA_USERNAME} diff --git a/backend/common/src/index_generic.py b/backend/common/src/index_generic.py index 3daeefe7..450c1f88 100644 --- a/backend/common/src/index_generic.py +++ b/backend/common/src/index_generic.py @@ -106,7 +106,7 @@ class Pagination: page_get = self.page_get page_from = 0 if page_get in [0, 1]: - prev_pages = False + prev_pages = None elif page_get > 1: page_from = (page_get - 1) * self.page_size prev_pages = [ diff --git a/backend/common/src/search_processor.py b/backend/common/src/search_processor.py index a5669852..714d33b0 100644 --- a/backend/common/src/search_processor.py +++ b/backend/common/src/search_processor.py @@ -115,6 +115,8 @@ class SearchProcess: video_dict["subtitles"][idx][ "media_url" ] = f"{media_root}/{url}" + else: + video_dict["subtitles"] = [] video_dict.update( { @@ -138,6 +140,9 @@ class SearchProcess: } ) + if "playlist" not in video_dict: + video_dict["playlist"] = [] + return dict(sorted(video_dict.items())) @staticmethod diff --git a/backend/common/views.py b/backend/common/views.py index b7b477c9..4a30b7e0 100644 --- a/backend/common/views.py +++ b/backend/common/views.py @@ -2,10 +2,23 @@ from appsettings.src.config import ReleaseVersion from appsettings.src.reindex import ReindexProgress +from common.serializers import ( + AsyncTaskResponseSerializer, + ErrorResponseSerializer, + NotificationQueryFilterSerializer, + NotificationSerializer, + PingSerializer, + RefreshAddDataSerializer, + RefreshAddQuerySerializer, + RefreshQuerySerializer, + RefreshResponseSerializer, + WatchedDataSerializer, +) from common.src.searching import SearchForm from common.src.ta_redis import RedisArchivist from common.src.watched import WatchState from common.views_base import AdminOnly, ApiBaseView +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.response import Response from task.tasks import check_reindex @@ -16,6 +29,9 @@ class PingView(ApiBaseView): """ @staticmethod + @extend_schema( + responses={200: OpenApiResponse(PingSerializer())}, + ) def get(request): """get pong""" data = { @@ -24,7 +40,8 @@ class PingView(ApiBaseView): "version": ReleaseVersion().get_local_version(), "ta_update": ReleaseVersion().get_update(), } - return Response(data) + serializer = PingSerializer(data) + return Response(serializer.data) class RefreshView(ApiBaseView): @@ -35,30 +52,69 @@ class RefreshView(ApiBaseView): permission_classes = [AdminOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(RefreshResponseSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + parameters=[RefreshQuerySerializer()], + ) def get(self, request): - """handle get request""" - request_type = request.GET.get("type") - request_id = request.GET.get("id") + """get refresh status""" + query_serializer = RefreshQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + request_type = validated_query.get("type") + request_id = validated_query.get("id") if request_id and not request_type: - return Response({"status": "Bad Request"}, status=400) + error = ErrorResponseSerializer( + {"error": "specified id also needs type"} + ) + return Response(error.data, status=400) try: progress = ReindexProgress( request_type=request_type, request_id=request_id ).get_progress() except ValueError: - return Response({"status": "Bad Request"}, status=400) + error = ErrorResponseSerializer({"error": "bad request"}) + return Response(error.data, status=400) - return Response(progress) + response_serializer = RefreshResponseSerializer(progress) + return Response(response_serializer.data) + + @extend_schema( + request=RefreshAddDataSerializer(), + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + }, + parameters=[RefreshAddQuerySerializer()], + ) def post(self, request): - """handle post request""" - data = request.data - extract_videos = bool(request.GET.get("extract_videos", False)) - check_reindex.delay(data=data, extract_videos=extract_videos) + """add to reindex queue""" + query_serializer = RefreshAddQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data - return Response(data) + data_serializer = RefreshAddDataSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + extract_videos = validated_query.get("extract_videos") + task = check_reindex.delay( + data=validated_data, extract_videos=extract_videos + ) + message = { + "message": "reindex task started", + "task_id": task.id, + } + serializer = AsyncTaskResponseSerializer(message) + + return Response(serializer.data) class WatchedView(ApiBaseView): @@ -66,17 +122,31 @@ class WatchedView(ApiBaseView): POST: change watched state of video, channel or playlist """ + @extend_schema( + request=WatchedDataSerializer(), + responses={ + 200: OpenApiResponse(WatchedDataSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) def post(self, request): """change watched state""" - youtube_id = request.data.get("id") - is_watched = request.data.get("is_watched") + data_serializer = WatchedDataSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + youtube_id = validated_data.get("id") + is_watched = validated_data.get("is_watched") if not youtube_id or is_watched is None: - message = {"message": "missing id or is_watched"} - return Response(message, status=400) + error = ErrorResponseSerializer( + {"error": "missing id or is_watched"} + ) + return Response(error.data, status=400) WatchState(youtube_id, is_watched, request.user.id).change() - return Response({"message": "success"}, status=200) + return Response(data_serializer.data) class SearchView(ApiBaseView): @@ -106,11 +176,26 @@ class NotificationView(ApiBaseView): valid_filters = ["download", "settings", "channel"] + @extend_schema( + responses={ + 200: OpenApiResponse(NotificationSerializer(many=True)), + }, + parameters=[NotificationQueryFilterSerializer], + ) def get(self, request): """get all notifications""" + query_serializer = NotificationQueryFilterSerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + filter_by = validated_query.get("filter") + query = "message" - filter_by = request.GET.get("filter", None) if filter_by in self.valid_filters: query = f"{query}:{filter_by}" - return Response(RedisArchivist().list_items(query)) + notifications = RedisArchivist().list_items(query) + response_serializer = NotificationSerializer(notifications, many=True) + + return Response(response_serializer.data) diff --git a/backend/common/views_base.py b/backend/common/views_base.py index 324ecb05..9382dbae 100644 --- a/backend/common/views_base.py +++ b/backend/common/views_base.py @@ -1,7 +1,5 @@ """base classes to inherit from""" -from appsettings.src.config import AppConfig -from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap from common.src.index_generic import Pagination from common.src.search_processor import SearchProcess, process_aggs @@ -45,13 +43,7 @@ class ApiBaseView(APIView): def __init__(self): super().__init__() - self.response = { - "data": False, - "config": { - "enable_cast": EnvironmentSettings.ENABLE_CAST, - "downloads": AppConfig().config["downloads"], - }, - } + self.response = {} self.data = {"query": {"match_all": {}}} self.status_code = False self.context = False @@ -62,12 +54,12 @@ class ApiBaseView(APIView): path = f"{self.search_base}{document_id}" response, status_code = ElasticWrap(path).get() try: - self.response["data"] = SearchProcess( + self.response = SearchProcess( response, match_video_user_progress=progress_match ).process() except KeyError: print(f"item not found: {document_id}") - self.response["data"] = False + self.status_code = status_code def initiate_pagination(self, request): diff --git a/backend/config/management/commands/ta_change_password.py b/backend/config/management/commands/ta_change_password.py new file mode 100644 index 00000000..4d2f1bb5 --- /dev/null +++ b/backend/config/management/commands/ta_change_password.py @@ -0,0 +1,36 @@ +"""change user password""" + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +User = get_user_model() + + +class Command(BaseCommand): + """change password""" + + help = "Change Password of user" + + def add_arguments(self, parser): + parser.add_argument("username", type=str) + parser.add_argument("password", type=str) + + def handle(self, *args, **kwargs): + """entry point""" + username = kwargs["username"] + new_password = kwargs["password"] + self.stdout.write(f"Changing password for user '{username}'") + try: + user = User.objects.get(name=username) + except User.DoesNotExist as err: + message = f"Username '{username}' does not exist. " + message += "Available username(s) are:\n" + message += ", ".join([i.name for i in User.objects.all()]) + raise CommandError(message) from err + + user.set_password(new_password) + user.save() + + self.stdout.write( + self.style.SUCCESS(f" ✓ updated password for user '{username}'") + ) diff --git a/backend/config/management/commands/ta_envcheck.py b/backend/config/management/commands/ta_envcheck.py index bdc591b5..a2a185aa 100644 --- a/backend/config/management/commands/ta_envcheck.py +++ b/backend/config/management/commands/ta_envcheck.py @@ -168,25 +168,35 @@ class Command(BaseCommand): self.stdout.write(self.style.SUCCESS(message)) def _enable_cast_overwrite(self): - """cast workaround, remove auth for static files in nginx""" + """enable cast env var""" self.stdout.write("[6] check ENABLE_CAST overwrite") overwrite = EnvironmentSettings.ENABLE_CAST if not overwrite: self.stdout.write(self.style.SUCCESS(" ENABLE_CAST is not set")) return + def _disable_static_auth(self): + """cast workaround, remove auth for static files in nginx""" + self.stdout.write("[7] check DISABLE_STATIC_AUTH overwrite") + overwrite = EnvironmentSettings.DISABLE_STATIC_AUTH + if not overwrite: + self.stdout.write( + self.style.SUCCESS(" DISABLE_STATIC_AUTH is not set") + ) + return + regex = re.compile(r"[^\S\r\n]*auth_request /api/ping/;\n") changed = file_overwrite(NGINX, regex, "") if changed: - message = " ✓ process nginx to enable Cast" + message = " ✓ process nginx to disable static auth" else: - message = " ✓ Cast is already enabled in nginx" + message = " ✓ static auth is already disabled in nginx" self.stdout.write(self.style.SUCCESS(message)) def _create_superuser(self): """create superuser if not exist""" - self.stdout.write("[7] create superuser") + self.stdout.write("[8] create superuser") is_created = Account.objects.filter(is_superuser=True) if is_created: message = " superuser already created" diff --git a/backend/config/management/commands/ta_startup.py b/backend/config/management/commands/ta_startup.py index e2d0991f..ec6d2ffc 100644 --- a/backend/config/management/commands/ta_startup.py +++ b/backend/config/management/commands/ta_startup.py @@ -42,7 +42,6 @@ class Command(BaseCommand): def handle(self, *args, **options): """run all commands""" self.stdout.write(TOPIC) - self._mig_app_settings() self._make_folders() self._clear_redis_keys() self._clear_tasks() @@ -50,9 +49,113 @@ class Command(BaseCommand): self._version_check() self._index_setup() self._snapshot_check() + self._mig_app_settings() self._create_default_schedules() self._update_schedule_tz() self._init_app_config() + self._mig_channel_tags() + self._mig_video_channel_tags() + + def _make_folders(self): + """make expected cache folders""" + self.stdout.write("[1] create expected cache folders") + folders = [ + "backup", + "channels", + "download", + "import", + "playlists", + "videos", + ] + cache_dir = EnvironmentSettings.CACHE_DIR + for folder in folders: + folder_path = os.path.join(cache_dir, folder) + os.makedirs(folder_path, exist_ok=True) + + self.stdout.write(self.style.SUCCESS(" ✓ expected folders created")) + + def _clear_redis_keys(self): + """make sure there are no leftover locks or keys set in redis""" + self.stdout.write("[2] clear leftover keys in redis") + all_keys = [ + "dl_queue_id", + "dl_queue", + "downloading", + "manual_import", + "reindex", + "rescan", + "run_backup", + "startup_check", + "reindex:ta_video", + "reindex:ta_channel", + "reindex:ta_playlist", + ] + + redis_con = RedisArchivist() + has_changed = False + for key in all_keys: + if redis_con.del_message(key): + self.stdout.write( + self.style.SUCCESS(f" ✓ cleared key {key}") + ) + has_changed = True + + if not has_changed: + self.stdout.write(self.style.SUCCESS(" no keys found")) + + def _clear_tasks(self): + """clear tasks and messages""" + self.stdout.write("[3] clear task leftovers") + TaskManager().fail_pending() + redis_con = RedisArchivist() + to_delete = redis_con.list_keys("message:") + if to_delete: + for key in to_delete: + redis_con.del_message(key) + + self.stdout.write( + self.style.SUCCESS(f" ✓ cleared {len(to_delete)} messages") + ) + + def _clear_dl_cache(self): + """clear leftover files from dl cache""" + self.stdout.write("[4] clear leftover files from dl cache") + leftover_files = clear_dl_cache(EnvironmentSettings.CACHE_DIR) + if leftover_files: + self.stdout.write( + self.style.SUCCESS(f" ✓ cleared {leftover_files} files") + ) + else: + self.stdout.write(self.style.SUCCESS(" no files found")) + + def _version_check(self): + """remove new release key if updated now""" + self.stdout.write("[5] check for first run after update") + new_version = ReleaseVersion().is_updated() + if new_version: + self.stdout.write( + self.style.SUCCESS(f" ✓ update to {new_version} completed") + ) + else: + self.stdout.write(self.style.SUCCESS(" no new update found")) + + version_task = CustomPeriodicTask.objects.filter(name="version_check") + if not version_task.exists(): + return + + if not version_task.first().last_run_at: + self.style.SUCCESS(" ✓ send initial version check task") + version_check.delay() + + def _index_setup(self): + """migration: validate index mappings""" + self.stdout.write("[6] validate index mappings") + ElasitIndexWrap().setup() + + def _snapshot_check(self): + """migration setup snapshots""" + self.stdout.write("[7] setup snapshots") + ElasticSnapshot().setup() def _mig_app_settings(self) -> None: """update from v0.4.13 to v0.5.0, migrate application settings""" @@ -87,110 +190,9 @@ class Command(BaseCommand): sleep(60) raise CommandError(message) - def _make_folders(self): - """make expected cache folders""" - self.stdout.write("[2] create expected cache folders") - folders = [ - "backup", - "channels", - "download", - "import", - "playlists", - "videos", - ] - cache_dir = EnvironmentSettings.CACHE_DIR - for folder in folders: - folder_path = os.path.join(cache_dir, folder) - os.makedirs(folder_path, exist_ok=True) - - self.stdout.write(self.style.SUCCESS(" ✓ expected folders created")) - - def _clear_redis_keys(self): - """make sure there are no leftover locks or keys set in redis""" - self.stdout.write("[3] clear leftover keys in redis") - all_keys = [ - "dl_queue_id", - "dl_queue", - "downloading", - "manual_import", - "reindex", - "rescan", - "run_backup", - "startup_check", - "reindex:ta_video", - "reindex:ta_channel", - "reindex:ta_playlist", - ] - - redis_con = RedisArchivist() - has_changed = False - for key in all_keys: - if redis_con.del_message(key): - self.stdout.write( - self.style.SUCCESS(f" ✓ cleared key {key}") - ) - has_changed = True - - if not has_changed: - self.stdout.write(self.style.SUCCESS(" no keys found")) - - def _clear_tasks(self): - """clear tasks and messages""" - self.stdout.write("[4] clear task leftovers") - TaskManager().fail_pending() - redis_con = RedisArchivist() - to_delete = redis_con.list_keys("message:") - if to_delete: - for key in to_delete: - redis_con.del_message(key) - - self.stdout.write( - self.style.SUCCESS(f" ✓ cleared {len(to_delete)} messages") - ) - - def _clear_dl_cache(self): - """clear leftover files from dl cache""" - self.stdout.write("[5] clear leftover files from dl cache") - leftover_files = clear_dl_cache(EnvironmentSettings.CACHE_DIR) - if leftover_files: - self.stdout.write( - self.style.SUCCESS(f" ✓ cleared {leftover_files} files") - ) - else: - self.stdout.write(self.style.SUCCESS(" no files found")) - - def _version_check(self): - """remove new release key if updated now""" - self.stdout.write("[6] check for first run after update") - new_version = ReleaseVersion().is_updated() - if new_version: - self.stdout.write( - self.style.SUCCESS(f" ✓ update to {new_version} completed") - ) - else: - self.stdout.write(self.style.SUCCESS(" no new update found")) - - version_task = CustomPeriodicTask.objects.filter(name="version_check") - if not version_task.exists(): - return - - if not version_task.first().last_run_at: - self.style.SUCCESS(" ✓ send initial version check task") - version_check.delay() - - def _index_setup(self): - """migration: validate index mappings""" - self.stdout.write("[7] validate index mappings") - ElasitIndexWrap().setup() - - def _snapshot_check(self): - """migration setup snapshots""" - self.stdout.write("[8] setup snapshots") - ElasticSnapshot().setup() - def _create_default_schedules(self) -> None: """create default schedules for new installations""" - self.stdout.write("[9] create initial schedules") + self.stdout.write("[8] create initial schedules") init_has_run = CustomPeriodicTask.objects.filter( name="version_check" ).exists() @@ -241,7 +243,7 @@ class Command(BaseCommand): def _update_schedule_tz(self) -> None: """update timezone for Schedule instances""" - self.stdout.write("[10] validate schedules TZ") + self.stdout.write("[9] validate schedules TZ") tz = EnvironmentSettings.TZ to_update = CrontabSchedule.objects.exclude(timezone=tz) @@ -259,7 +261,7 @@ class Command(BaseCommand): def _init_app_config(self) -> None: """init default app config to ES""" - self.stdout.write("[11] Check AppConfig") + self.stdout.write("[10] Check AppConfig") try: _ = AppConfig().config self.stdout.write( @@ -280,3 +282,67 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS(f" Status code: {status_code}") ) + + def _mig_channel_tags(self) -> None: + """update from v0.4.13 to v0.5.0, migrate incorrect data types""" + self.stdout.write("[MIGRATION] fix incorrect channel tags types") + path = "ta_channel/_update_by_query" + data = { + "query": {"match": {"channel_tags": False}}, + "script": { + "source": "ctx._source.channel_tags = []", + "lang": "painless", + }, + } + response, status_code = ElasticWrap(path).post(data) + if status_code in [200, 201]: + updated = response.get("updated") + if updated: + self.stdout.write( + self.style.SUCCESS(f" ✓ fixed {updated} channel tags") + ) + else: + self.stdout.write( + self.style.SUCCESS(" no channel tags needed fixing") + ) + return + + message = " 🗙 failed to fix channel tags" + self.stdout.write(self.style.ERROR(message)) + self.stdout.write(response) + sleep(60) + raise CommandError(message) + + def _mig_video_channel_tags(self) -> None: + """update from v0.4.13 to v0.5.0, migrate incorrect data types""" + self.stdout.write("[MIGRATION] fix incorrect video channel tags types") + path = "ta_video/_update_by_query" + data = { + "query": {"match": {"channel.channel_tags": False}}, + "script": { + "source": "ctx._source.channel.channel_tags = []", + "lang": "painless", + }, + } + response, status_code = ElasticWrap(path).post(data) + if status_code in [200, 201]: + updated = response.get("updated") + if updated: + self.stdout.write( + self.style.SUCCESS( + f" ✓ fixed {updated} video channel tags" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + " no video channel tags needed fixing" + ) + ) + return + + message = " 🗙 failed to fix video channel tags" + self.stdout.write(self.style.ERROR(message)) + self.stdout.write(response) + sleep(60) + raise CommandError(message) diff --git a/backend/config/settings.py b/backend/config/settings.py index 5652efc2..9ea6ddd4 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -59,6 +59,7 @@ INSTALLED_APPS = [ "django.contrib.humanize", "rest_framework", "rest_framework.authtoken", + "drf_spectacular", "common", "video", "channel", @@ -295,3 +296,15 @@ CORS_ALLOW_HEADERS = list(default_headers) + [ # TA application settings TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" TA_VERSION = "v0.5.0-unstable" + +# API +REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Tube Archivist API", + "DESCRIPTION": "API documentation for Tube Archivist backend.", + "VERSION": TA_VERSION, + "SERVE_INCLUDE_SCHEMA": False, +} diff --git a/backend/config/urls.py b/backend/config/urls.py index 2fbf84b3..cc56d743 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -16,6 +16,7 @@ Including another URLconf from django.contrib import admin from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns = [ path("api/", include("common.urls")), @@ -27,5 +28,11 @@ urlpatterns = [ path("api/appsettings/", include("appsettings.urls")), path("api/stats/", include("stats.urls")), path("api/user/", include("user.urls")), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/docs/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), path("admin/", admin.site.urls), ] diff --git a/backend/download/serializers.py b/backend/download/serializers.py new file mode 100644 index 00000000..43c24cc1 --- /dev/null +++ b/backend/download/serializers.py @@ -0,0 +1,93 @@ +"""download serializers""" + +# pylint: disable=abstract-method + +from common.serializers import PaginationSerializer, ValidateUnknownFieldsMixin +from rest_framework import serializers +from video.src.constants import VideoTypeEnum + + +class DownloadItemSerializer(serializers.Serializer): + """serialize download item""" + + auto_start = serializers.BooleanField() + channel_id = serializers.CharField() + channel_indexed = serializers.BooleanField() + channel_name = serializers.CharField() + duration = serializers.CharField() + published = serializers.CharField() + status = serializers.ChoiceField(choices=["pending", "ignore"]) + timestamp = serializers.IntegerField() + title = serializers.CharField() + vid_thumb_url = serializers.CharField() + vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values()) + youtube_id = serializers.CharField() + _index = serializers.CharField(required=False) + _score = serializers.IntegerField(required=False) + + +class DownloadListSerializer(serializers.Serializer): + """serialize download list""" + + data = DownloadItemSerializer(many=True) + paginate = PaginationSerializer() + + +class DownloadListQuerySerializer( + ValidateUnknownFieldsMixin, serializers.Serializer +): + """serialize query params for download list""" + + filter = serializers.ChoiceField( + choices=["pending", "ignore"], required=False + ) + channel = serializers.CharField(required=False, help_text="channel ID") + + +class DownloadListQueueDeleteQuerySerializer(serializers.Serializer): + """serialize bulk delete download queue query string""" + + filter = serializers.ChoiceField(choices=["pending", "ignore"]) + + +class AddDownloadItemSerializer(serializers.Serializer): + """serialize single item to add""" + + youtube_id = serializers.CharField() + status = serializers.ChoiceField(choices=["pending"]) + + +class AddToDownloadListSerializer(serializers.Serializer): + """serialize add to download queue data""" + + data = AddDownloadItemSerializer(many=True) + + +class AddToDownloadQuerySerializer(serializers.Serializer): + """add to queue query serializer""" + + autostart = serializers.BooleanField(required=False) + + +class DownloadQueueItemUpdateSerializer(serializers.Serializer): + """update single download queue item""" + + status = serializers.ChoiceField( + choices=["pending", "ignore", "ignore-force", "priority"] + ) + + +class DownloadAggBucketSerializer(serializers.Serializer): + """serialize bucket""" + + key = serializers.ListField(child=serializers.CharField()) + key_as_string = serializers.CharField() + doc_count = serializers.IntegerField() + + +class DownloadAggsSerializer(serializers.Serializer): + """serialize download channel bucket aggregations""" + + doc_count_error_upper_bound = serializers.IntegerField() + sum_other_doc_count = serializers.IntegerField() + buckets = DownloadAggBucketSerializer(many=True) diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py index d2dc4fe3..e4238c18 100644 --- a/backend/download/src/queue.py +++ b/backend/download/src/queue.py @@ -322,7 +322,7 @@ class PendingList(PendingIndex): duration = vid.get("duration") if duration and isinstance(duration, int): - if duration > 60: + if duration > 3 * 60: return False return is_shorts(vid["id"]) diff --git a/backend/download/src/yt_dlp_base.py b/backend/download/src/yt_dlp_base.py index 52070818..05043123 100644 --- a/backend/download/src/yt_dlp_base.py +++ b/backend/download/src/yt_dlp_base.py @@ -134,7 +134,7 @@ class CookieHandler: """set cookie str and activate in config""" cookie_clean = cookie.strip("\x00") RedisArchivist().set_message("cookie", cookie_clean, save=True) - AppConfig().update_config({"downloads.cookie_import": True}) + AppConfig().update_config({"downloads": {"cookie_import": True}}) self.config["downloads"]["cookie_import"] = True print("[cookie]: activated and stored in Redis") @@ -143,7 +143,7 @@ class CookieHandler: """revoke cookie""" RedisArchivist().del_message("cookie") RedisArchivist().del_message("cookie:valid") - AppConfig().update_config({"downloads.cookie_import": False}) + AppConfig().update_config({"downloads": {"cookie_import": False}}) print("[cookie]: revoked") def validate(self): @@ -211,9 +211,9 @@ class POTokenHandler: 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}) + 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}) + AppConfig().update_config({"downloads": {"potoken": False}}) diff --git a/backend/download/views.py b/backend/download/views.py index 33e245a1..6ef1f7ff 100644 --- a/backend/download/views.py +++ b/backend/download/views.py @@ -1,7 +1,22 @@ """all download API views""" +from common.serializers import ( + AsyncTaskResponseSerializer, + ErrorResponseSerializer, +) from common.views_base import AdminOnly, ApiBaseView +from download.serializers import ( + AddToDownloadListSerializer, + AddToDownloadQuerySerializer, + DownloadAggsSerializer, + DownloadItemSerializer, + DownloadListQuerySerializer, + DownloadListQueueDeleteQuerySerializer, + DownloadListSerializer, + DownloadQueueItemUpdateSerializer, +) from download.src.queue import PendingInteract +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.response import Response from task.tasks import download_pending, extrac_dl @@ -17,8 +32,14 @@ class DownloadApiListView(ApiBaseView): valid_filter = ["pending", "ignore"] permission_classes = [AdminOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(DownloadListSerializer()), + }, + parameters=[DownloadListQuerySerializer()], + ) def get(self, request): - """get request""" + """get download queue list""" query_filter = request.GET.get("filter", False) self.data.update( { @@ -29,16 +50,16 @@ class DownloadApiListView(ApiBaseView): } ) - must_list = [] - if query_filter: - if query_filter not in self.valid_filter: - message = f"invalid url query filter: {query_filter}" - print(message) - return Response({"message": message}, status=400) + serializer = DownloadListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + must_list = [] + query_filter = validated_data.get("filter") + if query_filter: must_list.append({"term": {"status": {"value": query_filter}}}) - filter_channel = request.GET.get("channel", False) + filter_channel = validated_data.get("channel") if filter_channel: must_list.append( {"term": {"channel_id": {"value": filter_channel}}} @@ -47,39 +68,169 @@ class DownloadApiListView(ApiBaseView): self.data["query"] = {"bool": {"must": must_list}} self.get_document_list(request) - return Response(self.response) + serializer = DownloadListSerializer(self.response) + + return Response(serializer.data) @staticmethod + @extend_schema( + request=AddToDownloadListSerializer(), + parameters=[AddToDownloadQuerySerializer()], + responses={ + 200: OpenApiResponse( + AsyncTaskResponseSerializer(), + description="New async task started", + ), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) def post(request): """add list of videos to download queue""" - data = request.data - auto_start = bool(request.GET.get("autostart")) - try: - to_add = data["data"] - except KeyError: - message = "missing expected data key" - print(message) - return Response({"message": message}, status=400) + data_serializer = AddToDownloadListSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + query_serializer = AddToDownloadQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + + auto_start = validated_query.get("autostart") + print(f"auto_start: {auto_start}") + to_add = validated_data["data"] pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"] url_str = " ".join(pending) - extrac_dl.delay(url_str, auto_start=auto_start) + task = extrac_dl.delay(url_str, auto_start=auto_start) - return Response(data) + message = { + "message": "add to queue task started", + "task_id": task.id, + } + response_serializer = AsyncTaskResponseSerializer(message) + return Response(response_serializer.data) + + @extend_schema( + parameters=[DownloadListQueueDeleteQuerySerializer()], + responses={ + 204: OpenApiResponse(description="Download items deleted"), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) def delete(self, request): - """delete download queue""" - query_filter = request.GET.get("filter", False) - if query_filter not in self.valid_filter: - message = f"invalid url query filter: {query_filter}" - print(message) - return Response({"message": message}, status=400) + """bulk delete download queue items by filter""" + serializer = DownloadListQueueDeleteQuerySerializer( + data=request.query_params + ) + serializer.is_valid(raise_exception=True) + validated_query = serializer.validated_data + query_filter = validated_query["filter"] message = f"delete queue by status: {query_filter}" print(message) PendingInteract(status=query_filter).delete_by_status() - return Response({"message": message}) + return Response(status=204) + + +class DownloadApiView(ApiBaseView): + """resolves to /api/download// + GET: returns metadata dict of an item in the download queue + POST: update status of item to pending or ignore + DELETE: forget from download queue + """ + + search_base = "ta_download/_doc/" + valid_status = ["pending", "ignore", "ignore-force", "priority"] + permission_classes = [AdminOnly] + + @extend_schema( + responses={ + 200: OpenApiResponse(DownloadItemSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), + description="Download item not found", + ), + }, + ) + def get(self, request, video_id): + # pylint: disable=unused-argument + """get download queue item""" + self.get_document(video_id) + if not self.response: + error = ErrorResponseSerializer( + {"error": "Download item not found"} + ) + return Response(error.data, status=404) + + response_serializer = DownloadItemSerializer(self.response) + + return Response(response_serializer.data, status=self.status_code) + + @extend_schema( + request=DownloadQueueItemUpdateSerializer(), + responses={ + 200: OpenApiResponse( + DownloadQueueItemUpdateSerializer(), + description="Download item update", + ), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + 404: OpenApiResponse( + ErrorResponseSerializer(), + description="Download item not found", + ), + }, + ) + def post(self, request, video_id): + """post to video to change status""" + data_serializer = DownloadQueueItemUpdateSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + item_status = validated_data["status"] + + if item_status == "ignore-force": + extrac_dl.delay(video_id, status="ignore") + return Response(data_serializer.data) + + _, status_code = PendingInteract(video_id).get_item() + if status_code == 404: + error = ErrorResponseSerializer( + {"error": "Download item not found"} + ) + return Response(error.data, status=404) + + print(f"{video_id}: change status to {item_status}") + PendingInteract(video_id, item_status).update_status() + if item_status == "priority": + download_pending.delay(auto_only=True) + + return Response(data_serializer.data) + + @staticmethod + @extend_schema( + responses={ + 204: OpenApiResponse(description="delete download item"), + 404: OpenApiResponse( + ErrorResponseSerializer(), + description="Download item not found", + ), + }, + ) + def delete(request, video_id): + # pylint: disable=unused-argument + """delete single video from queue""" + print(f"{video_id}: delete from queue") + PendingInteract(video_id).delete_item() + + return Response(status=204) class DownloadAggsApiView(ApiBaseView): @@ -90,9 +241,24 @@ class DownloadAggsApiView(ApiBaseView): search_base = "ta_download/_search" valid_filter_view = ["ignore", "pending"] + @extend_schema( + parameters=[DownloadListQueueDeleteQuerySerializer()], + responses={ + 200: OpenApiResponse(DownloadAggsSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + }, + ) def get(self, request): """get aggs""" - filter_view = request.GET.get("filter") + serializer = DownloadListQueueDeleteQuerySerializer( + data=request.query_params + ) + serializer.is_valid(raise_exception=True) + validated_query = serializer.validated_data + + filter_view = validated_query.get("filter") if filter_view: if filter_view not in self.valid_filter_view: message = f"invalid filter: {filter_view}" @@ -121,57 +287,6 @@ class DownloadAggsApiView(ApiBaseView): } ) self.get_aggs() + serializer = DownloadAggsSerializer(self.response["channel_downloads"]) - return Response(self.response) - - -class DownloadApiView(ApiBaseView): - """resolves to /api/download// - GET: returns metadata dict of an item in the download queue - POST: update status of item to pending or ignore - DELETE: forget from download queue - """ - - search_base = "ta_download/_doc/" - valid_status = ["pending", "ignore", "ignore-force", "priority"] - permission_classes = [AdminOnly] - - def get(self, request, video_id): - # pylint: disable=unused-argument - """get request""" - self.get_document(video_id) - return Response(self.response, status=self.status_code) - - def post(self, request, video_id): - """post to video to change status""" - item_status = request.data.get("status") - if item_status not in self.valid_status: - message = f"{video_id}: invalid status {item_status}" - print(message) - return Response({"message": message}, status=400) - - if item_status == "ignore-force": - extrac_dl.delay(video_id, status="ignore") - message = f"{video_id}: set status to ignore" - return Response(request.data) - - _, status_code = PendingInteract(video_id).get_item() - if status_code == 404: - message = f"{video_id}: item not found {status_code}" - return Response({"message": message}, status=404) - - print(f"{video_id}: change status to {item_status}") - PendingInteract(video_id, item_status).update_status() - if item_status == "priority": - download_pending.delay(auto_only=True) - - return Response(request.data) - - @staticmethod - def delete(request, video_id): - # pylint: disable=unused-argument - """delete single video from queue""" - print(f"{video_id}: delete from queue") - PendingInteract(video_id).delete_item() - - return Response({"success": True}) + return Response(serializer.data) diff --git a/backend/playlist/serializers.py b/backend/playlist/serializers.py new file mode 100644 index 00000000..15d6d027 --- /dev/null +++ b/backend/playlist/serializers.py @@ -0,0 +1,91 @@ +"""playlist serializers""" + +# pylint: disable=abstract-method + +from common.serializers import PaginationSerializer +from rest_framework import serializers + + +class PlaylistEntrySerializer(serializers.Serializer): + """serialize single playlist entry""" + + youtube_id = serializers.CharField() + title = serializers.CharField() + uploader = serializers.CharField() + idx = serializers.IntegerField() + downloaded = serializers.BooleanField() + + +class PlaylistSerializer(serializers.Serializer): + """serialize playlist""" + + playlist_active = serializers.BooleanField() + playlist_channel = serializers.CharField() + playlist_channel_id = serializers.CharField() + playlist_description = serializers.CharField() + playlist_entries = PlaylistEntrySerializer(many=True) + playlist_id = serializers.CharField() + playlist_last_refresh = serializers.CharField() + playlist_name = serializers.CharField() + playlist_subscribed = serializers.BooleanField() + playlist_thumbnail = serializers.CharField() + playlist_type = serializers.ChoiceField(choices=["regular", "custom"]) + _index = serializers.CharField(required=False) + _score = serializers.IntegerField(required=False) + + +class PlaylistListSerializer(serializers.Serializer): + """serialize list of playlists""" + + data = PlaylistSerializer(many=True) + paginate = PaginationSerializer() + + +class PlaylistListQuerySerializer(serializers.Serializer): + """serialize playlist list query params""" + + channel = serializers.CharField(required=False) + subscribed = serializers.BooleanField(required=False) + type = serializers.ChoiceField( + choices=["regular", "custom"], required=False + ) + + +class PlaylistSingleAddSerializer(serializers.Serializer): + """single item to add""" + + playlist_id = serializers.CharField() + playlist_subscribed = serializers.ChoiceField(choices=[True]) + + +class PlaylistBulkAddSerializer(serializers.Serializer): + """bulk add playlists serializers""" + + data = PlaylistSingleAddSerializer(many=True) + + +class PlaylistSingleUpdate(serializers.Serializer): + """update state of single playlist""" + + playlist_subscribed = serializers.BooleanField() + + +class PlaylistListCustomPostSerializer(serializers.Serializer): + """serialize list post custom playlist""" + + playlist_name = serializers.CharField() + + +class PlaylistCustomPostSerializer(serializers.Serializer): + """serialize playlist custom action""" + + action = serializers.ChoiceField( + choices=["create", "remove", "up", "down", "top", "bottom"] + ) + video_id = serializers.CharField() + + +class PlaylistDeleteQuerySerializer(serializers.Serializer): + """serialize playlist delete query params""" + + delete_videos = serializers.BooleanField(required=False) diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py index 387c213c..584342b3 100644 --- a/backend/playlist/src/index.py +++ b/backend/playlist/src/index.py @@ -201,7 +201,7 @@ class YoutubePlaylist(YouTubeItem): current_idx = all_entries.index(current[0]) if current_idx == 0: - previous_item = False + previous_item = None else: previous_item = all_entries[current_idx - 1] prev_id = previous_item["youtube_id"] @@ -209,7 +209,7 @@ class YoutubePlaylist(YouTubeItem): previous_item["vid_thumb"] = f"{cache_root}/{prev_thumb_path}" if current_idx == len(all_entries) - 1: - next_item = False + next_item = None else: next_item = all_entries[current_idx + 1] next_id = next_item["youtube_id"] diff --git a/backend/playlist/src/query_building.py b/backend/playlist/src/query_building.py index 4751144f..9b93f640 100644 --- a/backend/playlist/src/query_building.py +++ b/backend/playlist/src/query_building.py @@ -23,16 +23,15 @@ class QueryBuilder: must_list = [] channel = self.request_params.get("channel") if channel: - must_list.append({"match": {"playlist_channel_id": channel[0]}}) + must_list.append({"match": {"playlist_channel_id": channel}}) subscribed = self.request_params.get("subscribed") if subscribed: - subed_bool = subscribed[0] == "true" - must_list.append({"match": {"playlist_subscribed": subed_bool}}) + must_list.append({"match": {"playlist_subscribed": subscribed}}) playlist_type = self.request_params.get("type") if playlist_type: - type_list = self.parse_type(playlist_type[0]) + type_list = self.parse_type(playlist_type) must_list.append(type_list) query = {"bool": {"must": must_list}} diff --git a/backend/playlist/tests/test_src/test_query_building.py b/backend/playlist/tests/test_src/test_query_building.py index 40404f12..66a8ec22 100644 --- a/backend/playlist/tests/test_src/test_query_building.py +++ b/backend/playlist/tests/test_src/test_query_building.py @@ -7,9 +7,9 @@ from playlist.src.query_building import QueryBuilder def test_build_data(): """test for correct key building""" qb = QueryBuilder( - channel=["test_channel"], - subscribed=["true"], - type=["regular"], + channel="test_channel", + subscribed=True, + type="regular", ) result = qb.build_data() must_list = result["query"]["bool"]["must"] @@ -22,7 +22,7 @@ def test_build_data(): def test_parse_type(): """validate type""" - qb = QueryBuilder(type=["regular"]) + qb = QueryBuilder(type="regular") with pytest.raises(ValueError): qb.parse_type("invalid") diff --git a/backend/playlist/urls.py b/backend/playlist/urls.py index c144e2e0..93f3ca65 100644 --- a/backend/playlist/urls.py +++ b/backend/playlist/urls.py @@ -9,6 +9,16 @@ urlpatterns = [ views.PlaylistApiListView.as_view(), name="api-playlist-list", ), + path( + "custom/", + views.PlaylistCustomApiListView.as_view(), + name="api-custom-playlist-list", + ), + path( + "custom//", + views.PlaylistCustomApiView.as_view(), + name="api-custom-playlist", + ), path( "/", views.PlaylistApiView.as_view(), diff --git a/backend/playlist/views.py b/backend/playlist/views.py index 79f6ea09..6ec102ff 100644 --- a/backend/playlist/views.py +++ b/backend/playlist/views.py @@ -2,11 +2,25 @@ import uuid +from common.serializers import ( + AsyncTaskResponseSerializer, + ErrorResponseSerializer, +) from common.views_base import AdminWriteOnly, ApiBaseView from download.src.subscriptions import PlaylistSubscription +from drf_spectacular.utils import OpenApiResponse, extend_schema +from playlist.serializers import ( + PlaylistBulkAddSerializer, + PlaylistCustomPostSerializer, + PlaylistDeleteQuerySerializer, + PlaylistListCustomPostSerializer, + PlaylistListQuerySerializer, + PlaylistListSerializer, + PlaylistSerializer, + PlaylistSingleUpdate, +) from playlist.src.index import YoutubePlaylist from playlist.src.query_building import QueryBuilder -from rest_framework import status from rest_framework.response import Response from task.tasks import subscribe_to from user.src.user_config import UserConfig @@ -25,58 +39,156 @@ class PlaylistApiListView(ApiBaseView): search_base = "ta_playlist/_search/" permission_classes = [AdminWriteOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(PlaylistListSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + parameters=[PlaylistListQuerySerializer], + ) def get(self, request): - """get request""" + """get playlist list""" + query_serializer = PlaylistListQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data try: - data = QueryBuilder(**request.GET).build_data() + data = QueryBuilder(**validated_query).build_data() except ValueError as err: - return Response({"error": str(err)}, status=400) + error = ErrorResponseSerializer({"error": str(err)}) + return Response(error.data, status=400) self.data = data self.get_document_list(request) - return Response(self.response) + response_serializer = PlaylistListSerializer(self.response) + return Response(response_serializer.data) + + @extend_schema( + request=PlaylistBulkAddSerializer(), + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) def post(self, request): - """subscribe/unsubscribe to list of playlists""" - data = request.data - try: - to_add = data["data"] - except KeyError: - message = "missing expected data key" - print(message) - return Response({"message": message}, status=400) + """async subscribe to list of playlists""" + data_serializer = PlaylistBulkAddSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data - data = data["data"] - if isinstance(data, dict): - custom_name = data.get("create") - if custom_name: - playlist_id = f"TA_playlist_{uuid.uuid4()}" - custom_playlist = YoutubePlaylist(playlist_id) - custom_playlist.create(custom_name) - return Response(custom_playlist.json_data) + pending = [i["playlist_id"] for i in validated_data["data"]] + if not pending: + error = ErrorResponseSerializer({"error": "nothing to subscribe"}) + return Response(error.data, status=400) - pending = [] - for playlist_item in to_add: - playlist_id = playlist_item["playlist_id"] - if playlist_item["playlist_subscribed"]: - pending.append(playlist_id) - else: - self._unsubscribe(playlist_id) + url_str = " ".join(pending) + task = subscribe_to.delay(url_str, expected_type="playlist") - if pending: - url_str = " ".join(pending) - subscribe_to.delay(url_str, expected_type="playlist") + message = { + "message": "playlist subscribe task started", + "task_id": task.id, + } + serializer = AsyncTaskResponseSerializer(message) - return Response(data) + return Response(serializer.data) - @staticmethod - def _unsubscribe(playlist_id: str): - """unsubscribe""" - print(f"[{playlist_id}] unsubscribe from playlist") - _ = PlaylistSubscription().change_subscribe( - playlist_id, subscribe_status=False - ) + +class PlaylistCustomApiListView(ApiBaseView): + """resolves to /api/playlist/custom/ + POST: Create new custom playlist + """ + + search_base = "ta_playlist/_search/" + permission_classes = [AdminWriteOnly] + + @extend_schema( + request=PlaylistListCustomPostSerializer(), + responses={ + 200: OpenApiResponse(PlaylistSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) + def post(self, request): + """create new custom playlist""" + serializer = PlaylistListCustomPostSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated_data = serializer.validated_data + + custom_name = validated_data["playlist_name"] + playlist_id = f"TA_playlist_{uuid.uuid4()}" + custom_playlist = YoutubePlaylist(playlist_id) + custom_playlist.create(custom_name) + + response_serializer = PlaylistSerializer(custom_playlist.json_data) + + return Response(response_serializer.data) + + +class PlaylistCustomApiView(ApiBaseView): + """resolves to /api/playlist/custom// + POST: modify custom playlist + """ + + search_base = "ta_playlist/_doc/" + permission_classes = [AdminWriteOnly] + + @extend_schema( + request=PlaylistCustomPostSerializer(), + responses={ + 200: OpenApiResponse(PlaylistSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="playlist not found" + ), + }, + ) + def post(self, request, playlist_id): + """modify custom playlist""" + data_serializer = PlaylistCustomPostSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + self.get_document(playlist_id) + if not self.response: + error = ErrorResponseSerializer({"error": "playlist not found"}) + return Response(error.data, status=404) + + if not self.response["playlist_type"] == "custom": + error = ErrorResponseSerializer( + {"error": f"playlist with ID {playlist_id} is not custom"} + ) + return Response(error.data, status=400) + + action = validated_data.get("action") + video_id = validated_data.get("video_id") + + playlist = YoutubePlaylist(playlist_id) + if action == "create": + try: + playlist.add_video_to_playlist(video_id) + except TypeError: + error = ErrorResponseSerializer( + {"error": f"failed to add video {video_id} to playlist"} + ) + return Response(error.data, status=400) + else: + hide = UserConfig(request.user.id).get_value("hide_watched") + playlist.move_video(video_id, action, hide_watched=hide) + + response_serializer = PlaylistSerializer(playlist.json_data) + + return Response(response_serializer.data) class PlaylistApiView(ApiBaseView): @@ -88,51 +200,74 @@ class PlaylistApiView(ApiBaseView): permission_classes = [AdminWriteOnly] valid_custom_actions = ["create", "remove", "up", "down", "top", "bottom"] + @extend_schema( + responses={ + 200: OpenApiResponse(PlaylistSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="playlist not found" + ), + }, + ) def get(self, request, playlist_id): # pylint: disable=unused-argument - """get request""" + """get playlist""" self.get_document(playlist_id) - return Response(self.response, status=self.status_code) + if not self.response: + error = ErrorResponseSerializer({"error": "playlist not found"}) + return Response(error.data, status=404) + response_serializer = PlaylistSerializer(self.response) + + return Response(response_serializer.data) + + @extend_schema( + request=PlaylistSingleUpdate(), + responses={ + 200: OpenApiResponse(PlaylistSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="playlist not found" + ), + }, + ) def post(self, request, playlist_id): - """post to custom playlist to add a video to list""" + """update subscribed state of playlist""" + data_serializer = PlaylistSingleUpdate(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + self.get_document(playlist_id) - if not self.response["data"]: - return Response({"error": "playlist not found"}, status=404) + if not self.response: + error = ErrorResponseSerializer({"error": "playlist not found"}) + return Response(error.data, status=404) - data = request.data - subscribed = data.get("playlist_subscribed") - if subscribed is not None: - playlist_sub = PlaylistSubscription() - json_data = playlist_sub.change_subscribe(playlist_id, subscribed) - return Response(json_data, status=200) + subscribed = validated_data["playlist_subscribed"] + playlist_sub = PlaylistSubscription() + json_data = playlist_sub.change_subscribe(playlist_id, subscribed) - if not self.response["data"]["playlist_type"] == "custom": - message = f"playlist with ID {playlist_id} is not custom" - return Response({"message": message}, status=400) - - action = request.data.get("action") - if action not in self.valid_custom_actions: - message = f"invalid action: {action}" - return Response({"message": message}, status=400) - - playlist = YoutubePlaylist(playlist_id) - video_id = request.data.get("video_id") - if action == "create": - playlist.add_video_to_playlist(video_id) - else: - hide = UserConfig(request.user.id).get_value("hide_watched") - playlist.move_video(video_id, action, hide_watched=hide) - - return Response({"success": True}, status=status.HTTP_201_CREATED) + response_serializer = PlaylistSerializer(json_data) + return Response(response_serializer.data) + @extend_schema( + parameters=[PlaylistDeleteQuerySerializer], + responses={ + 204: OpenApiResponse(description="playlist deleted"), + }, + ) def delete(self, request, playlist_id): """delete playlist""" print(f"{playlist_id}: delete playlist") - delete_videos = request.GET.get("delete-videos", False) + + query_serializer = PlaylistDeleteQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + + delete_videos = validated_query.get("delete_videos", False) + if delete_videos: YoutubePlaylist(playlist_id).delete_videos_playlist() else: YoutubePlaylist(playlist_id).delete_metadata() - return Response({"success": True}) + return Response(status=204) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index bd641c5c..0a934098 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -1,8 +1,8 @@ -r requirements.txt -ipython==8.31.0 +ipython==8.32.0 pre-commit==4.1.0 pylint-django==2.6.1 -pylint==3.3.3 +pylint==3.3.4 pytest-django==4.9.0 pytest==8.3.4 python-dotenv==1.0.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index b4c6c09e..70c9a25b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,13 +2,14 @@ apprise==1.9.2 celery==5.4.0 django-auth-ldap==5.1.0 django-celery-beat==2.7.0 -django-cors-headers==4.6.0 -Django==5.1.5 +django-cors-headers==4.7.0 +Django==5.1.6 djangorestframework==3.15.2 +drf-spectacular==0.28.0 Pillow==11.1.0 redis==5.2.1 requests==2.32.3 ryd-client==0.0.6 uvicorn==0.34.0 -whitenoise==6.8.2 +whitenoise==6.9.0 yt-dlp[default]==2025.1.26 diff --git a/backend/stats/serializers.py b/backend/stats/serializers.py new file mode 100644 index 00000000..5f7e05ea --- /dev/null +++ b/backend/stats/serializers.py @@ -0,0 +1,110 @@ +"""serializers for stats""" + +# pylint: disable=abstract-method + +from rest_framework import serializers + + +class VideoStatsItemSerializer(serializers.Serializer): + """serialize video stats item""" + + doc_count = serializers.IntegerField() + media_size = serializers.IntegerField() + duration = serializers.IntegerField() + duration_str = serializers.CharField() + + +class VideoStatsSerializer(serializers.Serializer): + """serialize video stats""" + + doc_count = serializers.IntegerField() + media_size = serializers.IntegerField() + duration = serializers.IntegerField() + duration_str = serializers.CharField() + type_videos = VideoStatsItemSerializer(allow_null=True) + type_shorts = VideoStatsItemSerializer(allow_null=True) + type_streams = VideoStatsItemSerializer(allow_null=True) + active_true = VideoStatsItemSerializer(allow_null=True) + active_false = VideoStatsItemSerializer(allow_null=True) + + +class ChannelStatsSerializer(serializers.Serializer): + """serialize channel stats""" + + doc_count = serializers.IntegerField(allow_null=True) + active_true = serializers.IntegerField(allow_null=True) + active_false = serializers.IntegerField(allow_null=True) + subscribed_true = serializers.IntegerField(allow_null=True) + subscribed_false = serializers.IntegerField(allow_null=True) + + +class PlaylistStatsSerializer(serializers.Serializer): + """serialize playlists stats""" + + doc_count = serializers.IntegerField(allow_null=True) + active_true = serializers.IntegerField(allow_null=True) + active_false = serializers.IntegerField(allow_null=True) + subscribed_false = serializers.IntegerField(allow_null=True) + subscribed_true = serializers.IntegerField(allow_null=True) + + +class DownloadStatsSerializer(serializers.Serializer): + """serialize download stats""" + + pending = serializers.IntegerField(allow_null=True) + ignore = serializers.IntegerField(allow_null=True) + pending_videos = serializers.IntegerField(allow_null=True) + pending_shorts = serializers.IntegerField(allow_null=True) + pending_streams = serializers.IntegerField(allow_null=True) + + +class WatchTotalStatsSerializer(serializers.Serializer): + """serialize total watch stats""" + + duration = serializers.IntegerField() + duration_str = serializers.CharField() + items = serializers.IntegerField() + + +class WatchItemStatsSerializer(serializers.Serializer): + """serialize watch item stats""" + + duration = serializers.IntegerField() + duration_str = serializers.CharField() + progress = serializers.FloatField() + items = serializers.IntegerField() + + +class WatchStatsSerializer(serializers.Serializer): + """serialize watch stats""" + + total = WatchTotalStatsSerializer(allow_null=True) + unwatched = WatchItemStatsSerializer(allow_null=True) + watched = WatchItemStatsSerializer(allow_null=True) + + +class DownloadHistItemSerializer(serializers.Serializer): + """serialize download hist item""" + + date = serializers.CharField() + count = serializers.IntegerField() + media_size = serializers.IntegerField() + + +class BiggestChannelQuerySerializer(serializers.Serializer): + """serialize biggest channel query""" + + order = serializers.ChoiceField( + choices=["doc_count", "duration", "media_size"], default="doc_count" + ) + + +class BiggestChannelItemSerializer(serializers.Serializer): + """serialize biggest channel item""" + + id = serializers.CharField() + name = serializers.CharField() + doc_count = serializers.IntegerField() + duration = serializers.IntegerField() + duration_str = serializers.CharField() + media_size = serializers.IntegerField() diff --git a/backend/stats/views.py b/backend/stats/views.py index c5d83678..f0c57240 100644 --- a/backend/stats/views.py +++ b/backend/stats/views.py @@ -1,7 +1,19 @@ """all stats API views""" +from common.serializers import ErrorResponseSerializer from common.views_base import ApiBaseView +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.response import Response +from stats.serializers import ( + BiggestChannelItemSerializer, + BiggestChannelQuerySerializer, + ChannelStatsSerializer, + DownloadHistItemSerializer, + DownloadStatsSerializer, + PlaylistStatsSerializer, + VideoStatsSerializer, + WatchStatsSerializer, +) from stats.src.aggs import ( BiggestChannel, Channel, @@ -18,11 +30,13 @@ class StatVideoView(ApiBaseView): GET: return video stats """ + @extend_schema(responses=VideoStatsSerializer()) def get(self, request): - """get stats""" + """get video stats""" # pylint: disable=unused-argument + serializer = VideoStatsSerializer(Video().process()) - return Response(Video().process()) + return Response(serializer.data) class StatChannelView(ApiBaseView): @@ -30,11 +44,13 @@ class StatChannelView(ApiBaseView): GET: return channel stats """ + @extend_schema(responses=ChannelStatsSerializer()) def get(self, request): - """get stats""" + """get channel stats""" # pylint: disable=unused-argument + serializer = ChannelStatsSerializer(Channel().process()) - return Response(Channel().process()) + return Response(serializer.data) class StatPlaylistView(ApiBaseView): @@ -42,11 +58,13 @@ class StatPlaylistView(ApiBaseView): GET: return playlist stats """ + @extend_schema(responses=PlaylistStatsSerializer()) def get(self, request): - """get stats""" + """get playlist stats""" # pylint: disable=unused-argument + serializer = PlaylistStatsSerializer(Playlist().process()) - return Response(Playlist().process()) + return Response(serializer.data) class StatDownloadView(ApiBaseView): @@ -54,23 +72,27 @@ class StatDownloadView(ApiBaseView): GET: return download stats """ + @extend_schema(responses=DownloadStatsSerializer()) def get(self, request): - """get stats""" + """get download stats""" # pylint: disable=unused-argument + serializer = DownloadStatsSerializer(Download().process()) - return Response(Download().process()) + return Response(serializer.data) class StatWatchProgress(ApiBaseView): - """resolves to /api/stats/watchprogress/ + """resolves to /api/stats/watch/ GET: return watch/unwatch progress stats """ + @extend_schema(responses=WatchStatsSerializer()) def get(self, request): - """handle get request""" + """get watched stats""" # pylint: disable=unused-argument + serializer = WatchStatsSerializer(WatchProgress().process()) - return Response(WatchProgress().process()) + return Response(serializer.data) class StatDownloadHist(ApiBaseView): @@ -78,11 +100,14 @@ class StatDownloadHist(ApiBaseView): GET: return download video count histogram for last days """ + @extend_schema(responses=DownloadHistItemSerializer(many=True)) def get(self, request): - """handle get request""" + """get download hist items""" # pylint: disable=unused-argument + download_items = DownloadHist().process() + serializer = DownloadHistItemSerializer(download_items, many=True) - return Response(DownloadHist().process()) + return Response(serializer.data) class StatBiggestChannel(ApiBaseView): @@ -91,14 +116,24 @@ class StatBiggestChannel(ApiBaseView): param: order """ - order_choices = ["doc_count", "duration", "media_size"] - + @extend_schema( + responses={ + 200: OpenApiResponse(BiggestChannelItemSerializer(many=True)), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) def get(self, request): - """handle get request""" + """get biggest channels stats""" + query_serializer = BiggestChannelQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + order = validated_query["order"] - order = request.GET.get("order", "doc_count") - if order and order not in self.order_choices: - message = {"message": f"invalid order parameter {order}"} - return Response(message, status=400) + channel_items = BiggestChannel(order).process() + serializer = BiggestChannelItemSerializer(channel_items, many=True) - return Response(BiggestChannel(order).process()) + return Response(serializer.data) diff --git a/backend/task/serializers.py b/backend/task/serializers.py index 6c16f633..26d98a6d 100644 --- a/backend/task/serializers.py +++ b/backend/task/serializers.py @@ -1,7 +1,10 @@ """serializer for tasks""" +# pylint: disable=abstract-method + from rest_framework import serializers from task.models import CustomPeriodicTask +from task.src.task_config import TASK_CONFIG class CustomPeriodicTaskSerializer(serializers.ModelSerializer): @@ -21,3 +24,70 @@ class CustomPeriodicTaskSerializer(serializers.ModelSerializer): "last_run_at", "config", ] + + +class TaskResultSerializer(serializers.Serializer): + """serialize task result stored in redis""" + + status = serializers.ChoiceField( + choices=[ + "PENDING", + "STARTED", + "SUCCESS", + "FAILURE", + "RETRY", + "REVOKED", + ] + ) + result = serializers.CharField(allow_null=True) + traceback = serializers.CharField(allow_null=True) + date_done = serializers.CharField() + name = serializers.CharField() + args = serializers.ListField(child=serializers.JSONField(), required=False) + children = serializers.ListField( + child=serializers.CharField(), required=False + ) + kwargs = serializers.DictField(required=False) + worker = serializers.CharField(required=False) + retries = serializers.IntegerField(required=False) + queue = serializers.CharField(required=False) + task_id = serializers.CharField() + + +class TaskIDDataSerializer(serializers.Serializer): + """serialize task by ID POST data""" + + command = serializers.ChoiceField(choices=["stop", "kill"]) + + +class TaskCreateDataSerializer(serializers.Serializer): + """serialize task create data""" + + schedule = serializers.CharField(required=False) + config = serializers.DictField(required=False) + + +class TaskNotificationItemSerializer(serializers.Serializer): + """serialize single task notification""" + + urls = serializers.ListField(child=serializers.CharField()) + title = serializers.CharField() + + +def create_dynamic_notification_serializer(): + """use task config""" + fields = { + key: TaskNotificationItemSerializer(required=False) + for key in TASK_CONFIG + } + return type("DynamicDictSerializer", (serializers.Serializer,), fields) + + +TaskNotificationSerializer = create_dynamic_notification_serializer() + + +class TaskNotificationPostSerializer(serializers.Serializer): + """serialize task notification POST""" + + task_name = serializers.ChoiceField(choices=list(TASK_CONFIG)) + url = serializers.CharField(required=False) diff --git a/backend/task/src/config_schedule.py b/backend/task/src/config_schedule.py index 8b30c1a3..67195a14 100644 --- a/backend/task/src/config_schedule.py +++ b/backend/task/src/config_schedule.py @@ -28,18 +28,19 @@ class ScheduleBuilder: def update_schedule( self, task_name: str, cron_schedule: str, schedule_conf: dict | None - ) -> None: + ) -> CustomPeriodicTask: """update schedule""" if cron_schedule == "auto": cron_schedule = self.SCHEDULES[task_name] - if cron_schedule: - _ = self.get_set_task(task_name, cron_schedule) + task = self.get_set_task(task_name, cron_schedule) if schedule_conf: for key, value in schedule_conf.items(): self.set_config(task_name, key, value) + return task + def get_set_task(self, task_name, schedule=False): """get task""" try: @@ -69,14 +70,15 @@ class ScheduleBuilder: return task_crontab - def set_config(self, task_name: str, key: str, value) -> None: + def set_config( + self, task_name: str, key: str, value + ) -> CustomPeriodicTask: """set task_config, validate before""" - try: - task = CustomPeriodicTask.objects.get(name=task_name) - task.task_config.update({key: value}) - task.save() - except CustomPeriodicTask.DoesNotExist: - pass + task = CustomPeriodicTask.objects.get(name=task_name) + task.task_config.update({key: value}) + task.save() + + return task class CrontabValidator: diff --git a/backend/task/views.py b/backend/task/views.py index 61d03f7f..ff2562e3 100644 --- a/backend/task/views.py +++ b/backend/task/views.py @@ -1,10 +1,22 @@ """all task API views""" +from common.serializers import ( + AsyncTaskResponseSerializer, + ErrorResponseSerializer, +) from common.views_base import AdminOnly, ApiBaseView from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.response import Response from task.models import CustomPeriodicTask -from task.serializers import CustomPeriodicTaskSerializer +from task.serializers import ( + CustomPeriodicTaskSerializer, + TaskCreateDataSerializer, + TaskIDDataSerializer, + TaskNotificationPostSerializer, + TaskNotificationSerializer, + TaskResultSerializer, +) from task.src.config_schedule import CrontabValidator, ScheduleBuilder from task.src.notify import Notifications, get_all_notifications from task.src.task_config import TASK_CONFIG @@ -18,12 +30,14 @@ class TaskListView(ApiBaseView): permission_classes = [AdminOnly] + @extend_schema(responses=TaskResultSerializer(many=True)) def get(self, request): - """handle get request""" + """get all stored task results""" # pylint: disable=unused-argument all_results = TaskManager().get_all_results() + serializer = TaskResultSerializer(all_results, many=True) - return Response(all_results) + return Response(serializer.data) class TaskNameListView(ApiBaseView): @@ -34,36 +48,55 @@ class TaskNameListView(ApiBaseView): permission_classes = [AdminOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(TaskResultSerializer(many=True)), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="task name not found" + ), + }, + ) def get(self, request, task_name): - """handle get request""" + """get stored task by name""" # pylint: disable=unused-argument if task_name not in TASK_CONFIG: - message = {"message": "invalid task name"} - return Response(message, status=404) + error = ErrorResponseSerializer({"error": "task name not found"}) + return Response(error.data, status=404) all_results = TaskManager().get_tasks_by_name(task_name) + serializer = TaskResultSerializer(all_results, many=True) - return Response(all_results) + return Response(serializer.data) + @extend_schema( + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="task name not found" + ), + } + ) def post(self, request, task_name): - """ - handle post request - 404 for invalid task_name - 400 if task can't be started here without argument - """ + """start new task without args""" # pylint: disable=unused-argument task_config = TASK_CONFIG.get(task_name) if not task_config: - message = {"message": "invalid task name"} - return Response(message, status=404) + error = ErrorResponseSerializer({"error": "task name not found"}) + return Response(error.data, status=404) if not task_config.get("api_start"): - message = {"message": "can not start task through this endpoint"} - return Response(message, status=400) + error = ErrorResponseSerializer( + {"error": "can not start task through this endpoint"} + ) + return Response(error.data, status=404) message = TaskCommand().start(task_name) + serializer = AsyncTaskResponseSerializer(message) - return Response({"message": message}) + return Response(serializer.data) class TaskIDView(ApiBaseView): @@ -75,43 +108,70 @@ class TaskIDView(ApiBaseView): valid_commands = ["stop", "kill"] permission_classes = [AdminOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(TaskResultSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="task not found" + ), + }, + ) def get(self, request, task_id): - """handle get request""" + """get task by ID""" # pylint: disable=unused-argument task_result = TaskManager().get_task(task_id) if not task_result: - message = {"message": "task id not found"} - return Response(message, status=404) + error = ErrorResponseSerializer({"error": "task ID not found"}) + return Response(error.data, status=404) - return Response(task_result) + serializer = TaskResultSerializer(task_result) + return Response(serializer.data) + + @extend_schema( + request=TaskIDDataSerializer(), + responses={ + 204: OpenApiResponse(description="task command sent"), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="task not found" + ), + }, + ) def post(self, request, task_id): """post command to task""" - command = request.data.get("command") - if not command or command not in self.valid_commands: - message = {"message": "no valid command found"} - return Response(message, status=400) + data_serializer = TaskIDDataSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + command = validated_data["command"] task_result = TaskManager().get_task(task_id) if not task_result: - message = {"message": "task id not found"} - return Response(message, status=404) + error = ErrorResponseSerializer({"error": "task ID not found"}) + return Response(error.data, status=404) task_conf = TASK_CONFIG.get(task_result.get("name")) if command == "stop": if not task_conf.get("api_stop"): - message = {"message": "task can not be stopped"} - return Response(message, status=400) + error = ErrorResponseSerializer( + {"error": "task can not be stopped"} + ) + return Response(error.data, status=400) TaskCommand().stop(task_id) if command == "kill": if not task_conf.get("api_stop"): - message = {"message": "task can not be killed"} - return Response(message, status=400) + error = ErrorResponseSerializer( + {"error": "task can not be killed"} + ) + return Response(error.data, status=400) TaskCommand().kill(task_id) - return Response({"message": "command sent"}) + return Response(status=204) class ScheduleListView(ApiBaseView): @@ -121,11 +181,16 @@ class ScheduleListView(ApiBaseView): permission_classes = [AdminOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(CustomPeriodicTaskSerializer(many=True)), + }, + ) def get(self, request): """get all schedules""" tasks = CustomPeriodicTask.objects.all() - response = CustomPeriodicTaskSerializer(tasks, many=True).data - return Response(response) + serializer = CustomPeriodicTaskSerializer(tasks, many=True) + return Response(serializer.data) class ScheduleView(ApiBaseView): @@ -137,42 +202,77 @@ class ScheduleView(ApiBaseView): permission_classes = [AdminOnly] + @extend_schema( + responses={ + 200: OpenApiResponse(CustomPeriodicTaskSerializer()), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="schedule not found" + ), + }, + ) def get(self, request, task_name): """get single schedule by task_name""" task = get_object_or_404(CustomPeriodicTask, name=task_name) - response = CustomPeriodicTaskSerializer(task).data - return Response(response) + serializer = CustomPeriodicTaskSerializer(task) + return Response(serializer.data) + @extend_schema( + request=TaskCreateDataSerializer(), + responses={ + 200: OpenApiResponse(CustomPeriodicTaskSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + }, + ) def post(self, request, task_name): """create/update schedule for task""" - cron_schedule = request.data.get("schedule") - schedule_config = request.data.get("config") + data_serializer = TaskCreateDataSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + cron_schedule = validated_data.get("schedule") + schedule_config = validated_data.get("config") if not cron_schedule and not schedule_config: - message = {"message": "expected schedule or config key"} - return Response(message, status=400) + error = ErrorResponseSerializer( + {"error": "expected schedule or config key"} + ) + return Response(error.data, status=400) try: validator = CrontabValidator() validator.validate_cron(cron_schedule) validator.validate_config(task_name, schedule_config) except ValueError as err: - return Response({"message": str(err)}, status=400) + error = ErrorResponseSerializer({"error": str(err)}) + return Response(error.data, status=400) - ScheduleBuilder().update_schedule( + task = ScheduleBuilder().update_schedule( task_name, cron_schedule, schedule_config ) message = f"update schedule for task {task_name}" if schedule_config: message += f" with config {schedule_config}" - return Response({"message": message}) + print(message) + serializer = CustomPeriodicTaskSerializer(task) + return Response(serializer.data) + + @extend_schema( + responses={ + 204: OpenApiResponse(description="schedule deleted"), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="schedule not found" + ), + }, + ) def delete(self, request, task_name): - """delete schedule by task_name query""" + """delete schedule by task_name""" task = get_object_or_404(CustomPeriodicTask, name=task_name) _ = task.delete() - return Response({"success": True}) + return Response(status=204) class ScheduleNotification(ApiBaseView): @@ -182,42 +282,64 @@ class ScheduleNotification(ApiBaseView): DEL: delete notification """ + @extend_schema( + responses=TaskNotificationSerializer(), + ) def get(self, request): """handle get request""" + serializer = TaskNotificationSerializer(get_all_notifications()) - return Response(get_all_notifications()) + return Response(serializer.data) + @extend_schema( + request=TaskNotificationPostSerializer(), + responses={ + 200: OpenApiResponse(TaskNotificationSerializer()), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + }, + ) def post(self, request): - """handle create notification""" - task_name = request.data.get("task_name") - url = request.data.get("url") - - if not TASK_CONFIG.get(task_name): - message = {"message": "task_name not found"} - return Response(message, status=404) + """create notification""" + data_serializer = TaskNotificationPostSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + task_name = validated_data["task_name"] + url = validated_data["url"] if not url: - message = {"message": "missing url key"} - return Response(message, status=400) + error = ErrorResponseSerializer({"error": "missing url"}) + return Response(error.data, status=400) Notifications(task_name).add_url(url) - message = {"task_name": task_name, "url": url} - return Response(message) + serializer = TaskNotificationSerializer(get_all_notifications()) + return Response(serializer.data) + + @extend_schema( + request=TaskNotificationPostSerializer(), + responses={ + 204: OpenApiResponse(description="notification url deleted"), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + }, + ) def delete(self, request): - """handle delete""" + """delete notification""" - task_name = request.data.get("task_name") - url = request.data.get("url") + data_serializer = TaskNotificationPostSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data - if not TASK_CONFIG.get(task_name): - message = {"message": "task_name not found"} - return Response(message, status=404) + task_name = validated_data["task_name"] + url = validated_data.get("url") if url: - response, status_code = Notifications(task_name).remove_url(url) + Notifications(task_name).remove_url(url) else: - response, status_code = Notifications(task_name).remove_task() + Notifications(task_name).remove_task() - return Response({"response": response, "status_code": status_code}) + return Response(status=204) diff --git a/backend/user/serializers.py b/backend/user/serializers.py index e87a10c0..e8314b98 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -1,7 +1,11 @@ """serializer for account model""" +# pylint: disable=abstract-method + +from common.src.helper import get_stylesheets from rest_framework import serializers from user.models import Account +from video.src.constants import OrderEnum, SortEnum class AccountSerializer(serializers.ModelSerializer): @@ -18,3 +22,29 @@ class AccountSerializer(serializers.ModelSerializer): "user_permissions", "last_login", ) + + +class UserMeConfigSerializer(serializers.Serializer): + """serialize user me config""" + + stylesheet = serializers.ChoiceField(choices=get_stylesheets()) + page_size = serializers.IntegerField() + sort_by = serializers.ChoiceField(choices=SortEnum.names()) + sort_order = serializers.ChoiceField(choices=OrderEnum.values()) + view_style_home = serializers.ChoiceField(choices=["grid", "list"]) + view_style_channel = serializers.ChoiceField(choices=["grid", "list"]) + view_style_downloads = serializers.ChoiceField(choices=["grid", "list"]) + view_style_playlist = serializers.ChoiceField(choices=["grid", "list"]) + grid_items = serializers.IntegerField(max_value=7, min_value=3) + hide_watched = serializers.BooleanField() + show_ignored_only = serializers.BooleanField() + show_subed_only = serializers.BooleanField() + show_help_text = serializers.BooleanField() + + +class LoginSerializer(serializers.Serializer): + """serialize login""" + + username = serializers.CharField() + password = serializers.CharField() + remember_me = serializers.ChoiceField(choices=["on", "off"], default="off") diff --git a/backend/user/src/user_config.py b/backend/user/src/user_config.py index 42dfa2ef..a85ce5e3 100644 --- a/backend/user/src/user_config.py +++ b/backend/user/src/user_config.py @@ -7,7 +7,6 @@ Functionality: from typing import TypedDict from common.src.es_connect import ElasticWrap -from common.src.helper import get_stylesheets class UserConfigType(TypedDict, total=False): @@ -29,7 +28,10 @@ class UserConfigType(TypedDict, total=False): class UserConfig: - """Handle settings for an individual user""" + """ + Handle settings for an individual user + items are expected to be validated in the serializer + """ _DEFAULT_USER_SETTINGS = UserConfigType( stylesheet="dark.css", @@ -47,19 +49,6 @@ class UserConfig: show_help_text=True, ) - VALID_STYLESHEETS = get_stylesheets() - VALID_VIEW_STYLE = ["grid", "list"] - VALID_SORT_ORDER = ["asc", "desc"] - VALID_SORT_BY = [ - "published", - "downloaded", - "views", - "likes", - "duration", - "filesize", - ] - VALID_GRID_ITEMS = range(3, 8) - def __init__(self, user_id: str): self._user_id: str = user_id self._config: UserConfigType = self.get_config() @@ -84,7 +73,6 @@ class UserConfig: def set_value(self, key: str, value: str | bool | int): """Set or replace a configuration value for the user""" - self._validate(key, value) data = {"doc": {"config": {key: value}}} response, status = ElasticWrap(self.es_update_url).post(data) if status < 200 or status > 299: @@ -92,43 +80,6 @@ class UserConfig: print(f"User {self._user_id} value '{key}' change: to {value}") - def _validate(self, key, value): - """validate key and value""" - if not self._user_id: - raise ValueError("Unable to persist config for null user_id") - - if key not in self._DEFAULT_USER_SETTINGS: - raise KeyError( - f"Unable to persist config for an unknown key '{key}'" - ) - - valid_values = { - "stylesheet": self.VALID_STYLESHEETS, - "sort_by": self.VALID_SORT_BY, - "sort_order": self.VALID_SORT_ORDER, - "view_style_home": self.VALID_VIEW_STYLE, - "view_style_channel": self.VALID_VIEW_STYLE, - "view_style_download": self.VALID_VIEW_STYLE, - "view_style_playlist": self.VALID_VIEW_STYLE, - "grid_items": self.VALID_GRID_ITEMS, - "page_size": int, - "hide_watched": bool, - "show_ignored_only": bool, - "show_subed_only": bool, - "show_help_text": bool, - } - validation_value = valid_values.get(key) - - if isinstance(validation_value, (list, range)): - if value not in validation_value: - raise ValueError(f"Invalid value for {key}: {value}") - elif validation_value == int: - if not isinstance(value, int): - raise ValueError(f"Invalid value for {key}: {value}") - elif validation_value == bool: - if not isinstance(value, bool): - raise ValueError(f"Invalid value for {key}: {value}") - def get_config(self) -> UserConfigType: """get config from ES or load from the application defaults""" if not self._user_id: @@ -143,6 +94,16 @@ class UserConfig: return config + def update_config(self, to_update: dict) -> None: + """update config object""" + data = {"doc": {"config": to_update}} + response, status = ElasticWrap(self.es_update_url).post(data) + if status < 200 or status > 299: + raise ValueError(f"Failed storing user value {status}: {response}") + + for key, value in to_update.items(): + print(f"User {self._user_id} value '{key}' change: to {value}") + def sync_defaults(self): """set initial defaults on 404""" response, _ = ElasticWrap(self.es_url).post( diff --git a/backend/user/urls.py b/backend/user/urls.py index 437f66b7..319a5ccb 100644 --- a/backend/user/urls.py +++ b/backend/user/urls.py @@ -6,5 +6,6 @@ from user import views urlpatterns = [ path("login/", views.LoginApiView.as_view(), name="api-user-login"), path("logout/", views.LogoutApiView.as_view(), name="api-user-logout"), + path("account/", views.UserAccountView.as_view(), name="api-user-account"), path("me/", views.UserConfigView.as_view(), name="api-user-me"), ] diff --git a/backend/user/views.py b/backend/user/views.py index 4cbb4d71..8bac0877 100644 --- a/backend/user/views.py +++ b/backend/user/views.py @@ -1,63 +1,75 @@ """all user api views""" +from common.serializers import ErrorResponseSerializer from common.views import ApiBaseView from django.contrib.auth import authenticate, login, logout from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView from user.models import Account -from user.serializers import AccountSerializer +from user.serializers import ( + AccountSerializer, + LoginSerializer, + UserMeConfigSerializer, +) from user.src.user_config import UserConfig +class UserAccountView(ApiBaseView): + """resolves to /api/user/account/ + GET: return current user account + """ + + @extend_schema( + responses=AccountSerializer(), + ) + def get(self, request): + """get user account""" + user_id = request.user.id + account = Account.objects.get(id=user_id) + account_serializer = AccountSerializer(account) + return Response(account_serializer.data) + + class UserConfigView(ApiBaseView): """resolves to /api/user/me/ GET: return current user config POST: update user config """ + @extend_schema(responses=UserMeConfigSerializer()) def get(self, request): - """get config""" - user_id = request.user.id - account = Account.objects.get(id=user_id) - serializer = AccountSerializer(account) - response = serializer.data.copy() + """get user config""" + config = UserConfig(request.user.id).get_config() + serializer = UserMeConfigSerializer(config) - config = UserConfig(user_id).get_config() - response.update({"config": config}) - - return Response(response) + return Response(serializer.data) + @extend_schema( + request=UserMeConfigSerializer(required=False), + responses={ + 200: UserMeConfigSerializer(), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="Bad request" + ), + }, + ) def post(self, request): - """update config""" - user_id = request.user.id - data = request.data + """update config, allows partial update""" - data_config = data.get("config") - if not data_config: - message = { - "status": "Bad Request", - "message": "missing config key", - } - return Response(message, status=400) + data_serializer = UserMeConfigSerializer( + data=request.data, partial=True + ) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + UserConfig(request.user.id).update_config(to_update=validated_data) + config = UserConfig(request.user.id).get_config() + serializer = UserMeConfigSerializer(config) - user_conf = UserConfig(user_id) - for key, value in data_config.items(): - try: - user_conf.set_value(key, value) - except ValueError as err: - message = { - "status": "Bad Request", - "message": f"failed updating {key} to '{value}', {err}", - } - return Response(message, status=400) - - response = user_conf.get_config() - response.update({"user_id": user_id}) - - return Response(response) + return Response(serializer.data) @method_decorator(csrf_exempt, name="dispatch") @@ -67,21 +79,37 @@ class LoginApiView(APIView): """ permission_classes = [AllowAny] + SEC_IN_DAY = 60 * 60 * 24 + @extend_schema( + request=LoginSerializer(), + responses={204: OpenApiResponse(description="login successful")}, + ) def post(self, request, *args, **kwargs): - """post data""" + """login with username and password""" # pylint: disable=no-member + data_serializer = LoginSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data - username = request.data.get("username") - password = request.data.get("password") + username = validated_data["username"] + password = validated_data["password"] + remember_me = validated_data.get("remember_me") user = authenticate(request, username=username, password=password) + if user is None: + error = ErrorResponseSerializer({"error": "Invalid credentials"}) + return Response(error.data, status=400) - if user is not None: - login(request, user) # Creates a session for the user - return Response({"message": "Login successful"}, status=200) + if remember_me == "on": + request.session.set_expiry(self.SEC_IN_DAY * 365) + else: + request.session.set_expiry(self.SEC_IN_DAY * 2) - return Response({"message": "Invalid credentials"}, status=400) + print(f"expire session in {request.session.get_expiry_age()} secs") + + login(request, user) # Creates a session for the user + return Response(status=204) class LogoutApiView(ApiBaseView): @@ -89,7 +117,10 @@ class LogoutApiView(ApiBaseView): POST: handle logout """ + @extend_schema( + responses={204: OpenApiResponse(description="logout successful")} + ) def post(self, request, *args, **kwargs): - """logout on post request""" + """logout user from session""" logout(request) - return Response({"message": "Successfully logged out."}, status=200) + return Response(status=204) diff --git a/backend/video/serializers.py b/backend/video/serializers.py new file mode 100644 index 00000000..33cfd24a --- /dev/null +++ b/backend/video/serializers.py @@ -0,0 +1,187 @@ +"""video serializers""" + +# pylint: disable=abstract-method + +from channel.serializers import ChannelSerializer +from common.serializers import PaginationSerializer +from rest_framework import serializers +from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum, WatchedEnum + + +class PlayerSerializer(serializers.Serializer): + """serialize player""" + + watched = serializers.BooleanField() + duration = serializers.IntegerField() + duration_str = serializers.CharField() + progress = serializers.FloatField(required=False) + position = serializers.FloatField(required=False) + + +class SponsorBlockSegmentSerializer(serializers.Serializer): + """serialize sponsorblock segment""" + + actionType = serializers.CharField() + videoDuration = serializers.FloatField() + segment = serializers.ListField(child=serializers.FloatField()) + votes = serializers.IntegerField() + category = serializers.CharField() + UUID = serializers.CharField() + locked = serializers.IntegerField() + + +class SponsorBlockSerializer(serializers.Serializer): + """serialize sponsorblock""" + + is_enabled = serializers.BooleanField() + last_refresh = serializers.IntegerField() + has_unlocked = serializers.BooleanField(required=False) + segments = SponsorBlockSegmentSerializer(many=True) + + +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) + + +class StreamItemSerializer(serializers.Serializer): + """serialize stream item""" + + index = serializers.IntegerField() + codec = serializers.CharField() + bitrate = serializers.IntegerField() + type = serializers.ChoiceField(choices=["video", "audio"]) + width = serializers.IntegerField(required=False) + height = serializers.IntegerField(required=False) + + +class SubtitleItemSerializer(serializers.Serializer): + """serialize subtitle item""" + + ext = serializers.ChoiceField(choices=["json3"]) + name = serializers.CharField() + source = serializers.ChoiceField(choices=["user", "auto"]) + lang = serializers.CharField() + media_url = serializers.CharField() + url = serializers.URLField() + + +class VideoSerializer(serializers.Serializer): + """serialize video item""" + + active = serializers.BooleanField() + category = serializers.ListField(child=serializers.CharField()) + channel = ChannelSerializer() + comment_count = serializers.IntegerField(allow_null=True) + date_downloaded = serializers.IntegerField() + description = serializers.CharField() + media_size = serializers.IntegerField() + media_url = serializers.CharField() + player = PlayerSerializer() + playlist = serializers.ListField(child=serializers.CharField()) + published = serializers.CharField() + sponsorblock = SponsorBlockSerializer(allow_null=True) + stats = StatsSerializer() + streams = StreamItemSerializer(many=True) + subtitles = SubtitleItemSerializer(many=True) + tags = serializers.ListField(child=serializers.CharField()) + title = serializers.CharField() + vid_last_refresh = serializers.CharField() + vid_thumb_url = serializers.CharField() + vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values_known()) + youtube_id = serializers.CharField() + _index = serializers.CharField(required=False) + _score = serializers.FloatField(required=False) + + +class VideoListSerializer(serializers.Serializer): + """serialize video list""" + + data = VideoSerializer(many=True) + paginate = PaginationSerializer() + + +class VideoListQuerySerializer(serializers.Serializer): + """serialize query for video list""" + + playlist = serializers.CharField(required=False) + channel = serializers.CharField(required=False) + watch = serializers.ChoiceField( + choices=WatchedEnum.values(), required=False + ) + sort = serializers.ChoiceField(choices=SortEnum.names(), required=False) + order = serializers.ChoiceField(choices=OrderEnum.values(), required=False) + type = serializers.ChoiceField( + choices=VideoTypeEnum.values_known(), 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_parent = serializers.CharField() + comment_replies = CommentThreadItemSerializer(many=True) + + +class PlaylistNavMetaSerializer(serializers.Serializer): + """serialize playlist nav meta""" + + current_idx = serializers.IntegerField() + playlist_id = serializers.CharField() + playlist_name = serializers.CharField() + playlist_channel = serializers.CharField() + + +class PlaylistNavVideoSerializer(serializers.Serializer): + """serialize video item on playlist nav""" + + youtube_id = serializers.CharField() + title = serializers.CharField() + uploader = serializers.CharField() + idx = serializers.IntegerField() + downloaded = serializers.BooleanField() + vid_thumb = serializers.CharField() + + +class PlaylistNavItemSerializer(serializers.Serializer): + """serialize nav on playlist""" + + playlist_meta = PlaylistNavMetaSerializer() + playlist_previous = PlaylistNavVideoSerializer(allow_null=True) + playlist_next = PlaylistNavVideoSerializer(allow_null=True) + + +class VideoProgressUpdateSerializer(serializers.Serializer): + """serialize progress update data""" + + position = serializers.FloatField(default=0) diff --git a/backend/video/src/constants.py b/backend/video/src/constants.py index 39dcfff3..12fc4b23 100644 --- a/backend/video/src/constants.py +++ b/backend/video/src/constants.py @@ -11,6 +11,16 @@ class VideoTypeEnum(enum.Enum): SHORTS = "shorts" UNKNOWN = "unknown" + @classmethod + def values(cls) -> list[str]: + """value list""" + return [i.value for i in cls] + + @classmethod + def values_known(cls) -> list[str]: + """values known""" + return [i.value for i in cls if i.value != "unknown"] + class SortEnum(enum.Enum): """all sort by options""" @@ -22,9 +32,37 @@ class SortEnum(enum.Enum): DURATION = "player.duration" MEDIASIZE = "media_size" + @classmethod + def values(cls) -> list[str]: + """value list""" + return [i.value for i in cls] + + @classmethod + def names(cls) -> list[str]: + """name list""" + return [i.name.lower() for i in cls] + class OrderEnum(enum.Enum): """all order by options""" ASC = "asc" DESC = "desc" + + @classmethod + def values(cls) -> list[str]: + """value list""" + return [i.value for i in cls] + + +class WatchedEnum(enum.Enum): + """watched state enum""" + + WATCHED = "watched" + UNWATCHED = "unwatched" + CONTINUE = "continue" + + @classmethod + def values(cls) -> list[str]: + """value list""" + return [i.value for i in cls] diff --git a/backend/video/src/index.py b/backend/video/src/index.py index ff90b142..42ce02ac 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -53,8 +53,8 @@ class SponsorBlock: print(f"{youtube_id}: get sponsorblock timestamps") try: response = requests.get(url, headers=headers, timeout=10) - except requests.ReadTimeout: - print(f"{youtube_id}: sponsorblock API timeout") + except (requests.ReadTimeout, requests.ConnectionError) as err: + print(f"{youtube_id}: sponsorblock API error: {str(err)}") return False if not response.ok: diff --git a/backend/video/src/query_building.py b/backend/video/src/query_building.py index 2a2cd637..248a0e44 100644 --- a/backend/video/src/query_building.py +++ b/backend/video/src/query_building.py @@ -27,20 +27,20 @@ class QueryBuilder: must_list = [] channel = self.request_params.get("channel") if channel: - must_list.append({"match": {"channel.channel_id": channel[0]}}) + must_list.append({"match": {"channel.channel_id": channel}}) playlist = self.request_params.get("playlist") if playlist: - must_list.append({"match": {"playlist.keyword": playlist[0]}}) + must_list.append({"match": {"playlist.keyword": playlist}}) watch = self.request_params.get("watch") if watch: - watch_must_list = self.parse_watch(watch[0]) + watch_must_list = self.parse_watch(watch) must_list.append(watch_must_list) video_type = self.request_params.get("type") if video_type: - type_list_list = self.parse_type(video_type[0]) + type_list_list = self.parse_type(video_type) must_list.append(type_list_list) query = {"bool": {"must": must_list}} @@ -88,14 +88,12 @@ class QueryBuilder: if not sort: return None - sort = sort[0] if not hasattr(SortEnum, sort.upper()): raise ValueError(f"'{sort}' not in SortEnum") sort_field = getattr(SortEnum, sort.upper()).value - order = self.request_params.get("order", ["desc"]) - order = order[0] + order = self.request_params.get("order", "desc") if not hasattr(OrderEnum, order.upper()): raise ValueError(f"'{order}' not in OrderEnum") diff --git a/backend/video/tests/test_src/test_query_building.py b/backend/video/tests/test_src/test_query_building.py index aeb2c781..606d07c1 100644 --- a/backend/video/tests/test_src/test_query_building.py +++ b/backend/video/tests/test_src/test_query_building.py @@ -15,12 +15,12 @@ def test_build_data(): """test for correct key building""" qb = QueryBuilder( user_id=1, - channel=["test_channel"], - playlist=["test_playlist"], - watch=["watched"], - type=["videos"], - sort=["published"], - order=["desc"], + channel="test_channel", + playlist="test_playlist", + watch="watched", + type="videos", + sort="published", + order="desc", ) result = qb.build_data() assert "query" in result @@ -30,7 +30,7 @@ def test_build_data(): def test_parse_watch(): """watched query building""" - qb = QueryBuilder(user_id=1, watch=["watched"]) + qb = QueryBuilder(user_id=1, watch="watched") result = qb.parse_watch("watched") assert result == {"match": {"player.watched": True}} @@ -43,7 +43,7 @@ def test_parse_watch(): def test_parse_type(): """test type is parsed""" - qb = QueryBuilder(user_id=1, type=["videos"]) + qb = QueryBuilder(user_id=1, type="videos") with pytest.raises(ValueError): qb.parse_type("invalid") @@ -53,16 +53,14 @@ def test_parse_type(): def test_parse_sort(): """test sort and order""" - qb = QueryBuilder(user_id=1, sort=["views"], order=["desc"]) + qb = QueryBuilder(user_id=1, sort="views", order="desc") result = qb.parse_sort() assert result == {"sort": [{"stats.view_count": {"order": "desc"}}]} with pytest.raises(ValueError): - qb = QueryBuilder(user_id=1, sort=["invalid"]) + qb = QueryBuilder(user_id=1, sort="invalid") qb.parse_sort() with pytest.raises(ValueError): - qb = QueryBuilder( - user_id=1, sort=["stats.view_count"], order=["invalid"] - ) + qb = QueryBuilder(user_id=1, sort="stats.view_count", order="invalid") qb.parse_sort() diff --git a/backend/video/views.py b/backend/video/views.py index 0b3d93bb..88d53003 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -1,11 +1,22 @@ """all API views for video endpoints""" +from common.serializers import ErrorResponseSerializer from common.src.helper import calc_is_watched from common.src.ta_redis import RedisArchivist from common.src.watched import WatchState from common.views_base import AdminWriteOnly, ApiBaseView +from drf_spectacular.utils import OpenApiResponse, extend_schema from playlist.src.index import YoutubePlaylist from rest_framework.response import Response +from video.serializers import ( + CommentItemSerializer, + PlayerSerializer, + PlaylistNavItemSerializer, + VideoListQuerySerializer, + VideoListSerializer, + VideoProgressUpdateSerializer, + VideoSerializer, +) from video.src.index import YoutubeVideo from video.src.query_building import QueryBuilder @@ -24,13 +35,22 @@ class VideoApiListView(ApiBaseView): search_base = "ta_video/_search/" + @extend_schema( + parameters=[VideoListQuerySerializer()], + responses={ + 200: VideoListSerializer(), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + }, + ) def get(self, request): - """get request""" - try: - data = QueryBuilder(request.user.id, **request.GET).build_data() - except ValueError as err: - return Response({"error": str(err)}, status=400) + """get video list""" + query_serializer = VideoListQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + data = QueryBuilder(request.user.id, **validated_query).build_data() if data == {"query": {"bool": {"must": [None]}}}: # skip empty lookup return Response([]) @@ -38,7 +58,9 @@ class VideoApiListView(ApiBaseView): self.data = data self.get_document_list(request, progress_match=request.user.id) - return Response(self.response) + response_serializer = VideoListSerializer(self.response) + + return Response(response_serializer.data) class VideoApiView(ApiBaseView): @@ -49,25 +71,71 @@ class VideoApiView(ApiBaseView): search_base = "ta_video/_doc/" permission_classes = [AdminWriteOnly] + @extend_schema( + responses={ + 200: VideoSerializer(), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + }, + ) def get(self, request, video_id): - # pylint: disable=unused-argument - """get request""" + """get video""" self.get_document(video_id, progress_match=request.user.id) - return Response(self.response, status=self.status_code) + if not self.response: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + serializer = VideoSerializer(self.response) + return Response(serializer.data) + + @extend_schema( + responses={ + 204: OpenApiResponse(description="video deleted"), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + } + ) def delete(self, request, video_id): # pylint: disable=unused-argument - """delete single video""" - message = {"video": video_id} + """delete video""" try: YoutubeVideo(video_id).delete_media_file() - status_code = 200 - message.update({"state": "delete"}) except FileNotFoundError: - status_code = 404 - message.update({"state": "not found"}) + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) - return Response(message, status=status_code) + return Response(status=204) + + +class VideoCommentView(ApiBaseView): + """resolves to /api/video//comment/ + handle video comments + GET: return all comments from video with reply threads + """ + + search_base = "ta_comment/_doc/" + + @extend_schema( + responses={ + 200: CommentItemSerializer(), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + } + ) + def get(self, request, video_id): + """get video comments""" + # pylint: disable=unused-argument + self.get_document(video_id) + if self.status_code == 404: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + + serializer = CommentItemSerializer(self.response, many=True) + + return Response(serializer.data) class VideoApiNavView(ApiBaseView): @@ -77,26 +145,39 @@ class VideoApiNavView(ApiBaseView): search_base = "ta_video/_doc/" + @extend_schema( + responses={ + 200: PlaylistNavItemSerializer(), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + } + ) def get(self, request, video_id): # pylint: disable=unused-argument - """get request""" + """get video playlist nav""" self.get_document(video_id) - if self.status_code != 200: - return Response(status=self.status_code) + if self.status_code == 404: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) playlist_nav = [] - if not self.response["data"].get("playlist"): + if not self.response.get("playlist"): return Response(playlist_nav) - for playlist_id in self.response["data"]["playlist"]: + for playlist_id in self.response["playlist"]: playlist = YoutubePlaylist(playlist_id) playlist.get_from_es() playlist.build_nav(video_id) if playlist.nav: playlist_nav.append(playlist.nav) - return Response(playlist_nav, status=self.status_code) + response_serializer = PlaylistNavItemSerializer( + playlist_nav, many=True + ) + + return Response(response_serializer.data) class VideoProgressView(ApiBaseView): @@ -111,19 +192,32 @@ class VideoProgressView(ApiBaseView): """redis key""" return f"{user_id}:progress:{video_id}" + @extend_schema( + request=VideoProgressUpdateSerializer(), + responses={ + 200: PlayerSerializer(), + 404: OpenApiResponse( + ErrorResponseSerializer(), description="video not found" + ), + }, + ) def post(self, request, video_id): - """set progress position in redis""" - position = request.data.get("position", 0) + """set video progress position in redis""" + data_serializer = VideoProgressUpdateSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + self.get_document(video_id) + if self.status_code == 404: + error = ErrorResponseSerializer({"error": "video not found"}) + return Response(error.data, status=404) + + position = validated_data["position"] key = self._get_key(request.user.id, video_id) redis_con = RedisArchivist() - current_progress = redis_con.get_message_dict(key) - - if not current_progress: - self.get_document(video_id) - if self.status_code != 200: - return Response(status=self.status_code) - - current_progress = self.response["data"]["player"] + current_progress = ( + redis_con.get_message_dict(key) or self.response["player"] + ) current_progress.update({"position": position, "youtube_id": video_id}) watched = self._check_watched(request, video_id, current_progress) @@ -134,8 +228,11 @@ class VideoProgressView(ApiBaseView): current_progress.update({"watched": watched}) redis_con.set_message(key, current_progress, expire=expire) + print(current_progress) - return Response(current_progress) + response_serializer = PlayerSerializer(current_progress) + + return Response(response_serializer.data) def _check_watched(self, request, video_id, current_progress) -> bool: """check watched state""" @@ -150,29 +247,17 @@ class VideoProgressView(ApiBaseView): return watched + @extend_schema( + responses={ + 204: OpenApiResponse(description="video progress deleted"), + } + ) def delete(self, request, video_id): """delete progress position""" key = self._get_key(request.user.id, video_id) RedisArchivist().del_message(key) - self.response = {"progress-reset": video_id} - return Response(self.response) - - -class VideoCommentView(ApiBaseView): - """resolves to /api/video//comment/ - handle video comments - GET: return all comments from video with reply threads - """ - - search_base = "ta_comment/_doc/" - - def get(self, request, video_id): - """get video comments""" - # pylint: disable=unused-argument - self.get_document(video_id) - - return Response(self.response, status=200) + return Response(status=204) class VideoSimilarView(ApiBaseView): @@ -182,6 +267,9 @@ class VideoSimilarView(ApiBaseView): search_base = "ta_video/_search/" + @extend_schema( + responses=VideoSerializer(many=True), + ) def get(self, request, video_id): """get similar videos""" self.data = { @@ -196,4 +284,5 @@ class VideoSimilarView(ApiBaseView): }, } self.get_document_list(request, pagination=False) - return Response(self.response, status=200) + serializer = VideoSerializer(self.response["data"], many=True) + return Response(serializer.data) diff --git a/docker_assets/run.sh b/docker_assets/run.sh index 33029f1c..cabc6afb 100644 --- a/docker_assets/run.sh +++ b/docker_assets/run.sh @@ -8,10 +8,7 @@ python manage.py ta_stop_on_error # django setup python manage.py migrate - -if [[ -z "$DJANGO_DEBUG" ]]; then - python manage.py collectstatic --noinput -c -fi +python manage.py collectstatic --noinput -c # ta setup python manage.py ta_envcheck diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 77015096..42a04e9e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,27 +8,53 @@ "name": "tubearchivist-frontend", "version": "0.1.0", "dependencies": { - "dompurify": "^3.2.3", + "dompurify": "^3.2.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.3", + "react-router-dom": "^7.1.5", "zustand": "^5.0.3" }, "devDependencies": { "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "@vitejs/plugin-react-swc": "^3.7.2", - "eslint": "^9.19.0", + "@typescript-eslint/eslint-plugin": "^8.24.0", + "@typescript-eslint/parser": "^8.24.0", + "@vitejs/plugin-react-swc": "^3.8.0", + "eslint": "^9.20.1", "eslint-config-prettier": "^10.0.1", "eslint-plugin-react-hooks": "^5.1.0", - "eslint-plugin-react-refresh": "^0.4.18", - "globals": "^15.14.0", - "prettier": "3.4.2", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "prettier": "3.5.1", "typescript": "^5.7.3", - "typescript-eslint": "^8.21.0", - "vite": "^6.0.11" + "typescript-eslint": "^8.24.0", + "vite": "^6.1.0", + "vite-plugin-checker": "^0.8.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -457,16 +483,20 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } @@ -482,13 +512,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", - "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.5", + "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -521,9 +551,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz", + "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -595,9 +625,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.19.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", - "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", + "version": "9.20.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", + "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", "dev": true, "license": "MIT", "engines": { @@ -605,9 +635,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", - "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -628,6 +658,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", + "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -671,6 +714,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -732,9 +776,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", - "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", + "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", "cpu": [ "arm" ], @@ -746,9 +790,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", - "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", + "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", "cpu": [ "arm64" ], @@ -760,9 +804,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", - "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", + "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", "cpu": [ "arm64" ], @@ -774,9 +818,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", - "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", + "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", "cpu": [ "x64" ], @@ -788,9 +832,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", - "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", + "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", "cpu": [ "arm64" ], @@ -802,9 +846,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", - "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", + "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", "cpu": [ "x64" ], @@ -816,9 +860,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", - "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", + "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", "cpu": [ "arm" ], @@ -830,9 +874,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", - "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", + "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", "cpu": [ "arm" ], @@ -844,9 +888,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", - "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", + "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", "cpu": [ "arm64" ], @@ -858,9 +902,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", - "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", + "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", "cpu": [ "arm64" ], @@ -872,9 +916,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", - "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", + "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", "cpu": [ "loong64" ], @@ -886,9 +930,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", - "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", + "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", "cpu": [ "ppc64" ], @@ -900,9 +944,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", - "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", + "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", "cpu": [ "riscv64" ], @@ -914,9 +958,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", - "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", + "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", "cpu": [ "s390x" ], @@ -928,9 +972,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", - "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", + "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", "cpu": [ "x64" ], @@ -942,9 +986,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", - "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", + "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", "cpu": [ "x64" ], @@ -956,9 +1000,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", - "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", + "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", "cpu": [ "arm64" ], @@ -970,9 +1014,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", - "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", + "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", "cpu": [ "ia32" ], @@ -984,9 +1028,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", - "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", + "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", "cpu": [ "x64" ], @@ -998,9 +1042,9 @@ ] }, "node_modules/@swc/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.0.tgz", - "integrity": "sha512-+CuuTCmQFfzaNGg1JmcZvdUVITQXJk9sMnl1C2TiDLzOSVOJRwVD4dNo5dljX/qxpMAN+2BIYlwjlSkoGi6grg==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.17.tgz", + "integrity": "sha512-FXZx7jHpiwz4fTuuueWwsvN7VFLSoeS3mcxCTPUNOHs/K2ecaBO+slh5T5Xvt/KGuD2I/2T8G6Zts0maPkt2lQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -1016,16 +1060,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.10.0", - "@swc/core-darwin-x64": "1.10.0", - "@swc/core-linux-arm-gnueabihf": "1.10.0", - "@swc/core-linux-arm64-gnu": "1.10.0", - "@swc/core-linux-arm64-musl": "1.10.0", - "@swc/core-linux-x64-gnu": "1.10.0", - "@swc/core-linux-x64-musl": "1.10.0", - "@swc/core-win32-arm64-msvc": "1.10.0", - "@swc/core-win32-ia32-msvc": "1.10.0", - "@swc/core-win32-x64-msvc": "1.10.0" + "@swc/core-darwin-arm64": "1.10.17", + "@swc/core-darwin-x64": "1.10.17", + "@swc/core-linux-arm-gnueabihf": "1.10.17", + "@swc/core-linux-arm64-gnu": "1.10.17", + "@swc/core-linux-arm64-musl": "1.10.17", + "@swc/core-linux-x64-gnu": "1.10.17", + "@swc/core-linux-x64-musl": "1.10.17", + "@swc/core-win32-arm64-msvc": "1.10.17", + "@swc/core-win32-ia32-msvc": "1.10.17", + "@swc/core-win32-x64-msvc": "1.10.17" }, "peerDependencies": { "@swc/helpers": "*" @@ -1037,9 +1081,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.0.tgz", - "integrity": "sha512-wCeUpanqZyzvgqWRtXIyhcFK3CqukAlYyP+fJpY2gWc/+ekdrenNIfZMwY7tyTFDkXDYEKzvn3BN/zDYNJFowQ==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.17.tgz", + "integrity": "sha512-LSQhSjESleTc0c45BnVKRacp9Nl4zhJMlV/nmhpFCOv/CqHI5YBDX5c9bPk9jTRNHIf0QH92uTtswt8yN++TCQ==", "cpu": [ "arm64" ], @@ -1054,9 +1098,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.0.tgz", - "integrity": "sha512-0CZPzqTynUBO+SHEl/qKsFSahp2Jv/P2ZRjFG0gwZY5qIcr1+B/v+o74/GyNMBGz9rft+F2WpU31gz2sJwyF4A==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.17.tgz", + "integrity": "sha512-TTaZFS4jLuA3y6+D2HYv4yVGhmjkOGG6KyAwBiJEeoUaazX5MYOyQwaZBPhRGtzHZFrzi4t4jNix4kAkMajPkQ==", "cpu": [ "x64" ], @@ -1071,9 +1115,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.0.tgz", - "integrity": "sha512-oq+DdMu5uJOFPtRkeiITc4kxmd+QSmK+v+OBzlhdGkSgoH3yRWZP+H2ao0cBXo93ZgCr2LfjiER0CqSKhjGuNA==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.17.tgz", + "integrity": "sha512-8P+ESJyGnVdJi0nUcQfxkbTiB/7hnu6N3U72KbvHFBcuroherwzW4DId1XD4RTU2Cjsh1dztZoCcOLY8W9RW1Q==", "cpu": [ "arm" ], @@ -1088,9 +1132,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.0.tgz", - "integrity": "sha512-Y6+PC8knchEViRxiCUj3j8wsGXaIhuvU+WqrFqV834eiItEMEI9+Vh3FovqJMBE3L7d4E4ZQtgImHCXjrHfxbw==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.17.tgz", + "integrity": "sha512-zT21jDQCe+IslzOtw+BD/9ElO/H4qU4fkkOeVQ68PcxuqYS2gwyDxWqa9IGwpzWexYM+Lzi1rAbl/1BM6nGW8Q==", "cpu": [ "arm64" ], @@ -1105,9 +1149,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.0.tgz", - "integrity": "sha512-EbrX9A5U4cECCQQfky7945AW9GYnTXtCUXElWTkTYmmyQK87yCyFfY8hmZ9qMFIwxPOH6I3I2JwMhzdi8Qoz7g==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.17.tgz", + "integrity": "sha512-C2jaW1X+93HscVcesKYgSuZ9GaKqKcQvwvD+q+4JZkaKF4Zopt/aguc6Tmn/nuavRk0WV8yVCpHXoP7lz/2akA==", "cpu": [ "arm64" ], @@ -1122,9 +1166,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.0.tgz", - "integrity": "sha512-TaxpO6snTjjfLXFYh5EjZ78se69j2gDcqEM8yB9gguPYwkCHi2Ylfmh7iVaNADnDJFtjoAQp0L41bTV/Pfq9Cg==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.17.tgz", + "integrity": "sha512-vfyxqV5gddurG2NVJLemR/68s7GTe0QruozrZiDpNqr9V4VX9t3PadDKMDAvQz6jKrtiqMtshNXQTNRKAKlzFw==", "cpu": [ "x64" ], @@ -1139,9 +1183,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.0.tgz", - "integrity": "sha512-IEGvDd6aEEKEyZFZ8oCKuik05G5BS7qwG5hO5PEMzdGeh8JyFZXxsfFXbfeAqjue4UaUUrhnoX+Ze3M2jBVMHw==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.17.tgz", + "integrity": "sha512-8M+nI5MHZGQUnXyfTLsGw85a3oQRXMsFjgMZuOEJO9ZGBIEnYVuWOxENfcP6MmlJmTOW+cJxHnMGhKY+fjcntw==", "cpu": [ "x64" ], @@ -1156,9 +1200,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.0.tgz", - "integrity": "sha512-UkQ952GSpY+Z6XONj9GSW8xGSkF53jrCsuLj0nrcuw7Dvr1a816U/9WYZmmcYS8tnG2vHylhpm6csQkyS8lpCw==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.17.tgz", + "integrity": "sha512-iUeIBFM6c/NwsreLFSAH395Dahc+54mSi0Kq//IrZ2Y16VlqCV7VHdOIMrdAyDoBFUvh0jKuLJPWt+jlKGtSLg==", "cpu": [ "arm64" ], @@ -1173,9 +1217,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.0.tgz", - "integrity": "sha512-a2QpIZmTiT885u/mUInpeN2W9ClCnqrV2LnMqJR1/Fgx1Afw/hAtiDZPtQ0SqS8yDJ2VR5gfNZo3gpxWMrqdVA==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.17.tgz", + "integrity": "sha512-lPXYFvkfYIN8HdNmG6dCnQqgA+rOSTgeAjIhGsYCEyLsYkkhF2FQw34OF6PnWawQ6hOdOE9v6Bw3T4enj3Lb6w==", "cpu": [ "ia32" ], @@ -1190,9 +1234,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.0.tgz", - "integrity": "sha512-tZcCmMwf483nwsEBfUk5w9e046kMa1iSik4bP9Kwi2FGtOfHuDfIcwW4jek3hdcgF5SaBW1ktnK/lgQLDi5AtA==", + "version": "1.10.17", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.17.tgz", + "integrity": "sha512-KrnkFEWpBmxSe8LixhAZXeeUwTNDVukrPeXJ1PiG+pmb5nI989I9J9IQVIgBv+JXXaK+rmiWjlcIkphaDJJEAA==", "cpu": [ "x64" ], @@ -1244,9 +1288,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.0.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", - "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", + "version": "19.0.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", + "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1254,9 +1298,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.0.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz", - "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==", + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", + "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1271,21 +1315,21 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.21.0.tgz", - "integrity": "sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", + "integrity": "sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.21.0", - "@typescript-eslint/type-utils": "8.21.0", - "@typescript-eslint/utils": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/type-utils": "8.24.1", + "@typescript-eslint/utils": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1301,16 +1345,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.21.0.tgz", - "integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.1.tgz", + "integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.21.0", - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/typescript-estree": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0", + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", "debug": "^4.3.4" }, "engines": { @@ -1326,14 +1370,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz", - "integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz", + "integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0" + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1344,16 +1388,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.21.0.tgz", - "integrity": "sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz", + "integrity": "sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.21.0", - "@typescript-eslint/utils": "8.21.0", + "@typescript-eslint/typescript-estree": "8.24.1", + "@typescript-eslint/utils": "8.24.1", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1368,9 +1412,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz", - "integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz", + "integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==", "dev": true, "license": "MIT", "engines": { @@ -1382,20 +1426,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz", - "integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz", + "integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/visitor-keys": "8.21.0", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/visitor-keys": "8.24.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1409,16 +1453,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.21.0.tgz", - "integrity": "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz", + "integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.21.0", - "@typescript-eslint/types": "8.21.0", - "@typescript-eslint/typescript-estree": "8.21.0" + "@typescript-eslint/scope-manager": "8.24.1", + "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/typescript-estree": "8.24.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1433,13 +1477,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz", - "integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz", + "integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.21.0", + "@typescript-eslint/types": "8.24.1", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -1464,13 +1508,13 @@ } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.2.tgz", - "integrity": "sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.8.0.tgz", + "integrity": "sha512-T4sHPvS+DIqDP51ifPqa9XIRAz/kIvIi8oXcnOZZgHmMotgmmdxe/DD5tMFlt5nuIRzT0/QuiwmKlH0503Aapw==", "dev": true, "license": "MIT", "dependencies": { - "@swc/core": "^1.7.26" + "@swc/core": "^1.10.15" }, "peerDependencies": { "vite": "^4 || ^5 || ^6" @@ -1516,11 +1560,38 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -1531,6 +1602,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1542,7 +1627,21 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/brace-expansion": { "version": "2.0.1", @@ -1582,6 +1681,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1593,11 +1693,50 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/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/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1609,13 +1748,25 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cookie": { "version": "1.0.2", @@ -1645,15 +1796,17 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "devOptional": true, + "license": "MIT" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1668,12 +1821,13 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/dompurify": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz", - "integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -1725,6 +1879,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -1733,18 +1888,18 @@ } }, "node_modules/eslint": { - "version": "9.19.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", - "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", + "version": "9.20.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", + "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.11.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.19.0", + "@eslint/js": "9.20.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -1819,9 +1974,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.18", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.18.tgz", - "integrity": "sha512-IRGEoFn3OKalm3hjfolEWGqoF/jPqeEYFp+C8B0WMzwGwBMvlRDQd06kghDhF0C61uJ6WfSDhEZE/sAQjduKgw==", + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.19.tgz", + "integrity": "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1850,6 +2005,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -1862,6 +2018,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1885,6 +2042,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1924,10 +2082,11 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -1953,6 +2112,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -2015,12 +2175,13 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "dev": true, "license": "ISC", "dependencies": { @@ -2058,6 +2219,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -2084,12 +2246,27 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2110,6 +2287,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -2118,9 +2296,9 @@ } }, "node_modules/globals": { - "version": "15.14.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", - "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, "license": "MIT", "engines": { @@ -2130,34 +2308,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true + "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", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2176,15 +2364,30 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.19" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2194,6 +2397,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, + "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -2218,6 +2422,13 @@ "dev": true, "license": "ISC" }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2249,7 +2460,21 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } }, "node_modules/keyv": { "version": "4.5.4", @@ -2266,6 +2491,7 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -2279,6 +2505,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -2293,7 +2520,8 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -2336,10 +2564,11 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.8", @@ -2364,13 +2593,38 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -2388,6 +2642,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2403,6 +2658,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -2431,6 +2687,7 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2466,9 +2723,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", + "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", "dev": true, "funding": [ { @@ -2486,7 +2743,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2499,14 +2756,15 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", + "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "dev": true, "license": "MIT", "bin": { @@ -2572,9 +2830,9 @@ } }, "node_modules/react-router": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.3.tgz", - "integrity": "sha512-EezYymLY6Guk/zLQ2vRA8WvdUhWFEj5fcE3RfWihhxXBW7+cd1LsIiA3lmx+KCmneAGQuyBv820o44L2+TtkSA==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.5.tgz", + "integrity": "sha512-8BUF+hZEU4/z/JD201yK6S+UYhsf58bzYIDq2NS1iGpwxSXDu7F+DeGSkIXMFBuHZB21FSiCzEcUb18cQNdRkA==", "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", @@ -2596,12 +2854,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.3.tgz", - "integrity": "sha512-qQGTE+77hleBzv9SIUIkGRvuFBQGagW+TQKy53UTZAO/3+YFNBYvRsNIZ1GT17yHbc63FylMOdS+m3oUriF1GA==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.5.tgz", + "integrity": "sha512-/4f9+up0Qv92D3bB8iN5P1s3oHAepSGa9h5k6tpTFlixTTskJZwKGhJ6vRJ277tLD1zuaZTt95hyGWV1Z37csQ==", "license": "MIT", "dependencies": { - "react-router": "7.1.3" + "react-router": "7.1.5" }, "engines": { "node": ">=20.0.0" @@ -2611,6 +2869,19 @@ "react-dom": ">=18" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2633,9 +2904,9 @@ } }, "node_modules/rollup": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", - "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", + "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2649,25 +2920,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.28.1", - "@rollup/rollup-android-arm64": "4.28.1", - "@rollup/rollup-darwin-arm64": "4.28.1", - "@rollup/rollup-darwin-x64": "4.28.1", - "@rollup/rollup-freebsd-arm64": "4.28.1", - "@rollup/rollup-freebsd-x64": "4.28.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", - "@rollup/rollup-linux-arm-musleabihf": "4.28.1", - "@rollup/rollup-linux-arm64-gnu": "4.28.1", - "@rollup/rollup-linux-arm64-musl": "4.28.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", - "@rollup/rollup-linux-riscv64-gnu": "4.28.1", - "@rollup/rollup-linux-s390x-gnu": "4.28.1", - "@rollup/rollup-linux-x64-gnu": "4.28.1", - "@rollup/rollup-linux-x64-musl": "4.28.1", - "@rollup/rollup-win32-arm64-msvc": "4.28.1", - "@rollup/rollup-win32-ia32-msvc": "4.28.1", - "@rollup/rollup-win32-x64-msvc": "4.28.1", + "@rollup/rollup-android-arm-eabi": "4.34.8", + "@rollup/rollup-android-arm64": "4.34.8", + "@rollup/rollup-darwin-arm64": "4.34.8", + "@rollup/rollup-darwin-x64": "4.34.8", + "@rollup/rollup-freebsd-arm64": "4.34.8", + "@rollup/rollup-freebsd-x64": "4.34.8", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", + "@rollup/rollup-linux-arm-musleabihf": "4.34.8", + "@rollup/rollup-linux-arm64-gnu": "4.34.8", + "@rollup/rollup-linux-arm64-musl": "4.34.8", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", + "@rollup/rollup-linux-riscv64-gnu": "4.34.8", + "@rollup/rollup-linux-s390x-gnu": "4.34.8", + "@rollup/rollup-linux-x64-gnu": "4.34.8", + "@rollup/rollup-linux-x64-musl": "4.34.8", + "@rollup/rollup-win32-arm64-msvc": "4.34.8", + "@rollup/rollup-win32-ia32-msvc": "4.34.8", + "@rollup/rollup-win32-x64-msvc": "4.34.8", "fsevents": "~2.3.2" } }, @@ -2702,9 +2973,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "license": "ISC", "bin": { @@ -2753,6 +3024,19 @@ "node": ">=0.10.0" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2771,6 +3055,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -2778,6 +3063,13 @@ "node": ">=8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2792,9 +3084,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -2815,6 +3107,7 @@ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -2822,6 +3115,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -2837,15 +3143,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.21.0.tgz", - "integrity": "sha512-txEKYY4XMKwPXxNkN8+AxAdX6iIJAPiJbHE/FpQccs/sxw8Lf26kqwC3cn0xkHlW8kEbLhkhCsjWuMveaY9Rxw==", + "version": "8.24.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.24.1.tgz", + "integrity": "sha512-cw3rEdzDqBs70TIcb0Gdzbt6h11BSs2pS0yaq7hDWDBtCCSei1pPSUXE9qUdQ/Wm9NgFg8mKtMt1b8fTHIl1jA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.21.0", - "@typescript-eslint/parser": "8.21.0", - "@typescript-eslint/utils": "8.21.0" + "@typescript-eslint/eslint-plugin": "8.24.1", + "@typescript-eslint/parser": "8.24.1", + "@typescript-eslint/utils": "8.24.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2859,6 +3165,16 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2870,15 +3186,15 @@ } }, "node_modules/vite": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz", - "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.24.2", - "postcss": "^8.4.49", - "rollup": "^4.23.0" + "postcss": "^8.5.1", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" @@ -2941,6 +3257,167 @@ } } }, + "node_modules/vite-plugin-checker": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.8.0.tgz", + "integrity": "sha512-UA5uzOGm97UvZRTdZHiQVYFnd86AVn8EVaD4L3PoVzxH+IZSfaAw14WGFwX9QS23UW3lV/5bVKZn6l0w+q9P0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "ansi-escapes": "^4.3.0", + "chalk": "^4.1.1", + "chokidar": "^3.5.1", + "commander": "^8.0.0", + "fast-glob": "^3.2.7", + "fs-extra": "^11.1.0", + "npm-run-path": "^4.0.1", + "strip-ansi": "^6.0.0", + "tiny-invariant": "^1.1.0", + "vscode-languageclient": "^7.0.0", + "vscode-languageserver": "^7.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-uri": "^3.0.2" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "@biomejs/biome": ">=1.7", + "eslint": ">=7", + "meow": "^9.0.0", + "optionator": "^0.9.1", + "stylelint": ">=13", + "typescript": "*", + "vite": ">=2.0.0", + "vls": "*", + "vti": "*", + "vue-tsc": "~2.1.6" + }, + "peerDependenciesMeta": { + "@biomejs/biome": { + "optional": true + }, + "eslint": { + "optional": true + }, + "meow": { + "optional": true + }, + "optionator": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vls": { + "optional": true + }, + "vti": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/vscode-jsonrpc": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-6.0.0.tgz", + "integrity": "sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0 || >=10.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-7.0.0.tgz", + "integrity": "sha512-P9AXdAPlsCgslpP9pRxYPqkNYV7Xq8300/aZDpO35j1fJm/ncize8iGswzYlcvFw5DQUx4eVk+KvfXdL0rehNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.4", + "semver": "^7.3.4", + "vscode-languageserver-protocol": "3.16.0" + }, + "engines": { + "vscode": "^1.52.0" + } + }, + "node_modules/vscode-languageclient/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/vscode-languageclient/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/vscode-languageserver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-7.0.0.tgz", + "integrity": "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.16.0" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.16.0.tgz", + "integrity": "sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "6.0.0", + "vscode-languageserver-types": "3.16.0" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz", + "integrity": "sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2962,6 +3439,7 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2971,6 +3449,7 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, diff --git a/frontend/package.json b/frontend/package.json index caa2d6e4..e9d230c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,26 +11,27 @@ "preview": "vite preview" }, "dependencies": { - "dompurify": "^3.2.3", + "dompurify": "^3.2.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.3", + "react-router-dom": "^7.1.5", "zustand": "^5.0.3" }, "devDependencies": { "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", - "@typescript-eslint/eslint-plugin": "^8.21.0", - "@typescript-eslint/parser": "^8.21.0", - "@vitejs/plugin-react-swc": "^3.7.2", - "eslint": "^9.19.0", + "@typescript-eslint/eslint-plugin": "^8.24.0", + "@typescript-eslint/parser": "^8.24.0", + "@vitejs/plugin-react-swc": "^3.8.0", + "eslint": "^9.20.1", "eslint-config-prettier": "^10.0.1", "eslint-plugin-react-hooks": "^5.1.0", - "eslint-plugin-react-refresh": "^0.4.18", - "globals": "^15.14.0", - "prettier": "3.4.2", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "prettier": "3.5.1", "typescript": "^5.7.3", - "typescript-eslint": "^8.21.0", - "vite": "^6.0.11" + "typescript-eslint": "^8.24.0", + "vite": "^6.1.0", + "vite-plugin-checker": "^0.8.0" } } diff --git a/frontend/src/api/actions/createCustomPlaylist.ts b/frontend/src/api/actions/createCustomPlaylist.ts index 76717070..722203c0 100644 --- a/frontend/src/api/actions/createCustomPlaylist.ts +++ b/frontend/src/api/actions/createCustomPlaylist.ts @@ -1,9 +1,9 @@ import APIClient from '../../functions/APIClient'; const createCustomPlaylist = async (playlistId: string) => { - return APIClient('/api/playlist/', { + return APIClient('/api/playlist/custom/', { method: 'POST', - body: { data: { create: playlistId } }, + body: { playlist_name: playlistId }, }); }; diff --git a/frontend/src/api/actions/updateAppsettingsConfig.ts b/frontend/src/api/actions/updateAppsettingsConfig.ts index 462ca3ed..174dcf19 100644 --- a/frontend/src/api/actions/updateAppsettingsConfig.ts +++ b/frontend/src/api/actions/updateAppsettingsConfig.ts @@ -1,12 +1,10 @@ import APIClient from '../../functions/APIClient'; +import { AppSettingsConfigType } from '../loader/loadAppsettingsConfig'; -const updateAppsettingsConfig = async ( - configKey: string, - configValue: string | boolean | number | null, -) => { +const updateAppsettingsConfig = async (updatedConfig: Partial) => { return APIClient('/api/appsettings/config/', { method: 'POST', - body: { [configKey]: configValue }, + body: updatedConfig, }); }; diff --git a/frontend/src/api/actions/updateCustomPlaylist.ts b/frontend/src/api/actions/updateCustomPlaylist.ts index bef8403d..31d8a1e4 100644 --- a/frontend/src/api/actions/updateCustomPlaylist.ts +++ b/frontend/src/api/actions/updateCustomPlaylist.ts @@ -7,7 +7,7 @@ const updateCustomPlaylist = async ( playlistId: string, videoId: string, ) => { - return APIClient(`/api/playlist/${playlistId}/`, { + return APIClient(`/api/playlist/custom/${playlistId}/`, { method: 'POST', body: { action, video_id: videoId }, }); diff --git a/frontend/src/api/actions/updateUserConfig.ts b/frontend/src/api/actions/updateUserConfig.ts index c091741e..675d80d9 100644 --- a/frontend/src/api/actions/updateUserConfig.ts +++ b/frontend/src/api/actions/updateUserConfig.ts @@ -1,17 +1,6 @@ import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home'; import APIClient from '../../functions/APIClient'; -export type UserMeType = { - id: number; - name: string; - is_superuser: boolean; - is_staff: boolean; - groups: []; - user_permissions: []; - last_login: string; - config: UserConfigType; -}; - export type ColourVariants = 'dark.css' | 'light.css' | 'matrix.css' | 'midnight.css'; export type UserConfigType = { @@ -33,7 +22,7 @@ export type UserConfigType = { const updateUserConfig = async (config: Partial): Promise => { return APIClient('/api/user/me/', { method: 'POST', - body: { config: config }, + body: config, }); }; diff --git a/frontend/src/api/loader/loadDownloadAggs.ts b/frontend/src/api/loader/loadDownloadAggs.ts index b6f0a7ce..9954fa6a 100644 --- a/frontend/src/api/loader/loadDownloadAggs.ts +++ b/frontend/src/api/loader/loadDownloadAggs.ts @@ -7,11 +7,9 @@ type DownloadAggsBucket = { }; export type DownloadAggsType = { - channel_downloads: { - doc_count_error_upper_bound: number; - sum_other_doc_count: number; - buckets: DownloadAggsBucket[]; - }; + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: DownloadAggsBucket[]; }; const loadDownloadAggs = async (showIgnored: boolean): Promise => { diff --git a/frontend/src/api/loader/loadPlaylistById.ts b/frontend/src/api/loader/loadPlaylistById.ts index ad31fcf6..44007945 100644 --- a/frontend/src/api/loader/loadPlaylistById.ts +++ b/frontend/src/api/loader/loadPlaylistById.ts @@ -1,6 +1,32 @@ import APIClient from '../../functions/APIClient'; -const loadPlaylistById = async (playlistId: string | undefined) => { +export type PlaylistEntryType = { + youtube_id: string; + title: string; + uploader: string; + idx: number; + downloaded: boolean; +}; + +export type PlaylistType = { + playlist_active: boolean; + playlist_channel: string; + playlist_channel_id: string; + playlist_description: string; + playlist_entries: PlaylistEntryType[]; + playlist_id: string; + playlist_last_refresh: string; + playlist_name: string; + playlist_subscribed: boolean; + playlist_thumbnail: string; + playlist_type: string; + _index: string; + _score: number; +}; + +export type PlaylistResponseType = PlaylistType; + +const loadPlaylistById = async (playlistId: string | undefined): Promise => { return APIClient(`/api/playlist/${playlistId}/`); }; diff --git a/frontend/src/api/loader/loadUserAccount.ts b/frontend/src/api/loader/loadUserAccount.ts new file mode 100644 index 00000000..bc452cc2 --- /dev/null +++ b/frontend/src/api/loader/loadUserAccount.ts @@ -0,0 +1,17 @@ +import APIClient from '../../functions/APIClient'; + +export type UserAccountType = { + id: number; + name: string; + is_superuser: boolean; + is_staff: boolean; + groups: []; + user_permissions: []; + last_login: string; +}; + +const loadUserAccount = async (): Promise => { + return APIClient('/api/user/account/'); +}; + +export default loadUserAccount; diff --git a/frontend/src/api/loader/loadUserConfig.ts b/frontend/src/api/loader/loadUserConfig.ts index 3cd89736..fbf96dd3 100644 --- a/frontend/src/api/loader/loadUserConfig.ts +++ b/frontend/src/api/loader/loadUserConfig.ts @@ -1,7 +1,7 @@ -import { UserMeType } from '../actions/updateUserConfig'; +import { UserConfigType } from '../actions/updateUserConfig'; import APIClient from '../../functions/APIClient'; -const loadUserMeConfig = async (): Promise => { +const loadUserMeConfig = async (): Promise => { return APIClient('/api/user/me/'); }; diff --git a/frontend/src/components/ChannelList.tsx b/frontend/src/components/ChannelList.tsx index 538f3e8c..90264a78 100644 --- a/frontend/src/components/ChannelList.tsx +++ b/frontend/src/components/ChannelList.tsx @@ -16,7 +16,7 @@ type ChannelListProps = { const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => { const { userConfig } = useUserConfigStore(); - const viewLayout = userConfig.config.view_style_channel; + const viewLayout = userConfig.view_style_channel; if (!channelList || channelList.length === 0) { return

No channels found.

; diff --git a/frontend/src/components/DownloadListItem.tsx b/frontend/src/components/DownloadListItem.tsx index a16b7507..7a4c5812 100644 --- a/frontend/src/components/DownloadListItem.tsx +++ b/frontend/src/components/DownloadListItem.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; import Download from '../pages/Download'; import Routes from '../configuration/routes/RouteList'; @@ -5,9 +6,9 @@ import formatDate from '../functions/formatDates'; import Button from './Button'; import deleteDownloadById from '../api/actions/deleteDownloadById'; import updateDownloadQueueStatusById from '../api/actions/updateDownloadQueueStatusById'; -import { useState } from 'react'; import getApiUrl from '../configuration/getApiUrl'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import defaultVideoThumb from '/img/default-video-thumb.jpg'; type DownloadListItemProps = { download: Download; @@ -16,8 +17,8 @@ type DownloadListItemProps = { const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => { const { userConfig } = useUserConfigStore(); - const view = userConfig.config.view_style_downloads; - const showIgnored = userConfig.config.show_ignored_only; + const view = userConfig.view_style_downloads; + const showIgnored = userConfig.show_ignored_only; const [hideDownload, setHideDownload] = useState(false); @@ -25,7 +26,14 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
- video_thumb + video_thumb { + currentTarget.onerror = null; // prevents looping + currentTarget.src = defaultVideoThumb; + }} + />
{showIgnored && ignored} diff --git a/frontend/src/components/EmbeddableVideoPlayer.tsx b/frontend/src/components/EmbeddableVideoPlayer.tsx index 039855de..4bab055d 100644 --- a/frontend/src/components/EmbeddableVideoPlayer.tsx +++ b/frontend/src/components/EmbeddableVideoPlayer.tsx @@ -12,6 +12,7 @@ import formatNumbers from '../functions/formatNumbers'; import { Link, useSearchParams } from 'react-router-dom'; import Routes from '../configuration/routes/RouteList'; import loadPlaylistById from '../api/loader/loadPlaylistById'; +import { useAppSettingsStore } from '../stores/AppSettingsStore'; type Playlist = { id: string; @@ -25,6 +26,7 @@ type EmbeddableVideoPlayerProps = { const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { const inlinePlayerRef = useRef(null); + const { appSettingsConfig } = useAppSettingsStore(); const [, setSearchParams] = useSearchParams(); @@ -35,16 +37,16 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { useEffect(() => { (async () => { - if (refresh || videoId !== videoResponse?.data.youtube_id) { + if (refresh || videoId !== videoResponse?.youtube_id) { const videoResponse = await loadVideoById(videoId); - const playlistIds = videoResponse.data.playlist; + const playlistIds = videoResponse.playlist; if (playlistIds !== undefined) { const playlists = await Promise.all( playlistIds.map(async playlistid => { const playlistResponse = await loadPlaylistById(playlistid); - return playlistResponse.data; + return playlistResponse; }), ); @@ -69,6 +71,7 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { setRefresh(false); } })(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [videoId, refresh]); useEffect(() => { @@ -79,7 +82,7 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { return
; } - const video = videoResponse.data; + const video = videoResponse; const name = video.title; const channelId = video.channel.channel_id; const channelName = video.channel.channel_name; @@ -88,10 +91,10 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => { const views = formatNumbers(video.stats.view_count); const hasLikes = video.stats.like_count; const likes = formatNumbers(video.stats.like_count); - const hasDislikes = video.stats.dislike_count > 0 && videoResponse.config.downloads.integrate_ryd; + const hasDislikes = video.stats.dislike_count > 0 && appSettingsConfig.downloads.integrate_ryd; const dislikes = formatNumbers(video.stats.dislike_count); - const config = videoResponse.config; - const cast = config.enable_cast; + // const config = videoResponse.config; + const cast = false; // config.enable_cast; return ( <> diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx index 5017127e..9255749d 100644 --- a/frontend/src/components/Filterbar.tsx +++ b/frontend/src/components/Filterbar.tsx @@ -7,6 +7,7 @@ import iconListView from '/img/icon-listview.svg'; import { SortByType, SortOrderType } from '../pages/Home'; import { useUserConfigStore } from '../stores/UserConfigStore'; import { ViewStyles } from '../configuration/constants/ViewStyle'; +import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig'; type FilterbarProps = { hideToggleText: string; @@ -15,9 +16,14 @@ type FilterbarProps = { }; const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: FilterbarProps) => { - const { userConfig, setPartialConfig } = useUserConfigStore(); + const { userConfig, setUserConfig } = useUserConfigStore(); const [showHidden, setShowHidden] = useState(false); - const isGridView = userConfig.config.view_style_home === ViewStyles.grid; + const isGridView = userConfig.view_style_home === ViewStyles.grid; + + const handleUserConfigUpdate = async (config: Partial) => { + const updatedUserConfig = await updateUserConfig(config); + setUserConfig(updatedUserConfig); + }; return (
@@ -27,13 +33,13 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar { - setPartialConfig({ hide_watched: !userConfig.config.hide_watched }); + handleUserConfigUpdate({ hide_watched: !userConfig.hide_watched }); }} /> - {userConfig.config.hide_watched ? ( + {userConfig.hide_watched ? ( @@ -52,9 +58,9 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar { - setPartialConfig({ sort_order: event.target.value as SortOrderType }); + handleUserConfigUpdate({ sort_order: event.target.value as SortOrderType }); }} > @@ -90,22 +96,22 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar /> )} - {userConfig.config.grid_items !== undefined && isGridView && ( + {userConfig.grid_items !== undefined && isGridView && (
- {userConfig.config.grid_items < 7 && ( + {userConfig.grid_items < 7 && ( { - setPartialConfig({ grid_items: userConfig.config.grid_items + 1 }); + handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 }); }} alt="grid plus row" /> )} - {userConfig.config.grid_items > 3 && ( + {userConfig.grid_items > 3 && ( { - setPartialConfig({ grid_items: userConfig.config.grid_items - 1 }); + handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 }); }} alt="grid minus row" /> @@ -115,14 +121,14 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar { - setPartialConfig({ [viewStyleName]: 'grid' }); + handleUserConfigUpdate({ [viewStyleName]: 'grid' }); }} alt="grid view" /> { - setPartialConfig({ [viewStyleName]: 'list' }); + handleUserConfigUpdate({ [viewStyleName]: 'list' }); }} alt="list view" /> diff --git a/frontend/src/components/GoogleCast.tsx b/frontend/src/components/GoogleCast.tsx index 4024c561..210c7203 100644 --- a/frontend/src/components/GoogleCast.tsx +++ b/frontend/src/components/GoogleCast.tsx @@ -93,8 +93,10 @@ const GoogleCast = ({ video, setRefresh, onWatchStateChanged }: GoogleCastProps) const [isConnected, setIsConnected] = useState(false); const setup = useCallback(() => { - const cast = globalThis.cast; - const chrome = globalThis.chrome; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cast = (globalThis as any).cast; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chrome = (globalThis as any).chrome; cast.framework.CastContext.getInstance().setOptions({ receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee. @@ -132,8 +134,10 @@ const GoogleCast = ({ video, setRefresh, onWatchStateChanged }: GoogleCastProps) }, [setRefresh, video]); const startPlayback = useCallback(() => { - const chrome = globalThis.chrome; - const cast = globalThis.cast; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chrome = (globalThis as any).chrome; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cast = (globalThis as any).cast; const castSession = cast.framework.CastContext.getInstance().getCurrentSession(); const mediaUrl = video?.media_url; diff --git a/frontend/src/components/PlaylistList.tsx b/frontend/src/components/PlaylistList.tsx index 84c01525..182499db 100644 --- a/frontend/src/components/PlaylistList.tsx +++ b/frontend/src/components/PlaylistList.tsx @@ -1,11 +1,11 @@ import { Link } from 'react-router-dom'; import Routes from '../configuration/routes/RouteList'; -import { PlaylistType } from '../pages/Playlist'; import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription'; import formatDate from '../functions/formatDates'; import Button from './Button'; import PlaylistThumbnail from './PlaylistThumbnail'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import { PlaylistType } from '../api/loader/loadPlaylistById'; type PlaylistListProps = { playlistList: PlaylistType[] | undefined; @@ -14,7 +14,7 @@ type PlaylistListProps = { const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => { const { userConfig } = useUserConfigStore(); - const viewLayout = userConfig.config.view_style_playlist; + const viewLayout = userConfig.view_style_playlist; if (!playlistList || playlistList.length === 0) { return

No playlists found.

; diff --git a/frontend/src/components/VideoListItem.tsx b/frontend/src/components/VideoListItem.tsx index 6876ed45..be0d45a8 100644 --- a/frontend/src/components/VideoListItem.tsx +++ b/frontend/src/components/VideoListItem.tsx @@ -45,10 +45,14 @@ const VideoListItem = ({ >
- - video-thumb - - + video-thumb { + currentTarget.onerror = null; // prevents looping + currentTarget.src = defaultVideoThumb; + }} + /> {video.player.progress && (
{ const handleTimeUpdate = ( youtubeId: string, - duration: number, watched: boolean, sponsorBlock?: SponsorBlockType, setSponsorSegmentSkipped?: Dispatch>, @@ -142,15 +141,14 @@ const VideoPlayer = ({ const arrowRightPressed = useKeyPress('ArrowRight'); const arrowLeftPressed = useKeyPress('ArrowLeft'); - const videoId = video.data.youtube_id; - const videoUrl = video.data.media_url; - const videoThumbUrl = video.data.vid_thumb_url; - const watched = video.data.player.watched; - const duration = video.data.player.duration; - const videoSubtitles = video.data.subtitles; + const videoId = video.youtube_id; + const videoUrl = video.media_url; + const videoThumbUrl = video.vid_thumb_url; + const watched = video.player.watched; + const duration = video.player.duration; + const videoSubtitles = video.subtitles; - let videoSrcProgress = - Number(video.data.player?.position) > 0 ? Number(video.data.player?.position) : ''; + let videoSrcProgress = Number(video.player?.position) > 0 ? Number(video.player?.position) : ''; if (searchParamVideoProgress !== null) { videoSrcProgress = searchParamVideoProgress; @@ -337,7 +335,6 @@ const VideoPlayer = ({ }} onTimeUpdate={handleTimeUpdate( videoId, - duration, watched, sponsorBlock, setSkippedSegments, diff --git a/frontend/src/configuration/colours/useColours.ts b/frontend/src/configuration/colours/useColours.ts index 27b2acba..0c2a131d 100644 --- a/frontend/src/configuration/colours/useColours.ts +++ b/frontend/src/configuration/colours/useColours.ts @@ -9,7 +9,7 @@ export const ColourConstant = { const useColours = () => { const { userConfig } = useUserConfigStore(); - const stylesheet = userConfig?.config.stylesheet; + const stylesheet = userConfig?.stylesheet; switch (stylesheet) { case ColourConstant.Dark: diff --git a/frontend/src/functions/APIClient.ts b/frontend/src/functions/APIClient.ts index 48b7900a..c01ba8b8 100644 --- a/frontend/src/functions/APIClient.ts +++ b/frontend/src/functions/APIClient.ts @@ -55,8 +55,15 @@ const APIClient = async ( throw new Error('Forbidden: Access denied.'); } - // Try parsing response data let data; + + // expected empty response + if (response.status === 204) { + data = null; + return data; + } + + // Try parsing response data try { data = await response.json(); } catch (error) { diff --git a/frontend/src/functions/useIsAdmin.ts b/frontend/src/functions/useIsAdmin.ts index 7fb5661b..75ae3b39 100644 --- a/frontend/src/functions/useIsAdmin.ts +++ b/frontend/src/functions/useIsAdmin.ts @@ -1,8 +1,8 @@ -import { useUserConfigStore } from '../stores/UserConfigStore'; +import { useUserAccountStore } from '../stores/UserAccountStore'; const useIsAdmin = () => { - const { userConfig } = useUserConfigStore(); - const isAdmin = userConfig?.is_staff || userConfig?.is_superuser; + const { userAccount } = useUserAccountStore(); + const isAdmin = userAccount?.is_staff || userAccount?.is_superuser; return isAdmin; }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f3f37e89..a7d5ad94 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -25,6 +25,8 @@ import ChannelVideo from './pages/ChannelVideo'; import ChannelPlaylist from './pages/ChannelPlaylist'; import ChannelAbout from './pages/ChannelAbout'; import Download from './pages/Download'; +import loadUserAccount from './api/loader/loadUserAccount'; +import loadAppsettingsConfig from './api/loader/loadAppsettingsConfig'; const router = createBrowserRouter( [ @@ -41,8 +43,10 @@ const router = createBrowserRouter( const authData = await auth.json(); const userConfig = await loadUserMeConfig(); + const userAccount = await loadUserAccount(); + const appSettings = await loadAppsettingsConfig(); - return { userConfig, auth: authData }; + return { userConfig, userAccount, appSettings, auth: authData }; }, element: , errorElement: , diff --git a/frontend/src/pages/Base.tsx b/frontend/src/pages/Base.tsx index 74cae1ad..8b3b7228 100644 --- a/frontend/src/pages/Base.tsx +++ b/frontend/src/pages/Base.tsx @@ -1,13 +1,17 @@ import { Outlet, useLoaderData, useLocation, useSearchParams } from 'react-router-dom'; import Footer from '../components/Footer'; import useColours from '../configuration/colours/useColours'; -import { UserMeType } from '../api/actions/updateUserConfig'; +import { UserConfigType } from '../api/actions/updateUserConfig'; import { useEffect, useState } from 'react'; import Navigation from '../components/Navigation'; import { useAuthStore } from '../stores/AuthDataStore'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import { useUserAccountStore } from '../stores/UserAccountStore'; +import { UserAccountType } from '../api/loader/loadUserAccount'; +import { useAppSettingsStore } from '../stores/AppSettingsStore'; +import { AppSettingsConfigType } from '../api/loader/loadAppsettingsConfig'; -export type TaUpdateType = { +type TaUpdateType = { version?: string; is_breaking?: boolean; }; @@ -20,7 +24,9 @@ export type AuthenticationType = { }; type BaseLoaderData = { - userConfig: UserMeType; + userConfig: UserConfigType; + userAccount: UserAccountType; + appSettings: AppSettingsConfigType; auth: AuthenticationType; }; @@ -32,7 +38,10 @@ export type OutletContextType = { const Base = () => { const { setAuth } = useAuthStore(); const { setUserConfig } = useUserConfigStore(); - const { userConfig, auth } = useLoaderData() as BaseLoaderData; + const { setUserAccount } = useUserAccountStore(); + const { setAppSettingsConfig } = useAppSettingsStore(); + + const { userConfig, userAccount, appSettings, auth } = useLoaderData() as BaseLoaderData; const location = useLocation(); @@ -46,6 +55,9 @@ const Base = () => { useEffect(() => { setAuth(auth); setUserConfig(userConfig); + setUserAccount(userAccount); + setAppSettingsConfig(appSettings); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/src/pages/ChannelAbout.tsx b/frontend/src/pages/ChannelAbout.tsx index 679a7800..9fc4aa59 100644 --- a/frontend/src/pages/ChannelAbout.tsx +++ b/frontend/src/pages/ChannelAbout.tsx @@ -55,7 +55,7 @@ const ChannelAbout = () => { const [pageSizeShorts, setPageSizeShorts] = useState(null); const [pageSizeStreams, setPageSizeStreams] = useState(null); - const channel = channelResponse?.data; + const channel = channelResponse; useEffect(() => { (async () => { @@ -226,7 +226,7 @@ const ChannelAbout = () => {
)} - {channel.channel_tags && ( + {channel.channel_tags.length > 0 && (
{channel.channel_tags.map(tag => { @@ -244,7 +244,7 @@ const ChannelAbout = () => {

Channel Customization

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (
  • Overwrite the download format over the format set globally.
  • @@ -334,7 +334,7 @@ const ChannelAbout = () => {

Page Size Overrides

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (

Disable standard videos, shorts, or streams for this channel by setting their diff --git a/frontend/src/pages/ChannelBase.tsx b/frontend/src/pages/ChannelBase.tsx index 2b98d116..2da7c552 100644 --- a/frontend/src/pages/ChannelBase.tsx +++ b/frontend/src/pages/ChannelBase.tsx @@ -1,7 +1,6 @@ import { Link, Outlet, useOutletContext, useParams } from 'react-router-dom'; import Routes from '../configuration/routes/RouteList'; import { ChannelType } from './Channels'; -import { ConfigType } from './Home'; import { OutletContextType } from './Base'; import Notifications from '../components/Notifications'; import { useEffect, useState } from 'react'; @@ -14,10 +13,7 @@ type ChannelParams = { channelId: string; }; -export type ChannelResponseType = { - data: ChannelType; - config: ConfigType; -}; +export type ChannelResponseType = ChannelType; const ChannelBase = () => { const { channelId } = useParams() as ChannelParams; @@ -28,7 +24,7 @@ const ChannelBase = () => { const [channelNav, setChannelNav] = useState(); const [startNotification, setStartNotification] = useState(false); - const channel = channelResponse?.data; + const channel = channelResponse; const { has_streams, has_shorts, has_playlists, has_pending } = channelNav || {}; useEffect(() => { diff --git a/frontend/src/pages/ChannelPlaylist.tsx b/frontend/src/pages/ChannelPlaylist.tsx index 3f4846a1..52dbdcda 100644 --- a/frontend/src/pages/ChannelPlaylist.tsx +++ b/frontend/src/pages/ChannelPlaylist.tsx @@ -10,10 +10,11 @@ import { PlaylistsResponseType } from './Playlists'; import iconGridView from '/img/icon-gridview.svg'; import iconListView from '/img/icon-listview.svg'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig'; const ChannelPlaylist = () => { const { channelId } = useParams(); - const { userConfig, setPartialConfig } = useUserConfigStore(); + const { userConfig, setUserConfig } = useUserConfigStore(); const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const [refreshPlaylists, setRefreshPlaylists] = useState(false); @@ -23,8 +24,13 @@ const ChannelPlaylist = () => { const playlistList = playlistsResponse?.data; const pagination = playlistsResponse?.paginate; - const view = userConfig.config.view_style_playlist; - const showSubedOnly = userConfig.config.show_subed_only; + const view = userConfig.view_style_playlist; + const showSubedOnly = userConfig.show_subed_only; + + const handleUserConfigUpdate = async (config: Partial) => { + const updatedUserConfig = await updateUserConfig(config); + setUserConfig(updatedUserConfig); + }; useEffect(() => { (async () => { @@ -52,7 +58,7 @@ const ChannelPlaylist = () => { { - setPartialConfig({ show_subed_only: !showSubedOnly }); + handleUserConfigUpdate({ show_subed_only: !showSubedOnly }); setRefreshPlaylists(true); }} type="checkbox" @@ -73,14 +79,14 @@ const ChannelPlaylist = () => { { - setPartialConfig({ view_style_playlist: 'grid' }); + handleUserConfigUpdate({ view_style_playlist: 'grid' }); }} alt="grid view" /> { - setPartialConfig({ view_style_playlist: 'list' }); + handleUserConfigUpdate({ view_style_playlist: 'list' }); }} alt="list view" /> diff --git a/frontend/src/pages/ChannelVideo.tsx b/frontend/src/pages/ChannelVideo.tsx index ab43184a..39141e99 100644 --- a/frontend/src/pages/ChannelVideo.tsx +++ b/frontend/src/pages/ChannelVideo.tsx @@ -42,17 +42,17 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => { const [videoResponse, setVideoReponse] = useState(); const [videoAggsResponse, setVideoAggsResponse] = useState(); - const channel = channelResponse?.data; + const channel = channelResponse; const videoList = videoResponse?.data; const pagination = videoResponse?.paginate; const hasVideos = videoResponse?.data?.length !== 0; const showEmbeddedVideo = videoId !== null; - const view = userConfig.config.view_style_home; + const view = userConfig.view_style_home; const isGridView = view === ViewStyles.grid; - const gridView = isGridView ? `boxed-${userConfig.config.grid_items}` : ''; - const gridViewGrid = isGridView ? `grid-${userConfig.config.grid_items}` : ''; + const gridView = isGridView ? `boxed-${userConfig.grid_items}` : ''; + const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : ''; useEffect(() => { (async () => { @@ -60,9 +60,9 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => { const videos = await loadVideoListByFilter({ channel: channelId, page: currentPage, - watch: userConfig.config.hide_watched ? 'unwatched' : undefined, - sort: userConfig.config.sort_by, - order: userConfig.config.sort_order, + watch: userConfig.hide_watched ? 'unwatched' : undefined, + sort: userConfig.sort_by, + order: userConfig.sort_order, type: videoType, }); const channelAggs = await loadChannelAggs(channelId); @@ -74,9 +74,9 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => { })(); }, [ refresh, - userConfig.config.sort_by, - userConfig.config.sort_order, - userConfig.config.hide_watched, + userConfig.sort_by, + userConfig.sort_order, + userConfig.hide_watched, currentPage, channelId, pagination?.current_page, diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index b113708d..57c8d998 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -14,6 +14,7 @@ import Button from '../components/Button'; import updateBulkChannelSubscriptions from '../api/actions/updateBulkChannelSubscriptions'; import useIsAdmin from '../functions/useIsAdmin'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig'; type ChannelOverwritesType = { download_format?: string; @@ -48,7 +49,7 @@ type ChannelsListResponse = { }; const Channels = () => { - const { userConfig, setPartialConfig } = useUserConfigStore(); + const { userConfig, setUserConfig } = useUserConfigStore(); const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const isAdmin = useIsAdmin(); @@ -63,18 +64,20 @@ const Channels = () => { // const channelCount = pagination?.total_hits; const hasChannels = channels?.length !== 0; + const handleUserConfigUpdate = async (config: Partial) => { + const updatedUserConfig = await updateUserConfig(config); + setUserConfig(updatedUserConfig); + }; + useEffect(() => { (async () => { - const channelListResponse = await loadChannelList( - currentPage, - userConfig.config.show_subed_only, - ); + const channelListResponse = await loadChannelList(currentPage, userConfig.show_subed_only); setChannelListResponse(channelListResponse); setShowNotification(false); setRefresh(false); })(); - }, [refresh, userConfig.config.show_subed_only, currentPage, pagination?.current_page]); + }, [refresh, userConfig.show_subed_only, currentPage, pagination?.current_page]); return ( <> @@ -145,18 +148,18 @@ const Channels = () => { { - setPartialConfig({ show_subed_only: !userConfig.config.show_subed_only }); + handleUserConfigUpdate({ show_subed_only: !userConfig.show_subed_only }); setRefresh(true); }} type="checkbox" - checked={userConfig.config.show_subed_only} + checked={userConfig.show_subed_only} /> - {!userConfig.config.show_subed_only && ( + {!userConfig.show_subed_only && ( )} - {userConfig.config.show_subed_only && ( + {userConfig.show_subed_only && ( @@ -167,7 +170,7 @@ const Channels = () => { { - setPartialConfig({ view_style_channel: 'grid' }); + handleUserConfigUpdate({ view_style_channel: 'grid' }); }} data-origin="channel" data-value="grid" @@ -176,7 +179,7 @@ const Channels = () => { { - setPartialConfig({ view_style_channel: 'list' }); + handleUserConfigUpdate({ view_style_channel: 'list' }); }} data-origin="channel" data-value="list" @@ -186,7 +189,7 @@ const Channels = () => {

{/* {hasChannels &&

Total channels: {channelCount}

} */} -
+
{!hasChannels &&

No channels found...

} {hasChannels && } diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx index d46adae6..85325839 100644 --- a/frontend/src/pages/Download.tsx +++ b/frontend/src/pages/Download.tsx @@ -19,6 +19,7 @@ import Button from '../components/Button'; import DownloadListItem from '../components/DownloadListItem'; import loadDownloadAggs, { DownloadAggsType } from '../api/loader/loadDownloadAggs'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig'; type Download = { auto_start: boolean; @@ -46,7 +47,7 @@ export type DownloadResponseType = { const Download = () => { const [searchParams, setSearchParams] = useSearchParams(); - const { userConfig, setPartialConfig } = useUserConfigStore(); + const { userConfig, setUserConfig } = useUserConfigStore(); const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const channelFilterFromUrl = searchParams.get('channel'); @@ -65,8 +66,7 @@ const Download = () => { const downloadList = downloadResponse?.data; const pagination = downloadResponse?.paginate; - const channelDownloads = downloadAggsResponse?.channel_downloads; - const channelAggsList = channelDownloads?.buckets; + const channelAggsList = downloadAggsResponse?.buckets; const downloadCount = pagination?.total_hits; @@ -75,13 +75,18 @@ const Download = () => { ? downloadResponse?.data[0].channel_name : ''; - const view = userConfig.config.view_style_downloads; - const gridItems = userConfig.config.grid_items; - const showIgnored = userConfig.config.show_ignored_only; + const view = userConfig.view_style_downloads; + const gridItems = userConfig.grid_items; + const showIgnored = userConfig.show_ignored_only; const isGridView = view === ViewStyles.grid; const gridView = isGridView ? `boxed-${gridItems}` : ''; const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; + const handleUserConfigUpdate = async (config: Partial) => { + const updatedUserConfig = await updateUserConfig(config); + setUserConfig(updatedUserConfig); + }; + useEffect(() => { (async () => { const videos = await loadDownloadQueue(currentPage, channelFilterFromUrl, showIgnored); @@ -208,7 +213,7 @@ const Download = () => { { - setPartialConfig({ show_ignored_only: !showIgnored }); + handleUserConfigUpdate({ show_ignored_only: !showIgnored }); setRefresh(true); }} type="checkbox" @@ -263,7 +268,7 @@ const Download = () => { { - setPartialConfig({ grid_items: gridItems + 1 }); + handleUserConfigUpdate({ grid_items: gridItems + 1 }); }} alt="grid plus row" /> @@ -272,7 +277,7 @@ const Download = () => { { - setPartialConfig({ grid_items: gridItems - 1 }); + handleUserConfigUpdate({ grid_items: gridItems - 1 }); }} alt="grid minus row" /> @@ -283,14 +288,14 @@ const Download = () => { { - setPartialConfig({ view_style_downloads: 'grid' }); + handleUserConfigUpdate({ view_style_downloads: 'grid' }); }} alt="grid view" /> { - setPartialConfig({ view_style_downloads: 'list' }); + handleUserConfigUpdate({ view_style_downloads: 'list' }); }} alt="list view" /> diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 11cd89cb..14e94bca 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -60,7 +60,7 @@ export type VideoType = { player: PlayerType; published: string; sponsorblock?: SponsorBlockType; - playlist?: string[]; + playlist: string[]; stats: StatsType; streams: StreamType[]; subtitles: Subtitles[]; @@ -108,8 +108,6 @@ const Home = () => { const [searchParams] = useSearchParams(); const videoId = searchParams.get('videoId'); - const userMeConfig = userConfig.config; - const [refreshVideoList, setRefreshVideoList] = useState(false); const [videoResponse, setVideoReponse] = useState(); @@ -123,17 +121,17 @@ const Home = () => { const hasVideos = videoResponse?.data?.length !== 0; const showEmbeddedVideo = videoId !== null; - const isGridView = userMeConfig.view_style_home === ViewStyles.grid; - const gridView = isGridView ? `boxed-${userMeConfig.grid_items}` : ''; - const gridViewGrid = isGridView ? `grid-${userMeConfig.grid_items}` : ''; + const isGridView = userConfig.view_style_home === ViewStyles.grid; + const gridView = isGridView ? `boxed-${userConfig.grid_items}` : ''; + const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : ''; useEffect(() => { (async () => { const videos = await loadVideoListByFilter({ page: currentPage, - watch: userMeConfig.hide_watched ? 'unwatched' : undefined, - sort: userMeConfig.sort_by, - order: userMeConfig.sort_order, + watch: userConfig.hide_watched ? 'unwatched' : undefined, + sort: userConfig.sort_by, + order: userConfig.sort_order, }); try { @@ -150,9 +148,9 @@ const Home = () => { })(); }, [ refreshVideoList, - userMeConfig.sort_by, - userMeConfig.sort_order, - userMeConfig.hide_watched, + userConfig.sort_by, + userConfig.sort_order, + userConfig.hide_watched, currentPage, pagination?.current_page, showEmbeddedVideo, @@ -171,10 +169,10 @@ const Home = () => {

Continue Watching

-
+
@@ -189,7 +187,7 @@ const Home = () => {
-
+
{!hasVideos && ( <>

No videos found...

@@ -204,7 +202,7 @@ const Home = () => { {hasVideos && ( )} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 0a3cab85..e74b6f83 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -22,7 +22,7 @@ const Login = () => { const loginResponse = await signIn(username, password, saveLogin); - const signedIn = loginResponse.status === 200; + const signedIn = loginResponse.status === 204; if (signedIn) { navigate(Routes.Home); diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx index 3ea08180..b82dfecb 100644 --- a/frontend/src/pages/Playlist.tsx +++ b/frontend/src/pages/Playlist.tsx @@ -1,10 +1,9 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom'; -import loadPlaylistById from '../api/loader/loadPlaylistById'; +import loadPlaylistById, { PlaylistResponseType } from '../api/loader/loadPlaylistById'; import { OutletContextType } from './Base'; -import { ConfigType, VideoType } from './Home'; +import { VideoType } from './Home'; import Filterbar from '../components/Filterbar'; -import { PlaylistEntryType } from './Playlists'; import loadChannelById from '../api/loader/loadChannelById'; import VideoList from '../components/VideoList'; import Pagination, { PaginationType } from '../components/Pagination'; @@ -25,30 +24,8 @@ import loadVideoListByFilter from '../api/loader/loadVideoListByPage'; import useIsAdmin from '../functions/useIsAdmin'; import { useUserConfigStore } from '../stores/UserConfigStore'; -export type PlaylistType = { - playlist_active: boolean; - playlist_channel: string; - playlist_channel_id: string; - playlist_description: string; - playlist_entries: PlaylistEntryType[]; - playlist_id: string; - playlist_last_refresh: string; - playlist_name: string; - playlist_subscribed: boolean; - playlist_thumbnail: string; - playlist_type: string; - _index: string; - _score: number; -}; - -export type PlaylistResponseType = { - data?: PlaylistType; - config?: ConfigType; -}; - export type VideoResponseType = { data?: VideoType[]; - config?: ConfigType; paginate?: PaginationType; }; @@ -71,19 +48,19 @@ const Playlist = () => { const [channelResponse, setChannelResponse] = useState(); const [videoResponse, setVideoResponse] = useState(); - const playlist = playlistResponse?.data; - const channel = channelResponse?.data; + const playlist = playlistResponse; + const channel = channelResponse; const videos = videoResponse?.data; const pagination = videoResponse?.paginate; - const palylistEntries = playlistResponse?.data?.playlist_entries; + const palylistEntries = playlistResponse?.playlist_entries; const videoArchivedCount = Number(palylistEntries?.filter(video => video.downloaded).length); const videoInPlaylistCount = pagination?.total_hits; const showEmbeddedVideo = videoId !== null; - const view = userConfig.config.view_style_home; - const gridItems = userConfig.config.grid_items; - const hideWatched = userConfig.config.hide_watched; + const view = userConfig.view_style_home; + const gridItems = userConfig.grid_items; + const hideWatched = userConfig.hide_watched; const isGridView = view === ViewStyles.grid; const gridView = isGridView ? `boxed-${gridItems}` : ''; const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; @@ -98,9 +75,9 @@ const Playlist = () => { sort: 'downloaded', // downloaded or published? or playlist sort order? }); - const isCustomPlaylist = playlist?.data?.playlist_type === 'custom'; + const isCustomPlaylist = playlist?.playlist_type === 'custom'; if (!isCustomPlaylist) { - const channel = await loadChannelById(playlist.data.playlist_channel_id); + const channel = await loadChannelById(playlist.playlist_channel_id); setChannelResponse(channel); } @@ -112,7 +89,7 @@ const Playlist = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ playlistId, - userConfig.config.hide_watched, + userConfig.hide_watched, refresh, currentPage, pagination?.current_page, @@ -298,7 +275,7 @@ const Playlist = () => {
- {playlist.playlist_description && ( + {playlist.playlist_description !== 'False' && (

{ - const { userConfig, setPartialConfig } = useUserConfigStore(); + const { userConfig, setUserConfig } = useUserConfigStore(); const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const isAdmin = useIsAdmin(); @@ -51,8 +42,8 @@ const Playlists = () => { const hasPlaylists = playlistResponse?.data?.length !== 0; - const view = userConfig.config.view_style_playlist; - const showSubedOnly = userConfig.config.show_subed_only; + const view = userConfig.view_style_playlist; + const showSubedOnly = userConfig.show_subed_only; useEffect(() => { (async () => { @@ -66,7 +57,12 @@ const Playlists = () => { setShowNotification(false); })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [refresh, userConfig.config.show_subed_only, currentPage, pagination?.current_page]); + }, [refresh, userConfig.show_subed_only, currentPage, pagination?.current_page]); + + const handleUserConfigUpdate = async (config: Partial) => { + const updatedUserConfig = await updateUserConfig(config); + setUserConfig(updatedUserConfig); + }; return ( <> @@ -156,7 +152,7 @@ const Playlists = () => { { - setPartialConfig({ show_subed_only: !showSubedOnly }); + handleUserConfigUpdate({ show_subed_only: !showSubedOnly }); }} type="checkbox" /> @@ -176,14 +172,14 @@ const Playlists = () => { { - setPartialConfig({ view_style_playlist: 'grid' }); + handleUserConfigUpdate({ view_style_playlist: 'grid' }); }} alt="grid view" /> { - setPartialConfig({ view_style_playlist: 'list' }); + handleUserConfigUpdate({ view_style_playlist: 'list' }); }} alt="list view" /> diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index acf39d71..b391ee18 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { VideoType } from './Home'; import loadSearch from '../api/loader/loadSearch'; -import { PlaylistType } from './Playlist'; import { ChannelType } from './Channels'; import VideoList from '../components/VideoList'; import ChannelList from '../components/ChannelList'; @@ -12,6 +11,7 @@ import { ViewStyles } from '../configuration/constants/ViewStyle'; import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer'; import SearchExampleQueries from '../components/SearchExampleQueries'; import { useUserConfigStore } from '../stores/UserConfigStore'; +import { PlaylistType } from '../api/loader/loadPlaylistById'; const EmptySearchResponse: SearchResultsType = { results: { @@ -39,12 +39,11 @@ const Search = () => { const { userConfig } = useUserConfigStore(); const [searchParams] = useSearchParams(); const videoId = searchParams.get('videoId'); - const userMeConfig = userConfig.config; - const viewVideos = userMeConfig.view_style_home; - const viewChannels = userMeConfig.view_style_channel; - const viewPlaylists = userMeConfig.view_style_playlist; - const gridItems = userMeConfig.grid_items || 3; + const viewVideos = userConfig.view_style_home; + const viewChannels = userConfig.view_style_channel; + const viewPlaylists = userConfig.view_style_playlist; + const gridItems = userConfig.grid_items || 3; const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); diff --git a/frontend/src/pages/SettingsApplication.tsx b/frontend/src/pages/SettingsApplication.tsx index 06dc214d..103b20d5 100644 --- a/frontend/src/pages/SettingsApplication.tsx +++ b/frontend/src/pages/SettingsApplication.tsx @@ -151,7 +151,9 @@ const SettingsApplication = () => { configKey: string, configValue: string | boolean | number | null, ) => { - await updateAppsettingsConfig(configKey, configValue); + const [group, key] = configKey.split('.'); + const updatedConfig = { [group]: { [key]: configValue } } as Partial; + await updateAppsettingsConfig(updatedConfig); setRefresh(true); }; @@ -209,7 +211,7 @@ const SettingsApplication = () => {

Subscription Scan

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (

Configure how a subscription Scan tracks videos.

    @@ -275,7 +277,7 @@ const SettingsApplication = () => {

Downloads

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (
  • @@ -362,7 +364,7 @@ const SettingsApplication = () => {

Download Format

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (
  • @@ -507,7 +509,7 @@ const SettingsApplication = () => {

Subtitles

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (

Additional subtitle options show once you choose a language.

    @@ -586,7 +588,7 @@ const SettingsApplication = () => {

Comments

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (

Additional options show once you set a comment index option.

    @@ -650,7 +652,7 @@ const SettingsApplication = () => {
- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (

Importing your cookie will authenticate requests to YT with your user account. @@ -777,7 +779,7 @@ const SettingsApplication = () => {

Integrations

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (
  • The API token is used to make automater requests to TA through the API.
  • @@ -868,7 +870,7 @@ const SettingsApplication = () => {

Snapshots

- {userConfig.config.show_help_text && ( + {userConfig.show_help_text && (

Automatically create daily deduplicated snapshots of the index, stored in diff --git a/frontend/src/pages/SettingsUser.tsx b/frontend/src/pages/SettingsUser.tsx index c8f2087a..84be8e13 100644 --- a/frontend/src/pages/SettingsUser.tsx +++ b/frontend/src/pages/SettingsUser.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { ColourVariants } from '../api/actions/updateUserConfig'; +import updateUserConfig, { ColourVariants, UserConfigType } from '../api/actions/updateUserConfig'; import { ColourConstant } from '../configuration/colours/useColours'; import SettingsNavigation from '../components/SettingsNavigation'; import Notifications from '../components/Notifications'; @@ -10,35 +10,40 @@ import { useEffect, useState } from 'react'; import ToggleConfig from '../components/ToggleConfig'; const SettingsUser = () => { - const { userConfig, setPartialConfig } = useUserConfigStore(); + const { userConfig, setUserConfig } = useUserConfigStore(); const isAdmin = useIsAdmin(); const navigate = useNavigate(); - const [styleSheet, setStyleSheet] = useState(userConfig.config.stylesheet); + const [styleSheet, setStyleSheet] = useState(userConfig.stylesheet); const [styleSheetRefresh, setStyleSheetRefresh] = useState(false); - const [pageSize, setPageSize] = useState(userConfig.config.page_size); - const [showHelpText, setShowHelpText] = useState(userConfig.config.show_help_text); + const [pageSize, setPageSize] = useState(userConfig.page_size); + const [showHelpText, setShowHelpText] = useState(userConfig.show_help_text); useEffect(() => { (async () => { - setStyleSheet(userConfig.config.stylesheet); - setPageSize(userConfig.config.page_size); - setShowHelpText(userConfig.config.show_help_text); + setStyleSheet(userConfig.stylesheet); + setPageSize(userConfig.page_size); + setShowHelpText(userConfig.show_help_text); })(); - }, [userConfig.config.page_size, userConfig.config.stylesheet, userConfig.config.show_help_text]); + }, [userConfig.page_size, userConfig.stylesheet, userConfig.show_help_text]); const handleStyleSheetChange = async (selectedStyleSheet: ColourVariants) => { - setPartialConfig({ stylesheet: selectedStyleSheet }); + handleUserConfigUpdate({ stylesheet: selectedStyleSheet }); setStyleSheet(selectedStyleSheet); setStyleSheetRefresh(true); }; const handlePageSizeChange = async () => { - setPartialConfig({ page_size: pageSize }); + handleUserConfigUpdate({ page_size: pageSize }); }; const handleShowHelpTextChange = async (configKey: string, configValue: boolean) => { - setPartialConfig({ [configKey]: configValue }); + handleUserConfigUpdate({ [configKey]: configValue }); + }; + + const handleUserConfigUpdate = async (config: Partial) => { + const updatedUserConfig = await updateUserConfig(config); + setUserConfig(updatedUserConfig); }; const handlePageRefresh = () => { @@ -99,12 +104,10 @@ const SettingsUser = () => { />

- {userConfig.config.page_size !== pageSize && ( + {userConfig.page_size !== pageSize && ( <> - + )}
diff --git a/frontend/src/pages/Video.tsx b/frontend/src/pages/Video.tsx index 1e9f1b5b..e67cde2e 100644 --- a/frontend/src/pages/Video.tsx +++ b/frontend/src/pages/Video.tsx @@ -31,7 +31,6 @@ import loadPlaylistList from '../api/loader/loadPlaylistList'; import { PlaylistsResponseType } from './Playlists'; import PaginationDummy from '../components/PaginationDummy'; import updateCustomPlaylist from '../api/actions/updateCustomPlaylist'; -import { PlaylistType } from './Playlist'; import loadCommentsbyVideoId from '../api/loader/loadCommentsbyVideoId'; import CommentBox, { CommentsType } from '../components/CommentBox'; import Button from '../components/Button'; @@ -39,6 +38,8 @@ import getApiUrl from '../configuration/getApiUrl'; import loadVideoNav, { VideoNavResponseType } from '../api/loader/loadVideoNav'; import useIsAdmin from '../functions/useIsAdmin'; import ToggleConfig from '../components/ToggleConfig'; +import { PlaylistType } from '../api/loader/loadPlaylistById'; +// import { useAppSettingsStore } from '../stores/AppSettingsStore'; const isInPlaylist = (videoId: string, playlist: PlaylistType) => { return playlist.playlist_entries.some(entry => { @@ -95,20 +96,9 @@ export type SponsorBlockType = { message?: string; }; -export type SimilarVideosResponseType = { - data: VideoType[]; - config: ConfigType; -}; +export type VideoResponseType = VideoType; -export type VideoResponseType = { - data: VideoType; - config: ConfigType; -}; - -type CommentsResponseType = { - data: CommentsType[]; - config: ConfigType; -}; +type CommentsResponseType = CommentsType[]; export type VideoCommentsResponseType = { data: VideoType; @@ -120,6 +110,7 @@ const Video = () => { const { videoId } = useParams() as VideoParams; const navigate = useNavigate(); const isAdmin = useIsAdmin(); + // const { appSettingsConfig } = useAppSettingsStore(); const [videoEnded, setVideoEnded] = useState(false); const [playlistAutoplay, setPlaylistAutoplay] = useState( @@ -135,25 +126,31 @@ const Video = () => { const [reindex, setReindex] = useState(false); const [videoResponse, setVideoResponse] = useState(); - const [simmilarVideos, setSimmilarVideos] = useState(); + const [simmilarVideos, setSimmilarVideos] = useState(); const [videoPlaylistNav, setVideoPlaylistNav] = useState(); const [customPlaylistsResponse, setCustomPlaylistsResponse] = useState(); const [commentsResponse, setCommentsResponse] = useState(); useEffect(() => { (async () => { - if (refreshVideoList || videoId !== videoResponse?.data?.youtube_id) { + if (refreshVideoList || videoId !== videoResponse?.youtube_id) { const videoByIdResponse = await loadVideoById(videoId); const simmilarVideosResponse = await loadSimmilarVideosById(videoId); const customPlaylistsResponse = await loadPlaylistList({ type: 'custom' }); - const commentsResponse = await loadCommentsbyVideoId(videoId); const videoNavResponse = await loadVideoNav(videoId); + try { + const commentsResponse = await loadCommentsbyVideoId(videoId); + setCommentsResponse(commentsResponse); + } catch (e) { + console.log('Comments not found', e); + } + setVideoResponse(videoByIdResponse); setSimmilarVideos(simmilarVideosResponse); setVideoPlaylistNav(videoNavResponse); setCustomPlaylistsResponse(customPlaylistsResponse); - setCommentsResponse(commentsResponse); + setRefreshVideoList(false); } })(); @@ -194,18 +191,18 @@ const Video = () => { return []; } - const video = videoResponse.data; - const watched = videoResponse.data.player.watched; - const config = videoResponse.config; + const video = videoResponse; + const watched = videoResponse.player.watched; + // const config = appSettingsConfig; const playlistNav = videoPlaylistNav; - const sponsorBlock = videoResponse.data.sponsorblock; + const sponsorBlock = videoResponse.sponsorblock; const customPlaylists = customPlaylistsResponse?.data; const starRating = convertStarRating(video?.stats?.average_rating); - const comments = commentsResponse?.data; + const comments = commentsResponse; console.log('playlistNav', playlistNav); - const cast = config.enable_cast; + const cast = false; // config.enable_cast; return ( <> @@ -569,7 +566,7 @@ const Video = () => {

Similar Videos

diff --git a/frontend/src/stores/AppSettingsStore.ts b/frontend/src/stores/AppSettingsStore.ts new file mode 100644 index 00000000..72012f9b --- /dev/null +++ b/frontend/src/stores/AppSettingsStore.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand'; +import { AppSettingsConfigType } from '../api/loader/loadAppsettingsConfig'; + +interface AppSettingsState { + appSettingsConfig: AppSettingsConfigType; + setAppSettingsConfig: (appSettingsConfig: AppSettingsConfigType) => void; +} + +export const useAppSettingsStore = create(set => ({ + appSettingsConfig: { + subscriptions: { + channel_size: null, + live_channel_size: null, + shorts_channel_size: null, + auto_start: false, + }, + downloads: { + limit_speed: null, + sleep_interval: null, + autodelete_days: null, + 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, + throttledratelimit: null, + extractor_lang: null, + integrate_ryd: false, + integrate_sponsorblock: false, + }, + application: { + enable_snapshot: false, + }, + }, + setAppSettingsConfig: appSettingsConfig => set({ appSettingsConfig }), +})); diff --git a/frontend/src/stores/UserAccountStore.ts b/frontend/src/stores/UserAccountStore.ts new file mode 100644 index 00000000..8f014fce --- /dev/null +++ b/frontend/src/stores/UserAccountStore.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand'; +import { UserAccountType } from '../api/loader/loadUserAccount'; + +interface AccountState { + userAccount: UserAccountType | null; + setUserAccount: (userAccount: UserAccountType) => void; +} + +export const useUserAccountStore = create(set => ({ + userAccount: null, + setUserAccount: userAccount => set({ userAccount }), +})); diff --git a/frontend/src/stores/UserConfigStore.ts b/frontend/src/stores/UserConfigStore.ts index d3cec6b7..609f4b7b 100644 --- a/frontend/src/stores/UserConfigStore.ts +++ b/frontend/src/stores/UserConfigStore.ts @@ -1,45 +1,26 @@ import { create } from 'zustand'; -import updateUserConfig, { UserMeType, UserConfigType } from '../api/actions/updateUserConfig'; +import { UserConfigType } from '../api/actions/updateUserConfig'; interface UserConfigState { - userConfig: UserMeType; - setUserConfig: (userConfig: UserMeType) => void; - setPartialConfig: (userConfig: Partial) => void; + userConfig: UserConfigType; + setUserConfig: (userConfig: UserConfigType) => void; } export const useUserConfigStore = create(set => ({ userConfig: { - id: 0, - name: '', - is_superuser: false, - is_staff: false, - groups: [], - user_permissions: [], - last_login: '', - config: { - stylesheet: 'dark.css', - page_size: 12, - sort_by: 'published', - sort_order: 'desc', - view_style_home: 'grid', - view_style_channel: 'list', - view_style_downloads: 'list', - view_style_playlist: 'grid', - grid_items: 3, - hide_watched: false, - show_ignored_only: false, - show_subed_only: false, - show_help_text: true, - }, + stylesheet: 'dark.css', + page_size: 12, + sort_by: 'published', + sort_order: 'desc', + view_style_home: 'grid', + view_style_channel: 'list', + view_style_downloads: 'list', + view_style_playlist: 'grid', + grid_items: 3, + hide_watched: false, + show_ignored_only: false, + show_subed_only: false, + show_help_text: true, }, setUserConfig: userConfig => set({ userConfig }), - - setPartialConfig: async (userConfig: Partial) => { - const userConfigResponse = await updateUserConfig(userConfig); - set(state => ({ - userConfig: state.userConfig - ? { ...state.userConfig, config: userConfigResponse } - : state.userConfig, - })); - }, })); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 41261f94..be6d249e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,9 +1,10 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; +import checker from 'vite-plugin-checker'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), checker({ typescript: true })], base: '/', server: { host: true,