From 9593c040b041cf45521f07acb7d1b4a48d111e38 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:47:57 +0000 Subject: [PATCH] Misc features: Retries, user search config, sort by format, admin download control (#679) - Added the manual retry option for failed downloads - Added the ability to retry failed post-processing using existing downloaded file - Added admin-visible "Download as" selector, admin chooses a user to download on-behalf of - inherits their output preferences. - Added search mode and default metadata provider / release source options to User Preferences and My Account settings. - Added sort by format option in release results - Added {OriginalName} renaming field option, to retain the exact downloaded filename - Frontend dependency updates - fixes rollup vulnerability from this week Closes #662 #656 #649 #562 --- shelfmark/config/settings.py | 16 +- shelfmark/config/users_settings.py | 70 ++ shelfmark/core/admin_routes.py | 11 +- shelfmark/core/admin_settings_routes.py | 28 + shelfmark/core/models.py | 3 + shelfmark/core/naming.py | 1 + shelfmark/core/queue.py | 40 + shelfmark/core/self_user_routes.py | 19 + shelfmark/download/orchestrator.py | 183 +++- shelfmark/download/outputs/__init__.py | 2 +- shelfmark/download/outputs/booklore.py | 22 +- shelfmark/download/outputs/email.py | 22 +- shelfmark/download/outputs/folder.py | 17 +- shelfmark/download/postprocess/prepare.py | 11 +- shelfmark/download/postprocess/router.py | 17 +- shelfmark/download/postprocess/transfer.py | 20 +- shelfmark/main.py | 135 ++- shelfmark/metadata_providers/__init__.py | 38 +- src/frontend/package-lock.json | 952 ++++++++++-------- src/frontend/package.json | 4 +- src/frontend/src/App.tsx | 331 ++++-- src/frontend/src/components/Header.tsx | 164 ++- .../components/OnBehalfConfirmationModal.tsx | 150 +++ src/frontend/src/components/ReleaseModal.tsx | 110 +- .../src/components/activity/ActivityCard.tsx | 14 + .../components/activity/ActivitySidebar.tsx | 3 + .../components/activity/activityCardModel.ts | 15 +- .../src/components/activity/activityTypes.ts | 1 - .../components/settings/SelfSettingsModal.tsx | 17 +- .../components/settings/SettingsContent.tsx | 2 + .../src/components/settings/SettingsModal.tsx | 1 + .../customFields/UsersManagementField.tsx | 7 +- .../components/settings/customFields/types.ts | 1 + .../settings/users/UserOverridesSections.tsx | 25 +- .../settings/users/UserOverridesView.tsx | 3 + .../users/UserSearchPreferencesSection.tsx | 258 +++++ .../src/components/settings/users/types.ts | 4 + .../components/settings/users/useUserForm.ts | 8 +- .../settings/users/useUserMutations.ts | 4 +- .../settings/users/useUsersFetch.ts | 16 +- src/frontend/src/services/api.ts | 33 +- src/frontend/src/types/index.ts | 6 + src/frontend/src/utils/actingAsUser.ts | 5 + src/frontend/src/utils/releaseSort.ts | 22 + tests/config/test_users_settings.py | 41 +- tests/core/test_admin_users_api.py | 91 ++ tests/core/test_download_api_guardrails.py | 426 ++++++++ .../test_original_name_template_variable.py | 97 ++ tests/core/test_per_user_downloads.py | 13 + tests/core/test_processing_integration.py | 198 +++- tests/core/test_self_user_routes.py | 48 + tests/download/test_orchestrator_retry.py | 221 ++++ 52 files changed, 3288 insertions(+), 658 deletions(-) create mode 100644 src/frontend/src/components/OnBehalfConfirmationModal.tsx create mode 100644 src/frontend/src/components/settings/users/UserSearchPreferencesSection.tsx create mode 100644 src/frontend/src/utils/actingAsUser.ts create mode 100644 tests/core/test_original_name_template_variable.py create mode 100644 tests/download/test_orchestrator_retry.py diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 2755202..5edfa24 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -419,6 +419,7 @@ def search_mode_settings(): }, ], default="direct", + user_overridable=True, ), SelectField( key="AA_DEFAULT_SORT", @@ -441,6 +442,7 @@ def search_mode_settings(): options=_get_metadata_provider_options, # Callable - evaluated lazily to avoid circular imports default="openlibrary", show_when={"field": "SEARCH_MODE", "value": "universal"}, + user_overridable=True, ), SelectField( key="METADATA_PROVIDER_AUDIOBOOK", @@ -449,6 +451,7 @@ def search_mode_settings(): options=_get_metadata_provider_options_with_none, # Callable - includes "Use main provider" option default="", show_when={"field": "SEARCH_MODE", "value": "universal"}, + user_overridable=True, ), SelectField( key="DEFAULT_RELEASE_SOURCE", @@ -457,6 +460,7 @@ def search_mode_settings(): options=_get_release_source_options, # Callable - evaluated lazily to avoid circular imports default="direct_download", show_when={"field": "SEARCH_MODE", "value": "universal"}, + user_overridable=True, ), ] @@ -802,7 +806,7 @@ def download_settings(): { "value": "rename", "label": "Rename Only", - "description": "Rename files using a template" + "description": "Rename single-file downloads; multi-file keeps original names." }, { "value": "organize", @@ -820,7 +824,7 @@ def download_settings(): TextField( key="TEMPLATE_RENAME", label="Naming Template", - description="Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.", + description="Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders. Applies to single-file downloads.", default="{Author} - {Title} ({Year})", placeholder="{Author} - {Title} ({Year})", show_when=[ @@ -832,7 +836,7 @@ def download_settings(): TextField( key="TEMPLATE_ORGANIZE", label="Path Template", - description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.", + description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.", default="{Author}/{Title} ({Year})", placeholder="{Author}/{Series/}{Title} ({Year})", show_when=[ @@ -1057,7 +1061,7 @@ def download_settings(): description="Choose how downloaded audiobook files are named and organized.", options=[ {"value": "none", "label": "None", "description": "Keep original filename from source"}, - {"value": "rename", "label": "Rename Only", "description": "Rename files using a template"}, + {"value": "rename", "label": "Rename Only", "description": "Rename single-file downloads; multi-file keeps original names."}, {"value": "organize", "label": "Rename and Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."}, ], default="rename", @@ -1067,7 +1071,7 @@ def download_settings(): TextField( key="TEMPLATE_AUDIOBOOK_RENAME", label="Naming Template", - description="Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.", + description="Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders. Applies to single-file downloads.", default="{Author} - {Title}", placeholder="{Author} - {Title}{ - Part }{PartNumber}", show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"}, @@ -1077,7 +1081,7 @@ def download_settings(): TextField( key="TEMPLATE_AUDIOBOOK_ORGANIZE", label="Path Template", - description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.", + description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.", default="{Author}/{Title}", placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}", show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"}, diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index 4469280..c9c7929 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -5,6 +5,8 @@ The actual user management is handled by a custom frontend component that talks to /api/admin/users endpoints. """ +from typing import Any + from shelfmark.core.settings_registry import ( CheckboxField, CustomComponentField, @@ -56,6 +58,11 @@ _SELF_SETTINGS_SECTION_OPTIONS = [ "label": "Delivery Preferences", "description": "Show personal delivery output and destination settings.", }, + { + "value": "search", + "label": "Search Preferences", + "description": "Show personal search mode and provider settings.", + }, { "value": "notifications", "label": "Notifications", @@ -64,6 +71,13 @@ _SELF_SETTINGS_SECTION_OPTIONS = [ ] _SELF_SETTINGS_SECTION_VALUES = {option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS} _SELF_SETTINGS_SECTION_DEFAULTS = [option["value"] for option in _SELF_SETTINGS_SECTION_OPTIONS] +_SEARCH_MODE_VALUES = {"direct", "universal"} +_SEARCH_PREFERENCE_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK"} +_SEARCH_PREFERENCE_VALIDATABLE_KEYS = { + "SEARCH_MODE", + "DEFAULT_RELEASE_SOURCE", + *_SEARCH_PREFERENCE_PROVIDER_KEYS, +} _USERS_HEADING_DESCRIPTION_BY_AUTH_MODE = { "builtin": ( @@ -147,6 +161,50 @@ def _get_request_policy_rule_columns(): ] +def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | None]: + """Validate and normalize a search preference value for user overrides.""" + if key not in _SEARCH_PREFERENCE_VALIDATABLE_KEYS: + return value, None + + if value is None: + return None, None + + normalized_value = str(value).strip() + + if key == "SEARCH_MODE": + normalized_mode = normalized_value.lower() + if normalized_mode not in _SEARCH_MODE_VALUES: + return value, "SEARCH_MODE must be 'direct' or 'universal'" + return normalized_mode, None + + if key in _SEARCH_PREFERENCE_PROVIDER_KEYS: + if normalized_value == "": + return "", None + from shelfmark.metadata_providers import is_provider_registered + + if not is_provider_registered(normalized_value): + return ( + value, + f"{key} must be a valid metadata provider name or empty", + ) + return normalized_value, None + + if key == "DEFAULT_RELEASE_SOURCE": + if normalized_value == "": + return "", None + from shelfmark.release_sources import list_available_sources + + valid_sources = {source["name"] for source in list_available_sources()} + if normalized_value not in valid_sources: + return ( + value, + "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty", + ) + return normalized_value, None + + return value, None + + def _on_save_users(values): """Validate users/request-policy settings before persistence.""" if "VISIBLE_SELF_SETTINGS_SECTIONS" in values: @@ -207,6 +265,18 @@ def _on_save_users(values): } values["REQUEST_POLICY_RULES"] = normalized_rules + for key in _SEARCH_PREFERENCE_VALIDATABLE_KEYS: + if key not in values: + continue + normalized_value, validation_error = validate_search_preference_value(key, values[key]) + if validation_error: + return { + "error": True, + "message": validation_error, + "values": values, + } + values[key] = normalized_value + return {"error": False, "values": values} diff --git a/shelfmark/core/admin_routes.py b/shelfmark/core/admin_routes.py index 5358141..fb3e936 100644 --- a/shelfmark/core/admin_routes.py +++ b/shelfmark/core/admin_routes.py @@ -36,6 +36,7 @@ from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_db import UserDB logger = setup_logger(__name__) +_NO_AUTH_ACTIVITY_USERNAME = "__shelfmark_noauth_activity__" def _get_user_edit_capabilities( @@ -143,6 +144,11 @@ def _serialize_user( return payload +def _is_internal_system_user(user: dict[str, Any]) -> bool: + username = str(user.get("username") or "").strip() + return username == _NO_AUTH_ACTIVITY_USERNAME + + def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]: """Sync all users from the Calibre-Web database into users.db.""" if not CWA_DB_PATH or not CWA_DB_PATH.exists(): @@ -168,7 +174,7 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: @_require_admin def admin_list_users(): """List all users.""" - users = user_db.list_users() + users = [u for u in user_db.list_users() if not _is_internal_system_user(u)] auth_mode = _get_auth_mode() security_config = load_config_file("security") return jsonify([ @@ -206,7 +212,8 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None: return jsonify({"error": "Role must be 'admin' or 'user'"}), 400 # First user is always admin - if not user_db.list_users(): + real_users = [u for u in user_db.list_users() if not _is_internal_system_user(u)] + if not real_users: role = "admin" # Check if username already exists diff --git a/shelfmark/core/admin_settings_routes.py b/shelfmark/core/admin_settings_routes.py index 76efb98..cfefbe0 100644 --- a/shelfmark/core/admin_settings_routes.py +++ b/shelfmark/core/admin_settings_routes.py @@ -9,6 +9,7 @@ from shelfmark.config.notifications_settings import ( is_valid_notification_url, normalize_notification_routes, ) +from shelfmark.config.users_settings import validate_search_preference_value from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_settings_overrides import ( build_user_preferences_payload as _build_user_preferences_payload, @@ -68,6 +69,19 @@ def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], li valid[key] = normalized_routes continue + normalized_search_value, search_validation_error = validate_search_preference_value(key, value) + if search_validation_error: + errors.append(search_validation_error) + continue + if key in { + "SEARCH_MODE", + "METADATA_PROVIDER", + "METADATA_PROVIDER_AUDIOBOOK", + "DEFAULT_RELEASE_SOURCE", + }: + valid[key] = normalized_search_value + continue + valid[key] = value return valid, errors @@ -136,6 +150,20 @@ def register_admin_settings_routes( return jsonify(payload) + @app.route("/api/admin/users//search-preferences", methods=["GET"]) + @require_admin + def admin_get_search_preferences(user_id): + user = user_db.get_user(user_id=user_id) + if not user: + return jsonify({"error": "User not found"}), 404 + + try: + payload = _build_user_preferences_payload(user_db, user_id, "search_mode") + except ValueError: + return jsonify({"error": "Search mode settings tab not found"}), 500 + + return jsonify(payload) + @app.route("/api/admin/users//notification-preferences", methods=["GET"]) @require_admin def admin_get_notification_preferences(user_id): diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py index a5286db..b3ae3be 100644 --- a/shelfmark/core/models.py +++ b/shelfmark/core/models.py @@ -107,6 +107,9 @@ class DownloadTask: status: QueueStatus = QueueStatus.QUEUED status_message: Optional[str] = None download_path: Optional[str] = None + last_error_message: Optional[str] = None + last_error_type: Optional[str] = None + staged_path: Optional[str] = None def __lt__(self, other): """Compare tasks for priority queue (lower priority number = higher precedence).""" diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index 9fdc434..56743b1 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -14,6 +14,7 @@ logger = setup_logger(__name__) # e.g., "SeriesPosition" must match before "Series" KNOWN_TOKENS = [ 'seriesposition', + 'originalname', 'partnumber', 'subtitle', 'author', diff --git a/shelfmark/core/queue.py b/shelfmark/core/queue.py index efe4cdb..d72ff99 100644 --- a/shelfmark/core/queue.py +++ b/shelfmark/core/queue.py @@ -77,6 +77,11 @@ class BookQueue: with self._lock: return self._task_data.get(task_id) + def get_task_status(self, task_id: str) -> Optional[QueueStatus]: + """Get queue status for a task id.""" + with self._lock: + return self._status.get(task_id) + def _update_status(self, book_id: str, status: QueueStatus) -> None: """Internal method to update status and timestamp.""" self._status[book_id] = status @@ -247,6 +252,41 @@ class BookQueue: return found + def enqueue_existing(self, task_id: str, *, priority: Optional[int] = None) -> bool: + """Requeue an existing task regardless of current status. + + This is used for retries where task metadata should be preserved. + """ + with self._lock: + task = self._task_data.get(task_id) + if task is None: + return False + + if priority is not None: + task.priority = priority + + # Ensure task doesn't appear active while waiting for retry. + self._active_downloads.pop(task_id, None) + self._cancel_flags.pop(task_id, None) + + # De-duplicate queue entries for this task id. + temp_items: list[QueueItem] = [] + while not self._queue.empty(): + try: + item = self._queue.get_nowait() + except queue.Empty: + break + if item.book_id != task_id: + temp_items.append(item) + + for item in temp_items: + self._queue.put(item) + + queue_item = QueueItem(task_id, task.priority, time.time()) + self._queue.put(queue_item) + self._update_status(task_id, QueueStatus.QUEUED) + return True + def reorder_queue(self, task_priorities: Dict[str, int]) -> bool: """Bulk reorder queue by mapping task_id to new priority.""" with self._lock: diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py index 58a238b..75d04d5 100644 --- a/shelfmark/core/self_user_routes.py +++ b/shelfmark/core/self_user_routes.py @@ -33,9 +33,11 @@ logger = setup_logger(__name__) MIN_PASSWORD_LENGTH = 4 _VISIBLE_SELF_SETTINGS_SECTIONS_KEY = "VISIBLE_SELF_SETTINGS_SECTIONS" _SELF_SETTINGS_SECTION_DELIVERY = "delivery" +_SELF_SETTINGS_SECTION_SEARCH = "search" _SELF_SETTINGS_SECTION_NOTIFICATIONS = "notifications" _VALID_SELF_SETTINGS_SECTIONS = ( _SELF_SETTINGS_SECTION_DELIVERY, + _SELF_SETTINGS_SECTION_SEARCH, _SELF_SETTINGS_SECTION_NOTIFICATIONS, ) _DEFAULT_VISIBLE_SELF_SETTINGS_SECTIONS = list(_VALID_SELF_SETTINGS_SECTIONS) @@ -155,6 +157,11 @@ def _get_allowed_self_settings_keys(visible_sections: list[str]) -> set[str]: key for key, _field in _get_ordered_user_overridable_fields("downloads") } + if _SELF_SETTINGS_SECTION_SEARCH in visible_sections_set: + allowed_keys |= { + key for key, _field in _get_ordered_user_overridable_fields("search_mode") + } + if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_sections_set: allowed_keys |= { key for key, _field in _get_ordered_user_overridable_fields("notifications") @@ -188,6 +195,16 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}") delivery_preferences = None + search_preferences = None + if _SELF_SETTINGS_SECTION_SEARCH in visible_self_settings_sections: + try: + search_preferences = _build_user_preferences_payload(user_db, user_id, "search_mode") + except ValueError: + return jsonify({"error": "Search mode settings tab not found"}), 500 + except Exception as exc: + logger.warning(f"Failed to build user search preferences for user_id={user_id}: {exc}") + search_preferences = None + notification_preferences = None if _SELF_SETTINGS_SECTION_NOTIFICATIONS in visible_self_settings_sections: try: @@ -200,6 +217,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: user_overridable_keys = sorted( set(delivery_preferences.get("keys", []) if delivery_preferences else []) + | set(search_preferences.get("keys", []) if search_preferences else []) | set(notification_preferences.get("keys", []) if notification_preferences else []) ) @@ -207,6 +225,7 @@ def register_self_user_routes(app: Flask, user_db: UserDB) -> None: { "user": serialized_user, "deliveryPreferences": delivery_preferences, + "searchPreferences": search_preferences, "notificationPreferences": notification_preferences, "userOverridableKeys": user_overridable_keys, "visibleUserSettingsSections": visible_self_settings_sections, diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 30aa4b5..585fcb6 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -19,6 +19,7 @@ from shelfmark.core.logger import setup_logger from shelfmark.core.models import BookInfo, DownloadTask, QueueStatus, SearchFilters, SearchMode from shelfmark.core.queue import book_queue from shelfmark.core.utils import transform_cover_url, is_audiobook as check_audiobook +from shelfmark.config import env as env_config from shelfmark.download.fs import run_blocking_io from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path from shelfmark.download.postprocess.router import post_process_download @@ -347,6 +348,36 @@ def _task_to_dict(task: DownloadTask) -> Dict[str, Any]: } +def _clear_task_error_state(task: DownloadTask) -> None: + task.last_error_message = None + task.last_error_type = None + + +def _capture_task_error( + task: DownloadTask, + *, + message: Optional[str] = None, + exc_type: Optional[str] = None, +) -> None: + if isinstance(message, str): + normalized = message.strip() + if normalized: + task.last_error_message = normalized + book_queue.update_status_message(task.task_id, normalized) + if isinstance(exc_type, str): + normalized_type = exc_type.strip() + if normalized_type: + task.last_error_type = normalized_type + + +def _format_download_exception_message(exc: Exception) -> str: + if isinstance(exc, PermissionError) and "/cwa-book-ingest" in str(exc): + return "Destination misconfigured. Go to Settings → Downloads to update." + if isinstance(exc, PermissionError): + return f"Permission denied: {exc}" + return f"Download failed: {type(exc).__name__}" + + def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: """Download a task via appropriate handler, then post-process to ingest.""" try: @@ -372,25 +403,49 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: update_download_progress(task_id, progress) def status_callback(status: str, message: Optional[str] = None) -> None: + status_key = status.lower() + if status_key == "error": + _capture_task_error( + task, + message=message or "Download failed", + exc_type="StatusCallbackError", + ) + return update_download_status(task_id, status, message) # Get the download handler based on the task's source handler = get_handler(task.source) - temp_path = handler.download( - task, - cancel_flag, - progress_callback, - status_callback - ) + temp_file: Optional[Path] = None - # Handler returns temp path - orchestrator handles post-processing - if not temp_path: - return None + if task.staged_path: + staged_file = Path(task.staged_path) + if run_blocking_io(staged_file.exists): + temp_file = staged_file + logger.info("Task %s: reusing staged file for retry: %s", task_id, staged_file) + else: + task.staged_path = None - temp_file = Path(temp_path) - if not run_blocking_io(temp_file.exists): - logger.error(f"Handler returned non-existent path: {temp_path}") - return None + if temp_file is None: + temp_path = handler.download( + task, + cancel_flag, + progress_callback, + status_callback, + ) + + # Handler returns temp path - orchestrator handles post-processing + if not temp_path: + return None + + temp_file = Path(temp_path) + if not run_blocking_io(temp_file.exists): + logger.error(f"Handler returned non-existent path: {temp_path}") + _capture_task_error( + task, + message=f"Download file missing: {temp_path}", + exc_type="MissingDownloadPath", + ) + return None # Check cancellation before post-processing if cancel_flag.is_set(): @@ -401,9 +456,17 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: logger.info("Task %s: download finished; starting post-processing", task_id) logger.debug("Task %s: post-processing input path: %s", task_id, temp_file) + task.staged_path = str(temp_file) + preserve_source_on_failure = True # Post-processing: output routing + file processing pipeline - result = post_process_download(temp_file, task, cancel_flag, status_callback) + result = post_process_download( + temp_file, + task, + cancel_flag, + status_callback, + preserve_source_on_failure=preserve_source_on_failure, + ) if cancel_flag.is_set(): logger.info("Task %s: post-processing cancelled", task_id) @@ -412,12 +475,22 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: logger.debug("Task %s: post-processing result: %s", task_id, result) else: logger.warning("Task %s: post-processing failed", task_id) + if not task.last_error_message: + _capture_task_error( + task, + message="Download failed", + exc_type="UnknownFailure", + ) try: handler.post_process_cleanup(task, success=bool(result)) except Exception as e: logger.warning("Post-processing cleanup hook failed for %s: %s", task_id, e) + if result: + task.staged_path = None + _clear_task_error_state(task) + return result except Exception as e: @@ -425,21 +498,13 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: logger.info("Task %s: cancelled during error handling", task_id) else: logger.error_trace("Task %s: error downloading: %s", task_id, e) - # Update task status so user sees the failure task = book_queue.get_task(task_id) if task: - book_queue.update_status(task_id, QueueStatus.ERROR) - # Check for known misconfiguration from earlier versions - if isinstance(e, PermissionError) and "/cwa-book-ingest" in str(e): - book_queue.update_status_message( - task_id, - "Destination misconfigured. Go to Settings → Downloads to update." - ) - else: - if isinstance(e, PermissionError): - book_queue.update_status_message(task_id, f"Permission denied: {e}") - else: - book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}") + _capture_task_error( + task, + message=_format_download_exception_message(e), + exc_type=type(e).__name__, + ) return None @@ -531,6 +596,34 @@ def cancel_download(book_id: str) -> bool: return result + +def retry_download(book_id: str) -> Tuple[bool, Optional[str]]: + """Retry a failed standalone download.""" + task = book_queue.get_task(book_id) + if task is None: + return False, "Download not found" + + status = book_queue.get_task_status(book_id) + if status != QueueStatus.ERROR: + return False, "Download is not in an error state" + + if task.request_id: + return False, "Request-linked downloads must be retried from requests" + + task.last_error_message = None + task.last_error_type = None + task.priority = -10 + + if not book_queue.enqueue_existing(book_id, priority=-10): + return False, "Failed to requeue download" + + book_queue.update_status_message(book_id, "Retrying now") + + if ws_manager: + ws_manager.broadcast_status_update(queue_status()) + + return True, None + def set_book_priority(book_id: str, priority: int) -> bool: """Set priority for a queued book (lower = higher priority).""" return book_queue.set_priority(book_id, priority) @@ -560,6 +653,24 @@ def _cleanup_progress_tracking(task_id: str) -> None: _last_status_event.pop(task_id, None) +def _finalize_download_failure(task_id: str) -> None: + task = book_queue.get_task(task_id) + if not task: + return + + message = task.last_error_message or task.status_message or "" + normalized_message = message.strip() + if not normalized_message: + normalized_message = ( + f"Download failed: {task.last_error_type}" + if task.last_error_type + else "Download failed" + ) + + book_queue.update_status_message(task_id, normalized_message) + book_queue.update_status(task_id, QueueStatus.ERROR) + + def _process_single_download(task_id: str, cancel_flag: Event) -> None: """Process a single download job.""" try: @@ -579,12 +690,9 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None: if download_path: book_queue.update_download_path(task_id, download_path) - # Only update status if not already set (e.g., by archive extraction callback) - task = book_queue.get_task(task_id) - if not task or task.status != QueueStatus.COMPLETE: - book_queue.update_status(task_id, QueueStatus.COMPLETE) + book_queue.update_status(task_id, QueueStatus.COMPLETE) else: - book_queue.update_status(task_id, QueueStatus.ERROR) + _finalize_download_failure(task_id) # Broadcast final status (completed or error) if ws_manager: @@ -596,11 +704,14 @@ def _process_single_download(task_id: str, cancel_flag: Event) -> None: if not cancel_flag.is_set(): logger.error_trace(f"Error in download processing: {e}") - book_queue.update_status(task_id, QueueStatus.ERROR) - # Set error message if not already set by handler task = book_queue.get_task(task_id) - if task and not task.status_message: - book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}: {str(e)}") + if task: + _capture_task_error( + task, + message=f"Download failed: {type(e).__name__}: {str(e)}", + exc_type=type(e).__name__, + ) + _finalize_download_failure(task_id) else: logger.info(f"Download cancelled: {task_id}") book_queue.update_status(task_id, QueueStatus.CANCELLED) diff --git a/shelfmark/download/outputs/__init__.py b/shelfmark/download/outputs/__init__.py index a9b5ccd..bf9df74 100644 --- a/shelfmark/download/outputs/__init__.py +++ b/shelfmark/download/outputs/__init__.py @@ -8,7 +8,7 @@ from typing import Callable, Optional from shelfmark.core.models import DownloadTask StatusCallback = Callable[[str, Optional[str]], None] -OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback], Optional[str]] +OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback, bool], Optional[str]] @dataclass(frozen=True) diff --git a/shelfmark/download/outputs/booklore.py b/shelfmark/download/outputs/booklore.py index 6301cfe..0805fb7 100644 --- a/shelfmark/download/outputs/booklore.py +++ b/shelfmark/download/outputs/booklore.py @@ -13,7 +13,7 @@ from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask from shelfmark.core.utils import is_audiobook as check_audiobook from shelfmark.download.outputs import register_output -from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir +from shelfmark.download.staging import STAGE_COPY, STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir logger = setup_logger(__name__) @@ -241,6 +241,7 @@ def _post_process_booklore( task: DownloadTask, cancel_flag: Event, status_callback, + preserve_source_on_failure: bool = False, ) -> Optional[str]: from shelfmark.download.postprocess.pipeline import ( CustomScriptContext, @@ -249,6 +250,7 @@ def _post_process_booklore( is_managed_workspace_path, maybe_run_custom_script, prepare_output_files, + safe_cleanup_path, ) if cancel_flag.is_set(): @@ -267,7 +269,9 @@ def _post_process_booklore( status_callback("resolving", "Preparing Booklore upload") - stage_action = STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE + stage_action = STAGE_NONE + if is_managed_workspace_path(temp_file): + stage_action = STAGE_COPY if preserve_source_on_failure else STAGE_MOVE staging_dir = build_staging_dir("booklore", task.task_id) if stage_action != STAGE_NONE else get_staging_dir() output_plan = OutputPlan( @@ -283,12 +287,14 @@ def _post_process_booklore( BOOKLORE_OUTPUT_MODE, status_callback, output_plan=output_plan, + preserve_source_on_failure=preserve_source_on_failure, ) if not prepared: return None logger.debug("Task %s: prepared %d file(s) for Booklore upload", task.task_id, len(prepared.files)) + success = False try: unsupported_files = [ file_path @@ -359,6 +365,7 @@ def _post_process_booklore( if len(prepared.files) > 1: message = f"Uploaded to Booklore ({len(prepared.files)} files)" status_callback("complete", message) + success = True return f"booklore://{task.task_id}" except BookloreError as e: @@ -376,6 +383,8 @@ def _post_process_booklore( task, prepared.cleanup_paths, ) + if preserve_source_on_failure and success: + safe_cleanup_path(temp_file, task) @register_output(BOOKLORE_OUTPUT_MODE, supports_task=_supports_booklore, priority=10) @@ -384,5 +393,12 @@ def process_booklore_output( task: DownloadTask, cancel_flag: Event, status_callback, + preserve_source_on_failure: bool = False, ) -> Optional[str]: - return _post_process_booklore(temp_file, task, cancel_flag, status_callback) + return _post_process_booklore( + temp_file, + task, + cancel_flag, + status_callback, + preserve_source_on_failure=preserve_source_on_failure, + ) diff --git a/shelfmark/download/outputs/email.py b/shelfmark/download/outputs/email.py index 6a4d3f1..11f400e 100644 --- a/shelfmark/download/outputs/email.py +++ b/shelfmark/download/outputs/email.py @@ -15,7 +15,7 @@ from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask from shelfmark.core.utils import is_audiobook as check_audiobook from shelfmark.download.outputs import register_output -from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir +from shelfmark.download.staging import STAGE_COPY, STAGE_MOVE, STAGE_NONE, build_staging_dir, get_staging_dir logger = setup_logger(__name__) @@ -268,6 +268,7 @@ def _post_process_email( task: DownloadTask, cancel_flag: Event, status_callback, + preserve_source_on_failure: bool = False, ) -> Optional[str]: from shelfmark.download.postprocess.pipeline import ( CustomScriptContext, @@ -276,6 +277,7 @@ def _post_process_email( is_managed_workspace_path, maybe_run_custom_script, prepare_output_files, + safe_cleanup_path, ) if cancel_flag.is_set(): @@ -304,7 +306,9 @@ def _post_process_email( status_callback("resolving", "Preparing email") - stage_action = STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE + stage_action = STAGE_NONE + if is_managed_workspace_path(temp_file): + stage_action = STAGE_COPY if preserve_source_on_failure else STAGE_MOVE staging_dir = build_staging_dir("email", task.task_id) if stage_action != STAGE_NONE else get_staging_dir() output_plan = OutputPlan( @@ -320,10 +324,12 @@ def _post_process_email( EMAIL_OUTPUT_MODE, status_callback, output_plan=output_plan, + preserve_source_on_failure=preserve_source_on_failure, ) if not prepared: return None + success = False try: limit_mb_raw = core_config.config.get("EMAIL_ATTACHMENT_SIZE_LIMIT_MB", 25) try: @@ -399,6 +405,7 @@ def _post_process_email( return None status_callback("complete", f"Sent to {label}") + success = True return f"email://{task.task_id}" except EmailOutputError as exc: @@ -416,6 +423,8 @@ def _post_process_email( task, prepared.cleanup_paths, ) + if preserve_source_on_failure and success: + safe_cleanup_path(temp_file, task) @register_output(EMAIL_OUTPUT_MODE, supports_task=_supports_email, priority=10) @@ -424,5 +433,12 @@ def process_email_output( task: DownloadTask, cancel_flag: Event, status_callback, + preserve_source_on_failure: bool = False, ) -> Optional[str]: - return _post_process_email(temp_file, task, cancel_flag, status_callback) + return _post_process_email( + temp_file, + task, + cancel_flag, + status_callback, + preserve_source_on_failure=preserve_source_on_failure, + ) diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py index 712ec4e..61fa46d 100644 --- a/shelfmark/download/outputs/folder.py +++ b/shelfmark/download/outputs/folder.py @@ -88,6 +88,7 @@ def process_folder_output( task: DownloadTask, cancel_flag: Event, status_callback, + preserve_source_on_failure: bool = False, ) -> Optional[str]: """Post-process download to the configured folder destination.""" from shelfmark.download.postprocess.pipeline import ( @@ -122,6 +123,7 @@ def process_folder_output( output_mode=plan.output_mode, status_callback=status_callback, destination=plan.destination, + preserve_source_on_failure=preserve_source_on_failure, ) if not prepared: return None @@ -143,7 +145,7 @@ def process_folder_output( # For external usenet downloads, always copy from the client path. # "Move" is implemented as a client-side cleanup after import. - preserve_source = is_usenet + preserve_source = is_usenet or preserve_source_on_failure copy_for_label = is_torrent or preserve_source or prepared.output_plan.stage_action != STAGE_NONE @@ -227,12 +229,13 @@ def process_folder_output( ) if not maybe_run_custom_script(script_context, status_callback=status_callback, steps=steps): - cleanup_output_staging( - prepared.output_plan, - prepared.working_path, - task, - prepared.cleanup_paths, - ) + if not preserve_source_on_failure: + cleanup_output_staging( + prepared.output_plan, + prepared.working_path, + task, + prepared.cleanup_paths, + ) return None cleanup_output_staging( diff --git a/shelfmark/download/postprocess/prepare.py b/shelfmark/download/postprocess/prepare.py index 94e6fb7..fed06b2 100644 --- a/shelfmark/download/postprocess/prepare.py +++ b/shelfmark/download/postprocess/prepare.py @@ -43,6 +43,7 @@ def prepare_output_files( status_callback, destination: Optional[Path] = None, output_plan: Optional[OutputPlan] = None, + preserve_source_on_failure: bool = False, ) -> Optional[PreparedFiles]: if output_plan is None: output_plan = build_output_plan( @@ -59,19 +60,23 @@ def prepare_output_files( status_callback("resolving", step_label) working_path = stage_path(working_path, output_plan.staging_dir, output_plan.stage_action) - can_delete_source_archives = output_plan.stage_action != STAGE_NONE or is_managed_workspace_path(working_path) + can_delete_source_archives = output_plan.stage_action != STAGE_NONE or is_managed_workspace_path( + working_path + ) + cleanup_archives = can_delete_source_archives and not preserve_source_on_failure files, rejected_files, cleanup_paths, error = collect_staged_files( working_path=working_path, task=task, allow_archive_extraction=output_plan.allow_archive_extraction, status_callback=status_callback, - cleanup_archives=can_delete_source_archives, + cleanup_archives=cleanup_archives, ) if error: status_callback("error", error) - cleanup_output_staging(output_plan, working_path, task, cleanup_paths) + if not preserve_source_on_failure: + cleanup_output_staging(output_plan, working_path, task, cleanup_paths) return None if output_plan.stage_action == STAGE_NONE and is_managed_workspace_path(working_path): diff --git a/shelfmark/download/postprocess/router.py b/shelfmark/download/postprocess/router.py index ef102c6..deec794 100644 --- a/shelfmark/download/postprocess/router.py +++ b/shelfmark/download/postprocess/router.py @@ -26,6 +26,7 @@ def post_process_download( task: DownloadTask, cancel_flag: Event, status_callback, + preserve_source_on_failure: bool = False, ) -> Optional[str]: """Post-process download using the selected output handler.""" @@ -44,9 +45,21 @@ def post_process_download( output_handler = resolve_output_handler(task) if output_handler: logger.info("Task %s: using output mode %s", task.task_id, output_handler.mode) - return output_handler.handler(temp_file, task, cancel_flag, status_callback) + return output_handler.handler( + temp_file, + task, + cancel_flag, + status_callback, + preserve_source_on_failure, + ) from shelfmark.download.outputs.folder import process_folder_output logger.info("Task %s: using output mode folder", task.task_id) - return process_folder_output(temp_file, task, cancel_flag, status_callback) + return process_folder_output( + temp_file, + task, + cancel_flag, + status_callback, + preserve_source_on_failure, + ) diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index 571c692..b5ef778 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -57,6 +57,14 @@ def build_metadata_dict(task: DownloadTask) -> dict: } +def build_file_metadata(task: DownloadTask, source_file: Path, part_number: Optional[str] = None) -> dict: + metadata = build_metadata_dict(task) + metadata["OriginalName"] = source_file.stem + if part_number is not None: + metadata["PartNumber"] = part_number + return metadata + + def resolve_hardlink_source( temp_file: Path, task: DownloadTask, @@ -157,16 +165,16 @@ def transfer_book_files( if organization_mode == "organize": template = get_template(is_audiobook, "organize") - metadata = build_metadata_dict(task) if len(book_files) == 1: source_file = book_files[0] ext = source_file.suffix.lstrip(".") or task.format or "" + file_metadata = build_file_metadata(task, source_file) dest_path = run_blocking_io( build_library_path, str(destination), template, - metadata, + file_metadata, extension=ext or None, ) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) @@ -188,7 +196,7 @@ def transfer_book_files( for source_file, part_number in files_with_parts: ext = source_file.suffix.lstrip(".") or task.format or "" - file_metadata = {**metadata, "PartNumber": part_number} + file_metadata = build_file_metadata(task, source_file, part_number=part_number) dest_path = run_blocking_io( build_library_path, str(destination), @@ -218,7 +226,7 @@ def transfer_book_files( task.format = book_file.suffix.lower().lstrip(".") template = get_template(is_audiobook, "rename") - metadata = build_metadata_dict(task) + metadata = build_file_metadata(task, book_file) extension = book_file.suffix.lstrip(".") or task.format or "" filename = parse_naming_template(template, metadata, allow_path_separators=False) @@ -311,7 +319,9 @@ def transfer_file_to_library( use_hardlink: bool, ) -> Optional[str]: extension = source_path.suffix.lstrip(".") or task.format - dest_path = run_blocking_io(build_library_path, library_base, template, metadata, extension) + template_metadata = dict(metadata) + template_metadata.setdefault("OriginalName", source_path.stem) + dest_path = run_blocking_io(build_library_path, library_base, template, template_metadata, extension) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) is_torrent = is_torrent_source(source_path, task) diff --git a/shelfmark/main.py b/shelfmark/main.py index 7e284c5..42f8dd4 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -388,6 +388,36 @@ def _policy_block_response(mode: PolicyMode): ) +def _resolve_download_user_context( + db_user_id: Any, + username: Any, + on_behalf_of_user_id: Any, +) -> tuple[Any, Any, tuple[Response, int] | None]: + """Resolve download queue user context, including optional admin on-behalf overrides.""" + if on_behalf_of_user_id in (None, ""): + return db_user_id, username, None + + if not session.get("is_admin", False): + return db_user_id, username, (jsonify({"error": "Admin required"}), 403) + + if user_db is None: + return db_user_id, username, (jsonify({"error": "User database unavailable"}), 503) + + try: + target_user_id = int(on_behalf_of_user_id) + except (TypeError, ValueError): + return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400) + + if target_user_id <= 0: + return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400) + + target_user = user_db.get_user(user_id=target_user_id) + if not target_user: + return db_user_id, username, (jsonify({"error": "User not found"}), 404) + + return target_user["id"], target_user["username"], None + + if user_db is not None: try: from shelfmark.core.request_routes import register_request_routes @@ -864,6 +894,13 @@ def api_download() -> Union[Response, Tuple[Response, int]]: # Per-user download overrides db_user_id = session.get('db_user_id') _username = session.get('user_id') + db_user_id, _username, on_behalf_error = _resolve_download_user_context( + db_user_id, + _username, + request.args.get("on_behalf_of_user_id"), + ) + if on_behalf_error: + return on_behalf_error success, error_msg = backend.queue_book( book_id, priority, user_id=db_user_id, username=_username, @@ -922,6 +959,13 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]: # Per-user download overrides db_user_id = session.get('db_user_id') _username = session.get('user_id') + db_user_id, _username, on_behalf_error = _resolve_download_user_context( + db_user_id, + _username, + data.get("on_behalf_of_user_id"), + ) + if on_behalf_error: + return on_behalf_error success, error_msg = backend.queue_release( release_payload, priority, user_id=db_user_id, username=_username, @@ -953,6 +997,30 @@ def api_config() -> Union[Response, Tuple[Response, int]]: from shelfmark.config.env import _is_config_dir_writable from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete + raw_db_user_id = session.get("db_user_id") + try: + db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None + except (TypeError, ValueError): + db_user_id = None + + search_mode = app_config.get("SEARCH_MODE", "direct", user_id=db_user_id) + default_release_source = app_config.get( + "DEFAULT_RELEASE_SOURCE", + "direct_download", + user_id=db_user_id, + ) + configured_metadata_provider = app_config.get( + "METADATA_PROVIDER", + "", + user_id=db_user_id, + ) + _configured_metadata_provider_audiobook = app_config.get( + "METADATA_PROVIDER_AUDIOBOOK", + "", + user_id=db_user_id, + ) + metadata_ui_provider = configured_metadata_provider or _configured_metadata_provider_audiobook + config = { "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), "audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""), @@ -963,10 +1031,10 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "default_language": app_config.BOOK_LANGUAGE, "supported_formats": app_config.SUPPORTED_FORMATS, "supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS, - "search_mode": app_config.get("SEARCH_MODE", "direct"), - "metadata_sort_options": get_provider_sort_options(), - "metadata_search_fields": get_provider_search_fields(), - "default_release_source": app_config.get("DEFAULT_RELEASE_SOURCE", "direct_download"), + "search_mode": search_mode, + "metadata_sort_options": get_provider_sort_options(metadata_ui_provider), + "metadata_search_fields": get_provider_search_fields(metadata_ui_provider), + "default_release_source": default_release_source, "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), "download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False), @@ -974,7 +1042,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "onboarding_complete": _get_onboarding_complete(), # Default sort orders "default_sort": app_config.get("AA_DEFAULT_SORT", "relevance"), # For direct mode (Anna's Archive) - "metadata_default_sort": get_provider_default_sort(), # For universal mode + "metadata_default_sort": get_provider_default_sort(metadata_ui_provider), # For universal mode } return jsonify(config) except Exception as e: @@ -1434,6 +1502,55 @@ def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]: logger.error_trace(f"Cancel download error: {e}") return jsonify({"error": str(e)}), 500 + +@app.route('/api/download//retry', methods=['POST']) +@login_required +def api_retry_download(book_id: str) -> Union[Response, Tuple[Response, int]]: + """Retry a failed download.""" + try: + task = backend.book_queue.get_task(book_id) + if task is None: + return jsonify({"error": "Download not found"}), 404 + + is_admin, db_user_id, can_access_status = _resolve_status_scope() + if not is_admin: + if not can_access_status or db_user_id is None: + return jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}), 403 + + actor_username = session.get("user_id") + normalized_actor_username = actor_username if isinstance(actor_username, str) else None + if not _task_owned_by_actor( + task, + actor_user_id=db_user_id, + actor_username=normalized_actor_username, + ): + return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403 + + raw_owner_user_id = getattr(task, "user_id", None) + try: + owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None + except (TypeError, ValueError): + owner_user_id = None + + is_request_linked = bool(getattr(task, "request_id", None)) + if not is_request_linked and owner_user_id is not None: + is_request_linked = _is_graduated_request_download(book_id, user_id=owner_user_id) + if is_request_linked: + return jsonify({"error": "Forbidden", "code": "requested_download_retry_forbidden"}), 403 + + success, error = backend.retry_download(book_id) + if success: + return jsonify({"status": "queued", "book_id": book_id}) + + if error == "Download not found": + return jsonify({"error": error}), 404 + + return jsonify({"error": error or "Download cannot be retried"}), 409 + except Exception as e: + logger.error_trace(f"Retry download error: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route('/api/queue//priority', methods=['PUT']) @login_required def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]: @@ -1944,7 +2061,13 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: except ValueError: sort_order = SortOrder.RELEVANCE - provider = get_configured_provider(content_type=content_type) + raw_db_user_id = session.get("db_user_id") + try: + db_user_id = int(raw_db_user_id) if raw_db_user_id is not None else None + except (TypeError, ValueError): + db_user_id = None + + provider = get_configured_provider(content_type=content_type, user_id=db_user_id) if not provider: return jsonify({ "error": "No metadata provider configured", diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index 91fcb55..ca302ce 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -393,7 +393,10 @@ def get_enabled_providers() -> List[str]: return [name for name in _PROVIDERS if is_provider_enabled(name)] -def get_configured_provider(content_type: str = "ebook") -> Optional[MetadataProvider]: +def get_configured_provider( + content_type: str = "ebook", + user_id: Optional[int] = None, +) -> Optional[MetadataProvider]: """Get the currently configured metadata provider for the content type.""" from shelfmark.core.config import config as app_config @@ -402,11 +405,11 @@ def get_configured_provider(content_type: str = "ebook") -> Optional[MetadataPro # For audiobooks, try audiobook-specific provider first, then fall back to main provider if content_type == "audiobook": - metadata_provider = app_config.get("METADATA_PROVIDER_AUDIOBOOK", "") + metadata_provider = app_config.get("METADATA_PROVIDER_AUDIOBOOK", "", user_id=user_id) if not metadata_provider: - metadata_provider = app_config.get("METADATA_PROVIDER", "") + metadata_provider = app_config.get("METADATA_PROVIDER", "", user_id=user_id) else: - metadata_provider = app_config.get("METADATA_PROVIDER", "") + metadata_provider = app_config.get("METADATA_PROVIDER", "", user_id=user_id) if not metadata_provider: return None @@ -422,17 +425,20 @@ def get_configured_provider(content_type: str = "ebook") -> Optional[MetadataPro return get_provider(metadata_provider, **kwargs) -def _get_configured_provider_name() -> str: +def _get_configured_provider_name(user_id: Optional[int] = None) -> str: """Get the currently configured metadata provider name from config.""" from shelfmark.core.config import config as app_config app_config.refresh() - return app_config.get("METADATA_PROVIDER", "") + return app_config.get("METADATA_PROVIDER", "", user_id=user_id) -def get_provider_sort_options(provider_name: Optional[str] = None) -> List[Dict[str, str]]: +def get_provider_sort_options( + provider_name: Optional[str] = None, + user_id: Optional[int] = None, +) -> List[Dict[str, str]]: """Get sort options for a metadata provider as {value, label} dicts.""" if provider_name is None: - provider_name = _get_configured_provider_name() + provider_name = _get_configured_provider_name(user_id=user_id) if provider_name and provider_name in _PROVIDERS: provider_class = _PROVIDERS[provider_name] @@ -446,10 +452,13 @@ def get_provider_sort_options(provider_name: Optional[str] = None) -> List[Dict[ ] -def get_provider_search_fields(provider_name: Optional[str] = None) -> List[Dict[str, Any]]: +def get_provider_search_fields( + provider_name: Optional[str] = None, + user_id: Optional[int] = None, +) -> List[Dict[str, Any]]: """Get search fields for a metadata provider as serialized dicts.""" if provider_name is None: - provider_name = _get_configured_provider_name() + provider_name = _get_configured_provider_name(user_id=user_id) if provider_name and provider_name in _PROVIDERS: provider_class = _PROVIDERS[provider_name] @@ -460,19 +469,22 @@ def get_provider_search_fields(provider_name: Optional[str] = None) -> List[Dict return [serialize_search_field(f) for f in fields] -def get_provider_default_sort(provider_name: Optional[str] = None) -> str: +def get_provider_default_sort( + provider_name: Optional[str] = None, + user_id: Optional[int] = None, +) -> str: """Get the default sort order for a metadata provider.""" from shelfmark.core.config import config as app_config if provider_name is None: - provider_name = _get_configured_provider_name() + provider_name = _get_configured_provider_name(user_id=user_id) if not provider_name: return "relevance" # Look up provider-specific default sort setting setting_key = f"{provider_name.upper()}_DEFAULT_SORT" - return app_config.get(setting_key, "relevance") + return app_config.get(setting_key, "relevance", user_id=user_id) def sync_metadata_provider_selection() -> None: diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 6b80362..8e0fe9f 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -17,12 +17,12 @@ "@types/node": "^24.10.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", + "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.16", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "typescript": "^5.5.3", - "vite": "^5.4.0" + "vite": "^7.3.1" } }, "node_modules/@alloc/quick-lru": { @@ -39,13 +39,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -54,9 +54,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -64,22 +64,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -96,14 +96,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -113,13 +113,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -140,29 +140,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -172,9 +172,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -212,27 +212,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -274,33 +274,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -308,9 +308,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -322,9 +322,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -335,13 +335,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -352,13 +352,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -369,13 +369,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -386,13 +386,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -403,13 +403,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -420,13 +420,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -437,13 +437,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -454,13 +454,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -471,13 +471,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -488,13 +488,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -505,13 +505,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -522,13 +522,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -539,13 +539,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -556,13 +556,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -573,13 +573,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -590,13 +590,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -607,13 +607,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -624,13 +641,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -641,13 +675,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -658,13 +709,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -675,13 +726,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -692,13 +743,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -709,7 +760,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@jridgewell/gen-mapping": { @@ -810,16 +861,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.1.tgz", - "integrity": "sha512-bxZtughE4VNVJlL1RdoSE545kc4JxL7op57KKoi59/gwuU5rV6jLWFXXc8jwgFoT6vtj+ZjO+Z2C5nrY0Cl6wA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -831,9 +882,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.1.tgz", - "integrity": "sha512-44a1hreb02cAAfAKmZfXVercPFaDjqXCK+iKeVOlJ9ltvnO6QqsBHgKVPTu+MJHSLLeMEUbeG2qiDYgbFPU48g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -845,9 +896,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.1.tgz", - "integrity": "sha512-usmzIgD0rf1syoOZ2WZvy8YpXK5G1V3btm3QZddoGSa6mOgfXWkkv+642bfUUldomgrbiLQGrPryb7DXLovPWQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -859,9 +910,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.1.tgz", - "integrity": "sha512-is3r/k4vig2Gt8mKtTlzzyaSQ+hd87kDxiN3uDSDwggJLUV56Umli6OoL+/YZa/KvtdrdyNfMKHzL/P4siOOmg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -873,9 +924,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.1.tgz", - "integrity": "sha512-QJ1ksgp/bDJkZB4daldVmHaEQkG4r8PUXitCOC2WRmRaSaHx5RwPoI3DHVfXKwDkB+Sk6auFI/+JHacTekPRSw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -887,9 +938,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.1.tgz", - "integrity": "sha512-J6ma5xgAzvqsnU6a0+jgGX/gvoGokqpkx6zY4cWizRrm0ffhHDpJKQgC8dtDb3+MqfZDIqs64REbfHDMzxLMqQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -901,9 +952,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.1.tgz", - "integrity": "sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -915,9 +966,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.1.tgz", - "integrity": "sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -929,9 +980,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.1.tgz", - "integrity": "sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -943,9 +994,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.1.tgz", - "integrity": "sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -957,9 +1008,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.1.tgz", - "integrity": "sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -971,9 +1036,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.1.tgz", - "integrity": "sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -985,9 +1064,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.1.tgz", - "integrity": "sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -999,9 +1078,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.1.tgz", - "integrity": "sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1013,9 +1092,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.1.tgz", - "integrity": "sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1027,9 +1106,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.1.tgz", - "integrity": "sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1041,9 +1120,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.1.tgz", - "integrity": "sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1054,10 +1133,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.1.tgz", - "integrity": "sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1069,9 +1162,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.1.tgz", - "integrity": "sha512-VJXivz61c5uVdbmitLkDlbcTk9Or43YC2QVLRkqp86QoeFSqI81bNgjhttqhKNMKnQMWnecOCm7lZz4s+WLGpQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1083,9 +1176,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.1.tgz", - "integrity": "sha512-NmZPVTUOitCXUH6erJDzTQ/jotYw4CnkMDjCYRxNHVD9bNyfrGoIse684F9okwzKCV4AIHRbUkeTBc9F2OOH5Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1097,9 +1190,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.1.tgz", - "integrity": "sha512-2SNj7COIdAf6yliSpLdLG8BEsp5lgzRehgfkP0Av8zKfQFKku6JcvbobvHASPJu4f3BFxej5g+HuQPvqPhHvpQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1111,9 +1204,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.1.tgz", - "integrity": "sha512-rLarc1Ofcs3DHtgSzFO31pZsCh8g05R2azN1q3fF+H423Co87My0R+tazOEvYVKXSLh8C4LerMK41/K7wlklcg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1183,9 +1276,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", - "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "version": "24.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", + "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", "dev": true, "license": "MIT", "peer": true, @@ -1201,15 +1294,15 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.26", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", - "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { @@ -1223,21 +1316,21 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "react-refresh": "^0.18.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" @@ -1272,9 +1365,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", "dev": true, "funding": [ { @@ -1292,10 +1385,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, @@ -1310,13 +1402,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.25", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", - "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -1346,9 +1441,9 @@ } }, "node_modules/browserslist": { - "version": "4.27.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", - "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -1367,11 +1462,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.19", - "caniuse-lite": "^1.0.30001751", - "electron-to-chromium": "^1.5.238", - "node-releases": "^2.0.26", - "update-browserslist-db": "^1.1.4" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -1391,9 +1486,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001754", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", - "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "dev": true, "funding": [ { @@ -1480,9 +1575,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true, "license": "MIT" }, @@ -1490,7 +1585,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1519,42 +1613,25 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.249", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz", - "integrity": "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, "node_modules/engine.io-client": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", - "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", + "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.17.1", + "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, - "node_modules/engine.io-client/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -1565,9 +1642,9 @@ } }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1575,32 +1652,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -1644,9 +1724,9 @@ } }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -1667,16 +1747,16 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -1966,16 +2046,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2256,9 +2326,9 @@ } }, "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { @@ -2353,9 +2423,9 @@ } }, "node_modules/rollup": { - "version": "4.53.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.1.tgz", - "integrity": "sha512-n2I0V0lN3E9cxxMqBCT3opWOiQBzRN7UG60z/WDKqdX2zHUS/39lezBcsckZFsV6fUTSnfqI7kHf60jDAPGKug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2369,28 +2439,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.1", - "@rollup/rollup-android-arm64": "4.53.1", - "@rollup/rollup-darwin-arm64": "4.53.1", - "@rollup/rollup-darwin-x64": "4.53.1", - "@rollup/rollup-freebsd-arm64": "4.53.1", - "@rollup/rollup-freebsd-x64": "4.53.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.1", - "@rollup/rollup-linux-arm-musleabihf": "4.53.1", - "@rollup/rollup-linux-arm64-gnu": "4.53.1", - "@rollup/rollup-linux-arm64-musl": "4.53.1", - "@rollup/rollup-linux-loong64-gnu": "4.53.1", - "@rollup/rollup-linux-ppc64-gnu": "4.53.1", - "@rollup/rollup-linux-riscv64-gnu": "4.53.1", - "@rollup/rollup-linux-riscv64-musl": "4.53.1", - "@rollup/rollup-linux-s390x-gnu": "4.53.1", - "@rollup/rollup-linux-x64-gnu": "4.53.1", - "@rollup/rollup-linux-x64-musl": "4.53.1", - "@rollup/rollup-openharmony-arm64": "4.53.1", - "@rollup/rollup-win32-arm64-msvc": "4.53.1", - "@rollup/rollup-win32-ia32-msvc": "4.53.1", - "@rollup/rollup-win32-x64-gnu": "4.53.1", - "@rollup/rollup-win32-x64-msvc": "4.53.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -2438,13 +2511,13 @@ } }, "node_modules/socket.io-client": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", - "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", + "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" }, @@ -2452,53 +2525,19 @@ "node": ">=10.0.0" } }, - "node_modules/socket.io-client/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2546,9 +2585,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", - "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2697,9 +2736,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -2735,22 +2774,25 @@ "license": "MIT" }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -2759,19 +2801,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -2792,13 +2840,51 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/frontend/package.json b/src/frontend/package.json index aec17ee..0fb5a41 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -21,11 +21,11 @@ "@types/node": "^24.10.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", + "@vitejs/plugin-react": "^5.1.4", "autoprefixer": "^10.4.16", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "typescript": "^5.5.3", - "vite": "^5.4.0" + "vite": "^7.3.1" } } diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 3fee066..7a738d4 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { ButtonStateInfo, RequestPolicyMode, CreateRequestPayload, + ActingAsUserSelection, isMetadataBook, } from './types'; import { @@ -18,9 +19,11 @@ import { downloadBook, downloadRelease, cancelDownload, + retryDownload, getConfig, createRequest, isApiResponseError, + type DownloadReleasePayload, } from './services/api'; import { useToast } from './hooks/useToast'; import { useRealtimeStatus } from './hooks/useRealtimeStatus'; @@ -39,6 +42,7 @@ import { ResultsSection } from './components/ResultsSection'; import { DetailsModal } from './components/DetailsModal'; import { ReleaseModal } from './components/ReleaseModal'; import { RequestConfirmationModal } from './components/RequestConfirmationModal'; +import { OnBehalfConfirmationModal } from './components/OnBehalfConfirmationModal'; import { ToastContainer } from './components/ToastContainer'; import { Footer } from './components/Footer'; import { ActivitySidebar } from './components/activity'; @@ -48,6 +52,7 @@ import { ConfigSetupBanner } from './components/ConfigSetupBanner'; import { OnboardingModal } from './components/OnboardingModal'; import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; import { buildSearchQuery } from './utils/buildSearchQuery'; +import { formatActingAsUserName } from './utils/actingAsUser'; import { withBasePath } from './utils/basePath'; import { applyDirectPolicyModeToButtonState, @@ -116,6 +121,20 @@ const getErrorMessage = (error: unknown, fallback: string): string => { return fallback; }; +type PendingOnBehalfDownload = + | { + type: 'book'; + book: Book; + actingAsUser: ActingAsUserSelection; + } + | { + type: 'release'; + book: Book; + release: Release; + releaseContentType: ContentType; + actingAsUser: ActingAsUserSelection; + }; + function App() { const { toasts, showToast, removeToast } = useToast(); const { socket } = useSocket(); @@ -314,6 +333,8 @@ function App() { }); const [pendingRequestPayload, setPendingRequestPayload] = useState(null); + const [actingAsUser, setActingAsUser] = useState(null); + const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState(null); const [fulfillingRequest, setFulfillingRequest] = useState<{ requestId: number; book: Book; @@ -326,12 +347,22 @@ function App() { setBooks([]); clearTracking(); setPendingRequestPayload(null); + setActingAsUser(null); + setPendingOnBehalfDownload(null); setFulfillingRequest(null); resetActivity(); setSettingsOpen(false); setSelfSettingsOpen(false); }, [handleLogout, setBooks, clearTracking, resetActivity]); + useEffect(() => { + if (isAuthenticated && authIsAdmin) { + return; + } + setActingAsUser(null); + setPendingOnBehalfDownload(null); + }, [isAuthenticated, authIsAdmin]); + // UI state const [selectedBook, setSelectedBook] = useState(null); const [releaseBook, setReleaseBook] = useState(null); @@ -717,6 +748,149 @@ function App() { return getDefaultMode(contentType); }, [getDefaultMode, contentType]); + const buildReleaseDownloadPayload = useCallback( + (book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => ({ + source: release.source, + source_id: release.source_id, + title: book.title, // Use book metadata title, not release/torrent title + author: book.author, // Pass author from metadata + year: book.year, // Pass year from metadata + format: release.format, + size: release.size, + size_bytes: release.size_bytes, + download_url: release.download_url, + protocol: release.protocol, + indexer: release.indexer, + seeders: release.seeders, + extra: release.extra, + preview: book.preview, // Pass book cover from metadata + content_type: releaseContentType, // For audiobook directory routing + series_name: book.series_name, + series_position: book.series_position, + subtitle: book.subtitle, + }), + [] + ); + + const executeBookDownload = useCallback( + async (book: Book, onBehalfOfUserId?: number): Promise => { + try { + await downloadBook(book.id, onBehalfOfUserId); + await fetchStatus(); + } catch (error) { + console.error('Download failed:', error); + if (isPolicyGuardError(error)) { + const requiredMode = getPolicyGuardRequiredMode(error); + policyTrace('direct.action:policy_guard', { + bookId: book.id, + requiredMode, + code: isApiResponseError(error) ? error.code : null, + }); + if (requiredMode === 'request_release' || requiredMode === 'request_book') { + openRequestConfirmation(buildDirectRequestPayload(book, requiredMode)); + await refreshRequestPolicy({ force: true }); + return; + } + showToast('Download blocked by policy', 'error'); + await refreshRequestPolicy({ force: true }); + return; + } + showToast(getErrorMessage(error, 'Failed to queue download'), 'error'); + throw error; + } + }, + [fetchStatus, openRequestConfirmation, refreshRequestPolicy, showToast] + ); + + const executeReleaseDownload = useCallback( + async ( + book: Book, + release: Release, + releaseContentType: ContentType, + onBehalfOfUserId?: number + ): Promise => { + try { + trackRelease(book.id, release.source_id); + await downloadRelease( + buildReleaseDownloadPayload(book, release, releaseContentType), + onBehalfOfUserId + ); + await fetchStatus(); + } catch (error) { + console.error('Release download failed:', error); + if (isPolicyGuardError(error)) { + const requiredMode = getPolicyGuardRequiredMode(error); + const normalizedContentType = toContentType(releaseContentType); + policyTrace('release.action:policy_guard', { + bookId: book.id, + releaseId: release.source_id, + source: release.source, + requiredMode, + code: isApiResponseError(error) ? error.code : null, + contentType: normalizedContentType, + }); + if (requiredMode === 'request_release') { + openRequestConfirmation({ + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType), + context: { + source: release.source || 'direct_download', + content_type: normalizedContentType, + request_level: 'release', + }, + }); + await refreshRequestPolicy({ force: true }); + return; + } + if (requiredMode === 'request_book') { + setReleaseBook(null); + openRequestConfirmation({ + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: null, + context: { + source: release.source || 'direct_download', + content_type: normalizedContentType, + request_level: 'book', + }, + }); + await refreshRequestPolicy({ force: true }); + return; + } + showToast('Download blocked by policy', 'error'); + await refreshRequestPolicy({ force: true }); + return; + } + showToast(getErrorMessage(error, 'Failed to queue download'), 'error'); + throw error; + } + }, + [buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, showToast, trackRelease] + ); + + const handleConfirmOnBehalfDownload = useCallback(async (): Promise => { + if (!pendingOnBehalfDownload) { + return true; + } + + const onBehalfOfUserId = pendingOnBehalfDownload.actingAsUser.id; + try { + if (pendingOnBehalfDownload.type === 'book') { + await executeBookDownload(pendingOnBehalfDownload.book, onBehalfOfUserId); + } else { + await executeReleaseDownload( + pendingOnBehalfDownload.book, + pendingOnBehalfDownload.release, + pendingOnBehalfDownload.releaseContentType, + onBehalfOfUserId + ); + } + setPendingOnBehalfDownload(null); + return true; + } catch { + return false; + } + }, [executeBookDownload, executeReleaseDownload, pendingOnBehalfDownload]); + // Direct-mode action (download or release-level request based on policy). const handleDownload = async (book: Book): Promise => { let mode = getDirectPolicyMode(); @@ -759,30 +933,16 @@ function App() { return; } - try { - await downloadBook(book.id); - await fetchStatus(); - } catch (error) { - console.error('Download failed:', error); - if (isPolicyGuardError(error)) { - const requiredMode = getPolicyGuardRequiredMode(error); - policyTrace('direct.action:policy_guard', { - bookId: book.id, - requiredMode, - code: isApiResponseError(error) ? error.code : null, - }); - if (requiredMode === 'request_release' || requiredMode === 'request_book') { - openRequestConfirmation(buildDirectRequestPayload(book, requiredMode)); - await refreshRequestPolicy({ force: true }); - return; - } - showToast('Download blocked by policy', 'error'); - await refreshRequestPolicy({ force: true }); - return; - } - showToast(getErrorMessage(error, 'Failed to queue download'), 'error'); - throw error; + if (actingAsUser) { + setPendingOnBehalfDownload({ + type: 'book', + book, + actingAsUser, + }); + return; } + + await executeBookDownload(book); }; // Cancel download @@ -796,6 +956,16 @@ function App() { } }; + const handleRetry = async (id: string) => { + try { + await retryDownload(id); + await fetchStatus(); + } catch (error) { + console.error('Retry failed:', error); + showToast('Failed to retry download', 'error'); + } + }; + // Universal-mode "Get" action (open releases, request-book, or block by policy). const handleGetReleases = async (book: Book) => { let mode = getUniversalDefaultPolicyMode(); @@ -886,83 +1056,25 @@ function App() { // Handle download from ReleaseModal (universal mode release rows). const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => { - try { - policyTrace('release.action:start', { - bookId: book.id, - releaseId: release.source_id, - source: release.source, - contentType: toContentType(releaseContentType), - }); - trackRelease(book.id, release.source_id); + policyTrace('release.action:start', { + bookId: book.id, + releaseId: release.source_id, + source: release.source, + contentType: toContentType(releaseContentType), + }); - await downloadRelease({ - source: release.source, - source_id: release.source_id, - title: book.title, // Use book metadata title, not release/torrent title - author: book.author, // Pass author from metadata - year: book.year, // Pass year from metadata - format: release.format, - size: release.size, - size_bytes: release.size_bytes, - download_url: release.download_url, - protocol: release.protocol, - indexer: release.indexer, - seeders: release.seeders, - extra: release.extra, - preview: book.preview, // Pass book cover from metadata - content_type: releaseContentType, // For audiobook directory routing - series_name: book.series_name, - series_position: book.series_position, - subtitle: book.subtitle, + if (actingAsUser) { + setPendingOnBehalfDownload({ + type: 'release', + book, + release, + releaseContentType, + actingAsUser, }); - await fetchStatus(); - } catch (error) { - console.error('Release download failed:', error); - if (isPolicyGuardError(error)) { - const requiredMode = getPolicyGuardRequiredMode(error); - const normalizedContentType = toContentType(releaseContentType); - policyTrace('release.action:policy_guard', { - bookId: book.id, - releaseId: release.source_id, - source: release.source, - requiredMode, - code: isApiResponseError(error) ? error.code : null, - contentType: normalizedContentType, - }); - if (requiredMode === 'request_release') { - openRequestConfirmation({ - book_data: buildMetadataBookRequestData(book, normalizedContentType), - release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType), - context: { - source: release.source || 'direct_download', - content_type: normalizedContentType, - request_level: 'release', - }, - }); - await refreshRequestPolicy({ force: true }); - return; - } - if (requiredMode === 'request_book') { - setReleaseBook(null); - openRequestConfirmation({ - book_data: buildMetadataBookRequestData(book, normalizedContentType), - release_data: null, - context: { - source: release.source || 'direct_download', - content_type: normalizedContentType, - request_level: 'book', - }, - }); - await refreshRequestPolicy({ force: true }); - return; - } - showToast('Download blocked by policy', 'error'); - await refreshRequestPolicy({ force: true }); - return; - } - showToast(getErrorMessage(error, 'Failed to queue download'), 'error'); - throw error; + return; } + + await executeReleaseDownload(book, release, releaseContentType); }; const handleReleaseRequest = useCallback( @@ -1190,6 +1302,17 @@ function App() { setReleaseBook(null); }, [isBrowseFulfilMode]); + const pendingOnBehalfTitle = pendingOnBehalfDownload + ? pendingOnBehalfDownload.type === 'book' + ? pendingOnBehalfDownload.book.title || 'Untitled' + : pendingOnBehalfDownload.release.title || + pendingOnBehalfDownload.book.title || + 'Untitled' + : ''; + const pendingOnBehalfUserName = pendingOnBehalfDownload + ? formatActingAsUserName(pendingOnBehalfDownload.actingAsUser) + : ''; + const mainAppContent = (
@@ -1217,6 +1340,8 @@ function App() { canAccessSettings={isAuthenticated} username={username} displayName={displayName} + actingAsUser={actingAsUser} + onActingAsUserChange={setActingAsUser} statusCounts={statusCounts} onLogoClick={() => handleResetSearch(config)} authRequired={authRequired} @@ -1383,6 +1508,16 @@ function App() { /> )} + {pendingOnBehalfDownload && ( + setPendingOnBehalfDownload(null)} + /> + )} +
@@ -1401,6 +1536,7 @@ function App() { isAdmin={requestRoleIsAdmin} onClearCompleted={handleClearCompleted} onCancel={handleCancel} + onRetry={handleRetry} onDownloadDismiss={handleDownloadDismiss} requestItems={requestItems} dismissedItemKeys={dismissedActivityKeys} @@ -1436,6 +1572,7 @@ function App() { isOpen={selfSettingsOpen} onClose={() => setSelfSettingsOpen(false)} onShowToast={showToast} + onSettingsSaved={handleSettingsSaved} /> {/* Auto-show banner on startup for users without config */} diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index a9c3a1b..6f1a2fe 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -1,7 +1,10 @@ -import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import { SearchBar, SearchBarHandle } from './SearchBar'; -import { ContentType } from '../types'; +import { DropdownList } from './DropdownList'; +import { getAdminUsers } from '../services/api'; +import { ContentType, ActingAsUserSelection } from '../types'; import { ActivityStatusCounts, getActivityBadgeState } from '../utils/activityBadge'; +import { formatActingAsUserName } from '../utils/actingAsUser'; import { withBasePath } from '../utils/basePath'; export interface HeaderHandle { @@ -29,6 +32,8 @@ interface HeaderProps { isAuthenticated?: boolean; username?: string | null; displayName?: string | null; + actingAsUser?: ActingAsUserSelection | null; + onActingAsUserChange?: (user: ActingAsUserSelection | null) => void; onLogout?: () => void; onShowToast?: (message: string, type: 'success' | 'error' | 'info', persistent?: boolean) => string; onRemoveToast?: (id: string) => void; @@ -57,6 +62,8 @@ export const Header = forwardRef(({ isAuthenticated = false, username, displayName, + actingAsUser = null, + onActingAsUserChange, onLogout, onShowToast, onRemoveToast, @@ -76,6 +83,59 @@ export const Header = forwardRef(({ const [isClosing, setIsClosing] = useState(false); const [shouldAnimateIn, setShouldAnimateIn] = useState(false); const dropdownRef = useRef(null); + const [adminUsers, setAdminUsers] = useState([]); + const [isAdminUsersLoading, setIsAdminUsersLoading] = useState(false); + const [adminUsersError, setAdminUsersError] = useState(null); + const [hasLoadedAdminUsers, setHasLoadedAdminUsers] = useState(false); + + const loadAdminUsers = useCallback(async () => { + if (!isAdmin) { + return; + } + + setIsAdminUsersLoading(true); + setAdminUsersError(null); + try { + const users = await getAdminUsers(); + const filteredUsers = users.filter((user) => { + if (username && user.username === username) { + return false; + } + return true; + }); + setAdminUsers( + filteredUsers.map((user) => ({ + id: user.id, + username: user.username, + displayName: user.display_name, + })) + ); + setHasLoadedAdminUsers(true); + } catch (error) { + console.error('Failed to load admin users:', error); + setAdminUsersError('Failed to load users'); + } finally { + setIsAdminUsersLoading(false); + } + }, [isAdmin, username]); + + const actingAsOptions = useMemo( + () => [ + { value: '', label: 'Myself' }, + ...adminUsers.map((user) => { + const displayLabel = formatActingAsUserName(user); + return { + value: String(user.id), + label: displayLabel, + description: displayLabel !== user.username ? `@${user.username}` : undefined, + }; + }), + ], + [adminUsers] + ); + + const selectedActingAsValue = actingAsUser ? String(actingAsUser.id) : ''; + const dropdownPanelWidthClass = 'w-48'; useEffect(() => { const saved = localStorage.getItem('preferred-theme') || 'auto'; @@ -98,6 +158,39 @@ export const Header = forwardRef(({ return () => mq.removeEventListener('change', handler); }, []); + useEffect(() => { + if (isAdmin) { + return; + } + setAdminUsers([]); + setAdminUsersError(null); + setIsAdminUsersLoading(false); + setHasLoadedAdminUsers(false); + }, [isAdmin]); + + useEffect(() => { + if (!onActingAsUserChange || !actingAsUser) { + return; + } + if (username && actingAsUser.username === username) { + onActingAsUserChange(null); + return; + } + if (hasLoadedAdminUsers && !isAdminUsersLoading) { + const stillAvailable = adminUsers.some((user) => user.id === actingAsUser.id); + if (!stillAvailable) { + onActingAsUserChange(null); + } + } + }, [ + onActingAsUserChange, + actingAsUser, + username, + hasLoadedAdminUsers, + isAdminUsersLoading, + adminUsers, + ]); + // Helper function to close dropdown with animation const closeDropdown = () => { setIsClosing(true); @@ -150,6 +243,9 @@ export const Header = forwardRef(({ if (isDropdownOpen) { closeDropdown(); } else { + if (isAdmin && !hasLoadedAdminUsers && !isAdminUsersLoading) { + void loadAdminUsers(); + } setShouldAnimateIn(true); setIsDropdownOpen(true); // Reset animation flag after animation completes @@ -165,6 +261,24 @@ export const Header = forwardRef(({ onSearchChange?.(value); }; + const handleActingAsChange = (nextValue: string[] | string) => { + if (Array.isArray(nextValue)) { + return; + } + + if (nextValue === '') { + onActingAsUserChange?.(null); + return; + } + + const selectedUser = adminUsers.find((user) => String(user.id) === nextValue); + if (!selectedUser) { + return; + } + + onActingAsUserChange?.(selectedUser); + }; + // Determine if we should show icons only (both URLs configured) const showIconsOnly = Boolean(calibreWebUrl && audiobookLibraryUrl); @@ -266,12 +380,18 @@ export const Header = forwardRef(({ d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> + {actingAsUser && ( + + )} {/* Dropdown Menu */} {(isDropdownOpen || isClosing) && (
(({
)} + + {isAdmin && onActingAsUserChange && ( +
+
+ Download as +
+
+ +
+ {isAdminUsersLoading && ( +
Loading users...
+ )} + {adminUsersError && ( +
+
+ {adminUsersError} +
+ +
+ )} +
+ )}
)} diff --git a/src/frontend/src/components/OnBehalfConfirmationModal.tsx b/src/frontend/src/components/OnBehalfConfirmationModal.tsx new file mode 100644 index 0000000..4467114 --- /dev/null +++ b/src/frontend/src/components/OnBehalfConfirmationModal.tsx @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useState } from 'react'; + +interface OnBehalfConfirmationModalProps { + isOpen: boolean; + actingAsName: string; + itemTitle: string; + onConfirm: () => Promise; + onClose: () => void; +} + +export const OnBehalfConfirmationModal = ({ + isOpen, + actingAsName, + itemTitle, + onConfirm, + onClose, +}: OnBehalfConfirmationModalProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [isClosing, setIsClosing] = useState(false); + + const handleClose = useCallback(() => { + if (isSubmitting) { + return; + } + setIsClosing(true); + setTimeout(() => { + onClose(); + setIsClosing(false); + }, 150); + }, [isSubmitting, onClose]); + + useEffect(() => { + if (!isOpen) { + return; + } + setIsSubmitting(false); + setIsClosing(false); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) { + return; + } + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isOpen]); + + useEffect(() => { + if (!isOpen) { + return; + } + const onEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose(); + } + }; + document.addEventListener('keydown', onEscape); + return () => { + document.removeEventListener('keydown', onEscape); + }; + }, [isOpen, handleClose]); + + if (!isOpen && !isClosing) return null; + if (!isOpen) return null; + + const titleId = 'on-behalf-confirmation-modal-title'; + const confirmDisabled = isSubmitting; + + const submit = async () => { + if (confirmDisabled) { + return; + } + + setIsSubmitting(true); + try { + const success = await onConfirm(); + if (!success) { + setIsSubmitting(false); + } + } catch { + setIsSubmitting(false); + } + }; + + return ( +
+
+ +
+
+

+ Download as {actingAsName}? +

+ +
+ +
+

+ This download will use {actingAsName}'s output preferences and destination settings. +

+
+

Title

+

{itemTitle}

+
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 3581a04..8da2626 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -34,7 +34,7 @@ import { import { getReleaseFormats } from '../utils/releaseFormats'; import { getBookTitleCandidates, getBookAuthorCandidates, sortReleasesByBookMatch } from '../utils/releaseScoring'; import { getCachedReleases, setCachedReleases, invalidateCachedReleases } from '../utils/releaseCache'; -import { SortState, getSavedSort, saveSort, clearSort, inferDefaultDirection, sortReleases } from '../utils/releaseSort'; +import { SortState, getSavedSort, saveSort, clearSort, inferDefaultDirection, sortReleases, FORMAT_SORT_KEY, sortReleasesByFormat } from '../utils/releaseSort'; // Default column configuration (fallback when backend doesn't provide one) @@ -575,6 +575,7 @@ export const ReleaseModal = ({ // Sort state - keyed by source name, persisted to localStorage // null means "Default" (best title match), undefined means "not set yet" const [sortBySource, setSortBySource] = useState>({}); + const [formatSortExpanded, setFormatSortExpanded] = useState(false); // Description expansion const [descriptionExpanded, setDescriptionExpanded] = useState(false); @@ -1052,27 +1053,34 @@ export const ReleaseModal = ({ return [...fromColumns, ...fromExtra]; }, [sortableColumns, columnConfig.extra_sort_options]); + const isValidSortForCurrentResults = useCallback((sort: SortState | null): boolean => { + if (!sort) return false; + + if (sort.key === FORMAT_SORT_KEY) { + return !!sort.value && availableFormats.includes(sort.value); + } + + return allSortOptions.some(opt => opt.sortKey === sort.key); + }, [availableFormats, allSortOptions]); + // Get current sort state for active tab (from state, localStorage, or default to null = best match) const currentSort = useMemo((): SortState | null => { // Check state first - explicit null means "Default" was selected if (activeTab in sortBySource) { - return sortBySource[activeTab]; + const inMemory = sortBySource[activeTab]; + return inMemory === null || isValidSortForCurrentResults(inMemory) ? inMemory : null; } // Check localStorage const saved = getSavedSort(activeTab); - if (saved) { - // Verify the saved sort is still valid for this source - const isValid = allSortOptions.some(opt => opt.sortKey === saved.key); - if (isValid) { - return saved; - } + if (isValidSortForCurrentResults(saved)) { + return saved; } // Default to null (best-match sorting) return null; - }, [activeTab, sortBySource, allSortOptions]); + }, [activeTab, sortBySource, isValidSortForCurrentResults]); // Handle sort change - null means "Default" (best title match), otherwise toggle direction or set new column - const handleSortChange = useCallback((sortKey: string | null, defaultDirection: 'asc' | 'desc') => { + const handleSortChange = useCallback((sortKey: string | null, defaultDirection: 'asc' | 'desc', value?: string) => { if (sortKey === null) { // "Default" selected - use best-match sorting setSortBySource(prev => { @@ -1087,17 +1095,20 @@ export const ReleaseModal = ({ const currentState = sortBySource[activeTab] ?? currentSort; let newState: SortState; - if (currentState && currentState.key === sortKey) { - // Same key - toggle direction + const isSameSort = currentState && currentState.key === sortKey && currentState.value === value; + if (isSameSort) { + // Same key+value - toggle direction newState = { key: sortKey, direction: currentState.direction === 'asc' ? 'desc' : 'asc', + ...(value !== undefined && { value }), }; } else { - // New key - use provided default direction + // New key or different value - use provided default direction newState = { key: sortKey, direction: defaultDirection, + ...(value !== undefined && { value }), }; } @@ -1143,8 +1154,10 @@ export const ReleaseModal = ({ return true; }); - // Then, sort by explicit column, or default to book-title relevance with exact author boost - if (currentSort && allSortOptions.length > 0) { + // Then, sort by explicit column/format, or default to book-title relevance with exact author boost + if (currentSort?.key === FORMAT_SORT_KEY && currentSort.value) { + filtered = sortReleasesByFormat(filtered, currentSort.value, currentSort.direction); + } else if (currentSort && allSortOptions.length > 0) { filtered = sortReleases(filtered, currentSort.key, currentSort.direction); } else { const responseBook = releasesBySource[activeTab]?.book; @@ -1532,8 +1545,8 @@ export const ReleaseModal = ({ - {/* Sort dropdown - only show if source has sort options */} - {allSortOptions.length > 0 && ( + {/* Sort dropdown - show if source has sort options or multiple formats */} + {(allSortOptions.length > 0 || availableFormats.length > 1) && ( { handleSortChange(null, 'asc'); + setFormatSortExpanded(false); close(); }} className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort @@ -1585,6 +1599,7 @@ export const ReleaseModal = ({ type="button" onClick={() => { handleSortChange(opt.sortKey, opt.defaultDirection); + setFormatSortExpanded(false); // Don't close - allow toggling direction if (!isSelected) close(); }} @@ -1606,6 +1621,67 @@ export const ReleaseModal = ({ ); })} + + {/* Format priority sort sub-menu */} + {availableFormats.length > 1 && ( + <> + {allSortOptions.length > 0 && ( +
+ )} + + {formatSortExpanded && availableFormats.map((fmt) => { + const isSelected = currentSort?.key === FORMAT_SORT_KEY && currentSort.value === fmt; + const direction = isSelected ? currentSort?.direction : null; + return ( + + ); + })} + + )}
)}
diff --git a/src/frontend/src/components/activity/ActivityCard.tsx b/src/frontend/src/components/activity/ActivityCard.tsx index 249290d..381c7f3 100644 --- a/src/frontend/src/components/activity/ActivityCard.tsx +++ b/src/frontend/src/components/activity/ActivityCard.tsx @@ -25,6 +25,7 @@ interface ActivityCardProps { item: ActivityItem; isAdmin: boolean; onDownloadCancel?: (bookId: string) => void; + onDownloadRetry?: (bookId: string) => void; onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void; onRequestCancel?: (requestId: number) => void; onRequestApprove?: RequestApproveHandler; @@ -72,6 +73,7 @@ const actionKey = (action: ActivityCardAction): string => { switch (action.kind) { case 'download-remove': case 'download-stop': + case 'download-retry': case 'download-dismiss': return `${action.kind}-${action.bookId}`; case 'request-approve': @@ -107,6 +109,12 @@ const actionUiConfig = ( className: 'text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30', icon: 'cross', }; + case 'download-retry': + return { + title: 'Retry', + className: 'text-sky-600 dark:text-sky-400 hover:bg-sky-100 dark:hover:bg-sky-900/30', + icon: 'retry', + }; case 'request-approve': return { title: 'Approve', @@ -235,6 +243,7 @@ export const ActivityCard = ({ item, isAdmin, onDownloadCancel, + onDownloadRetry, onDownloadDismiss, onRequestCancel, onRequestApprove, @@ -353,6 +362,9 @@ export const ActivityCard = ({ case 'download-stop': onDownloadCancel?.(action.bookId); break; + case 'download-retry': + onDownloadRetry?.(action.bookId); + break; case 'download-dismiss': onDownloadDismiss?.(action.bookId, action.linkedRequestId); break; @@ -388,6 +400,8 @@ export const ActivityCard = ({ case 'download-remove': case 'download-stop': return Boolean(onDownloadCancel); + case 'download-retry': + return Boolean(onDownloadRetry); case 'download-dismiss': return Boolean(onDownloadDismiss); case 'request-approve': diff --git a/src/frontend/src/components/activity/ActivitySidebar.tsx b/src/frontend/src/components/activity/ActivitySidebar.tsx index 06cfd78..fb438cf 100644 --- a/src/frontend/src/components/activity/ActivitySidebar.tsx +++ b/src/frontend/src/components/activity/ActivitySidebar.tsx @@ -12,6 +12,7 @@ interface ActivitySidebarProps { isAdmin: boolean; onClearCompleted: (items: ActivityDismissTarget[]) => void; onCancel: (id: string) => void; + onRetry?: (id: string) => void; onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void; requestItems: ActivityItem[]; dismissedItemKeys?: string[]; @@ -241,6 +242,7 @@ export const ActivitySidebar = ({ isAdmin, onClearCompleted, onCancel, + onRetry, onDownloadDismiss, requestItems, dismissedItemKeys = [], @@ -886,6 +888,7 @@ export const ActivitySidebar = ({ item={item} isAdmin={isAdmin} onDownloadCancel={onCancel} + onDownloadRetry={onRetry} onDownloadDismiss={onDownloadDismiss} onRequestCancel={onRequestCancel} onRequestApprove={onRequestApprove} diff --git a/src/frontend/src/components/activity/activityCardModel.ts b/src/frontend/src/components/activity/activityCardModel.ts index c04c084..a27c3c3 100644 --- a/src/frontend/src/components/activity/activityCardModel.ts +++ b/src/frontend/src/components/activity/activityCardModel.ts @@ -4,7 +4,7 @@ import { ActivityItem, ActivityVisualStatus } from './activityTypes'; export type ActivityCardAction = | { - kind: 'download-remove' | 'download-stop' | 'download-dismiss'; + kind: 'download-remove' | 'download-stop' | 'download-dismiss' | 'download-retry'; bookId: string; linkedRequestId?: number; } @@ -162,6 +162,19 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[ ) { return [{ kind: 'download-stop', bookId: item.downloadBookId }]; } + if (item.visualStatus === 'error' && !item.requestId) { + return [ + { + kind: 'download-retry', + bookId: item.downloadBookId, + }, + { + kind: 'download-dismiss', + bookId: item.downloadBookId, + linkedRequestId: item.requestId, + }, + ]; + } return [ { kind: 'download-dismiss', diff --git a/src/frontend/src/components/activity/activityTypes.ts b/src/frontend/src/components/activity/activityTypes.ts index 9a57e3b..15408de 100644 --- a/src/frontend/src/components/activity/activityTypes.ts +++ b/src/frontend/src/components/activity/activityTypes.ts @@ -38,7 +38,6 @@ export interface ActivityItem { downloadBookId?: string; downloadPath?: string; - requestId?: number; requestLevel?: 'book' | 'release'; requestNote?: string; diff --git a/src/frontend/src/components/settings/SelfSettingsModal.tsx b/src/frontend/src/components/settings/SelfSettingsModal.tsx index a336703..8465757 100644 --- a/src/frontend/src/components/settings/SelfSettingsModal.tsx +++ b/src/frontend/src/components/settings/SelfSettingsModal.tsx @@ -23,6 +23,7 @@ interface SelfSettingsModalProps { isOpen: boolean; onClose: () => void; onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; + onSettingsSaved?: () => void; } const MIN_PASSWORD_LENGTH = 4; @@ -47,7 +48,12 @@ const getErrorMessage = (error: unknown, fallback: string): string => { return fallback; }; -export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettingsModalProps) => { +export const SelfSettingsModal = ({ + isOpen, + onClose, + onShowToast, + onSettingsSaved, +}: SelfSettingsModalProps) => { const [isClosing, setIsClosing] = useState(false); const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); @@ -56,6 +62,7 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings const [editingUser, setEditingUser] = useState(null); const [originalUser, setOriginalUser] = useState(null); const [deliveryPreferences, setDeliveryPreferences] = useState(null); + const [searchPreferences, setSearchPreferences] = useState(null); const [notificationPreferences, setNotificationPreferences] = useState(null); const [visibleSections, setVisibleSections] = useState( DEFAULT_SELF_USER_OVERRIDE_SECTIONS @@ -67,8 +74,8 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings const [themeValue, setThemeValue] = useState(getStoredThemePreference()); const preferenceGroups = useMemo( - () => [deliveryPreferences, notificationPreferences], - [deliveryPreferences, notificationPreferences] + () => [deliveryPreferences, searchPreferences, notificationPreferences], + [deliveryPreferences, searchPreferences, notificationPreferences] ); const { userSettings, @@ -88,6 +95,7 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings setEditingUser(context.user); setOriginalUser(context.user); setDeliveryPreferences(context.deliveryPreferences || null); + setSearchPreferences(context.searchPreferences || null); setNotificationPreferences(context.notificationPreferences || null); setVisibleSections( normalizeUserOverrideSections(context.visibleUserSettingsSections, 'self') @@ -208,6 +216,7 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings try { await updateSelfUser(payload); onShowToast?.('Account updated', 'success'); + onSettingsSaved?.(); await loadEditContext(); } catch (error) { onShowToast?.(getErrorMessage(error, 'Failed to update account'), 'error'); @@ -219,6 +228,7 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings editingUser, hasSettingsChanges, loadEditContext, + onSettingsSaved, onShowToast, originalUser, passwordError, @@ -322,6 +332,7 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings scope="self" sections={visibleSections} deliveryPreferences={deliveryPreferences} + searchPreferences={searchPreferences} notificationPreferences={notificationPreferences} isUserOverridable={isUserOverridable} userSettings={userSettings} diff --git a/src/frontend/src/components/settings/SettingsContent.tsx b/src/frontend/src/components/settings/SettingsContent.tsx index ddbb3f1..a740b7f 100644 --- a/src/frontend/src/components/settings/SettingsContent.tsx +++ b/src/frontend/src/components/settings/SettingsContent.tsx @@ -54,6 +54,7 @@ interface SettingsContentProps { onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; onRefreshOverrideSummary?: () => void; onRefreshAuth?: () => Promise; + onSettingsSaved?: () => void; }; } @@ -409,6 +410,7 @@ export const SettingsContent = ({ onShowToast: customFieldContext?.onShowToast, onRefreshOverrideSummary: customFieldContext?.onRefreshOverrideSummary, onRefreshAuth: customFieldContext?.onRefreshAuth, + onSettingsSaved: customFieldContext?.onSettingsSaved, }) : renderField( field, diff --git a/src/frontend/src/components/settings/SettingsModal.tsx b/src/frontend/src/components/settings/SettingsModal.tsx index 660fc43..d5346a5 100644 --- a/src/frontend/src/components/settings/SettingsModal.tsx +++ b/src/frontend/src/components/settings/SettingsModal.tsx @@ -303,6 +303,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin onShowToast, onRefreshOverrideSummary: handleRefreshCurrentTabOverrideSummary, onRefreshAuth, + onSettingsSaved, }} /> )) diff --git a/src/frontend/src/components/settings/customFields/UsersManagementField.tsx b/src/frontend/src/components/settings/customFields/UsersManagementField.tsx index 7ec73be..255dd10 100644 --- a/src/frontend/src/components/settings/customFields/UsersManagementField.tsx +++ b/src/frontend/src/components/settings/customFields/UsersManagementField.tsx @@ -22,6 +22,7 @@ export const UsersManagementField = ({ onShowToast, onRefreshOverrideSummary, onRefreshAuth, + onSettingsSaved, }: CustomSettingsFieldRendererProps) => { const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState(); const activeEditRequestIdRef = useRef(0); @@ -46,6 +47,7 @@ export const UsersManagementField = ({ setEditPasswordConfirm, downloadDefaults, deliveryPreferences, + searchPreferences, notificationPreferences, isUserOverridable, userSettings, @@ -79,6 +81,7 @@ export const UsersManagementField = ({ userSettings, userOverridableSettings, deliveryPreferences, + searchPreferences, notificationPreferences, onEditSaveSuccess: clearEditState, }); @@ -191,10 +194,11 @@ export const UsersManagementField = ({ includeSettings: true, }); if (ok) { + onSettingsSaved?.(); onRefreshOverrideSummary?.(); backToList(); } - }, [backToList, onRefreshOverrideSummary, saveEditedUser]); + }, [backToList, onRefreshOverrideSummary, onSettingsSaved, saveEditedUser]); const handleSaveUserOverridesRef = useRef(handleSaveUserOverrides); useEffect(() => { @@ -249,6 +253,7 @@ export const UsersManagementField = ({ hasChanges={hasUserSettingsChanges} onBack={handleBackToEdit} deliveryPreferences={deliveryPreferences} + searchPreferences={searchPreferences} notificationPreferences={notificationPreferences} isUserOverridable={isUserOverridable} userSettings={userSettings} diff --git a/src/frontend/src/components/settings/customFields/types.ts b/src/frontend/src/components/settings/customFields/types.ts index 9c7bfa2..8aea513 100644 --- a/src/frontend/src/components/settings/customFields/types.ts +++ b/src/frontend/src/components/settings/customFields/types.ts @@ -14,6 +14,7 @@ export interface CustomSettingsFieldRendererProps { onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; onRefreshOverrideSummary?: () => void; onRefreshAuth?: () => Promise; + onSettingsSaved?: () => void; } export interface CustomSettingsFieldLayout { diff --git a/src/frontend/src/components/settings/users/UserOverridesSections.tsx b/src/frontend/src/components/settings/users/UserOverridesSections.tsx index d5d95b1..c0f98be 100644 --- a/src/frontend/src/components/settings/users/UserOverridesSections.tsx +++ b/src/frontend/src/components/settings/users/UserOverridesSections.tsx @@ -5,15 +5,17 @@ import { SettingsTab } from '../../../types/settings'; import { UserNotificationOverridesSection } from './UserNotificationOverridesSection'; import { UserOverridesSection } from './UserOverridesSection'; import { UserRequestPolicyOverridesSection } from './UserRequestPolicyOverridesSection'; +import { UserSearchPreferencesSection } from './UserSearchPreferencesSection'; import { PerUserSettings } from './types'; export type UserOverrideScope = 'admin' | 'self'; -export type UserOverrideSectionId = 'delivery' | 'notifications' | 'requestPolicy'; +export type UserOverrideSectionId = 'delivery' | 'search' | 'notifications' | 'requestPolicy'; interface UserOverridesSectionsProps { scope: UserOverrideScope; sections?: UserOverrideSectionId[]; deliveryPreferences: DeliveryPreferencesResponse | null; + searchPreferences: DeliveryPreferencesResponse | null; notificationPreferences: DeliveryPreferencesResponse | null; isUserOverridable: (key: keyof PerUserSettings) => boolean; userSettings: PerUserSettings; @@ -35,6 +37,7 @@ interface UserOverrideSectionNode { const USER_OVERRIDE_SECTION_DEFINITIONS: UserOverrideSectionDefinition[] = [ { id: 'delivery', adminOnly: false }, + { id: 'search', adminOnly: false }, { id: 'notifications', adminOnly: false }, { id: 'requestPolicy', adminOnly: true }, ]; @@ -45,6 +48,7 @@ const USER_OVERRIDE_SECTION_ID_SET = new Set(USER_OVERRID const USER_OVERRIDE_SECTION_META: Record = { delivery: { id: 'delivery', adminOnly: false }, + search: { id: 'search', adminOnly: false }, notifications: { id: 'notifications', adminOnly: false }, requestPolicy: { id: 'requestPolicy', adminOnly: true }, }; @@ -100,6 +104,7 @@ export const UserOverridesSections = ({ scope, sections, deliveryPreferences, + searchPreferences, notificationPreferences, isUserOverridable, userSettings, @@ -150,6 +155,24 @@ export const UserOverridesSections = ({ return; } + if (sectionId === 'search') { + if (!searchPreferences) { + return; + } + sectionNodes.push({ + id: sectionId, + node: ( + + ), + }); + return; + } + if (!usersTab || !globalUsersSettingsValues) { return; } diff --git a/src/frontend/src/components/settings/users/UserOverridesView.tsx b/src/frontend/src/components/settings/users/UserOverridesView.tsx index 9998c05..ed632c7 100644 --- a/src/frontend/src/components/settings/users/UserOverridesView.tsx +++ b/src/frontend/src/components/settings/users/UserOverridesView.tsx @@ -9,6 +9,7 @@ interface UserOverridesViewProps { hasChanges: boolean; onBack: () => void; deliveryPreferences: DeliveryPreferencesResponse | null; + searchPreferences: DeliveryPreferencesResponse | null; notificationPreferences: DeliveryPreferencesResponse | null; isUserOverridable: (key: keyof PerUserSettings) => boolean; userSettings: PerUserSettings; @@ -23,6 +24,7 @@ export const UserOverridesView = ({ hasChanges, onBack, deliveryPreferences, + searchPreferences, notificationPreferences, isUserOverridable, userSettings, @@ -56,6 +58,7 @@ export const UserOverridesView = ({ boolean; + userSettings: PerUserSettings; + setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void; +} + +type SearchSettingKey = + | 'SEARCH_MODE' + | 'METADATA_PROVIDER' + | 'METADATA_PROVIDER_AUDIOBOOK' + | 'DEFAULT_RELEASE_SOURCE'; + +const fallbackSearchModeField: SelectFieldConfig = { + type: 'SelectField', + key: 'SEARCH_MODE', + label: 'Search Mode', + description: 'How you want to search for and download books.', + value: 'direct', + options: [ + { value: 'direct', label: 'Direct' }, + { value: 'universal', label: 'Universal' }, + ], +}; + +const fallbackMetadataProviderField: SelectFieldConfig = { + type: 'SelectField', + key: 'METADATA_PROVIDER', + label: 'Book Metadata Provider', + description: 'Choose which metadata provider to use for book searches.', + value: '', + options: [], +}; + +const fallbackAudiobookMetadataProviderField: SelectFieldConfig = { + type: 'SelectField', + key: 'METADATA_PROVIDER_AUDIOBOOK', + label: 'Audiobook Metadata Provider', + description: 'Metadata provider for audiobook searches. Uses the book provider if not set.', + value: '', + options: [{ value: '', label: 'Use main provider' }], +}; + +const fallbackDefaultReleaseSourceField: SelectFieldConfig = { + type: 'SelectField', + key: 'DEFAULT_RELEASE_SOURCE', + label: 'Default Release Source', + description: 'The release source tab to open by default in the release modal.', + value: 'direct_download', + options: [], +}; + +const searchHeading: HeadingFieldConfig = { + type: 'HeadingField', + key: 'search_preferences_heading', + title: 'Search Preferences', + description: 'Personal search settings for this user. Reset to inherit global defaults from Search Mode.', +}; + +const normalizeSearchMode = (value: unknown): 'direct' | 'universal' => { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'universal' ? 'universal' : 'direct'; +}; + +const toStringValue = (value: unknown): string => { + if (value === undefined || value === null) { + return ''; + } + return String(value); +}; + +export const UserSearchPreferencesSection = ({ + searchPreferences, + isUserOverridable, + userSettings, + setUserSettings, +}: UserSearchPreferencesSectionProps) => { + if (!searchPreferences) { + return null; + } + + const fields = searchPreferences.fields ?? []; + const globalValues = searchPreferences.globalValues ?? {}; + const preferenceKeySet = new Set(searchPreferences.keys ?? []); + + const searchModeField = getFieldByKey( + fields, + 'SEARCH_MODE', + fallbackSearchModeField + ); + const metadataProviderField = getFieldByKey( + fields, + 'METADATA_PROVIDER', + fallbackMetadataProviderField + ); + const metadataProviderAudiobookField = getFieldByKey( + fields, + 'METADATA_PROVIDER_AUDIOBOOK', + fallbackAudiobookMetadataProviderField + ); + const defaultReleaseSourceField = getFieldByKey( + fields, + 'DEFAULT_RELEASE_SOURCE', + fallbackDefaultReleaseSourceField + ); + + const isOverridden = (key: SearchSettingKey): boolean => { + if ( + !Object.prototype.hasOwnProperty.call(userSettings, key) + || userSettings[key] === null + || userSettings[key] === undefined + ) { + return false; + } + + return toStringValue(userSettings[key]) !== toStringValue(globalValues[key]); + }; + + const readValue = (key: SearchSettingKey, fallback = ''): string => { + if (isOverridden(key)) { + return toStringValue(userSettings[key]); + } + if (Object.prototype.hasOwnProperty.call(globalValues, key)) { + return toStringValue(globalValues[key]); + } + return fallback; + }; + + const resetKeys = (keys: SearchSettingKey[]) => { + setUserSettings((prev) => { + const next = { ...prev }; + keys.forEach((key) => { + delete next[key]; + }); + return next; + }); + }; + + const searchModeValue = readValue('SEARCH_MODE', 'direct'); + const effectiveSearchMode = normalizeSearchMode(searchModeValue); + const metadataProviderValue = readValue('METADATA_PROVIDER'); + const metadataProviderAudiobookValue = readValue('METADATA_PROVIDER_AUDIOBOOK'); + const defaultReleaseSourceValue = readValue('DEFAULT_RELEASE_SOURCE', 'direct_download'); + + const canOverrideSearchMode = isUserOverridable('SEARCH_MODE') && preferenceKeySet.has('SEARCH_MODE'); + const canOverrideMetadataProvider = isUserOverridable('METADATA_PROVIDER') + && preferenceKeySet.has('METADATA_PROVIDER'); + const canOverrideAudiobookMetadataProvider = isUserOverridable('METADATA_PROVIDER_AUDIOBOOK') + && preferenceKeySet.has('METADATA_PROVIDER_AUDIOBOOK'); + const canOverrideDefaultReleaseSource = isUserOverridable('DEFAULT_RELEASE_SOURCE') + && preferenceKeySet.has('DEFAULT_RELEASE_SOURCE'); + + if ( + !canOverrideSearchMode + && !canOverrideMetadataProvider + && !canOverrideAudiobookMetadataProvider + && !canOverrideDefaultReleaseSource + ) { + return null; + } + + return ( +
+ + + {canOverrideSearchMode && ( + resetKeys(['SEARCH_MODE']), + } + : undefined + } + > + setUserSettings((prev) => ({ ...prev, SEARCH_MODE: value }))} + disabled={Boolean(searchModeField.fromEnv)} + /> + + )} + + {effectiveSearchMode === 'universal' && canOverrideMetadataProvider && ( + resetKeys(['METADATA_PROVIDER']), + } + : undefined + } + > + setUserSettings((prev) => ({ ...prev, METADATA_PROVIDER: value }))} + disabled={Boolean(metadataProviderField.fromEnv)} + /> + + )} + + {effectiveSearchMode === 'universal' && canOverrideAudiobookMetadataProvider && ( + resetKeys(['METADATA_PROVIDER_AUDIOBOOK']), + } + : undefined + } + > + setUserSettings((prev) => ({ ...prev, METADATA_PROVIDER_AUDIOBOOK: value }))} + disabled={Boolean(metadataProviderAudiobookField.fromEnv)} + /> + + )} + + {effectiveSearchMode === 'universal' && canOverrideDefaultReleaseSource && ( + resetKeys(['DEFAULT_RELEASE_SOURCE']), + } + : undefined + } + > + setUserSettings((prev) => ({ ...prev, DEFAULT_RELEASE_SOURCE: value }))} + disabled={Boolean(defaultReleaseSourceField.fromEnv)} + /> + + )} +
+ ); +}; diff --git a/src/frontend/src/components/settings/users/types.ts b/src/frontend/src/components/settings/users/types.ts index 76c1eae..cd2a8e5 100644 --- a/src/frontend/src/components/settings/users/types.ts +++ b/src/frontend/src/components/settings/users/types.ts @@ -8,6 +8,10 @@ export interface PerUserSettings { BOOKLORE_LIBRARY_ID?: string; BOOKLORE_PATH_ID?: string; EMAIL_RECIPIENT?: string; + SEARCH_MODE?: string; + METADATA_PROVIDER?: string; + METADATA_PROVIDER_AUDIOBOOK?: string; + DEFAULT_RELEASE_SOURCE?: string; USER_NOTIFICATION_ROUTES?: Array>; REQUESTS_ENABLED?: boolean; REQUEST_POLICY_DEFAULT_EBOOK?: string; diff --git a/src/frontend/src/components/settings/users/useUserForm.ts b/src/frontend/src/components/settings/users/useUserForm.ts index 6102b52..50d1b8d 100644 --- a/src/frontend/src/components/settings/users/useUserForm.ts +++ b/src/frontend/src/components/settings/users/useUserForm.ts @@ -11,10 +11,11 @@ export const useUserForm = () => { const [editPasswordConfirm, setEditPasswordConfirm] = useState(''); const [downloadDefaults, setDownloadDefaults] = useState(null); const [deliveryPreferences, setDeliveryPreferences] = useState(null); + const [searchPreferences, setSearchPreferences] = useState(null); const [notificationPreferences, setNotificationPreferences] = useState(null); const preferenceGroups = useMemo( - () => [deliveryPreferences, notificationPreferences], - [deliveryPreferences, notificationPreferences] + () => [deliveryPreferences, searchPreferences, notificationPreferences], + [deliveryPreferences, searchPreferences, notificationPreferences] ); const { userSettings, @@ -31,6 +32,7 @@ export const useUserForm = () => { const resetEditContext = () => { setDownloadDefaults(null); setDeliveryPreferences(null); + setSearchPreferences(null); setNotificationPreferences(null); resetUserOverridesState(); }; @@ -45,6 +47,7 @@ export const useUserForm = () => { setEditingUser({ ...context.user }); setDownloadDefaults(context.downloadDefaults); setDeliveryPreferences(context.deliveryPreferences); + setSearchPreferences(context.searchPreferences); setNotificationPreferences(context.notificationPreferences); applyUserOverridesContext({ settings: context.userSettings, @@ -75,6 +78,7 @@ export const useUserForm = () => { setEditPasswordConfirm, downloadDefaults, deliveryPreferences, + searchPreferences, notificationPreferences, userSettings, setUserSettings, diff --git a/src/frontend/src/components/settings/users/useUserMutations.ts b/src/frontend/src/components/settings/users/useUserMutations.ts index dcf7529..c83a444 100644 --- a/src/frontend/src/components/settings/users/useUserMutations.ts +++ b/src/frontend/src/components/settings/users/useUserMutations.ts @@ -23,6 +23,7 @@ interface UseUserMutationsParams { userSettings: PerUserSettings; userOverridableSettings: Set; deliveryPreferences: DeliveryPreferencesResponse | null; + searchPreferences: DeliveryPreferencesResponse | null; notificationPreferences: DeliveryPreferencesResponse | null; onEditSaveSuccess?: () => void; } @@ -61,6 +62,7 @@ export const useUserMutations = ({ userSettings, userOverridableSettings, deliveryPreferences, + searchPreferences, notificationPreferences, onEditSaveSuccess, }: UseUserMutationsParams) => { @@ -110,7 +112,7 @@ export const useUserMutations = ({ ? buildUserSettingsPayload( userSettings, userOverridableSettings, - [deliveryPreferences, notificationPreferences] + [deliveryPreferences, searchPreferences, notificationPreferences] ) : null; const updatePayload: Partial> & { diff --git a/src/frontend/src/components/settings/users/useUsersFetch.ts b/src/frontend/src/components/settings/users/useUsersFetch.ts index b90777f..285e6a1 100644 --- a/src/frontend/src/components/settings/users/useUsersFetch.ts +++ b/src/frontend/src/components/settings/users/useUsersFetch.ts @@ -4,6 +4,7 @@ import { DeliveryPreferencesResponse, DownloadDefaults, getAdminDeliveryPreferences, + getAdminSearchPreferences, getAdminNotificationPreferences, getAdminUser, getAdminUsers, @@ -64,6 +65,7 @@ export interface UserEditContext { user: AdminUser; downloadDefaults: DownloadDefaults; deliveryPreferences: DeliveryPreferencesResponse | null; + searchPreferences: DeliveryPreferencesResponse | null; notificationPreferences: DeliveryPreferencesResponse | null; userSettings: PerUserSettings; userOverridableSettings: Set; @@ -136,14 +138,16 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => { ]); let deliveryPreferences: DeliveryPreferencesResponse | null = null; + let searchPreferences: DeliveryPreferencesResponse | null = null; let notificationPreferences: DeliveryPreferencesResponse | null = null; let userSettings = { ...(fullUser.settings || {}), } as PerUserSettings; let userOverridableSettings = new Set(); - const [deliveryResult, notificationResult] = await Promise.allSettled([ + const [deliveryResult, searchResult, notificationResult] = await Promise.allSettled([ getAdminDeliveryPreferences(userId), + getAdminSearchPreferences(userId), getAdminNotificationPreferences(userId), ]); @@ -156,6 +160,15 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => { deliveryResult.value.keys.forEach((key) => userOverridableSettings.add(key)); } + if (searchResult.status === 'fulfilled') { + searchPreferences = searchResult.value; + userSettings = { + ...userSettings, + ...(searchResult.value.userOverrides || {}), + } as PerUserSettings; + searchResult.value.keys.forEach((key) => userOverridableSettings.add(key)); + } + if (notificationResult.status === 'fulfilled') { notificationPreferences = notificationResult.value; userSettings = { @@ -177,6 +190,7 @@ export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => { user: fullUser, downloadDefaults: defaults, deliveryPreferences, + searchPreferences, notificationPreferences, userSettings, userOverridableSettings, diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 633d857..2466a90 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -33,6 +33,7 @@ const API = { download: `${API_BASE}/download`, status: `${API_BASE}/status`, cancelDownload: `${API_BASE}/download`, + retryDownload: `${API_BASE}/download`, setPriority: `${API_BASE}/queue`, clearCompleted: `${API_BASE}/queue/clear`, config: `${API_BASE}/config`, @@ -280,14 +281,17 @@ export const getMetadataBookInfo = async (provider: string, bookId: string): Pro return transformMetadataToBook(response); }; -export const downloadBook = async (id: string): Promise => { +export const downloadBook = async (id: string, onBehalfOfUserId?: number): Promise => { const params = new URLSearchParams(); params.set('id', id); + if (typeof onBehalfOfUserId === 'number') { + params.set('on_behalf_of_user_id', String(onBehalfOfUserId)); + } await fetchJSON(`${API.download}?${params.toString()}`); }; // Download a specific release (from ReleaseModal) -export const downloadRelease = async (release: { +export type DownloadReleasePayload = { source: string; source_id: string; title: string; @@ -307,10 +311,20 @@ export const downloadRelease = async (release: { series_position?: number; subtitle?: string; search_author?: string; -}): Promise => { +}; + +export const downloadRelease = async ( + release: DownloadReleasePayload, + onBehalfOfUserId?: number +): Promise => { + const payload = + typeof onBehalfOfUserId === 'number' + ? { ...release, on_behalf_of_user_id: onBehalfOfUserId } + : release; + await fetchJSON(`${API_BASE}/releases/download`, { method: 'POST', - body: JSON.stringify(release), + body: JSON.stringify(payload), }); }; @@ -354,6 +368,10 @@ export const cancelDownload = async (id: string): Promise => { await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' }); }; +export const retryDownload = async (id: string): Promise => { + await fetchJSON(`${API.retryDownload}/${encodeURIComponent(id)}/retry`, { method: 'POST' }); +}; + export const clearCompleted = async (): Promise => { await fetchJSON(`${API_BASE}/queue/clear`, { method: 'DELETE' }); }; @@ -631,6 +649,7 @@ export interface AdminUser { export interface SelfUserEditContext { user: AdminUser; deliveryPreferences: DeliveryPreferencesResponse | null; + searchPreferences: DeliveryPreferencesResponse | null; notificationPreferences: DeliveryPreferencesResponse | null; userOverridableKeys: string[]; visibleUserSettingsSections?: string[]; @@ -732,6 +751,12 @@ export const getAdminDeliveryPreferences = async ( return fetchJSON(`${API_BASE}/admin/users/${userId}/delivery-preferences`); }; +export const getAdminSearchPreferences = async ( + userId: number +): Promise => { + return fetchJSON(`${API_BASE}/admin/users/${userId}/search-preferences`); +}; + export const getAdminNotificationPreferences = async ( userId: number ): Promise => { diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index f0a446f..8028a79 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -265,6 +265,12 @@ export interface AuthResponse { oidc_auto_redirect?: boolean; } +export interface ActingAsUserSelection { + id: number; + username: string; + displayName: string | null; +} + // Type guard to check if a book is from a metadata provider // Returns true and narrows type to include required provider fields export const isMetadataBook = (book: Book): book is Book & { diff --git a/src/frontend/src/utils/actingAsUser.ts b/src/frontend/src/utils/actingAsUser.ts new file mode 100644 index 0000000..383c44d --- /dev/null +++ b/src/frontend/src/utils/actingAsUser.ts @@ -0,0 +1,5 @@ +import type { ActingAsUserSelection } from '../types'; + +export const formatActingAsUserName = (user: ActingAsUserSelection): string => { + return user.displayName || user.username; +}; diff --git a/src/frontend/src/utils/releaseSort.ts b/src/frontend/src/utils/releaseSort.ts index 461725f..98cf3fe 100644 --- a/src/frontend/src/utils/releaseSort.ts +++ b/src/frontend/src/utils/releaseSort.ts @@ -1,10 +1,14 @@ import { Release } from '../types'; +import { getReleaseFormats } from './releaseFormats'; export interface SortState { key: string; direction: 'asc' | 'desc'; + value?: string; } +export const FORMAT_SORT_KEY = '_format_priority'; + // LocalStorage helpers for persisting sort preferences per source const SORT_STORAGE_PREFIX = 'cwa-bd-release-sort-'; @@ -59,6 +63,24 @@ export function inferDefaultDirection(renderType: string): 'asc' | 'desc' { return 'asc'; } +// Sort releases by format priority - matching releases come first (asc) or last (desc) +export function sortReleasesByFormat( + releases: Release[], + targetFormat: string, + direction: 'asc' | 'desc' +): Release[] { + const target = targetFormat.toLowerCase(); + return [...releases].sort((a, b) => { + const aFormats = getReleaseFormats(a); + const bFormats = getReleaseFormats(b); + const aMatch = aFormats.includes(target) ? 1 : 0; + const bMatch = bFormats.includes(target) ? 1 : 0; + if (aMatch === bMatch) return 0; + // asc = matching first, desc = matching last + return direction === 'asc' ? bMatch - aMatch : aMatch - bMatch; + }); +} + // Sort releases by a column export function sortReleases( releases: Release[], diff --git a/tests/config/test_users_settings.py b/tests/config/test_users_settings.py index 5e18019..04132de 100644 --- a/tests/config/test_users_settings.py +++ b/tests/config/test_users_settings.py @@ -78,7 +78,7 @@ def test_visible_self_settings_sections_field_defaults_and_options(): fields = _field_map("users") field = fields["VISIBLE_SELF_SETTINGS_SECTIONS"] - assert field.default == ["delivery", "notifications"] + assert field.default == ["delivery", "search", "notifications"] assert field.variant == "dropdown" assert field.env_supported is False assert field.options == [ @@ -87,6 +87,11 @@ def test_visible_self_settings_sections_field_defaults_and_options(): "label": "Delivery Preferences", "description": "Show personal delivery output and destination settings.", }, + { + "value": "search", + "label": "Search Preferences", + "description": "Show personal search mode and provider settings.", + }, { "value": "notifications", "label": "Notifications", @@ -330,3 +335,37 @@ def test_on_save_users_normalizes_rules(monkeypatch): assert result["values"]["REQUEST_POLICY_RULES"] == [ {"source": "direct_download", "content_type": "ebook", "mode": "request_release"}, ] + + +def test_on_save_users_normalizes_search_mode_override(): + result = users_settings_module._on_save_users({"SEARCH_MODE": " UNIVERSAL "}) + + assert result["error"] is False + assert result["values"]["SEARCH_MODE"] == "universal" + + +def test_on_save_users_rejects_invalid_metadata_provider_override(monkeypatch): + monkeypatch.setattr( + "shelfmark.metadata_providers.is_provider_registered", + lambda provider_name: provider_name == "openlibrary", + ) + + result = users_settings_module._on_save_users({"METADATA_PROVIDER": "unknown-provider"}) + + assert result["error"] is True + assert "METADATA_PROVIDER must be a valid metadata provider name or empty" in result["message"] + + +def test_on_save_users_rejects_invalid_default_release_source_override(monkeypatch): + monkeypatch.setattr( + "shelfmark.release_sources.list_available_sources", + lambda: [ + {"name": "direct_download", "display_name": "Direct Download", "enabled": True}, + {"name": "prowlarr", "display_name": "Prowlarr", "enabled": True}, + ], + ) + + result = users_settings_module._on_save_users({"DEFAULT_RELEASE_SOURCE": "unknown-source"}) + + assert result["error"] is True + assert "DEFAULT_RELEASE_SOURCE must be a valid release source name or empty" in result["message"] diff --git a/tests/core/test_admin_users_api.py b/tests/core/test_admin_users_api.py index b806ccb..c15336c 100644 --- a/tests/core/test_admin_users_api.py +++ b/tests/core/test_admin_users_api.py @@ -106,6 +106,20 @@ class TestAdminUsersListEndpoint: users = resp.json assert "password_hash" not in users[0] + def test_list_users_hides_internal_no_auth_activity_user(self, admin_client, user_db): + user_db.create_user( + username="__shelfmark_noauth_activity__", + display_name="No-auth Activity", + role="admin", + ) + user_db.create_user(username="alice", email="alice@example.com") + + resp = admin_client.get("/api/admin/users") + assert resp.status_code == 200 + usernames = [u["username"] for u in resp.json] + assert "__shelfmark_noauth_activity__" not in usernames + assert "alice" in usernames + def test_list_users_includes_auth_source_and_is_active(self, admin_client, user_db): user_db.create_user(username="local_user", auth_source="builtin") user_db.create_user( @@ -1151,6 +1165,83 @@ class TestAdminDeliveryPreferences: assert resp.status_code == 403 +# --------------------------------------------------------------------------- +# GET /api/admin/users//search-preferences +# --------------------------------------------------------------------------- + + +class TestAdminSearchPreferences: + """Tests for GET /api/admin/users//search-preferences.""" + + @pytest.fixture(autouse=True) + def setup_config(self, tmp_path, monkeypatch): + import json + from pathlib import Path + + config_dir = str(tmp_path) + monkeypatch.setenv("CONFIG_DIR", config_dir) + monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", Path(config_dir)) + + plugins_dir = tmp_path / "plugins" + plugins_dir.mkdir() + search_mode_config = { + "SEARCH_MODE": "direct", + "METADATA_PROVIDER": "openlibrary", + "METADATA_PROVIDER_AUDIOBOOK": "", + "DEFAULT_RELEASE_SOURCE": "direct_download", + } + (plugins_dir / "search_mode.json").write_text(json.dumps(search_mode_config)) + + from shelfmark.core.config import config as app_config + app_config.refresh() + + def test_returns_curated_fields_and_effective_values(self, admin_client, user_db): + user = user_db.create_user(username="alice") + user_db.set_user_settings( + user["id"], + { + "SEARCH_MODE": "universal", + "METADATA_PROVIDER": "openlibrary", + "DEFAULT_RELEASE_SOURCE": "prowlarr", + }, + ) + + resp = admin_client.get(f"/api/admin/users/{user['id']}/search-preferences") + assert resp.status_code == 200 + + data = resp.json + assert data["tab"] == "search_mode" + assert data["keys"] == [ + "SEARCH_MODE", + "METADATA_PROVIDER", + "METADATA_PROVIDER_AUDIOBOOK", + "DEFAULT_RELEASE_SOURCE", + ] + + field_keys = [field["key"] for field in data["fields"]] + assert set(field_keys) == set(data["keys"]) + + assert data["userOverrides"]["SEARCH_MODE"] == "universal" + assert data["userOverrides"]["METADATA_PROVIDER"] == "openlibrary" + assert data["userOverrides"]["DEFAULT_RELEASE_SOURCE"] == "prowlarr" + + assert data["effective"]["SEARCH_MODE"]["source"] == "user_override" + assert data["effective"]["SEARCH_MODE"]["value"] == "universal" + assert data["effective"]["METADATA_PROVIDER"]["source"] == "user_override" + assert data["effective"]["METADATA_PROVIDER_AUDIOBOOK"]["source"] in {"global_config", "default"} + assert data["effective"]["DEFAULT_RELEASE_SOURCE"]["source"] == "user_override" + assert data["effective"]["DEFAULT_RELEASE_SOURCE"]["value"] == "prowlarr" + + def test_returns_404_for_unknown_user(self, admin_client): + resp = admin_client.get("/api/admin/users/9999/search-preferences") + assert resp.status_code == 404 + + def test_requires_admin(self, regular_client, user_db): + user = user_db.create_user(username="alice") + resp = regular_client.get(f"/api/admin/users/{user['id']}/search-preferences") + assert resp.status_code == 403 + + # --------------------------------------------------------------------------- # GET /api/admin/users//notification-preferences # --------------------------------------------------------------------------- diff --git a/tests/core/test_download_api_guardrails.py b/tests/core/test_download_api_guardrails.py index b6da224..4c1dd6d 100644 --- a/tests/core/test_download_api_guardrails.py +++ b/tests/core/test_download_api_guardrails.py @@ -111,6 +111,121 @@ class TestDownloadEndpointGuardrails: assert resp.status_code == 401 assert resp.get_json() == {"error": "Unauthorized"} + def test_admin_can_queue_book_on_behalf_of_another_user(self, main_module, client): + target_user = _create_user(main_module, prefix="target") + admin_user = _create_user(main_module, prefix="admin", role="admin") + captured: dict[str, object] = {} + + def fake_queue_book(book_id, priority, user_id=None, username=None): + captured.update( + { + "book_id": book_id, + "priority": priority, + "user_id": user_id, + "username": username, + } + ) + return True, None + + _set_authenticated_session( + client, + user_id=admin_user["username"], + db_user_id=admin_user["id"], + is_admin=True, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_book", side_effect=fake_queue_book): + resp = client.get( + f"/api/download?id=book-123&priority=4&on_behalf_of_user_id={target_user['id']}" + ) + + assert resp.status_code == 200 + assert resp.get_json() == {"status": "queued", "priority": 4} + assert captured == { + "book_id": "book-123", + "priority": 4, + "user_id": target_user["id"], + "username": target_user["username"], + } + + def test_non_admin_cannot_queue_book_on_behalf_of_user(self, main_module, client): + target_user = _create_user(main_module, prefix="target") + actor_user = _create_user(main_module, prefix="actor") + _set_authenticated_session( + client, + user_id=actor_user["username"], + db_user_id=actor_user["id"], + is_admin=False, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_book") as mock_queue_book: + resp = client.get( + f"/api/download?id=book-123&on_behalf_of_user_id={target_user['id']}" + ) + + assert resp.status_code == 403 + assert resp.get_json() == {"error": "Admin required"} + mock_queue_book.assert_not_called() + + @pytest.mark.parametrize("raw_user_id", ["abc", "-1", "0"]) + def test_invalid_on_behalf_user_id_returns_400_for_book_download( + self, main_module, client, raw_user_id + ): + admin_user = _create_user(main_module, prefix="admin", role="admin") + _set_authenticated_session( + client, + user_id=admin_user["username"], + db_user_id=admin_user["id"], + is_admin=True, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_book") as mock_queue_book: + resp = client.get( + f"/api/download?id=book-123&on_behalf_of_user_id={raw_user_id}" + ) + + assert resp.status_code == 400 + assert resp.get_json() == {"error": "Invalid on_behalf_of_user_id"} + mock_queue_book.assert_not_called() + + def test_unknown_on_behalf_user_returns_404_for_book_download(self, main_module, client): + admin_user = _create_user(main_module, prefix="admin", role="admin") + _set_authenticated_session( + client, + user_id=admin_user["username"], + db_user_id=admin_user["id"], + is_admin=True, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_book") as mock_queue_book: + resp = client.get("/api/download?id=book-123&on_behalf_of_user_id=99999999") + + assert resp.status_code == 404 + assert resp.get_json() == {"error": "User not found"} + mock_queue_book.assert_not_called() + + def test_on_behalf_book_download_returns_503_when_user_db_unavailable(self, main_module, client): + admin_user = _create_user(main_module, prefix="admin", role="admin") + _set_authenticated_session( + client, + user_id=admin_user["username"], + db_user_id=admin_user["id"], + is_admin=True, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module, "user_db", None): + with patch.object(main_module.backend, "queue_book") as mock_queue_book: + resp = client.get("/api/download?id=book-123&on_behalf_of_user_id=7") + + assert resp.status_code == 503 + assert resp.get_json() == {"error": "User database unavailable"} + mock_queue_book.assert_not_called() + class TestReleaseDownloadEndpointGuardrails: def test_empty_json_payload_returns_400(self, main_module, client): @@ -218,6 +333,147 @@ class TestReleaseDownloadEndpointGuardrails: assert "Unsupported Media Type" in body["error"] mock_queue_release.assert_not_called() + def test_admin_can_queue_release_on_behalf_of_another_user(self, main_module, client): + target_user = _create_user(main_module, prefix="target") + admin_user = _create_user(main_module, prefix="admin", role="admin") + captured: dict[str, object] = {} + + def fake_queue_release(release_data, priority, user_id=None, username=None): + captured.update( + { + "release_data": release_data, + "priority": priority, + "user_id": user_id, + "username": username, + } + ) + return True, None + + _set_authenticated_session( + client, + user_id=admin_user["username"], + db_user_id=admin_user["id"], + is_admin=True, + ) + payload = { + "source": "direct_download", + "source_id": "release-admin-on-behalf", + "title": "Release Title", + "priority": 2, + "on_behalf_of_user_id": target_user["id"], + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release): + resp = client.post("/api/releases/download", json=payload) + + assert resp.status_code == 200 + assert resp.get_json() == {"status": "queued", "priority": 2} + assert captured["priority"] == 2 + assert captured["user_id"] == target_user["id"] + assert captured["username"] == target_user["username"] + assert captured["release_data"] == { + **payload, + "content_type": "ebook", + } + + def test_non_admin_cannot_queue_release_on_behalf_of_user(self, main_module, client): + target_user = _create_user(main_module, prefix="target") + actor_user = _create_user(main_module, prefix="actor") + _set_authenticated_session( + client, + user_id=actor_user["username"], + db_user_id=actor_user["id"], + is_admin=False, + ) + payload = { + "source": "direct_download", + "source_id": "release-forbidden", + "title": "Release Title", + "on_behalf_of_user_id": target_user["id"], + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_release") as mock_queue_release: + resp = client.post("/api/releases/download", json=payload) + + assert resp.status_code == 403 + assert resp.get_json() == {"error": "Admin required"} + mock_queue_release.assert_not_called() + + @pytest.mark.parametrize("raw_user_id", ["abc", "-1", "0"]) + def test_invalid_on_behalf_user_id_returns_400_for_release_download( + self, main_module, client, raw_user_id + ): + admin_user = _create_user(main_module, prefix="admin", role="admin") + _set_authenticated_session( + client, + user_id=admin_user["username"], + db_user_id=admin_user["id"], + is_admin=True, + ) + payload = { + "source": "direct_download", + "source_id": "release-invalid", + "title": "Release Title", + "on_behalf_of_user_id": raw_user_id, + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_release") as mock_queue_release: + resp = client.post("/api/releases/download", json=payload) + + assert resp.status_code == 400 + assert resp.get_json() == {"error": "Invalid on_behalf_of_user_id"} + mock_queue_release.assert_not_called() + + def test_unknown_on_behalf_user_returns_404_for_release_download(self, main_module, client): + admin_user = _create_user(main_module, prefix="admin", role="admin") + _set_authenticated_session( + client, + user_id=admin_user["username"], + db_user_id=admin_user["id"], + is_admin=True, + ) + payload = { + "source": "direct_download", + "source_id": "release-missing-user", + "title": "Release Title", + "on_behalf_of_user_id": 99999999, + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_release") as mock_queue_release: + resp = client.post("/api/releases/download", json=payload) + + assert resp.status_code == 404 + assert resp.get_json() == {"error": "User not found"} + mock_queue_release.assert_not_called() + + def test_on_behalf_release_download_returns_503_when_user_db_unavailable(self, main_module, client): + admin_user = _create_user(main_module, prefix="admin", role="admin") + _set_authenticated_session( + client, + user_id=admin_user["username"], + db_user_id=admin_user["id"], + is_admin=True, + ) + payload = { + "source": "direct_download", + "source_id": "release-user-db-missing", + "title": "Release Title", + "on_behalf_of_user_id": 7, + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module, "user_db", None): + with patch.object(main_module.backend, "queue_release") as mock_queue_release: + resp = client.post("/api/releases/download", json=payload) + + assert resp.status_code == 503 + assert resp.get_json() == {"error": "User database unavailable"} + mock_queue_release.assert_not_called() + class TestCancelDownloadEndpointGuardrails: def test_owner_can_cancel_direct_download(self, main_module, client): @@ -361,6 +617,176 @@ class TestCancelDownloadEndpointGuardrails: mock_cancel.assert_called_once_with("requested-task-2") +class TestRetryDownloadEndpointGuardrails: + def test_retry_returns_404_when_task_missing(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_authenticated_session( + client, + user_id=user["username"], + db_user_id=user["id"], + is_admin=False, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=None): + with patch.object(main_module.backend, "retry_download") as mock_retry: + resp = client.post("/api/download/missing-task/retry") + + assert resp.status_code == 404 + assert resp.get_json() == {"error": "Download not found"} + mock_retry.assert_not_called() + + def test_owner_can_retry_direct_download(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_authenticated_session( + client, + user_id=user["username"], + db_user_id=user["id"], + is_admin=False, + ) + task = DownloadTask( + task_id="direct-task-retry-1", + source="direct_download", + title="Direct Task", + user_id=user["id"], + username=user["username"], + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + resp = client.post("/api/download/direct-task-retry-1/retry") + + assert resp.status_code == 200 + assert resp.get_json() == {"status": "queued", "book_id": "direct-task-retry-1"} + mock_retry.assert_called_once_with("direct-task-retry-1") + + def test_non_owner_cannot_retry_download(self, main_module, client): + owner = _create_user(main_module, prefix="owner") + actor = _create_user(main_module, prefix="actor") + _set_authenticated_session( + client, + user_id=actor["username"], + db_user_id=actor["id"], + is_admin=False, + ) + task = DownloadTask( + task_id="owned-task-retry-1", + source="direct_download", + title="Owned Task", + user_id=owner["id"], + username=owner["username"], + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + resp = client.post("/api/download/owned-task-retry-1/retry") + + assert resp.status_code == 403 + assert resp.get_json()["code"] == "download_not_owned" + mock_retry.assert_not_called() + + def test_retry_forbidden_for_request_id_linked_download(self, main_module, client): + user = _create_user(main_module, prefix="requester") + _set_authenticated_session( + client, + user_id=user["username"], + db_user_id=user["id"], + is_admin=False, + ) + task = DownloadTask( + task_id="requested-retry-1", + source="prowlarr", + title="Requested Book", + user_id=user["id"], + username=user["username"], + request_id=123, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + resp = client.post("/api/download/requested-retry-1/retry") + + assert resp.status_code == 403 + assert resp.get_json()["code"] == "requested_download_retry_forbidden" + mock_retry.assert_not_called() + + def test_retry_forbidden_for_graduated_request_download(self, main_module, client): + user = _create_user(main_module, prefix="requester") + _set_authenticated_session( + client, + user_id=user["username"], + db_user_id=user["id"], + is_admin=False, + ) + main_module.user_db.create_request( + user_id=user["id"], + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data={ + "title": "Requested Book", + "author": "Request Author", + "provider": "openlibrary", + "provider_id": "req-retry-1", + }, + release_data={ + "source": "prowlarr", + "source_id": "requested-retry-2", + "title": "Requested Book.epub", + }, + status="fulfilled", + delivery_state="error", + ) + task = DownloadTask( + task_id="requested-retry-2", + source="prowlarr", + title="Requested Book", + user_id=user["id"], + username=user["username"], + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object(main_module.backend, "retry_download", return_value=(True, None)) as mock_retry: + resp = client.post("/api/download/requested-retry-2/retry") + + assert resp.status_code == 403 + assert resp.get_json()["code"] == "requested_download_retry_forbidden" + mock_retry.assert_not_called() + + def test_retry_returns_409_for_non_retryable_state(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_authenticated_session( + client, + user_id=user["username"], + db_user_id=user["id"], + is_admin=False, + ) + task = DownloadTask( + task_id="direct-task-retry-409", + source="direct_download", + title="Direct Task", + user_id=user["id"], + username=user["username"], + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object( + main_module.backend, + "retry_download", + return_value=(False, "Download is not in an error state"), + ) as mock_retry: + resp = client.post("/api/download/direct-task-retry-409/retry") + + assert resp.status_code == 409 + assert resp.get_json() == {"error": "Download is not in an error state"} + mock_retry.assert_called_once_with("direct-task-retry-409") + + class TestStatusEndpointGuardrails: def test_no_auth_allows_without_session_and_returns_status(self, main_module, client): observed: dict[str, object] = {} diff --git a/tests/core/test_original_name_template_variable.py b/tests/core/test_original_name_template_variable.py new file mode 100644 index 0000000..05ed18b --- /dev/null +++ b/tests/core/test_original_name_template_variable.py @@ -0,0 +1,97 @@ +"""Tests for {OriginalName} template variable support.""" + +from pathlib import Path + +from shelfmark.core.models import DownloadTask +from shelfmark.core.naming import KNOWN_TOKENS, parse_naming_template +from shelfmark.download.postprocess.transfer import transfer_book_files + + +class TestOriginalNameInKnownTokens: + def test_original_name_in_known_tokens(self): + assert "originalname" in KNOWN_TOKENS + + def test_original_name_token_parsed(self): + result = parse_naming_template("{OriginalName}", {"OriginalName": "Part 1 of 2"}) + assert result == "Part 1 of 2" + + def test_original_name_token_case_insensitive(self): + result = parse_naming_template("{originalname}", {"OriginalName": "Chapter 01"}) + assert result == "Chapter 01" + + +class TestOriginalNameTransferTemplates: + def test_single_file_rename_can_use_original_name(self, tmp_path: Path, monkeypatch): + source_dir = tmp_path / "source" + destination = tmp_path / "destination" + source_dir.mkdir() + destination.mkdir() + + source_file = source_dir / "Part 1 of 2.mp3" + source_file.write_text("audio") + + monkeypatch.setattr( + "shelfmark.download.postprocess.transfer.get_template", + lambda _is_audiobook, mode: "{OriginalName}" if mode == "rename" else "{Author}/{Title}", + ) + + task = DownloadTask( + task_id="original-name-rename", + source="direct_download", + title="Archive Audio", + author="Tester", + format="mp3", + content_type="audiobook", + ) + + final_paths, error, _op_counts = transfer_book_files( + [source_file], + destination=destination, + task=task, + use_hardlink=False, + is_torrent=False, + organization_mode="rename", + ) + + assert error is None + assert len(final_paths) == 1 + assert final_paths[0].name == "Part 1 of 2.mp3" + + def test_multifile_organize_can_use_original_name(self, tmp_path: Path, monkeypatch): + source_dir = tmp_path / "source" + destination = tmp_path / "destination" + source_dir.mkdir() + destination.mkdir() + + part2 = source_dir / "Part 2 of 2.mp3" + part1 = source_dir / "Part 1 of 2.mp3" + part2.write_text("audio2") + part1.write_text("audio1") + + monkeypatch.setattr( + "shelfmark.download.postprocess.transfer.get_template", + lambda _is_audiobook, mode: "{Author}/{Title}/{OriginalName}", + ) + + task = DownloadTask( + task_id="original-name-organize", + source="direct_download", + title="Archive Audio", + author="Tester", + format="mp3", + content_type="audiobook", + ) + + final_paths, error, _op_counts = transfer_book_files( + [part2, part1], + destination=destination, + task=task, + use_hardlink=False, + is_torrent=False, + organization_mode="organize", + ) + + assert error is None + assert len(final_paths) == 2 + assert {path.name for path in final_paths} == {"Part 1 of 2.mp3", "Part 2 of 2.mp3"} + assert all(path.parent == destination / "Tester" / "Archive Audio" for path in final_paths) diff --git a/tests/core/test_per_user_downloads.py b/tests/core/test_per_user_downloads.py index aa87b3a..5c11f5a 100644 --- a/tests/core/test_per_user_downloads.py +++ b/tests/core/test_per_user_downloads.py @@ -149,6 +149,19 @@ class TestQueueFilterByUser: removed = q.clear_completed(user_id=1) assert removed == 2 + def test_enqueue_existing_deduplicates_queue_entries(self): + q = BookQueue() + q.add(self._make_task("book-1", user_id=1)) + + assert q.enqueue_existing("book-1") + assert q.enqueue_existing("book-1", priority=-10) + + queue_order = q.get_queue_order() + assert len(queue_order) == 1 + assert queue_order[0]["id"] == "book-1" + assert q.get_task("book-1").priority == -10 + assert q.get_task_status("book-1") == QueueStatus.QUEUED + # --------------------------------------------------------------------------- # Per-user destination override in get_final_destination diff --git a/tests/core/test_processing_integration.py b/tests/core/test_processing_integration.py index 012aa97..fb826b2 100644 --- a/tests/core/test_processing_integration.py +++ b/tests/core/test_processing_integration.py @@ -16,9 +16,13 @@ def _build_config( organization: str, hardlink: bool = False, rename_template: str = "{Author} - {Title}", + organize_template: str = "{Author}/{Title}", + audiobook_rename_template: str | None = None, + audiobook_organize_template: str = "{Author}/{Title}{ - PartNumber}", supported_formats: list[str] | None = None, supported_audiobook_formats: list[str] | None = None, ): + audiobook_rename = audiobook_rename_template or rename_template values = { "DESTINATION": str(destination), "INGEST_DIR": str(destination), @@ -26,9 +30,9 @@ def _build_config( "FILE_ORGANIZATION": organization, "FILE_ORGANIZATION_AUDIOBOOK": organization, "TEMPLATE_RENAME": rename_template, - "TEMPLATE_ORGANIZE": "{Author}/{Title}", - "TEMPLATE_AUDIOBOOK_RENAME": rename_template, - "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}", + "TEMPLATE_ORGANIZE": organize_template, + "TEMPLATE_AUDIOBOOK_RENAME": audiobook_rename, + "TEMPLATE_AUDIOBOOK_ORGANIZE": audiobook_organize_template, "SUPPORTED_FORMATS": supported_formats or ["epub"], "SUPPORTED_AUDIOBOOK_FORMATS": supported_audiobook_formats or ["mp3"], "HARDLINK_TORRENTS": hardlink, @@ -82,6 +86,56 @@ def test_direct_download_rename_moves_file(tmp_path): assert any("Moving" in msg for _, msg in statuses) +@pytest.mark.parametrize("source_kind", ["direct", "torrent"]) +def test_original_name_rename_single_file_for_direct_and_torrent(tmp_path, source_kind: str): + from shelfmark.download.postprocess.router import post_process_download as _post_process_download + + staging = tmp_path / "staging" + downloads = tmp_path / "downloads" + ingest = tmp_path / "ingest" + staging.mkdir() + downloads.mkdir() + ingest.mkdir() + + base_dir = staging if source_kind == "direct" else downloads + input_path = base_dir / "Some.Release.v2.epub" + input_path.write_text("content") + + task = DownloadTask( + task_id=f"original-name-single-{source_kind}", + source="direct_download" if source_kind == "direct" else "prowlarr", + title="Ignored Title", + author="Ignored Author", + format="epub", + search_mode=SearchMode.DIRECT if source_kind == "direct" else SearchMode.UNIVERSAL, + original_download_path=str(input_path) if source_kind == "torrent" else None, + ) + + with patch("shelfmark.core.config.config") as mock_config, \ + patch("shelfmark.config.env.TMP_DIR", staging): + mock_config.get = _build_config( + ingest, + organization="rename", + rename_template="{OriginalName}", + supported_formats=["epub"], + ) + mock_config.CUSTOM_SCRIPT = None + _sync_config(mock_config, mock_config) + + result = _post_process_download(input_path, task, Event(), lambda *_args: None) + + assert result is not None + result_path = Path(result) + assert result_path.exists() + assert result_path.parent == ingest + assert result_path.name == "Some.Release.v2.epub" + + if source_kind == "direct": + assert not input_path.exists() + else: + assert input_path.exists() + + def test_torrent_hardlink_preserves_source(tmp_path): from shelfmark.download.postprocess.router import post_process_download as _post_process_download @@ -120,6 +174,47 @@ def test_torrent_hardlink_preserves_source(tmp_path): assert os.stat(original).st_ino == os.stat(result_path).st_ino +def test_archive_extraction_rename_single_file_can_use_original_name(tmp_path): + from shelfmark.download.postprocess.router import post_process_download as _post_process_download + + staging = tmp_path / "staging" + ingest = tmp_path / "ingest" + staging.mkdir() + ingest.mkdir() + + archive_path = staging / "book.zip" + with zipfile.ZipFile(archive_path, "w") as zf: + zf.writestr("book.v2.epub", "content") + + task = DownloadTask( + task_id="archive-single-original-name", + source="direct_download", + title="Ignored", + author="Ignored", + format="epub", + search_mode=SearchMode.DIRECT, + ) + + with patch("shelfmark.core.config.config") as mock_config, \ + patch("shelfmark.config.env.TMP_DIR", staging): + mock_config.get = _build_config( + ingest, + organization="rename", + rename_template="{OriginalName}", + supported_formats=["epub"], + ) + mock_config.CUSTOM_SCRIPT = None + _sync_config(mock_config, mock_config) + + result = _post_process_download(archive_path, task, Event(), lambda *_args: None) + + assert result is not None + result_path = Path(result) + assert result_path.exists() + assert result_path.parent == ingest + assert result_path.name == "book.v2.epub" + + def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_path): from shelfmark.download.postprocess.router import post_process_download as _post_process_download @@ -172,6 +267,49 @@ def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_p assert list(ingest.glob("*.epub")) == [] +def test_multifile_rename_ignores_template_even_with_original_name(tmp_path): + from shelfmark.download.postprocess.router import post_process_download as _post_process_download + + staging = tmp_path / "staging" + ingest = tmp_path / "ingest" + staging.mkdir() + ingest.mkdir() + + source_dir = staging / "release" + source_dir.mkdir() + (source_dir / "Part 2 of 2.mp3").write_text("audio2") + (source_dir / "Part 1 of 2.mp3").write_text("audio1") + + task = DownloadTask( + task_id="multi-rename-template-ignored", + source="direct_download", + title="Ignored", + author="Ignored", + format="mp3", + content_type="audiobook", + search_mode=SearchMode.DIRECT, + ) + + with patch("shelfmark.core.config.config") as mock_config, \ + patch("shelfmark.config.env.TMP_DIR", staging): + mock_config.get = _build_config( + ingest, + organization="rename", + rename_template="{Author} - {Title}", + audiobook_rename_template="{OriginalName} - RENAMED", + supported_audiobook_formats=["mp3"], + ) + mock_config.CUSTOM_SCRIPT = None + _sync_config(mock_config, mock_config) + + result = _post_process_download(source_dir, task, Event(), lambda *_args: None) + + assert result is not None + files = sorted(path.name for path in ingest.glob("*.mp3")) + assert files == ["Part 1 of 2.mp3", "Part 2 of 2.mp3"] + assert all("RENAMED" not in name for name in files) + + def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_path): from shelfmark.download.postprocess.router import post_process_download as _post_process_download @@ -436,6 +574,60 @@ def test_archive_extraction_organize_multifile_assigns_part_numbers(tmp_path): assert files[1].name == "Archive Audio - 02.mp3" +def test_archive_extraction_organize_multifile_can_use_original_name(tmp_path): + from shelfmark.download.postprocess.router import post_process_download as _post_process_download + + staging = tmp_path / "staging" + ingest = tmp_path / "ingest" + staging.mkdir() + ingest.mkdir() + + archive_path = staging / "audio.zip" + with zipfile.ZipFile(archive_path, "w") as zf: + zf.writestr("Part 2 of 2.mp3", "audio2") + zf.writestr("Part 1 of 2.mp3", "audio1") + + task = DownloadTask( + task_id="direct-archive-audio-original-name", + source="direct_download", + title="Archive Audio", + author="Tester", + format="mp3", + content_type="audiobook", + search_mode=SearchMode.DIRECT, + ) + + status_cb = lambda *_args: None + values = { + "DESTINATION": str(ingest), + "INGEST_DIR": str(ingest), + "DESTINATION_AUDIOBOOK": str(ingest), + "FILE_ORGANIZATION": "organize", + "FILE_ORGANIZATION_AUDIOBOOK": "organize", + "TEMPLATE_RENAME": "{Author} - {Title}", + "TEMPLATE_ORGANIZE": "{Author}/{Title}", + "TEMPLATE_AUDIOBOOK_RENAME": "{Author} - {Title}", + "TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}/{OriginalName}", + "SUPPORTED_FORMATS": ["epub"], + "SUPPORTED_AUDIOBOOK_FORMATS": ["mp3"], + "HARDLINK_TORRENTS": False, + "HARDLINK_TORRENTS_AUDIOBOOK": False, + } + + with patch("shelfmark.core.config.config") as mock_config, \ + patch("shelfmark.config.env.TMP_DIR", staging): + mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: values.get(key, default)) + mock_config.CUSTOM_SCRIPT = None + _sync_config(mock_config, mock_config) + + result = _post_process_download(archive_path, task, Event(), status_cb) + + assert result is not None + author_title_dir = ingest / "Tester" / "Archive Audio" + files = sorted(path.name for path in author_title_dir.glob("*.mp3")) + assert files == ["Part 1 of 2.mp3", "Part 2 of 2.mp3"] + + def test_booklore_mode_uploads_and_cleans_staging(tmp_path): from shelfmark.download.postprocess.router import post_process_download as _post_process_download diff --git a/tests/core/test_self_user_routes.py b/tests/core/test_self_user_routes.py index 1d4756d..3a952b8 100644 --- a/tests/core/test_self_user_routes.py +++ b/tests/core/test_self_user_routes.py @@ -78,6 +78,54 @@ def test_users_me_edit_context_respects_visible_sections(app, user_db): assert resp.json["userOverridableKeys"] == ["DESTINATION"] +def test_users_me_edit_context_includes_search_preferences_when_visible(app, user_db): + user = user_db.create_user(username="alice") + client = _authed_client_for_user(app, user) + + def build_preferences(_user_db, _user_id, tab_name): + payloads = { + "downloads": { + "tab": "downloads", + "keys": ["DESTINATION"], + "fields": [], + "globalValues": {}, + "userOverrides": {}, + "effective": {}, + }, + "search_mode": { + "tab": "search_mode", + "keys": ["SEARCH_MODE", "METADATA_PROVIDER"], + "fields": [], + "globalValues": {}, + "userOverrides": {}, + "effective": {}, + }, + } + if tab_name not in payloads: + raise AssertionError(f"Unexpected tab requested: {tab_name}") + return payloads[tab_name] + + with patch("shelfmark.core.self_user_routes._get_auth_mode", return_value="builtin"): + with patch( + "shelfmark.core.self_user_routes.load_config_file", + side_effect=lambda tab_name: { + "VISIBLE_SELF_SETTINGS_SECTIONS": ["delivery", "search"] + } if tab_name == "users" else {}, + ): + with patch( + "shelfmark.core.self_user_routes._build_user_preferences_payload", + side_effect=build_preferences, + ): + resp = client.get("/api/users/me/edit-context") + + assert resp.status_code == 200 + assert resp.json["visibleUserSettingsSections"] == ["delivery", "search"] + assert resp.json["deliveryPreferences"]["tab"] == "downloads" + assert resp.json["searchPreferences"]["tab"] == "search_mode" + assert resp.json["notificationPreferences"] is None + assert resp.json["userOverridableKeys"] == ["DESTINATION", "METADATA_PROVIDER", "SEARCH_MODE"] + + def test_users_me_update_rejects_hidden_section_settings(app, user_db): user = user_db.create_user(username="alice") client = _authed_client_for_user(app, user) diff --git a/tests/download/test_orchestrator_retry.py b/tests/download/test_orchestrator_retry.py new file mode 100644 index 0000000..2af3c8f --- /dev/null +++ b/tests/download/test_orchestrator_retry.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from threading import Event +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from shelfmark.core.models import DownloadTask, QueueStatus +from shelfmark.core.queue import BookQueue + + +def test_retry_download_requeues_error_task(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + task = DownloadTask( + task_id="task-1", + source="direct_download", + title="Retryable", + last_error_message="Timeout", + last_error_type="TimeoutError", + ) + + mock_queue = MagicMock() + mock_queue.get_task.return_value = task + mock_queue.get_task_status.return_value = QueueStatus.ERROR + mock_queue.enqueue_existing.return_value = True + monkeypatch.setattr(orchestrator, "book_queue", mock_queue) + monkeypatch.setattr(orchestrator, "ws_manager", None) + + ok, error = orchestrator.retry_download("task-1") + + assert ok is True + assert error is None + assert task.last_error_message is None + assert task.last_error_type is None + assert task.priority == -10 + mock_queue.enqueue_existing.assert_called_once_with("task-1", priority=-10) + mock_queue.update_status_message.assert_called_once_with("task-1", "Retrying now") + + +def test_retry_download_rejects_request_linked_tasks(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + task = DownloadTask( + task_id="task-2", + source="prowlarr", + title="Request linked", + request_id=123, + ) + + mock_queue = MagicMock() + mock_queue.get_task.return_value = task + mock_queue.get_task_status.return_value = QueueStatus.ERROR + monkeypatch.setattr(orchestrator, "book_queue", mock_queue) + monkeypatch.setattr(orchestrator, "ws_manager", None) + + ok, error = orchestrator.retry_download("task-2") + + assert ok is False + assert error == "Request-linked downloads must be retried from requests" + mock_queue.enqueue_existing.assert_not_called() + + +def test_finalize_download_failure_sets_terminal_error(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + task = DownloadTask( + task_id="task-3", + source="direct_download", + title="Terminal failure", + last_error_message="Download timed out", + last_error_type="TimeoutError", + ) + + mock_queue = MagicMock() + mock_queue.get_task.return_value = task + monkeypatch.setattr(orchestrator, "book_queue", mock_queue) + + orchestrator._finalize_download_failure("task-3") + + mock_queue.update_status_message.assert_called_once_with("task-3", "Download timed out") + mock_queue.update_status.assert_called_once_with("task-3", QueueStatus.ERROR) + + +def test_finalize_download_failure_uses_fallback_message(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + task = DownloadTask( + task_id="task-4", + source="direct_download", + title="No message", + last_error_type="TimeoutError", + ) + + mock_queue = MagicMock() + mock_queue.get_task.return_value = task + monkeypatch.setattr(orchestrator, "book_queue", mock_queue) + + orchestrator._finalize_download_failure("task-4") + + mock_queue.update_status_message.assert_called_once_with( + "task-4", "Download failed: TimeoutError" + ) + mock_queue.update_status.assert_called_once_with("task-4", QueueStatus.ERROR) + + +def test_callback_error_results_in_terminal_error(monkeypatch): + """When a handler signals error via status_callback, it should result in terminal ERROR.""" + import shelfmark.download.orchestrator as orchestrator + + task = DownloadTask( + task_id="task-callback-1", + source="direct_download", + title="Callback Error", + ) + + queue = BookQueue() + queue.add(task) + terminal_calls: list[tuple[str, QueueStatus]] = [] + queue.set_terminal_status_hook( + lambda task_id, status, _task: terminal_calls.append((task_id, status)) + ) + + handler = MagicMock() + + def _download(_task, _cancel_flag, _progress_callback, status_callback): + status_callback("error", "Download timed out") + return None + + handler.download.side_effect = _download + handler.post_process_cleanup = MagicMock() + + monkeypatch.setattr(orchestrator, "book_queue", queue) + monkeypatch.setattr(orchestrator, "get_handler", lambda _source: handler) + monkeypatch.setattr(orchestrator, "ws_manager", None) + + orchestrator._process_single_download(task.task_id, Event()) + + assert task.last_error_message == "Download timed out" + assert task.last_error_type == "StatusCallbackError" + assert queue.get_task_status(task.task_id) == QueueStatus.ERROR + assert len(terminal_calls) == 1 + assert terminal_calls[0] == (task.task_id, QueueStatus.ERROR) + + +def test_output_stage_retry_skips_redownload_when_staged_file_exists(monkeypatch, tmp_path): + import shelfmark.download.orchestrator as orchestrator + + staged_file = tmp_path / "staged.epub" + staged_file.write_text("staged") + + task = DownloadTask( + task_id="task-staged-1", + source="direct_download", + title="Reuse Staged File", + staged_path=str(staged_file), + ) + + handler = MagicMock() + handler.download = MagicMock(return_value=str(tmp_path / "should-not-download.epub")) + handler.post_process_cleanup = MagicMock() + + mock_queue = MagicMock() + mock_queue.get_task.return_value = task + monkeypatch.setattr(orchestrator, "book_queue", mock_queue) + monkeypatch.setattr(orchestrator, "get_handler", lambda _source: handler) + + seen_temp_files: list[Path] = [] + + def _post_process(temp_file, *_args, **_kwargs): + seen_temp_files.append(temp_file) + return "folder://done" + + monkeypatch.setattr(orchestrator, "post_process_download", _post_process) + + result = orchestrator._download_task(task.task_id, Event()) + + assert result == "folder://done" + handler.download.assert_not_called() + assert seen_temp_files == [staged_file] + assert task.staged_path is None + + +def test_output_stage_retry_falls_back_to_download_when_staged_file_missing(monkeypatch, tmp_path): + import shelfmark.download.orchestrator as orchestrator + + missing_staged_file = tmp_path / "missing.epub" + downloaded_file = tmp_path / "downloaded.epub" + downloaded_file.write_text("downloaded") + + task = DownloadTask( + task_id="task-staged-2", + source="direct_download", + title="Fallback Download", + staged_path=str(missing_staged_file), + ) + + handler = MagicMock() + handler.download = MagicMock(return_value=str(downloaded_file)) + handler.post_process_cleanup = MagicMock() + + mock_queue = MagicMock() + mock_queue.get_task.return_value = task + monkeypatch.setattr(orchestrator, "book_queue", mock_queue) + monkeypatch.setattr(orchestrator, "get_handler", lambda _source: handler) + + seen_temp_files: list[Path] = [] + + def _post_process(temp_file, *_args, **_kwargs): + seen_temp_files.append(temp_file) + return "folder://done" + + monkeypatch.setattr(orchestrator, "post_process_download", _post_process) + + result = orchestrator._download_task(task.task_id, Event()) + + assert result == "folder://done" + handler.download.assert_called_once() + assert seen_temp_files == [downloaded_file] + assert task.staged_path is None