diff --git a/.gitignore b/.gitignore index 812a03ba..474bdc25 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ backend/.env # JavaScript stuff node_modules + +.editorconfig diff --git a/Dockerfile b/Dockerfile index b8260679..c4319f71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,24 @@ # multi stage to build tube archivist # build python wheel, download and extract ffmpeg, copy into final image +FROM node:lts-alpine AS npm-builder +COPY frontend/package.json frontend/package-lock.json / +RUN npm i + FROM node:lts-alpine AS node-builder # RUN npm config set registry https://registry.npmjs.org/ +COPY --from=npm-builder ./node_modules /frontend/node_modules COPY ./frontend /frontend - WORKDIR /frontend -RUN npm i + RUN npm run build:deploy WORKDIR / # First stage to build python wheel -FROM python:3.11.8-slim-bookworm AS builder +FROM python:3.11.13-slim-bookworm AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential gcc libldap2-dev libsasl2-dev libssl-dev git @@ -24,7 +28,7 @@ COPY ./backend/requirements.txt /requirements.txt RUN pip install --user -r requirements.txt # build ffmpeg -FROM python:3.11.8-slim-bookworm AS ffmpeg-builder +FROM python:3.11.13-slim-bookworm AS ffmpeg-builder ARG TARGETPLATFORM @@ -32,7 +36,7 @@ COPY docker_assets/ffmpeg_download.py ffmpeg_download.py RUN python ffmpeg_download.py $TARGETPLATFORM # build final image -FROM python:3.11.8-slim-bookworm AS tubearchivist +FROM python:3.11.13-slim-bookworm AS tubearchivist ARG INSTALL_DEBUG @@ -54,9 +58,9 @@ RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recomm # install debug tools for testing environment RUN if [ "$INSTALL_DEBUG" ] ; then \ - apt-get -y update && apt-get -y install --no-install-recommends \ - vim htop bmon net-tools iputils-ping procps lsof \ - && pip install --user ipython pytest pytest-django \ + apt-get -y update && apt-get -y install --no-install-recommends \ + vim htop bmon net-tools iputils-ping procps lsof \ + && pip install --user ipython pytest pytest-django \ ; fi # make folders diff --git a/README.md b/README.md index 7d3b2a96..9c50cd8c 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ All environment variables are explained in detail in the docs [here](https://doc | ELASTIC_USER | Change the default ElasticSearch user | Optional | | TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) | | DISABLE_STATIC_AUTH | Remove authentication from media files, (Google Cast...) | [Read more](https://docs.tubearchivist.com/installation/env-vars/#disable_static_auth) | +| TA_AUTO_UPDATE_YTDLP | Configure TA to automatically install the latest yt-dlp on container start | Optional | | DJANGO_DEBUG | Return additional error messages, for debug only | Optional | | TA_LOGIN_AUTH_MODE | Configure the order of login authentication backends (Default: single) | Optional | diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json index d1bbd500..72845492 100644 --- a/backend/appsettings/index_mapping.json +++ b/backend/appsettings/index_mapping.json @@ -461,6 +461,15 @@ "playlist_description": { "type": "text" }, + "playlist_subscribed": { + "type": "boolean" + }, + "playlist_type": { + "type": "keyword" + }, + "playlist_active": { + "type": "boolean" + }, "playlist_name": { "type": "text", "analyzer": "english", @@ -497,6 +506,9 @@ "type": "date", "format": "epoch_second" }, + "playlist_sort_order": { + "type": "keyword" + }, "playlist_entries": { "properties": { "downloaded": { diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py index 5e6e434b..a690f37d 100644 --- a/backend/appsettings/serializers.py +++ b/backend/appsettings/serializers.py @@ -25,6 +25,7 @@ class AppConfigSubSerializer( live_channel_size = serializers.IntegerField(required=False) shorts_channel_size = serializers.IntegerField(required=False) auto_start = serializers.BooleanField(required=False) + extract_flat = serializers.BooleanField(required=False) class AppConfigDownloadsSerializer( diff --git a/backend/appsettings/src/config.py b/backend/appsettings/src/config.py index 3c14a2c0..52c69bb6 100644 --- a/backend/appsettings/src/config.py +++ b/backend/appsettings/src/config.py @@ -22,6 +22,7 @@ class SubscriptionsConfigType(TypedDict): live_channel_size: int shorts_channel_size: int auto_start: bool + extract_flat: bool class DownloadsConfigType(TypedDict): @@ -73,6 +74,7 @@ class AppConfig: "live_channel_size": 50, "shorts_channel_size": 50, "auto_start": False, + "extract_flat": False, }, "downloads": { "limit_speed": None, diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py index 56fd339e..20f82425 100644 --- a/backend/appsettings/src/reindex.py +++ b/backend/appsettings/src/reindex.py @@ -11,11 +11,11 @@ from typing import Callable, TypedDict from appsettings.src.config import AppConfig from channel.src.index import YoutubeChannel +from channel.src.remote_query import get_last_channel_videos from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.helper import rand_sleep from common.src.ta_redis import RedisQueue -from download.src.subscriptions import ChannelSubscription from download.src.thumbnails import ThumbManager from download.src.yt_dlp_base import CookieHandler from playlist.src.index import YoutubePlaylist @@ -376,7 +376,7 @@ class Reindex(ReindexBase): channel.upload_to_es() channel.sync_to_videos() - ChannelFullScan(channel_id).scan() + ChannelFullScan(channel_id, self.config).scan() self.processed["channels"] += 1 def _reindex_single_playlist(self, playlist_id: str) -> None: @@ -493,13 +493,11 @@ class ReindexProgress(ReindexBase): class ChannelFullScan: - """ - update from v0.3.0 to v0.3.1 - full scan of channel to fix vid_type mismatch - """ + """full scan of channel to fix vid_type mismatch""" - def __init__(self, channel_id): + def __init__(self, channel_id, config): self.channel_id = channel_id + self.config = config self.to_update = False def scan(self): @@ -510,12 +508,14 @@ class ChannelFullScan: self.to_update = [] for video in all_local_videos: video_id = video["youtube_id"] - remote_match = [i for i in all_remote_videos if i[0] == video_id] + remote_match = [ + i for i in all_remote_videos if i["id"] == video_id + ] if not remote_match: print(f"{video_id}: no remote match found") continue - expected_type = remote_match[0][-1] + expected_type = remote_match[0]["vid_type"] if video["vid_type"] != expected_type: self.to_update.append( { @@ -528,9 +528,8 @@ class ChannelFullScan: def _get_all_remote(self): """get all channel videos""" - sub = ChannelSubscription() - all_remote_videos = sub.get_last_youtube_videos( - self.channel_id, limit=False + all_remote_videos = get_last_channel_videos( + self.channel_id, self.config, limit=False ) return all_remote_videos diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py index 65503ef7..17bf9526 100644 --- a/backend/channel/src/index.py +++ b/backend/channel/src/index.py @@ -4,7 +4,6 @@ functionality: - index and update in es """ -import json import os from datetime import datetime @@ -121,27 +120,6 @@ class YoutubeChannel(YouTubeItem): "channel_thumb_url": False, "channel_views": 0, } - self._info_json_fallback() - - def _info_json_fallback(self): - """read channel info.json for additional metadata""" - info_json = os.path.join( - EnvironmentSettings.CACHE_DIR, - "import", - f"{self.youtube_id}.info.json", - ) - if os.path.exists(info_json): - print(f"{self.youtube_id}: read info.json file") - with open(info_json, "r", encoding="utf-8") as f: - content = json.loads(f.read()) - - self.json_data.update( - { - "channel_subs": content.get("channel_follower_count", 0), - "channel_description": content.get("description", False), - } - ) - os.remove(info_json) def get_channel_art(self): """download channel art for new channels""" @@ -177,48 +155,15 @@ class YoutubeChannel(YouTubeItem): update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}" _, _ = ElasticWrap(update_path).post(data) - def get_folder_path(self): - """get folder where media files get stored""" - folder_path = os.path.join( - EnvironmentSettings.MEDIA_DIR, - self.json_data["channel_id"], - ) - return folder_path + def change_subscribe(self, new_subscribe_state: bool): + """change subscribe status""" + if not self.json_data: + self.build_json() - def delete_es_videos(self): - """delete all channel documents from elasticsearch""" - data = { - "query": { - "term": {"channel.channel_id": {"value": self.youtube_id}} - } - } - _, _ = ElasticWrap("ta_video/_delete_by_query").post(data) - - def delete_es_comments(self): - """delete all comments from this channel""" - data = { - "query": { - "term": {"comment_channel_id": {"value": self.youtube_id}} - } - } - _, _ = ElasticWrap("ta_comment/_delete_by_query").post(data) - - def delete_es_subtitles(self): - """delete all subtitles from this channel""" - data = { - "query": { - "term": {"subtitle_channel_id": {"value": self.youtube_id}} - } - } - _, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data) - - def delete_playlists(self): - """delete all indexed playlist from es""" - from playlist.src.index import YoutubePlaylist - - all_playlists = self.get_indexed_playlists() - for playlist in all_playlists: - YoutubePlaylist(playlist["playlist_id"]).delete_metadata() + self.json_data["channel_subscribed"] = new_subscribe_state + self.upload_to_es() + self.sync_to_videos() + return self.json_data def delete_channel(self): """delete channel and all videos""" @@ -227,24 +172,7 @@ class YoutubeChannel(YouTubeItem): if not self.json_data: raise FileNotFoundError - folder_path = self.get_folder_path() - print(f"{self.youtube_id}: delete all media files") - try: - all_videos = os.listdir(folder_path) - for video in all_videos: - video_path = os.path.join(folder_path, video) - os.remove(video_path) - os.rmdir(folder_path) - except FileNotFoundError: - print(f"no videos found for {folder_path}") - - print(f"{self.youtube_id}: delete indexed playlists") - self.delete_playlists() - print(f"{self.youtube_id}: delete indexed videos") - self.delete_es_videos() - self.delete_es_comments() - self.delete_es_subtitles() - self.del_in_es() + ChannelDelete(json_data=self.json_data).delete() def index_channel_playlists(self): """add all playlists of channel to index""" @@ -266,6 +194,21 @@ class YoutubeChannel(YouTubeItem): print("add playlist: " + playlist[1]) rand_sleep(self.config) + def get_all_playlists(self): + """get all playlists owned by this channel""" + url = ( + f"https://www.youtube.com/channel/{self.youtube_id}" + + "/playlists?view=1&sort=dd&shelf_id=0" + ) + obs = {"skip_download": True, "extract_flat": True} + playlists, _ = YtWrap(obs, self.config).extract(url) + if not playlists: + self.all_playlists = [] + return + + all_entries = [(i["id"], i["title"]) for i in playlists["entries"]] + self.all_playlists = all_entries + def _notify_single_playlist(self, idx, total): """send notification""" channel_name = self.json_data["channel_name"] @@ -294,34 +237,6 @@ class YoutubeChannel(YouTubeItem): all_videos = IndexPaginate("ta_video", data).get_results() return all_videos - def get_all_playlists(self): - """get all playlists owned by this channel""" - url = ( - f"https://www.youtube.com/channel/{self.youtube_id}" - + "/playlists?view=1&sort=dd&shelf_id=0" - ) - obs = {"skip_download": True, "extract_flat": True} - playlists = YtWrap(obs, self.config).extract(url) - if not playlists: - self.all_playlists = [] - return - - all_entries = [(i["id"], i["title"]) for i in playlists["entries"]] - self.all_playlists = all_entries - - def get_indexed_playlists(self, active_only=False): - """get all indexed playlists from channel""" - must_list = [ - {"term": {"playlist_channel_id": {"value": self.youtube_id}}} - ] - if active_only: - must_list.append({"term": {"playlist_active": {"value": True}}}) - - data = {"query": {"bool": {"must": must_list}}} - - all_playlists = IndexPaginate("ta_playlist", data).get_results() - return all_playlists - def get_overwrites(self) -> dict: """get all per channel overwrites""" return self.json_data.get("channel_overwrites", {}) @@ -352,6 +267,93 @@ class YoutubeChannel(YouTubeItem): self.json_data["channel_overwrites"] = to_write +class ChannelDelete(YouTubeItem): + """delete and cleanup""" + + index_name = "ta_channel" + + def __init__(self, json_data): + super().__init__(youtube_id=json_data["channel_id"]) + self.json_data = json_data + + def delete(self): + """delete channel and all videos""" + folder_path = self._get_folder_path() + print(f"{self.youtube_id}: delete all media files") + try: + all_videos = os.listdir(folder_path) + for video in all_videos: + video_path = os.path.join(folder_path, video) + os.remove(video_path) + os.rmdir(folder_path) + except FileNotFoundError: + print(f"no videos found for {folder_path}") + + print(f"{self.youtube_id}: delete indexed playlists") + self._delete_playlists() + print(f"{self.youtube_id}: delete indexed videos") + self._delete_es_videos() + self._delete_es_comments() + self._delete_es_subtitles() + self.del_in_es() + + def _get_folder_path(self): + """get folder where media files get stored""" + folder_path = os.path.join( + EnvironmentSettings.MEDIA_DIR, + self.json_data["channel_id"], + ) + return folder_path + + def _delete_es_videos(self): + """delete all channel documents from elasticsearch""" + data = { + "query": { + "term": {"channel.channel_id": {"value": self.youtube_id}} + } + } + _, _ = ElasticWrap("ta_video/_delete_by_query").post(data) + + def _delete_es_comments(self): + """delete all comments from this channel""" + data = { + "query": { + "term": {"comment_channel_id": {"value": self.youtube_id}} + } + } + _, _ = ElasticWrap("ta_comment/_delete_by_query").post(data) + + def _delete_es_subtitles(self): + """delete all subtitles from this channel""" + data = { + "query": { + "term": {"subtitle_channel_id": {"value": self.youtube_id}} + } + } + _, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data) + + def _delete_playlists(self): + """delete all indexed playlist from es""" + from playlist.src.index import YoutubePlaylist + + all_playlists = self._get_indexed_playlists() + for playlist in all_playlists: + YoutubePlaylist(playlist["playlist_id"]).delete_metadata() + + def _get_indexed_playlists(self, active_only=False): + """get all indexed playlists from channel""" + must_list = [ + {"term": {"playlist_channel_id": {"value": self.youtube_id}}} + ] + if active_only: + must_list.append({"term": {"playlist_active": {"value": True}}}) + + data = {"query": {"bool": {"must": must_list}}} + + all_playlists = IndexPaginate("ta_playlist", data).get_results() + return all_playlists + + def channel_overwrites(channel_id, overwrites): """collection to overwrite settings per channel""" channel = YoutubeChannel(channel_id) diff --git a/backend/channel/src/remote_query.py b/backend/channel/src/remote_query.py new file mode 100644 index 00000000..a38d1cce --- /dev/null +++ b/backend/channel/src/remote_query.py @@ -0,0 +1,124 @@ +"""build queries for video extraction from channel subscriptions""" + +from download.src.yt_dlp_base import YtWrap +from video.src.constants import VideoTypeEnum + + +class VideoQueryBuilder: + """Build queries for yt-dlp.""" + + def __init__(self, config: dict, channel_overwrites: dict | None = None): + self.config = config + self.channel_overwrites = channel_overwrites or {} + + def build_queries( + self, video_type: VideoTypeEnum | None, limit: bool = True + ) -> list[tuple[VideoTypeEnum, int | None]]: + """Build queries for all or specific video type.""" + query_methods = { + VideoTypeEnum.VIDEOS: self.videos_query, + VideoTypeEnum.STREAMS: self.streams_query, + VideoTypeEnum.SHORTS: self.shorts_query, + } + + if video_type: + # build query for specific type + query_method = query_methods.get(video_type) + if query_method: + query = query_method(limit) + if query[1] != 0: + return [query] + return [] + + # Build and return queries for all video types + queries = [] + for build_query in query_methods.values(): + query = build_query(limit) + if query[1] != 0: + queries.append(query) + + return queries + + def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]: + """Build query for videos.""" + return self._build_generic_query( + video_type=VideoTypeEnum.VIDEOS, + overwrite_key="subscriptions_channel_size", + config_key="channel_size", + limit=limit, + ) + + def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]: + """Build query for streams.""" + return self._build_generic_query( + video_type=VideoTypeEnum.STREAMS, + overwrite_key="subscriptions_live_channel_size", + config_key="live_channel_size", + limit=limit, + ) + + def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]: + """Build query for shorts.""" + return self._build_generic_query( + video_type=VideoTypeEnum.SHORTS, + overwrite_key="subscriptions_shorts_channel_size", + config_key="shorts_channel_size", + limit=limit, + ) + + def _build_generic_query( + self, + video_type: VideoTypeEnum, + overwrite_key: str, + config_key: str, + limit: bool, + ) -> tuple[VideoTypeEnum, int | None]: + """Generic query for video page scraping.""" + if not limit: + return (video_type, None) + + if ( + overwrite_key in self.channel_overwrites + and self.channel_overwrites[overwrite_key] is not None + ): + overwrite = self.channel_overwrites[overwrite_key] + return (video_type, overwrite) + + if overwrite := self.config["subscriptions"].get(config_key): + return (video_type, overwrite) + + return (video_type, 0) + + +def get_last_channel_videos( + channel_id, + config, + limit=None, + query_filter=None, + channel_overwrites=None, +): + """get a list of last videos from channel""" + query_handler = VideoQueryBuilder(config, channel_overwrites) + queries = query_handler.build_queries(query_filter) + last_videos = [] + + for vid_type_enum, limit_amount in queries: + obs = { + "skip_download": True, + "extract_flat": True, + } + vid_type = vid_type_enum.value + + if limit is not None: + obs.update({"playlist_items": f":{limit_amount}:1"}) + + url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}" + channel_query, _ = YtWrap(obs, config).extract(url) + if not channel_query: + continue + + for entry in channel_query["entries"]: + entry["vid_type"] = vid_type + last_videos.append(entry) + + return last_videos diff --git a/backend/channel/views.py b/backend/channel/views.py index f915ecec..1f0daf57 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -14,7 +14,6 @@ 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, @@ -89,9 +88,7 @@ class ChannelApiListView(ApiBaseView): def _unsubscribe(channel_id: str): """unsubscribe""" print(f"[{channel_id}] unsubscribe from channel") - ChannelSubscription().change_subscribe( - channel_id, channel_subscribed=False - ) + YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=False) class ChannelApiView(ApiBaseView): @@ -146,7 +143,9 @@ class ChannelApiView(ApiBaseView): subscribed = validated_data.get("channel_subscribed") if subscribed is not None: - ChannelSubscription().change_subscribe(channel_id, subscribed) + YoutubeChannel(channel_id).change_subscribe( + new_subscribe_state=subscribed + ) overwrites = validated_data.get("channel_overwrites") if overwrites: diff --git a/backend/common/src/es_connect.py b/backend/common/src/es_connect.py index f7a59e3a..b2d11635 100644 --- a/backend/common/src/es_connect.py +++ b/backend/common/src/es_connect.py @@ -56,7 +56,7 @@ class ElasticWrap: return response.json(), response.status_code def post( - self, data: bool | dict = False, ndjson: bool = False + self, data: bool | dict | str = False, ndjson: bool = False ) -> tuple[dict, int]: """post data to es""" diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py index fcf75dfe..cf647a4e 100644 --- a/backend/common/src/helper.py +++ b/backend/common/src/helper.py @@ -103,8 +103,11 @@ def requests_headers() -> dict[str, str]: return {"User-Agent": template} -def date_parser(timestamp: int | str) -> str: +def date_parser(timestamp: int | str | None) -> str | None: """return formatted date string""" + if timestamp is None: + return None + if isinstance(timestamp, int): date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc) elif isinstance(timestamp, str): @@ -152,9 +155,13 @@ def is_shorts(youtube_id: str) -> bool: """check if youtube_id is a shorts video, bot not it it's not a shorts""" shorts_url = f"https://www.youtube.com/shorts/{youtube_id}" cookies = {"SOCS": "CAI"} - response = requests.head( - shorts_url, cookies=cookies, headers=requests_headers(), timeout=10 - ) + try: + response = requests.head( + shorts_url, cookies=cookies, headers=requests_headers(), timeout=10 + ) + except requests.exceptions.RequestException: + # assume video on error + return False return response.status_code == 200 @@ -184,11 +191,12 @@ def get_duration_sec(file_path: str) -> int: return duration_sec -def get_duration_str(seconds: int) -> str: +def get_duration_str(seconds: int | float) -> str: """Return a human-readable duration string from seconds.""" if not seconds: return "NA" + seconds = int(seconds) units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)] duration_parts = [] @@ -284,6 +292,50 @@ def get_channel_overwrites() -> dict[str, dict[str, Any]]: return overwrites +def get_channels( + subscribed_only: bool, source: list[str] | None = None +) -> list[dict]: + """get a list of all channels""" + data = { + "sort": [{"channel_name.keyword": {"order": "asc"}}], + } + if subscribed_only: + query = {"term": {"channel_subscribed": {"value": True}}} + else: + query = {"match_all": {}} + + data["query"] = query # type: ignore + + if source: + data["_source"] = source # type: ignore + + all_channels = IndexPaginate("ta_channel", data).get_results() + + return all_channels + + +def get_playlists( + subscribed_only: bool, source: list[str] | None = None +) -> list[dict]: + """get list of playlists""" + + data = { + "sort": [{"playlist_channel.keyword": {"order": "desc"}}], + } + + must_list = [{"term": {"playlist_active": {"value": True}}}] + if subscribed_only: + must_list.append({"term": {"playlist_subscribed": {"value": True}}}) + + data = {"query": {"bool": {"must": must_list}}} # type: ignore + if source: + data["_source"] = source # type: ignore + + all_playlists = IndexPaginate("ta_playlist", data).get_results() + + return all_playlists + + def calc_is_watched(duration: float, position: float) -> bool: """considered watched based on duration position""" diff --git a/backend/common/src/index_generic.py b/backend/common/src/index_generic.py index 450c1f88..354f83ea 100644 --- a/backend/common/src/index_generic.py +++ b/backend/common/src/index_generic.py @@ -26,6 +26,7 @@ class YouTubeItem: self.youtube_id = youtube_id self.es_path = f"{self.index_name}/_doc/{youtube_id}" self.config = AppConfig().config + self.error = None self.youtube_meta = False self.json_data = False @@ -33,17 +34,24 @@ class YouTubeItem: """build youtube url""" return self.yt_base + self.youtube_id - def get_from_youtube(self): + def get_from_youtube(self, obs_overwrite: dict | None = None): """use yt-dlp to get meta data from youtube""" print(f"{self.youtube_id}: get metadata from youtube") obs_request = self.yt_obs.copy() if self.config["downloads"]["extractor_lang"]: langs = self.config["downloads"]["extractor_lang"] langs_list = [i.strip() for i in langs.split(",")] - obs_request["extractor_args"] = {"youtube": {"lang": langs_list}} + obs_request["extractor_args"] = { + "youtube": {"lang": langs_list} + } # type: ignore + + if obs_overwrite: + obs_request.update(obs_overwrite) url = self.build_yt_url() - self.youtube_meta = YtWrap(obs_request, self.config).extract(url) + self.youtube_meta, self.error = YtWrap( + obs_request, self.config + ).extract(url) def get_from_es(self): """get indexed data from elastic search""" diff --git a/backend/common/src/search_processor.py b/backend/common/src/search_processor.py index 714d33b0..e5c0cac4 100644 --- a/backend/common/src/search_processor.py +++ b/backend/common/src/search_processor.py @@ -165,15 +165,17 @@ class SearchProcess: def _process_download(self, download_dict): """run on single download item""" - video_id = download_dict["youtube_id"] - cache_root = EnvironmentSettings().get_cache_root() - vid_thumb_url = ThumbManager(video_id).vid_thumb_path() - published = date_parser(download_dict["published"]) + vid_thumb_url = None + if download_dict.get("vid_thumb_url"): + video_id = download_dict["youtube_id"] + cache_root = EnvironmentSettings().get_cache_root() + relative_path = ThumbManager(video_id).vid_thumb_path() + vid_thumb_url = f"{cache_root}/{relative_path}" download_dict.update( { - "vid_thumb_url": f"{cache_root}/{vid_thumb_url}", - "published": published, + "vid_thumb_url": vid_thumb_url, + "published": date_parser(download_dict["published"]), } ) return dict(sorted(download_dict.items())) diff --git a/backend/common/src/urlparser.py b/backend/common/src/urlparser.py index 3db07ad6..c3b80690 100644 --- a/backend/common/src/urlparser.py +++ b/backend/common/src/urlparser.py @@ -4,6 +4,7 @@ Functionality: - identify vid_type if possible """ +from typing import Literal, NotRequired, TypedDict from urllib.parse import parse_qs, urlparse from common.src.ta_redis import RedisArchivist @@ -11,19 +12,28 @@ from download.src.yt_dlp_base import YtWrap from video.src.constants import VideoTypeEnum +class ParsedURLType(TypedDict): + """represents single parsed url""" + + type: Literal["video", "channel", "playlist"] + url: str + vid_type: VideoTypeEnum + limit: NotRequired[int | None] + + class Parser: """ take a multi line string and detect valid youtube ids channel handle lookup is cached, can be disabled for unittests """ - def __init__(self, url_str, use_cache=True): + def __init__(self, url_str: str, use_cache: bool = True): self.url_list = [i.strip() for i in url_str.split()] self.use_cache = use_cache - def parse(self): + def parse(self) -> list[ParsedURLType]: """parse the list""" - ids = [] + ids: list[ParsedURLType] = [] for url in self.url_list: parsed = urlparse(url) if parsed.netloc: @@ -124,9 +134,9 @@ class Parser: "extract_flat": True, "playlistend": 0, } - url_info = YtWrap(obs_request).extract(url) + url_info, error = YtWrap(obs_request).extract(url) if not url_info: - raise ValueError(f"failed to retrieve content from URL: {url}") + raise ValueError(f"failed to retrieve URL: {error}") channel_id = url_info.get("channel_id", False) if channel_id: diff --git a/backend/common/src/watched.py b/backend/common/src/watched.py index 5cdef464..27e135c2 100644 --- a/backend/common/src/watched.py +++ b/backend/common/src/watched.py @@ -28,6 +28,11 @@ class WatchState: self.change_vid_state() return + if url_type == "channel": + self.reset_channel_progress() + if url_type == "playlist": + self.reset_playlist_progress() + self._add_pipeline() path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}" data = self._build_update_data(url_type) @@ -53,6 +58,31 @@ class WatchState: print(response) raise ValueError("failed to mark video as watched") + def reset_channel_progress(self): + """reset channel progress positions""" + from channel.src.index import YoutubeChannel + + videos = YoutubeChannel(self.youtube_id).get_channel_videos() + video_ids = [i["youtube_id"] for i in videos] + self._reset_list(video_ids) + + def reset_playlist_progress(self): + """reset playlist progress positions""" + from playlist.src.index import YoutubePlaylist + + videos = YoutubePlaylist(self.youtube_id).get_playlist_videos() + video_ids = [i["youtube_id"] for i in videos] + self._reset_list(video_ids) + + def _reset_list(self, video_ids: list[str]): + """reset list of video ids""" + redis_con = RedisArchivist() + all_ids = redis_con.list_keys(f"{self.user_id}:progress") + for progress_id in all_ids: + video_id = progress_id.split(":")[-1] + if video_id in video_ids: + redis_con.del_message(progress_id) + def _build_update_data(self, url_type): """build update by query data based on url_type""" term_key_map = { diff --git a/backend/config/management/commands/ta_startup.py b/backend/config/management/commands/ta_startup.py index 980354b1..4b75f05b 100644 --- a/backend/config/management/commands/ta_startup.py +++ b/backend/config/management/commands/ta_startup.py @@ -56,6 +56,7 @@ class Command(BaseCommand): self._mig_channel_tags() self._mig_video_channel_tags() self._mig_fix_download_channel_indexed() + self._mig_add_default_playlist_sort() def _make_folders(self): """make expected cache folders""" @@ -389,3 +390,37 @@ class Command(BaseCommand): self.stdout.write(response) sleep(60) raise CommandError(message) + + def _mig_add_default_playlist_sort(self) -> None: + """migrate from 0.5.4 to 0.5.5 set default playlist sortorder""" + self.stdout.write("[MIGRATION] set default playlist sort order") + path = "ta_playlist/_update_by_query" + data = { + "query": { + "bool": { + "must_not": [{"exists": {"field": "playlist_sort_order"}}] + } + }, + "script": { + "source": "ctx._source.playlist_sort_order = 'top'", + "lang": "painless", + }, + } + response, status_code = ElasticWrap(path).post(data) + if status_code in [200, 201]: + updated = response.get("updated") + if updated: + self.stdout.write( + self.style.SUCCESS(f" ✓ updated {updated} playlists") + ) + else: + self.stdout.write( + self.style.SUCCESS(" no playlists need updating") + ) + return + + message = " 🗙 failed to set default playlist sort order" + self.stdout.write(self.style.ERROR(message)) + self.stdout.write(response) + sleep(60) + raise CommandError(message) diff --git a/backend/config/settings.py b/backend/config/settings.py index 52507280..c8196868 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -316,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [ # TA application settings TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" -TA_VERSION = "v0.5.3" +TA_VERSION = "v0.5.5-unstable" # API REST_FRAMEWORK = { diff --git a/backend/download/serializers.py b/backend/download/serializers.py index 4ae1b180..b838b69c 100644 --- a/backend/download/serializers.py +++ b/backend/download/serializers.py @@ -15,11 +15,11 @@ class DownloadItemSerializer(serializers.Serializer): channel_indexed = serializers.BooleanField() channel_name = serializers.CharField() duration = serializers.CharField() - published = serializers.CharField() + published = serializers.CharField(allow_null=True) status = serializers.ChoiceField(choices=["pending", "ignore"]) - timestamp = serializers.IntegerField() + timestamp = serializers.IntegerField(allow_null=True) title = serializers.CharField() - vid_thumb_url = serializers.CharField() + vid_thumb_url = serializers.CharField(allow_null=True) vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values()) youtube_id = serializers.CharField() message = serializers.CharField(required=False) @@ -42,14 +42,23 @@ class DownloadListQuerySerializer( filter = serializers.ChoiceField( choices=["pending", "ignore"], required=False ) + vid_type = serializers.ChoiceField( + choices=VideoTypeEnum.values_known(), required=False + ) channel = serializers.CharField(required=False, help_text="channel ID") page = serializers.IntegerField(required=False) + q = serializers.CharField(required=False, help_text="Search Query") + error = serializers.BooleanField(required=False, allow_null=True) class DownloadListQueueDeleteQuerySerializer(serializers.Serializer): """serialize bulk delete download queue query string""" filter = serializers.ChoiceField(choices=["pending", "ignore"]) + channel = serializers.CharField(required=False, help_text="channel ID") + vid_type = serializers.ChoiceField( + choices=VideoTypeEnum.values_known(), required=False + ) class AddDownloadItemSerializer(serializers.Serializer): @@ -69,6 +78,23 @@ class AddToDownloadQuerySerializer(serializers.Serializer): """add to queue query serializer""" autostart = serializers.BooleanField(required=False) + flat = serializers.BooleanField(required=False) + + +class BulkUpdateDowloadQuerySerializer(serializers.Serializer): + """serialize bulk update query""" + + filter = serializers.ChoiceField(choices=["pending", "ignore", "priority"]) + channel = serializers.CharField(required=False) + vid_type = serializers.ChoiceField( + choices=VideoTypeEnum.values_known(), required=False + ) + + +class BulkUpdateDowloadDataSerializer(serializers.Serializer): + """serialize data""" + + status = serializers.ChoiceField(choices=["pending", "ignore", "priority"]) class DownloadQueueItemUpdateSerializer(serializers.Serializer): diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py index 7110ceaf..a35b985b 100644 --- a/backend/download/src/queue.py +++ b/backend/download/src/queue.py @@ -4,16 +4,25 @@ Functionality: - linked with ta_dowload index """ +import json from datetime import datetime from appsettings.src.config import AppConfig +from channel.src.index import YoutubeChannel +from channel.src.remote_query import get_last_channel_videos from common.src.es_connect import ElasticWrap, IndexPaginate -from common.src.helper import get_duration_str, is_shorts, rand_sleep -from download.src.subscriptions import ChannelSubscription +from common.src.helper import ( + get_channels, + get_duration_str, + is_shorts, + rand_sleep, +) +from common.src.urlparser import ParsedURLType +from download.src.queue_interact import PendingInteract from download.src.thumbnails import ThumbManager -from download.src.yt_dlp_base import YtWrap from playlist.src.index import YoutubePlaylist from video.src.constants import VideoTypeEnum +from video.src.index import YoutubeVideo class PendingIndex: @@ -61,11 +70,7 @@ class PendingIndex: """get a list of all channels indexed""" self.all_channels = [] self.channel_overwrites = {} - data = { - "query": {"match_all": {}}, - "sort": [{"channel_id": {"order": "asc"}}], - } - channels = IndexPaginate("ta_channel", data).get_results() + channels = get_channels(subscribed_only=False) for channel in channels: channel_id = channel["channel_id"] @@ -88,67 +93,6 @@ class PendingIndex: self.video_overwrites.update({video_id: overwrites}) -class PendingInteract: - """interact with items in download queue""" - - def __init__(self, youtube_id=False, status=False): - self.youtube_id = youtube_id - self.status = status - - def delete_item(self): - """delete single item from pending""" - path = f"ta_download/_doc/{self.youtube_id}" - _, _ = ElasticWrap(path).delete(refresh=True) - - def delete_by_status(self): - """delete all matching item by status""" - data = {"query": {"term": {"status": {"value": self.status}}}} - path = "ta_download/_delete_by_query" - _, _ = ElasticWrap(path).post(data=data) - - def update_status(self): - """update status of pending item""" - if self.status == "priority": - data = { - "doc": { - "status": "pending", - "auto_start": True, - "message": None, - } - } - else: - data = {"doc": {"status": self.status}} - - path = f"ta_download/_update/{self.youtube_id}/?refresh=true" - _, _ = ElasticWrap(path).post(data=data) - - def get_item(self): - """return pending item dict""" - path = f"ta_download/_doc/{self.youtube_id}" - response, status_code = ElasticWrap(path).get() - return response["_source"], status_code - - def get_channel(self): - """ - get channel metadata from queue to not depend on channel to be indexed - """ - data = { - "size": 1, - "query": {"term": {"channel_id": {"value": self.youtube_id}}}, - } - response, _ = ElasticWrap("ta_download/_search").get(data=data) - hits = response["hits"]["hits"] - if not hits: - channel_name = "NA" - else: - channel_name = hits[0]["_source"].get("channel_name", "NA") - - return { - "channel_id": self.youtube_id, - "channel_name": channel_name, - } - - class PendingList(PendingIndex): """manage the pending videos list""" @@ -159,126 +103,365 @@ class PendingList(PendingIndex): "check_formats": None, } - def __init__(self, youtube_ids=False, task=False): + def __init__( + self, + youtube_ids: list[ParsedURLType], + task=None, + auto_start=False, + flat=False, + ): super().__init__() self.config = AppConfig().config self.youtube_ids = youtube_ids self.task = task + self.auto_start = auto_start + self.flat = flat self.to_skip = False - self.missing_videos = False + self.missing_videos: list[dict] = [] - def parse_url_list(self, auto_start=False): + def parse_url_list(self): """extract youtube ids from list""" - self.missing_videos = [] self.get_download() self.get_indexed() + self.get_channels() total = len(self.youtube_ids) for idx, entry in enumerate(self.youtube_ids): - self._process_entry(entry, auto_start=auto_start) - if not self.task: - continue + if self.task: + self.task.send_progress( + message_lines=[f"Extracting items {idx + 1}/{total}"], + progress=(idx + 1) / total, + ) - self.task.send_progress( - message_lines=[f"Extracting items {idx + 1}/{total}"], - progress=(idx + 1) / total, - ) + self._process_entry(entry, idx, total) + if self.task and self.task.is_stopped(): + break - def _process_entry(self, entry, auto_start=False): + rand_sleep(self.config) + + def _process_entry(self, entry: dict, idx_url: int, total_url: int): """process single entry from url list""" - vid_type = self._get_vid_type(entry) if entry["type"] == "video": - self._add_video(entry["url"], vid_type, auto_start=auto_start) + self._add_video( + entry["url"], entry["vid_type"], idx_url, total_url + ) elif entry["type"] == "channel": - self._parse_channel(entry["url"], vid_type) + self._parse_channel(entry) elif entry["type"] == "playlist": - self._parse_playlist(entry["url"]) + self._parse_playlist(entry["url"], entry.get("limit")) else: raise ValueError(f"invalid url_type: {entry}") - @staticmethod - def _get_vid_type(entry): - """add vid type enum if available""" - vid_type_str = entry.get("vid_type") - if not vid_type_str: - return VideoTypeEnum.UNKNOWN - - return VideoTypeEnum(vid_type_str) - - def _add_video(self, url, vid_type, auto_start=False): + def _add_video(self, url, vid_type, idx, total): """add video to list""" - if auto_start and url in set( + if self.auto_start and url in set( i["youtube_id"] for i in self.all_pending ): PendingInteract(youtube_id=url, status="priority").update_status() return - if url not in self.missing_videos and url not in self.to_skip: - self.missing_videos.append((url, vid_type)) - else: + if url in self.missing_videos or url in self.to_skip: print(f"{url}: skipped adding already indexed video to download.") + else: + to_add = self._parse_video( + url, + vid_type, + url_type="video", + idx=idx, + total=total, + ) + if to_add: + self.missing_videos.append(to_add) - def _parse_channel(self, url, vid_type): - """add all videos of channel to list""" - video_results = ChannelSubscription().get_last_youtube_videos( - url, limit=False, query_filter=vid_type + def _parse_channel(self, entry): + """parse channel""" + url = entry["url"] + vid_type = entry["vid_type"] + if isinstance(vid_type, str): + # lookup enum + vid_type = getattr(VideoTypeEnum, vid_type.upper()) + + limit = entry.get("limit") + video_results = get_last_channel_videos( + channel_id=url, + config=self.config, + limit=limit, + query_filter=vid_type, ) - for video_id, _, vid_type in video_results: - self._add_video(video_id, vid_type) - - def _parse_playlist(self, url): - """add all videos of playlist to list""" - playlist = YoutubePlaylist(url) - is_active = playlist.update_playlist() - if not is_active: - message = f"{playlist.youtube_id}: failed to extract metadata" - print(message) - raise ValueError(message) - - entries = playlist.json_data["playlist_entries"] - to_add = [i["youtube_id"] for i in entries if not i["downloaded"]] - if not to_add: + if not video_results: + print(f"{url}: no videos to add from channel, skipping") return - for video_id in to_add: - # match vid_type later - self._add_video(video_id, VideoTypeEnum.UNKNOWN) + channel_handler = YoutubeChannel(url) + channel_handler.build_json(upload=False) + if not channel_handler.json_data: + print(f"{url}: channel metadata extraction failed, skipping") + return - def add_to_pending(self, status="pending", auto_start=False): - """add missing videos to pending list""" - self.get_channels() + total = len(video_results) + for idx, video_data in enumerate(video_results): + to_add = self.__parse_channel_video( + video_data, vid_type, idx, total, channel_handler + ) + if to_add: + self.missing_videos.append(to_add) - total = len(self.missing_videos) - videos_added = [] - for idx, (youtube_id, vid_type) in enumerate(self.missing_videos): if self.task and self.task.is_stopped(): break - print(f"{youtube_id}: [{idx + 1}/{total}]: add to queue") - self._notify_add(idx, total) - video_details = self.get_youtube_details(youtube_id, vid_type) - if not video_details: - rand_sleep(self.config) - continue + def __parse_channel_video( + self, video_data, vid_type, idx, total, channel_handler + ) -> dict | None: + """parse video of channel""" + video_id = video_data["id"] + if video_id in self.to_skip: + return None - video_details.update( - { - "status": status, - "auto_start": auto_start, - } + if self.flat: + if not video_data.get("channel"): + channel_name = channel_handler.json_data["channel_name"] + video_data["channel"] = channel_name + + if not video_data.get("channel_id"): + channel_id = channel_handler.json_data["channel_id"] + video_data["channel_id"] = channel_id + + to_add = self._parse_entry( + youtube_id=video_id, + video_data=video_data, + url_type="channel", + idx=idx, + total=total, + ) + else: + to_add = self._parse_video( + video_id, + vid_type, + url_type="channel", + idx=idx, + total=total, ) - url = video_details["vid_thumb_url"] - ThumbManager(youtube_id).download_video_thumb(url) - es_url = f"ta_download/_doc/{youtube_id}" - _, _ = ElasticWrap(es_url).put(video_details) - videos_added.append(youtube_id) + return to_add - if idx != total: - rand_sleep(self.config) + def _parse_playlist(self, url: str, limit: int | None): + """fast parse playlist""" + playlist = YoutubePlaylist(url) + playlist.update_playlist(limit=limit) + if not playlist.youtube_meta: + print(f"{url}: playlist metadata extraction failed, skipping") + return - return videos_added + video_results = playlist.youtube_meta["entries"] - def _notify_add(self, idx, total): + total = len(video_results) + for idx, video_data in enumerate(video_results): + video_id = video_data["id"] + if video_id in self.to_skip: + continue + + if self.task and self.task.is_stopped(): + break + + if self.flat: + if not video_data.get("channel"): + video_data["channel"] = playlist.youtube_meta["channel"] + + if not video_data.get("channel_id"): + channel_id = playlist.youtube_meta["channel_id"] + video_data["channel_id"] = channel_id + + to_add = self._parse_entry( + video_id, + video_data, + url_type="playlist", + idx=idx, + total=total, + ) + else: + to_add = self._parse_video( + video_id, + vid_type=None, + url_type="playlist", + idx=idx, + total=total, + ) + + if to_add: + self.missing_videos.append(to_add) + + def _parse_video(self, url, vid_type, url_type, idx, total): + """parse video""" + video = YoutubeVideo(youtube_id=url) + video.get_from_youtube() + + if not video.youtube_meta: + print(f"{url}: video metadata extraction failed, skipping") + if self.task: + self.task.send_progress( + message_lines=[ + "Video extraction failed.", + f"{video.error}", + ], + level="error", + ) + return None + + video.youtube_meta["vid_type"] = vid_type + to_add = self._parse_entry( + youtube_id=url, + video_data=video.youtube_meta, + url_type=url_type, + idx=idx, + total=total, + ) + ThumbManager(item_id=url).download_video_thumb(to_add["vid_thumb_url"]) + rand_sleep(self.config) + + return to_add + + def _parse_entry( + self, + youtube_id: str, + video_data: dict, + url_type: str, + idx: int, + total: int, + ) -> dict | None: + """parse entry""" + if video_data.get("id") != youtube_id: + # skip premium videos with different id or redirects + print(f"{youtube_id}: skipping redirect, id not matching") + return None + + if video_data.get("live_status") in ["is_upcoming", "is_live"]: + print(f"{youtube_id}: skip is_upcoming or is_live") + return None + + to_add = { + "youtube_id": video_data["id"], + "title": video_data["title"], + "vid_thumb_url": self.__extract_thumb(video_data), + "duration": get_duration_str(video_data.get("duration", 0)), + "published": self.__extract_published(video_data), + "timestamp": int(datetime.now().timestamp()), + "vid_type": self.__extract_vid_type(video_data), + "channel_name": video_data["channel"], + "channel_id": video_data["channel_id"], + "channel_indexed": video_data["channel_id"] in self.all_channels, + } + + self._notify_progress(url_type, video_data["title"], idx, total) + + return to_add + + def __extract_thumb(self, video_data) -> str | None: + """extract thumb""" + if "thumbnail" in video_data: + return video_data["thumbnail"] + + return None + + def __extract_published(self, video_data) -> str | int | None: + """build published date or timestamp""" + timestamp = video_data.get("timestamp") + if timestamp: + return timestamp + + upload_date = video_data.get("upload_date") + if not upload_date: + return None + + upload_date_time = datetime.strptime(upload_date, "%Y%m%d") + published = upload_date_time.strftime("%Y-%m-%d") + + return published + + def __extract_vid_type(self, video_data) -> str: + """build vid type""" + if "vid_type" in video_data: + return video_data["vid_type"] + + if video_data.get("live_status") == "was_live": + return VideoTypeEnum.STREAMS.value + + if video_data.get("width", 0) > video_data.get("height", 0): + return VideoTypeEnum.VIDEOS.value + + duration = video_data.get("duration") + if duration and isinstance(duration, int): + if duration > 3 * 60: + return VideoTypeEnum.VIDEOS.value + + if is_shorts(video_data["id"]): + return VideoTypeEnum.SHORTS.value + + return VideoTypeEnum.VIDEOS.value + + def add_to_pending(self, status="pending") -> int: + """add missing videos to pending list""" + + total = len(self.missing_videos) + + if not self.missing_videos: + self._notify_empty() + return 0 + + self._notify_start(total) + bulk_list = [] + for video_entry in self.missing_videos: + video_entry.update( + { + "status": status, + "auto_start": self.auto_start, + } + ) + video_id = video_entry["youtube_id"] + action = {"index": {"_index": "ta_download", "_id": video_id}} + bulk_list.append(json.dumps(action)) + bulk_list.append(json.dumps(video_entry)) + + # add last newline + bulk_list.append("\n") + query_str = "\n".join(bulk_list) + _, status_code = ElasticWrap("_bulk").post(query_str, ndjson=True) + if status_code != 200: + self._notify_fail(status_code) + else: + self._notify_done(total) + + return len(self.missing_videos) + + def _notify_progress(self, url_type, name, idx, total): + """notify extraction progress""" + if not self.task: + return + + if self.flat: + second_line = f"Bulk processing {total} items." + else: + second_line = f"Processing video {idx + 1}/{total}." + + self.task.send_progress( + message_lines=[ + f"Extracting '{name}' from {url_type.title()}.", + second_line, + ], + progress=(idx + 1) / total, + ) + + def _notify_empty(self): + """notify nothing to add""" + if not self.task: + return + + self.task.send_progress( + message_lines=[ + "Extracting videos completed.", + "No new videos found to add.", + ] + ) + + def _notify_start(self, total): """send notification for adding videos to download queue""" if not self.task: return @@ -286,82 +469,31 @@ class PendingList(PendingIndex): self.task.send_progress( message_lines=[ "Adding new videos to download queue.", - f"Extracting items {idx + 1}/{total}", - ], - progress=(idx + 1) / total, + f"Bulk adding {total} videos", + ] ) - def get_youtube_details(self, youtube_id, vid_type=VideoTypeEnum.VIDEOS): - """get details from youtubedl for single pending video""" - vid = YtWrap(self.yt_obs, self.config).extract(youtube_id) - if not vid: - return False + def _notify_done(self, total): + """send done notification""" + if not self.task: + return - if vid.get("id") != youtube_id: - # skip premium videos with different id - print(f"{youtube_id}: skipping premium video, id not matching") - return False - # stop if video is streaming live now - if vid["live_status"] in ["is_upcoming", "is_live"]: - print(f"{youtube_id}: skip is_upcoming or is_live") - return False + self.task.send_progress( + message_lines=[ + "Adding new videos to the queue completed.", + f"Added {total} videos.", + ] + ) - if vid["live_status"] == "was_live": - vid_type = VideoTypeEnum.STREAMS - else: - if self._check_shorts(vid): - vid_type = VideoTypeEnum.SHORTS - else: - vid_type = VideoTypeEnum.VIDEOS + def _notify_fail(self, status_code): + """failed to add""" + if not self.task: + return - if not vid.get("channel"): - print(f"{youtube_id}: skip video not part of channel") - return False - - return self._parse_youtube_details(vid, vid_type) - - @staticmethod - def _check_shorts(vid): - """check if vid is shorts video""" - if vid["width"] > vid["height"]: - return False - - duration = vid.get("duration") - if duration and isinstance(duration, int): - if duration > 3 * 60: - return False - - return is_shorts(vid["id"]) - - def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS): - """parse response""" - vid_id = vid.get("id") - - # build dict - youtube_details = { - "youtube_id": vid_id, - "channel_name": vid["channel"], - "vid_thumb_url": vid["thumbnail"], - "title": vid["title"], - "channel_id": vid["channel_id"], - "duration": get_duration_str(vid["duration"]), - "published": self._build_published(vid), - "timestamp": int(datetime.now().timestamp()), - "vid_type": vid_type.value, - "channel_indexed": vid["channel_id"] in self.all_channels, - } - - return youtube_details - - @staticmethod - def _build_published(vid): - """build published date or timestamp""" - timestamp = vid["timestamp"] - if timestamp: - return timestamp - - upload_date = vid["upload_date"] - upload_date_time = datetime.strptime(upload_date, "%Y%m%d") - published = upload_date_time.strftime("%Y-%m-%d") - - return published + self.task.send_progress( + message_lines=[ + "Adding extracted videos failed.", + f"Status code: {status_code}", + ], + level="error", + ) diff --git a/backend/download/src/queue_interact.py b/backend/download/src/queue_interact.py new file mode 100644 index 00000000..260b5e5a --- /dev/null +++ b/backend/download/src/queue_interact.py @@ -0,0 +1,100 @@ +"""interact with queue items""" + +from common.src.es_connect import ElasticWrap + + +class PendingInteract: + """interact with items in download queue""" + + def __init__(self, youtube_id=False, status=False): + self.youtube_id = youtube_id + self.status = status + + def delete_item(self): + """delete single item from pending""" + path = f"ta_download/_doc/{self.youtube_id}" + _, _ = ElasticWrap(path).delete(refresh=True) + + def delete_bulk(self, channel_id: str | None, vid_type: str | None): + """delete all matching item by status""" + must_list = [{"term": {"status": {"value": self.status}}}] + if channel_id: + must_list.append({"term": {"channel_id": {"value": channel_id}}}) + + if vid_type: + must_list.append({"term": {"vid_type": {"value": vid_type}}}) + + data = {"query": {"bool": {"must": must_list}}} + + path = "ta_download/_delete_by_query?refresh=true" + _, _ = ElasticWrap(path).post(data=data) + + def update_bulk( + self, channel_id: str | None, vid_type: str | None, new_status: str + ): + """update status in bulk""" + must_list = [{"term": {"status": {"value": self.status}}}] + if channel_id: + must_list.append({"term": {"channel_id": {"value": channel_id}}}) + + if vid_type: + must_list.append({"term": {"vid_type": {"value": vid_type}}}) + + if new_status == "priority": + source = """ + ctx._source.status = 'pending'; + ctx._source.auto_start = true; + ctx._source.message = null; + """ + else: + source = f"ctx._source.status = '{new_status}'" + + data = { + "query": {"bool": {"must": must_list}}, + "script": {"source": source, "lang": "painless"}, + } + + path = "ta_download/_update_by_query?refresh=true" + _, _ = ElasticWrap(path).post(data) + + def update_status(self): + """update status of pending item""" + if self.status == "priority": + data = { + "doc": { + "status": "pending", + "auto_start": True, + "message": None, + } + } + else: + data = {"doc": {"status": self.status}} + + path = f"ta_download/_update/{self.youtube_id}/?refresh=true" + _, _ = ElasticWrap(path).post(data=data) + + def get_item(self): + """return pending item dict""" + path = f"ta_download/_doc/{self.youtube_id}" + response, status_code = ElasticWrap(path).get() + return response["_source"], status_code + + def get_channel(self): + """ + get channel metadata from queue to not depend on channel to be indexed + """ + data = { + "size": 1, + "query": {"term": {"channel_id": {"value": self.youtube_id}}}, + } + response, _ = ElasticWrap("ta_download/_search").get(data=data) + hits = response["hits"]["hits"] + if not hits: + channel_name = "NA" + else: + channel_name = hits[0]["_source"].get("channel_name", "NA") + + return { + "channel_id": self.youtube_id, + "channel_name": channel_name, + } diff --git a/backend/download/src/subscriptions.py b/backend/download/src/subscriptions.py index abc0a4eb..3c8df7dc 100644 --- a/backend/download/src/subscriptions.py +++ b/backend/download/src/subscriptions.py @@ -6,324 +6,97 @@ Functionality: from appsettings.src.config import AppConfig from channel.src.index import YoutubeChannel -from common.src.es_connect import IndexPaginate -from common.src.helper import is_missing, rand_sleep -from common.src.urlparser import Parser -from download.src.thumbnails import ThumbManager -from download.src.yt_dlp_base import YtWrap +from channel.src.remote_query import VideoQueryBuilder +from common.src.helper import get_channels, get_playlists +from common.src.urlparser import ParsedURLType, Parser +from download.src.queue import PendingList from playlist.src.index import YoutubePlaylist from video.src.constants import VideoTypeEnum from video.src.index import YoutubeVideo class ChannelSubscription: - """manage the list of channels subscribed""" + """scan subscribed channels to find missing videos to add to pending""" - def __init__(self, task=False): + def __init__(self, task=None): self.config = AppConfig().config self.task = task - @staticmethod - def get_channels(subscribed_only=True): - """get a list of all channels subscribed to""" - data = { - "sort": [{"channel_name.keyword": {"order": "asc"}}], - } - if subscribed_only: - data["query"] = {"term": {"channel_subscribed": {"value": True}}} - else: - data["query"] = {"match_all": {}} - - all_channels = IndexPaginate("ta_channel", data).get_results() - - return all_channels - - def get_last_youtube_videos( - self, - channel_id, - limit=True, - query_filter=None, - channel_overwrites=None, - ): - """get a list of last videos from channel""" - query_handler = VideoQueryBuilder(self.config, channel_overwrites) - queries = query_handler.build_queries(query_filter) - last_videos = [] - - for vid_type_enum, limit_amount in queries: - obs = { - "skip_download": True, - "extract_flat": True, - } - vid_type = vid_type_enum.value - - if limit: - obs["playlistend"] = limit_amount - - url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}" - channel_query = YtWrap(obs, self.config).extract(url) - if not channel_query: - continue - - last_videos.extend( - [ - (i["id"], i["title"], vid_type) - for i in channel_query["entries"] - ] - ) - - return last_videos - - def find_missing(self): - """add missing videos from subscribed channels to pending""" - all_channels = self.get_channels() + def find_missing(self) -> int: + """find missing videos from channel subscriptions""" + all_channels = get_channels( + subscribed_only=True, source=["channel_id", "channel_overwrites"] + ) if not all_channels: - return False + return 0 - missing_videos = [] + all_channel_urls: list[ParsedURLType] = [] - total = len(all_channels) - for idx, channel in enumerate(all_channels): - channel_id = channel["channel_id"] - print(f"{channel_id}: find missing videos.") - last_videos = self.get_last_youtube_videos( - channel_id, - channel_overwrites=channel.get("channel_overwrites"), - ) + for channel in all_channels: + queries = VideoQueryBuilder( + config=self.config, + channel_overwrites=channel.get("channel_overwrites", {}), + ).build_queries(video_type=None) - if last_videos: - ids_to_add = is_missing([i[0] for i in last_videos]) - for video_id, _, vid_type in last_videos: - if video_id in ids_to_add: - missing_videos.append((video_id, vid_type)) + for query in queries: + all_channel_urls.append( + ParsedURLType( + type="channel", + url=channel["channel_id"], + vid_type=query[0], + limit=query[1], + ) + ) - if not self.task: - continue - - if self.task.is_stopped(): - self.task.send_progress(["Received Stop signal."]) - break - - self.task.send_progress( - message_lines=[f"Scanning Channel {idx + 1}/{total}"], - progress=(idx + 1) / total, - ) - rand_sleep(self.config) - - return missing_videos - - @staticmethod - def change_subscribe(channel_id, channel_subscribed): - """subscribe or unsubscribe from channel and update""" - channel = YoutubeChannel(channel_id) - channel.build_json() - channel.json_data["channel_subscribed"] = channel_subscribed - channel.upload_to_es() - channel.sync_to_videos() - - return channel.json_data - - -class VideoQueryBuilder: - """Build queries for yt-dlp.""" - - def __init__(self, config: dict, channel_overwrites: dict | None = None): - self.config = config - self.channel_overwrites = channel_overwrites or {} - - def build_queries( - self, video_type: VideoTypeEnum | None, limit: bool = True - ) -> list[tuple[VideoTypeEnum, int | None]]: - """Build queries for all or specific video type.""" - query_methods = { - VideoTypeEnum.VIDEOS: self.videos_query, - VideoTypeEnum.STREAMS: self.streams_query, - VideoTypeEnum.SHORTS: self.shorts_query, - } - - if video_type: - # build query for specific type - query_method = query_methods.get(video_type) - if query_method: - query = query_method(limit) - if query[1] != 0: - return [query] - return [] - - # Build and return queries for all video types - queries = [] - for build_query in query_methods.values(): - query = build_query(limit) - if query[1] != 0: - queries.append(query) - - return queries - - def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]: - """Build query for videos.""" - return self._build_generic_query( - video_type=VideoTypeEnum.VIDEOS, - overwrite_key="subscriptions_channel_size", - config_key="channel_size", - limit=limit, + pending_handler = PendingList( + youtube_ids=all_channel_urls, + task=self.task, + auto_start=self.config["subscriptions"].get("auto_start", False), + flat=self.config["subscriptions"].get("extract_flat", False), ) + pending_handler.parse_url_list() + added = pending_handler.add_to_pending() - def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]: - """Build query for streams.""" - return self._build_generic_query( - video_type=VideoTypeEnum.STREAMS, - overwrite_key="subscriptions_live_channel_size", - config_key="live_channel_size", - limit=limit, - ) - - def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]: - """Build query for shorts.""" - return self._build_generic_query( - video_type=VideoTypeEnum.SHORTS, - overwrite_key="subscriptions_shorts_channel_size", - config_key="shorts_channel_size", - limit=limit, - ) - - def _build_generic_query( - self, - video_type: VideoTypeEnum, - overwrite_key: str, - config_key: str, - limit: bool, - ) -> tuple[VideoTypeEnum, int | None]: - """Generic query for video page scraping.""" - if not limit: - return (video_type, None) - - if ( - overwrite_key in self.channel_overwrites - and self.channel_overwrites[overwrite_key] is not None - ): - overwrite = self.channel_overwrites[overwrite_key] - return (video_type, overwrite) - - if overwrite := self.config["subscriptions"].get(config_key): - return (video_type, overwrite) - - return (video_type, 0) + return added class PlaylistSubscription: - """manage the playlist download functionality""" + """scan subscribed playlists for videos to add to pending""" - def __init__(self, task=False): + def __init__(self, task=None): self.config = AppConfig().config self.task = task - @staticmethod - def get_playlists(subscribed_only=True): - """get a list of all active playlists""" - data = { - "sort": [{"playlist_channel.keyword": {"order": "desc"}}], - } - data["query"] = { - "bool": {"must": [{"term": {"playlist_active": {"value": True}}}]} - } - if subscribed_only: - data["query"]["bool"]["must"].append( - {"term": {"playlist_subscribed": {"value": True}}} - ) - - all_playlists = IndexPaginate("ta_playlist", data).get_results() - - return all_playlists - - def process_url_str(self, new_playlists, subscribed=True): - """process playlist subscribe form url_str""" - for idx, playlist in enumerate(new_playlists): - playlist_id = playlist["url"] - if not playlist["type"] == "playlist": - print(f"{playlist_id} not a playlist, skipping...") - continue - - playlist_h = YoutubePlaylist(playlist_id) - playlist_h.build_json() - if not playlist_h.json_data: - message = f"{playlist_h.youtube_id}: failed to extract data" - print(message) - raise ValueError(message) - - playlist_h.json_data["playlist_subscribed"] = subscribed - playlist_h.upload_to_es() - playlist_h.add_vids_to_playlist() - self.channel_validate(playlist_h.json_data["playlist_channel_id"]) - - url = playlist_h.json_data["playlist_thumbnail"] - thumb = ThumbManager(playlist_id, item_type="playlist") - thumb.download_playlist_thumb(url) - - if self.task: - self.task.send_progress( - message_lines=[ - f"Processing {idx + 1} of {len(new_playlists)}" - ], - progress=(idx + 1) / len(new_playlists), - ) - - @staticmethod - def channel_validate(channel_id): - """make sure channel of playlist is there""" - channel = YoutubeChannel(channel_id) - channel.build_json(upload=True) - - @staticmethod - def change_subscribe(playlist_id, subscribe_status): - """change the subscribe status of a playlist""" - playlist = YoutubePlaylist(playlist_id) - playlist.build_json() - playlist.json_data["playlist_subscribed"] = subscribe_status - playlist.upload_to_es() - return playlist.json_data - - def find_missing(self): - """find videos in subscribed playlists not downloaded yet""" - all_playlists = [i["playlist_id"] for i in self.get_playlists()] + def find_missing(self) -> int: + """find missing""" + all_playlists = get_playlists( + subscribed_only=True, source=["playlist_id"] + ) if not all_playlists: - return False + return 0 - missing_videos = [] - total = len(all_playlists) - for idx, playlist_id in enumerate(all_playlists): - playlist = YoutubePlaylist(playlist_id) - is_active = playlist.update_playlist() - if not is_active: - playlist.deactivate() - continue - - playlist_entries = playlist.json_data["playlist_entries"] - size_limit = self.config["subscriptions"]["channel_size"] - if size_limit: - del playlist_entries[size_limit:] - - to_check = [ - i["youtube_id"] - for i in playlist_entries - if i["downloaded"] is False - ] - needs_downloading = is_missing(to_check) - missing_videos.extend(needs_downloading) - - if not self.task: - continue - - if self.task.is_stopped(): - self.task.send_progress(["Received Stop signal."]) - break - - self.task.send_progress( - message_lines=[f"Scanning Playlists {idx + 1}/{total}"], - progress=(idx + 1) / total, + size_limit = self.config["subscriptions"]["channel_size"] + all_playlist_urls: list[ParsedURLType] = [] + for playlist in all_playlists: + all_playlist_urls.append( + ParsedURLType( + type="playlist", + url=playlist["playlist_id"], + vid_type=VideoTypeEnum.UNKNOWN, + limit=size_limit, + ) ) - rand_sleep(self.config) - return missing_videos + pending_handler = PendingList( + youtube_ids=all_playlist_urls, + task=self.task, + auto_start=self.config["subscriptions"].get("auto_start", False), + flat=self.config["subscriptions"].get("extract_flat", False), + ) + pending_handler.parse_url_list() + added = pending_handler.add_to_pending() + + return added class SubscriptionScanner: @@ -339,40 +112,12 @@ class SubscriptionScanner: if self.task: self.task.send_progress(["Rescanning channels and playlists."]) - self.missing_videos = [] - self.scan_channels() + added = 0 + added += ChannelSubscription(task=self.task).find_missing() if self.task and not self.task.is_stopped(): - self.scan_playlists() + added += PlaylistSubscription(task=self.task).find_missing() - return self.missing_videos - - def scan_channels(self): - """get missing from channels""" - channel_handler = ChannelSubscription(task=self.task) - missing = channel_handler.find_missing() - if not missing: - return - - for vid_id, vid_type in missing: - self.missing_videos.append( - {"type": "video", "vid_type": vid_type, "url": vid_id} - ) - - def scan_playlists(self): - """get missing from playlists""" - playlist_handler = PlaylistSubscription(task=self.task) - missing = playlist_handler.find_missing() - if not missing: - return - - for i in missing: - self.missing_videos.append( - { - "type": "video", - "vid_type": VideoTypeEnum.VIDEOS.value, - "url": i, - } - ) + return added class SubscriptionHandler: @@ -404,7 +149,8 @@ class SubscriptionHandler: f"expected {expected_type} url but got {item.get('type')}" ) - PlaylistSubscription().process_url_str([item]) + playlist = YoutubePlaylist(item["url"]) + playlist.change_subscribe(new_subscribe_state=True) return if item["type"] == "video": @@ -427,9 +173,7 @@ class SubscriptionHandler: def _subscribe(self, channel_id): """subscribe to channel""" - _ = ChannelSubscription().change_subscribe( - channel_id, channel_subscribed=True - ) + YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=True) def _notify(self, idx, item, total): """send notification message to redis""" diff --git a/backend/download/src/yt_dlp_base.py b/backend/download/src/yt_dlp_base.py index c80f9d7d..68332da0 100644 --- a/backend/download/src/yt_dlp_base.py +++ b/backend/download/src/yt_dlp_base.py @@ -80,30 +80,33 @@ class YtWrap: return True, True - def extract(self, url): - """make extract request""" + def extract(self, url) -> tuple[dict | None, str | None]: + """ + make extract request + returns response, error + """ with yt_dlp.YoutubeDL(self.obs) as ydl: try: response = ydl.extract_info(url) except cookiejar.LoadError as err: print(f"cookie file is invalid: {err}") - return False + return None, str(err) except yt_dlp.utils.ExtractorError as err: print(f"{url}: failed to extract: {err}, continue...") - return False + return None, str(err) except yt_dlp.utils.DownloadError as err: if "This channel does not have a" in str(err): - return False + return None, None print(f"{url}: failed to get info from youtube: {err}") if "Temporary failure in name resolution" in str(err): raise ConnectionError("lost the internet, abort!") from err - return False + return None, str(err) self._validate_cookie() - return response + return response, None def _validate_cookie(self): """check cookie and write it back for next use""" @@ -146,7 +149,7 @@ class CookieHandler: AppConfig().update_config({"downloads": {"cookie_import": False}}) print("[cookie]: revoked") - def validate(self): + def validate(self) -> bool: """validate cookie using the liked videos playlist""" validation = RedisArchivist().get_message_dict("cookie:valid") if validation: @@ -159,8 +162,8 @@ class CookieHandler: "extract_flat": True, } validator = YtWrap(obs_request, self.config) - response = bool(validator.extract("LL")) - self.store_validation(response) + response, error = validator.extract("LL") + self.store_validation(bool(response)) # update in redis to avoid expiring modified = validator.obs["cookiefile"].getvalue().strip("\x00") @@ -173,15 +176,15 @@ class CookieHandler: "status": "message:download", "level": "error", "title": "Cookie validation failed, exiting...", - "message": "", + "message": error, } RedisArchivist().set_message( "message:download", mess_dict, expire=4 ) print("[cookie]: validation failed, exiting...") - print(f"[cookie]: validation success: {response}") - return response + print(f"[cookie]: validation success: {bool(response)}") + return bool(response) @staticmethod def store_validation(response): diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index 6348e3ab..f74af4eb 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -16,12 +16,12 @@ from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.helper import ( get_channel_overwrites, + get_playlists, ignore_filelist, rand_sleep, ) from common.src.ta_redis import RedisQueue from download.src.queue import PendingList -from download.src.subscriptions import PlaylistSubscription from download.src.yt_dlp_base import YtWrap from playlist.src.index import YoutubePlaylist from video.src.comments import CommentList @@ -403,8 +403,8 @@ class DownloadPostProcess(DownloaderBase): def _add_playlist_sub(self): """add subscribed playlists to refresh""" - subs = PlaylistSubscription().get_playlists() - to_add = [i["playlist_id"] for i in subs] + playlists = get_playlists(subscribed_only=True, source=["playlist_id"]) + to_add = [i["playlist_id"] for i in playlists] RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add) def _add_channel_playlists(self): diff --git a/backend/download/views.py b/backend/download/views.py index 6ef1f7ff..05f3deb5 100644 --- a/backend/download/views.py +++ b/backend/download/views.py @@ -8,6 +8,8 @@ from common.views_base import AdminOnly, ApiBaseView from download.serializers import ( AddToDownloadListSerializer, AddToDownloadQuerySerializer, + BulkUpdateDowloadDataSerializer, + BulkUpdateDowloadQuerySerializer, DownloadAggsSerializer, DownloadItemSerializer, DownloadListQuerySerializer, @@ -15,7 +17,7 @@ from download.serializers import ( DownloadListSerializer, DownloadQueueItemUpdateSerializer, ) -from download.src.queue import PendingInteract +from download.src.queue_interact import PendingInteract from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.response import Response from task.tasks import download_pending, extrac_dl @@ -65,6 +67,22 @@ class DownloadApiListView(ApiBaseView): {"term": {"channel_id": {"value": filter_channel}}} ) + vid_type_filter = validated_data.get("vid_type") + if vid_type_filter: + must_list.append( + {"term": {"vid_type": {"value": vid_type_filter}}} + ) + + search_query = validated_data.get("q") + if search_query: + must_list.append({"match_phrase_prefix": {"title": search_query}}) + + if validated_data.get("error") is not None: + operator = "must" if validated_data["error"] else "must_not" + must_list.append( + {"bool": {operator: [{"exists": {"field": "message"}}]}} + ) + self.data["query"] = {"bool": {"must": must_list}} self.get_document_list(request) @@ -99,12 +117,13 @@ class DownloadApiListView(ApiBaseView): validated_query = query_serializer.validated_data auto_start = validated_query.get("autostart") - print(f"auto_start: {auto_start}") + flat = validated_query.get("flat", False) + print(f"auto_start: {auto_start}, flat: {flat}") to_add = validated_data["data"] pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"] url_str = " ".join(pending) - task = extrac_dl.delay(url_str, auto_start=auto_start) + task = extrac_dl.delay(url_str, auto_start=auto_start, flat=flat) message = { "message": "add to queue task started", @@ -114,6 +133,38 @@ class DownloadApiListView(ApiBaseView): return Response(response_serializer.data) + @staticmethod + @extend_schema( + request=BulkUpdateDowloadDataSerializer(), + parameters=[BulkUpdateDowloadQuerySerializer()], + responses={204: OpenApiResponse(description="Status updated")}, + ) + def patch(request): + """bulk update status""" + data_serializer = BulkUpdateDowloadDataSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + new_status = validated_data["status"] + + query_serializer = BulkUpdateDowloadQuerySerializer( + data=request.query_params + ) + query_serializer.is_valid(raise_exception=True) + validated_query = query_serializer.validated_data + status_filter = validated_query.get("filter") + channel = validated_query.get("channel") + vid_type = validated_query.get("vid_type") + + PendingInteract(status=status_filter).update_bulk( + channel_id=channel, vid_type=vid_type, new_status=new_status + ) + + if new_status == "priority": + download_pending.delay(auto_only=True) + + return Response(status=204) + @extend_schema( parameters=[DownloadListQueueDeleteQuerySerializer()], responses={ @@ -132,9 +183,18 @@ class DownloadApiListView(ApiBaseView): validated_query = serializer.validated_data query_filter = validated_query["filter"] + channel = validated_query.get("channel") + vid_type = validated_query.get("vid_type") message = f"delete queue by status: {query_filter}" + if channel: + message += f" - filter by channel: {channel}" + if vid_type: + message += f" - filter by vid_type: {vid_type}" + print(message) - PendingInteract(status=query_filter).delete_by_status() + PendingInteract(status=query_filter).delete_bulk( + channel_id=channel, vid_type=vid_type + ) return Response(status=204) diff --git a/backend/playlist/serializers.py b/backend/playlist/serializers.py index 9362f5b6..741c770d 100644 --- a/backend/playlist/serializers.py +++ b/backend/playlist/serializers.py @@ -28,6 +28,7 @@ class PlaylistSerializer(serializers.Serializer): playlist_last_refresh = serializers.CharField() playlist_name = serializers.CharField() playlist_subscribed = serializers.BooleanField() + playlist_sort_order = serializers.ChoiceField(choices=["top", "bottom"]) playlist_thumbnail = serializers.CharField() playlist_type = serializers.ChoiceField(choices=["regular", "custom"]) _index = serializers.CharField(required=False) @@ -68,7 +69,10 @@ class PlaylistBulkAddSerializer(serializers.Serializer): class PlaylistSingleUpdate(serializers.Serializer): """update state of single playlist""" - playlist_subscribed = serializers.BooleanField() + playlist_subscribed = serializers.BooleanField(required=False) + playlist_sort_order = serializers.ChoiceField( + choices=["top", "bottom"], required=False + ) class PlaylistListCustomPostSerializer(serializers.Serializer): diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py index 3533205a..165860dc 100644 --- a/backend/playlist/src/index.py +++ b/backend/playlist/src/index.py @@ -30,16 +30,24 @@ class YoutubePlaylist(YouTubeItem): self.all_members = False self.nav = False - def build_json(self, scrape=False): + def build_json(self, scrape=False, limit: int | None = None): """collection to create json_data""" self.get_from_es() if self.json_data: subscribed = self.json_data.get("playlist_subscribed") + playlist_sort_order = self.json_data.get("playlist_sort_order") else: subscribed = False + playlist_sort_order = "top" + + limit_str = limit if limit is not None else "" + sort_order = 1 if playlist_sort_order == "top" else -1 + playlist_items = f":{limit_str}:{sort_order}" if scrape or not self.json_data: - self.get_from_youtube() + self.get_from_youtube( + obs_overwrite={"playlist_items": playlist_items} + ) if not self.youtube_meta: self.json_data = False return @@ -48,8 +56,13 @@ class YoutubePlaylist(YouTubeItem): self._ensure_channel() ids_found = self.get_local_vids() self.get_entries(ids_found) - self.json_data["playlist_entries"] = self.all_members - self.json_data["playlist_subscribed"] = subscribed + self.json_data.update( + { + "playlist_entries": self.all_members, + "playlist_subscribed": subscribed, + "playlist_sort_order": playlist_sort_order, + } + ) def process_youtube_meta(self): """extract relevant fields from youtube""" @@ -59,6 +72,9 @@ class YoutubePlaylist(YouTubeItem): print(f"{self.youtube_id}: thumbnail extraction failed") playlist_thumbnail = False + if not self.youtube_meta.get("channel_id"): + raise ValueError("Failed to extract Channel ID for Playlist") + self.json_data = { "playlist_id": self.youtube_id, "playlist_active": True, @@ -79,6 +95,18 @@ class YoutubePlaylist(YouTubeItem): channel_handler = YoutubeChannel(channel_id) channel_handler.build_json(upload=True) + def get_playlist_videos(self): + """get all playlist videos""" + data = { + "query": { + "term": {"playlist.keyword": {"value": self.youtube_id}} + }, + "_source": ["youtube_id"], + } + result = IndexPaginate("ta_video", data).get_results() + + return result + def get_local_vids(self) -> list[str]: """get local video ids from youtube entries""" entries = self.youtube_meta["entries"] @@ -111,6 +139,13 @@ class YoutubePlaylist(YouTubeItem): url = self.json_data["playlist_thumbnail"] ThumbManager(self.youtube_id, item_type="playlist").download(url) + def change_subscribe(self, new_subscribe_state: bool): + """change subscribe status""" + self.build_json() + self.json_data["playlist_subscribed"] = new_subscribe_state + self.upload_to_es() + return self.json_data + def add_vids_to_playlist(self): """sync the playlist id to videos""" script = ( @@ -144,14 +179,7 @@ class YoutubePlaylist(YouTubeItem): def remove_vids_from_playlist(self): """remove playlist ids from videos if needed""" needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]] - data = { - "query": {"match": {"playlist": self.youtube_id}}, - "_source": ["youtube_id"], - } - data = { - "query": {"term": {"playlist.keyword": {"value": self.youtube_id}}} - } - result = IndexPaginate("ta_video", data).get_results() + result = self.get_playlist_videos() to_remove = [ i["youtube_id"] for i in result if i["youtube_id"] not in needed ] @@ -170,9 +198,9 @@ class YoutubePlaylist(YouTubeItem): if status_code == 200: print(f"{self.youtube_id}: removed {video_id} from playlist") - def update_playlist(self, skip_on_empty=False): + def update_playlist(self, skip_on_empty=False, limit: int | None = None): """update metadata for playlist with data from YouTube""" - self.build_json(scrape=True) + self.build_json(scrape=True, limit=limit) if not self.json_data: # return false to deactivate return False @@ -190,6 +218,15 @@ class YoutubePlaylist(YouTubeItem): self.get_playlist_art() return True + def change_sort_order(self, new_sort_order): + """update sort order of playlist""" + playlist = YoutubePlaylist(self.youtube_id) + playlist.build_json() + playlist.json_data["playlist_sort_order"] = new_sort_order + playlist.upload_to_es() + + return playlist.json_data + def build_nav(self, youtube_id): """find next and previous in playlist of a given youtube_id""" cache_root = EnvironmentSettings().get_cache_root() diff --git a/backend/playlist/views.py b/backend/playlist/views.py index 6ec102ff..bb756081 100644 --- a/backend/playlist/views.py +++ b/backend/playlist/views.py @@ -7,7 +7,6 @@ from common.serializers import ( 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, @@ -240,9 +239,25 @@ class PlaylistApiView(ApiBaseView): error = ErrorResponseSerializer({"error": "playlist not found"}) return Response(error.data, status=404) - subscribed = validated_data["playlist_subscribed"] - playlist_sub = PlaylistSubscription() - json_data = playlist_sub.change_subscribe(playlist_id, subscribed) + subscribed = validated_data.get("playlist_subscribed") + sort_order = validated_data.get("playlist_sort_order") + + json_data = None + if subscribed is not None: + json_data = YoutubePlaylist(playlist_id).change_subscribe( + new_subscribe_state=subscribed + ) + + if sort_order: + json_data = YoutubePlaylist(playlist_id).change_sort_order( + new_sort_order=sort_order + ) + + if not json_data: + error = ErrorResponseSerializer( + {"error": "expect playlist_subscribed or playlist_sort_order"} + ) + return Response(error.data, status=400) response_serializer = PlaylistSerializer(json_data) return Response(response_serializer.data) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index a5ee0352..30696ad3 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -4,7 +4,7 @@ pre-commit==4.2.0 pylint-django==2.6.1 pylint==3.3.7 pytest-django==4.11.1 -pytest==8.4.0 -python-dotenv==1.1.0 +pytest==8.4.1 +python-dotenv==1.1.1 requirementscheck==0.0.6 -types-requests==2.32.0.20250602 +types-requests==2.32.4.20250611 diff --git a/backend/requirements.txt b/backend/requirements.txt index fa21380e..b939b37f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,13 +3,13 @@ celery==5.5.3 django-auth-ldap==5.2.0 django-celery-beat==2.8.1 django-cors-headers==4.7.0 -Django==5.2.2 +Django==5.2.3 djangorestframework==3.16.0 drf-spectacular==0.28.0 Pillow==11.2.1 redis==6.2.0 -requests==2.32.3 +requests==2.32.4 ryd-client==0.0.6 -uvicorn==0.34.3 +uvicorn==0.35.0 whitenoise==6.9.0 -yt-dlp[default]==2025.5.22 +yt-dlp[default]==2025.6.30 diff --git a/backend/task/tasks.py b/backend/task/tasks.py index d879cb9e..63a843f0 100644 --- a/backend/task/tasks.py +++ b/backend/task/tasks.py @@ -39,10 +39,10 @@ class BaseTask(Task): RedisArchivist().set_message(key, message, expire=20) def on_success(self, retval, task_id, args, kwargs): - """callback task completed successfully""" + """callback task completed""" print(f"{task_id} success callback") message, key = self._build_message() - message.update({"messages": ["Task completed successfully"]}) + message.update({"messages": ["Task completed"]}) RedisArchivist().set_message(key, message, expire=5) def before_start(self, task_id, args, kwargs): @@ -58,9 +58,11 @@ class BaseTask(Task): task_title = TASK_CONFIG.get(self.name).get("title") Notifications(self.name).send(task_id, task_title) - def send_progress(self, message_lines, progress=False, title=False): + def send_progress( + self, message_lines, progress=False, title=False, level="info" + ): """send progress message""" - message, key = self._build_message() + message, key = self._build_message(level=level) message.update( { "messages": message_lines, @@ -101,13 +103,13 @@ def update_subscribed(self): manager.init(self) handler = SubscriptionScanner(task=self) - missing_videos = handler.scan() + added = handler.scan() auto_start = handler.auto_start - if missing_videos: - print(missing_videos) - extrac_dl.delay(missing_videos, auto_start=auto_start) - message = f"Found {len(missing_videos)} videos to add to the queue." - return message + if added: + if auto_start: + download_pending.delay(auto_only=True) + + return f"Found {added} videos to add to the queue." return None @@ -147,7 +149,9 @@ def download_pending(self, auto_only=False): @shared_task(name="extract_download", bind=True, base=BaseTask) -def extrac_dl(self, youtube_ids, auto_start=False, status="pending"): +def extrac_dl( + self, youtube_ids, auto_start=False, flat=False, status="pending" +): """parse list passed and add to pending""" TaskManager().init(self) if isinstance(youtube_ids, str): @@ -155,17 +159,17 @@ def extrac_dl(self, youtube_ids, auto_start=False, status="pending"): else: to_add = youtube_ids - pending_handler = PendingList(youtube_ids=to_add, task=self) - pending_handler.parse_url_list(auto_start=auto_start) - videos_added = pending_handler.add_to_pending( - status=status, auto_start=auto_start + pending_handler = PendingList( + youtube_ids=to_add, task=self, auto_start=auto_start, flat=flat ) + pending_handler.parse_url_list() + videos_added = pending_handler.add_to_pending(status=status) if auto_start: download_pending.delay(auto_only=True) if videos_added: - return f"added {len(videos_added)} Videos to Queue" + return f"added {videos_added} Videos to Queue" return None @@ -259,7 +263,7 @@ def rescan_filesystem(self): handler = Scanner(task=self) handler.scan() handler.apply() - ThumbValidator(task=self).validate() + thumbnail_check.delay() @shared_task(bind=True, name="thumbnail_check", base=BaseTask) diff --git a/backend/video/src/comments.py b/backend/video/src/comments.py index 2d13448c..4b145a75 100644 --- a/backend/video/src/comments.py +++ b/backend/video/src/comments.py @@ -79,7 +79,9 @@ class Comments: def get_yt_comments(self): """get comments from youtube""" yt_obs = self.build_yt_obs() - info_json = YtWrap(yt_obs, config=self.config).extract(self.youtube_id) + info_json, _ = YtWrap(yt_obs, config=self.config).extract( + self.youtube_id + ) if not info_json: return False, False diff --git a/backend/video/src/index.py b/backend/video/src/index.py index 6f324eb3..34e23a8c 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -10,10 +10,10 @@ from datetime import datetime import requests from channel.src import index as ta_channel from common.src.env_settings import EnvironmentSettings -from common.src.es_connect import ElasticWrap from common.src.helper import get_duration_sec, get_duration_str, randomizor from common.src.index_generic import YouTubeItem from django.conf import settings +from download.src.thumbnails import ThumbManager from playlist.src import index as ta_playlist from ryd_client import ryd_client from user.src.user_config import UserConfig @@ -394,12 +394,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): return subtitles - def update_media_url(self): - """update only media_url in es for reindex channel rename""" - data = {"doc": {"media_url": self.json_data["media_url"]}} - path = f"{self.index_name}/_update/{self.youtube_id}" - _, _ = ElasticWrap(path).post(data=data) - def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS): """combined classes to create new video in index""" @@ -409,5 +403,8 @@ def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS): raise ValueError("failed to get metadata for " + youtube_id) video.check_subtitles() + url = video.json_data["vid_thumb_url"] + ThumbManager(item_id=video.youtube_id).download_video_thumb(url=url) video.upload_to_es() + return video.json_data diff --git a/backend/video/views.py b/backend/video/views.py index a679552c..ef5c011a 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -227,7 +227,8 @@ class VideoProgressView(ApiBaseView): expire = False current_progress.update({"watched": watched}) - redis_con.set_message(key, current_progress, expire=expire) + if position > 5: + redis_con.set_message(key, current_progress, expire=expire) response_serializer = PlayerSerializer(current_progress) diff --git a/docker_assets/run.sh b/docker_assets/run.sh index 9f065d7b..b09bee22 100644 --- a/docker_assets/run.sh +++ b/docker_assets/run.sh @@ -9,6 +9,15 @@ else LOGLEVEL="INFO" fi +# update yt-dlp if needed +if [[ "${TA_AUTO_UPDATE_YTDLP,,}" =~ ^(release|nightly)$ ]]; then + echo "Updating yt-dlp..." + preflag=$([[ "${TA_AUTO_UPDATE_YTDLP,,}" == "nightly" ]] && echo "--pre" || echo "") + python -m pip install --target=/root/.local/bin --upgrade $preflag "yt-dlp[default]" || { + echo "yt-dlp update failed" + } +fi + # stop on pending manual migration python manage.py ta_stop_on_error diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5be2f31..44cefb06 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,24 +11,24 @@ "dompurify": "^3.2.6", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router-dom": "^7.6.0", - "zustand": "^5.0.5" + "react-router-dom": "^7.6.3", + "zustand": "^5.0.6" }, "devDependencies": { - "@types/react": "^19.1.5", - "@types/react-dom": "^19.1.5", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", - "@vitejs/plugin-react-swc": "^3.10.0", - "eslint": "^9.27.0", + "@vitejs/plugin-react-swc": "^3.10.2", + "eslint": "^9.30.1", "eslint-config-prettier": "^10.1.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.1.0", - "prettier": "3.5.3", + "globals": "^16.3.0", + "prettier": "3.6.2", "typescript": "^5.8.3", "typescript-eslint": "^8.32.1", - "vite": ">=6.3.5", + "vite": ">=7.0.0", "vite-plugin-checker": "^0.9.3" } }, @@ -512,9 +512,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -527,9 +527,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -551,9 +551,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -598,9 +598,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -635,9 +635,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", - "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", "dev": true, "license": "MIT", "engines": { @@ -776,16 +776,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", - "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", + "integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz", - "integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", + "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", "cpu": [ "arm" ], @@ -797,9 +797,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz", - "integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", + "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", "cpu": [ "arm64" ], @@ -811,9 +811,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz", - "integrity": "sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", + "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", "cpu": [ "arm64" ], @@ -825,9 +825,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz", - "integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", + "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", "cpu": [ "x64" ], @@ -839,9 +839,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz", - "integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", + "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", "cpu": [ "arm64" ], @@ -853,9 +853,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz", - "integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", + "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", "cpu": [ "x64" ], @@ -867,9 +867,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz", - "integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", + "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", "cpu": [ "arm" ], @@ -881,9 +881,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz", - "integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", + "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", "cpu": [ "arm" ], @@ -895,9 +895,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz", - "integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", + "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", "cpu": [ "arm64" ], @@ -909,9 +909,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz", - "integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", + "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", "cpu": [ "arm64" ], @@ -923,9 +923,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz", - "integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", + "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", "cpu": [ "loong64" ], @@ -937,9 +937,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz", - "integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", + "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", "cpu": [ "ppc64" ], @@ -951,9 +951,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz", - "integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", + "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", "cpu": [ "riscv64" ], @@ -965,9 +965,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz", - "integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", + "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", "cpu": [ "riscv64" ], @@ -979,9 +979,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz", - "integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", + "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", "cpu": [ "s390x" ], @@ -993,9 +993,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz", - "integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", + "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", "cpu": [ "x64" ], @@ -1007,9 +1007,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz", - "integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", + "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", "cpu": [ "x64" ], @@ -1021,9 +1021,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz", - "integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", + "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", "cpu": [ "arm64" ], @@ -1035,9 +1035,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz", - "integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", + "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", "cpu": [ "ia32" ], @@ -1049,9 +1049,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz", - "integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", + "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", "cpu": [ "x64" ], @@ -1063,15 +1063,15 @@ ] }, "node_modules/@swc/core": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.29.tgz", - "integrity": "sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.9.tgz", + "integrity": "sha512-O+LfT2JlVMsIMWG9x+rdxg8GzpzeGtCZQfXV7cKc1PjIKUkLFf1QJ7okuseA4f/9vncu37dQ2ZcRrPKy0Ndd5g==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.21" + "@swc/types": "^0.1.23" }, "engines": { "node": ">=10" @@ -1081,16 +1081,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.11.29", - "@swc/core-darwin-x64": "1.11.29", - "@swc/core-linux-arm-gnueabihf": "1.11.29", - "@swc/core-linux-arm64-gnu": "1.11.29", - "@swc/core-linux-arm64-musl": "1.11.29", - "@swc/core-linux-x64-gnu": "1.11.29", - "@swc/core-linux-x64-musl": "1.11.29", - "@swc/core-win32-arm64-msvc": "1.11.29", - "@swc/core-win32-ia32-msvc": "1.11.29", - "@swc/core-win32-x64-msvc": "1.11.29" + "@swc/core-darwin-arm64": "1.12.9", + "@swc/core-darwin-x64": "1.12.9", + "@swc/core-linux-arm-gnueabihf": "1.12.9", + "@swc/core-linux-arm64-gnu": "1.12.9", + "@swc/core-linux-arm64-musl": "1.12.9", + "@swc/core-linux-x64-gnu": "1.12.9", + "@swc/core-linux-x64-musl": "1.12.9", + "@swc/core-win32-arm64-msvc": "1.12.9", + "@swc/core-win32-ia32-msvc": "1.12.9", + "@swc/core-win32-x64-msvc": "1.12.9" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -1102,9 +1102,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.29.tgz", - "integrity": "sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.9.tgz", + "integrity": "sha512-GACFEp4nD6V+TZNR2JwbMZRHB+Yyvp14FrcmB6UCUYmhuNWjkxi+CLnEvdbuiKyQYv0zA+TRpCHZ+whEs6gwfA==", "cpu": [ "arm64" ], @@ -1119,9 +1119,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.29.tgz", - "integrity": "sha512-S3eTo/KYFk+76cWJRgX30hylN5XkSmjYtCBnM4jPLYn7L6zWYEPajsFLmruQEiTEDUg0gBEWLMNyUeghtswouw==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.12.9.tgz", + "integrity": "sha512-hv2kls7Ilkm2EpeJz+I9MCil7pGS3z55ZAgZfxklEuYsxpICycxeH+RNRv4EraggN44ms+FWCjtZFu0LGg2V3g==", "cpu": [ "x64" ], @@ -1136,9 +1136,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.29.tgz", - "integrity": "sha512-o9gdshbzkUMG6azldHdmKklcfrcMx+a23d/2qHQHPDLUPAN+Trd+sDQUYArK5Fcm7TlpG4sczz95ghN0DMkM7g==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.9.tgz", + "integrity": "sha512-od9tDPiG+wMU9wKtd6y3nYJdNqgDOyLdgRRcrj1/hrbHoUPOM8wZQZdwQYGarw63iLXGgsw7t5HAF9Yc51ilFA==", "cpu": [ "arm" ], @@ -1153,9 +1153,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.29.tgz", - "integrity": "sha512-sLoaciOgUKQF1KX9T6hPGzvhOQaJn+3DHy4LOHeXhQqvBgr+7QcZ+hl4uixPKTzxk6hy6Hb0QOvQEdBAAR1gXw==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.9.tgz", + "integrity": "sha512-6qx1ka9LHcLzxIgn2Mros+CZLkHK2TawlXzi/h7DJeNnzi8F1Hw0Yzjp8WimxNCg6s2n+o3jnmin1oXB7gg8rw==", "cpu": [ "arm64" ], @@ -1170,9 +1170,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.29.tgz", - "integrity": "sha512-PwjB10BC0N+Ce7RU/L23eYch6lXFHz7r3NFavIcwDNa/AAqywfxyxh13OeRy+P0cg7NDpWEETWspXeI4Ek8otw==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.9.tgz", + "integrity": "sha512-yghFZWKPVVGbUdqiD7ft23G0JX6YFGDJPz9YbLLAwGuKZ9th3/jlWoQDAw1Naci31LQhVC+oIji6ozihSuwB2A==", "cpu": [ "arm64" ], @@ -1187,9 +1187,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.29.tgz", - "integrity": "sha512-i62vBVoPaVe9A3mc6gJG07n0/e7FVeAvdD9uzZTtGLiuIfVfIBta8EMquzvf+POLycSk79Z6lRhGPZPJPYiQaA==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.9.tgz", + "integrity": "sha512-SFUxyhWLZRNL8QmgGNqdi2Q43PNyFVkRZ2zIif30SOGFSxnxcf2JNeSeBgKIGVgaLSuk6xFVVCtJ3KIeaStgRg==", "cpu": [ "x64" ], @@ -1204,9 +1204,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.29.tgz", - "integrity": "sha512-YER0XU1xqFdK0hKkfSVX1YIyCvMDI7K07GIpefPvcfyNGs38AXKhb2byySDjbVxkdl4dycaxxhRyhQ2gKSlsFQ==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.9.tgz", + "integrity": "sha512-9FB0wM+6idCGTI20YsBNBg9xSWtkDBymnpaTCsZM3qDc0l4uOpJMqbfWhQvp17x7r/ulZfb2QY8RDvQmCL6AcQ==", "cpu": [ "x64" ], @@ -1221,9 +1221,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.29.tgz", - "integrity": "sha512-po+WHw+k9g6FAg5IJ+sMwtA/fIUL3zPQ4m/uJgONBATCVnDDkyW6dBA49uHNVtSEvjvhuD8DVWdFP847YTcITw==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.9.tgz", + "integrity": "sha512-zHOusMVbOH9ik5RtRrMiGzLpKwxrPXgXkBm3SbUCa65HAdjV33NZ0/R9Rv1uPESALtEl2tzMYLUxYA5ECFDFhA==", "cpu": [ "arm64" ], @@ -1238,9 +1238,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.29.tgz", - "integrity": "sha512-h+NjOrbqdRBYr5ItmStmQt6x3tnhqgwbj9YxdGPepbTDamFv7vFnhZR0YfB3jz3UKJ8H3uGJ65Zw1VsC+xpFkg==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.9.tgz", + "integrity": "sha512-aWZf0PqE0ot7tCuhAjRkDFf41AzzSQO0x2xRfTbnhpROp57BRJ/N5eee1VULO/UA2PIJRG7GKQky5bSGBYlFug==", "cpu": [ "ia32" ], @@ -1255,9 +1255,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.11.29", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.29.tgz", - "integrity": "sha512-Q8cs2BDV9wqDvqobkXOYdC+pLUSEpX/KvI0Dgfun1F+LzuLotRFuDhrvkU9ETJA6OnD2+Fn/ieHgloiKA/Mn/g==", + "version": "1.12.9", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.9.tgz", + "integrity": "sha512-C25fYftXOras3P3anSUeXXIpxmEkdAcsIL9yrr0j1xepTZ/yKwpnQ6g3coj8UXdeJy4GTVlR6+Ow/QiBgZQNOg==", "cpu": [ "x64" ], @@ -1279,9 +1279,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.21", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz", - "integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==", + "version": "0.1.23", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz", + "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1289,9 +1289,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -1303,9 +1303,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz", - "integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==", + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1313,9 +1313,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.1.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", - "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1533,23 +1533,23 @@ } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.0.tgz", - "integrity": "sha512-ZmkdHw3wo/o/Rk05YsXZs/DJAfY2CdQ5DUAjoWji+PEr+hYADdGMCGgEAILbiKj+CjspBTuTACBcWDrmC8AUfw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.2.tgz", + "integrity": "sha512-xD3Rdvrt5LgANug7WekBn1KhcvLn1H3jNBfJRL3reeOIua/WnZOEV5qi5qIBq5T8R0jUDmRtxuvk4bPhzGHDWw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.9", - "@swc/core": "^1.11.22" + "@rolldown/pluginutils": "1.0.0-beta.11", + "@swc/core": "^1.11.31" }, "peerDependencies": { - "vite": "^4 || ^5 || ^6" + "vite": "^4 || ^5 || ^6 || ^7.0.0-beta.0" } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -1630,9 +1630,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1722,6 +1722,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1833,19 +1842,19 @@ } }, "node_modules/eslint": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", - "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.27.0", + "@eslint/js": "9.30.1", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -1857,9 +1866,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -1933,9 +1942,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1963,9 +1972,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1974,9 +1983,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2000,15 +2009,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2018,9 +2027,9 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2230,9 +2239,9 @@ } }, "node_modules/globals": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", - "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true, "license": "MIT", "engines": { @@ -2631,9 +2640,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -2651,7 +2660,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2670,9 +2679,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { @@ -2738,9 +2747,9 @@ } }, "node_modules/react-router": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", - "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz", + "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2760,12 +2769,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", - "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.3.tgz", + "integrity": "sha512-DiWJm9qdUAmiJrVWaeJdu4TKu13+iB/8IEi0EW/XgaHCjW/vWGrwzup0GVvaMteuZjKnh5bEvJP/K0MDnzawHw==", "license": "MIT", "dependencies": { - "react-router": "7.6.0" + "react-router": "7.6.3" }, "engines": { "node": ">=20.0.0" @@ -2775,15 +2784,6 @@ "react-dom": ">=18" } }, - "node_modules/react-router/node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2820,13 +2820,13 @@ } }, "node_modules/rollup": { - "version": "4.39.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz", - "integrity": "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", + "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -2836,26 +2836,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.39.0", - "@rollup/rollup-android-arm64": "4.39.0", - "@rollup/rollup-darwin-arm64": "4.39.0", - "@rollup/rollup-darwin-x64": "4.39.0", - "@rollup/rollup-freebsd-arm64": "4.39.0", - "@rollup/rollup-freebsd-x64": "4.39.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.39.0", - "@rollup/rollup-linux-arm-musleabihf": "4.39.0", - "@rollup/rollup-linux-arm64-gnu": "4.39.0", - "@rollup/rollup-linux-arm64-musl": "4.39.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.39.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.39.0", - "@rollup/rollup-linux-riscv64-gnu": "4.39.0", - "@rollup/rollup-linux-riscv64-musl": "4.39.0", - "@rollup/rollup-linux-s390x-gnu": "4.39.0", - "@rollup/rollup-linux-x64-gnu": "4.39.0", - "@rollup/rollup-linux-x64-musl": "4.39.0", - "@rollup/rollup-win32-arm64-msvc": "4.39.0", - "@rollup/rollup-win32-ia32-msvc": "4.39.0", - "@rollup/rollup-win32-x64-msvc": "4.39.0", + "@rollup/rollup-android-arm-eabi": "4.44.1", + "@rollup/rollup-android-arm64": "4.44.1", + "@rollup/rollup-darwin-arm64": "4.44.1", + "@rollup/rollup-darwin-x64": "4.44.1", + "@rollup/rollup-freebsd-arm64": "4.44.1", + "@rollup/rollup-freebsd-x64": "4.44.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", + "@rollup/rollup-linux-arm-musleabihf": "4.44.1", + "@rollup/rollup-linux-arm64-gnu": "4.44.1", + "@rollup/rollup-linux-arm64-musl": "4.44.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-musl": "4.44.1", + "@rollup/rollup-linux-s390x-gnu": "4.44.1", + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1", + "@rollup/rollup-win32-arm64-msvc": "4.44.1", + "@rollup/rollup-win32-ia32-msvc": "4.44.1", + "@rollup/rollup-win32-x64-msvc": "4.44.1", "fsevents": "~2.3.2" } }, @@ -2991,9 +2991,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3135,24 +3135,24 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", + "fdir": "^6.4.6", "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3161,14 +3161,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -3285,9 +3285,9 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3359,9 +3359,9 @@ } }, "node_modules/zustand": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz", - "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz", + "integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index 6d205445..2407f73b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,24 +14,24 @@ "dompurify": "^3.2.6", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router-dom": "^7.6.0", - "zustand": "^5.0.5" + "react-router-dom": "^7.6.3", + "zustand": "^5.0.6" }, "devDependencies": { - "@types/react": "^19.1.5", - "@types/react-dom": "^19.1.5", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", - "@vitejs/plugin-react-swc": "^3.10.0", - "eslint": "^9.27.0", + "@vitejs/plugin-react-swc": "^3.10.2", + "eslint": "^9.30.1", "eslint-config-prettier": "^10.1.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.1.0", - "prettier": "3.5.3", + "globals": "^16.3.0", + "prettier": "3.6.2", "typescript": "^5.8.3", "typescript-eslint": "^8.32.1", - "vite": ">=6.3.5", + "vite": ">=7.0.0", "vite-plugin-checker": "^0.9.3" } } diff --git a/frontend/src/api/actions/deleteAppriseNotificationUrl.ts b/frontend/src/api/actions/deleteAppriseNotificationUrl.ts index 5018b79f..33153c49 100644 --- a/frontend/src/api/actions/deleteAppriseNotificationUrl.ts +++ b/frontend/src/api/actions/deleteAppriseNotificationUrl.ts @@ -6,10 +6,10 @@ type AppriseTaskNameType = | 'download_pending' | 'check_reindex'; -const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType) => { +const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => { return APIClient('/api/task/notification/', { method: 'DELETE', - body: { task_name: taskName }, + body: { task_name: taskName, url: url }, }); }; diff --git a/frontend/src/api/actions/deleteDownloadQueueByFilter.ts b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts index 65a01a3c..9d4f5902 100644 --- a/frontend/src/api/actions/deleteDownloadQueueByFilter.ts +++ b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts @@ -2,9 +2,15 @@ import APIClient from '../../functions/APIClient'; type FilterType = 'ignore' | 'pending'; -const deleteDownloadQueueByFilter = async (filter: FilterType) => { +const deleteDownloadQueueByFilter = async ( + filter: FilterType, + channel: string | null, + vid_type: string | null, +) => { const searchParams = new URLSearchParams(); if (filter) searchParams.append('filter', filter); + if (channel) searchParams.append('channel', channel); + if (vid_type) searchParams.append('vid_type', vid_type); return APIClient(`/api/download/?${searchParams.toString()}`, { method: 'DELETE', diff --git a/frontend/src/api/actions/updateDownloadQueue.ts b/frontend/src/api/actions/updateDownloadQueue.ts index 071707df..beb14120 100644 --- a/frontend/src/api/actions/updateDownloadQueue.ts +++ b/frontend/src/api/actions/updateDownloadQueue.ts @@ -1,6 +1,6 @@ import APIClient from '../../functions/APIClient'; -const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) => { +const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean, flat: boolean) => { const urls = []; const containsMultiple = youtubeIdStrings.includes('\n'); @@ -16,12 +16,12 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) urls.push({ youtube_id: youtubeIdStrings, status: 'pending' }); } - let params = ''; - if (autostart) { - params = '?autostart=true'; - } + const searchParams = new URLSearchParams(); + if (autostart) searchParams.append('autostart', 'true'); + if (flat) searchParams.append('flat', 'true'); + const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; - return APIClient(`/api/download/${params}`, { + return APIClient(endpoint, { method: 'POST', body: { data: [...urls] }, }); diff --git a/frontend/src/api/actions/updateDownloadQueueByFilter.ts b/frontend/src/api/actions/updateDownloadQueueByFilter.ts new file mode 100644 index 00000000..1410360e --- /dev/null +++ b/frontend/src/api/actions/updateDownloadQueueByFilter.ts @@ -0,0 +1,23 @@ +import APIClient from '../../functions/APIClient'; + +type FilterType = 'ignore' | 'pending'; +export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority'; + +const updateDownloadQueueByFilter = async ( + filter: FilterType, + channel: string | null, + vid_type: string | null, + status: DownloadQueueStatus, +) => { + const searchParams = new URLSearchParams(); + if (filter) searchParams.append('filter', filter); + if (channel) searchParams.append('channel', channel); + if (vid_type) searchParams.append('vid_type', vid_type); + + return APIClient(`/api/download/?${searchParams.toString()}`, { + method: 'PATCH', + body: { status: status }, + }); +}; + +export default updateDownloadQueueByFilter; diff --git a/frontend/src/api/actions/updatePlaylistSortOrder.ts b/frontend/src/api/actions/updatePlaylistSortOrder.ts new file mode 100644 index 00000000..a7234da2 --- /dev/null +++ b/frontend/src/api/actions/updatePlaylistSortOrder.ts @@ -0,0 +1,10 @@ +import APIClient from '../../functions/APIClient'; + +const updatePlaylistSortOrder = async (playlistId: string, newSortOrder: 'top' | 'bottom') => { + return APIClient(`/api/playlist/${playlistId}/`, { + method: 'POST', + body: { playlist_sort_order: newSortOrder }, + }); +}; + +export default updatePlaylistSortOrder; diff --git a/frontend/src/api/loader/loadAppsettingsConfig.ts b/frontend/src/api/loader/loadAppsettingsConfig.ts index 44f22e8a..3a6489fd 100644 --- a/frontend/src/api/loader/loadAppsettingsConfig.ts +++ b/frontend/src/api/loader/loadAppsettingsConfig.ts @@ -6,6 +6,7 @@ export type AppSettingsConfigType = { live_channel_size: number | null; shorts_channel_size: number | null; auto_start: boolean; + extract_flat: boolean; }; downloads: { limit_speed: number | null; diff --git a/frontend/src/api/loader/loadDownloadQueue.ts b/frontend/src/api/loader/loadDownloadQueue.ts index d74f4bae..367638ab 100644 --- a/frontend/src/api/loader/loadDownloadQueue.ts +++ b/frontend/src/api/loader/loadDownloadQueue.ts @@ -1,11 +1,21 @@ import APIClient from '../../functions/APIClient'; import { DownloadResponseType } from '../../pages/Download'; -const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => { +const loadDownloadQueue = async ( + page: number, + channelId: string | null, + vid_type: string | null, + errorFilterFromUrl: string | null, + showIgnored: boolean, + search: string, +) => { const searchParams = new URLSearchParams(); if (page) searchParams.append('page', page.toString()); if (channelId) searchParams.append('channel', channelId); + if (vid_type) searchParams.append('vid_type', vid_type); + if (search) searchParams.append('q', encodeURIComponent(search)); + if (errorFilterFromUrl !== null) searchParams.append('error', errorFilterFromUrl); searchParams.append('filter', showIgnored ? 'ignore' : 'pending'); const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; diff --git a/frontend/src/api/loader/loadPlaylistById.ts b/frontend/src/api/loader/loadPlaylistById.ts index b800410f..5a339baa 100644 --- a/frontend/src/api/loader/loadPlaylistById.ts +++ b/frontend/src/api/loader/loadPlaylistById.ts @@ -14,6 +14,7 @@ export type PlaylistType = { playlist_channel_id: string; playlist_description: string; playlist_entries: PlaylistEntryType[]; + playlist_sort_order: 'top' | 'bottom'; playlist_id: string; playlist_last_refresh: string; playlist_name: string; diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx index c80c70bb..f7f71379 100644 --- a/frontend/src/components/CommentBox.tsx +++ b/frontend/src/components/CommentBox.tsx @@ -37,9 +37,10 @@ export type CommentsType = { type CommentBoxProps = { comment: CommentsType; + onTimestampClick?: (seconds: number) => void; }; -const CommentBox = ({ comment }: CommentBoxProps) => { +const CommentBox = ({ comment, onTimestampClick }: CommentBoxProps) => { const [showSubComments, setShowSubComments] = useState(false); const hasSubComments = @@ -51,7 +52,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => { {comment.comment_author}

- {comment.comment_text} + {comment.comment_text}

@@ -95,7 +96,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => { comment.comment_replies?.map(comment => { return ( - + ); })} diff --git a/frontend/src/components/DownloadListItem.tsx b/frontend/src/components/DownloadListItem.tsx index bb0a82e3..cd540c87 100644 --- a/frontend/src/components/DownloadListItem.tsx +++ b/frontend/src/components/DownloadListItem.tsx @@ -47,14 +47,19 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => { {!download.channel_indexed && {download.channel_name}} - +

{download.title}

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

{download.message &&

{download.message}

} diff --git a/frontend/src/components/InputConfig.tsx b/frontend/src/components/InputConfig.tsx index a378a5fb..cf1bfa13 100644 --- a/frontend/src/components/InputConfig.tsx +++ b/frontend/src/components/InputConfig.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import LoadingIndicator from './LoadingIndicator'; type InputTextProps = { type: 'text' | 'number'; @@ -51,13 +52,7 @@ const InputConfig = ({ type, name, value, setValue, oldValue, updateCallback }: )} {oldValue !== null && } - {loading && ( - <> -
-
-
- - )} + {loading && } {success && }
diff --git a/frontend/src/components/Linkify.tsx b/frontend/src/components/Linkify.tsx index a9d1d839..57dd92cb 100644 --- a/frontend/src/components/Linkify.tsx +++ b/frontend/src/components/Linkify.tsx @@ -3,17 +3,61 @@ import DOMPurify from 'dompurify'; type LinkifyProps = { children: string; ignoreLineBreak?: boolean; + onTimestampClick?: (seconds: number) => void; }; // source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/ -const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => { +const Linkify = ({ children, ignoreLineBreak = false, onTimestampClick }: LinkifyProps) => { const isUrl = (word: string) => { const urlPattern = /(https?:\/\/[^\s]+)/g; return word.match(urlPattern); }; + const isTimestamp = (word: string) => { + const timestampPattern = /^(\d{1,}:)?(\d{1,2}):(\d{1,2})$/; + return word.match(timestampPattern); + }; + + const parseTimestamp = (timestamp: string): number => { + const parts = timestamp.split(':').map(part => parseInt(part, 10)); + + if (parts.length === 2) { + // MM:SS format + const [minutes, seconds] = parts; + return minutes * 60 + seconds; + } + + if (parts.length === 3) { + // HH:MM:SS format + const [hours, minutes, seconds] = parts; + return hours * 3600 + minutes * 60 + seconds; + } + + return 0; + }; + const addMarkup = (word: string) => { - return isUrl(word) ? `${word}` : word; + if (isUrl(word)) { + return `${word}`; + } + + if (isTimestamp(word) && onTimestampClick) { + const seconds = parseTimestamp(word); + return `${word}`; + } + + return word; + }; + + const handleClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + if (target.classList.contains('timestamp-link') && onTimestampClick) { + const timestamp = target.getAttribute('data-timestamp'); + if (timestamp) { + const seconds = parseInt(timestamp, 10); + onTimestampClick(seconds); + } + } }; let workingText = children; @@ -26,9 +70,11 @@ const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => { const formatedWords = words.map(w => addMarkup(w)); - const html = DOMPurify.sanitize(formatedWords.join(' ')); + const html = DOMPurify.sanitize(formatedWords.join(' '), { + ADD_ATTR: ['target'], + }); - return ; + return ; }; export default Linkify; diff --git a/frontend/src/components/LoadingIndicator.tsx b/frontend/src/components/LoadingIndicator.tsx new file mode 100644 index 00000000..0eaeaeb4 --- /dev/null +++ b/frontend/src/components/LoadingIndicator.tsx @@ -0,0 +1,9 @@ +const LoadingIndicator = () => { + return ( +
+
+
+ ); +}; + +export default LoadingIndicator; diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx index 2e17792e..1c181c6e 100644 --- a/frontend/src/components/SearchExampleQueries.tsx +++ b/frontend/src/components/SearchExampleQueries.tsx @@ -95,8 +95,8 @@ const SearchExampleQueries = () => { full: — search in video subtitles
  • - lang: — subtitles language (use two-letter ISO country code, same as - the one from settings page) + lang: — subtitles language (use two-letter ISO 639 language code, + same as the one from settings page)
  • source: diff --git a/frontend/src/components/VideoListItemTable.tsx b/frontend/src/components/VideoListItemTable.tsx index afd62216..3542406c 100644 --- a/frontend/src/components/VideoListItemTable.tsx +++ b/frontend/src/components/VideoListItemTable.tsx @@ -6,6 +6,11 @@ import humanFileSize from '../functions/humanFileSize'; import { FileSizeUnits } from '../api/actions/updateUserConfig'; import { useUserConfigStore } from '../stores/UserConfigStore'; +const StreamsTypeEmun = { + Video: 'video', + Audio: 'audio', +}; + type VideoListItemProps = { videoList: VideoType[] | undefined; viewStyle: ViewStylesType; @@ -35,7 +40,8 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => { {videoList?.map(({ youtube_id, title, channel, vid_type, media_size, streams }) => { - const [videoStream, audioStream] = streams; + const videoStream = streams?.find(s => s.type === StreamsTypeEmun.Video); + const audioStream = streams?.find(s => s.type === StreamsTypeEmun.Audio); return ( @@ -46,12 +52,12 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => { {title} {vid_type} - {`${videoStream.width}x${videoStream.height}`} + {`${videoStream?.width || '-'}x${videoStream?.height || '-'}`} {humanFileSize(media_size, useSiUnits)} - {videoStream.codec} - {humanFileSize(videoStream.bitrate, useSiUnits)} - {audioStream.codec} - {humanFileSize(audioStream.bitrate, useSiUnits)} + {videoStream?.codec || '-'} + {humanFileSize(videoStream?.bitrate || 0, useSiUnits)} + {audioStream?.codec || '-'} + {humanFileSize(audioStream?.bitrate || 0, useSiUnits)} ); })} diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index 7684e24a..d6c66c15 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -111,6 +111,8 @@ type VideoPlayerProps = { autoplay?: boolean; onWatchStateChanged?: (status: boolean) => void; onVideoEnd?: () => void; + seekToTimestamp?: number; + setSeekToTimestamp?: (timestamp: number | undefined) => void; }; const VideoPlayer = ({ @@ -120,9 +122,30 @@ const VideoPlayer = ({ autoplay = false, onWatchStateChanged, onVideoEnd, + seekToTimestamp, + setSeekToTimestamp, }: VideoPlayerProps) => { const videoRef = useRef(null); + useEffect(() => { + if (seekToTimestamp === undefined || !videoRef.current) { + return; + } + + const videoDuration = videoRef.current.duration; + if (isNaN(videoDuration) || seekToTimestamp > videoDuration) { + return; + } + + videoRef.current.currentTime = seekToTimestamp; + + if (videoRef.current.paused) { + videoRef.current.play(); + } + if (setSeekToTimestamp) setSeekToTimestamp(undefined); + window.scroll(0, 0); + }, [seekToTimestamp]); + const [searchParams] = useSearchParams(); const searchParamVideoProgress = searchParams.get('t'); @@ -140,6 +163,8 @@ const VideoPlayer = ({ const [showHelpDialog, setShowHelpDialog] = useState(false); const [showInfoDialog, setShowInfoDialog] = useState(false); const [infoDialogContent, setInfoDialogContent] = useState(''); + const [isTheaterMode, setIsTheaterMode] = useState(false); + const [theaterModeKeyPressed, setTheaterModeKeyPressed] = useState(false); const questionmarkPressed = useKeyPress('?'); const mutePressed = useKeyPress('m'); @@ -151,6 +176,8 @@ const VideoPlayer = ({ const arrowRightPressed = useKeyPress('ArrowRight'); const arrowLeftPressed = useKeyPress('ArrowLeft'); const pPausedPressed = useKeyPress('p'); + const theaterModePressed = useKeyPress('t'); + const escapePressed = useKeyPress('Escape'); const videoId = video.youtube_id; const videoUrl = video.media_url; @@ -345,10 +372,42 @@ const VideoPlayer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [questionmarkPressed]); + useEffect(() => { + if (embed) { + return; + } + + if (theaterModePressed && !theaterModeKeyPressed) { + setTheaterModeKeyPressed(true); + + const newTheaterMode = !isTheaterMode; + setIsTheaterMode(newTheaterMode); + + infoDialog(newTheaterMode ? 'Theater mode' : 'Normal mode'); + } else if (!theaterModePressed) { + setTheaterModeKeyPressed(false); + } + }, [theaterModePressed, isTheaterMode, theaterModeKeyPressed]); + + useEffect(() => { + if (embed) { + return; + } + + if (escapePressed && isTheaterMode) { + setIsTheaterMode(false); + + infoDialog('Normal mode'); + } + }, [escapePressed, isTheaterMode]); + return ( <> -
    -
    +
    +