From 483575d24a6bfcc3880a8ecf7070de4aecf855f4 Mon Sep 17 00:00:00 2001 From: Alex Shnitman Date: Wed, 1 Apr 2026 14:18:18 +0300 Subject: [PATCH] add subscriptions; change persistence file format to JSON (closes #901, #76, #113, #170, #242, #444, #503, #555, #566) --- Dockerfile | 3 - README.md | 5 +- app/main.py | 195 +++- app/state_store.py | 156 +++ app/subscriptions.py | 666 ++++++++++++ app/tests/test_download_queue.py | 31 + app/tests/test_persistent_queue.py | 198 +++- app/tests/test_state_store.py | 53 + app/tests/test_subscriptions.py | 443 ++++++++ app/tests/test_ytdl_utils.py | 73 ++ app/ytdl.py | 302 ++++-- ui/package.json | 28 +- ui/pnpm-lock.yaml | 959 +++++++++--------- ui/src/app/app.html | 231 ++++- ui/src/app/app.ts | 284 +++++- .../components/master-checkbox.component.ts | 2 +- ui/src/app/interfaces/index.ts | 2 +- ui/src/app/interfaces/subscription.ts | 15 + ui/src/app/services/subscriptions.service.ts | 128 +++ uv.lock | 138 +-- 20 files changed, 3168 insertions(+), 744 deletions(-) create mode 100644 app/state_store.py create mode 100644 app/subscriptions.py create mode 100644 app/tests/test_state_store.py create mode 100644 app/tests/test_subscriptions.py create mode 100644 ui/src/app/interfaces/subscription.ts create mode 100644 ui/src/app/services/subscriptions.service.ts diff --git a/Dockerfile b/Dockerfile index cad8d56..8d17cd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,9 +26,6 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \ gosu \ curl \ tini \ - file \ - gdbmtool \ - sqlite3 \ build-essential && \ curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && \ UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \ diff --git a/README.md b/README.md index 16228af..ad47702 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ Certain values can be set via environment variables, using the `-e` parameter on * __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`. * __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`. * __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit). +* __SUBSCRIPTION_DEFAULT_CHECK_INTERVAL__: Default minutes between automatic checks for each subscription. Defaults to `60`. +* __SUBSCRIPTION_SCAN_PLAYLIST_END__: Maximum playlist/channel entries to fetch per subscription check (newest-first). Defaults to `50`. +* __SUBSCRIPTION_MAX_SEEN_IDS__: Cap on stored video IDs per subscription to limit state file growth. Defaults to `50000`. * __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled). ### 📁 Storage & Directories @@ -46,7 +49,7 @@ Certain values can be set via environment variables, using the `-e` parameter on * __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`. * __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`. * __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`. -* __STATE_DIR__: Path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise. +* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise. * __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise. * Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance. * __Note__: Using a RAM filesystem may prevent downloads from being resumed. diff --git a/app/main.py b/app/main.py index db7be18..284198d 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ import re from watchfiles import DefaultFilter, Change, awatch from ytdl import DownloadQueueNotifier, DownloadQueue, Download +from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo from yt_dlp.version import __version__ as yt_dlp_version log = logging.getLogger('main') @@ -50,6 +51,9 @@ class Config: 'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s', 'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0', + 'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL': '60', + 'SUBSCRIPTION_SCAN_PLAYLIST_END': '50', + 'SUBSCRIPTION_MAX_SEEN_IDS': '50000', 'CLEAR_COMPLETED_AFTER': '0', 'YTDL_OPTIONS': '{}', 'YTDL_OPTIONS_FILE': '', @@ -114,6 +118,7 @@ class Config: 'PUBLIC_HOST_URL', 'PUBLIC_HOST_AUDIO_URL', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT', + 'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL', ) def frontend_safe(self) -> dict: @@ -272,6 +277,34 @@ dqueue = DownloadQueue(config, Notifier()) app.on_startup.append(lambda app: dqueue.initialize()) app.on_cleanup.append(lambda app: Download.shutdown_manager()) + +class MetubeSubscriptionNotifier(SubscriptionNotifier): + async def subscription_added(self, sub: SubscriptionInfo): + log.info("Subscription added: %s", sub.name) + await sio.emit('subscription_added', serializer.encode(sub.to_public_dict())) + + async def subscription_updated(self, sub: SubscriptionInfo): + await sio.emit('subscription_updated', serializer.encode(sub.to_public_dict())) + + async def subscription_removed(self, sub_id: str): + log.info("Subscription removed: %s", sub_id) + await sio.emit('subscription_removed', serializer.encode(sub_id)) + + async def subscriptions_all(self, subs: list[SubscriptionInfo]): + await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in subs])) + + +submgr = SubscriptionManager(config, dqueue, MetubeSubscriptionNotifier()) +app.on_cleanup.append(lambda app: submgr.close()) + + +async def _subscription_loop_startup(app): + """aiohttp on_startup requires awaitable receivers; start_background_loop is sync.""" + submgr.start_background_loop() + + +app.on_startup.append(_subscription_loop_startup) + class FileOpsFilter(DefaultFilter): def __call__(self, change_type: int, path: str) -> bool: # Check if this path matches our YTDL_OPTIONS_FILE @@ -332,27 +365,17 @@ async def _read_json_request(request: web.Request) -> dict: return post -@routes.post(config.URL_PREFIX + 'add') -async def add(request): - log.info("Received request to add download") - post = await _read_json_request(request) - post = _migrate_legacy_request(post) - log.info( - "Add download request: type=%s quality=%s format=%s has_folder=%s auto_start=%s", - post.get('download_type'), - post.get('quality'), - post.get('format'), - bool(post.get('folder')), - post.get('auto_start'), - ) +def parse_download_options(post: dict) -> dict: + """Validate add/subscribe body; raise HTTPBadRequest on invalid input.""" + post = _migrate_legacy_request(dict(post)) url = post.get('url') download_type = post.get('download_type') codec = post.get('codec') format = post.get('format') quality = post.get('quality') if not url or not quality or not download_type: - log.error("Bad request: missing 'url', 'download_type', or 'quality'") - raise web.HTTPBadRequest() + raise web.HTTPBadRequest(reason="missing 'url', 'download_type', or 'quality'") + url = str(url).strip() folder = post.get('folder') custom_name_prefix = post.get('custom_name_prefix') playlist_item_limit = post.get('playlist_item_limit') @@ -429,20 +452,54 @@ async def add(request): except (TypeError, ValueError) as exc: raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from exc + return { + 'url': url, + 'download_type': download_type, + 'codec': codec, + 'format': format, + 'quality': quality, + 'folder': folder, + 'custom_name_prefix': custom_name_prefix, + 'playlist_item_limit': playlist_item_limit, + 'auto_start': auto_start, + 'split_by_chapters': split_by_chapters, + 'chapter_template': chapter_template, + 'subtitle_language': subtitle_language, + 'subtitle_mode': subtitle_mode, + } + + +@routes.post(config.URL_PREFIX + 'add') +async def add(request): + log.info("Received request to add download") + post = await _read_json_request(request) + try: + o = parse_download_options(post) + except web.HTTPBadRequest as e: + log.error("Bad request: %s", e.reason) + raise + log.info( + "Add download request: type=%s quality=%s format=%s has_folder=%s auto_start=%s", + o['download_type'], + o['quality'], + o['format'], + bool(o.get('folder')), + o['auto_start'], + ) status = await dqueue.add( - url, - download_type, - codec, - format, - quality, - folder, - custom_name_prefix, - playlist_item_limit, - auto_start, - split_by_chapters, - chapter_template, - subtitle_language, - subtitle_mode, + o['url'], + o['download_type'], + o['codec'], + o['format'], + o['quality'], + o['folder'], + o['custom_name_prefix'], + o['playlist_item_limit'], + o['auto_start'], + o['split_by_chapters'], + o['chapter_template'], + o['subtitle_language'], + o['subtitle_mode'], ) return web.Response(text=serializer.encode(status)) @@ -451,6 +508,82 @@ async def cancel_add(request): dqueue.cancel_add() return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json') + +@routes.post(config.URL_PREFIX + 'subscribe') +async def subscribe(request): + post = await _read_json_request(request) + try: + o = parse_download_options(post) + except web.HTTPBadRequest: + raise + cic = post.get('check_interval_minutes') + if cic is None: + cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL + try: + cic = int(cic) + except (TypeError, ValueError) as exc: + raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc + if cic < 1: + raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1') + + result = await submgr.add_subscription( + o['url'], + check_interval_minutes=cic, + download_type=o['download_type'], + codec=o['codec'], + format=o['format'], + quality=o['quality'], + folder=o['folder'] or '', + custom_name_prefix=o['custom_name_prefix'], + auto_start=o['auto_start'], + playlist_item_limit=o['playlist_item_limit'], + split_by_chapters=o['split_by_chapters'], + chapter_template=o['chapter_template'], + subtitle_language=o['subtitle_language'], + subtitle_mode=o['subtitle_mode'], + ) + return web.Response(text=serializer.encode(result)) + + +@routes.get(config.URL_PREFIX + 'subscriptions') +async def subscriptions_list(request): + return web.Response(text=serializer.encode([s.to_public_dict() for s in submgr.list_all()])) + + +@routes.post(config.URL_PREFIX + 'subscriptions/update') +async def subscriptions_update(request): + post = await _read_json_request(request) + sub_id = post.get('id') + if not sub_id: + raise web.HTTPBadRequest(reason='missing subscription id') + changes = {k: v for k, v in post.items() if k != 'id' and k in ('enabled', 'check_interval_minutes', 'name')} + if not changes: + raise web.HTTPBadRequest(reason='no valid fields to update') + log.info("Subscription update requested for %s: %s", sub_id, sorted(changes.keys())) + result = await submgr.update_subscription(str(sub_id), changes) + return web.Response(text=serializer.encode(result)) + + +@routes.post(config.URL_PREFIX + 'subscriptions/delete') +async def subscriptions_delete(request): + post = await _read_json_request(request) + ids = post.get('ids') + if not ids or not isinstance(ids, list): + raise web.HTTPBadRequest(reason='missing ids list') + result = await submgr.delete_subscriptions([str(i) for i in ids]) + return web.Response(text=serializer.encode(result)) + + +@routes.post(config.URL_PREFIX + 'subscriptions/check') +async def subscriptions_check(request): + post = await _read_json_request(request) + ids = post.get('ids') + if ids is not None and not isinstance(ids, list): + raise web.HTTPBadRequest(reason='ids must be a list') + log.info("Subscription check-now requested for ids=%s", ids if ids else "all-enabled") + result = await submgr.check_now([str(i) for i in ids] if ids else None) + return web.Response(text=serializer.encode(result)) + @routes.post(config.URL_PREFIX + 'delete') async def delete(request): post = await _read_json_request(request) @@ -554,6 +687,7 @@ async def history(request): async def connect(sid, environ): log.info(f"Client connected: {sid}") await sio.emit('all', serializer.encode(dqueue.get()), to=sid) + await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid) await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid) if config.CUSTOM_DIRS: await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid) @@ -672,6 +806,11 @@ async def add_cors(request): app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors) app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscribe', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/update', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/delete', add_cors) +app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors) app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors) app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors) diff --git a/app/state_store.py b/app/state_store.py new file mode 100644 index 0000000..cab711a --- /dev/null +++ b/app/state_store.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import base64 +import collections.abc +import json +import logging +import os +import shelve +import tempfile +import time +from datetime import datetime +from typing import Any, Optional + +log = logging.getLogger("state_store") + +STATE_SCHEMA_VERSION = 2 +_BYTES_MARKER = "__metube_bytes__" +_DATETIME_MARKER = "__metube_datetime__" + + +def to_json_compatible(value: Any) -> Any: + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, bytes): + return {_BYTES_MARKER: base64.b64encode(value).decode("ascii")} + if isinstance(value, datetime): + return {_DATETIME_MARKER: value.isoformat()} + if isinstance(value, collections.abc.Mapping): + return {str(k): to_json_compatible(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set, frozenset)): + return [to_json_compatible(v) for v in value] + if isinstance(value, collections.abc.Iterable): + return [to_json_compatible(v) for v in value] + raise TypeError(f"Value of type {type(value).__name__} is not JSON serializable") + + +def from_json_compatible(value: Any) -> Any: + if isinstance(value, list): + return [from_json_compatible(v) for v in value] + if isinstance(value, dict): + if set(value.keys()) == {_BYTES_MARKER}: + return base64.b64decode(value[_BYTES_MARKER].encode("ascii")) + if set(value.keys()) == {_DATETIME_MARKER}: + return datetime.fromisoformat(value[_DATETIME_MARKER]) + return {k: from_json_compatible(v) for k, v in value.items()} + return value + + +def read_legacy_shelf(path: str) -> Optional[list[tuple[Any, Any]]]: + if not os.path.exists(path): + return None + try: + with shelve.open(path, "r") as shelf: + return list(shelf.items()) + except Exception as exc: + log.warning("Could not read legacy shelf at %s: %s", path, exc) + return None + + +class AtomicJsonStore: + def __init__(self, path: str, *, kind: str, schema_version: int = STATE_SCHEMA_VERSION): + self.path = path + self.kind = kind + self.schema_version = schema_version + + def _ensure_parent(self) -> None: + parent = os.path.dirname(self.path) + if parent and not os.path.isdir(parent): + os.makedirs(parent, exist_ok=True) + + def _build_payload(self, data: dict[str, Any]) -> dict[str, Any]: + payload = { + "schema_version": self.schema_version, + "kind": self.kind, + } + payload.update(data) + return payload + + def load(self) -> Optional[dict[str, Any]]: + if not os.path.exists(self.path): + return None + try: + with open(self.path, encoding="utf-8") as f: + payload = json.load(f) + if not isinstance(payload, dict): + raise ValueError("State file must contain a JSON object") + if payload.get("kind") != self.kind: + raise ValueError( + f"State file kind mismatch: expected {self.kind}, got {payload.get('kind')}" + ) + return payload + except Exception as exc: + self.quarantine_invalid_file(exc) + return None + + def save(self, data: dict[str, Any]) -> None: + self._ensure_parent() + payload = self._build_payload(data) + parent = os.path.dirname(self.path) or "." + fd, tmp_path = tempfile.mkstemp( + prefix=f".{os.path.basename(self.path)}.", + suffix=".tmp", + dir=parent, + text=True, + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, separators=(",", ":")) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.path) + self._fsync_directory(parent) + except Exception: + try: + os.remove(tmp_path) + except OSError: + pass + raise + + def quarantine_invalid_file(self, exc: Exception) -> None: + if not os.path.exists(self.path): + return + ts = time.strftime("%Y%m%d%H%M%S") + backup_path = f"{self.path}.invalid.{ts}" + try: + os.replace(self.path, backup_path) + log.warning( + "State file at %s was invalid (%s); moved it to %s", + self.path, + exc, + backup_path, + ) + except OSError as move_exc: + log.warning( + "State file at %s was invalid (%s) and could not be moved aside: %s", + self.path, + exc, + move_exc, + ) + + @staticmethod + def _fsync_directory(path: str) -> None: + try: + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + fd = os.open(path, flags) + except OSError: + return + try: + os.fsync(fd) + except OSError: + pass + finally: + os.close(fd) diff --git a/app/subscriptions.py b/app/subscriptions.py new file mode 100644 index 0000000..901ff59 --- /dev/null +++ b/app/subscriptions.py @@ -0,0 +1,666 @@ +"""Channel/playlist subscriptions: periodic yt-dlp flat extract + queue new videos.""" + +from __future__ import annotations + +import asyncio +import copy +import logging +import os +import time +import types +import uuid +from dataclasses import dataclass, field, fields +from typing import Any, Optional + +import yt_dlp +import yt_dlp.networking.impersonate +from state_store import AtomicJsonStore, read_legacy_shelf + +log = logging.getLogger("subscriptions") + +VIDEO_ONLY_MSG = ( + "This URL points to a single video, not a channel or playlist. Use Download instead." +) +_MEDIA_HINT_FIELDS = ( + "duration", + "timestamp", + "release_timestamp", + "upload_date", + "view_count", + "live_status", + "availability", +) + + +def _impersonate_opt(ytdl_options: dict) -> dict: + opts = dict(ytdl_options) + if "impersonate" in opts: + opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str( + opts["impersonate"] + ) + return opts + + +def _build_ydl_params(config, *, playlistend: Optional[int] = None) -> dict: + params: dict[str, Any] = { + "quiet": not logging.getLogger().isEnabledFor(logging.DEBUG), + "verbose": logging.getLogger().isEnabledFor(logging.DEBUG), + "no_color": True, + "extract_flat": True, + "ignore_no_formats_error": True, + "lazy_playlist": True, + "paths": {"home": config.DOWNLOAD_DIR, "temp": config.TEMP_DIR}, + **config.YTDL_OPTIONS, + } + params = _impersonate_opt(params) + if playlistend is not None and playlistend > 0: + params["playlistend"] = playlistend + return params + + +def _is_media_entry(entry: Any) -> bool: + if not isinstance(entry, dict): + return False + etype = str(entry.get("_type") or "") + if etype in ("playlist", "multi_video", "channel"): + return False + if entry.get("entries"): + return False + url = _entry_video_url(entry) + if not url: + return False + ie_key = str(entry.get("ie_key") or entry.get("extractor_key") or "").lower() + if any(token in ie_key for token in ("playlist", "channel", "tab")): + return any(entry.get(field) is not None for field in _MEDIA_HINT_FIELDS) + return True + + +def extract_flat_playlist(config, url: str, playlistend: int, *, _depth: int = 0): + """Return (info_dict, entries_list) for playlist/channel URLs.""" + params = _build_ydl_params(config, playlistend=playlistend) + with yt_dlp.YoutubeDL(params=params) as ydl: + info = ydl.extract_info(url, download=False) + if not info: + return None, [] + etype = info.get("_type") or "video" + if etype == "video": + return info, [] + if etype in ("playlist", "channel"): + entries = info.get("entries") or [] + if isinstance(entries, types.GeneratorType): + entries = list(entries) + # Drop None placeholders from incomplete flat playlists + entries = [e for e in entries if e] + media_entries = [e for e in entries if _is_media_entry(e)] + if media_entries: + return info, media_entries + if _depth < 1: + for ent in entries[:5]: + nested_url = _entry_video_url(ent) + if not nested_url: + continue + nested_info, nested_entries = extract_flat_playlist( + config, + nested_url, + playlistend, + _depth=_depth + 1, + ) + if nested_entries: + return nested_info, nested_entries + return info, entries + if etype.startswith("url") and info.get("url"): + # Single nested URL without playlist wrapper — treat as non-subscribable + return info, [] + return info, [] + + +def _entry_video_url(entry: dict) -> Optional[str]: + return entry.get("webpage_url") or entry.get("url") + + +def _entry_id(entry: dict) -> Optional[str]: + eid = entry.get("id") + if eid is not None: + return str(eid) + url = _entry_video_url(entry) + return url + + +@dataclass +class SubscriptionInfo: + id: str + name: str + url: str + enabled: bool = True + check_interval_minutes: int = 60 + download_type: str = "video" + codec: str = "auto" + format: str = "any" + quality: str = "best" + folder: str = "" + custom_name_prefix: str = "" + auto_start: bool = True + playlist_item_limit: int = 0 + split_by_chapters: bool = False + chapter_template: str = "" + subtitle_language: str = "en" + subtitle_mode: str = "prefer_manual" + last_checked: Optional[float] = None + seen_ids: list[str] = field(default_factory=list) + error: Optional[str] = None + timestamp: float = field(default_factory=time.time) + + def seen_set(self) -> set[str]: + return set(self.seen_ids) + + def to_public_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "url": self.url, + "enabled": self.enabled, + "check_interval_minutes": self.check_interval_minutes, + "download_type": self.download_type, + "codec": self.codec, + "format": self.format, + "quality": self.quality, + "folder": self.folder, + "last_checked": self.last_checked, + "seen_count": len(self.seen_ids), + "error": self.error, + } + + +def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]: + return { + "id": sub.id, + "name": sub.name, + "url": sub.url, + "enabled": sub.enabled, + "check_interval_minutes": sub.check_interval_minutes, + "download_type": sub.download_type, + "codec": sub.codec, + "format": sub.format, + "quality": sub.quality, + "folder": sub.folder, + "custom_name_prefix": sub.custom_name_prefix, + "auto_start": sub.auto_start, + "playlist_item_limit": sub.playlist_item_limit, + "split_by_chapters": sub.split_by_chapters, + "chapter_template": sub.chapter_template, + "subtitle_language": sub.subtitle_language, + "subtitle_mode": sub.subtitle_mode, + "last_checked": sub.last_checked, + "seen_ids": list(sub.seen_ids), + "error": sub.error, + } + + +def _subscription_from_record(record: Any) -> Optional[SubscriptionInfo]: + field_names = {f.name for f in fields(SubscriptionInfo)} + if isinstance(record, SubscriptionInfo): + return record + if isinstance(record, dict): + try: + return SubscriptionInfo(**{k: v for k, v in record.items() if k in field_names}) + except TypeError: + return None + return None + + +class SubscriptionNotifier: + """Hook for Socket.IO / UI updates.""" + + async def subscription_added(self, sub: SubscriptionInfo) -> None: + raise NotImplementedError + + async def subscription_updated(self, sub: SubscriptionInfo) -> None: + raise NotImplementedError + + async def subscription_removed(self, sub_id: str) -> None: + raise NotImplementedError + + async def subscriptions_all(self, subs: list[SubscriptionInfo]) -> None: + raise NotImplementedError + + +class SubscriptionManager: + def __init__(self, config, download_queue, notifier: SubscriptionNotifier): + self.config = config + self.dqueue = download_queue + self.notifier = notifier + pdir = config.STATE_DIR + if not os.path.isdir(pdir): + os.makedirs(pdir, exist_ok=True) + self._legacy_path = os.path.join(pdir, "subscriptions") + self._path = os.path.join(pdir, "subscriptions.json") + self._store = AtomicJsonStore(self._path, kind="subscriptions") + self._subs: dict[str, SubscriptionInfo] = {} + self._url_index: dict[str, str] = {} # normalized url -> id + self._pending_urls: set[str] = set() + self._lock = asyncio.Lock() + self._loop_task: Optional[asyncio.Task] = None + self._load_all() + + def close(self) -> None: + # No persistent shelf handle to close. + return + + def _normalize_url(self, url: str) -> str: + return (url or "").strip() + + def _normalize_seen_ids(self, seen_ids: list[str]) -> list[str]: + max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000)) + normalized = [str(sid) for sid in dict.fromkeys(seen_ids)] + if len(normalized) > max_seen: + normalized = normalized[:max_seen] + return normalized + + def _load_all(self) -> None: + payload = self._store.load() + loaded_from_legacy = False + if payload is not None: + records = payload.get("items") or [] + else: + legacy_items = read_legacy_shelf(self._legacy_path) + records = [raw for _key, raw in legacy_items] if legacy_items else [] + if records: + loaded_from_legacy = True + + loaded_subs = self._iter_valid_subs(records) + compact_records = [] + for sub in loaded_subs: + sub.seen_ids = self._normalize_seen_ids(sub.seen_ids) + self._subs[sub.id] = sub + self._url_index[self._normalize_url(sub.url)] = sub.id + compact_records.append(_subscription_to_record(sub)) + + if loaded_from_legacy or ( + payload is not None + and ( + payload.get("schema_version") != self._store.schema_version + or compact_records != records + ) + ): + self._store.save({"items": compact_records}) + + def _iter_valid_subs(self, records: list[Any]) -> list[SubscriptionInfo]: + subs: list[SubscriptionInfo] = [] + for record in records: + sub = _subscription_from_record(record) + if sub is not None: + subs.append(sub) + return subs + + def _save_locked(self) -> None: + self._store.save({"items": [_subscription_to_record(sub) for sub in self._subs.values()]}) + + async def _queue_subscription_entries( + self, + entries: list[dict], + *, + download_type: str, + codec: str, + format: str, + quality: str, + folder: str, + custom_name_prefix: str, + playlist_item_limit: int, + auto_start: bool, + split_by_chapters: bool, + chapter_template: str, + subtitle_language: str, + subtitle_mode: str, + ) -> tuple[list[str], list[str]]: + queued_ids: list[str] = [] + queue_errors: list[str] = [] + for ent in entries: + eid = _entry_id(ent) + vurl = _entry_video_url(ent) + if not eid or not vurl: + continue + queue_entry = dict(ent) + queue_entry["_type"] = "video" + queue_entry["webpage_url"] = vurl + result = await self.dqueue.add_entry( + queue_entry, + download_type, + codec, + format, + quality, + folder or None, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template or None, + subtitle_language, + subtitle_mode, + ) + if isinstance(result, dict) and result.get("status") == "error": + msg = str(result.get("msg") or f"Queueing failed for {vurl}") + queue_errors.append(msg) + log.warning("Subscription queueing failed for %s: %s", vurl, msg) + continue + queued_ids.append(eid) + return queued_ids, queue_errors + + def list_all(self) -> list[SubscriptionInfo]: + return list(self._subs.values()) + + def get(self, sub_id: str) -> Optional[SubscriptionInfo]: + return self._subs.get(sub_id) + + def start_background_loop(self) -> None: + if self._loop_task is not None and not self._loop_task.done(): + return + self._loop_task = asyncio.create_task(self._periodic_loop()) + self._loop_task.add_done_callback( + lambda t: log.error("Subscription loop failed: %s", t.exception()) + if not t.cancelled() and t.exception() + else None + ) + + async def _periodic_loop(self) -> None: + while True: + await asyncio.sleep(60) + try: + await self.run_due_checks() + except Exception as e: + log.exception("Subscription periodic check error: %s", e) + + async def run_due_checks(self) -> None: + now = time.time() + due: list[SubscriptionInfo] = [] + async with self._lock: + for sub in list(self._subs.values()): + if not sub.enabled: + continue + interval_sec = max(60, int(sub.check_interval_minutes) * 60) + if sub.last_checked is None: + due.append(sub) + continue + if now - sub.last_checked < interval_sec: + continue + due.append(sub) + for sub in due: + await self._check_one_unlocked(sub) + + async def add_subscription( + self, + url: str, + *, + check_interval_minutes: int, + download_type: str, + codec: str, + format: str, + quality: str, + folder: str, + custom_name_prefix: str, + auto_start: bool, + playlist_item_limit: int, + split_by_chapters: bool, + chapter_template: str, + subtitle_language: str, + subtitle_mode: str, + ) -> dict: + url = self._normalize_url(url) + if not url: + return {"status": "error", "msg": "Missing URL"} + + async with self._lock: + if url in self._url_index or url in self._pending_urls: + return {"status": "error", "msg": "This URL is already subscribed"} + self._pending_urls.add(url) + + try: + scan_first = max(int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50)), 1) + try: + info, entries = extract_flat_playlist(self.config, url, scan_first) + except yt_dlp.utils.YoutubeDLError as exc: + return {"status": "error", "msg": str(exc)} + + if not info: + return {"status": "error", "msg": "Could not resolve URL"} + + etype = info.get("_type") or "video" + if etype not in ("playlist", "channel"): + return {"status": "error", "msg": VIDEO_ONLY_MSG} + + name = ( + info.get("title") + or info.get("channel") + or info.get("playlist_title") + or info.get("uploader") + or url + ) + + seen_entries = [ent for ent in entries if _is_media_entry(ent)] + all_ids: list[str] = [] + for ent in seen_entries: + eid = _entry_id(ent) + if eid: + all_ids.append(eid) + + sub = SubscriptionInfo( + id=str(uuid.uuid4()), + name=str(name), + url=url, + enabled=True, + check_interval_minutes=max(1, int(check_interval_minutes)), + download_type=download_type, + codec=codec, + format=format, + quality=quality, + folder=folder or "", + custom_name_prefix=custom_name_prefix or "", + auto_start=bool(auto_start), + playlist_item_limit=int(playlist_item_limit), + split_by_chapters=bool(split_by_chapters), + chapter_template=chapter_template or "", + subtitle_language=subtitle_language, + subtitle_mode=subtitle_mode, + last_checked=time.time(), + seen_ids=list(dict.fromkeys(all_ids)), + error=None, + ) + + async with self._lock: + if url in self._url_index: + return {"status": "error", "msg": "This URL is already subscribed"} + self._subs[sub.id] = sub + self._url_index[url] = sub.id + try: + self._save_locked() + except Exception: + self._subs.pop(sub.id, None) + self._url_index.pop(url, None) + raise + + await self.notifier.subscription_added(sub) + return {"status": "ok", "subscription": sub.to_public_dict()} + finally: + async with self._lock: + self._pending_urls.discard(url) + + async def delete_subscriptions(self, ids: list[str]) -> dict: + removed: list[str] = [] + async with self._lock: + previous_subs = self._subs.copy() + previous_index = self._url_index.copy() + for sid in ids: + sub = self._subs.pop(sid, None) + if sub: + normalized_url = self._normalize_url(sub.url) + self._url_index.pop(normalized_url, None) + removed.append(sid) + if removed: + try: + self._save_locked() + except Exception: + self._subs = previous_subs + self._url_index = previous_index + raise + for sid in removed: + await self.notifier.subscription_removed(sid) + return {"status": "ok"} + + async def update_subscription(self, sub_id: str, changes: dict) -> dict: + async with self._lock: + sub = self._subs.get(sub_id) + if not sub: + return {"status": "error", "msg": "Subscription not found"} + previous = copy.deepcopy(sub) + old_enabled = sub.enabled + + if "enabled" in changes: + sub.enabled = bool(changes["enabled"]) + if "check_interval_minutes" in changes: + sub.check_interval_minutes = max(1, int(changes["check_interval_minutes"])) + if "name" in changes and changes["name"]: + sub.name = str(changes["name"]) + + try: + self._save_locked() + except Exception: + self._subs[sub_id] = previous + raise + updated = sub + if "enabled" in changes and updated.enabled != old_enabled: + log.info( + "Subscription %s %s", + updated.name, + "resumed" if updated.enabled else "paused", + ) + await self.notifier.subscription_updated(updated) + return {"status": "ok", "subscription": updated.to_public_dict()} + + async def check_now(self, ids: Optional[list[str]] = None) -> dict: + async with self._lock: + targets = ( + [self._subs[i] for i in ids if i in self._subs] + if ids + else [s for s in self._subs.values() if s.enabled] + ) + log.info( + "Manual subscription check requested for %d subscription(s)", + len(targets), + ) + for sub in targets: + await self._check_one_unlocked(sub) + return {"status": "ok"} + + async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None: + sid = sub.id + scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50)) + log.info("Checking subscription: %s", sub.name) + try: + info, entries = extract_flat_playlist(self.config, sub.url, scan) + except yt_dlp.utils.YoutubeDLError as exc: + async with self._lock: + cur = self._subs.get(sid) + if cur: + previous = copy.deepcopy(cur) + cur.error = str(exc) + try: + self._save_locked() + except Exception: + self._subs[sid] = previous + raise + sub = cur + log.warning("Subscription check failed for %s: %s", sub.name, exc) + await self.notifier.subscription_updated(sub) + return + entries = [ent for ent in entries if _is_media_entry(ent)] + + etype = (info or {}).get("_type") or "video" + if etype == "video" or not entries: + async with self._lock: + cur = self._subs.get(sid) + if cur: + previous = copy.deepcopy(cur) + cur.error = VIDEO_ONLY_MSG + try: + self._save_locked() + except Exception: + self._subs[sid] = previous + raise + sub = cur + log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name) + await self.notifier.subscription_updated(sub) + return + + async with self._lock: + cur = self._subs.get(sid) + if not cur: + return + seen = cur.seen_set() + seen_ids_snapshot = list(cur.seen_ids) + dl_type = cur.download_type + dl_codec = cur.codec + dl_format = cur.format + dl_quality = cur.quality + dl_folder = cur.folder + dl_prefix = cur.custom_name_prefix + dl_plimit = cur.playlist_item_limit + dl_autostart = cur.auto_start + dl_split = cur.split_by_chapters + dl_chapter = cur.chapter_template + dl_sublang = cur.subtitle_language + dl_submode = cur.subtitle_mode + + new_entries: list[dict] = [] + new_ids: list[str] = [] + for ent in entries: + eid = _entry_id(ent) + if not eid or eid in seen: + continue + new_entries.append(ent) + new_ids.append(eid) + + queued_ids, queue_errors = await self._queue_subscription_entries( + new_entries, + download_type=dl_type, + codec=dl_codec, + format=dl_format, + quality=dl_quality, + folder=dl_folder, + custom_name_prefix=dl_prefix, + playlist_item_limit=dl_plimit, + auto_start=dl_autostart, + split_by_chapters=dl_split, + chapter_template=dl_chapter or "", + subtitle_language=dl_sublang, + subtitle_mode=dl_submode, + ) + log.info( + "Subscription check finished for %s: %d new, %d queued, %d failed", + sub.name, + len(new_entries), + len(queued_ids), + len(queue_errors), + ) + + merged = list(dict.fromkeys(queued_ids + seen_ids_snapshot)) + max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000)) + if len(merged) > max_seen: + merged = merged[:max_seen] + + async with self._lock: + cur = self._subs.get(sid) + if not cur: + return + previous = copy.deepcopy(cur) + cur.seen_ids = merged + cur.last_checked = time.time() + cur.error = "; ".join(queue_errors[:3]) if queue_errors else None + try: + self._save_locked() + except Exception: + self._subs[sid] = previous + raise + sub = cur + await self.notifier.subscription_updated(sub) + + async def emit_all(self) -> None: + await self.notifier.subscriptions_all(self.list_all()) diff --git a/app/tests/test_download_queue.py b/app/tests/test_download_queue.py index 36f2da4..dfa21c7 100644 --- a/app/tests/test_download_queue.py +++ b/app/tests/test_download_queue.py @@ -144,3 +144,34 @@ async def test_start_pending_moves_to_queue(dq_env): with patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()): await dq.start_pending([url]) assert not dq.pending.exists(url) + + +@pytest.mark.asyncio +async def test_add_entry_queues_single_video_without_reextracting(dq_env): + notifier = AsyncMock() + dq = DownloadQueue(dq_env, notifier) + entry = { + "_type": "video", + "id": "vid1", + "title": "Test Video", + "url": "https://example.com/watch?v=1", + "webpage_url": "https://example.com/watch?v=1", + "playlist_index": "01", + "playlist_title": "Playlist", + } + + with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")): + result = await dq.add_entry( + entry, + "video", + "auto", + "any", + "best", + "", + "", + 0, + auto_start=False, + ) + + assert result["status"] == "ok" + assert dq.pending.exists("https://example.com/watch?v=1") diff --git a/app/tests/test_persistent_queue.py b/app/tests/test_persistent_queue.py index 57b814a..4bb0226 100644 --- a/app/tests/test_persistent_queue.py +++ b/app/tests/test_persistent_queue.py @@ -1,12 +1,39 @@ -"""Integration tests for ``PersistentQueue`` (shelve-backed storage).""" +"""Integration tests for ``PersistentQueue`` using the JSON state store.""" from __future__ import annotations +import json import os +import shelve +import sys import tempfile +import types import unittest from unittest.mock import patch +fake_yt_dlp = types.ModuleType("yt_dlp") +fake_networking = types.ModuleType("yt_dlp.networking") +fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate") +fake_utils = types.ModuleType("yt_dlp.utils") + + +class _ImpersonateTarget: + @staticmethod + def from_str(value): + return value + + +fake_impersonate.ImpersonateTarget = _ImpersonateTarget +fake_networking.impersonate = fake_impersonate +fake_utils.STR_FORMAT_RE_TMPL = r"(?P)%\((?P{})\)(?P[-0-9.]*{})" +fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa" +fake_yt_dlp.networking = fake_networking +fake_yt_dlp.utils = fake_utils +sys.modules.setdefault("yt_dlp", fake_yt_dlp) +sys.modules.setdefault("yt_dlp.networking", fake_networking) +sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate) +sys.modules.setdefault("yt_dlp.utils", fake_utils) + from ytdl import DownloadInfo, PersistentQueue @@ -36,6 +63,12 @@ def _make_info(url: str = "https://example.com/v") -> DownloadInfo: ) +def _create_legacy_shelf(path: str, *infos: DownloadInfo) -> None: + with shelve.open(path, "c") as shelf: + for info in infos: + shelf[info.url] = info + + class PersistentQueueTests(unittest.TestCase): def test_put_get_delete_roundtrip(self): with tempfile.TemporaryDirectory() as tmp: @@ -43,6 +76,7 @@ class PersistentQueueTests(unittest.TestCase): pq = PersistentQueue("queue", path) dl = _FakeDownload(_make_info("http://a.example")) pq.put(dl) + self.assertTrue(os.path.exists(path + ".json")) self.assertTrue(pq.exists("http://a.example")) self.assertFalse(pq.empty()) got = pq.get("http://a.example") @@ -63,7 +97,7 @@ class PersistentQueueTests(unittest.TestCase): keys = [k for k, _ in pq.saved_items()] self.assertEqual(keys, ["http://first.example", "http://second.example"]) - def test_load_restores_from_shelve(self): + def test_load_restores_from_json(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "queue") pq1 = PersistentQueue("queue", path) @@ -72,21 +106,159 @@ class PersistentQueueTests(unittest.TestCase): pq2.load() self.assertTrue(pq2.exists("http://load.example")) - def test_put_rollbacks_in_memory_queue_when_shelf_write_fails(self): + def test_load_imports_legacy_shelve(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + _create_legacy_shelf(path, _make_info("http://legacy.example")) + pq = PersistentQueue("queue", path) + pq.load() + self.assertTrue(pq.exists("http://legacy.example")) + self.assertTrue(os.path.exists(path + ".json")) + + def test_queue_persists_only_compact_entry_subset(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + pq = PersistentQueue("queue", path) + info = _make_info("http://entry.example") + info.entry = { + "playlist_index": "01", + "playlist_title": "Playlist", + "channel_index": "02", + "channel_title": "Channel", + "formats": [{"id": "huge"}], + "description": "very large payload", + } + pq.put(_FakeDownload(info)) + + with open(path + ".json", encoding="utf-8") as f: + payload = json.load(f) + + record = payload["items"][0]["info"] + self.assertEqual( + record["entry"], + { + "playlist_index": "01", + "playlist_title": "Playlist", + "channel_index": "02", + "channel_title": "Channel", + }, + ) + self.assertNotIn("formats", record["entry"]) + self.assertNotIn("description", record["entry"]) + + def test_completed_queue_does_not_persist_entry_or_transient_progress(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "completed") + pq = PersistentQueue("completed", path) + info = _make_info("http://done.example") + info.status = "finished" + info.percent = 88 + info.speed = 123 + info.eta = 9 + info.entry = { + "playlist_index": "01", + "playlist_title": "Playlist", + "formats": [{"id": "huge"}], + } + info.filename = "done.mp4" + pq.put(_FakeDownload(info)) + + with open(path + ".json", encoding="utf-8") as f: + payload = json.load(f) + + record = payload["items"][0]["info"] + self.assertNotIn("entry", record) + self.assertNotIn("percent", record) + self.assertNotIn("speed", record) + self.assertNotIn("eta", record) + self.assertEqual(record["filename"], "done.mp4") + + def test_invalid_json_is_quarantined_and_legacy_is_imported(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + _create_legacy_shelf(path, _make_info("http://legacy.example")) + with open(path + ".json", "w", encoding="utf-8") as f: + f.write("{not valid json") + + pq = PersistentQueue("queue", path) + pq.load() + + self.assertTrue(pq.exists("http://legacy.example")) + self.assertTrue( + any(name.startswith("queue.json.invalid.") for name in os.listdir(tmp)) + ) + + def test_loading_old_json_rewrites_to_compact_format(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue") + with open(path + ".json", "w", encoding="utf-8") as f: + json.dump( + { + "schema_version": 1, + "kind": "persistent_queue:queue", + "items": [ + { + "key": "http://legacy-json.example", + "info": { + "id": "id1", + "title": "Title", + "url": "http://legacy-json.example", + "quality": "best", + "download_type": "video", + "codec": "auto", + "format": "any", + "folder": "", + "custom_name_prefix": "", + "playlist_item_limit": 0, + "split_by_chapters": False, + "chapter_template": "", + "subtitle_language": "en", + "subtitle_mode": "prefer_manual", + "status": "pending", + "timestamp": 1, + "entry": { + "playlist_index": "01", + "playlist_title": "Playlist", + "formats": [{"id": "huge"}], + }, + "percent": 15, + "speed": 20, + "eta": 30, + }, + } + ], + }, + f, + ) + + pq = PersistentQueue("queue", path) + pq.load() + + with open(path + ".json", encoding="utf-8") as f: + payload = json.load(f) + + record = payload["items"][0]["info"] + self.assertEqual(payload["schema_version"], 2) + self.assertEqual(record["entry"], {"playlist_index": "01", "playlist_title": "Playlist"}) + self.assertNotIn("percent", record) + self.assertNotIn("speed", record) + self.assertNotIn("eta", record) + + def test_put_rollbacks_in_memory_queue_when_state_write_fails(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "queue") pq = PersistentQueue("queue", path) dl = _FakeDownload(_make_info("http://rollback.example")) self.assertFalse(pq.exists("http://rollback.example")) - orig_open = __import__("shelve").open + orig_save = __import__("state_store").AtomicJsonStore.save - def bad_open(filename, flag="c", *args, **kwargs): - if flag == "w": + def bad_save(store, data): + if store.path == path + ".json": raise OSError("simulated shelf failure") - return orig_open(filename, flag, *args, **kwargs) + return orig_save(store, data) - with patch("ytdl.shelve.open", bad_open): + with patch("ytdl.AtomicJsonStore.save", bad_save): with self.assertRaises(OSError): pq.put(dl) @@ -101,14 +273,14 @@ class PersistentQueueTests(unittest.TestCase): second.info.title = "Replaced title" pq.put(first) - orig_open = __import__("shelve").open + orig_save = __import__("state_store").AtomicJsonStore.save - def bad_open(filename, flag="c", *args, **kwargs): - if flag == "w": + def bad_save(store, data): + if store.path == path + ".json": raise OSError("simulated shelf failure") - return orig_open(filename, flag, *args, **kwargs) + return orig_save(store, data) - with patch("ytdl.shelve.open", bad_open): + with patch("ytdl.AtomicJsonStore.save", bad_save): with self.assertRaises(OSError): pq.put(second) diff --git a/app/tests/test_state_store.py b/app/tests/test_state_store.py new file mode 100644 index 0000000..fb71b08 --- /dev/null +++ b/app/tests/test_state_store.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from datetime import datetime + +from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible + + +class StateStoreTests(unittest.TestCase): + def test_save_and_load_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + store.save({"items": [{"key": "a", "info": {"title": "hello"}}]}) + + payload = store.load() + + self.assertEqual(payload["kind"], "persistent_queue:queue") + self.assertEqual(payload["schema_version"], 2) + self.assertEqual(payload["items"][0]["info"]["title"], "hello") + + def test_invalid_file_is_quarantined(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + with open(path, "w", encoding="utf-8") as f: + f.write("{broken") + + store = AtomicJsonStore(path, kind="persistent_queue:queue") + payload = store.load() + + self.assertIsNone(payload) + self.assertTrue( + any(name.startswith("queue.json.invalid.") for name in os.listdir(tmp)) + ) + + def test_json_compat_helpers_roundtrip_bytes_and_datetime(self): + raw = { + "payload": b"abc", + "timestamp": datetime(2024, 1, 2, 3, 4, 5), + "items": (1, 2, 3), + } + + restored = from_json_compatible(to_json_compatible(raw)) + + self.assertEqual(restored["payload"], b"abc") + self.assertEqual(restored["timestamp"], datetime(2024, 1, 2, 3, 4, 5)) + self.assertEqual(restored["items"], [1, 2, 3]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_subscriptions.py b/app/tests/test_subscriptions.py new file mode 100644 index 0000000..9fc465b --- /dev/null +++ b/app/tests/test_subscriptions.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import json +import os +import shelve +import sys +import tempfile +import types +import unittest +from unittest.mock import patch + +fake_yt_dlp = types.ModuleType("yt_dlp") +fake_networking = types.ModuleType("yt_dlp.networking") +fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate") + + +class _ImpersonateTarget: + @staticmethod + def from_str(value): + return value + + +fake_impersonate.ImpersonateTarget = _ImpersonateTarget +fake_networking.impersonate = fake_impersonate +fake_yt_dlp.networking = fake_networking +fake_yt_dlp.utils = types.SimpleNamespace(YoutubeDLError=Exception) +sys.modules.setdefault("yt_dlp", fake_yt_dlp) +sys.modules.setdefault("yt_dlp.networking", fake_networking) +sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate) + +from subscriptions import SubscriptionManager, extract_flat_playlist + + +class _Config: + def __init__(self, state_dir: str): + self.STATE_DIR = state_dir + self.SUBSCRIPTION_SCAN_PLAYLIST_END = 50 + self.SUBSCRIPTION_MAX_SEEN_IDS = 50000 + self.DOWNLOAD_DIR = state_dir + self.TEMP_DIR = state_dir + self.YTDL_OPTIONS = {} + + +class _Queue: + def __init__(self): + self.entries = [] + self.fail = False + + async def add(self, *args, **kwargs): + return None + + async def add_entry(self, entry, *args, **kwargs): + if self.fail: + return {"status": "error", "msg": "queue failed"} + self.entries.append((entry, args, kwargs)) + return {"status": "ok"} + + +class _Notifier: + async def subscription_added(self, sub): + return None + + async def subscription_updated(self, sub): + return None + + async def subscription_removed(self, sub_id): + return None + + async def subscriptions_all(self, subs): + return None + + +def _create_legacy_shelf(path: str, record) -> None: + with shelve.open(path, "c") as shelf: + shelf["sub-1"] = record + + +class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase): + def test_load_imports_legacy_subscription_shelf(self): + with tempfile.TemporaryDirectory() as tmp: + legacy_path = os.path.join(tmp, "subscriptions") + json_path = os.path.join(tmp, "subscriptions.json") + _create_legacy_shelf( + legacy_path, + { + "id": "sub-1", + "name": "Channel", + "url": "https://example.com/channel", + "timestamp": 1.0, + }, + ) + + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + + self.assertEqual(len(mgr.list_all()), 1) + self.assertTrue(os.path.exists(json_path)) + with open(json_path, encoding="utf-8") as f: + payload = json.load(f) + self.assertEqual(payload["schema_version"], 2) + self.assertNotIn("timestamp", payload["items"][0]) + + def test_invalid_json_is_quarantined_and_legacy_is_imported(self): + with tempfile.TemporaryDirectory() as tmp: + legacy_path = os.path.join(tmp, "subscriptions") + json_path = os.path.join(tmp, "subscriptions.json") + _create_legacy_shelf( + legacy_path, + { + "id": "sub-1", + "name": "Channel", + "url": "https://example.com/channel", + "timestamp": 1.0, + }, + ) + with open(json_path, "w", encoding="utf-8") as f: + f.write("{not valid json") + + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + + self.assertEqual(len(mgr.list_all()), 1) + self.assertTrue( + any(name.startswith("subscriptions.json.invalid.") for name in os.listdir(tmp)) + ) + + def test_load_rewrites_old_json_and_trims_seen_ids(self): + with tempfile.TemporaryDirectory() as tmp: + json_path = os.path.join(tmp, "subscriptions.json") + cfg = _Config(tmp) + cfg.SUBSCRIPTION_MAX_SEEN_IDS = 2 + with open(json_path, "w", encoding="utf-8") as f: + json.dump( + { + "schema_version": 1, + "kind": "subscriptions", + "items": [ + { + "id": "sub-1", + "name": "Channel", + "url": "https://example.com/channel", + "enabled": True, + "check_interval_minutes": 60, + "download_type": "video", + "codec": "auto", + "format": "any", + "quality": "best", + "folder": "", + "custom_name_prefix": "", + "auto_start": True, + "playlist_item_limit": 0, + "split_by_chapters": False, + "chapter_template": "", + "subtitle_language": "en", + "subtitle_mode": "prefer_manual", + "last_checked": None, + "seen_ids": ["a", "b", "a", "c"], + "error": None, + "timestamp": 123, + } + ], + }, + f, + ) + + mgr = SubscriptionManager(cfg, _Queue(), _Notifier()) + self.assertEqual(mgr.list_all()[0].seen_ids, ["a", "b"]) + + with open(json_path, encoding="utf-8") as f: + payload = json.load(f) + + self.assertEqual(payload["schema_version"], 2) + self.assertEqual(payload["items"][0]["seen_ids"], ["a", "b"]) + self.assertNotIn("timestamp", payload["items"][0]) + + async def test_add_subscription_rolls_back_when_state_write_fails(self): + with tempfile.TemporaryDirectory() as tmp: + mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier()) + + orig_save = __import__("state_store").AtomicJsonStore.save + + def bad_save(store, data): + if store.path == mgr._path: + raise OSError("simulated shelf failure") + return orig_save(store, data) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "webpage_url": "https://example.com/v1"}], + ), + ): + with patch("subscriptions.AtomicJsonStore.save", bad_save): + with self.assertRaises(OSError): + await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + + self.assertEqual(mgr.list_all(), []) + self.assertNotIn("https://example.com/channel", mgr._url_index) + + async def test_add_subscription_marks_existing_videos_seen_without_queueing(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [ + {"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}, + {"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"}, + {"id": "v3", "title": "Three", "webpage_url": "https://example.com/v3"}, + ], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + + assert result["status"] == "ok" + sub = mgr.list_all()[0] + self.assertEqual(sub.seen_ids, ["v1", "v2", "v3"]) + self.assertIsNone(sub.error) + self.assertEqual(queue.entries, []) + + async def test_add_subscription_skips_collection_tab_entries(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + return_value=( + {"_type": "channel", "title": "Channel"}, + [ + { + "_type": "url", + "ie_key": "YoutubeTab", + "title": "Channel - Live", + "url": "https://example.com/live", + "webpage_url": "https://example.com/live", + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "v1", + "title": "One", + "duration": 10, + "webpage_url": "https://example.com/v1", + }, + ], + ), + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + + self.assertEqual(result["status"], "ok") + sub = mgr.list_all()[0] + self.assertEqual(sub.seen_ids, ["v1"]) + self.assertEqual(queue.entries, []) + + async def test_check_now_keeps_failed_queue_items_unseen_and_sets_error(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + side_effect=[ + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"}], + ), + ], + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + queue.fail = True + await mgr.check_now([result["subscription"]["id"]]) + + sub = mgr.list_all()[0] + self.assertEqual(sub.error, "queue failed") + self.assertEqual(sub.seen_ids, ["v1"]) + + async def test_check_now_queues_new_video_and_updates_seen_ids(self): + with tempfile.TemporaryDirectory() as tmp: + queue = _Queue() + mgr = SubscriptionManager(_Config(tmp), queue, _Notifier()) + + with patch( + "subscriptions.extract_flat_playlist", + side_effect=[ + ( + {"_type": "channel", "title": "Channel"}, + [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}], + ), + ( + {"_type": "channel", "title": "Channel"}, + [ + {"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"}, + {"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}, + ], + ), + ], + ): + result = await mgr.add_subscription( + "https://example.com/channel", + check_interval_minutes=60, + download_type="video", + codec="auto", + format="any", + quality="best", + folder="", + custom_name_prefix="", + auto_start=True, + playlist_item_limit=0, + split_by_chapters=False, + chapter_template="", + subtitle_language="en", + subtitle_mode="prefer_manual", + ) + await mgr.check_now([result["subscription"]["id"]]) + + sub = mgr.list_all()[0] + self.assertIsNotNone(sub.last_checked) + self.assertIsNone(sub.error) + self.assertEqual(sub.seen_ids[:2], ["v2", "v1"]) + self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"]) + +class ExtractFlatPlaylistTests(unittest.TestCase): + def test_descends_one_level_when_root_entries_are_nested_collections(self): + responses = iter( + [ + { + "_type": "channel", + "entries": [ + { + "_type": "url", + "ie_key": "YoutubeTab", + "title": "Channel - Videos", + "url": "https://example.com/videos", + "webpage_url": "https://example.com/videos", + } + ], + }, + { + "_type": "playlist", + "entries": [ + { + "_type": "url", + "ie_key": "Youtube", + "id": "v1", + "title": "One", + "duration": 10, + "webpage_url": "https://example.com/v1", + } + ], + }, + ] + ) + + class _FakeYDL: + def __init__(self, params): + self.params = params + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def extract_info(self, url, download=False): + return next(responses) + + cfg = _Config(tempfile.mkdtemp()) + with patch("subscriptions.yt_dlp.YoutubeDL", _FakeYDL, create=True): + info, entries = extract_flat_playlist(cfg, "https://example.com/channel", 50) + + self.assertEqual(info.get("_type"), "playlist") + self.assertEqual([entry["webpage_url"] for entry in entries], ["https://example.com/v1"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_ytdl_utils.py b/app/tests/test_ytdl_utils.py index 7975023..54c9400 100644 --- a/app/tests/test_ytdl_utils.py +++ b/app/tests/test_ytdl_utils.py @@ -3,13 +3,39 @@ from __future__ import annotations import pickle +import sys import tempfile import threading +import types import unittest from pathlib import Path +fake_yt_dlp = types.ModuleType("yt_dlp") +fake_networking = types.ModuleType("yt_dlp.networking") +fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate") +fake_utils = types.ModuleType("yt_dlp.utils") + + +class _ImpersonateTarget: + @staticmethod + def from_str(value): + return value + + +fake_impersonate.ImpersonateTarget = _ImpersonateTarget +fake_networking.impersonate = fake_impersonate +fake_utils.STR_FORMAT_RE_TMPL = r"(?P)%\((?P{})\)(?P[-0-9.]*{})" +fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa" +fake_yt_dlp.networking = fake_networking +fake_yt_dlp.utils = fake_utils +sys.modules.setdefault("yt_dlp", fake_yt_dlp) +sys.modules.setdefault("yt_dlp.networking", fake_networking) +sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate) +sys.modules.setdefault("yt_dlp.utils", fake_utils) + from ytdl import ( DownloadInfo, + _compact_persisted_entry, _convert_srt_to_txt_file, _outtmpl_substitute_field, _sanitize_entry_for_pickle, @@ -167,6 +193,53 @@ class DownloadInfoSetstateTests(unittest.TestCase): di.__setstate__(state) self.assertEqual(di.subtitle_files, []) + def test_missing_optional_fields_are_defaulted(self): + state = self._base_state( + download_type="video", + codec="auto", + format="any", + quality="best", + ) + state.pop("folder") + state.pop("custom_name_prefix") + state.pop("playlist_item_limit") + state.pop("split_by_chapters") + state.pop("chapter_template") + di = DownloadInfo.__new__(DownloadInfo) + di.__setstate__(state) + self.assertEqual(di.folder, "") + self.assertEqual(di.custom_name_prefix, "") + self.assertEqual(di.playlist_item_limit, 0) + self.assertFalse(di.split_by_chapters) + self.assertEqual(di.chapter_template, "") + + +class CompactPersistedEntryTests(unittest.TestCase): + def test_keeps_only_playlist_and_channel_keys(self): + entry = { + "playlist_index": "01", + "playlist_title": "Playlist", + "channel_index": "02", + "channel_title": "Channel", + "formats": [{"id": "huge"}], + "description": "big blob", + } + + compact = _compact_persisted_entry(entry) + + self.assertEqual( + compact, + { + "playlist_index": "01", + "playlist_title": "Playlist", + "channel_index": "02", + "channel_title": "Channel", + }, + ) + + def test_returns_none_when_no_restart_relevant_keys_exist(self): + self.assertIsNone(_compact_persisted_entry({"id": "x", "title": "y"})) + if __name__ == "__main__": unittest.main() diff --git a/app/ytdl.py b/app/ytdl.py index ba5aada..6aac00d 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -3,24 +3,23 @@ import shutil import yt_dlp import collections import collections.abc +import copy import pickle from collections import OrderedDict -import shelve import time import asyncio import multiprocessing import logging import re import types -import dbm -import subprocess -from typing import Any +from typing import Any, Optional from functools import lru_cache import yt_dlp.networking.impersonate from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES from dl_formats import get_format, get_opts, AUDIO_FORMATS from datetime import datetime +from state_store import AtomicJsonStore, from_json_compatible, read_legacy_shelf, to_json_compatible log = logging.getLogger('ytdl') @@ -250,8 +249,100 @@ class DownloadInfo: if not getattr(self, "codec", None): self.codec = "auto" + if not hasattr(self, "folder"): + self.folder = "" + if not hasattr(self, "custom_name_prefix"): + self.custom_name_prefix = "" + if not hasattr(self, "playlist_item_limit"): + self.playlist_item_limit = 0 + if not hasattr(self, "split_by_chapters"): + self.split_by_chapters = False + if not hasattr(self, "chapter_template"): + self.chapter_template = "" + if not hasattr(self, "subtitle_language"): + self.subtitle_language = "en" + if not hasattr(self, "subtitle_mode"): + self.subtitle_mode = "prefer_manual" + if not hasattr(self, "entry"): + self.entry = None if not hasattr(self, "subtitle_files"): self.subtitle_files = [] + if not hasattr(self, "chapter_files"): + self.chapter_files = [] + + +_PERSISTED_DOWNLOAD_FIELDS = ( + "id", + "title", + "url", + "quality", + "download_type", + "codec", + "format", + "folder", + "custom_name_prefix", + "playlist_item_limit", + "split_by_chapters", + "chapter_template", + "subtitle_language", + "subtitle_mode", + "status", + "timestamp", + "error", + "msg", + "filename", + "size", + "chapter_files", +) + + +def _compact_persisted_entry(entry: Any) -> Optional[dict[str, Any]]: + if not isinstance(entry, dict): + return None + compact = { + key: value + for key, value in entry.items() + if key.startswith("playlist") or key.startswith("channel") + } + return compact or None + + +def _download_info_to_record( + info: DownloadInfo, + *, + include_entry: bool, +) -> dict[str, Any]: + record: dict[str, Any] = {} + for key in _PERSISTED_DOWNLOAD_FIELDS: + if hasattr(info, key): + value = getattr(info, key) + if value is not None: + record[key] = to_json_compatible(value) + if include_entry: + compact_entry = _compact_persisted_entry(getattr(info, "entry", None)) + if compact_entry is not None: + record["entry"] = to_json_compatible(compact_entry) + return record + + +def _download_info_from_record(record: dict[str, Any]) -> DownloadInfo: + info = DownloadInfo.__new__(DownloadInfo) + info.__setstate__({key: from_json_compatible(value) for key, value in record.items()}) + if not hasattr(info, "msg"): + info.msg = None + if not hasattr(info, "percent"): + info.percent = None + if not hasattr(info, "speed"): + info.speed = None + if not hasattr(info, "eta"): + info.eta = None + if not hasattr(info, "status"): + info.status = "pending" + if not hasattr(info, "size"): + info.size = None + if not hasattr(info, "error"): + info.error = None + return info class Download: manager = None @@ -502,11 +593,9 @@ class PersistentQueue: pdir = os.path.dirname(path) if not os.path.isdir(pdir): os.mkdir(pdir) - with shelve.open(path, 'c'): - pass - - self.path = path - self.repair() + self.legacy_path = path + self.path = f"{path}.json" + self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}") self.dict = OrderedDict() def load(self): @@ -523,16 +612,75 @@ class PersistentQueue: return self.dict.items() def saved_items(self): - with shelve.open(self.path, 'r') as shelf: - return sorted(shelf.items(), key=lambda item: item[1].timestamp) + items = [ + (item["key"], _download_info_from_record(item["info"])) + for item in self._load_state_items() + ] + return sorted(items, key=lambda item: item[1].timestamp) + + def _should_persist_entry(self) -> bool: + return self.identifier != "completed" + + def _serialize_items(self): + return [ + { + "key": key, + "info": _download_info_to_record( + download.info, + include_entry=self._should_persist_entry(), + ), + } + for key, download in self.dict.items() + ] + + def _save_dict(self): + self.store.save({"items": self._serialize_items()}) + + def _load_state_items(self): + payload = self.store.load() + if payload is not None: + items = payload.get("items") + if isinstance(items, list): + compact_items = [ + { + "key": item["key"], + "info": _download_info_to_record( + _download_info_from_record(item["info"]), + include_entry=self._should_persist_entry(), + ), + } + for item in items + if isinstance(item, dict) and "key" in item and "info" in item + ] + if payload.get("schema_version") != self.store.schema_version or compact_items != items: + self.store.save({"items": compact_items}) + return compact_items + log.warning("PersistentQueue:%s state file did not contain an items list", self.identifier) + return [] + + legacy_items = read_legacy_shelf(self.legacy_path) + if legacy_items is None: + return [] + + items = [ + { + "key": key, + "info": _download_info_to_record( + value, + include_entry=self._should_persist_entry(), + ), + } + for key, value in sorted(legacy_items, key=lambda item: item[1].timestamp) + ] + self.store.save({"items": items}) + return items def put(self, value): key = value.info.url old = self.dict.get(key) self.dict[key] = value try: - with shelve.open(self.path, 'w') as shelf: - shelf[key] = value.info + self._save_dict() except Exception: if old is None: del self.dict[key] @@ -542,9 +690,13 @@ class PersistentQueue: def delete(self, key): if key in self.dict: + old = self.dict[key] del self.dict[key] - with shelve.open(self.path, 'w') as shelf: - shelf.pop(key, None) + try: + self._save_dict() + except Exception: + self.dict[key] = old + raise def next(self): k, v = next(iter(self.dict.items())) @@ -553,90 +705,6 @@ class PersistentQueue: def empty(self): return not bool(self.dict) - def repair(self): - # check DB format - type_check = subprocess.run( - ["file", self.path], - capture_output=True, - text=True - ) - db_type = type_check.stdout.lower() - - # create backup (.old) - try: - shutil.copy2(self.path, f"{self.path}.old") - except Exception as e: - # if we cannot backup then its not safe to attempt a repair - # since it could be due to a filesystem error - log.debug(f"PersistentQueue:{self.identifier} backup failed, skipping repair") - return - - if "gnu dbm" in db_type: - # perform gdbm repair - log_prefix = f"PersistentQueue:{self.identifier} repair (dbm/file)" - log.debug(f"{log_prefix} started") - try: - result = subprocess.run( - ["gdbmtool", self.path], - input="recover verbose summary\n", - text=True, - capture_output=True, - timeout=60 - ) - log.debug(f"{log_prefix} {result.stdout}") - if result.stderr: - log.debug(f"{log_prefix} failed: {result.stderr}") - except FileNotFoundError: - log.debug(f"{log_prefix} failed: 'gdbmtool' was not found") - - # perform null key cleanup - log_prefix = f"PersistentQueue:{self.identifier} repair (null keys)" - log.debug(f"{log_prefix} started") - deleted = 0 - try: - with dbm.open(self.path, "w") as db: - for key in list(db.keys()): - if len(key) > 0 and all(b == 0x00 for b in key): - log.debug(f"{log_prefix} deleting key of length {len(key)} (all NUL bytes)") - del db[key] - deleted += 1 - log.debug(f"{log_prefix} done - deleted {deleted} key(s)") - except dbm.error: - log.debug(f"{log_prefix} failed: db type is dbm.gnu, but the module is not available (dbm.error; module support may be missing or the file may be corrupted)") - - elif "sqlite" in db_type: - # perform sqlite3 recovery - log_prefix = f"PersistentQueue:{self.identifier} repair (sqlite3/file)" - log.debug(f"{log_prefix} started") - try: - recover_proc = subprocess.Popen( - ["sqlite3", self.path, ".recover"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - run_result = subprocess.run( - ["sqlite3", f"{self.path}.tmp"], - stdin=recover_proc.stdout, - capture_output=True, - text=True, - timeout=60, - ) - if recover_proc.stdout is not None: - recover_proc.stdout.close() - recover_stderr = recover_proc.stderr.read() if recover_proc.stderr is not None else "" - recover_proc.wait(timeout=60) - if run_result.stderr or recover_stderr: - error_text = " ".join(part for part in [recover_stderr.strip(), run_result.stderr.strip()] if part) - log.debug(f"{log_prefix} failed: {error_text}") - else: - shutil.move(f"{self.path}.tmp", self.path) - log.debug(f"{log_prefix}{run_result.stdout or ' was successful, no output'}") - except FileNotFoundError: - log.debug(f"{log_prefix} failed: 'sqlite3' was not found") - except subprocess.TimeoutExpired: - log.debug(f"{log_prefix} failed: sqlite recovery timed out") - class DownloadQueue: def __init__(self, config, notifier): self.config = config @@ -949,6 +1017,42 @@ class DownloadQueue: _add_gen, ) + async def add_entry( + self, + entry, + download_type, + codec, + format, + quality, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start=True, + split_by_chapters=False, + chapter_template=None, + subtitle_language="en", + subtitle_mode="prefer_manual", + ): + normalized_entry = copy.deepcopy(entry) if isinstance(entry, dict) else entry + already = set() + return await self.__add_entry( + normalized_entry, + download_type, + codec, + format, + quality, + folder, + custom_name_prefix, + playlist_item_limit, + auto_start, + split_by_chapters, + chapter_template, + subtitle_language, + subtitle_mode, + already, + None, + ) + async def start_pending(self, ids): for id in ids: if not self.pending.exists(id): diff --git a/ui/package.json b/ui/package.json index 745ec5b..0b4b42d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -23,21 +23,21 @@ }, "private": true, "dependencies": { - "@angular/animations": "^21.2.5", - "@angular/common": "^21.2.5", - "@angular/compiler": "^21.2.5", - "@angular/core": "^21.2.5", - "@angular/forms": "^21.2.5", - "@angular/platform-browser": "^21.2.5", - "@angular/platform-browser-dynamic": "^21.2.5", - "@angular/service-worker": "^21.2.5", + "@angular/animations": "^21.2.6", + "@angular/common": "^21.2.6", + "@angular/compiler": "^21.2.6", + "@angular/core": "^21.2.6", + "@angular/forms": "^21.2.6", + "@angular/platform-browser": "^21.2.6", + "@angular/platform-browser-dynamic": "^21.2.6", + "@angular/service-worker": "^21.2.6", "@fortawesome/angular-fontawesome": "~4.0.0", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-brands-svg-icons": "^7.2.0", "@fortawesome/free-regular-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@ng-bootstrap/ng-bootstrap": "^20.0.0", - "@ng-select/ng-select": "^21.5.2", + "@ng-select/ng-select": "^21.7.0", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.8", "ngx-cookie-service": "^21.3.1", @@ -48,16 +48,16 @@ }, "devDependencies": { "@angular-eslint/builder": "21.1.0", - "@angular/build": "^21.2.3", - "@angular/cli": "^21.2.3", - "@angular/compiler-cli": "^21.2.5", - "@angular/localize": "^21.2.5", + "@angular/build": "^21.2.5", + "@angular/cli": "^21.2.5", + "@angular/compiler-cli": "^21.2.6", + "@angular/localize": "^21.2.6", "@eslint/js": "^9.39.4", "angular-eslint": "21.1.0", "eslint": "^9.39.4", "jsdom": "^27.4.0", "typescript": "~5.9.3", "typescript-eslint": "8.47.0", - "vitest": "^4.1.0" + "vitest": "^4.1.2" } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 379f3d1..5f399db 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -9,32 +9,32 @@ importers: .: dependencies: '@angular/animations': - specifier: ^21.2.5 - version: 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)) + specifier: ^21.2.6 + version: 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)) '@angular/common': - specifier: ^21.2.5 - version: 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + specifier: ^21.2.6 + version: 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) '@angular/compiler': - specifier: ^21.2.5 - version: 21.2.5 + specifier: ^21.2.6 + version: 21.2.6 '@angular/core': - specifier: ^21.2.5 - version: 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) + specifier: ^21.2.6 + version: 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) '@angular/forms': - specifier: ^21.2.5 - version: 21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + specifier: ^21.2.6 + version: 21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) '@angular/platform-browser': - specifier: ^21.2.5 - version: 21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)) + specifier: ^21.2.6 + version: 21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)) '@angular/platform-browser-dynamic': - specifier: ^21.2.5 - version: 21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.2.5)(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))) + specifier: ^21.2.6 + version: 21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.2.6)(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))) '@angular/service-worker': - specifier: ^21.2.5 - version: 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + specifier: ^21.2.6 + version: 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) '@fortawesome/angular-fontawesome': specifier: ~4.0.0 - version: 4.0.0(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)) + version: 4.0.0(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)) '@fortawesome/fontawesome-svg-core': specifier: ^7.2.0 version: 7.2.0 @@ -49,10 +49,10 @@ importers: version: 7.2.0 '@ng-bootstrap/ng-bootstrap': specifier: ^20.0.0 - version: 20.0.0(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))(@angular/localize@21.2.5(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5))(@popperjs/core@2.11.8)(rxjs@7.8.2) + version: 20.0.0(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))(@angular/localize@21.2.6(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6))(@popperjs/core@2.11.8)(rxjs@7.8.2) '@ng-select/ng-select': - specifier: ^21.5.2 - version: 21.5.2(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)) + specifier: ^21.7.0 + version: 21.7.0(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)) '@popperjs/core': specifier: ^2.11.8 version: 2.11.8 @@ -61,10 +61,10 @@ importers: version: 5.3.8(@popperjs/core@2.11.8) ngx-cookie-service: specifier: ^21.3.1 - version: 21.3.1(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)) + version: 21.3.1(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)) ngx-socket-io: specifier: ~4.10.0 - version: 4.10.0(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + version: 4.10.0(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) rxjs: specifier: ~7.8.2 version: 7.8.2 @@ -77,25 +77,25 @@ importers: devDependencies: '@angular-eslint/builder': specifier: 21.1.0 - version: 21.1.0(@angular/cli@21.2.3(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3) + version: 21.1.0(@angular/cli@21.2.5(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3) '@angular/build': - specifier: ^21.2.3 - version: 21.2.3(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5)(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/localize@21.2.5(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/service-worker@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@types/node@25.5.0)(chokidar@5.0.0)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.1.0(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3))) + specifier: ^21.2.5 + version: 21.2.5(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6)(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/localize@21.2.6(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/service-worker@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(chokidar@5.0.0)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.1.2(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3))) '@angular/cli': - specifier: ^21.2.3 - version: 21.2.3(@types/node@25.5.0)(chokidar@5.0.0) + specifier: ^21.2.5 + version: 21.2.5(@types/node@25.5.0)(chokidar@5.0.0) '@angular/compiler-cli': - specifier: ^21.2.5 - version: 21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3) + specifier: ^21.2.6 + version: 21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3) '@angular/localize': - specifier: ^21.2.5 - version: 21.2.5(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5) + specifier: ^21.2.6 + version: 21.2.6(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6) '@eslint/js': specifier: ^9.39.4 version: 9.39.4 angular-eslint: specifier: 21.1.0 - version: 21.1.0(@angular/cli@21.2.3(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript-eslint@8.47.0(eslint@9.39.4)(typescript@5.9.3))(typescript@5.9.3) + version: 21.1.0(@angular/cli@21.2.5(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript-eslint@8.47.0(eslint@9.39.4)(typescript@5.9.3))(typescript@5.9.3) eslint: specifier: ^9.39.4 version: 9.39.4 @@ -109,8 +109,8 @@ importers: specifier: 8.47.0 version: 8.47.0(eslint@9.39.4)(typescript@5.9.3) vitest: - specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)) + specifier: ^4.1.2 + version: 4.1.2(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)) packages: @@ -177,13 +177,13 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@angular-devkit/architect@0.2102.3': - resolution: {integrity: sha512-G4wSWUbtWp1WCKw5GMRqHH8g4m5RBpIyzt8n8IX5Pm6iYe/rwCBSKL3ktEkk7AYMwjtonkRlDtAK1GScFsf1Sg==} + '@angular-devkit/architect@0.2102.5': + resolution: {integrity: sha512-9xE7G177R9G9Kte+4AtbEMlEeZUupnvdBUMVBlZRa/n4UDUyAkB/vj58KrzRCCIVQ/ypHVMwUilaDTO484dd+g==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular-devkit/core@21.2.3': - resolution: {integrity: sha512-i++JVHOijyFckjdYqKbSXUpKnvmO2a0Utt/wQVwiLAT0O9H1hR/2NGPzubB4hnLMNSyVWY8diminaF23mZ0xjA==} + '@angular-devkit/core@21.2.5': + resolution: {integrity: sha512-9z9w7UxKKVmib5QHFZTOfJpAiSudqQwwEZFpQy31yaXR3tJw85xO5owi+66sgTpEvNh9Ix2THhcUq//ToP/0VA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: chokidar: ^5.0.0 @@ -191,8 +191,8 @@ packages: chokidar: optional: true - '@angular-devkit/schematics@21.2.3': - resolution: {integrity: sha512-tc/bBloRTVIBWGRiMPln1QbW+2QPj+YnWL/nG79abLKWkdrL9dJLcCRXY7dsPNrxOc/QF+8tVpnr8JofhWL9cQ==} + '@angular-devkit/schematics@21.2.5': + resolution: {integrity: sha512-gEg84eipTX6lcpNTDVUXBBwp0vs3rXM319Qom+sCLOKBGyqE0mvb1RM1WwfNcyOqeSMQC/vLUwRKqnP0wg1UDg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@angular-eslint/builder@21.1.0': @@ -239,14 +239,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '*' - '@angular/animations@21.2.5': - resolution: {integrity: sha512-8jH48A1gNph5YGlTXXoXJ/5T6uEZB14ITad3uQwBMM1mUUvM0T4QIMk555jIe1fIHHUyTfRR2y7v8SfTe2++fA==} + '@angular/animations@21.2.6': + resolution: {integrity: sha512-SPzTOlkyVagPdb7OMe9hw3dnpMGq2p/nADatzNfRUMXwit8AU8VaiPIrFRsCD52sAL1zDDj60gKsk/dprzIyFA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - '@angular/core': 21.2.5 + '@angular/core': 21.2.6 - '@angular/build@21.2.3': - resolution: {integrity: sha512-u4bhVQruK7KOuHQuoltqlHg+szp0f6rnsGIUolJnT3ez5V6OuSoWIxUorSbvryi2DiKRD/3iwMq7qJN1aN9HCA==} + '@angular/build@21.2.5': + resolution: {integrity: sha512-AfE09K+pkgS3VB84R74XG/XB9LQmO6Q6YfpssjDwMnWGwDGGwUGydXn8AKdhnhI4mM2nFKoe+QYszFgrzu5HeQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} peerDependencies: '@angular/compiler': ^21.0.0 @@ -256,7 +256,7 @@ packages: '@angular/platform-browser': ^21.0.0 '@angular/platform-server': ^21.0.0 '@angular/service-worker': ^21.0.0 - '@angular/ssr': ^21.2.3 + '@angular/ssr': ^21.2.5 karma: ^6.4.0 less: ^4.2.0 ng-packagr: ^21.0.0 @@ -291,38 +291,38 @@ packages: vitest: optional: true - '@angular/cli@21.2.3': - resolution: {integrity: sha512-QzDxnSy8AUOz6ca92xfbNuEmRdWRDi1dfFkxDVr+4l6XUnA9X6VmOi7ioCO1I9oDR73LXHybOqkqHBYDlqt/Ag==} + '@angular/cli@21.2.5': + resolution: {integrity: sha512-nLpyqXQ0s96jC/vR8CsKM3q94/F/nZwtbjM3E6g5lXpKe7cHfJkCfERPexx+jzzYP5JBhtm+u61aH6auu9KYQw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular/common@21.2.5': - resolution: {integrity: sha512-MTjCbsHBkF9W12CW9yYiTJdVfZv/qCqBCZ2iqhMpDA5G+ZJiTKP0IDTJVrx2N5iHfiJ1lnK719t/9GXROtEAvg==} + '@angular/common@21.2.6': + resolution: {integrity: sha512-2FcpZ1h6AZ4JwCIlnpHCYrbRTGQTOj/RFXkuX/qw7K6cFmJGfWFMmr++xWtHZEvUddfbR9hqDo+v1mkqEKE/Kw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - '@angular/core': 21.2.5 + '@angular/core': 21.2.6 rxjs: ^6.5.3 || ^7.4.0 - '@angular/compiler-cli@21.2.5': - resolution: {integrity: sha512-Ox3vz6KAM7i47ujR/3M3NCOeCRn6vrC9yV1SHZRhSrYg6CWWcOMveavEEwtNjYtn3hOzrktO4CnuVwtDbU8pLg==} + '@angular/compiler-cli@21.2.6': + resolution: {integrity: sha512-CiPmat4+D+hWXMTAY++09WeII/5D0r6iTjdLdaTq8tlo0uJcrOlazib4CpA94kJ2CRdzfhmC1H+ttwBI1xIlTg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} hasBin: true peerDependencies: - '@angular/compiler': 21.2.5 + '@angular/compiler': 21.2.6 typescript: '>=5.9 <6.1' peerDependenciesMeta: typescript: optional: true - '@angular/compiler@21.2.5': - resolution: {integrity: sha512-QloEsknGqLvmr+ED7QShDt7SoMY9mipV+gVnwn4hBI5sbl+TOBfYWXIaJMnxseFwSqjXTSCVGckfylIlynNcFg==} + '@angular/compiler@21.2.6': + resolution: {integrity: sha512-shGkb/aAIPbG8oSYkVJ0msGlRdDVcJBVaUVx2KenMltifQjfLn5N8DFMAzOR6haaA3XeugFExxKqmvySjrVq+A==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - '@angular/core@21.2.5': - resolution: {integrity: sha512-JgHU134Adb1wrpyGC9ozcv3hiRAgaFTvJFn1u9OU/AVXyxu4meMmVh2hp5QhAvPnv8XQdKWWIkAY+dbpPE6zKA==} + '@angular/core@21.2.6': + resolution: {integrity: sha512-svgK5DhFlQlS+sMybXftn08rHHRiDGY/uIKT5LZUaKgyffnkPb8uClpMIW0NzANtU8qs8pwgDZFoJw85Ia3oqQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - '@angular/compiler': 21.2.5 + '@angular/compiler': 21.2.6 rxjs: ^6.5.3 || ^7.4.0 zone.js: ~0.15.0 || ~0.16.0 peerDependenciesMeta: @@ -331,49 +331,49 @@ packages: zone.js: optional: true - '@angular/forms@21.2.5': - resolution: {integrity: sha512-pqRuK+a1ZAFZbs8/dZoorFJah2IWaf/SH8axHUpaDJ7fyNrwNEcpczyObdxZ00lOgORpKAhWo/q0hlVS+In8cw==} + '@angular/forms@21.2.6': + resolution: {integrity: sha512-i8BoWxBAm0g2xOMcQ8wTdj07gqMPIFYIyefCOo0ezcGj5XhYjd+C2UrYnKsup0aMZqqEAO1l2aZbmfHx9xLheQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - '@angular/common': 21.2.5 - '@angular/core': 21.2.5 - '@angular/platform-browser': 21.2.5 + '@angular/common': 21.2.6 + '@angular/core': 21.2.6 + '@angular/platform-browser': 21.2.6 rxjs: ^6.5.3 || ^7.4.0 - '@angular/localize@21.2.5': - resolution: {integrity: sha512-L/Aa+wMONTM3tvHczwHLYwKwgFhjXwU+TDYJFswu1/nFJ2epb0yNrJzgi9dHXDAMdihJy8920dZr9BI6J/OZ5A==} + '@angular/localize@21.2.6': + resolution: {integrity: sha512-+nScGHruNCUiGz9nbNyFLO0Wg5dGZt+PBH/9wvzCxe1A+VhyiRSNCTD9hjcjsjtK3WPTRPd+Vo1s2URn+fgD4A==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} hasBin: true peerDependencies: - '@angular/compiler': 21.2.5 - '@angular/compiler-cli': 21.2.5 + '@angular/compiler': 21.2.6 + '@angular/compiler-cli': 21.2.6 - '@angular/platform-browser-dynamic@21.2.5': - resolution: {integrity: sha512-0yDogezPC4OaqkvL/3Pa5mBodOCCUnO4CTOxC+fPy7L+dRhQfVEwtOsN9XkZv5eMGemGeCcNKdchSuYsVkCA2g==} + '@angular/platform-browser-dynamic@21.2.6': + resolution: {integrity: sha512-6a+zA9jM70b1kH3fSfAJIEVmkE3qB3oIXw7otWkv1nEhOJtNO0mM0dTUuO70C3GhnV9tmpLXa2him56C2LhVig==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - '@angular/common': 21.2.5 - '@angular/compiler': 21.2.5 - '@angular/core': 21.2.5 - '@angular/platform-browser': 21.2.5 + '@angular/common': 21.2.6 + '@angular/compiler': 21.2.6 + '@angular/core': 21.2.6 + '@angular/platform-browser': 21.2.6 - '@angular/platform-browser@21.2.5': - resolution: {integrity: sha512-VuuYguxjgyI4XWuoXrKynmuA3FB991pXbkNhxHeCW0yX+7DGOnGLPF1oierd4/X+IvskmN8foBZLfjyg9u4Ffg==} + '@angular/platform-browser@21.2.6': + resolution: {integrity: sha512-LW1vPXVHvy71LBahn+fSzPlWQl25kJIdcXq+ptG7HsMVgbPQ3/vvkKXAHYaRdppLGCFL+v+3dQGHYLNLiYL9qg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: - '@angular/animations': 21.2.5 - '@angular/common': 21.2.5 - '@angular/core': 21.2.5 + '@angular/animations': 21.2.6 + '@angular/common': 21.2.6 + '@angular/core': 21.2.6 peerDependenciesMeta: '@angular/animations': optional: true - '@angular/service-worker@21.2.5': - resolution: {integrity: sha512-PbkbDuVmpN135bu/XtskkQ1gPVsiGBI+CX9rVUykqomT3y/okW/qaqsYnmzFZedBpZTGKDOaeFGN5GhJj2O22g==} + '@angular/service-worker@21.2.6': + resolution: {integrity: sha512-RSFspGPBCDU1fEPTTRXarF6vszYEh+d8DXA/o6Bdo4U3+bFCEjekfe2PjHHA2hTaagxR7Ow44gcYWxR7RnNjrA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} hasBin: true peerDependencies: - '@angular/core': 21.2.5 + '@angular/core': 21.2.6 rxjs: ^6.5.3 || ^7.4.0 '@asamuzakjp/css-color@4.1.2': @@ -484,8 +484,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.1': - resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: @@ -740,8 +740,8 @@ packages: '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} - '@hono/node-server@1.19.11': - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + '@hono/node-server@1.19.12': + resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 @@ -1115,8 +1115,11 @@ packages: resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} engines: {node: '>= 10'} - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.2': + resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 '@ng-bootstrap/ng-bootstrap@20.0.0': resolution: {integrity: sha512-Jt+GUQ0PdM8VsOUUVr7vTQXhwcGwe2DCe1mmfS21vz9pLSOtGRz41ohZKc1egUevj5Rxm2sHVq5Sve68/nTMfA==} @@ -1128,8 +1131,8 @@ packages: '@popperjs/core': ^2.11.8 rxjs: ^6.5.3 || ^7.4.0 - '@ng-select/ng-select@21.5.2': - resolution: {integrity: sha512-QFfZ00Z/UfZikK3BCyl4rWpZGd7W5YE2ScTbRcC2jH6QN8E1pNgxH1bBS05p2YEYZIA6RZ5/HcJguj3ipPy5Jw==} + '@ng-select/ng-select@21.7.0': + resolution: {integrity: sha512-YHeayf5hzl8FmbCXsJDoAeTchxO1mxCQdIVbM8sarJE0K+Dvtxeiec59l4rnaj0ZW/Vb97wD5K7vaCQfvuXhoQ==} engines: {node: ^20.19.0 || ^22.12.0 || ^24.0.0} peerDependencies: '@angular/common': ^21.0.0 @@ -1363,146 +1366,146 @@ packages: '@rolldown/pluginutils@1.0.0-rc.4': resolution: {integrity: sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==} - '@rollup/rollup-android-arm-eabi@4.59.1': - resolution: {integrity: sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==} + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.59.1': - resolution: {integrity: sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==} + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.59.1': - resolution: {integrity: sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==} + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.59.1': - resolution: {integrity: sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==} + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.59.1': - resolution: {integrity: sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==} + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.59.1': - resolution: {integrity: sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==} + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.59.1': - resolution: {integrity: sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.59.1': - resolution: {integrity: sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==} + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.59.1': - resolution: {integrity: sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==} + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.59.1': - resolution: {integrity: sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==} + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.59.1': - resolution: {integrity: sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==} + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.59.1': - resolution: {integrity: sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==} + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.59.1': - resolution: {integrity: sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==} + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.59.1': - resolution: {integrity: sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==} + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.59.1': - resolution: {integrity: sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==} + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.59.1': - resolution: {integrity: sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==} + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.59.1': - resolution: {integrity: sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==} + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.59.1': - resolution: {integrity: sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==} + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.59.1': - resolution: {integrity: sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==} + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.59.1': - resolution: {integrity: sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==} + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.59.1': - resolution: {integrity: sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==} + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.59.1': - resolution: {integrity: sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==} + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.59.1': - resolution: {integrity: sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==} + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.59.1': - resolution: {integrity: sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==} + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.59.1': - resolution: {integrity: sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==} + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} cpu: [x64] os: [win32] - '@schematics/angular@21.2.3': - resolution: {integrity: sha512-rCEprgpNbJLl9Rm/t92eRYc1eIqD4BAJqB1OO8fzQolyDajCcOBpohjXkuLYSwK9RMyS6f+szNnYGOQawlrPYw==} + '@schematics/angular@21.2.5': + resolution: {integrity: sha512-orOiXcG86t34ejqbkm7ZHEkGfwTU/ySYFgY7BOQdaYFCoNQXxtU87fZoHckJ2xYpVitoKTvbf1bxDDphXb3ycw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} '@sigstore/bundle@4.0.0': @@ -1603,18 +1606,18 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.57.1': - resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/scope-manager@8.47.0': resolution: {integrity: sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.57.1': - resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.47.0': @@ -1623,11 +1626,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.57.1': - resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/type-utils@8.47.0': resolution: {integrity: sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==} @@ -1640,8 +1643,8 @@ packages: resolution: {integrity: sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.57.1': - resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@8.47.0': @@ -1650,11 +1653,11 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.57.1': - resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/utils@8.47.0': resolution: {integrity: sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==} @@ -1663,19 +1666,19 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.57.1': - resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' '@typescript-eslint/visitor-keys@8.47.0': resolution: {integrity: sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.57.1': - resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-basic-ssl@2.1.4': @@ -1684,34 +1687,34 @@ packages: peerDependencies: vite: ^6.0.0 || ^7.0.0 - '@vitest/expect@4.1.0': - resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} - '@vitest/mocker@4.1.0': - resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.1.0': - resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} - '@vitest/runner@4.1.0': - resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} - '@vitest/snapshot@4.1.0': - resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} - '@vitest/spy@4.1.0': - resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} - '@vitest/utils@4.1.0': - resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -1814,8 +1817,8 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.10.9: - resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} + baseline-browser-mapping@2.10.13: + resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==} engines: {node: '>=6.0.0'} hasBin: true @@ -1838,22 +1841,22 @@ packages: peerDependencies: '@popperjs/core': ^2.11.8 - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1880,8 +1883,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001780: - resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + caniuse-lite@1.0.30001784: + resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -2038,8 +2041,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.321: - resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + electron-to-chromium@1.5.330: + resolution: {integrity: sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2191,8 +2194,8 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - express-rate-limit@8.3.1: - resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + express-rate-limit@8.3.2: + resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -2333,8 +2336,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.12.8: - resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} engines: {node: '>=16.9.0'} hosted-git-info@9.0.2: @@ -2596,8 +2599,8 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -2615,8 +2618,8 @@ packages: resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} engines: {node: ^20.17.0 || >=22.9.0} - minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} engines: {node: '>= 8'} minipass-pipeline@1.2.4: @@ -2819,8 +2822,8 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.1: + resolution: {integrity: sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2828,12 +2831,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} piscina@5.1.4: @@ -2931,8 +2934,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.59.1: - resolution: {integrity: sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==} + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -3126,8 +3129,8 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tar@7.5.12: - resolution: {integrity: sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==} + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} tinybench@2.9.0: @@ -3204,8 +3207,8 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.22.0: - resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + undici@7.24.4: + resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} unpipe@1.0.0: @@ -3269,21 +3272,21 @@ packages: yaml: optional: true - vitest@4.1.0: - resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.0 - '@vitest/browser-preview': 4.1.0 - '@vitest/browser-webdriverio': 4.1.0 - '@vitest/ui': 4.1.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -3373,8 +3376,8 @@ packages: utf-8-validate: optional: true - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3430,10 +3433,10 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: - zod: ^3.25 || ^4 + zod: ^3.25.28 || ^4 zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -3534,27 +3537,27 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - '@angular-devkit/architect@0.2102.3(chokidar@5.0.0)': + '@angular-devkit/architect@0.2102.5(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.3(chokidar@5.0.0) + '@angular-devkit/core': 21.2.5(chokidar@5.0.0) rxjs: 7.8.2 transitivePeerDependencies: - chokidar - '@angular-devkit/core@21.2.3(chokidar@5.0.0)': + '@angular-devkit/core@21.2.5(chokidar@5.0.0)': dependencies: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) jsonc-parser: 3.3.1 - picomatch: 4.0.3 + picomatch: 4.0.4 rxjs: 7.8.2 source-map: 0.7.6 optionalDependencies: chokidar: 5.0.0 - '@angular-devkit/schematics@21.2.3(chokidar@5.0.0)': + '@angular-devkit/schematics@21.2.5(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.3(chokidar@5.0.0) + '@angular-devkit/core': 21.2.5(chokidar@5.0.0) jsonc-parser: 3.3.1 magic-string: 0.30.21 ora: 9.3.0 @@ -3562,11 +3565,11 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-eslint/builder@21.1.0(@angular/cli@21.2.3(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3)': + '@angular-eslint/builder@21.1.0(@angular/cli@21.2.5(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@angular-devkit/architect': 0.2102.3(chokidar@5.0.0) - '@angular-devkit/core': 21.2.3(chokidar@5.0.0) - '@angular/cli': 21.2.3(@types/node@25.5.0)(chokidar@5.0.0) + '@angular-devkit/architect': 0.2102.5(chokidar@5.0.0) + '@angular-devkit/core': 21.2.5(chokidar@5.0.0) + '@angular/cli': 21.2.5(@types/node@25.5.0)(chokidar@5.0.0) eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: @@ -3574,34 +3577,34 @@ snapshots: '@angular-eslint/bundled-angular-compiler@21.1.0': {} - '@angular-eslint/eslint-plugin-template@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@typescript-eslint/types@8.57.1)(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@angular-eslint/eslint-plugin-template@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@typescript-eslint/types@8.58.0)(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.1.0 '@angular-eslint/template-parser': 21.1.0(eslint@9.39.4)(typescript@5.9.3) - '@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) + '@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) aria-query: 5.3.2 axobject-query: 4.1.0 eslint: 9.39.4 typescript: 5.9.3 - '@angular-eslint/eslint-plugin@21.1.0(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@angular-eslint/eslint-plugin@21.1.0(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.1.0 - '@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) + '@angular-eslint/utils': 21.1.0(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 - '@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@angular/cli@21.2.3(@types/node@25.5.0)(chokidar@5.0.0))(@typescript-eslint/types@8.57.1)(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3)': + '@angular-eslint/schematics@21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@angular/cli@21.2.5(@types/node@25.5.0)(chokidar@5.0.0))(@typescript-eslint/types@8.58.0)(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3)': dependencies: - '@angular-devkit/core': 21.2.3(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.3(chokidar@5.0.0) - '@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@typescript-eslint/types@8.57.1)(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@angular/cli': 21.2.3(@types/node@25.5.0)(chokidar@5.0.0) + '@angular-devkit/core': 21.2.5(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.5(chokidar@5.0.0) + '@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@typescript-eslint/types@8.58.0)(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@angular/cli': 21.2.5(@types/node@25.5.0)(chokidar@5.0.0) ignore: 7.0.5 semver: 7.7.3 strip-json-comments: 3.1.1 @@ -3620,31 +3623,31 @@ snapshots: eslint-scope: 9.1.2 typescript: 5.9.3 - '@angular-eslint/utils@21.1.0(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@angular-eslint/utils@21.1.0(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@angular-eslint/bundled-angular-compiler': 21.1.0 - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 typescript: 5.9.3 - '@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))': + '@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))': dependencies: - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.8.1 - '@angular/build@21.2.3(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5)(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/localize@21.2.5(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/service-worker@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@types/node@25.5.0)(chokidar@5.0.0)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.1.0(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)))': + '@angular/build@21.2.5(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6)(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/localize@21.2.6(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/service-worker@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(chokidar@5.0.0)(postcss@8.5.8)(tslib@2.8.1)(typescript@5.9.3)(vitest@4.1.2(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)))': dependencies: '@ampproject/remapping': 2.3.0 - '@angular-devkit/architect': 0.2102.3(chokidar@5.0.0) - '@angular/compiler': 21.2.5 - '@angular/compiler-cli': 21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3) + '@angular-devkit/architect': 0.2102.5(chokidar@5.0.0) + '@angular/compiler': 21.2.6 + '@angular/compiler-cli': 21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3) '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 5.1.21(@types/node@25.5.0) '@vitejs/plugin-basic-ssl': 2.1.4(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)) beasties: 0.4.1 - browserslist: 4.28.1 + browserslist: 4.28.2 esbuild: 0.27.3 https-proxy-agent: 7.0.6 istanbul-lib-instrument: 6.0.3 @@ -3653,27 +3656,29 @@ snapshots: magic-string: 0.30.21 mrmime: 2.0.1 parse5-html-rewriting-stream: 8.0.0 - picomatch: 4.0.3 + picomatch: 4.0.4 piscina: 5.1.4 - rolldown: 1.0.0-rc.4 + rolldown: 1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) sass: 1.97.3 semver: 7.7.4 source-map-support: 0.5.21 tinyglobby: 0.2.15 tslib: 2.8.1 typescript: 5.9.3 - undici: 7.22.0 + undici: 7.24.4 vite: 7.3.1(@types/node@25.5.0)(sass@1.97.3) watchpack: 2.5.1 optionalDependencies: - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/localize': 21.2.5(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5) - '@angular/platform-browser': 21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)) - '@angular/service-worker': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/localize': 21.2.6(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6) + '@angular/platform-browser': 21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/service-worker': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) lmdb: 3.5.1 postcss: 8.5.8 - vitest: 4.1.0(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)) + vitest: 4.1.2(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)) transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - '@types/node' - chokidar - jiti @@ -3686,15 +3691,15 @@ snapshots: - tsx - yaml - '@angular/cli@21.2.3(@types/node@25.5.0)(chokidar@5.0.0)': + '@angular/cli@21.2.5(@types/node@25.5.0)(chokidar@5.0.0)': dependencies: - '@angular-devkit/architect': 0.2102.3(chokidar@5.0.0) - '@angular-devkit/core': 21.2.3(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.3(chokidar@5.0.0) + '@angular-devkit/architect': 0.2102.5(chokidar@5.0.0) + '@angular-devkit/core': 21.2.5(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.5(chokidar@5.0.0) '@inquirer/prompts': 7.10.1(@types/node@25.5.0) '@listr2/prompt-adapter-inquirer': 3.0.5(@inquirer/prompts@7.10.1(@types/node@25.5.0))(@types/node@25.5.0)(listr2@9.0.5) '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6) - '@schematics/angular': 21.2.3(chokidar@5.0.0) + '@schematics/angular': 21.2.5(chokidar@5.0.0) '@yarnpkg/lockfile': 1.1.0 algoliasearch: 5.48.1 ini: 6.0.0 @@ -3712,15 +3717,15 @@ snapshots: - chokidar - supports-color - '@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': + '@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) rxjs: 7.8.2 tslib: 2.8.1 - '@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3)': + '@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3)': dependencies: - '@angular/compiler': 21.2.5 + '@angular/compiler': 21.2.6 '@babel/core': 7.29.0 '@jridgewell/sourcemap-codec': 1.5.5 chokidar: 5.0.0 @@ -3734,31 +3739,31 @@ snapshots: transitivePeerDependencies: - supports-color - '@angular/compiler@21.2.5': + '@angular/compiler@21.2.6': dependencies: tslib: 2.8.1 - '@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)': + '@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)': dependencies: rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@angular/compiler': 21.2.5 + '@angular/compiler': 21.2.6 zone.js: 0.15.0 - '@angular/forms@21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': + '@angular/forms@21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2)': dependencies: - '@angular/common': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/platform-browser': 21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/common': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)) '@standard-schema/spec': 1.1.0 rxjs: 7.8.2 tslib: 2.8.1 - '@angular/localize@21.2.5(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5)': + '@angular/localize@21.2.6(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6)': dependencies: - '@angular/compiler': 21.2.5 - '@angular/compiler-cli': 21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3) + '@angular/compiler': 21.2.6 + '@angular/compiler-cli': 21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3) '@babel/core': 7.29.0 '@types/babel__core': 7.20.5 tinyglobby: 0.2.15 @@ -3766,25 +3771,25 @@ snapshots: transitivePeerDependencies: - supports-color - '@angular/platform-browser-dynamic@21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.2.5)(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))': + '@angular/platform-browser-dynamic@21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/compiler@21.2.6)(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))': dependencies: - '@angular/common': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/compiler': 21.2.5 - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/platform-browser': 21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/common': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/compiler': 21.2.6 + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/platform-browser': 21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)) tslib: 2.8.1 - '@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))': + '@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))': dependencies: - '@angular/common': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/common': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.8.1 optionalDependencies: - '@angular/animations': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)) + '@angular/animations': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)) - '@angular/service-worker@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': + '@angular/service-worker@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2)': dependencies: - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) rxjs: 7.8.2 tslib: 2.8.1 @@ -3850,7 +3855,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -3932,7 +3937,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 @@ -4080,9 +4085,9 @@ snapshots: '@exodus/bytes@1.15.0': {} - '@fortawesome/angular-fontawesome@4.0.0(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))': + '@fortawesome/angular-fontawesome@4.0.0(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))': dependencies: - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) '@fortawesome/fontawesome-svg-core': 7.2.0 tslib: 2.8.1 @@ -4109,9 +4114,9 @@ snapshots: '@harperfast/extended-iterable@1.0.3': optional: true - '@hono/node-server@1.19.11(hono@4.12.8)': + '@hono/node-server@1.19.12(hono@4.12.9)': dependencies: - hono: 4.12.8 + hono: 4.12.9 '@humanfs/core@0.19.1': {} @@ -4305,7 +4310,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.11(hono@4.12.8) + '@hono/node-server': 1.19.12(hono@4.12.9) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -4314,14 +4319,14 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.0.6 express: 5.2.1 - express-rate-limit: 8.3.1(express@5.2.1) - hono: 4.12.8 + express-rate-limit: 8.3.2(express@5.2.1) + hono: 4.12.9 jose: 6.2.2 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod-to-json-schema: 3.25.2(zod@4.3.6) transitivePeerDependencies: - supports-color @@ -4415,28 +4420,28 @@ snapshots: '@napi-rs/nice-win32-x64-msvc': 1.1.1 optional: true - '@napi-rs/wasm-runtime@1.1.1': + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 '@tybys/wasm-util': 0.10.1 optional: true - '@ng-bootstrap/ng-bootstrap@20.0.0(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))(@angular/localize@21.2.5(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5))(@popperjs/core@2.11.8)(rxjs@7.8.2)': + '@ng-bootstrap/ng-bootstrap@20.0.0(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))(@angular/localize@21.2.6(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6))(@popperjs/core@2.11.8)(rxjs@7.8.2)': dependencies: - '@angular/common': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/forms': 21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) - '@angular/localize': 21.2.5(@angular/compiler-cli@21.2.5(@angular/compiler@21.2.5)(typescript@5.9.3))(@angular/compiler@21.2.5) + '@angular/common': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/forms': 21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + '@angular/localize': 21.2.6(@angular/compiler-cli@21.2.6(@angular/compiler@21.2.6)(typescript@5.9.3))(@angular/compiler@21.2.6) '@popperjs/core': 2.11.8 rxjs: 7.8.2 tslib: 2.8.1 - '@ng-select/ng-select@21.5.2(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))': + '@ng-select/ng-select@21.7.0(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/forms@21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2))': dependencies: - '@angular/common': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) - '@angular/forms': 21.2.5(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.5(@angular/animations@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) + '@angular/common': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/forms': 21.2.6(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@21.2.6(@angular/animations@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)))(rxjs@7.8.2) tslib: 2.8.1 '@nodelib/fs.scandir@2.1.5': @@ -4555,7 +4560,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -4604,9 +4609,12 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.0.0-rc.4': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.4': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.4': @@ -4617,85 +4625,85 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.4': {} - '@rollup/rollup-android-arm-eabi@4.59.1': + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true - '@rollup/rollup-android-arm64@4.59.1': + '@rollup/rollup-android-arm64@4.60.1': optional: true - '@rollup/rollup-darwin-arm64@4.59.1': + '@rollup/rollup-darwin-arm64@4.60.1': optional: true - '@rollup/rollup-darwin-x64@4.59.1': + '@rollup/rollup-darwin-x64@4.60.1': optional: true - '@rollup/rollup-freebsd-arm64@4.59.1': + '@rollup/rollup-freebsd-arm64@4.60.1': optional: true - '@rollup/rollup-freebsd-x64@4.59.1': + '@rollup/rollup-freebsd-x64@4.60.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.59.1': + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.59.1': + '@rollup/rollup-linux-arm-musleabihf@4.60.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.59.1': + '@rollup/rollup-linux-arm64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.59.1': + '@rollup/rollup-linux-arm64-musl@4.60.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.59.1': + '@rollup/rollup-linux-loong64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-loong64-musl@4.59.1': + '@rollup/rollup-linux-loong64-musl@4.60.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.59.1': + '@rollup/rollup-linux-ppc64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-ppc64-musl@4.59.1': + '@rollup/rollup-linux-ppc64-musl@4.60.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.59.1': + '@rollup/rollup-linux-riscv64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.59.1': + '@rollup/rollup-linux-riscv64-musl@4.60.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.59.1': + '@rollup/rollup-linux-s390x-gnu@4.60.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.59.1': + '@rollup/rollup-linux-x64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-x64-musl@4.59.1': + '@rollup/rollup-linux-x64-musl@4.60.1': optional: true - '@rollup/rollup-openbsd-x64@4.59.1': + '@rollup/rollup-openbsd-x64@4.60.1': optional: true - '@rollup/rollup-openharmony-arm64@4.59.1': + '@rollup/rollup-openharmony-arm64@4.60.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.59.1': + '@rollup/rollup-win32-arm64-msvc@4.60.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.59.1': + '@rollup/rollup-win32-ia32-msvc@4.60.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.59.1': + '@rollup/rollup-win32-x64-gnu@4.60.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.59.1': + '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true - '@schematics/angular@21.2.3(chokidar@5.0.0)': + '@schematics/angular@21.2.5(chokidar@5.0.0)': dependencies: - '@angular-devkit/core': 21.2.3(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.3(chokidar@5.0.0) + '@angular-devkit/core': 21.2.5(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.5(chokidar@5.0.0) jsonc-parser: 3.3.1 transitivePeerDependencies: - chokidar @@ -4741,7 +4749,7 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.4 + minimatch: 10.2.5 '@tybys/wasm-util@0.10.1': dependencies: @@ -4832,10 +4840,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -4846,16 +4854,16 @@ snapshots: '@typescript-eslint/types': 8.47.0 '@typescript-eslint/visitor-keys': 8.47.0 - '@typescript-eslint/scope-manager@8.57.1': + '@typescript-eslint/scope-manager@8.58.0': dependencies: - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 '@typescript-eslint/tsconfig-utils@8.47.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -4873,7 +4881,7 @@ snapshots: '@typescript-eslint/types@8.47.0': {} - '@typescript-eslint/types@8.57.1': {} + '@typescript-eslint/types@8.58.0': {} '@typescript-eslint/typescript-estree@8.47.0(typescript@5.9.3)': dependencies: @@ -4891,14 +4899,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/visitor-keys': 8.57.1 + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -4917,12 +4925,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.57.1 - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: @@ -4933,53 +4941,53 @@ snapshots: '@typescript-eslint/types': 8.47.0 eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.57.1': + '@typescript-eslint/visitor-keys@8.58.0': dependencies: - '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/types': 8.58.0 eslint-visitor-keys: 5.0.1 '@vitejs/plugin-basic-ssl@2.1.4(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3))': dependencies: vite: 7.3.1(@types/node@25.5.0)(sass@1.97.3) - '@vitest/expect@4.1.0': + '@vitest/expect@4.1.2': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3))': + '@vitest/mocker@4.1.2(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3))': dependencies: - '@vitest/spy': 4.1.0 + '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.1(@types/node@25.5.0)(sass@1.97.3) - '@vitest/pretty-format@4.1.0': + '@vitest/pretty-format@4.1.2': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.0': + '@vitest/runner@4.1.2': dependencies: - '@vitest/utils': 4.1.0 + '@vitest/utils': 4.1.2 pathe: 2.0.3 - '@vitest/snapshot@4.1.0': + '@vitest/snapshot@4.1.2': dependencies: - '@vitest/pretty-format': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.0': {} + '@vitest/spy@4.1.2': {} - '@vitest/utils@4.1.0': + '@vitest/utils@4.1.2': dependencies: - '@vitest/pretty-format': 4.1.0 + '@vitest/pretty-format': 4.1.2 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -5040,18 +5048,18 @@ snapshots: '@algolia/requester-fetch': 5.48.1 '@algolia/requester-node-http': 5.48.1 - angular-eslint@21.1.0(@angular/cli@21.2.3(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript-eslint@8.47.0(eslint@9.39.4)(typescript@5.9.3))(typescript@5.9.3): + angular-eslint@21.1.0(@angular/cli@21.2.5(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript-eslint@8.47.0(eslint@9.39.4)(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@angular-devkit/core': 21.2.3(chokidar@5.0.0) - '@angular-devkit/schematics': 21.2.3(chokidar@5.0.0) - '@angular-eslint/builder': 21.1.0(@angular/cli@21.2.3(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3) - '@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@typescript-eslint/types@8.57.1)(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@angular/cli@21.2.3(@types/node@25.5.0)(chokidar@5.0.0))(@typescript-eslint/types@8.57.1)(@typescript-eslint/utils@8.57.1(eslint@9.39.4)(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3) + '@angular-devkit/core': 21.2.5(chokidar@5.0.0) + '@angular-devkit/schematics': 21.2.5(chokidar@5.0.0) + '@angular-eslint/builder': 21.1.0(@angular/cli@21.2.5(@types/node@25.5.0)(chokidar@5.0.0))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3) + '@angular-eslint/eslint-plugin': 21.1.0(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@angular-eslint/eslint-plugin-template': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@typescript-eslint/types@8.58.0)(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@angular-eslint/schematics': 21.1.0(@angular-eslint/template-parser@21.1.0(eslint@9.39.4)(typescript@5.9.3))(@angular/cli@21.2.5(@types/node@25.5.0)(chokidar@5.0.0))(@typescript-eslint/types@8.58.0)(@typescript-eslint/utils@8.58.0(eslint@9.39.4)(typescript@5.9.3))(chokidar@5.0.0)(eslint@9.39.4)(typescript@5.9.3) '@angular-eslint/template-parser': 21.1.0(eslint@9.39.4)(typescript@5.9.3) - '@angular/cli': 21.2.3(@types/node@25.5.0)(chokidar@5.0.0) - '@typescript-eslint/types': 8.57.1 - '@typescript-eslint/utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) + '@angular/cli': 21.2.5(@types/node@25.5.0)(chokidar@5.0.0) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 typescript: 5.9.3 typescript-eslint: 8.47.0(eslint@9.39.4)(typescript@5.9.3) @@ -5087,7 +5095,7 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.10.9: {} + baseline-browser-mapping@2.10.13: {} beasties@0.4.1: dependencies: @@ -5125,16 +5133,16 @@ snapshots: dependencies: '@popperjs/core': 2.11.8 - brace-expansion@1.1.12: + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -5142,13 +5150,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.9 - caniuse-lite: 1.0.30001780 - electron-to-chromium: 1.5.321 + baseline-browser-mapping: 2.10.13 + caniuse-lite: 1.0.30001784 + electron-to-chromium: 1.5.330 node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-from@1.1.2: {} @@ -5162,7 +5170,7 @@ snapshots: lru-cache: 11.2.7 minipass: 7.1.3 minipass-collect: 2.0.1 - minipass-flush: 1.0.5 + minipass-flush: 1.0.7 minipass-pipeline: 1.2.4 p-map: 7.0.4 ssri: 13.0.1 @@ -5179,7 +5187,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001780: {} + caniuse-lite@1.0.30001784: {} chai@6.2.2: {} @@ -5274,7 +5282,7 @@ snapshots: cssstyle@5.3.7: dependencies: '@asamuzakjp/css-color': 4.1.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) css-tree: 3.2.1 lru-cache: 11.2.7 @@ -5322,7 +5330,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.321: {} + electron-to-chromium@1.5.330: {} emoji-regex@10.6.0: {} @@ -5511,7 +5519,7 @@ snapshots: exponential-backoff@3.1.3: {} - express-rate-limit@8.3.1(express@5.2.1): + express-rate-limit@8.3.2(express@5.2.1): dependencies: express: 5.2.1 ip-address: 10.1.0 @@ -5569,9 +5577,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: @@ -5653,7 +5661,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -5673,7 +5681,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.12.8: {} + hono@4.12.9: {} hosted-git-info@9.0.2: dependencies: @@ -5722,7 +5730,7 @@ snapshots: ignore-walk@8.0.0: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 ignore@5.3.2: {} @@ -5811,7 +5819,7 @@ snapshots: webidl-conversions: 8.0.1 whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - ws: 8.19.0 + ws: 8.20.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -5913,7 +5921,7 @@ snapshots: http-cache-semantics: 4.2.0 minipass: 7.1.3 minipass-fetch: 5.0.2 - minipass-flush: 1.0.5 + minipass-flush: 1.0.7 minipass-pipeline: 1.2.4 negotiator: 1.0.0 proc-log: 6.1.0 @@ -5934,7 +5942,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -5950,17 +5958,17 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.4: + minimatch@10.2.5: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.5 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.13 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.3 minipass-collect@2.0.1: dependencies: @@ -5974,7 +5982,7 @@ snapshots: optionalDependencies: iconv-lite: 0.7.2 - minipass-flush@1.0.5: + minipass-flush@1.0.7: dependencies: minipass: 3.3.6 @@ -6027,16 +6035,16 @@ snapshots: negotiator@1.0.0: {} - ngx-cookie-service@21.3.1(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0)): + ngx-cookie-service@21.3.1(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0)): dependencies: - '@angular/common': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/common': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.8.1 - ngx-socket-io@4.10.0(@angular/common@21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2): + ngx-socket-io@4.10.0(@angular/common@21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2): dependencies: - '@angular/common': 21.2.5(@angular/core@21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) - '@angular/core': 21.2.5(@angular/compiler@21.2.5)(rxjs@7.8.2)(zone.js@0.15.0) + '@angular/common': 21.2.6(@angular/core@21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2) + '@angular/core': 21.2.6(@angular/compiler@21.2.6)(rxjs@7.8.2)(zone.js@0.15.0) core-js: 3.49.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 @@ -6068,7 +6076,7 @@ snapshots: nopt: 9.0.0 proc-log: 6.1.0 semver: 7.7.4 - tar: 7.5.12 + tar: 7.5.13 tinyglobby: 0.2.15 which: 6.0.1 transitivePeerDependencies: @@ -6195,7 +6203,7 @@ snapshots: promise-retry: 2.0.1 sigstore: 4.1.0 ssri: 13.0.1 - tar: 7.5.12 + tar: 7.5.13 transitivePeerDependencies: - supports-color @@ -6228,15 +6236,15 @@ snapshots: lru-cache: 11.2.7 minipass: 7.1.3 - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.1: {} pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} piscina@5.1.4: optionalDependencies: @@ -6308,7 +6316,7 @@ snapshots: rfdc@1.4.1: {} - rolldown@1.0.0-rc.4: + rolldown@1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): dependencies: '@oxc-project/types': 0.113.0 '@rolldown/pluginutils': 1.0.0-rc.4 @@ -6323,39 +6331,42 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.4 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.4 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.4 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.4 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.4 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - rollup@4.59.1: + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.1 - '@rollup/rollup-android-arm64': 4.59.1 - '@rollup/rollup-darwin-arm64': 4.59.1 - '@rollup/rollup-darwin-x64': 4.59.1 - '@rollup/rollup-freebsd-arm64': 4.59.1 - '@rollup/rollup-freebsd-x64': 4.59.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.1 - '@rollup/rollup-linux-arm-musleabihf': 4.59.1 - '@rollup/rollup-linux-arm64-gnu': 4.59.1 - '@rollup/rollup-linux-arm64-musl': 4.59.1 - '@rollup/rollup-linux-loong64-gnu': 4.59.1 - '@rollup/rollup-linux-loong64-musl': 4.59.1 - '@rollup/rollup-linux-ppc64-gnu': 4.59.1 - '@rollup/rollup-linux-ppc64-musl': 4.59.1 - '@rollup/rollup-linux-riscv64-gnu': 4.59.1 - '@rollup/rollup-linux-riscv64-musl': 4.59.1 - '@rollup/rollup-linux-s390x-gnu': 4.59.1 - '@rollup/rollup-linux-x64-gnu': 4.59.1 - '@rollup/rollup-linux-x64-musl': 4.59.1 - '@rollup/rollup-openbsd-x64': 4.59.1 - '@rollup/rollup-openharmony-arm64': 4.59.1 - '@rollup/rollup-win32-arm64-msvc': 4.59.1 - '@rollup/rollup-win32-ia32-msvc': 4.59.1 - '@rollup/rollup-win32-x64-gnu': 4.59.1 - '@rollup/rollup-win32-x64-msvc': 4.59.1 + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 router@2.2.0: @@ -6364,7 +6375,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.1 transitivePeerDependencies: - supports-color @@ -6603,7 +6614,7 @@ snapshots: symbol-tree@3.2.4: {} - tar@7.5.12: + tar@7.5.13: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -6617,8 +6628,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@3.1.0: {} @@ -6681,13 +6692,13 @@ snapshots: undici-types@7.18.2: {} - undici@7.22.0: {} + undici@7.24.4: {} unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -6702,31 +6713,31 @@ snapshots: vite@7.3.1(@types/node@25.5.0)(sass@1.97.3): dependencies: esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.8 - rollup: 4.59.1 + rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.5.0 fsevents: 2.3.3 sass: 1.97.3 - vitest@4.1.0(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)): + vitest@4.1.2(@types/node@25.5.0)(jsdom@27.4.0)(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)): dependencies: - '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)) - '@vitest/pretty-format': 4.1.0 - '@vitest/runner': 4.1.0 - '@vitest/snapshot': 4.1.0 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@25.5.0)(sass@1.97.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 @@ -6794,7 +6805,7 @@ snapshots: ws@8.18.3: {} - ws@8.19.0: {} + ws@8.20.0: {} xml-name-validator@5.0.0: {} @@ -6827,7 +6838,7 @@ snapshots: yoctocolors@2.1.2: {} - zod-to-json-schema@3.25.1(zod@4.3.6): + zod-to-json-schema@3.25.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 8ef50f8..303bdd1 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -89,15 +89,7 @@
-
- + @if (addInProgress && cancelRequested) { + } @else if (subscribeInProgress) { + + } @else { + } + + + +
+ +
+ +
+
+ + +
+ +
@@ -136,7 +169,7 @@ name="downloadType" [(ngModel)]="downloadType" (change)="downloadTypeChanged()" - [disabled]="addInProgress || downloads.loading"> + [disabled]="addInProgress || subscribeInProgress || downloads.loading"> @for (type of downloadTypes; track type.id) { } @@ -150,7 +183,7 @@ name="codec" [(ngModel)]="codec" (change)="codecChanged()" - [disabled]="addInProgress || downloads.loading"> + [disabled]="addInProgress || subscribeInProgress || downloads.loading"> @for (vc of videoCodecs; track vc.id) { } @@ -164,7 +197,7 @@ name="format" [(ngModel)]="format" (change)="formatChanged()" - [disabled]="addInProgress || downloads.loading"> + [disabled]="addInProgress || subscribeInProgress || downloads.loading"> @for (f of formatOptions; track f.id) { } @@ -178,7 +211,7 @@ name="quality" [(ngModel)]="quality" (change)="qualityChanged()" - [disabled]="addInProgress || downloads.loading || !showQualitySelector()"> + [disabled]="addInProgress || subscribeInProgress || downloads.loading || !showQualitySelector()"> @for (q of qualities; track q.id) { } @@ -193,7 +226,7 @@ name="downloadType" [(ngModel)]="downloadType" (change)="downloadTypeChanged()" - [disabled]="addInProgress || downloads.loading"> + [disabled]="addInProgress || subscribeInProgress || downloads.loading"> @for (type of downloadTypes; track type.id) { } @@ -207,7 +240,7 @@ name="format" [(ngModel)]="format" (change)="formatChanged()" - [disabled]="addInProgress || downloads.loading"> + [disabled]="addInProgress || subscribeInProgress || downloads.loading"> @for (f of formatOptions; track f.id) { } @@ -221,7 +254,7 @@ name="quality" [(ngModel)]="quality" (change)="qualityChanged()" - [disabled]="addInProgress || downloads.loading"> + [disabled]="addInProgress || subscribeInProgress || downloads.loading"> @for (q of qualities; track q.id) { } @@ -229,28 +262,29 @@
} @else if (downloadType === 'captions') { -
+ +
Type
-
+
Format
-
+
Language @@ -277,14 +311,14 @@
-
+
Subtitle Source +
+
@@ -415,7 +463,7 @@
Template
@@ -745,6 +793,127 @@
+ +
Subscriptions
+
+ @if (checkingAllSubscriptions) { + + } @else { + + } + @if (checkingSelectedSubscriptions) { + + } @else { + + } + +
+
+ + + + + + + + + + + + + + @for (entry of cachedSubs; track entry[0]) { + + + + + + + + + + } + +
+ + NameURLInterval (min)Last checkedStatus
+ + {{ entry[1].name }}{{ entry[1].url }}{{ entry[1].check_interval_minutes }} + @if (entry[1].last_checked !== null) { + {{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }} + } @else { + + } + + @if (entry[1].error) { + {{ entry[1].error }} + } @else if (entry[1].enabled) { + Active + } @else { + Paused + } + +
+ @if (isSubscriptionChecking(entry[0])) { + + } @else { + + } + + +
+
+