diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py index 70564465..734b3131 100644 --- a/backend/appsettings/serializers.py +++ b/backend/appsettings/serializers.py @@ -100,6 +100,20 @@ class PoTokenSerializer(serializers.Serializer): potoken = serializers.CharField() +class RescanFileSystemConfig(serializers.Serializer): + """serialize rescan filesystem config""" + + ignore_error = serializers.BooleanField() + prefer_local = serializers.BooleanField() + + +class ManualImportConfig(serializers.Serializer): + """serialize for manual import task""" + + ignore_error = serializers.BooleanField() + prefer_local = serializers.BooleanField() + + class SnapshotItemSerializer(serializers.Serializer): """serialize snapshot response""" diff --git a/backend/appsettings/src/filesystem.py b/backend/appsettings/src/filesystem.py index 1cf02cb0..13324331 100644 --- a/backend/appsettings/src/filesystem.py +++ b/backend/appsettings/src/filesystem.py @@ -5,11 +5,13 @@ Functionality: import os +from appsettings.src.config import AppConfig from common.src.env_settings import EnvironmentSettings from common.src.es_connect import IndexPaginate -from common.src.helper import ignore_filelist -from video.src.comments import CommentList +from common.src.helper import ignore_filelist, rand_sleep +from video.src.comments import Comments from video.src.index import YoutubeVideo, index_new_video +from video.src.meta_embed import IndexFromEmbed class Scanner: @@ -17,19 +19,27 @@ class Scanner: VIDEOS: str = EnvironmentSettings.MEDIA_DIR - def __init__(self, task=False) -> None: + def __init__( + self, + task=False, + ignore_error: bool = False, + prefer_local: bool = False, + ) -> None: self.task = task - self.to_delete: set[str] = set() - self.to_index: set[str] = set() + self.ignore_error = ignore_error + self.prefer_local = prefer_local + self.config = None + self.to_delete: set[tuple[str, str]] = set() + self.to_index: set[tuple[str, str]] = set() def scan(self) -> None: """scan the filesystem""" - downloaded: set[str] = self._get_downloaded() - indexed: set[str] = self._get_indexed() + downloaded = self._get_downloaded() + indexed = self._get_indexed() self.to_index = downloaded - indexed self.to_delete = indexed - downloaded - def _get_downloaded(self) -> set[str]: + def _get_downloaded(self) -> set[tuple[str, str]]: """get downloaded ids""" if self.task: self.task.send_progress(["Scan your filesystem for videos."]) @@ -39,28 +49,40 @@ class Scanner: for channel in channels: folder = os.path.join(self.VIDEOS, channel) files = ignore_filelist(os.listdir(folder)) - downloaded.update({i.split(".")[0] for i in files}) + downloaded.update( + { + (i.split(".")[0], f"{channel}/{i}") + for i in files + if i.endswith(".mp4") + } + ) return downloaded - def _get_indexed(self) -> set: + def _get_indexed(self) -> set[tuple[str, str]]: """get all indexed ids""" if self.task: self.task.send_progress(["Get all videos indexed."]) - data = {"query": {"match_all": {}}, "_source": ["youtube_id"]} + data = { + "query": {"match_all": {}}, + "_source": ["youtube_id", "media_url"], + } response = IndexPaginate("ta_video", data).get_results() - return {i["youtube_id"] for i in response} + return {(i["youtube_id"], i["media_url"]) for i in response} def apply(self) -> None: """apply all changes""" + if not self.config: + self.config = AppConfig().config + self.delete() self.index() def delete(self) -> None: """delete videos from index""" if not self.to_delete: - print("nothing to delete") + print("[scanner] nothing to delete") return if self.task: @@ -68,26 +90,69 @@ class Scanner: [f"Remove {len(self.to_delete)} videos from index."] ) - for youtube_id in self.to_delete: + for youtube_id, _ in self.to_delete: YoutubeVideo(youtube_id).delete_media_file() def index(self) -> None: """index new""" if not self.to_index: - print("nothing to index") + print("[scanner] nothing to index") return total = len(self.to_index) - for idx, youtube_id in enumerate(self.to_index): - if self.task: - self.task.send_progress( - message_lines=[ - f"Index missing video {youtube_id}, {idx + 1}/{total}" - ], - progress=(idx + 1) / total, - ) - index_new_video(youtube_id) + for idx, (youtube_id, media_url) in enumerate(self.to_index): + self._notify(total, youtube_id, idx) - comment_list = CommentList(task=self.task) - comment_list.add(video_ids=list(self.to_index)) - comment_list.index() + file_path = os.path.join(self.VIDEOS, media_url) + if self.prefer_local: + # try index from embed + json_data = IndexFromEmbed( + file_path, use_user_conf=True, config=self.config + ).run_index() + if json_data: + continue + + try: + # try index from remote + json_data = index_new_video(youtube_id) + Comments(youtube_id).build_json(upload=True) + YoutubeVideo(youtube_id).embed_metadata() + rand_sleep(self.config) + except ValueError as err: + # fallback from index from embed + json_data = IndexFromEmbed( + file_path, use_user_conf=True, config=self.config + ).run_index() + if json_data: + continue + + if self.ignore_error: + self._notify_error(youtube_id) + rand_sleep(self.config) + continue + + raise ValueError from err + + def _notify(self, total, youtube_id, idx): + """send notification""" + if not self.task: + return + + self.task.send_progress( + message_lines=[ + f"Index missing video {youtube_id}, {idx + 1}/{total}" + ], + progress=(idx + 1) / total, + ) + + def _notify_error(self, youtube_id): + """notify error""" + if not self.task: + return + + message = f"[scanner] Failed to index {youtube_id}, no metadata" + print(f"[scanner] {message}") + self.task.send_progress( + message_lines=[message, "Continue..."], + level="error", + ) diff --git a/backend/appsettings/src/manual.py b/backend/appsettings/src/manual.py index 08e5b8e3..36c3c62b 100644 --- a/backend/appsettings/src/manual.py +++ b/backend/appsettings/src/manual.py @@ -16,8 +16,9 @@ from common.src.env_settings import EnvironmentSettings from common.src.helper import ignore_filelist from download.src.thumbnails import ThumbManager from PIL import Image -from video.src.comments import CommentList +from video.src.comments import Comments from video.src.index import YoutubeVideo +from video.src.meta_embed import IndexFromEmbed from yt_dlp.utils import ISO639Utils @@ -41,9 +42,16 @@ class ImportFolderScanner: "subtitle": [".vtt"], } - def __init__(self, task=False): + def __init__( + self, + task=False, + ignore_error: bool = False, + prefer_local: bool = False, + ): self.task = task self.to_import = False + self.ignore_error = ignore_error + self.prefer_local = prefer_local def scan(self): """scan and match media files""" @@ -142,14 +150,14 @@ class ImportFolderScanner: self._convert_thumb(current_video) self._get_subtitles(current_video) self._convert_video(current_video) + print(f"manual import: {current_video}") - - ManualImport(current_video, config).run() - - video_ids = [i["video_id"] for i in self.to_import] - comment_list = CommentList(task=self.task) - comment_list.add(video_ids=video_ids) - comment_list.index() + ManualImport( + current_video, + config, + ignore_error=self.ignore_error, + prefer_local=self.prefer_local, + ).run() def _notify(self, idx, current_video): """send notification back to task""" @@ -185,17 +193,27 @@ class ImportFolderScanner: expects filename ending in []. """ base_name, _ = os.path.splitext(file_name) + + # yt-dlp default like [youtubeid] id_search = re.search(r"\[([a-zA-Z0-9_-]{11})\]$", base_name) if id_search: youtube_id = id_search.group(1) return youtube_id + file_name_search = re.search(r"([a-zA-Z0-9_-]{11})$", base_name) + if file_name_search: + youtube_id = file_name_search.group(1) + return youtube_id + print(f"id extraction failed from filename: {file_name}") return False - def _extract_id_from_json(self, json_file): + def _extract_id_from_json(self, json_file: str | bool) -> str | None: """open json file and extract id""" + if not json_file or not isinstance(json_file, str): + return None + json_path = os.path.join(self.CACHE_DIR, "import", json_file) with open(json_path, "r", encoding="utf-8") as f: json_content = f.read() @@ -388,17 +406,49 @@ class ImportFolderScanner: class ManualImport: """import single identified video""" - def __init__(self, current_video, config): + def __init__( + self, current_video, config, ignore_error: bool, prefer_local: bool + ): self.current_video = current_video self.config = config + self.ignore_error: bool = ignore_error + self.prefer_local: bool = prefer_local def run(self): """run all""" - json_data = self.index_metadata() - self._move_to_archive(json_data) - self._cleanup(json_data) + json_data = None + if self.prefer_local: + # embedded first + json_data = IndexFromEmbed( + self.current_video["media"], + use_user_conf=False, + config=self.config, + ).run_index() + if json_data: + self._cleanup() + return - def index_metadata(self): + try: + json_data = self.index_metadata() + except ValueError as err: + json_data = IndexFromEmbed( + self.current_video["media"], + use_user_conf=False, + config=self.config, + ).run_index() + if not json_data and not self.ignore_error: + raise ValueError from err + + if not json_data: + return + + self._move_to_archive(json_data) + self._cleanup() + + Comments(self.current_video["video_id"]).build_json(upload=True) + YoutubeVideo(self.current_video["video_id"]).embed_metadata() + + def index_metadata(self) -> dict | None: """get metadata from yt or json""" video_id = self.current_video["video_id"] video = YoutubeVideo(video_id) @@ -411,6 +461,9 @@ class ManualImport: f"{video_id}: manual import failed, and no metadata found." ) print(message) + if self.ignore_error: + return None + raise ValueError(message) video.check_subtitles(subtitle_files=self.current_video["subtitle"]) @@ -463,7 +516,7 @@ class ManualImport: new_path = f"{base_name}.{lang}.vtt" shutil.move(old_path, new_path, copy_function=shutil.copyfile) - def _cleanup(self, json_data): + def _cleanup(self): """cleanup leftover files""" meta_data = self.current_video["metadata"] if meta_data and os.path.exists(meta_data): @@ -476,11 +529,3 @@ class ManualImport: for subtitle_file in self.current_video["subtitle"]: if os.path.exists(subtitle_file): os.remove(subtitle_file) - - channel_info = os.path.join( - EnvironmentSettings.CACHE_DIR, - "import", - f"{json_data['channel']['channel_id']}.info.json", - ) - if os.path.exists(channel_info): - os.remove(channel_info) diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py index 234fd7fa..8abe5269 100644 --- a/backend/appsettings/src/reindex.py +++ b/backend/appsettings/src/reindex.py @@ -345,6 +345,7 @@ class Reindex(ReindexBase): thumb_handler.download_video_thumb(video.json_data["vid_thumb_url"]) Comments(youtube_id, config=self.config).reindex_comments() + video.get_from_es() video.embed_metadata() self.processed["videos"] += 1 diff --git a/backend/appsettings/urls.py b/backend/appsettings/urls.py index 4dde4188..7a5c3632 100644 --- a/backend/appsettings/urls.py +++ b/backend/appsettings/urls.py @@ -44,6 +44,16 @@ urlpatterns = [ views.TokenView.as_view(), name="api-token", ), + path( + "rescan-filesystem/", + views.RescanFileSystem.as_view(), + name="api-rescan-filesystem", + ), + path( + "manual-import/", + views.ManualImportView.as_view(), + name="api-manual-import", + ), path( "membership/profile/", views_mb.MembershipProfileView.as_view(), diff --git a/backend/appsettings/views.py b/backend/appsettings/views.py index edb87ca3..83787b02 100644 --- a/backend/appsettings/views.py +++ b/backend/appsettings/views.py @@ -5,7 +5,9 @@ from appsettings.serializers import ( BackupFileSerializer, CookieUpdateSerializer, CookieValidationSerializer, + ManualImportConfig, PoTokenSerializer, + RescanFileSystemConfig, SnapshotCreateResponseSerializer, SnapshotItemSerializer, SnapshotListSerializer, @@ -291,7 +293,11 @@ class CookieView(ApiBaseView): class POTokenView(ApiBaseView): - """handle PO token""" + """resolves to /api/appsettings/potoken/ + GET: get potoken + POST: update potoken + DELETE: revoke potoken + """ permission_classes = [AdminOnly] @@ -388,6 +394,58 @@ class SnapshotApiListView(ApiBaseView): return Response(serializer.data) +class RescanFileSystem(ApiBaseView): + """resolves to /api/appsettings/rescan-filesystem/ + POST: start new rescan filesystem task + """ + + permission_classes = [AdminOnly] + + @staticmethod + @extend_schema( + request=RescanFileSystemConfig, + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + }, + ) + def post(request): + """start new task rescan filesystem task""" + data_serializer = RescanFileSystemConfig(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + message = TaskCommand().start("rescan_filesystem", validated_data) + serializer = AsyncTaskResponseSerializer(message) + + return Response(serializer.data) + + +class ManualImportView(ApiBaseView): + """resolves to /api/appsettings/manual-import/ + POST: start new manual import task + """ + + permission_classes = [AdminOnly] + + @staticmethod + @extend_schema( + request=ManualImportConfig, + responses={ + 200: OpenApiResponse(AsyncTaskResponseSerializer()), + }, + ) + def post(request): + """manual import""" + data_serializer = ManualImportConfig(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + message = TaskCommand().start("manual_import", validated_data) + serializer = AsyncTaskResponseSerializer(message) + + return Response(serializer.data) + + class SnapshotApiView(ApiBaseView): """resolves to /api/appsettings/snapshot// GET: return a single snapshot diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py index dbf238ce..a29da9a2 100644 --- a/backend/common/src/helper.py +++ b/backend/common/src/helper.py @@ -110,6 +110,8 @@ def date_parser(timestamp: int | str | None) -> str | None: if isinstance(timestamp, int): date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc) + elif isinstance(timestamp, str) and timestamp.isdigit(): + date_obj = datetime.fromtimestamp(int(timestamp), tz=timezone.utc) elif isinstance(timestamp, str): date_obj = datetime.strptime(timestamp, "%Y-%m-%d") date_obj = date_obj.replace(tzinfo=timezone.utc) diff --git a/backend/common/src/searching.py b/backend/common/src/searching.py index 17c164b1..44b75d0b 100644 --- a/backend/common/src/searching.py +++ b/backend/common/src/searching.py @@ -113,6 +113,7 @@ class SearchParser: "index": "ta_subtitle", "lang": [], "source": [], + "channel": [], }, } @@ -345,6 +346,22 @@ class QueryBuilder: """build query for fulltext search""" must_list = [] + if (channel := self.query_map.get("channel")) is not None: + must_list.append( + { + "multi_match": { + "query": channel, + "type": "bool_prefix", + "fuzziness": self._get_fuzzy(), + "operator": "and", + "fields": [ + "subtitle_channel", + "subtitle_channel.keyword", + ], + } + } + ) + if (term := self.query_map.get("term")) is not None: must_list.append( { diff --git a/backend/common/tests/test_src/test_helper.py b/backend/common/tests/test_src/test_helper.py index 013e774b..73578a76 100644 --- a/backend/common/tests/test_src/test_helper.py +++ b/backend/common/tests/test_src/test_helper.py @@ -26,6 +26,13 @@ def test_date_parser_with_int(): assert date_parser(timestamp) == expected_date +def test_date_parser_with_digit(): + """unix timestamp""" + timestamp = "1621539600" + expected_date = "2021-05-20T19:40:00+00:00" + assert date_parser(timestamp) == expected_date + + def test_date_parser_with_str(): """iso timestamp""" date_str = "2021-05-21" diff --git a/backend/download/serializers.py b/backend/download/serializers.py index f34a4ce7..bcaf33a9 100644 --- a/backend/download/serializers.py +++ b/backend/download/serializers.py @@ -10,13 +10,15 @@ from video.src.constants import VideoTypeEnum class DownloadItemSerializer(serializers.Serializer): """serialize download item""" - auto_start = serializers.BooleanField() + auto_start = serializers.BooleanField(required=False) channel_id = serializers.CharField() channel_indexed = serializers.BooleanField() channel_name = serializers.CharField() duration = serializers.CharField() published = serializers.CharField(allow_null=True) - status = serializers.ChoiceField(choices=["pending", "ignore"]) + status = serializers.ChoiceField( + choices=["pending", "ignore"], required=False + ) timestamp = serializers.IntegerField(allow_null=True) title = serializers.CharField() vid_thumb_url = serializers.CharField(allow_null=True) diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py index e03a8871..f372c5ea 100644 --- a/backend/download/src/queue.py +++ b/backend/download/src/queue.py @@ -20,6 +20,7 @@ from common.src.helper import ( rand_sleep, ) from common.src.urlparser import ParsedURLType +from download.serializers import DownloadItemSerializer from download.src.queue_interact import PendingInteract from download.src.thumbnails import ThumbManager from playlist.src.index import YoutubePlaylist @@ -379,6 +380,12 @@ class PendingList(PendingIndex): "channel_id": video_data["channel_id"], "channel_indexed": video_data["channel_id"] in self.all_channels, } + serializer = DownloadItemSerializer(data=to_add) + is_valid = serializer.is_valid() + if not is_valid: + print(f"{youtube_id}: serializer failed: {serializer.errors}") + self._notify_fail(403, youtube_id) + return None return to_add @@ -393,18 +400,24 @@ class PendingList(PendingIndex): return None @staticmethod - def _extract_published(video_data) -> str | int | None: + def _extract_published(video_data) -> int | None: """build published date or timestamp""" timestamp = video_data.get("timestamp") - if timestamp: + if timestamp and isinstance(timestamp, int): return timestamp upload_date = video_data.get("upload_date") if upload_date: - upload_date_time = datetime.strptime(upload_date, "%Y%m%d") - return upload_date_time.replace( - tzinfo=ZoneInfo(EnvironmentSettings.TZ) - ).timestamp() + try: + upload_date_time = datetime.strptime(upload_date, "%Y%m%d") + except ValueError: + youtube_id = video_data["id"] + print(f"{youtube_id}: published date extraction failed.") + return None + + tz = ZoneInfo(EnvironmentSettings.TZ) + timestamp = int(upload_date_time.replace(tzinfo=tz).timestamp()) + return timestamp return None diff --git a/backend/download/src/thumbnails.py b/backend/download/src/thumbnails.py index 2d9d4739..c4efa66f 100644 --- a/backend/download/src/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -4,9 +4,7 @@ functionality: - check for missing thumbnails """ -import base64 import os -from io import BytesIO from time import sleep import requests @@ -14,7 +12,7 @@ from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.helper import is_missing from mutagen.mp4 import MP4, MP4Cover -from PIL import Image, ImageFile, ImageFilter, UnidentifiedImageError +from PIL import Image, ImageFile, UnidentifiedImageError ImageFile.LOAD_TRUNCATED_IMAGES = True @@ -23,6 +21,7 @@ class ThumbManagerBase: """base class for thumbnail management""" CACHE_DIR = EnvironmentSettings.CACHE_DIR + MEDIA_DIR = EnvironmentSettings.MEDIA_DIR VIDEO_DIR = os.path.join(CACHE_DIR, "videos") CHANNEL_DIR = os.path.join(CACHE_DIR, "channels") PLAYLIST_DIR = os.path.join(CACHE_DIR, "playlists") @@ -217,6 +216,56 @@ class ThumbManager(ThumbManagerBase): img_raw = img_raw.resize((336, 189)) img_raw.convert("RGB").save(thumb_path) + def embed_video_art(self, json_data: dict): + """embed video artwork""" + file_path = os.path.join(self.MEDIA_DIR, json_data["media_url"]) + video = MP4(file_path) + + thumb_path = self.vid_thumb_path(absolute=True) + if os.path.exists(thumb_path): + with open(thumb_path, "rb") as f: + cover_data = f.read() + + video["covr"] = [ + MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + channel_id = json_data["channel"]["channel_id"] + banner_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_banner.jpg" + ) + if os.path.exists(banner_path): + with open(banner_path, "rb") as f: + banner_data = f.read() + + video["----:com.tubearchivist:channel_banner"] = [ + MP4Cover(banner_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + channel_icon_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_thumb.jpg" + ) + if os.path.exists(channel_icon_path): + with open(channel_icon_path, "rb") as f: + icon_data = f.read() + + video["----:com.tubearchivist:channel_icon"] = [ + MP4Cover(icon_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + channel_tv_path = os.path.join( + self.CHANNEL_DIR, f"{channel_id}_tvart.jpg" + ) + if os.path.exists(channel_tv_path): + with open(channel_tv_path, "rb") as f: + tv_data = f.read() + + video["----:com.tubearchivist:channel_tv"] = [ + MP4Cover(tv_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + video.save() + def delete_video_thumb(self): """delete video thumbnail if exists""" thumb_path = self.vid_thumb_path() @@ -228,10 +277,13 @@ class ThumbManager(ThumbManagerBase): """delete all artwork of channel""" thumb = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_thumb.jpg") banner = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_banner.jpg") + tv = os.path.join(self.CHANNEL_DIR, f"{self.item_id}_tvart.jpg") if os.path.exists(thumb): os.remove(thumb) if os.path.exists(banner): os.remove(banner) + if os.path.exists(tv): + os.remove(tv) def delete_playlist_thumb(self): """delete playlist thumbnail""" @@ -239,20 +291,6 @@ class ThumbManager(ThumbManagerBase): if os.path.exists(thumb_path): os.remove(thumb_path) - def get_vid_base64_blur(self): - """return base64 encoded placeholder""" - file_path = os.path.join(self.CACHE_DIR, self.vid_thumb_path()) - img_raw = Image.open(file_path) - img_raw.thumbnail((img_raw.width // 20, img_raw.height // 20)) - img_blur = img_raw.filter(ImageFilter.BLUR) - buffer = BytesIO() - img_blur.save(buffer, format="JPEG") - img_data = buffer.getvalue() - img_base64 = base64.b64encode(img_data).decode() - data_url = f"data:image/jpg;base64,{img_base64}" - - return data_url - class ValidatorCallback: """handle callback validate thumbnails page by page""" @@ -458,7 +496,7 @@ class ThumbFilesystem: """entry point""" data = { "query": {"match_all": {}}, - "_source": ["media_url", "youtube_id"], + "_source": ["media_url", "youtube_id", "channel.channel_id"], } paginate = IndexPaginate( index_name=self.INDEX_NAME, @@ -481,9 +519,7 @@ class ThumbFilesystem: class EmbedCallback: """callback class to embed thumbnails""" - CACHE_DIR = EnvironmentSettings.CACHE_DIR MEDIA_DIR = EnvironmentSettings.MEDIA_DIR - FORMAT = MP4Cover.FORMAT_JPEG def __init__(self, source, index_name, counter=0): self.source = source @@ -494,19 +530,4 @@ class EmbedCallback: """run embed""" for video in self.source: video_id = video["_source"]["youtube_id"] - media_url = os.path.join( - self.MEDIA_DIR, video["_source"]["media_url"] - ) - thumb_path = os.path.join( - self.CACHE_DIR, ThumbManager(video_id).vid_thumb_path() - ) - if os.path.exists(thumb_path): - self.embed(media_url, thumb_path) - - def embed(self, media_url, thumb_path): - """embed thumb in single media file""" - video = MP4(media_url) - with open(thumb_path, "rb") as f: - video["covr"] = [MP4Cover(f.read(), imageformat=self.FORMAT)] - - video.save() + ThumbManager(video_id).embed_video_art(video["_source"]) diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index a5702a4f..08017a3c 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -196,15 +196,6 @@ class VideoDownloader(DownloaderBase): } ) - if self.config["downloads"]["add_thumbnail"]: - postprocessors.append( - { - "key": "EmbedThumbnail", - "already_have_thumbnail": True, - } - ) - self.obs["writethumbnail"] = True - self.obs["postprocessors"] = postprocessors def _set_overwrites(self, obs: dict, channel_id: str) -> None: @@ -326,6 +317,9 @@ class DownloadPostProcess(DownloaderBase): for channel_id, value in self.channel_overwrites.items(): if "autodelete_days" in value: autodelete_days = value.get("autodelete_days") + if autodelete_days is None: + continue + print(f"{channel_id}: delete older than {autodelete_days}d") now_lte = str(self.now - autodelete_days * 24 * 60 * 60) must_list = [ @@ -488,7 +482,10 @@ class DownloadPostProcess(DownloaderBase): def embed_metadata(self): """embed metadata in media file""" - if not self.config["downloads"].get("add_metadata"): + meta = self.config["downloads"].get("add_metadata") + thumb = self.config["downloads"].get("add_thumbnail") + + if not meta and not thumb: return queue = RedisQueue(self.VIDEO_QUEUE) diff --git a/backend/requirements.txt b/backend/requirements.txt index 329dd967..136e49af 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,14 +1,14 @@ -apprise==1.9.5 -celery==5.5.3 +apprise==1.9.6 +celery==5.6.0 django-auth-ldap==5.2.0 django-celery-beat==2.8.1 django-cors-headers==4.9.0 -Django==5.2.8 +Django==5.2.9 djangorestframework==3.16.1 -drf-spectacular==0.29.0 +drf-spectacular==0.28.0 # rc:ignore Pillow==12.0.0 -redis==7.0.1 +redis==7.1.0 requests==2.32.5 ryd-client==0.0.6 uvicorn==0.38.0 -yt-dlp[default]==2025.11.12 +yt-dlp[default]==2025.12.8 diff --git a/backend/task/src/task_config.py b/backend/task/src/task_config.py index 9fd494e9..8d8e3a7e 100644 --- a/backend/task/src/task_config.py +++ b/backend/task/src/task_config.py @@ -48,7 +48,7 @@ CHECK_REINDEX: TaskItemConfig = { MANUAL_IMPORT: TaskItemConfig = { "title": "Manual video import", "group": "setting:import", - "api_start": True, + "api_start": False, "api_stop": False, } @@ -69,7 +69,7 @@ RESTORE_BACKUP: TaskItemConfig = { RESCAN_FILESYSTEM: TaskItemConfig = { "title": "Rescan your Filesystem", "group": "setting:filesystemscan", - "api_start": True, + "api_start": False, "api_stop": False, } diff --git a/backend/task/src/task_manager.py b/backend/task/src/task_manager.py index ea48e5e8..81b612ec 100644 --- a/backend/task/src/task_manager.py +++ b/backend/task/src/task_manager.py @@ -84,9 +84,9 @@ class TaskManager: class TaskCommand: """run commands on task""" - def start(self, task_name): + def start(self, task_name, kwargs: dict | None = None): """start task by task_name, only pass task that don't take args""" - task = celery_app.tasks.get(task_name).delay() + task = celery_app.tasks.get(task_name).delay(**(kwargs or {})) message = { "task_id": task.id, "status": task.status, diff --git a/backend/task/tasks.py b/backend/task/tasks.py index 995e1c85..8b31b8dc 100644 --- a/backend/task/tasks.py +++ b/backend/task/tasks.py @@ -216,7 +216,7 @@ def check_reindex(self, data=False, extract_videos=False): @shared_task(bind=True, name="manual_import", base=BaseTask) -def run_manual_import(self): +def manual_import(self, ignore_error, prefer_local): """called from settings page, to go through import folder""" manager = TaskManager() if manager.is_pending(self): @@ -225,7 +225,9 @@ def run_manual_import(self): return manager.init(self) - ImportFolderScanner(task=self).scan() + ImportFolderScanner( + task=self, ignore_error=ignore_error, prefer_local=prefer_local + ).scan() @shared_task(bind=True, name="run_backup", base=BaseTask) @@ -260,7 +262,7 @@ def run_restore_backup(self, filename): @shared_task(bind=True, name="rescan_filesystem", base=BaseTask) -def rescan_filesystem(self): +def rescan_filesystem(self, ignore_error, prefer_local): """check the media folder for mismatches""" manager = TaskManager() if manager.is_pending(self): @@ -269,7 +271,9 @@ def rescan_filesystem(self): return manager.init(self) - handler = Scanner(task=self) + handler = Scanner( + task=self, ignore_error=ignore_error, prefer_local=prefer_local + ) handler.scan() handler.apply() thumbnail_check.delay() diff --git a/backend/video/serializers.py b/backend/video/serializers.py index 55d229f0..5a5d7e3e 100644 --- a/backend/video/serializers.py +++ b/backend/video/serializers.py @@ -60,6 +60,23 @@ class StreamItemSerializer(serializers.Serializer): height = serializers.IntegerField(required=False) +class SubtitleFragmentSerializer(serializers.Serializer): + """serialize subtitle fragment""" + + subtitle_index = serializers.IntegerField() + subtitle_line = serializers.CharField() + subtitle_start = serializers.CharField() + subtitle_fragment_id = serializers.CharField() + subtitle_end = serializers.CharField() + youtube_id = serializers.CharField() + title = serializers.CharField() + subtitle_channel = serializers.CharField() + subtitle_channel_id = serializers.CharField() + subtitle_last_refresh = serializers.IntegerField() + subtitle_lang = serializers.CharField() + subtitle_source = serializers.ChoiceField(choices=["user", "auto"]) + + class SubtitleItemSerializer(serializers.Serializer): """serialize subtitle item""" @@ -76,16 +93,18 @@ class VideoSerializer(serializers.Serializer): active = serializers.BooleanField() category = serializers.ListField(child=serializers.CharField()) - channel = ChannelSerializer() - comment_count = serializers.IntegerField(allow_null=True) + channel = ChannelSerializer(required=False) + comment_count = serializers.IntegerField(allow_null=True, required=False) date_downloaded = serializers.IntegerField() description = serializers.CharField() media_size = serializers.IntegerField() media_url = serializers.CharField() player = PlayerSerializer() - playlist = serializers.ListField(child=serializers.CharField()) + playlist = serializers.ListField( + child=serializers.CharField(), required=False + ) published = serializers.CharField() - sponsorblock = SponsorBlockSerializer(allow_null=True) + sponsorblock = SponsorBlockSerializer(allow_null=True, required=False) stats = StatsSerializer() streams = StreamItemSerializer(many=True) subtitles = SubtitleItemSerializer(many=True) @@ -153,7 +172,16 @@ class CommentItemSerializer(serializers.Serializer): comment_author_thumbnail = serializers.URLField() comment_author_is_uploader = serializers.BooleanField() comment_parent = serializers.CharField() - comment_replies = CommentThreadItemSerializer(many=True) + comment_replies = CommentThreadItemSerializer(many=True, required=False) + + +class CommentsSerializer(serializers.Serializer): + """serialize comments as indexed""" + + youtube_id = serializers.CharField() + comment_last_refresh = serializers.IntegerField() + comment_channel_id = serializers.CharField() + comment_comments = CommentItemSerializer(many=True) class PlaylistNavMetaSerializer(serializers.Serializer): diff --git a/backend/video/src/comments.py b/backend/video/src/comments.py index 4b145a75..368b5b0b 100644 --- a/backend/video/src/comments.py +++ b/backend/video/src/comments.py @@ -25,7 +25,7 @@ class Comments: self.is_activated = False self.comments_format = False - def build_json(self): + def build_json(self, upload: bool = False): """build json document for es""" print(f"{self.youtube_id}: get comments") self.check_config() @@ -44,6 +44,8 @@ class Comments: "comment_channel_id": channel_id, "comment_comments": self.comments_format, } + if upload: + self.upload_comments() def check_config(self): """read config if not attached""" @@ -142,14 +144,13 @@ class Comments: def upload_comments(self): """upload comments to es""" - if not self.is_activated: - return - print(f"{self.youtube_id}: upload comments") _, _ = ElasticWrap(self.es_path).put(self.json_data) vid_path = f"ta_video/_update/{self.youtube_id}" - data = {"doc": {"comment_count": len(self.comments_format)}} + data = { + "doc": {"comment_count": len(self.json_data["comment_comments"])} + } _, _ = ElasticWrap(vid_path).post(data=data) def delete_comments(self): diff --git a/backend/video/src/index.py b/backend/video/src/index.py index 0dbb2aae..e039aaa8 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -220,7 +220,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def _build_published(self): """build published date or timestamp""" timestamp = self.youtube_meta.get("timestamp") - if timestamp: + if timestamp and isinstance(timestamp, int): return timestamp upload_date = self.youtube_meta["upload_date"] @@ -424,10 +424,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): def embed_metadata(self): """embed metadata for video""" - if not self.config["downloads"].get("add_metadata"): - return - - print(f"{self.youtube_id}: embed metadata") if not self.json_data: self.get_from_es() @@ -435,6 +431,15 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): print(f"{self.youtube_id}: skip embed, video not indexed") return + if self.config["downloads"].get("add_metadata"): + self._embed_text_data() + + if self.config["downloads"].get("add_thumbnail"): + self._embed_artwork() + + def _embed_text_data(self): + """embed text metadata""" + print(f"{self.youtube_id}: embed metadata") video_base = EnvironmentSettings.MEDIA_DIR media_url = self.json_data.get("media_url") file_path = os.path.join(video_base, media_url) @@ -479,11 +484,17 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): "comments": comments, "subtitles": subtitles, "playlists": playlists, + "version": settings.TA_VERSION, } ) return to_embed + def _embed_artwork(self): + """embed artwork""" + print(f"{self.youtube_id}: embed artwork") + ThumbManager(self.youtube_id).embed_video_art(self.json_data) + def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS): """combined classes to create new video in index""" diff --git a/backend/video/src/meta_embed.py b/backend/video/src/meta_embed.py index 0a7f62e1..a568d04f 100644 --- a/backend/video/src/meta_embed.py +++ b/backend/video/src/meta_embed.py @@ -1,7 +1,28 @@ -"""bulk metadata embedding""" +""" +Functionality: +- bulk metadata embedding +- restore from embedded metadata +""" +import json +import os +import shutil + +from appsettings.src.config import AppConfig, AppConfigType +from channel.serializers import ChannelSerializer +from channel.src.index import YoutubeChannel +from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate +from download.src.thumbnails import ThumbManager +from mutagen.mp4 import MP4, MP4FreeForm +from video.serializers import ( + CommentsSerializer, + SubtitleFragmentSerializer, + VideoSerializer, +) +from video.src.comments import Comments from video.src.index import YoutubeVideo +from video.src.subtitle import SubtitleParser, YoutubeSubtitle class MetadataEmbed: @@ -49,3 +70,302 @@ class MetadataEmbedCallback: for video in self.source: youtube_id = video["_source"]["youtube_id"] YoutubeVideo(youtube_id).embed_metadata() + + +class IndexFromEmbed: + """restore from embedded metadata, potential untrusted""" + + VIDEOS_BASE = EnvironmentSettings.MEDIA_DIR + CACHE_DIR = EnvironmentSettings.CACHE_DIR + HOST_UID = EnvironmentSettings.HOST_UID + HOST_GID = EnvironmentSettings.HOST_GID + + def __init__( + self, + file_path: str, + use_user_conf: bool = True, + config: AppConfigType | None = None, + ): + self.file_path = file_path + self.use_user_conf = use_user_conf + self.config = config + + def run_index(self) -> None | dict: + """run index""" + if not self.config: + self.config = AppConfig().config + + json_embed = self._get_embedded() + if not json_embed: + return None + + channel_data_clean = self.index_channel(json_embed) + video = self.index_video(json_embed, channel_data_clean) + self.archive_video(video) + self.index_subtitles(json_embed, video) + self.index_comments(json_embed) + + return video.json_data + + def _get_embedded(self) -> dict | None: + """get embedded metadata""" + video_mutagen = MP4(self.file_path) + ta_data = video_mutagen.get("----:com.tubearchivist:ta") + if not ta_data: + return None + + if not isinstance(ta_data, list): + raise ValueError(f"[{self.file_path}] unexpected embedded data") + + to_decode = ta_data[0] + + if not isinstance(to_decode, MP4FreeForm): + raise ValueError(f"[{self.file_path}] unexpected embedded data") + + try: + json_embed = json.loads(to_decode.decode()) + except Exception as exc: # pylint: disable=broad-exception-caught + err = f"[{self.file_path}] embedded decoding failed: {str(exc)}" + raise ValueError(err) from exc + + if not json_embed.get("video"): + err = f"[{self.file_path}] embedded does not contain video key" + raise ValueError(err) + + return json_embed + + def index_channel(self, json_embed): + """index channel""" + channel_data = json_embed["video"].get("channel") + if not channel_data: + raise ValueError(f"[{self.file_path}] missing channel metadata") + + serializer = ChannelSerializer(data=channel_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] channel serializer failed: {err}" + ) + + channel_data_clean = dict(serializer.data) + if not self.use_user_conf: + if "channel_overwrites" in channel_data_clean: + channel_data_clean.pop("channel_overwrites") + + channel_data_clean["channel_subscribed"] = False + + channel = YoutubeChannel(youtube_id=channel_data_clean["channel_id"]) + channel.get_from_es() + if not channel.json_data: + channel.json_data = channel_data_clean + channel.upload_to_es() + + return channel.json_data + + def index_video(self, json_embed, channel_data_clean): + """index video""" + video_data = json_embed["video"] + video_data.pop("channel") + + serializer = VideoSerializer(data=video_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] video serializer failed: {err}" + ) + + video_data_clean = dict(serializer.data) + if not self.use_user_conf: + video_data_clean["player"]["watched"] = False + + video_data_clean["channel"] = channel_data_clean + video = YoutubeVideo(youtube_id=video_data_clean["youtube_id"]) + video.get_from_es() + if not video.json_data: + video.json_data = video_data_clean + video.upload_to_es() + + return video + + def archive_video(self, video): + """archive video file""" + channel_id = video.json_data["channel"]["channel_id"] + folder = os.path.join(self.VIDEOS_BASE, channel_id) + if not os.path.exists(folder): + os.makedirs(folder) + if self.HOST_UID and self.HOST_GID: + os.chown(folder, self.HOST_UID, self.HOST_GID) + + new_path = os.path.join(folder, f"{video.youtube_id}.mp4") + if self.file_path == new_path: + # already archived + return + + shutil.move(self.file_path, new_path, copy_function=shutil.copyfile) + if self.HOST_UID and self.HOST_GID: + os.chown(new_path, self.HOST_UID, self.HOST_GID) + + def restore_artwork(self, video): + """restore artwork if needed""" + video_mutagen = MP4(self.file_path) + + thumb = ThumbManager(video.youtube_id).vid_thumb_path(absolute=True) + self._restore_art_item(video_mutagen, "covr", thumb) + + channel_id = video.json_data["channel"]["channel_id"] + banner_path = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_banner.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_banner", banner_path + ) + + channel_icon = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_thumb.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_icon", channel_icon + ) + + tv_art_path = os.path.join( + self.CACHE_DIR, "channels", f"{channel_id}_tvart.jpg" + ) + self._restore_art_item( + video_mutagen, "----:com.tubearchivist:channel_tv", tv_art_path + ) + + def _restore_art_item( + self, video_mutagen, mutagen_key: str, target_path: str + ) -> None: + """restore single art item""" + if os.path.exists(target_path): + # don't overwrite + return + + art_item = video_mutagen.get(mutagen_key) + if not art_item and not isinstance(art_item, list): + # is not embedded + return + + with open(target_path, "wb") as f: + f.write(bytes(art_item[0])) + + def index_subtitles(self, json_embed, video): + """index subtitles""" + subtitle_data = json_embed.get("subtitles") + if not subtitle_data: + return + + serializer = SubtitleFragmentSerializer(data=subtitle_data, many=True) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] subtitle serializer failed: {err}" + ) + + self._process_embedded_subs(video, subtitle_data=serializer.data) + + def _process_embedded_subs(self, video, subtitle_data): + """process single embedded subtitle""" + embedded_subs = { + (i["subtitle_lang"], i["subtitle_source"]) for i in subtitle_data + } + subs = YoutubeSubtitle(video) + response = subs.get_es_subtitles() + indexed = { + (i["subtitle_lang"], i["subtitle_source"]) for i in response + } + + for embedded_lang, embedded_source in embedded_subs: + needs_processing = self._process_subtitle( + indexed, embedded_lang, embedded_source + ) + if not needs_processing: + continue + + segments = [ + i + for i in subtitle_data + if i["subtitle_lang"] == embedded_lang + and i["subtitle_source"] == embedded_source + ] + to_index = sorted(segments, key=lambda d: d["subtitle_index"]) + parser = SubtitleParser( + subtitle_str="{}", lang=embedded_lang, source=embedded_source + ) + + for segment in to_index: + parser.all_cues.append( + { + "start": segment["subtitle_start"], + "end": segment["subtitle_end"], + "text": segment["subtitle_line"], + "idx": segment["subtitle_index"], + } + ) + + subtitle_str = parser.get_subtitle_str() + query_str = parser.create_bulk_import(to_index) + subs.index_subtitle(query_str) + + media_url = subs.get_media_url(lang=embedded_lang) + dest_path = os.path.join(self.VIDEOS_BASE, media_url) + subs.write_subtitle_file(dest_path, subtitle_str) + + def _process_subtitle( + self, indexed, embedded_lang, embedded_source + ) -> bool: + """check if subtitle should be processed""" + for sub_indexed in indexed: + if ( + sub_indexed.get("lang") == embedded_lang + and sub_indexed.get("source") == embedded_source + ): + # already indexed + return False + + if not self.use_user_conf: + return True + + if not self.config: + return False + + langs = self.config["downloads"]["subtitle"] + source = self.config["downloads"]["subtitle_source"] + if not langs or not source: + return False + + lang_codes = [i.strip() for i in langs.split(",")] + if embedded_lang not in lang_codes: + return True + + return False + + def index_comments(self, json_embed): + """index comments""" + comment_data = json_embed.get("comments") + if not comment_data: + return + + serializer = CommentsSerializer(data=comment_data) + is_valid = serializer.is_valid() + if not is_valid: + err = serializer.errors + raise ValueError( + f"[{self.file_path}] comments serializer failed: {err}" + ) + + if self.use_user_conf: + if self.config and not self.config["downloads"]["comment_max"]: + return + + comments = Comments(youtube_id=serializer.data["youtube_id"]) + existing = comments.get_es_comments() + if existing: + return + + comments.json_data = dict(serializer.data) + comments.upload_comments() diff --git a/backend/video/src/subtitle.py b/backend/video/src/subtitle.py index 42e686c9..68868eb9 100644 --- a/backend/video/src/subtitle.py +++ b/backend/video/src/subtitle.py @@ -10,14 +10,25 @@ import os import re from datetime import datetime from operator import itemgetter +from typing import TypedDict import requests from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap, IndexPaginate from common.src.helper import rand_sleep, requests_headers +from download.src.yt_dlp_base import CookieHandler from yt_dlp.utils import orderedSet_from_options +class SubtitleCue(TypedDict): + """describe single subtitle queue""" + + start: str + end: str + text: str + idx: int + + class YoutubeSubtitle: """handle video subtitle functionality""" @@ -54,7 +65,9 @@ class YoutubeSubtitle: ) ] except re.error as e: - raise ValueError(f"wrong regex in subtitle config: {e.pattern}") + raise ValueError( + f"wrong regex in subtitle config: {e.pattern}" + ) from e return relevant_subtitles @@ -74,8 +87,7 @@ class YoutubeSubtitle: # not supported yet continue - video_media_url = self.video.json_data["media_url"] - media_url = video_media_url.replace(".mp4", f".{lang}.vtt") + media_url = self.get_media_url(lang) if not all_formats: # no subtitles found continue @@ -93,6 +105,12 @@ class YoutubeSubtitle: return candidate_subtitles + def get_media_url(self, lang: str) -> str: + """get media url""" + video_media_url = self.video.json_data["media_url"] + media_url = video_media_url.replace(".mp4", f".{lang}.vtt") + return media_url + def get_es_subtitles(self) -> list[dict]: """get subtitles from elastic""" data = { @@ -113,39 +131,66 @@ class YoutubeSubtitle: dest_path = os.path.join(videos_base, subtitle["media_url"]) source = subtitle["source"] lang = subtitle.get("lang") - response = requests.get( - subtitle["url"], headers=requests_headers(), timeout=30 - ) - if not response.ok: - subtitle_key = f"{self.video.youtube_id}-{lang}" - print(f"{subtitle_key}: failed to download subtitle") - print(response.text) - rand_sleep(self.video.config) + + response_text = self._make_request(subtitle["url"], lang) + if not response_text: continue - if not response.text: - print(f"{subtitle_key}: skip empty subtitle") - rand_sleep(self.video.config) - continue - - parser = SubtitleParser(response.text, lang, source) + parser = SubtitleParser(response_text, lang, source) parser.process() if not parser.all_cues: rand_sleep(self.video.config) continue subtitle_str = parser.get_subtitle_str() - self._write_subtitle_file(dest_path, subtitle_str) + self.write_subtitle_file(dest_path, subtitle_str) if self.video.config["downloads"]["subtitle_index"]: - query_str = parser.create_bulk_import(self.video, source) - self._index_subtitle(query_str) + documents = parser.create_documents(self.video, source) + query_str = parser.create_bulk_import(documents) + self.index_subtitle(query_str) indexed.append(subtitle) rand_sleep(self.video.config) return indexed - def _write_subtitle_file(self, dest_path, subtitle_str): + def _make_request(self, url: str, lang: str | None) -> str | None: + """make the request""" + request_kwargs: dict = { + "timeout": 30, + "headers": requests_headers(), + } + if self.video.config["downloads"].get("cookie_import"): + cookie = CookieHandler(self.video.config).get() + if cookie: + cookies_txt = cookie.read() + jar = requests.cookies.RequestsCookieJar() + for line in cookies_txt.split("\n"): + words = line.split() + if (len(words) == 7) and (words[0] != "#"): + jar.set( + words[5], words[6], domain=words[0], path=words[2] + ) + + request_kwargs["cookies"] = jar + + response = requests.get(url, **request_kwargs) + + if not response.ok: + subtitle_key = f"{self.video.youtube_id}-{lang}" + print(f"{subtitle_key}: failed to download subtitle") + print(response.text) + rand_sleep(self.video.config) + return None + + if not response.text: + print(f"{subtitle_key}: skip empty subtitle") + rand_sleep(self.video.config) + return None + + return response.text + + def write_subtitle_file(self, dest_path, subtitle_str): """write subtitle file to disk""" # create folder here for first video of channel os.makedirs(os.path.split(dest_path)[0], exist_ok=True) @@ -158,7 +203,7 @@ class YoutubeSubtitle: os.chown(dest_path, host_uid, host_gid) @staticmethod - def _index_subtitle(query_str): + def index_subtitle(query_str): """send subtitle to es for indexing""" _, _ = ElasticWrap("_bulk").post(data=query_str, ndjson=True) @@ -190,15 +235,14 @@ class YoutubeSubtitle: class SubtitleParser: """parse subtitle str from youtube""" - def __init__(self, subtitle_str, lang, source): + def __init__(self, subtitle_str: str, lang: str, source: str) -> None: self.subtitle_raw = json.loads(subtitle_str) self.lang = lang self.source = source - self.all_cues = False + self.all_cues: list[SubtitleCue] = [] - def process(self): + def process(self) -> None: """extract relevant que data""" - self.all_cues = [] all_events = self.subtitle_raw.get("events") if not all_events: @@ -213,12 +257,12 @@ class SubtitleParser: print(f"skipping subtitle event without content: {event}") continue - cue = { - "start": self._ms_conv(event["tStartMs"]), - "end": self._ms_conv(event["tStartMs"] + event["dDurationMs"]), - "text": "".join([i.get("utf8") for i in event["segs"]]), - "idx": idx + 1, - } + cue = SubtitleCue( + start=self._ms_conv(event["tStartMs"]), + end=self._ms_conv(event["tStartMs"] + event["dDurationMs"]), + text="".join([i.get("utf8") for i in event["segs"]]), + idx=idx + 1, + ) self.all_cues.append(cue) @staticmethod @@ -272,9 +316,8 @@ class SubtitleParser: return subtitle_str - def create_bulk_import(self, video, source): + def create_bulk_import(self, documents): """subtitle lines for es import""" - documents = self._create_documents(video, source) bulk_list = [] for document in documents: @@ -288,7 +331,7 @@ class SubtitleParser: return query_str - def _create_documents(self, video, source): + def create_documents(self, video, source): """process documents""" documents = self._chunk_list(video.youtube_id) channel = video.json_data.get("channel") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7267a7c2..aea629f1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,28 +8,28 @@ "name": "tubearchivist-frontend", "version": "0.5.1", "dependencies": { - "dompurify": "^3.2.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.8.0", - "zustand": "^5.0.7" + "dompurify": "^3.3.1", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "react-router-dom": "^7.10.1", + "zustand": "^5.0.9" }, "devDependencies": { - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.39.0", - "@vitejs/plugin-react-swc": "^4.0.0", - "eslint": "^9.33.0", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.3.0", - "prettier": "3.6.2", - "typescript": "^5.9.2", - "typescript-eslint": "^8.39.0", - "vite": ">=7.1.1", - "vite-plugin-checker": "^0.10.2" + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "prettier": "3.7.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.49.0", + "vite": ">=7.2.7", + "vite-plugin-checker": "^0.12.0" } }, "node_modules/@babel/code-frame": { @@ -47,6 +47,153 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -57,6 +204,94 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -753,42 +988,54 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@rolldown/pluginutils": { @@ -1347,14 +1594,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", - "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { @@ -1375,18 +1621,17 @@ "optional": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.4.tgz", - "integrity": "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/type-utils": "8.46.4", - "@typescript-eslint/utils": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -1399,23 +1644,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.4", + "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.4.tgz", - "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "engines": { @@ -1431,14 +1675,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.4.tgz", - "integrity": "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.4", - "@typescript-eslint/types": "^8.46.4", + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "engines": { @@ -1453,14 +1697,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz", - "integrity": "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4" + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1471,9 +1715,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz", - "integrity": "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", "dev": true, "license": "MIT", "engines": { @@ -1488,15 +1732,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.4.tgz", - "integrity": "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1513,9 +1757,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.4.tgz", - "integrity": "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -1527,21 +1771,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz", - "integrity": "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.4", - "@typescript-eslint/tsconfig-utils": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/visitor-keys": "8.46.4", + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -1556,16 +1799,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.4.tgz", - "integrity": "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.4", - "@typescript-eslint/types": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4" + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1580,13 +1823,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz", - "integrity": "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.4", + "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1633,7 +1876,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1668,19 +1910,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1711,6 +1940,16 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", + "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -1721,17 +1960,38 @@ "balanced-match": "^1.0.0" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">=8" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, "node_modules/callsites": { @@ -1744,6 +2004,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1804,13 +2085,24 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -1829,9 +2121,9 @@ } }, "node_modules/csstype": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz", - "integrity": "sha512-98XGutrXoh75MlgLihlNxAGbUuFQc7l1cqcnEZlLNKc0UrVdPndgmaDmYTDDh929VS/eqTZV0rozmhu2qqT1/g==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, "license": "MIT" }, @@ -1861,14 +2153,21 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", - "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1911,6 +2210,16 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1930,7 +2239,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2002,13 +2310,20 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" @@ -2185,36 +2500,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2229,16 +2514,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2252,19 +2527,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2318,6 +2580,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2344,13 +2616,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2361,6 +2626,23 @@ "node": ">=8" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2421,16 +2703,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2458,6 +2730,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2479,6 +2764,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2526,28 +2824,14 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "yallist": "^3.0.2" } }, "node_modules/minimatch": { @@ -2599,6 +2883,13 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", @@ -2719,19 +3010,6 @@ "dev": true, "license": "ISC" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2772,9 +3050,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { @@ -2797,54 +3075,31 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", + "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.1" } }, "node_modules/react-router": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", - "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", + "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2864,12 +3119,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", - "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", + "integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", "license": "MIT", "dependencies": { - "react-router": "7.9.6" + "react-router": "7.10.1" }, "engines": { "node": ">=20.0.0" @@ -2903,17 +3158,6 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", @@ -2956,30 +3200,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3038,22 +3258,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3128,7 +3332,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3136,19 +3339,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -3181,7 +3371,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3191,16 +3380,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.46.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.4.tgz", - "integrity": "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.46.4", - "@typescript-eslint/parser": "8.46.4", - "@typescript-eslint/typescript-estree": "8.46.4", - "@typescript-eslint/utils": "8.46.4" + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3227,6 +3416,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3238,12 +3458,11 @@ } }, "node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3314,9 +3533,9 @@ } }, "node_modules/vite-plugin-checker": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.10.3.tgz", - "integrity": "sha512-f4sekUcDPF+T+GdbbE8idb1i2YplBAoH+SfRS0e/WRBWb2rYb1Jf5Pimll0Rj+3JgIYWwG2K5LtBPCXxoibkLg==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz", + "integrity": "sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -3325,22 +3544,22 @@ "npm-run-path": "^6.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.3", - "strip-ansi": "^7.1.0", "tiny-invariant": "^1.3.3", - "tinyglobby": "^0.2.14", + "tinyglobby": "^0.2.15", "vscode-uri": "^3.1.0" }, "engines": { - "node": ">=14.16" + "node": ">=16.11" }, "peerDependencies": { "@biomejs/biome": ">=1.7", - "eslint": ">=7", + "eslint": ">=9.39.1", "meow": "^13.2.0", "optionator": "^0.9.4", + "oxlint": ">=1", "stylelint": ">=16", "typescript": "*", - "vite": ">=2.0.0", + "vite": ">=5.4.21", "vls": "*", "vti": "*", "vue-tsc": "~2.2.10 || ^3.0.0" @@ -3358,6 +3577,9 @@ "optionator": { "optional": true }, + "oxlint": { + "optional": true + }, "stylelint": { "optional": true }, @@ -3412,7 +3634,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3453,6 +3674,13 @@ "node": ">=0.10.0" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3466,10 +3694,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index bc8a1229..eba43b05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,27 +11,27 @@ "preview": "vite preview" }, "dependencies": { - "dompurify": "^3.2.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-router-dom": "^7.8.0", - "zustand": "^5.0.7" + "dompurify": "^3.3.1", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "react-router-dom": "^7.10.1", + "zustand": "^5.0.9" }, "devDependencies": { - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.39.0", - "@vitejs/plugin-react-swc": "^4.0.0", - "eslint": "^9.33.0", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.20", - "globals": "^16.3.0", - "prettier": "3.6.2", - "typescript": "^5.9.2", - "typescript-eslint": "^8.39.0", - "vite": ">=7.1.1", - "vite-plugin-checker": "^0.10.2" + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "prettier": "3.7.4", + "typescript": "^5.9.3", + "typescript-eslint": "^8.49.0", + "vite": ">=7.2.7", + "vite-plugin-checker": "^0.12.0" } } diff --git a/frontend/src/api/actions/queueManualImport.ts b/frontend/src/api/actions/queueManualImport.ts new file mode 100644 index 00000000..57663c85 --- /dev/null +++ b/frontend/src/api/actions/queueManualImport.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const queueManualImport = async (ignore_error: boolean, prefer_local: boolean) => { + return APIClient('/api/appsettings/manual-import/', { + method: 'POST', + body: { ignore_error, prefer_local }, + }); +}; +export default queueManualImport; diff --git a/frontend/src/api/actions/queueStartFilesystemRescan.ts b/frontend/src/api/actions/queueStartFilesystemRescan.ts new file mode 100644 index 00000000..e6eeab0b --- /dev/null +++ b/frontend/src/api/actions/queueStartFilesystemRescan.ts @@ -0,0 +1,9 @@ +import APIClient from '../../functions/APIClient'; + +const queueStartFilesystemRescan = async (ignore_error: boolean, prefer_local: boolean) => { + return APIClient('/api/appsettings/rescan-filesystem/', { + method: 'POST', + body: { ignore_error, prefer_local }, + }); +}; +export default queueStartFilesystemRescan; diff --git a/frontend/src/api/actions/updateTaskByName.ts b/frontend/src/api/actions/updateTaskByName.ts index 7053dca9..d4bfd104 100644 --- a/frontend/src/api/actions/updateTaskByName.ts +++ b/frontend/src/api/actions/updateTaskByName.ts @@ -1,12 +1,6 @@ import APIClient from '../../functions/APIClient'; -type TaskNamesType = - | 'download_pending' - | 'update_subscribed' - | 'manual_import' - | 'resync_thumbs' - | 'resync_metadata' - | 'rescan_filesystem'; +type TaskNamesType = 'download_pending' | 'update_subscribed' | 'resync_thumbs' | 'resync_metadata'; const updateTaskByName = async (taskName: TaskNamesType) => { return APIClient(`/api/task/by-name/${taskName}/`, { diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx index 95f3c8f9..24bc0999 100644 --- a/frontend/src/components/Filterbar.tsx +++ b/frontend/src/components/Filterbar.tsx @@ -60,6 +60,7 @@ const Filterbar = ({ } if (currentViewStyle === ViewStylesEnum.Table) { + // eslint-disable-next-line react-hooks/set-state-in-effect setShowHidden(true); } else { setShowHidden(false); diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx index 1c181c6e..a4822b61 100644 --- a/frontend/src/components/SearchExampleQueries.tsx +++ b/frontend/src/components/SearchExampleQueries.tsx @@ -104,6 +104,10 @@ const SearchExampleQueries = () => { auto-generated subtitles only, or user to search through user-uploaded subtitles only +
  • + channel: — limit subtitle search to a specific channel name (for + example: full:javascript channel:corey schafer) +
  • diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index f0ef79d6..b4660dac 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -146,6 +146,7 @@ const VideoPlayer = ({ } if (setSeekToTimestamp) setSeekToTimestamp(undefined); window.scroll(0, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [seekToTimestamp]); const [searchParams] = useSearchParams(); @@ -389,6 +390,7 @@ const VideoPlayer = ({ } else if (!theaterModePressed) { setTheaterModeKeyPressed(false); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [theaterModePressed, isTheaterMode, theaterModeKeyPressed]); useEffect(() => { @@ -401,6 +403,7 @@ const VideoPlayer = ({ infoDialog('Normal mode'); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [escapePressed, isTheaterMode]); return ( diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx index 910b3205..45f2bdee 100644 --- a/frontend/src/pages/Playlist.tsx +++ b/frontend/src/pages/Playlist.tsx @@ -108,7 +108,6 @@ const Playlist = () => { } setRefresh(false); })(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ playlistId, userConfig.hide_watched_playlist, diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 6af56508..47fcce9a 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -65,6 +65,13 @@ const Search = () => { const gridView = isGridView ? `boxed-${gridItems}` : ''; const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; + const fetchResults = async (searchQuery: string) => { + const searchResults = await loadSearch(searchQuery); + + setSearchResults(searchResults); + setRefresh(false); + }; + useEffect(() => { const handler = setTimeout(() => { setDebouncedSearchTerm(searchTerm); @@ -77,19 +84,13 @@ const Search = () => { useEffect(() => { if (debouncedSearchTerm.trim() !== '') { + // eslint-disable-next-line react-hooks/set-state-in-effect fetchResults(debouncedSearchTerm); } else { setSearchResults(EmptySearchResponse); } }, [debouncedSearchTerm, refresh, videoId]); - const fetchResults = async (searchQuery: string) => { - const searchResults = await loadSearch(searchQuery); - - setSearchResults(searchResults); - setRefresh(false); - }; - return ( <> TubeArchivist diff --git a/frontend/src/pages/SettingsActions.tsx b/frontend/src/pages/SettingsActions.tsx index a226019d..5262e34b 100644 --- a/frontend/src/pages/SettingsActions.tsx +++ b/frontend/src/pages/SettingsActions.tsx @@ -7,6 +7,9 @@ import restoreBackup from '../api/actions/restoreBackup'; import Notifications from '../components/Notifications'; import Button from '../components/Button'; import { ApiResponseType } from '../functions/APIClient'; +import ToggleConfig from '../components/ToggleConfig'; +import queueStartFilesystemRescan from '../api/actions/queueStartFilesystemRescan'; +import queueManualImport from '../api/actions/queueManualImport'; const SettingsActions = () => { const [deleteIgnored, setDeleteIgnored] = useState(false); @@ -17,6 +20,10 @@ const SettingsActions = () => { const [backupStarted, setBackupStarted] = useState(false); const [isRestoringBackup, setIsRestoringBackup] = useState(false); const [reScanningFileSystem, setReScanningFileSystem] = useState(false); + const [rescanIgnoreErrors, setRescanIgnoreErrors] = useState(false); + const [rescanPreferLocal, setRescanPreferLocal] = useState(false); + const [manualPreferLocal, setManualPreferLocal] = useState(false); + const [manualIgnoreErrors, setManualIgnoreErrors] = useState(false); const [backupListResponse, setBackupListResponse] = useState>(); @@ -69,22 +76,42 @@ const SettingsActions = () => {

    Manual media files import.

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

    +
    +
    +

    Prefer embedded metadata

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

    Ignore missing metadata errors

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

    Processing import

    } {!processingImports && (
    diff --git a/frontend/src/pages/SettingsScheduling.tsx b/frontend/src/pages/SettingsScheduling.tsx index 5d8084da..a5a0dd9b 100644 --- a/frontend/src/pages/SettingsScheduling.tsx +++ b/frontend/src/pages/SettingsScheduling.tsx @@ -103,6 +103,7 @@ const SettingsScheduling = () => { }, [refresh]); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setRefresh(true); }, []); diff --git a/requirements-dev.txt b/requirements-dev.txt index d03272ae..625e19b1 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,10 @@ -r backend/requirements.txt -ipython==9.7.0 -pre-commit==4.4.0 +ipython==9.8.0 +pre-commit==4.5.0 pylint-django==2.6.1 pylint==3.3.9 pytest-django==4.11.1 -pytest==9.0.1 +pytest==9.0.2 python-dotenv==1.2.1 requirementscheck==0.1.0 types-requests==2.32.4.20250913