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:
Alex
2026-03-01 19:47:57 +00:00
committed by GitHub
parent ea0d06ae08
commit 9593c040b0
52 changed files with 3288 additions and 658 deletions
+10 -6
View File
@@ -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"},
+70
View File
@@ -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}
+9 -2
View File
@@ -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
+28
View File
@@ -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):
+3
View File
@@ -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)."""
+1
View File
@@ -14,6 +14,7 @@ logger = setup_logger(__name__)
# e.g., "SeriesPosition" must match before "Series"
KNOWN_TOKENS = [
'seriesposition',
'originalname',
'partnumber',
'subtitle',
'author',
+40
View File
@@ -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:
+19
View File
@@ -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,
+147 -36
View File
@@ -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)
+1 -1
View File
@@ -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)
+19 -3
View File
@@ -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,
)
+19 -3
View File
@@ -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,
)
+10 -7
View File
@@ -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(
+8 -3
View File
@@ -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):
+15 -2
View File
@@ -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,
)
+15 -5
View File
@@ -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
View File
@@ -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",
+25 -13
View File
@@ -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:
+519 -433
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
@@ -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 */}
+161 -3
View File
@@ -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}&apos;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>
);
};
+93 -17
View File
@@ -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,
+29 -4
View File
@@ -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> => {
+6
View File
@@ -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 & {
+5
View File
@@ -0,0 +1,5 @@
import type { ActingAsUserSelection } from '../types';
export const formatActingAsUserName = (user: ActingAsUserSelection): string => {
return user.displayName || user.username;
};
+22
View File
@@ -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[],
+40 -1
View File
@@ -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"]
+91
View File
@@ -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
# ---------------------------------------------------------------------------
+426
View File
@@ -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)
+13
View File
@@ -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
+195 -3
View File
@@ -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
+48
View File
@@ -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)
+221
View File
@@ -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