diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 5e59e738..9c1794bc 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -468,6 +468,14 @@ def search_mode_settings(): ), default=True, ), + CheckboxField( + key="SHOW_COMBINED_SELECTOR", + label="Show Combined Download Selector", + description="Show the option to search for and download both a book and audiobook together.", + default=True, + show_when={"field": "SEARCH_MODE", "value": "universal"}, + user_overridable=True, + ), HeadingField( key="universal_mode_heading", title="Universal Mode Settings", @@ -492,6 +500,15 @@ def search_mode_settings(): show_when={"field": "SEARCH_MODE", "value": "universal"}, user_overridable=True, ), + SelectField( + key="METADATA_PROVIDER_COMBINED", + label="Combined Mode Metadata Provider", + description="Metadata provider for combined mode searches. Uses the book provider if not set.", + 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", label="Default Book Release Source", diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index 8b7f46ac..6fc96168 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -72,11 +72,12 @@ _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_PROVIDER_KEYS = {"METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK", "METADATA_PROVIDER_COMBINED"} _SEARCH_PREFERENCE_VALIDATABLE_KEYS = { "SEARCH_MODE", "DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", + "SHOW_COMBINED_SELECTOR", *_SEARCH_PREFERENCE_PROVIDER_KEYS, } @@ -215,6 +216,11 @@ def validate_search_preference_value(key: str, value: Any) -> tuple[Any, str | N ) return normalized_value, None + if key == "SHOW_COMBINED_SELECTOR": + if isinstance(value, bool): + return value, None + return bool(value), None + return value, None diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py index 3f0e13a9..133df309 100644 --- a/shelfmark/core/request_routes.py +++ b/shelfmark/core/request_routes.py @@ -22,6 +22,7 @@ from shelfmark.core.requests_service import ( RequestServiceError, cancel_request, create_request, + create_requests, fulfil_request, reject_request, ) @@ -231,6 +232,158 @@ def _format_requester_label(user_db: UserDB, request_row: dict[str, Any]) -> str return _format_user_label(None, user_id) +def _resolve_request_user_context( + user_db: UserDB, + *, + actor_user_id: int, + actor_username: str | None, + on_behalf_of_user_id: Any, +) -> tuple[int, str | None, str]: + if on_behalf_of_user_id in (None, ""): + actor_label = _format_user_label(actor_username, actor_user_id) + return actor_user_id, actor_username, actor_label + + if not session.get("is_admin", False): + raise RequestServiceError("Admin required", status_code=403) + + try: + target_user_id = int(on_behalf_of_user_id) + except (TypeError, ValueError) as exc: + raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400) from exc + + if target_user_id <= 0: + raise RequestServiceError("Invalid on_behalf_of_user_id", status_code=400) + + target_user = user_db.get_user(user_id=target_user_id) + if not target_user: + raise RequestServiceError("User not found", status_code=404) + + target_username = normalize_optional_text(target_user.get("username")) + actor_label = _format_user_label(actor_username, actor_user_id) + target_label = _format_user_label(target_username, target_user_id) + return target_user_id, target_username, f"{actor_label} on behalf of {target_label}" + + +def _prepare_request_create_arguments( + user_db: UserDB, + data: dict[str, Any], +) -> dict[str, Any]: + db_user_id, db_gate = _require_db_user_id() + if db_gate is not None or db_user_id is None: + raise RequestServiceError( + "User identity is unavailable for request workflow", + status_code=403, + code="user_identity_unavailable", + ) + + actor_username = normalize_optional_text(session.get("user_id")) + target_user_id, _, actor_label = _resolve_request_user_context( + user_db, + actor_user_id=db_user_id, + actor_username=actor_username, + on_behalf_of_user_id=data.get("on_behalf_of_user_id"), + ) + + context = data.get("context") or {} + if not isinstance(context, dict): + raise RequestServiceError("context must be an object", status_code=400) + + source = normalize_source(context.get("source")) + release_data = data.get("release_data") + request_level = context.get("request_level") + if request_level is None: + request_level = "book" if release_data is None else "release" + + book_data = data.get("book_data") + if not isinstance(book_data, dict): + raise RequestServiceError("book_data must be an object", status_code=400) + request_title = _resolve_title_from_book_data(book_data) + + content_type = normalize_content_type( + context.get("content_type") + or data.get("content_type") + or book_data.get("content_type") + ) + request_level, release_data = _normalize_release_result_request_payload( + source=source, + request_level=request_level, + book_data=book_data, + release_data=release_data, + content_type=content_type, + ) + + global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy( + user_db, + db_user_id=target_user_id, + ) + if not requests_enabled: + raise RequestServiceError( + "Request workflow is disabled by policy", + status_code=403, + code="requests_unavailable", + ) + + max_pending = coerce_int( + effective.get("MAX_PENDING_REQUESTS_PER_USER"), + default=20, + ) + if max_pending < 1: + max_pending = 1 + if max_pending > 1000: + max_pending = 1000 + allow_notes = coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True) + note_value = data.get("note") if allow_notes else None + + resolved_mode = resolve_policy_mode( + source=source, + content_type=content_type, + global_settings=global_settings, + user_settings=user_settings, + ) + logger.debug( + "request create policy actor=%s target_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s", + session.get("user_id"), + target_user_id, + source, + content_type, + request_level, + resolved_mode.value, + ) + + if resolved_mode == PolicyMode.BLOCKED: + raise RequestServiceError( + "Requesting is blocked by policy", + status_code=403, + code="policy_blocked", + required_mode=PolicyMode.BLOCKED.value, + ) + + requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else "" + if resolved_mode == PolicyMode.REQUEST_BOOK and requested_level != "book": + raise RequestServiceError( + "Policy requires book-level requests", + status_code=403, + code="policy_requires_request", + required_mode=PolicyMode.REQUEST_BOOK.value, + ) + + return { + "create_args": { + "user_id": target_user_id, + "source_hint": source, + "content_type": content_type, + "request_level": request_level, + "policy_mode": resolved_mode.value, + "book_data": book_data, + "release_data": release_data, + "note": note_value, + "max_pending_per_user": max_pending, + }, + "actor_label": actor_label, + "request_title": request_title, + } + + def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str, str | None]: release_data = request_row.get("release_data") if isinstance(release_data, dict): @@ -385,130 +538,20 @@ def register_request_routes( if auth_gate is not None: return auth_gate - db_user_id, db_gate = _require_db_user_id() - if db_gate is not None or db_user_id is None: - return db_gate - actor_username = normalize_optional_text(session.get("user_id")) - actor_label = _format_user_label(actor_username, db_user_id) - data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify({"error": "No data provided"}), 400 - context = data.get("context") or {} - if not isinstance(context, dict): - return jsonify({"error": "context must be an object"}), 400 - - source = normalize_source(context.get("source")) - release_data = data.get("release_data") - request_level = context.get("request_level") - if request_level is None: - request_level = "book" if release_data is None else "release" - - book_data = data.get("book_data") - if not isinstance(book_data, dict): - return jsonify({"error": "book_data must be an object"}), 400 - request_title = _resolve_title_from_book_data(book_data) - - content_type = normalize_content_type( - context.get("content_type") - or data.get("content_type") - or book_data.get("content_type") - ) - request_level, release_data = _normalize_release_result_request_payload( - source=source, - request_level=request_level, - book_data=book_data, - release_data=release_data, - content_type=content_type, - ) - - global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy( - user_db, - db_user_id=db_user_id, - ) - if not requests_enabled: - logger.debug( - "Request not created for '%s' by %s: requests are disabled", - request_title, - actor_label, - ) - return _error_response( - "Request workflow is disabled by policy", - 403, - code="requests_unavailable", - ) - - max_pending = coerce_int( - effective.get("MAX_PENDING_REQUESTS_PER_USER"), - default=20, - ) - if max_pending < 1: - max_pending = 1 - if max_pending > 1000: - max_pending = 1000 - allow_notes = coerce_bool(effective.get("REQUESTS_ALLOW_NOTES"), default=True) - note_value = data.get("note") if allow_notes else None - - resolved_mode = resolve_policy_mode( - source=source, - content_type=content_type, - global_settings=global_settings, - user_settings=user_settings, - ) - logger.debug( - "request create policy user=%s db_user_id=%s source=%s content_type=%s request_level=%s resolved_mode=%s", - session.get("user_id"), - db_user_id, - source, - content_type, - request_level, - resolved_mode.value, - ) - - if resolved_mode == PolicyMode.BLOCKED: - logger.debug( - "Request blocked by policy for '%s' by %s", - request_title, - actor_label, - ) - return _error_response( - "Requesting is blocked by policy", - 403, - code="policy_blocked", - required_mode=PolicyMode.BLOCKED.value, - ) - - if resolved_mode == PolicyMode.REQUEST_BOOK: - requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else "" - if requested_level != "book": - logger.debug( - "Request not created for '%s' by %s: policy requires book-level requests", - request_title, - actor_label, - ) - return _error_response( - "Policy requires book-level requests", - 403, - code="policy_requires_request", - required_mode=PolicyMode.REQUEST_BOOK.value, - ) - try: - created = create_request( - user_db, - user_id=db_user_id, - source_hint=source, - content_type=content_type, - request_level=request_level, - policy_mode=resolved_mode.value, - book_data=book_data, - release_data=release_data, - note=note_value, - max_pending_per_user=max_pending, - ) + prepared = _prepare_request_create_arguments(user_db, data) + created = create_request(user_db, **prepared["create_args"]) except RequestServiceError as exc: - return _error_response(str(exc), exc.status_code, code=exc.code) + return _error_response( + str(exc), + exc.status_code, + code=exc.code, + required_mode=exc.required_mode, + ) event_payload = { "request_id": created["id"], @@ -519,7 +562,7 @@ def register_request_routes( "Request created #%s for '%s' by %s", created["id"], event_payload["title"], - actor_label, + prepared["actor_label"], ) emit_ws_event( ws_manager, @@ -531,7 +574,7 @@ def register_request_routes( ws_manager, event_name="request_update", payload=event_payload, - room=f"user_{db_user_id}", + room=f"user_{created['user_id']}", ) _notify_admin_for_request_event( @@ -542,6 +585,69 @@ def register_request_routes( return jsonify(created), 201 + @app.route("/api/requests/batch", methods=["POST"]) + def api_create_requests_batch(): + auth_gate = _require_request_endpoints_available(resolve_auth_mode) + if auth_gate is not None: + return auth_gate + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "No data provided"}), 400 + + raw_requests = data.get("requests") + if not isinstance(raw_requests, list) or len(raw_requests) == 0: + return jsonify({"error": "requests must contain at least one request"}), 400 + + try: + prepared_requests = [ + _prepare_request_create_arguments(user_db, raw_request) + for raw_request in raw_requests + ] + created_rows = create_requests( + user_db, + requests=[prepared["create_args"] for prepared in prepared_requests], + ) + except RequestServiceError as exc: + return _error_response( + str(exc), + exc.status_code, + code=exc.code, + required_mode=exc.required_mode, + ) + + for created, prepared in zip(created_rows, prepared_requests): + event_payload = { + "request_id": created["id"], + "status": created["status"], + "title": _resolve_request_title(created), + } + logger.info( + "Request created #%s for '%s' by %s", + created["id"], + event_payload["title"], + prepared["actor_label"], + ) + emit_ws_event( + ws_manager, + event_name="new_request", + payload=event_payload, + room="admins", + ) + emit_ws_event( + ws_manager, + event_name="request_update", + payload=event_payload, + room=f"user_{created['user_id']}", + ) + _notify_admin_for_request_event( + user_db, + event=NotificationEvent.REQUEST_CREATED, + request_row=created, + ) + + return jsonify(created_rows), 201 + @app.route("/api/requests", methods=["GET"]) def api_list_requests(): auth_gate = _require_request_endpoints_available(resolve_auth_mode) diff --git a/shelfmark/core/requests_service.py b/shelfmark/core/requests_service.py index 6dfb7af4..6eea1ac6 100644 --- a/shelfmark/core/requests_service.py +++ b/shelfmark/core/requests_service.py @@ -37,10 +37,12 @@ class RequestServiceError(ValueError): *, status_code: int = 400, code: str | None = None, + required_mode: str | None = None, ): super().__init__(message) self.status_code = status_code self.code = code + self.required_mode = required_mode def _normalize_match_text(value: Any) -> str: @@ -132,6 +134,45 @@ def _normalize_admin_note(admin_note: Any) -> str | None: return admin_note.strip() or None +def _prepare_request_create( + *, + user_id: int, + source_hint: str | None, + content_type: Any, + request_level: Any, + policy_mode: Any, + book_data: Any, + release_data: Any = None, + note: Any = None, +) -> dict[str, Any]: + validated_book_data = _validate_book_data(book_data) + normalized_note = normalize_note(note) + normalized_content_type = normalize_content_type( + content_type or validated_book_data.get("content_type") + ) + validated_book_data["content_type"] = normalized_content_type + + try: + normalized_request_level = validate_request_level_payload(request_level, release_data) + normalized_policy_mode = normalize_policy_mode(policy_mode) + except ValueError as exc: + raise RequestServiceError(str(exc), status_code=400) from exc + + _validate_json_blob_size("book_data", validated_book_data) + _validate_json_blob_size("release_data", release_data) + + return { + "user_id": user_id, + "source_hint": source_hint, + "content_type": normalized_content_type, + "request_level": normalized_request_level, + "policy_mode": normalized_policy_mode, + "book_data": validated_book_data, + "release_data": release_data, + "note": normalized_note, + } + + def sync_delivery_states_from_queue_status( user_db: "UserDB", *, @@ -208,21 +249,16 @@ def create_request( max_pending_per_user: int | None = None, ) -> dict[str, Any]: """Create a pending request after service-level validation.""" - validated_book_data = _validate_book_data(book_data) - normalized_note = normalize_note(note) - normalized_content_type = normalize_content_type( - content_type or validated_book_data.get("content_type") + prepared_request = _prepare_request_create( + user_id=user_id, + source_hint=source_hint, + content_type=content_type, + request_level=request_level, + policy_mode=policy_mode, + book_data=book_data, + release_data=release_data, + note=note, ) - validated_book_data["content_type"] = normalized_content_type - - try: - normalized_request_level = validate_request_level_payload(request_level, release_data) - normalized_policy_mode = normalize_policy_mode(policy_mode) - except ValueError as exc: - raise RequestServiceError(str(exc), status_code=400) from exc - - _validate_json_blob_size("book_data", validated_book_data) - _validate_json_blob_size("release_data", release_data) if max_pending_per_user is not None: pending_count = user_db.count_user_pending_requests(user_id) @@ -236,9 +272,9 @@ def create_request( duplicate = _find_duplicate_pending_request( user_db, user_id=user_id, - title=_normalize_match_text(validated_book_data.get("title")), - author=_normalize_match_text(validated_book_data.get("author")), - content_type=normalized_content_type, + title=_normalize_match_text(prepared_request["book_data"].get("title")), + author=_normalize_match_text(prepared_request["book_data"].get("author")), + content_type=prepared_request["content_type"], ) if duplicate is not None: raise RequestServiceError( @@ -248,16 +284,85 @@ def create_request( ) try: - return user_db.create_request( + return user_db.create_request(**prepared_request) + except ValueError as exc: + raise RequestServiceError(str(exc), status_code=400) from exc + + +def create_requests( + user_db: "UserDB", + *, + requests: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Create multiple pending requests atomically after validation.""" + if not isinstance(requests, list) or len(requests) == 0: + raise RequestServiceError("requests must contain at least one request", status_code=400) + + prepared_requests: list[dict[str, Any]] = [] + pending_counts_by_user: dict[int, int] = {} + seen_request_keys: set[tuple[int, str, str, str]] = set() + + for request in requests: + if not isinstance(request, dict): + raise RequestServiceError("requests must contain objects", status_code=400) + + user_id = int(request["user_id"]) + prepared_request = _prepare_request_create( user_id=user_id, - source_hint=source_hint, - content_type=normalized_content_type, - request_level=normalized_request_level, - policy_mode=normalized_policy_mode, - book_data=validated_book_data, - release_data=release_data, - note=normalized_note, + source_hint=request.get("source_hint"), + content_type=request.get("content_type"), + request_level=request.get("request_level"), + policy_mode=request.get("policy_mode"), + book_data=request.get("book_data"), + release_data=request.get("release_data"), + note=request.get("note"), ) + + request_key = ( + user_id, + _normalize_match_text(prepared_request["book_data"].get("title")), + _normalize_match_text(prepared_request["book_data"].get("author")), + prepared_request["content_type"], + ) + if request_key in seen_request_keys: + raise RequestServiceError( + "Duplicate pending request exists for this title/author/content_type", + status_code=409, + code="duplicate_pending_request", + ) + seen_request_keys.add(request_key) + + max_pending_per_user = request.get("max_pending_per_user") + if max_pending_per_user is not None: + existing_pending = pending_counts_by_user.get(user_id) + if existing_pending is None: + existing_pending = user_db.count_user_pending_requests(user_id) + if existing_pending >= max_pending_per_user: + raise RequestServiceError( + "Maximum pending requests reached for this user", + status_code=409, + code="max_pending_reached", + ) + pending_counts_by_user[user_id] = existing_pending + 1 + + duplicate = _find_duplicate_pending_request( + user_db, + user_id=user_id, + title=request_key[1], + author=request_key[2], + content_type=request_key[3], + ) + if duplicate is not None: + raise RequestServiceError( + "Duplicate pending request exists for this title/author/content_type", + status_code=409, + code="duplicate_pending_request", + ) + + prepared_requests.append(prepared_request) + + try: + return user_db.create_requests(prepared_requests) except ValueError as exc: raise RequestServiceError(str(exc), status_code=400) from exc diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index 6e5d943d..0cf83c3d 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -470,6 +470,72 @@ class UserDB: payload[key] = None return payload + def _insert_request( + self, + conn: sqlite3.Connection, + *, + user_id: int, + content_type: str, + request_level: str, + policy_mode: str, + book_data: Dict[str, Any], + release_data: Optional[Dict[str, Any]] = None, + status: str = RequestStatus.PENDING, + source_hint: Optional[str] = None, + note: Optional[str] = None, + admin_note: Optional[str] = None, + reviewed_by: Optional[int] = None, + reviewed_at: Optional[str] = None, + delivery_state: str = DELIVERY_STATE_NONE, + delivery_updated_at: Optional[str] = None, + ) -> Dict[str, Any]: + cursor = conn.execute( + """ + INSERT INTO download_requests ( + user_id, + status, + delivery_state, + source_hint, + content_type, + request_level, + policy_mode, + book_data, + release_data, + note, + admin_note, + reviewed_by, + reviewed_at, + delivery_updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + status, + delivery_state, + source_hint, + content_type, + request_level, + policy_mode, + self._serialize_json(book_data, "book_data"), + self._serialize_json(release_data, "release_data"), + note, + admin_note, + reviewed_by, + reviewed_at, + delivery_updated_at, + ), + ) + request_id = cursor.lastrowid + row = conn.execute( + "SELECT * FROM download_requests WHERE id = ?", + (request_id,), + ).fetchone() + parsed = self._parse_request_row(row) + if parsed is None: + raise ValueError(f"Request {request_id} not found after creation") + return parsed + def create_request( self, *, @@ -504,53 +570,38 @@ class UserDB: with self._lock: conn = self._connect() try: - cursor = conn.execute( - """ - INSERT INTO download_requests ( - user_id, - status, - delivery_state, - source_hint, - content_type, - request_level, - policy_mode, - book_data, - release_data, - note, - admin_note, - reviewed_by, - reviewed_at, - delivery_updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - user_id, - normalized_status, - normalized_delivery_state, - source_hint, - content_type, - normalized_request_level, - normalized_policy_mode, - self._serialize_json(book_data, "book_data"), - self._serialize_json(release_data, "release_data"), - note, - admin_note, - reviewed_by, - reviewed_at, - delivery_updated_at, - ), + created = self._insert_request( + conn, + user_id=user_id, + content_type=content_type, + request_level=normalized_request_level, + policy_mode=normalized_policy_mode, + book_data=book_data, + release_data=release_data, + status=normalized_status, + source_hint=source_hint, + note=note, + admin_note=admin_note, + reviewed_by=reviewed_by, + reviewed_at=reviewed_at, + delivery_state=normalized_delivery_state, + delivery_updated_at=delivery_updated_at, ) conn.commit() - request_id = cursor.lastrowid - row = conn.execute( - "SELECT * FROM download_requests WHERE id = ?", - (request_id,), - ).fetchone() - parsed = self._parse_request_row(row) - if parsed is None: - raise ValueError(f"Request {request_id} not found after creation") - return parsed + return created + finally: + conn.close() + + def create_requests(self, requests: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Create multiple request rows atomically and return them in input order.""" + with self._lock: + conn = self._connect() + try: + created: List[Dict[str, Any]] = [] + for request in requests: + created.append(self._insert_request(conn, **request)) + conn.commit() + return created finally: conn.close() diff --git a/shelfmark/main.py b/shelfmark/main.py index 9dcbb40e..348c3fac 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1014,6 +1014,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "default_release_source": default_release_source, "default_release_source_audiobook": default_release_source_audiobook, "show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True), + "show_combined_selector": app_config.get("SHOW_COMBINED_SELECTOR", True, user_id=db_user_id), "books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"), "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), "hardcover_auto_remove_on_download": app_config.get("HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True), @@ -1982,6 +1983,11 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]: user_id=db_user_id, fallback_to_main=False, ) + configured_combined_metadata_provider = get_configured_provider_name( + content_type="combined", + user_id=db_user_id, + fallback_to_main=False, + ) providers = [] for info in list_providers(): enabled_key = f"{info['name'].upper()}_ENABLED" @@ -2006,6 +2012,7 @@ def api_metadata_providers() -> Union[Response, Tuple[Response, int]]: "providers": providers, "configured_provider": configured_metadata_provider or None, "configured_provider_audiobook": configured_audiobook_metadata_provider or None, + "configured_provider_combined": configured_combined_metadata_provider or None, }) except Exception as e: logger.error_trace(f"Metadata providers error: {e}") diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index 95beb538..8a808a91 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -528,6 +528,15 @@ def get_configured_provider_name( app_config.refresh() + if content_type == "combined": + combined_provider = app_config.get( + "METADATA_PROVIDER_COMBINED", + "", + user_id=user_id, + ) + if combined_provider or not fallback_to_main: + return combined_provider + if content_type == "audiobook": audiobook_provider = app_config.get( "METADATA_PROVIDER_AUDIOBOOK", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 994c61e7..a5901430 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -27,7 +27,7 @@ import { getStatus, getMetadataProviders, getMetadataSearchConfig, - createRequest, + createRequests, isApiResponseError, updateSelfUser, setBookTargetState, @@ -78,6 +78,7 @@ import { getRequestSuccessMessage, toContentType, } from './utils/requestPayload'; +import { applyRequestNoteToPayload } from './utils/requestConfirmation'; import { bookFromRequestData } from './utils/requestFulfil'; import { emitBookTargetChange, onBookTargetChange } from './utils/bookTargetEvents'; import { bookSupportsTargets } from './utils/bookTargetLoader'; @@ -91,16 +92,19 @@ import './styles.css'; const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type'; -const getInitialContentType = (): ContentType => { +const getInitialContentType = (): { contentType: ContentType; combinedMode: boolean } => { try { const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY); + if (saved === 'combined') { + return { contentType: 'ebook', combinedMode: true }; + } if (saved === 'ebook' || saved === 'audiobook') { - return saved; + return { contentType: saved, combinedMode: false }; } } catch { // localStorage may be unavailable in private browsing } - return 'ebook'; + return { contentType: 'ebook', combinedMode: false }; }; const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']); @@ -142,6 +146,14 @@ const getErrorMessage = (error: unknown, fallback: string): string => { const CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE = 'Download queued, but the proxy interrupted the response. Status will refresh shortly.'; +type CombinedSelectionState = { + phase: 'ebook' | 'audiobook'; + ebookMode: RequestPolicyMode; + audiobookMode: RequestPolicyMode; + stagedEbook?: { book: Book; release: Release }; + stagedAudiobook?: Release; +}; + type PendingOnBehalfDownload = | { type: 'book'; @@ -154,6 +166,12 @@ type PendingOnBehalfDownload = release: Release; releaseContentType: ContentType; actingAsUser: ActingAsUserSelection; + } + | { + type: 'combined'; + book: Book; + combinedState: CombinedSelectionState; + actingAsUser: ActingAsUserSelection; }; function App() { @@ -214,15 +232,8 @@ function App() { }, [authChecked, isAuthenticated, authIsAdmin, username, fetchStatus]); // Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it - const [contentType, setContentType] = useState(() => getInitialContentType()); - - useEffect(() => { - try { - localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, contentType); - } catch { - // localStorage may be unavailable in private browsing - } - }, [contentType]); + const initialContentTypePref = useMemo(() => getInitialContentType(), []); + const [contentType, setContentType] = useState(initialContentTypePref.contentType); const { policy: requestPolicy, @@ -256,6 +267,7 @@ function App() { useEffect(() => { if (allowedContentTypes.length > 0 && !allowedContentTypes.includes(contentType)) { setContentType(allowedContentTypes[0]); + setCombinedMode(false); } }, [allowedContentTypes, contentType]); @@ -418,6 +430,7 @@ function App() { }, [setBooks]); const [pendingRequestPayload, setPendingRequestPayload] = useState(null); + const [pendingRequestExtraPayloads, setPendingRequestExtraPayloads] = useState([]); const [actingAsUser, setActingAsUser] = useState(null); const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState(null); const [fulfillingRequest, setFulfillingRequest] = useState<{ @@ -433,6 +446,7 @@ function App() { clearTracking(); setActiveQueryTarget('general'); setPendingRequestPayload(null); + setPendingRequestExtraPayloads([]); setActingAsUser(null); setPendingOnBehalfDownload(null); setFulfillingRequest(null); @@ -452,10 +466,33 @@ function App() { // UI state const [selectedBook, setSelectedBook] = useState(null); const [releaseBook, setReleaseBook] = useState(null); + + // Combined mode state (ebook + audiobook in one transaction) + const [combinedMode, setCombinedMode] = useState(initialContentTypePref.combinedMode); + const [combinedState, setCombinedState] = useState(null); + + // Persist content type + combined mode to localStorage + useEffect(() => { + try { + localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, combinedMode ? 'combined' : contentType); + } catch { + // localStorage may be unavailable in private browsing + } + }, [contentType, combinedMode]); + + // Clear combined state when combined mode is turned off + // (combinedModeAllowed guard is in a separate effect below, after effectiveSearchMode is declared) + useEffect(() => { + if (!combinedMode) { + setCombinedState(null); + } + }, [combinedMode]); + const [config, setConfig] = useState(null); const [metadataProviders, setMetadataProviders] = useState([]); const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState(null); const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState(null); + const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState(null); const [activeMetadataConfig, setActiveMetadataConfig] = useState(null); const [activeQueryTarget, setActiveQueryTarget] = useState('general'); const [activeResultsSort, setActiveResultsSort] = useState(''); @@ -645,11 +682,13 @@ function App() { getConfig(), getMetadataProviders(), ]); - const activeConfiguredProvider = getConfiguredMetadataProviderForContentType({ - contentType, - configuredMetadataProvider: metadataProviderState.configured_provider, - configuredAudiobookMetadataProvider: metadataProviderState.configured_provider_audiobook, - }); + const activeConfiguredProvider = combinedMode && metadataProviderState.configured_provider_combined + ? metadataProviderState.configured_provider_combined + : getConfiguredMetadataProviderForContentType({ + contentType, + configuredMetadataProvider: metadataProviderState.configured_provider, + configuredAudiobookMetadataProvider: metadataProviderState.configured_provider_audiobook, + }); let nextMetadataConfig: MetadataSearchConfig | null = null; if (cfg.search_mode === 'universal') { @@ -685,6 +724,7 @@ function App() { setMetadataProviders(metadataProviderState.providers); setConfiguredMetadataProvider(metadataProviderState.configured_provider); setConfiguredAudiobookMetadataProvider(metadataProviderState.configured_provider_audiobook); + setConfiguredCombinedMetadataProvider(metadataProviderState.configured_provider_combined); setActiveMetadataConfig(nextMetadataConfig); // Show onboarding modal on first run (settings enabled but not completed yet) @@ -716,7 +756,7 @@ function App() { } catch (error) { console.error('Failed to load config:', error); } - }, [clearTracking, contentType, setAdvancedFilters, setBooks]); + }, [clearTracking, combinedMode, contentType, setAdvancedFilters, setBooks]); // Fetch config when authenticated useEffect(() => { @@ -726,11 +766,32 @@ function App() { }, [isAuthenticated, loadConfig]); const effectiveSearchMode: SearchMode = config?.search_mode ?? 'direct'; - const defaultMetadataProviderForContentType = getConfiguredMetadataProviderForContentType({ - contentType, - configuredMetadataProvider, - configuredAudiobookMetadataProvider, - }); + + // Combined mode requires universal mode, config enabled, and both content types accessible + const combinedModeAllowed = useMemo(() => { + if (effectiveSearchMode !== 'universal') return false; + if (config?.show_combined_selector === false) return false; + const ebookMode = getDefaultMode('ebook'); + const audiobookMode = getDefaultMode('audiobook'); + return ebookMode !== 'blocked' && audiobookMode !== 'blocked'; + }, [effectiveSearchMode, config?.show_combined_selector, getDefaultMode]); + + // Auto-disable combined mode if policy changes make it unavailable + // Skip while config is still loading to avoid resetting localStorage-restored state + useEffect(() => { + if (!config) return; + if (combinedMode && !combinedModeAllowed) { + setCombinedMode(false); + } + }, [config, combinedMode, combinedModeAllowed]); + + const defaultMetadataProviderForContentType = combinedMode && configuredCombinedMetadataProvider + ? configuredCombinedMetadataProvider + : getConfiguredMetadataProviderForContentType({ + contentType, + configuredMetadataProvider, + configuredAudiobookMetadataProvider, + }); const effectiveMetadataProvider = effectiveSearchMode === 'universal' ? (defaultMetadataProviderForContentType || null) : null; @@ -1033,10 +1094,10 @@ function App() { } }; - const submitRequest = useCallback( - async (payload: CreateRequestPayload, successMessage: string): Promise => { + const submitRequests = useCallback( + async (payloads: CreateRequestPayload[], successMessage: string): Promise => { try { - await createRequest(payload); + await createRequests(payloads); await refreshActivitySnapshot(); showToast(successMessage, 'success'); await refreshRequestPolicy({ force: true }); @@ -1053,19 +1114,41 @@ function App() { [showToast, refreshRequestPolicy, refreshActivitySnapshot] ); - const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => { - setPendingRequestPayload(payload); - }, []); + const openRequestConfirmation = useCallback(( + payload: CreateRequestPayload, + extraPayloads: CreateRequestPayload[] = [], + onBehalfOfUserId: number | undefined = actingAsUser?.id, + ) => { + const applyOnBehalf = (requestPayload: CreateRequestPayload): CreateRequestPayload => { + if (typeof onBehalfOfUserId !== 'number') { + return requestPayload; + } + return { + ...requestPayload, + on_behalf_of_user_id: onBehalfOfUserId, + }; + }; + + setPendingRequestPayload(applyOnBehalf(payload)); + setPendingRequestExtraPayloads(extraPayloads.map(applyOnBehalf)); + }, [actingAsUser?.id]); const handleConfirmRequest = useCallback( - async (payload: CreateRequestPayload): Promise => { - const success = await submitRequest(payload, getRequestSuccessMessage(payload)); - if (success) { - setPendingRequestPayload(null); - } - return success; + async (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]): Promise => { + const requestPayloads = [payload, ...(extraPayloads ?? pendingRequestExtraPayloads)].map((requestPayload) => + applyRequestNoteToPayload(requestPayload, payload.note ?? '', allowRequestNotes) + ); + const success = await submitRequests( + requestPayloads, + requestPayloads.length === 1 ? getRequestSuccessMessage(requestPayloads[0]) : 'Requests submitted', + ); + if (!success) return false; + + setPendingRequestPayload(null); + setPendingRequestExtraPayloads([]); + return true; }, - [submitRequest] + [allowRequestNotes, pendingRequestExtraPayloads, submitRequests] ); const getDirectPolicyMode = useCallback((book: Book): RequestPolicyMode => { @@ -1076,6 +1159,20 @@ function App() { return getDefaultMode(contentType); }, [getDefaultMode, contentType]); + const getCombinedSelectionPhases = useCallback( + (state: Pick): ContentType[] => { + const phases: ContentType[] = []; + if (state.ebookMode !== 'request_book') { + phases.push('ebook'); + } + if (state.audiobookMode !== 'request_book') { + phases.push('audiobook'); + } + return phases; + }, + [] + ); + const buildReleaseDownloadPayload = useCallback( (book: Book, release: Release, releaseContentType: ContentType): DownloadReleasePayload => { const isManual = book.provider === 'manual'; @@ -1165,7 +1262,7 @@ function App() { code: isApiResponseError(error) ? error.code : null, }); if (requiredMode === 'request_release') { - openRequestConfirmation(buildDirectRequestPayload(book)); + openRequestConfirmation(buildDirectRequestPayload(book), [], onBehalfOfUserId); await refreshRequestPolicy({ force: true }); return; } @@ -1229,7 +1326,7 @@ function App() { content_type: normalizedContentType, request_level: 'release', }, - }); + }, [], onBehalfOfUserId); await refreshRequestPolicy({ force: true }); return; } @@ -1243,7 +1340,7 @@ function App() { content_type: normalizedContentType, request_level: 'book', }, - }); + }, [], onBehalfOfUserId); await refreshRequestPolicy({ force: true }); return; } @@ -1269,6 +1366,69 @@ function App() { [buildReleaseDownloadPayload, fetchStatus, openRequestConfirmation, refreshRequestPolicy, removeBookFromActiveList, showToast, trackRelease] ); + const executeCombinedAction = useCallback( + async (book: Book, selection: CombinedSelectionState, onBehalfOfUserId?: number): Promise => { + const ebookRelease = selection.stagedEbook?.release; + const audiobookRelease = selection.stagedAudiobook; + const ebookMode = ebookRelease ? getSourceMode(ebookRelease.source, 'ebook') : selection.ebookMode; + const audiobookMode = audiobookRelease ? getSourceMode(audiobookRelease.source, 'audiobook') : selection.audiobookMode; + + const buildRequestPayload = ( + release: Release | undefined, + releaseContentType: ContentType, + mode: RequestPolicyMode, + ): CreateRequestPayload => { + const payload = mode === 'request_release' + ? { + book_data: buildMetadataBookRequestData(book, releaseContentType), + release_data: buildReleaseDataFromMetadataRelease(book, release!, releaseContentType), + context: { + source: release!.source, + content_type: releaseContentType, + request_level: 'release' as const, + }, + } + : { + book_data: buildMetadataBookRequestData(book, releaseContentType), + release_data: null, + context: { + source: '*', + content_type: releaseContentType, + request_level: 'book' as const, + }, + }; + + if (typeof onBehalfOfUserId !== 'number') { + return payload; + } + + return { + ...payload, + on_behalf_of_user_id: onBehalfOfUserId, + }; + }; + + const requestPayloads: CreateRequestPayload[] = []; + + if (ebookMode === 'download') { + await executeReleaseDownload(book, ebookRelease!, 'ebook', onBehalfOfUserId); + } else { + requestPayloads.push(buildRequestPayload(ebookRelease, 'ebook', ebookMode)); + } + + if (audiobookMode === 'download') { + await executeReleaseDownload(book, audiobookRelease!, 'audiobook', onBehalfOfUserId); + } else { + requestPayloads.push(buildRequestPayload(audiobookRelease, 'audiobook', audiobookMode)); + } + + if (requestPayloads.length > 0) { + openRequestConfirmation(requestPayloads[0], requestPayloads.slice(1), onBehalfOfUserId); + } + }, + [executeReleaseDownload, getSourceMode, openRequestConfirmation] + ); + const handleConfirmOnBehalfDownload = useCallback(async (): Promise => { if (!pendingOnBehalfDownload) { return true; @@ -1278,6 +1438,12 @@ function App() { try { if (pendingOnBehalfDownload.type === 'book') { await executeBookDownload(pendingOnBehalfDownload.book, onBehalfOfUserId); + } else if (pendingOnBehalfDownload.type === 'combined') { + await executeCombinedAction( + pendingOnBehalfDownload.book, + pendingOnBehalfDownload.combinedState, + onBehalfOfUserId + ); } else { await executeReleaseDownload( pendingOnBehalfDownload.book, @@ -1291,7 +1457,7 @@ function App() { } catch { return false; } - }, [executeBookDownload, executeReleaseDownload, pendingOnBehalfDownload]); + }, [executeBookDownload, executeCombinedAction, executeReleaseDownload, pendingOnBehalfDownload]); // Direct-mode action (download or release-level request based on policy). const handleDownload = async (book: Book): Promise => { @@ -1413,22 +1579,52 @@ function App() { return; } - if (mode === 'request_book') { - policyTrace('universal.get:request_modal', { - bookId: book.id, - requestLevel: 'book', - contentType: normalizedContentType, + // Combined mode is only available when both default content types are accessible. + if (combinedMode) { + const latestPolicy2 = await refreshRequestPolicy({ force: true }).catch(() => null); + const effectiveIsAdmin2 = latestPolicy2 ? Boolean(latestPolicy2.is_admin) : requestRoleIsAdmin; + const ebookMode = resolveDefaultModeFromPolicy(latestPolicy2, effectiveIsAdmin2, 'ebook'); + const audiobookMode = resolveDefaultModeFromPolicy(latestPolicy2, effectiveIsAdmin2, 'audiobook'); + + if (ebookMode === 'request_book' && audiobookMode === 'request_book') { + const ebookPayload: CreateRequestPayload = { + book_data: buildMetadataBookRequestData(book, 'ebook'), + release_data: null, + context: { source: '*', content_type: 'ebook', request_level: 'book' }, + }; + const audiobookPayload: CreateRequestPayload = { + book_data: buildMetadataBookRequestData(book, 'audiobook'), + release_data: null, + context: { source: '*', content_type: 'audiobook', request_level: 'book' }, + }; + openRequestConfirmation(ebookPayload, [audiobookPayload]); + return; + } + + const selectionPhases = getCombinedSelectionPhases({ ebookMode, audiobookMode }); + setCombinedState({ + phase: selectionPhases[0], + ebookMode, + audiobookMode, }); - openRequestConfirmation({ - book_data: buildMetadataBookRequestData(book, normalizedContentType), - release_data: null, - context: { - source: '*', - content_type: normalizedContentType, - request_level: 'book', - }, - }); - return; + } else { + if (mode === 'request_book') { + policyTrace('universal.get:request_modal', { + bookId: book.id, + requestLevel: 'book', + contentType: normalizedContentType, + }); + openRequestConfirmation({ + book_data: buildMetadataBookRequestData(book, normalizedContentType), + release_data: null, + context: { + source: '*', + content_type: normalizedContentType, + request_level: 'book', + }, + }); + return; + } } if (book.provider && book.provider_id) { @@ -1525,6 +1721,53 @@ function App() { return refreshRequestPolicy({ force: true }); }, [refreshRequestPolicy]); + // Combined mode callbacks + const handleCombinedNext = useCallback((release: Release) => { + if (!releaseBook || !combinedState) return; + const phases = getCombinedSelectionPhases(combinedState); + const nextPhase = phases[phases.indexOf(combinedState.phase) + 1]; + + setCombinedState({ + ...combinedState, + phase: nextPhase, + stagedEbook: { book: releaseBook, release }, + }); + }, [combinedState, getCombinedSelectionPhases, releaseBook]); + + const handleCombinedBack = useCallback((audiobookRelease: Release | null) => { + setCombinedState((prev) => prev ? { ...prev, phase: 'ebook', stagedAudiobook: audiobookRelease ?? undefined } : null); + }, []); + + const handleCombinedDownload = useCallback(async (release: Release) => { + if (!combinedState || !releaseBook) return; + + const nextCombinedState: CombinedSelectionState = combinedState.phase === 'ebook' + ? { + ...combinedState, + stagedEbook: { book: releaseBook, release }, + } + : { + ...combinedState, + stagedAudiobook: release, + }; + + if (actingAsUser) { + setPendingOnBehalfDownload({ + type: 'combined', + book: releaseBook, + combinedState: nextCombinedState, + actingAsUser, + }); + setCombinedState(null); + setReleaseBook(null); + return; + } + + await executeCombinedAction(releaseBook, nextCombinedState); + setCombinedState(null); + setReleaseBook(null); + }, [actingAsUser, combinedState, executeCombinedAction, releaseBook]); + const handleRequestCancel = useCallback( async (requestId: number) => { try { @@ -1806,22 +2049,29 @@ function App() { const handleSearchModeChange = useCallback((nextMode: SearchMode) => { setConfig((prev) => prev ? { ...prev, search_mode: nextMode } : prev); + if (nextMode !== 'universal') { + setCombinedMode(false); + } updateSelfUser({ settings: { SEARCH_MODE: nextMode } }) .then(() => loadConfig('settings-saved')) .catch((err) => console.error('Failed to save search mode:', err)); }, [loadConfig]); const handleMetadataProviderChange = useCallback((provider: string) => { - if (contentType === 'audiobook') { + if (combinedMode) { + setConfiguredCombinedMetadataProvider(provider); + } else if (contentType === 'audiobook') { setConfiguredAudiobookMetadataProvider(provider); } else { setConfiguredMetadataProvider(provider); } - const key = contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER'; + const key = combinedMode + ? 'METADATA_PROVIDER_COMBINED' + : contentType === 'audiobook' ? 'METADATA_PROVIDER_AUDIOBOOK' : 'METADATA_PROVIDER'; updateSelfUser({ settings: { [key]: provider } }) .then(() => loadConfig('settings-saved')) .catch((err) => console.error('Failed to save metadata provider:', err)); - }, [contentType, loadConfig]); + }, [combinedMode, contentType, loadConfig]); const buildCurrentSearchRequest = useCallback((sortOverride?: string) => { const appliedSort = effectiveSearchMode === 'universal' @@ -2034,7 +2284,15 @@ function App() { const isBrowseFulfilMode = fulfillingRequest !== null; const activeReleaseBook = fulfillingRequest?.book ?? releaseBook; - const activeReleaseContentType = fulfillingRequest?.contentType ?? contentType; + const activeReleaseContentType = fulfillingRequest?.contentType ?? combinedState?.phase ?? contentType; + const combinedSelectionPhases = combinedState ? getCombinedSelectionPhases(combinedState) : []; + const combinedCurrentStep = combinedState ? combinedSelectionPhases.indexOf(combinedState.phase) + 1 : 0; + const combinedIsFinalStep = combinedState + ? combinedSelectionPhases[combinedSelectionPhases.length - 1] === combinedState.phase + : false; + const combinedHasPreviousStep = combinedState + ? combinedSelectionPhases.indexOf(combinedState.phase) > 0 + : false; const usePinnedMainScrollContainer = sidebarPinnedOpen; const handleReleaseModalClose = useCallback(() => { @@ -2042,15 +2300,18 @@ function App() { setFulfillingRequest(null); return; } + setCombinedState(null); setReleaseBook(null); }, [isBrowseFulfilMode]); const pendingOnBehalfTitle = pendingOnBehalfDownload ? pendingOnBehalfDownload.type === 'book' ? pendingOnBehalfDownload.book.title || 'Untitled' - : pendingOnBehalfDownload.release.title || - pendingOnBehalfDownload.book.title || - 'Untitled' + : pendingOnBehalfDownload.type === 'combined' + ? pendingOnBehalfDownload.book.title || 'Untitled' + : pendingOnBehalfDownload.release.title || + pendingOnBehalfDownload.book.title || + 'Untitled' : ''; const pendingOnBehalfUserName = pendingOnBehalfDownload ? formatActingAsUserName(pendingOnBehalfDownload.actingAsUser) @@ -2104,6 +2365,8 @@ function App() { contentType={contentType} onContentTypeChange={setContentType} allowedContentTypes={allowedContentTypes} + combinedMode={combinedMode} + onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} queryTargets={queryTargets} activeQueryTarget={activeQueryTarget} onQueryTargetChange={setActiveQueryTarget} @@ -2142,6 +2405,7 @@ function App() { activeMetadataProvider={effectiveMetadataProvider} onMetadataProviderChange={handleMetadataProviderChange} contentType={contentType} + combinedMode={combinedMode} isAdmin={requestRoleIsAdmin} onClose={() => setShowAdvanced(false)} /> @@ -2180,6 +2444,8 @@ function App() { contentType={contentType} onContentTypeChange={setContentType} allowedContentTypes={allowedContentTypes} + combinedMode={combinedMode} + onCombinedModeChange={combinedModeAllowed ? setCombinedMode : undefined} activeQueryField={activeQueryField} searchMode={effectiveSearchMode} onSearchModeChange={handleSearchModeChange} @@ -2272,15 +2538,26 @@ function App() { isRequestMode={isBrowseFulfilMode || activeReleaseBook?.provider === 'manual'} showReleaseSourceLinks={config?.show_release_source_links !== false} onShowToast={showToast} + combinedPhase={combinedState?.phase ?? null} + combinedCurrentStep={combinedCurrentStep} + combinedTotalSteps={combinedSelectionPhases.length} + combinedEbookMode={combinedState?.ebookMode ?? null} + combinedAudiobookMode={combinedState?.audiobookMode ?? null} + onCombinedNext={combinedState && !combinedIsFinalStep ? handleCombinedNext : undefined} + onCombinedBack={combinedState && combinedHasPreviousStep ? handleCombinedBack : undefined} + onCombinedDownload={combinedState && combinedIsFinalStep ? handleCombinedDownload : undefined} + stagedEbookRelease={combinedState?.stagedEbook?.release ?? null} + stagedAudiobookRelease={combinedState?.stagedAudiobook ?? null} /> )} {pendingRequestPayload && ( setPendingRequestPayload(null)} + onClose={() => { setPendingRequestPayload(null); setPendingRequestExtraPayloads([]); }} /> )} diff --git a/src/frontend/src/components/AdvancedFilters.tsx b/src/frontend/src/components/AdvancedFilters.tsx index 8c76b252..644fbba6 100644 --- a/src/frontend/src/components/AdvancedFilters.tsx +++ b/src/frontend/src/components/AdvancedFilters.tsx @@ -27,6 +27,7 @@ interface AdvancedFiltersProps { activeMetadataProvider?: string | null; onMetadataProviderChange?: (provider: string) => void; contentType?: ContentType; + combinedMode?: boolean; isAdmin?: boolean; onClose?: () => void; } @@ -50,6 +51,7 @@ export const AdvancedFilters = ({ activeMetadataProvider, onMetadataProviderChange, contentType = 'ebook', + combinedMode = false, isAdmin = false, onClose, }: AdvancedFiltersProps) => { @@ -121,35 +123,33 @@ export const AdvancedFilters = ({ )} {isAdmin && ( - <> -
+
+ { + const next = Array.isArray(value) ? value[0] ?? 'direct' : value; + onSearchModeChange(next === 'universal' ? 'universal' : 'direct'); + }} + placeholder="Choose a mode" + widthClassName="w-full" + /> + + {searchMode === 'universal' && ( { - const next = Array.isArray(value) ? value[0] ?? 'direct' : value; - onSearchModeChange(next === 'universal' ? 'universal' : 'direct'); + const next = Array.isArray(value) ? value[0] ?? '' : value; + onMetadataProviderChange?.(next); }} - placeholder="Choose a mode" + placeholder="Choose a provider" widthClassName="w-full" /> - - {searchMode === 'universal' && ( - { - const next = Array.isArray(value) ? value[0] ?? '' : value; - onMetadataProviderChange?.(next); - }} - placeholder="Choose a provider" - widthClassName="w-full" - /> - )} -
- + )} +
)} {searchMode === 'direct' && ( diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index 91b961f2..2ab1e71d 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -42,6 +42,8 @@ interface HeaderProps { contentType?: ContentType; onContentTypeChange?: (type: ContentType) => void; allowedContentTypes?: ContentType[]; + combinedMode?: boolean; + onCombinedModeChange?: (enabled: boolean) => void; queryTargets?: QueryTargetOption[]; activeQueryTarget?: string; onQueryTargetChange?: (target: string) => void; @@ -79,6 +81,8 @@ export const Header = forwardRef(({ contentType = 'ebook', onContentTypeChange, allowedContentTypes, + combinedMode, + onCombinedModeChange, queryTargets = [], activeQueryTarget = 'general', onQueryTargetChange, @@ -671,6 +675,8 @@ export const Header = forwardRef(({ contentType={contentType} onContentTypeChange={onContentTypeChange} allowedContentTypes={allowedContentTypes} + combinedMode={combinedMode} + onCombinedModeChange={onCombinedModeChange} queryTargets={queryTargets} activeQueryTarget={activeQueryTarget} onQueryTargetChange={onQueryTargetChange} diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index e2113e58..c3ea32b2 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -101,6 +101,17 @@ interface ReleaseModalProps { isRequestMode?: boolean; showReleaseSourceLinks?: boolean; onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; + // Combined mode props + combinedPhase?: 'ebook' | 'audiobook' | null; + combinedCurrentStep?: number; + combinedTotalSteps?: number; + combinedEbookMode?: RequestPolicyMode | null; + combinedAudiobookMode?: RequestPolicyMode | null; + onCombinedNext?: (release: Release) => void; + onCombinedBack?: (audiobookRelease: Release | null) => void; + onCombinedDownload?: (release: Release) => void; + stagedEbookRelease?: Release | null; + stagedAudiobookRelease?: Release | null; } @@ -217,6 +228,25 @@ const LeadingCell = ({ return ; }; +// Radio indicator for selection mode +const RadioIndicator = ({ selected }: { selected: boolean }) => ( +
+
+ {selected && ( + + + + )} +
+
+); + // Release row component with dynamic columns const ReleaseRow = ({ release, @@ -228,6 +258,9 @@ const ReleaseRow = ({ leadingCell, onlineServers, showReleaseSourceLinks, + selectionMode = false, + isSelected = false, + onSelect, }: { release: Release; index: number; @@ -238,6 +271,9 @@ const ReleaseRow = ({ leadingCell?: LeadingCellConfig; onlineServers?: string[]; showReleaseSourceLinks: boolean; + selectionMode?: boolean; + isSelected?: boolean; + onSelect?: () => void; }) => { const author = release.extra?.author as string | undefined; @@ -257,13 +293,20 @@ const ReleaseRow = ({ ? 'auto 1fr auto' : '1fr auto'; + const handleRowClick = selectionMode && onSelect ? onSelect : undefined; + return (
{/* Desktop layout with dynamic grid */}
))} - {/* Fixed: Action button */} - + {/* Fixed: Action button or radio indicator */} + {selectionMode ? ( + + ) : ( + + )}
{/* Mobile layout - author inline with title, info line below */} @@ -379,13 +426,17 @@ const ReleaseRow = ({ )}
- + {selectionMode ? ( + + ) : ( + + )} ); @@ -540,6 +591,16 @@ export const ReleaseModal = ({ isRequestMode = false, showReleaseSourceLinks = true, onShowToast, + combinedPhase = null, + combinedCurrentStep = 1, + combinedTotalSteps = 2, + combinedEbookMode = null, + combinedAudiobookMode = null, + onCombinedNext, + onCombinedBack, + onCombinedDownload, + stagedEbookRelease = null, + stagedAudiobookRelease = null, }: ReleaseModalProps) => { // Use audiobook formats when in audiobook mode const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0 @@ -550,6 +611,8 @@ export const ReleaseModal = ({ : defaultReleaseSource; const [isClosing, setIsClosing] = useState(false); const [isRequestingBook, setIsRequestingBook] = useState(false); + const [selectedRelease, setSelectedRelease] = useState(null); + const isCombinedMode = combinedPhase !== null; // Available sources from plugin registry const [availableSources, setAvailableSources] = useState([]); @@ -646,6 +709,15 @@ export const ReleaseModal = ({ } }, [book]); + // Restore staged selection when navigating between phases + useEffect(() => { + if (combinedPhase === 'ebook' && stagedEbookRelease) { + setSelectedRelease(stagedEbookRelease); + } else if (combinedPhase === 'audiobook' && stagedAudiobookRelease) { + setSelectedRelease(stagedAudiobookRelease); + } + }, [combinedPhase, stagedEbookRelease, stagedAudiobookRelease]); + // Reset modal state when book changes to prevent stale data useEffect(() => { setDescriptionExpanded(false); @@ -659,6 +731,11 @@ export const ReleaseModal = ({ setLanguageFilter([LANGUAGE_OPTION_DEFAULT]); setIndexerFilter([]); indexerFilterInitializedRef.current = new Set(); + // Don't clear selectedRelease here — the combinedPhase effect handles it + // (restoring the staged ebook selection when going back) + if (!isCombinedMode) { + setSelectedRelease(null); + } const baseTitle = book?.search_title || book?.title || ''; const baseAuthor = book?.search_author || book?.author || ''; const defaultQuery = `${baseTitle} ${baseAuthor}`.trim(); @@ -671,7 +748,7 @@ export const ReleaseModal = ({ clearTimeout(statusTimeoutRef.current); statusTimeoutRef.current = null; } - }, [book?.id, defaultShowManualQuery, book?.search_title, book?.title, book?.search_author, book?.author]); + }, [book?.id, contentType, defaultShowManualQuery, book?.search_title, book?.title, book?.search_author, book?.author]); // Set up WebSocket listener for search status updates useEffect(() => { @@ -774,7 +851,7 @@ export const ReleaseModal = ({ return () => scrollContainer.removeEventListener('scroll', handleScroll); }, [book]); - // Fetch available sources on mount + // Fetch available sources on mount (only when book changes, not content type) useEffect(() => { if (!book) return; @@ -784,47 +861,9 @@ export const ReleaseModal = ({ setSourcesError(null); const sources = await getReleaseSources(); setAvailableSources(sources); - - const providerContextSource = sources.find((source) => ( - source.name === book.provider && source.browse_results_are_releases - )); - - // Filter sources by content type support - const supportedSources = sources.filter(s => { - const types = s.supported_content_types || ['ebook', 'audiobook']; - return types.includes(contentType); - }); - - if (providerContextSource) { - setActiveTab(providerContextSource.name); - return; - } - - // Set active tab: prefer the configured default source if enabled and supports content type - if (supportedSources.length > 0) { - const enabledSources = supportedSources.filter(s => s.enabled); - const defaultIsEnabled = preferredDefaultReleaseSource && - enabledSources.some(s => s.name === preferredDefaultReleaseSource); - - let defaultSource: string; - if (defaultIsEnabled) { - defaultSource = preferredDefaultReleaseSource; - } else if (enabledSources.length > 0) { - defaultSource = enabledSources[0].name; - } else { - defaultSource = supportedSources[0].name; // Fallback to first supported source - } - setActiveTab(defaultSource); - } else if (sources.length > 0) { - // No sources support this content type - fall back to first source - setActiveTab(sources[0].name); - } else { - setActiveTab(''); - } } catch (err) { console.error('Failed to fetch release sources:', err); setAvailableSources([]); - setActiveTab(''); setSourcesError(err instanceof Error ? err.message : 'Failed to load release sources'); } finally { setSourcesLoading(false); @@ -832,7 +871,46 @@ export const ReleaseModal = ({ }; fetchSources(); - }, [book, preferredDefaultReleaseSource, contentType]); + }, [book]); + + // Pick default active tab when sources or content type changes (synchronous, no flash) + useEffect(() => { + if (availableSources.length === 0) return; + + const providerContextSource = availableSources.find((source) => ( + source.name === book?.provider && source.browse_results_are_releases + )); + + if (providerContextSource) { + setActiveTab(providerContextSource.name); + return; + } + + const supportedSources = availableSources.filter(s => { + const types = s.supported_content_types || ['ebook', 'audiobook']; + return types.includes(contentType); + }); + + if (supportedSources.length > 0) { + const enabledSources = supportedSources.filter(s => s.enabled); + const defaultIsEnabled = preferredDefaultReleaseSource && + enabledSources.some(s => s.name === preferredDefaultReleaseSource); + + let defaultSource: string; + if (defaultIsEnabled) { + defaultSource = preferredDefaultReleaseSource; + } else if (enabledSources.length > 0) { + defaultSource = enabledSources[0].name; + } else { + defaultSource = supportedSources[0].name; + } + setActiveTab(defaultSource); + } else if (availableSources.length > 0) { + setActiveTab(availableSources[0].name); + } else { + setActiveTab(''); + } + }, [availableSources, book?.provider, preferredDefaultReleaseSource, contentType]); // Fetch releases when active tab changes (with caching) // Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch @@ -1262,6 +1340,16 @@ export const ReleaseModal = ({ return; } + // In combined mode, clicking a row selects it (don't download) + if (isCombinedMode) { + const mode = getReleaseActionMode(release); + if (mode === 'blocked' || mode === 'request_book') { + return; + } + setSelectedRelease(release); + return; + } + const mode = getReleaseActionMode(release); if (mode === 'download') { await onDownload(book, release, contentType); @@ -1275,10 +1363,8 @@ export const ReleaseModal = ({ } return; } - // blocked / request_book — should not be reachable (button is disabled), - // but guard defensively. }, - [book, getReleaseActionMode, onDownload, onRequestRelease, contentType, handleClose] + [book, isCombinedMode, getReleaseActionMode, onDownload, onRequestRelease, contentType, handleClose] ); if (!book && !isClosing) return null; @@ -1370,7 +1456,9 @@ export const ReleaseModal = ({ )}

- Find Releases + {isCombinedMode + ? `Step ${combinedCurrentStep} of ${combinedTotalSteps} — Select ${combinedPhase === 'ebook' ? 'book' : 'audiobook'}` + : 'Find Releases'}

{book.provider === 'manual' ? 'Manual Query' : (book.title || 'Untitled')} @@ -2037,6 +2125,9 @@ export const ReleaseModal = ({ leadingCell={columnConfig.leading_cell} onlineServers={columnConfig.online_servers} showReleaseSourceLinks={showReleaseSourceLinks} + selectionMode={isCombinedMode} + isSelected={isCombinedMode && selectedRelease?.source_id === release.source_id} + onSelect={isCombinedMode ? () => setSelectedRelease(release) : undefined} /> ))}

@@ -2084,6 +2175,111 @@ export const ReleaseModal = ({ )} + + {/* Combined mode footer */} + {isCombinedMode && ( +
+
+ {/* Phase indicators with live selection chips */} +
+ {(() => { + const ebookRelease = combinedPhase === 'ebook' ? selectedRelease : stagedEbookRelease; + const audiobookRelease = combinedPhase === 'audiobook' ? selectedRelease : stagedAudiobookRelease; + return ( + <> + + {ebookRelease ? ( + <> + + + + {ebookRelease.format?.toUpperCase() || 'Book'} · {ebookRelease.size || '?'} + + ) : ( + <>{combinedPhase === 'ebook' ? '\u25CF' : '\u25CB'} Book + )} + + + {audiobookRelease ? ( + <> + + + + {audiobookRelease.format?.toUpperCase() || 'Audiobook'} · {audiobookRelease.size || '?'} + + ) : ( + <>{combinedPhase === 'audiobook' ? '\u25CF' : '\u25CB'} Audiobook + )} + + + ); + })()} +
+ + {/* Action buttons */} +
+ {onCombinedBack && ( + + )} + + {combinedPhase === 'ebook' && onCombinedNext && ( + + )} + + {onCombinedDownload && (() => { + const ebookActionMode = combinedPhase === 'ebook' + ? (selectedRelease ? getReleaseActionMode(selectedRelease) : combinedEbookMode) + : (stagedEbookRelease ? getReleaseActionMode(stagedEbookRelease) : combinedEbookMode); + const audiobookActionMode = combinedPhase === 'audiobook' + ? (selectedRelease ? getReleaseActionMode(selectedRelease) : combinedAudiobookMode) + : (stagedAudiobookRelease ? getReleaseActionMode(stagedAudiobookRelease) : combinedAudiobookMode); + const ebookIsRequest = ebookActionMode === 'request_release' || ebookActionMode === 'request_book'; + const audiobookIsRequest = audiobookActionMode === 'request_release' || audiobookActionMode === 'request_book'; + const label = ebookIsRequest && audiobookIsRequest + ? 'Request Both' + : ebookIsRequest || audiobookIsRequest + ? 'Download & Request' + : 'Download Both'; + return ( + + ); + })()} +
+
+
+ )} diff --git a/src/frontend/src/components/RequestConfirmationModal.tsx b/src/frontend/src/components/RequestConfirmationModal.tsx index 0c5cf6a5..735af77b 100644 --- a/src/frontend/src/components/RequestConfirmationModal.tsx +++ b/src/frontend/src/components/RequestConfirmationModal.tsx @@ -13,13 +13,15 @@ import { interface RequestConfirmationModalProps { payload: CreateRequestPayload | null; + extraPayloads?: CreateRequestPayload[]; allowNotes: boolean; - onConfirm: (payload: CreateRequestPayload) => Promise; + onConfirm: (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]) => Promise; onClose: () => void; } export const RequestConfirmationModal = ({ payload, + extraPayloads = [], allowNotes, onConfirm, onClose, @@ -71,6 +73,10 @@ export const RequestConfirmationModal = ({ return payload ? buildRequestConfirmationPreview(payload) : null; }, [payload]); + const extraPreviews = useMemo(() => { + return extraPayloads.map(buildRequestConfirmationPreview); + }, [extraPayloads]); + const [enriched, setEnriched] = useState(null); const enrichRef = useRef(0); @@ -122,7 +128,7 @@ export const RequestConfirmationModal = ({ setIsSubmitting(true); try { const nextPayload = applyRequestNoteToPayload(payload, note, allowNotes); - const success = await onConfirm(nextPayload); + const success = await onConfirm(nextPayload, extraPayloads.length > 0 ? extraPayloads : undefined); if (!success) { setIsSubmitting(false); } @@ -147,7 +153,7 @@ export const RequestConfirmationModal = ({ >

- Request Book + {extraPayloads.length > 0 ? 'Request Book & Audiobook' : 'Request Book'}

+ {onCombinedModeChange && (() => { + const lineColor = combinedMode ? 'bg-emerald-500' : 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500'; + return ( + + + + ); + })()} )} diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index 9a04628d..9119d3d3 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -30,6 +30,8 @@ interface SearchSectionProps { contentType?: ContentType; onContentTypeChange?: (type: ContentType) => void; allowedContentTypes?: ContentType[]; + combinedMode?: boolean; + onCombinedModeChange?: (enabled: boolean) => void; activeQueryField?: MetadataSearchField | null; searchMode: SearchMode; onSearchModeChange: (mode: SearchMode) => void; @@ -59,6 +61,8 @@ export const SearchSection = ({ contentType = 'ebook', onContentTypeChange, allowedContentTypes, + combinedMode, + onCombinedModeChange, activeQueryField, searchMode, onSearchModeChange, @@ -96,6 +100,8 @@ export const SearchSection = ({ contentType={contentType} onContentTypeChange={onContentTypeChange} allowedContentTypes={allowedContentTypes} + combinedMode={combinedMode} + onCombinedModeChange={onCombinedModeChange} queryTargets={queryTargets} activeQueryTarget={activeQueryTarget} onQueryTargetChange={onQueryTargetChange} @@ -120,6 +126,7 @@ export const SearchSection = ({ activeMetadataProvider={activeMetadataProvider} onMetadataProviderChange={onMetadataProviderChange} contentType={contentType} + combinedMode={combinedMode} isAdmin={isAdmin} onClose={onAdvancedToggle} /> diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index b7ad3ac6..b2b11be2 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -49,6 +49,7 @@ const API = { settings: `${API_BASE}/settings`, requestPolicy: `${API_BASE}/request-policy`, requests: `${API_BASE}/requests`, + requestsBatch: `${API_BASE}/requests/batch`, adminRequests: `${API_BASE}/admin/requests`, adminRequestCounts: `${API_BASE}/admin/requests/count`, activitySnapshot: `${API_BASE}/activity/snapshot`, @@ -591,6 +592,13 @@ export const createRequest = async (payload: CreateRequestPayload): Promise => { + return fetchJSON(API.requestsBatch, { + method: 'POST', + body: JSON.stringify({ requests: payloads }), + }); +}; + export const listRequests = async (params: ListRequestsParams = {}): Promise => { const url = buildRequestListUrl(API.requests, params); return fetchJSON(url); diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 8b0d4f81..58ad4290 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -221,6 +221,7 @@ export interface CreateRequestPayload { book_data: Record; release_data?: Record | null; note?: string; + on_behalf_of_user_id?: number; context: RequestContextPayload; } @@ -264,6 +265,7 @@ export interface AppConfig { default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download') default_release_source_audiobook?: string; // Default tab in ReleaseModal for audiobooks show_release_source_links: boolean; + show_combined_selector: boolean; books_output_mode: BooksOutputMode; auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download @@ -286,6 +288,7 @@ export interface MetadataProvidersResponse { providers: MetadataProviderSummary[]; configured_provider: string | null; configured_provider_audiobook: string | null; + configured_provider_combined: string | null; } export interface MetadataCapability { diff --git a/tests/core/test_admin_users_api.py b/tests/core/test_admin_users_api.py index 6ee78755..93aa7b54 100644 --- a/tests/core/test_admin_users_api.py +++ b/tests/core/test_admin_users_api.py @@ -1202,8 +1202,10 @@ class TestAdminSearchPreferences: assert data["tab"] == "search_mode" assert data["keys"] == [ "SEARCH_MODE", + "SHOW_COMBINED_SELECTOR", "METADATA_PROVIDER", "METADATA_PROVIDER_AUDIOBOOK", + "METADATA_PROVIDER_COMBINED", "DEFAULT_RELEASE_SOURCE", "DEFAULT_RELEASE_SOURCE_AUDIOBOOK", ] diff --git a/tests/core/test_request_routes_api.py b/tests/core/test_request_routes_api.py index 3a791a5c..fb6ea50f 100644 --- a/tests/core/test_request_routes_api.py +++ b/tests/core/test_request_routes_api.py @@ -227,6 +227,71 @@ class TestRequestRoutes: assert updated["user_id"] == user["id"] assert updated["status"] == "cancelled" + def test_admin_can_create_request_on_behalf_of_another_user(self, main_module, client): + admin = _create_user(main_module, prefix="admin", role="admin") + target_user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + policy = _policy(default_ebook="request_book") + + payload = { + "book_data": { + "title": "Shared Request", + "author": "Shelfmark", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "shared-1", + }, + "context": { + "source": "*", + "content_type": "ebook", + "request_level": "book", + }, + "on_behalf_of_user_id": target_user["id"], + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): + with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + resp = client.post("/api/requests", json=payload) + + assert resp.status_code == 201 + assert resp.json["user_id"] == target_user["id"] + created = main_module.user_db.get_request(resp.json["id"]) + assert created is not None + assert created["user_id"] == target_user["id"] + + def test_batch_create_requests_is_atomic(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + policy = _policy(default_ebook="request_book", default_audiobook="request_book") + + duplicate_request = { + "book_data": { + "title": "Duplicate Title", + "author": "Same Author", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "dup-1", + }, + "context": { + "source": "*", + "content_type": "ebook", + "request_level": "book", + }, + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): + with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + resp = client.post( + "/api/requests/batch", + json={"requests": [duplicate_request, duplicate_request]}, + ) + + assert resp.status_code == 409 + assert resp.json["code"] == "duplicate_pending_request" + assert main_module.user_db.list_requests(user_id=user["id"]) == [] + def test_create_request_emits_websocket_events(self, main_module, client): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)