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:
Alex
2026-03-16 18:34:46 +00:00
committed by GitHub
parent b464d62672
commit cb093f61c6
18 changed files with 1308 additions and 361 deletions
+17
View File
@@ -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",
+7 -1
View File
@@ -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
View File
@@ -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)
+130 -25
View File
@@ -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
View File
@@ -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()
+7
View File
@@ -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}")
+9
View File
@@ -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
View File
@@ -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([]); }}
/>
)}
+24 -24
View File
@@ -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' && (
+6
View File
@@ -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}
+257 -61
View File
@@ -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)"
>
&larr; 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 &rarr;
</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>
+76 -16
View File
@@ -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}
/>
+8
View File
@@ -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);
+3
View File
@@ -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 {
+2
View File
@@ -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",
]
+65
View File
@@ -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)