mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 22:05:45 +01:00
Feature: Combined book+audiobook downloads (#773)
- Adds a combined search option in the search bar selector - Choose both a book and audiobook file in a two-step release modal, and download both simultaneously from a single search result. - Works for requests. Request both a book+audiobook at once, or works seamlessly with request policies that differ between book + audiobook (E.g. automatically download the ebook portion, while the audiobook gets sent as a request) - Hidden for users who have book or audiobooks blocked. Closes #611
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+226
-120
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+96
-45
@@ -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()
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
+341
-64
@@ -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<ContentType>(() => 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<ContentType>(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<CreateRequestPayload | null>(null);
|
||||
const [pendingRequestExtraPayloads, setPendingRequestExtraPayloads] = useState<CreateRequestPayload[]>([]);
|
||||
const [actingAsUser, setActingAsUser] = useState<ActingAsUserSelection | null>(null);
|
||||
const [pendingOnBehalfDownload, setPendingOnBehalfDownload] = useState<PendingOnBehalfDownload | null>(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<Book | null>(null);
|
||||
const [releaseBook, setReleaseBook] = useState<Book | null>(null);
|
||||
|
||||
// Combined mode state (ebook + audiobook in one transaction)
|
||||
const [combinedMode, setCombinedMode] = useState(initialContentTypePref.combinedMode);
|
||||
const [combinedState, setCombinedState] = useState<CombinedSelectionState | null>(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<AppConfig | null>(null);
|
||||
const [metadataProviders, setMetadataProviders] = useState<MetadataProviderSummary[]>([]);
|
||||
const [configuredMetadataProvider, setConfiguredMetadataProvider] = useState<string | null>(null);
|
||||
const [configuredAudiobookMetadataProvider, setConfiguredAudiobookMetadataProvider] = useState<string | null>(null);
|
||||
const [configuredCombinedMetadataProvider, setConfiguredCombinedMetadataProvider] = useState<string | null>(null);
|
||||
const [activeMetadataConfig, setActiveMetadataConfig] = useState<MetadataSearchConfig | null>(null);
|
||||
const [activeQueryTarget, setActiveQueryTarget] = useState<string>('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<boolean> => {
|
||||
const submitRequests = useCallback(
|
||||
async (payloads: CreateRequestPayload[], successMessage: string): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
const success = await submitRequest(payload, getRequestSuccessMessage(payload));
|
||||
if (success) {
|
||||
setPendingRequestPayload(null);
|
||||
}
|
||||
return success;
|
||||
async (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]): Promise<boolean> => {
|
||||
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<CombinedSelectionState, 'ebookMode' | 'audiobookMode'>): 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<void> => {
|
||||
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<boolean> => {
|
||||
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<void> => {
|
||||
@@ -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 && (
|
||||
<RequestConfirmationModal
|
||||
payload={pendingRequestPayload}
|
||||
extraPayloads={pendingRequestExtraPayloads}
|
||||
allowNotes={allowRequestNotes}
|
||||
onConfirm={handleConfirmRequest}
|
||||
onClose={() => setPendingRequestPayload(null)}
|
||||
onClose={() => { setPendingRequestPayload(null); setPendingRequestExtraPayloads([]); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 = ({
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<DropdownList
|
||||
label="Search Mode"
|
||||
options={SEARCH_MODE_OPTIONS}
|
||||
value={searchMode}
|
||||
onChange={(value) => {
|
||||
const next = Array.isArray(value) ? value[0] ?? 'direct' : value;
|
||||
onSearchModeChange(next === 'universal' ? 'universal' : 'direct');
|
||||
}}
|
||||
placeholder="Choose a mode"
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
|
||||
{searchMode === 'universal' && (
|
||||
<DropdownList
|
||||
label="Search Mode"
|
||||
options={SEARCH_MODE_OPTIONS}
|
||||
value={searchMode}
|
||||
label={combinedMode ? 'Combined Metadata Provider' : contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
|
||||
options={providerOptions}
|
||||
value={activeMetadataProvider ?? ''}
|
||||
onChange={(value) => {
|
||||
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' && (
|
||||
<DropdownList
|
||||
label={contentType === 'audiobook' ? 'Audiobook Metadata Provider' : 'Book Metadata Provider'}
|
||||
options={providerOptions}
|
||||
value={activeMetadataProvider ?? ''}
|
||||
onChange={(value) => {
|
||||
const next = Array.isArray(value) ? value[0] ?? '' : value;
|
||||
onMetadataProviderChange?.(next);
|
||||
}}
|
||||
placeholder="Choose a provider"
|
||||
widthClassName="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMode === 'direct' && (
|
||||
|
||||
@@ -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<HeaderHandle, HeaderProps>(({
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
combinedMode,
|
||||
onCombinedModeChange,
|
||||
queryTargets = [],
|
||||
activeQueryTarget = 'general',
|
||||
onQueryTargetChange,
|
||||
@@ -671,6 +675,8 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
contentType={contentType}
|
||||
onContentTypeChange={onContentTypeChange}
|
||||
allowedContentTypes={allowedContentTypes}
|
||||
combinedMode={combinedMode}
|
||||
onCombinedModeChange={onCombinedModeChange}
|
||||
queryTargets={queryTargets}
|
||||
activeQueryTarget={activeQueryTarget}
|
||||
onQueryTargetChange={onQueryTargetChange}
|
||||
|
||||
@@ -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 <ReleaseThumbnail preview={undefined} title={release.title} />;
|
||||
};
|
||||
|
||||
// Radio indicator for selection mode
|
||||
const RadioIndicator = ({ selected }: { selected: boolean }) => (
|
||||
<div className="flex items-center justify-center w-8 h-8 shrink-0">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'border-emerald-500 bg-emerald-500'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" strokeWidth="3" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className="pl-5 pr-4 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform"
|
||||
className={`pl-5 pr-4 sm:pr-5 py-2 transition-colors duration-200 hover-row animate-pop-up will-change-transform ${
|
||||
selectionMode ? 'cursor-pointer' : ''
|
||||
} ${isSelected ? 'bg-emerald-50/50 dark:bg-emerald-900/10' : ''}`}
|
||||
style={{
|
||||
animationDelay: `${index * 30}ms`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
onClick={handleRowClick}
|
||||
role={selectionMode ? 'option' : undefined}
|
||||
aria-selected={selectionMode ? isSelected : undefined}
|
||||
>
|
||||
{/* Desktop layout with dynamic grid */}
|
||||
<div
|
||||
@@ -302,14 +345,18 @@ const ReleaseRow = ({
|
||||
<ReleaseCell key={col.key} column={col} release={release} onlineServers={onlineServers} />
|
||||
))}
|
||||
|
||||
{/* Fixed: Action button */}
|
||||
<BookDownloadButton
|
||||
buttonState={buttonState}
|
||||
onDownload={onDownload}
|
||||
variant="icon"
|
||||
size="sm"
|
||||
ariaLabel={`${buttonState.text} ${release.title}`}
|
||||
/>
|
||||
{/* Fixed: Action button or radio indicator */}
|
||||
{selectionMode ? (
|
||||
<RadioIndicator selected={isSelected} />
|
||||
) : (
|
||||
<BookDownloadButton
|
||||
buttonState={buttonState}
|
||||
onDownload={onDownload}
|
||||
variant="icon"
|
||||
size="sm"
|
||||
ariaLabel={`${buttonState.text} ${release.title}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile layout - author inline with title, info line below */}
|
||||
@@ -379,13 +426,17 @@ const ReleaseRow = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookDownloadButton
|
||||
buttonState={buttonState}
|
||||
onDownload={onDownload}
|
||||
variant="icon"
|
||||
size="sm"
|
||||
ariaLabel={`${buttonState.text} ${release.title}`}
|
||||
/>
|
||||
{selectionMode ? (
|
||||
<RadioIndicator selected={isSelected} />
|
||||
) : (
|
||||
<BookDownloadButton
|
||||
buttonState={buttonState}
|
||||
onDownload={onDownload}
|
||||
variant="icon"
|
||||
size="sm"
|
||||
ariaLabel={`${buttonState.text} ${release.title}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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<Release | null>(null);
|
||||
const isCombinedMode = combinedPhase !== null;
|
||||
|
||||
// Available sources from plugin registry
|
||||
const [availableSources, setAvailableSources] = useState<ReleaseSource[]>([]);
|
||||
@@ -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 = ({
|
||||
)}
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
Find Releases
|
||||
{isCombinedMode
|
||||
? `Step ${combinedCurrentStep} of ${combinedTotalSteps} — Select ${combinedPhase === 'ebook' ? 'book' : 'audiobook'}`
|
||||
: 'Find Releases'}
|
||||
</p>
|
||||
<h3 id={titleId} className="text-lg font-semibold leading-snug truncate">
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -2084,6 +2175,111 @@ export const ReleaseModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Combined mode footer */}
|
||||
{isCombinedMode && (
|
||||
<div className="border-t border-(--border-muted) bg-(--bg) sm:bg-(--bg-soft) px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 sm:gap-3">
|
||||
{/* Phase indicators with live selection chips */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-1 sm:gap-3 text-sm min-w-0">
|
||||
{(() => {
|
||||
const ebookRelease = combinedPhase === 'ebook' ? selectedRelease : stagedEbookRelease;
|
||||
const audiobookRelease = combinedPhase === 'audiobook' ? selectedRelease : stagedAudiobookRelease;
|
||||
return (
|
||||
<>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${
|
||||
ebookRelease
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400'
|
||||
: combinedPhase === 'ebook'
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 text-(--text)'
|
||||
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 dark:text-zinc-500'
|
||||
}`}>
|
||||
{ebookRelease ? (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
{ebookRelease.format?.toUpperCase() || 'Book'} · {ebookRelease.size || '?'}
|
||||
</>
|
||||
) : (
|
||||
<>{combinedPhase === 'ebook' ? '\u25CF' : '\u25CB'} Book</>
|
||||
)}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${
|
||||
audiobookRelease
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400'
|
||||
: combinedPhase === 'audiobook'
|
||||
? 'bg-zinc-100 dark:bg-zinc-800 text-(--text)'
|
||||
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 dark:text-zinc-500'
|
||||
}`}>
|
||||
{audiobookRelease ? (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
{audiobookRelease.format?.toUpperCase() || 'Audiobook'} · {audiobookRelease.size || '?'}
|
||||
</>
|
||||
) : (
|
||||
<>{combinedPhase === 'audiobook' ? '\u25CF' : '\u25CB'} Audiobook</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-end gap-3 shrink-0">
|
||||
{onCombinedBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const picked = selectedRelease; setSelectedRelease(stagedEbookRelease); onCombinedBack!(picked); }}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors hover-surface text-(--text)"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{combinedPhase === 'ebook' && onCombinedNext && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (selectedRelease) { const picked = selectedRelease; setSelectedRelease(stagedAudiobookRelease); onCombinedNext(picked); } }}
|
||||
disabled={!selectedRelease}
|
||||
className="px-4 py-1.5 text-sm font-medium text-white rounded-lg transition-colors bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Select Audiobook →
|
||||
</button>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedRelease && onCombinedDownload(selectedRelease)}
|
||||
disabled={!selectedRelease}
|
||||
className="px-4 py-1.5 text-sm font-medium text-white rounded-lg transition-colors bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,13 +13,15 @@ import {
|
||||
|
||||
interface RequestConfirmationModalProps {
|
||||
payload: CreateRequestPayload | null;
|
||||
extraPayloads?: CreateRequestPayload[];
|
||||
allowNotes: boolean;
|
||||
onConfirm: (payload: CreateRequestPayload) => Promise<boolean>;
|
||||
onConfirm: (payload: CreateRequestPayload, extraPayloads?: CreateRequestPayload[]) => Promise<boolean>;
|
||||
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<RequestConfirmationPreview | null>(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 = ({
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-(--border-muted) px-6 py-4">
|
||||
<h3 id={titleId} className="text-lg font-semibold">
|
||||
Request Book
|
||||
{extraPayloads.length > 0 ? 'Request Book & Audiobook' : 'Request Book'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -194,8 +200,24 @@ export const RequestConfirmationModal = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{preview.releaseLine && (
|
||||
<p className="text-xs opacity-60 mt-1.5">{preview.releaseLine}</p>
|
||||
{/* Release lines — show all (primary + extras) with content type labels when combined */}
|
||||
{(preview.releaseLine || extraPreviews.length > 0) && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{preview.releaseLine && (
|
||||
<p className="text-xs opacity-60">
|
||||
{extraPreviews.length > 0 && (
|
||||
<span className="font-medium opacity-80">{payload.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}</span>
|
||||
)}
|
||||
{preview.releaseLine}
|
||||
</p>
|
||||
)}
|
||||
{extraPreviews.map((ep, i) => ep.releaseLine && (
|
||||
<p key={i} className="text-xs opacity-60">
|
||||
<span className="font-medium opacity-80">{extraPayloads[i]?.context.content_type === 'ebook' ? 'Book: ' : 'Audiobook: '}</span>
|
||||
{ep.releaseLine}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useSearchMode } from '../contexts/SearchModeContext';
|
||||
import { Tooltip } from './shared/Tooltip';
|
||||
import {
|
||||
ContentType,
|
||||
MetadataSearchField,
|
||||
@@ -41,6 +42,8 @@ interface SearchBarProps {
|
||||
contentType?: ContentType;
|
||||
onContentTypeChange?: (type: ContentType) => void;
|
||||
allowedContentTypes?: ContentType[];
|
||||
combinedMode?: boolean;
|
||||
onCombinedModeChange?: (enabled: boolean) => void;
|
||||
queryTargets?: QueryTargetOption[];
|
||||
activeQueryTarget?: string;
|
||||
onQueryTargetChange?: (target: string) => void;
|
||||
@@ -88,17 +91,23 @@ const autocompleteOptionsCache = new Map<string, DynamicFieldOption[]>();
|
||||
const AUTOCOMPLETE_CACHE_MAX = 100;
|
||||
|
||||
const BookIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AudiobookIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BothIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
@@ -109,10 +118,12 @@ const getDefaultPlaceholder = (
|
||||
contentType: ContentType,
|
||||
activeQueryTarget: QueryTargetOption | undefined,
|
||||
fallback?: string,
|
||||
isCombinedMode?: boolean,
|
||||
): string => {
|
||||
if (fallback) return fallback;
|
||||
|
||||
if (!activeQueryTarget || activeQueryTarget.source === 'general') {
|
||||
if (isCombinedMode) return 'Search Books & Audiobooks (combined)';
|
||||
return contentType === 'ebook' ? 'Search Books' : 'Search Audiobooks';
|
||||
}
|
||||
|
||||
@@ -167,6 +178,8 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
contentType = 'ebook',
|
||||
onContentTypeChange,
|
||||
allowedContentTypes,
|
||||
combinedMode = false,
|
||||
onCombinedModeChange,
|
||||
queryTargets = [],
|
||||
activeQueryTarget = 'general',
|
||||
onQueryTargetChange,
|
||||
@@ -396,6 +409,18 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
|
||||
const handleContentTypeSelect = (type: ContentType) => {
|
||||
onContentTypeChange?.(type);
|
||||
onCombinedModeChange?.(false);
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
const handleCombinedModeSelect = () => {
|
||||
if (combinedMode) {
|
||||
// Toggle off — revert to ebook-only
|
||||
onCombinedModeChange?.(false);
|
||||
} else {
|
||||
onContentTypeChange?.('ebook');
|
||||
onCombinedModeChange?.(true);
|
||||
}
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
@@ -404,7 +429,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
setIsSelectorOpen(false);
|
||||
};
|
||||
|
||||
const effectivePlaceholder = getDefaultPlaceholder(contentType, activeTarget, placeholder);
|
||||
const effectivePlaceholder = getDefaultPlaceholder(contentType, activeTarget, placeholder, combinedMode);
|
||||
const effectiveInputAriaLabel = activeTarget
|
||||
? `${inputAriaLabel}: ${activeTarget.label}`
|
||||
: inputAriaLabel;
|
||||
@@ -611,11 +636,11 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
onClick={() => { setIsSelectorOpen((prev) => !prev); setIsSelectOpen(false); setIsAutocompleteOpen(false); }}
|
||||
className="flex items-center gap-1.5 pl-5 pr-2 rounded-l-full transition-colors hover-action"
|
||||
style={{ color: 'var(--text)' }}
|
||||
aria-label={`Searching ${contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
|
||||
aria-label={`Searching ${combinedMode ? 'books and audiobooks' : contentType === 'ebook' ? 'books' : 'audiobooks'} by ${activeTarget?.label ?? 'general'}. Click to change.`}
|
||||
aria-expanded={isSelectorOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
{contentType === 'ebook' ? <BookIcon /> : <AudiobookIcon />}
|
||||
{combinedMode ? <BothIcon /> : contentType === 'ebook' ? <BookIcon /> : <AudiobookIcon />}
|
||||
{showActiveTargetLabel && (
|
||||
<span className="hidden max-w-24 truncate text-sm font-medium sm:inline">
|
||||
{activeTarget?.label}
|
||||
@@ -650,7 +675,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
>
|
||||
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
|
||||
{showContentTypeSelector && (
|
||||
<div className="border-b pb-3" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className={`border-b ${onCombinedModeChange ? 'pb-0' : 'pb-3'}`} style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<div className="flex items-center justify-between px-1 pb-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide opacity-60">Content</span>
|
||||
{onAdvancedToggle && (
|
||||
@@ -693,29 +718,64 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('ebook')}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
contentType === 'ebook' || combinedMode ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
}`}
|
||||
style={contentType !== 'ebook'
|
||||
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
|
||||
style={contentType === 'ebook' || combinedMode
|
||||
? { borderColor: 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
{contentType === 'ebook' ? <CheckIcon /> : <BookIcon />}
|
||||
{contentType === 'ebook' || combinedMode ? <CheckIcon /> : <BookIcon />}
|
||||
<span>Books</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleContentTypeSelect('audiobook')}
|
||||
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
|
||||
contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
contentType === 'audiobook' || combinedMode ? 'bg-emerald-600 text-white' : 'hover-surface'
|
||||
}`}
|
||||
style={contentType !== 'audiobook'
|
||||
? { color: 'var(--text)', borderColor: 'var(--border-muted)' }
|
||||
: { borderColor: 'rgb(16 185 129 / 0.7)' }}
|
||||
style={contentType === 'audiobook' || combinedMode
|
||||
? { borderColor: 'rgb(16 185 129 / 0.7)' }
|
||||
: { color: 'var(--text)', borderColor: 'var(--border-muted)' }}
|
||||
>
|
||||
{contentType === 'audiobook' ? <CheckIcon /> : <AudiobookIcon />}
|
||||
{contentType === 'audiobook' || combinedMode ? <CheckIcon /> : <AudiobookIcon />}
|
||||
<span>Audiobooks</span>
|
||||
</button>
|
||||
</div>
|
||||
{onCombinedModeChange && (() => {
|
||||
const lineColor = combinedMode ? 'bg-emerald-500' : 'bg-(--border-muted) group-hover:bg-zinc-400 dark:group-hover:bg-zinc-500';
|
||||
return (
|
||||
<Tooltip content="Combined search" position="bottom" triggerClassName="w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCombinedModeSelect}
|
||||
className="group w-full"
|
||||
aria-label="Combined search"
|
||||
>
|
||||
{/* Bracket connector: vertical drops + horizontal bar with icon */}
|
||||
<div className="relative flex items-end h-7">
|
||||
{/* Left vertical */}
|
||||
<div className={`absolute left-[25%] top-1.5 bottom-[11px] w-px transition-colors ${lineColor}`} />
|
||||
{/* Right vertical */}
|
||||
<div className={`absolute right-[25%] top-1.5 bottom-[11px] w-px transition-colors ${lineColor}`} />
|
||||
{/* Horizontal bar – left segment */}
|
||||
<div className={`absolute left-[25%] bottom-[11px] h-px transition-colors ${lineColor}`} style={{ width: 'calc(25% - 16px)' }} />
|
||||
{/* Horizontal bar – right segment */}
|
||||
<div className={`absolute right-[25%] bottom-[11px] h-px transition-colors ${lineColor}`} style={{ width: 'calc(25% - 16px)' }} />
|
||||
{/* Chain icon centered at bottom */}
|
||||
<div className={`mx-auto relative z-10 p-1 rounded-full transition-colors ${
|
||||
combinedMode
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-(--bg) text-zinc-400 dark:text-zinc-500 group-hover:bg-zinc-200 dark:group-hover:bg-zinc-700 group-hover:text-zinc-600 dark:group-hover:text-zinc-300'
|
||||
}`}>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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<Requ
|
||||
});
|
||||
};
|
||||
|
||||
export const createRequests = async (payloads: CreateRequestPayload[]): Promise<RequestRecord[]> => {
|
||||
return fetchJSON<RequestRecord[]>(API.requestsBatch, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ requests: payloads }),
|
||||
});
|
||||
};
|
||||
|
||||
export const listRequests = async (params: ListRequestsParams = {}): Promise<RequestRecord[]> => {
|
||||
const url = buildRequestListUrl(API.requests, params);
|
||||
return fetchJSON<RequestRecord[]>(url);
|
||||
|
||||
@@ -221,6 +221,7 @@ export interface CreateRequestPayload {
|
||||
book_data: Record<string, unknown>;
|
||||
release_data?: Record<string, unknown> | 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 {
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user