mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 18:31:43 +01:00
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
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<int:user_id>/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/<int:user_id>/notification-preferences", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_notification_preferences(user_id):
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -14,6 +14,7 @@ logger = setup_logger(__name__)
|
||||
# e.g., "SeriesPosition" must match before "Series"
|
||||
KNOWN_TOKENS = [
|
||||
'seriesposition',
|
||||
'originalname',
|
||||
'partnumber',
|
||||
'subtitle',
|
||||
'author',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+129
-6
@@ -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/<path:book_id>/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/<path:book_id>/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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+519
-433
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+234
-97
@@ -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<CreateRequestPayload | null>(null);
|
||||
const [actingAsUser, setActingAsUser] = useState<ActingAsUserSelection | null>(null);
|
||||
const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState<PendingOnBehalfDownload | null>(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<Book | null>(null);
|
||||
const [releaseBook, setReleaseBook] = useState<Book | null>(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<void> => {
|
||||
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<void> => {
|
||||
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<boolean> => {
|
||||
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<void> => {
|
||||
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 = (
|
||||
<SearchModeProvider searchMode={searchMode}>
|
||||
<div ref={headerRef} className="fixed top-0 left-0 right-0 z-40">
|
||||
@@ -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 && (
|
||||
<OnBehalfConfirmationModal
|
||||
isOpen={Boolean(pendingOnBehalfDownload)}
|
||||
actingAsName={pendingOnBehalfUserName}
|
||||
itemTitle={pendingOnBehalfTitle}
|
||||
onConfirm={handleConfirmOnBehalfDownload}
|
||||
onClose={() => setPendingOnBehalfDownload(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
<div className={usePinnedMainScrollContainer ? 'mt-auto' : undefined}>
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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<HeaderHandle, HeaderProps>(({
|
||||
isAuthenticated = false,
|
||||
username,
|
||||
displayName,
|
||||
actingAsUser = null,
|
||||
onActingAsUserChange,
|
||||
onLogout,
|
||||
onShowToast,
|
||||
onRemoveToast,
|
||||
@@ -76,6 +83,59 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [shouldAnimateIn, setShouldAnimateIn] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [adminUsers, setAdminUsers] = useState<ActingAsUserSelection[]>([]);
|
||||
const [isAdminUsersLoading, setIsAdminUsersLoading] = useState(false);
|
||||
const [adminUsersError, setAdminUsersError] = useState<string | null>(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<HeaderHandle, HeaderProps>(({
|
||||
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<HeaderHandle, HeaderProps>(({
|
||||
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<HeaderHandle, HeaderProps>(({
|
||||
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<HeaderHandle, HeaderProps>(({
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
{actingAsUser && (
|
||||
<span
|
||||
className="absolute top-1 right-1 h-2 w-2 rounded-full bg-sky-500 border border-[var(--bg)]"
|
||||
title={`Downloading as ${formatActingAsUserName(actingAsUser)}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{(isDropdownOpen || isClosing) && (
|
||||
<div
|
||||
className={`absolute right-0 mt-2 w-48 rounded-lg shadow-lg border z-50 ${
|
||||
className={`absolute right-0 mt-2 ${dropdownPanelWidthClass} rounded-lg shadow-lg border z-50 ${
|
||||
isClosing ? 'animate-fade-out-up' : shouldAnimateIn ? 'animate-fade-in-down' : ''
|
||||
}`}
|
||||
style={{
|
||||
@@ -441,6 +561,44 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && onActingAsUserChange && (
|
||||
<div
|
||||
className="border-t px-4 py-3 space-y-2"
|
||||
style={{ borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
<div className="text-xs font-medium uppercase tracking-wide opacity-70">
|
||||
Download as
|
||||
</div>
|
||||
<div className={isAdminUsersLoading ? 'pointer-events-none opacity-60' : ''}>
|
||||
<DropdownList
|
||||
options={actingAsOptions}
|
||||
value={selectedActingAsValue}
|
||||
onChange={handleActingAsChange}
|
||||
placeholder="Myself"
|
||||
widthClassName="w-full"
|
||||
buttonClassName="rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
{isAdminUsersLoading && (
|
||||
<div className="text-xs opacity-70">Loading users...</div>
|
||||
)}
|
||||
{adminUsersError && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
{adminUsersError}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadAdminUsers()}
|
||||
className="text-xs font-medium text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface OnBehalfConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
actingAsName: string;
|
||||
itemTitle: string;
|
||||
onConfirm: () => Promise<boolean>;
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`relative w-full max-w-lg rounded-xl border border-[var(--border-muted)] shadow-2xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
|
||||
<h3 id={titleId} className="text-lg font-semibold">
|
||||
Download as {actingAsName}?
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Close download confirmation"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3 px-6 py-5">
|
||||
<p className="text-sm opacity-90">
|
||||
This download will use {actingAsName}'s output preferences and destination settings.
|
||||
</p>
|
||||
<div className="rounded-xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide opacity-60">Title</p>
|
||||
<p className="text-sm font-medium mt-1 break-words">{itemTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={confirmDisabled}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? 'Queuing...' : 'Confirm'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<Record<string, SortState | null>>({});
|
||||
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 = ({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 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) && (
|
||||
<Dropdown
|
||||
align="right"
|
||||
widthClassName="w-auto flex-shrink-0"
|
||||
@@ -1562,6 +1575,7 @@ export const ReleaseModal = ({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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 = ({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Format priority sort sub-menu */}
|
||||
{availableFormats.length > 1 && (
|
||||
<>
|
||||
{allSortOptions.length > 0 && (
|
||||
<div className="mx-2 my-1 border-t border-gray-200 dark:border-gray-700" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormatSortExpanded(prev => !prev)}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
|
||||
currentSort?.key === FORMAT_SORT_KEY
|
||||
? 'text-emerald-600 dark:text-emerald-400 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
Format{currentSort?.key === FORMAT_SORT_KEY && currentSort.value ? ` (${currentSort.value.toUpperCase()})` : ''}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${formatSortExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{formatSortExpanded && availableFormats.map((fmt) => {
|
||||
const isSelected = currentSort?.key === FORMAT_SORT_KEY && currentSort.value === fmt;
|
||||
const direction = isSelected ? currentSort?.direction : null;
|
||||
return (
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleSortChange(FORMAT_SORT_KEY, 'asc', fmt);
|
||||
if (!isSelected) close();
|
||||
}}
|
||||
className={`w-full pl-6 pr-3 py-1.5 text-left text-sm flex items-center justify-between hover-surface rounded ${
|
||||
isSelected
|
||||
? 'text-emerald-600 dark:text-emerald-400 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span>{fmt.toUpperCase()}</span>
|
||||
{isSelected && direction && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
{direction === 'asc' ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -38,7 +38,6 @@ export interface ActivityItem {
|
||||
|
||||
downloadBookId?: string;
|
||||
downloadPath?: string;
|
||||
|
||||
requestId?: number;
|
||||
requestLevel?: 'book' | 'release';
|
||||
requestNote?: string;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
@@ -56,6 +62,7 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [originalUser, setOriginalUser] = useState<AdminUser | null>(null);
|
||||
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
|
||||
const [searchPreferences, setSearchPreferences] = useState<DeliveryPreferencesResponse | null>(null);
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<DeliveryPreferencesResponse | null>(null);
|
||||
const [visibleSections, setVisibleSections] = useState<UserOverrideSectionId[]>(
|
||||
DEFAULT_SELF_USER_OVERRIDE_SECTIONS
|
||||
@@ -67,8 +74,8 @@ export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettings
|
||||
const [themeValue, setThemeValue] = useState<string>(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}
|
||||
|
||||
@@ -54,6 +54,7 @@ interface SettingsContentProps {
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
onRefreshOverrideSummary?: () => void;
|
||||
onRefreshAuth?: () => Promise<void>;
|
||||
onSettingsSaved?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,6 +410,7 @@ export const SettingsContent = ({
|
||||
onShowToast: customFieldContext?.onShowToast,
|
||||
onRefreshOverrideSummary: customFieldContext?.onRefreshOverrideSummary,
|
||||
onRefreshAuth: customFieldContext?.onRefreshAuth,
|
||||
onSettingsSaved: customFieldContext?.onSettingsSaved,
|
||||
})
|
||||
: renderField(
|
||||
field,
|
||||
|
||||
@@ -303,6 +303,7 @@ export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettin
|
||||
onShowToast,
|
||||
onRefreshOverrideSummary: handleRefreshCurrentTabOverrideSummary,
|
||||
onRefreshAuth,
|
||||
onSettingsSaved,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CustomSettingsFieldRendererProps {
|
||||
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
|
||||
onRefreshOverrideSummary?: () => void;
|
||||
onRefreshAuth?: () => Promise<void>;
|
||||
onSettingsSaved?: () => void;
|
||||
}
|
||||
|
||||
export interface CustomSettingsFieldLayout {
|
||||
|
||||
@@ -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<UserOverrideSectionId>(USER_OVERRID
|
||||
|
||||
const USER_OVERRIDE_SECTION_META: Record<UserOverrideSectionId, UserOverrideSectionDefinition> = {
|
||||
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: (
|
||||
<UserSearchPreferencesSection
|
||||
searchPreferences={searchPreferences}
|
||||
isUserOverridable={isUserOverridable}
|
||||
userSettings={userSettings}
|
||||
setUserSettings={setUserSettings}
|
||||
/>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usersTab || !globalUsersSettingsValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 = ({
|
||||
<UserOverridesSections
|
||||
scope="admin"
|
||||
deliveryPreferences={deliveryPreferences}
|
||||
searchPreferences={searchPreferences}
|
||||
notificationPreferences={notificationPreferences}
|
||||
isUserOverridable={isUserOverridable}
|
||||
userSettings={userSettings}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { DeliveryPreferencesResponse } from '../../../services/api';
|
||||
import { HeadingFieldConfig, SelectFieldConfig } from '../../../types/settings';
|
||||
import { HeadingField, SelectField } from '../fields';
|
||||
import { FieldWrapper } from '../shared';
|
||||
import { getFieldByKey } from './fieldHelpers';
|
||||
import { PerUserSettings } from './types';
|
||||
|
||||
interface UserSearchPreferencesSectionProps {
|
||||
searchPreferences: DeliveryPreferencesResponse | null;
|
||||
isUserOverridable: (key: keyof PerUserSettings) => 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<SelectFieldConfig>(
|
||||
fields,
|
||||
'SEARCH_MODE',
|
||||
fallbackSearchModeField
|
||||
);
|
||||
const metadataProviderField = getFieldByKey<SelectFieldConfig>(
|
||||
fields,
|
||||
'METADATA_PROVIDER',
|
||||
fallbackMetadataProviderField
|
||||
);
|
||||
const metadataProviderAudiobookField = getFieldByKey<SelectFieldConfig>(
|
||||
fields,
|
||||
'METADATA_PROVIDER_AUDIOBOOK',
|
||||
fallbackAudiobookMetadataProviderField
|
||||
);
|
||||
const defaultReleaseSourceField = getFieldByKey<SelectFieldConfig>(
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<HeadingField field={searchHeading} />
|
||||
|
||||
{canOverrideSearchMode && (
|
||||
<FieldWrapper
|
||||
field={searchModeField}
|
||||
resetAction={
|
||||
isOverridden('SEARCH_MODE')
|
||||
? {
|
||||
disabled: Boolean(searchModeField.fromEnv),
|
||||
onClick: () => resetKeys(['SEARCH_MODE']),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
field={searchModeField}
|
||||
value={searchModeValue}
|
||||
onChange={(value) => setUserSettings((prev) => ({ ...prev, SEARCH_MODE: value }))}
|
||||
disabled={Boolean(searchModeField.fromEnv)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
|
||||
{effectiveSearchMode === 'universal' && canOverrideMetadataProvider && (
|
||||
<FieldWrapper
|
||||
field={metadataProviderField}
|
||||
resetAction={
|
||||
isOverridden('METADATA_PROVIDER')
|
||||
? {
|
||||
disabled: Boolean(metadataProviderField.fromEnv),
|
||||
onClick: () => resetKeys(['METADATA_PROVIDER']),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
field={metadataProviderField}
|
||||
value={metadataProviderValue}
|
||||
onChange={(value) => setUserSettings((prev) => ({ ...prev, METADATA_PROVIDER: value }))}
|
||||
disabled={Boolean(metadataProviderField.fromEnv)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
|
||||
{effectiveSearchMode === 'universal' && canOverrideAudiobookMetadataProvider && (
|
||||
<FieldWrapper
|
||||
field={metadataProviderAudiobookField}
|
||||
resetAction={
|
||||
isOverridden('METADATA_PROVIDER_AUDIOBOOK')
|
||||
? {
|
||||
disabled: Boolean(metadataProviderAudiobookField.fromEnv),
|
||||
onClick: () => resetKeys(['METADATA_PROVIDER_AUDIOBOOK']),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
field={metadataProviderAudiobookField}
|
||||
value={metadataProviderAudiobookValue}
|
||||
onChange={(value) => setUserSettings((prev) => ({ ...prev, METADATA_PROVIDER_AUDIOBOOK: value }))}
|
||||
disabled={Boolean(metadataProviderAudiobookField.fromEnv)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
|
||||
{effectiveSearchMode === 'universal' && canOverrideDefaultReleaseSource && (
|
||||
<FieldWrapper
|
||||
field={defaultReleaseSourceField}
|
||||
resetAction={
|
||||
isOverridden('DEFAULT_RELEASE_SOURCE')
|
||||
? {
|
||||
disabled: Boolean(defaultReleaseSourceField.fromEnv),
|
||||
onClick: () => resetKeys(['DEFAULT_RELEASE_SOURCE']),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
field={defaultReleaseSourceField}
|
||||
value={defaultReleaseSourceValue}
|
||||
onChange={(value) => setUserSettings((prev) => ({ ...prev, DEFAULT_RELEASE_SOURCE: value }))}
|
||||
disabled={Boolean(defaultReleaseSourceField.fromEnv)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<Record<string, unknown>>;
|
||||
REQUESTS_ENABLED?: boolean;
|
||||
REQUEST_POLICY_DEFAULT_EBOOK?: string;
|
||||
|
||||
@@ -11,10 +11,11 @@ export const useUserForm = () => {
|
||||
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
|
||||
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
|
||||
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
|
||||
const [searchPreferences, setSearchPreferences] = useState<DeliveryPreferencesResponse | null>(null);
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<DeliveryPreferencesResponse | null>(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,
|
||||
|
||||
@@ -23,6 +23,7 @@ interface UseUserMutationsParams {
|
||||
userSettings: PerUserSettings;
|
||||
userOverridableSettings: Set<string>;
|
||||
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<Pick<AdminUser, 'role' | 'email' | 'display_name'>> & {
|
||||
|
||||
@@ -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<string>;
|
||||
@@ -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<string>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<void> => {
|
||||
export const downloadBook = async (id: string, onBehalfOfUserId?: number): Promise<void> => {
|
||||
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<void> => {
|
||||
};
|
||||
|
||||
export const downloadRelease = async (
|
||||
release: DownloadReleasePayload,
|
||||
onBehalfOfUserId?: number
|
||||
): Promise<void> => {
|
||||
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<void> => {
|
||||
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const retryDownload = async (id: string): Promise<void> => {
|
||||
await fetchJSON(`${API.retryDownload}/${encodeURIComponent(id)}/retry`, { method: 'POST' });
|
||||
};
|
||||
|
||||
export const clearCompleted = async (): Promise<void> => {
|
||||
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<DeliveryPreferencesResponse>(`${API_BASE}/admin/users/${userId}/delivery-preferences`);
|
||||
};
|
||||
|
||||
export const getAdminSearchPreferences = async (
|
||||
userId: number
|
||||
): Promise<DeliveryPreferencesResponse> => {
|
||||
return fetchJSON<DeliveryPreferencesResponse>(`${API_BASE}/admin/users/${userId}/search-preferences`);
|
||||
};
|
||||
|
||||
export const getAdminNotificationPreferences = async (
|
||||
userId: number
|
||||
): Promise<DeliveryPreferencesResponse> => {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ActingAsUserSelection } from '../types';
|
||||
|
||||
export const formatActingAsUserName = (user: ActingAsUserSelection): string => {
|
||||
return user.displayName || user.username;
|
||||
};
|
||||
@@ -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[],
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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/<id>/search-preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdminSearchPreferences:
|
||||
"""Tests for GET /api/admin/users/<id>/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/<id>/notification-preferences
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user