mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 18:40:29 +01:00
Fixes: Entrypoint, seedtime, request policy flow (#805)
- Added a path for rootless permissions in the entrypoint script - Routed prowlarr searches through torznab for seedtime info - Added additional request flow for download permissions
This commit is contained in:
+31
-2
@@ -126,6 +126,35 @@ fi
|
||||
USERNAME=$(getent passwd "$RUN_UID" | cut -d: -f1)
|
||||
echo "Username for UID $RUN_UID is $USERNAME"
|
||||
|
||||
# Avoid unnecessary gosu hops when we're already running as the target user.
|
||||
# Some nested LXC setups spin on root-to-root gosu invocations.
|
||||
needs_user_switch() {
|
||||
local current_uid
|
||||
local current_gid
|
||||
|
||||
current_uid=$(id -u)
|
||||
current_gid=$(id -g)
|
||||
|
||||
[ "$current_uid" != "$RUN_UID" ] || [ "$current_gid" != "$RUN_GID" ]
|
||||
}
|
||||
|
||||
run_as_target_user() {
|
||||
if needs_user_switch; then
|
||||
gosu "$USERNAME" "$@"
|
||||
return $?
|
||||
fi
|
||||
|
||||
"$@"
|
||||
}
|
||||
|
||||
exec_as_target_user() {
|
||||
if needs_user_switch; then
|
||||
exec gosu "$USERNAME" "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
test_write() {
|
||||
local folder=$1
|
||||
local test_file="$folder/shelfmark_TEST_WRITE"
|
||||
@@ -138,7 +167,7 @@ test_write() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! gosu "$USERNAME" sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then
|
||||
if ! run_as_target_user sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then
|
||||
echo "Failed to write test file in $folder as $USERNAME"
|
||||
return 1
|
||||
fi
|
||||
@@ -400,4 +429,4 @@ echo "Setting umask to $UMASK_VALUE"
|
||||
umask $UMASK_VALUE
|
||||
|
||||
stop_file_logging
|
||||
exec gosu "$USERNAME" env HOME=/app $command
|
||||
exec_as_target_user env HOME=/app $command
|
||||
|
||||
@@ -381,6 +381,7 @@ def _prepare_request_create_arguments(
|
||||
},
|
||||
"actor_label": actor_label,
|
||||
"request_title": request_title,
|
||||
"resolved_mode": resolved_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -397,6 +398,69 @@ def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str
|
||||
return normalize_source(request_row.get("source_hint")), None
|
||||
|
||||
|
||||
def _build_queued_download_result(
|
||||
*,
|
||||
create_args: dict[str, Any],
|
||||
request_title: str,
|
||||
) -> dict[str, Any]:
|
||||
release_data = create_args.get("release_data")
|
||||
source = create_args.get("source_hint")
|
||||
source_id: str | None = None
|
||||
|
||||
if isinstance(release_data, dict):
|
||||
source = release_data.get("source") or source
|
||||
source_id = _normalize_optional_source_id(release_data.get("source_id"))
|
||||
|
||||
return {
|
||||
"kind": "download",
|
||||
"status": "queued",
|
||||
"priority": 0,
|
||||
"title": request_title,
|
||||
"source": normalize_source(source),
|
||||
"source_id": source_id,
|
||||
"content_type": create_args.get("content_type"),
|
||||
}
|
||||
|
||||
|
||||
def _queue_prepared_download_submission(
|
||||
user_db: UserDB,
|
||||
*,
|
||||
queue_release: Callable[..., tuple[bool, str | None]],
|
||||
create_args: dict[str, Any],
|
||||
request_title: str,
|
||||
) -> dict[str, Any]:
|
||||
release_data = create_args.get("release_data")
|
||||
if not isinstance(release_data, dict):
|
||||
raise RequestServiceError(
|
||||
"Download policy requires a concrete release",
|
||||
status_code=400,
|
||||
code="policy_requires_download",
|
||||
required_mode=PolicyMode.DOWNLOAD.value,
|
||||
)
|
||||
|
||||
requester = user_db.get_user(user_id=create_args["user_id"])
|
||||
if requester is None:
|
||||
raise RequestServiceError("Requesting user not found", status_code=404)
|
||||
|
||||
success, error = queue_release(
|
||||
dict(release_data),
|
||||
0,
|
||||
user_id=create_args["user_id"],
|
||||
username=requester.get("username"),
|
||||
)
|
||||
if not success:
|
||||
raise RequestServiceError(
|
||||
error or "Failed to queue release",
|
||||
status_code=409,
|
||||
code="queue_failed",
|
||||
)
|
||||
|
||||
return _build_queued_download_result(
|
||||
create_args=create_args,
|
||||
request_title=request_title,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def _notify_admin_for_request_event(
|
||||
@@ -544,6 +608,19 @@ def register_request_routes(
|
||||
|
||||
try:
|
||||
prepared = _prepare_request_create_arguments(user_db, data)
|
||||
if prepared["resolved_mode"] == PolicyMode.DOWNLOAD:
|
||||
queued = _queue_prepared_download_submission(
|
||||
user_db,
|
||||
queue_release=queue_release,
|
||||
create_args=prepared["create_args"],
|
||||
request_title=prepared["request_title"],
|
||||
)
|
||||
logger.info(
|
||||
"Policy download queued for '%s' by %s",
|
||||
prepared["request_title"],
|
||||
prepared["actor_label"],
|
||||
)
|
||||
return jsonify(queued), 200
|
||||
created = create_request(user_db, **prepared["create_args"])
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(
|
||||
@@ -604,10 +681,6 @@ def register_request_routes(
|
||||
_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),
|
||||
@@ -616,7 +689,34 @@ def register_request_routes(
|
||||
required_mode=exc.required_mode,
|
||||
)
|
||||
|
||||
for created, prepared in zip(created_rows, prepared_requests):
|
||||
request_prepared_items: list[tuple[int, dict[str, Any]]] = []
|
||||
download_prepared_items: list[tuple[int, dict[str, Any]]] = []
|
||||
|
||||
for index, prepared in enumerate(prepared_requests):
|
||||
if prepared["resolved_mode"] == PolicyMode.DOWNLOAD:
|
||||
download_prepared_items.append((index, prepared))
|
||||
continue
|
||||
|
||||
request_prepared_items.append((index, prepared))
|
||||
|
||||
created_rows: list[dict[str, Any]] = []
|
||||
if request_prepared_items:
|
||||
try:
|
||||
created_rows = create_requests(
|
||||
user_db,
|
||||
requests=[prepared["create_args"] for _, prepared in request_prepared_items],
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(
|
||||
str(exc),
|
||||
exc.status_code,
|
||||
code=exc.code,
|
||||
required_mode=exc.required_mode,
|
||||
)
|
||||
|
||||
results_by_index: dict[int, dict[str, Any]] = {}
|
||||
|
||||
for (index, prepared), created in zip(request_prepared_items, created_rows):
|
||||
event_payload = {
|
||||
"request_id": created["id"],
|
||||
"status": created["status"],
|
||||
@@ -645,8 +745,32 @@ def register_request_routes(
|
||||
event=NotificationEvent.REQUEST_CREATED,
|
||||
request_row=created,
|
||||
)
|
||||
results_by_index[index] = created
|
||||
|
||||
return jsonify(created_rows), 201
|
||||
for index, prepared in download_prepared_items:
|
||||
try:
|
||||
results_by_index[index] = _queue_prepared_download_submission(
|
||||
user_db,
|
||||
queue_release=queue_release,
|
||||
create_args=prepared["create_args"],
|
||||
request_title=prepared["request_title"],
|
||||
)
|
||||
except RequestServiceError as exc:
|
||||
return _error_response(
|
||||
str(exc),
|
||||
exc.status_code,
|
||||
code=exc.code,
|
||||
required_mode=exc.required_mode,
|
||||
)
|
||||
logger.info(
|
||||
"Policy download queued for '%s' by %s",
|
||||
prepared["request_title"],
|
||||
prepared["actor_label"],
|
||||
)
|
||||
|
||||
ordered_results = [results_by_index[index] for index in range(len(prepared_requests))]
|
||||
status_code = 201 if request_prepared_items else 200
|
||||
return jsonify(ordered_results), status_code
|
||||
|
||||
@app.route("/api/requests", methods=["GET"])
|
||||
def api_list_requests():
|
||||
|
||||
@@ -105,7 +105,7 @@ class ProwlarrClient:
|
||||
|
||||
def get_enriched_indexer_ids(self, *, restrict_to: Optional[List[int]] = None) -> List[int]:
|
||||
"""
|
||||
Return enabled indexer IDs that should use Torznab for richer metadata.
|
||||
Return enabled indexer IDs that benefit from extra Torznab handling.
|
||||
|
||||
Args:
|
||||
restrict_to: Optional list of candidate indexer IDs to consider.
|
||||
@@ -225,27 +225,3 @@ class ProwlarrClient:
|
||||
if 7000 <= subcat.get("id", 0) <= 7999:
|
||||
return True
|
||||
return False
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
indexer_ids: Optional[List[int]] = None,
|
||||
categories: Optional[List[int]] = None,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for releases via Prowlarr."""
|
||||
if not query:
|
||||
return []
|
||||
|
||||
params: Dict[str, Any] = {"query": query, "limit": limit}
|
||||
if indexer_ids:
|
||||
params["indexerIds"] = indexer_ids
|
||||
if categories:
|
||||
params["categories"] = categories
|
||||
|
||||
try:
|
||||
results = self._request("GET", "/api/v1/search", params=params)
|
||||
return results if isinstance(results, list) else []
|
||||
except Exception as e:
|
||||
logger.error(f"Prowlarr search failed: {e}")
|
||||
return []
|
||||
|
||||
@@ -25,6 +25,25 @@ COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
|
||||
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def _coerce_seed_time_minutes(raw_seed_time: object) -> Optional[int]:
|
||||
"""Convert Prowlarr's minimum seed time from seconds to whole minutes."""
|
||||
if raw_seed_time is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
seed_time_seconds = int(raw_seed_time)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"Invalid Prowlarr minimumSeedTime value: {raw_seed_time!r}")
|
||||
return None
|
||||
|
||||
if seed_time_seconds < 0:
|
||||
logger.warning(f"Ignoring negative Prowlarr minimumSeedTime value: {seed_time_seconds}")
|
||||
return None
|
||||
|
||||
# Round up so we never under-seed when a tracker uses a non-minute boundary.
|
||||
return (seed_time_seconds + 59) // 60
|
||||
|
||||
|
||||
@register_handler("prowlarr")
|
||||
class ProwlarrHandler(ExternalClientHandler):
|
||||
"""Handler for Prowlarr downloads via configured torrent or usenet client."""
|
||||
@@ -76,8 +95,9 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
|
||||
# Seed criteria from the indexer (Torznab attributes)
|
||||
raw_seed_time = prowlarr_result.get("minimumSeedTime")
|
||||
seeding_time_limit = int(raw_seed_time) if raw_seed_time is not None else None
|
||||
raw_ratio = prowlarr_result.get("minimumRatio")
|
||||
|
||||
seeding_time_limit = _coerce_seed_time_minutes(raw_seed_time)
|
||||
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
|
||||
|
||||
return DownloadRequest(
|
||||
|
||||
@@ -238,6 +238,50 @@ def _detect_content_type_from_categories(categories: list, fallback: str = "book
|
||||
return "other"
|
||||
|
||||
|
||||
def _extract_capability_category_ids(categories: list[dict]) -> set[int]:
|
||||
"""Flatten capability categories and subcategories into a single ID set."""
|
||||
category_ids: set[int] = set()
|
||||
|
||||
for category in categories:
|
||||
if not isinstance(category, dict):
|
||||
continue
|
||||
|
||||
category_id = category.get("id")
|
||||
if isinstance(category_id, int):
|
||||
category_ids.add(category_id)
|
||||
|
||||
for subcategory in category.get("subCategories", []):
|
||||
if not isinstance(subcategory, dict):
|
||||
continue
|
||||
subcategory_id = subcategory.get("id")
|
||||
if isinstance(subcategory_id, int):
|
||||
category_ids.add(subcategory_id)
|
||||
|
||||
return category_ids
|
||||
|
||||
|
||||
def _indexer_supports_search_categories(indexer: dict, categories: Optional[List[int]]) -> bool:
|
||||
"""Return whether an indexer should be queried for the requested categories."""
|
||||
if not categories:
|
||||
return True
|
||||
|
||||
capability_categories = indexer.get("capabilities", {}).get("categories", [])
|
||||
category_ids = _extract_capability_category_ids(capability_categories)
|
||||
if not category_ids:
|
||||
return True
|
||||
|
||||
for requested_category in categories:
|
||||
if requested_category == 7000:
|
||||
if any(cat_id in BOOK_CATEGORY_RANGE for cat_id in category_ids):
|
||||
return True
|
||||
continue
|
||||
|
||||
if requested_category in category_ids:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _prowlarr_result_to_release(
|
||||
result: dict,
|
||||
search_content_type: str = "ebook",
|
||||
@@ -551,6 +595,35 @@ class ProwlarrSource(ReleaseSource):
|
||||
logger.warning(f"Failed to resolve indexer names to IDs: {e}")
|
||||
return None
|
||||
|
||||
def _get_search_indexer_ids(
|
||||
self,
|
||||
client: ProwlarrClient,
|
||||
selected_indexer_ids: Optional[List[int]],
|
||||
categories: Optional[List[int]],
|
||||
) -> List[int]:
|
||||
"""Resolve the concrete indexer IDs to query via Torznab."""
|
||||
if selected_indexer_ids is not None:
|
||||
return selected_indexer_ids
|
||||
|
||||
try:
|
||||
enabled_indexers = client.get_enabled_indexers_detailed()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load enabled Prowlarr indexers: {e}")
|
||||
return []
|
||||
|
||||
indexer_ids: List[int] = []
|
||||
for indexer in enabled_indexers:
|
||||
if not _indexer_supports_search_categories(indexer, categories):
|
||||
continue
|
||||
|
||||
indexer_id = indexer.get("id")
|
||||
try:
|
||||
indexer_ids.append(int(indexer_id))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return indexer_ids
|
||||
|
||||
def search(
|
||||
self,
|
||||
book: BookMetadata,
|
||||
@@ -614,81 +687,39 @@ class ProwlarrSource(ReleaseSource):
|
||||
f"Searching Prowlarr: {query_type} ({len(variants)} variants), {indexer_desc}, categories={categories}"
|
||||
)
|
||||
|
||||
# Identify indexers that should be enriched via Torznab/Newznab.
|
||||
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
|
||||
non_enriched_indexer_ids: Optional[List[int]] = None
|
||||
if indexer_ids:
|
||||
non_enriched_indexer_ids = [i for i in indexer_ids if i not in enriched_indexer_ids]
|
||||
|
||||
def search_indexers(query: str, cats: Optional[List[int]], *, enriched_query: Optional[str] = None) -> List[dict]:
|
||||
"""Search indexers with given categories, collecting results.
|
||||
|
||||
Args:
|
||||
query: Query string for standard indexers (title only).
|
||||
cats: Category filter list.
|
||||
enriched_query: Optional query for enriched indexers (title + author).
|
||||
Falls back to ``query`` when not provided.
|
||||
"""
|
||||
results = []
|
||||
eq = enriched_query or query
|
||||
|
||||
# Search standard indexers via JSON endpoint.
|
||||
if indexer_ids:
|
||||
if non_enriched_indexer_ids:
|
||||
# Prefer a single request for selected indexers to reduce latency.
|
||||
try:
|
||||
raw = client.search(query=query, indexer_ids=non_enriched_indexer_ids, categories=cats)
|
||||
if raw:
|
||||
results.extend(raw)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Search failed for selected indexers {non_enriched_indexer_ids}: {e}. Falling back to per-indexer search."
|
||||
)
|
||||
|
||||
for indexer_id in non_enriched_indexer_ids:
|
||||
try:
|
||||
raw = client.search(query=query, indexer_ids=[indexer_id], categories=cats)
|
||||
if raw:
|
||||
results.extend(raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"Search failed for indexer {indexer_id}: {e}")
|
||||
else:
|
||||
# Search all enabled indexers at once, then remove enriched results (re-fetched via Torznab).
|
||||
try:
|
||||
raw = client.search(query=query, indexer_ids=None, categories=cats)
|
||||
if raw:
|
||||
if enriched_indexer_ids:
|
||||
raw = [r for r in raw if r.get("indexerId") not in enriched_indexer_ids]
|
||||
results.extend(raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"Search failed for all indexers: {e}")
|
||||
|
||||
# Search enriched indexers via Torznab/Newznab for richer metadata.
|
||||
# Use enriched_query (title + author) for better results on these indexers.
|
||||
for indexer_id in enriched_indexer_ids:
|
||||
raw = client.torznab_search(indexer_id=indexer_id, query=eq, categories=cats, search_type="book")
|
||||
if raw:
|
||||
results.extend(raw)
|
||||
else:
|
||||
# Fallback to JSON search for enriched indexers if Torznab fails.
|
||||
try:
|
||||
raw_fallback = client.search(query=eq, indexer_ids=[indexer_id], categories=cats)
|
||||
if raw_fallback:
|
||||
results.extend(raw_fallback)
|
||||
except Exception as e:
|
||||
logger.warning(f"Fallback search failed for enriched indexer {indexer_id}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
try:
|
||||
auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False)
|
||||
deadline = time.monotonic() + PROWLARR_SEARCH_TIMEOUT_SECONDS
|
||||
# Some indexers benefit from title+author queries and extra format detection.
|
||||
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
|
||||
enriched_indexer_ids_set = set(enriched_indexer_ids)
|
||||
|
||||
def _check_timeout() -> None:
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError(
|
||||
f"Prowlarr search timed out after {int(PROWLARR_SEARCH_TIMEOUT_SECONDS)}s"
|
||||
)
|
||||
|
||||
def search_indexers(query: str, cats: Optional[List[int]], *, enriched_query: Optional[str] = None) -> List[dict]:
|
||||
"""Search indexers with given categories via Torznab/Newznab."""
|
||||
results: List[dict] = []
|
||||
target_indexer_ids = self._get_search_indexer_ids(client, indexer_ids, cats)
|
||||
if not target_indexer_ids:
|
||||
return results
|
||||
|
||||
for indexer_id in target_indexer_ids:
|
||||
_check_timeout()
|
||||
indexer_query = enriched_query if indexer_id in enriched_indexer_ids_set and enriched_query else query
|
||||
raw = client.torznab_search(
|
||||
indexer_id=indexer_id,
|
||||
query=indexer_query,
|
||||
categories=cats,
|
||||
search_type="book",
|
||||
)
|
||||
if raw:
|
||||
results.extend(raw)
|
||||
|
||||
return results
|
||||
seen_keys: set[str] = set()
|
||||
all_results: List[dict] = []
|
||||
|
||||
@@ -722,7 +753,6 @@ class ProwlarrSource(ReleaseSource):
|
||||
seen_keys.add(key)
|
||||
all_results.append(r)
|
||||
|
||||
enriched_indexer_ids_set = set(enriched_indexer_ids)
|
||||
results: List[Release] = []
|
||||
enriched_source_ids: set[str] = set()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Book,
|
||||
Release,
|
||||
RequestRecord,
|
||||
RequestSubmissionResult,
|
||||
StatusData,
|
||||
AppConfig,
|
||||
ContentType,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
ActingAsUserSelection,
|
||||
MetadataProviderSummary,
|
||||
MetadataSearchConfig,
|
||||
QueuedDownloadResult,
|
||||
QueryTargetOption,
|
||||
SearchMode,
|
||||
isMetadataBook,
|
||||
@@ -143,6 +145,37 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const isQueuedDownloadResult = (value: unknown): value is QueuedDownloadResult => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const row = value as Record<string, unknown>;
|
||||
return row.kind === 'download' && row.status === 'queued';
|
||||
};
|
||||
|
||||
const getSubmissionSuccessMessage = (
|
||||
results: RequestSubmissionResult[],
|
||||
fallback: string,
|
||||
): string => {
|
||||
const queuedDownloads = results.filter(isQueuedDownloadResult);
|
||||
if (queuedDownloads.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (queuedDownloads.length === results.length) {
|
||||
if (queuedDownloads.length === 1) {
|
||||
const title = typeof queuedDownloads[0].title === 'string' && queuedDownloads[0].title.trim()
|
||||
? queuedDownloads[0].title.trim()
|
||||
: 'Untitled';
|
||||
return `Download queued: ${title}`;
|
||||
}
|
||||
return 'Downloads queued';
|
||||
}
|
||||
|
||||
return 'Download queued and request submitted';
|
||||
};
|
||||
|
||||
const CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE =
|
||||
'Download queued, but the proxy interrupted the response. Status will refresh shortly.';
|
||||
|
||||
@@ -1097,9 +1130,12 @@ function App() {
|
||||
const submitRequests = useCallback(
|
||||
async (payloads: CreateRequestPayload[], successMessage: string): Promise<boolean> => {
|
||||
try {
|
||||
await createRequests(payloads);
|
||||
const results = await createRequests(payloads);
|
||||
await refreshActivitySnapshot();
|
||||
showToast(successMessage, 'success');
|
||||
if (results.some(isQueuedDownloadResult)) {
|
||||
await fetchStatus();
|
||||
}
|
||||
showToast(getSubmissionSuccessMessage(results, successMessage), 'success');
|
||||
await refreshRequestPolicy({ force: true });
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -1111,7 +1147,7 @@ function App() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[showToast, refreshRequestPolicy, refreshActivitySnapshot]
|
||||
[fetchStatus, showToast, refreshRequestPolicy, refreshActivitySnapshot]
|
||||
);
|
||||
|
||||
const openRequestConfirmation = useCallback((
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RequestPolicyResponse,
|
||||
CreateRequestPayload,
|
||||
RequestRecord,
|
||||
RequestSubmissionResult,
|
||||
MetadataProvidersResponse,
|
||||
MetadataSearchConfig,
|
||||
} from '../types';
|
||||
@@ -585,15 +586,15 @@ export const fetchRequestPolicy = async (): Promise<RequestPolicyResponse> => {
|
||||
return fetchJSON<RequestPolicyResponse>(API.requestPolicy);
|
||||
};
|
||||
|
||||
export const createRequest = async (payload: CreateRequestPayload): Promise<RequestRecord> => {
|
||||
return fetchJSON<RequestRecord>(API.requests, {
|
||||
export const createRequest = async (payload: CreateRequestPayload): Promise<RequestSubmissionResult> => {
|
||||
return fetchJSON<RequestSubmissionResult>(API.requests, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export const createRequests = async (payloads: CreateRequestPayload[]): Promise<RequestRecord[]> => {
|
||||
return fetchJSON<RequestRecord[]>(API.requestsBatch, {
|
||||
export const createRequests = async (payloads: CreateRequestPayload[]): Promise<RequestSubmissionResult[]> => {
|
||||
return fetchJSON<RequestSubmissionResult[]>(API.requestsBatch, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ requests: payloads }),
|
||||
});
|
||||
|
||||
@@ -247,6 +247,18 @@ export interface RequestRecord {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface QueuedDownloadResult {
|
||||
kind: 'download';
|
||||
status: 'queued';
|
||||
priority: number;
|
||||
title: string;
|
||||
source: string;
|
||||
source_id: string | null;
|
||||
content_type?: ContentType;
|
||||
}
|
||||
|
||||
export type RequestSubmissionResult = RequestRecord | QueuedDownloadResult;
|
||||
|
||||
export type BooksOutputMode = 'folder' | 'booklore' | 'email';
|
||||
|
||||
export interface AppConfig {
|
||||
|
||||
@@ -227,6 +227,134 @@ class TestRequestRoutes:
|
||||
assert updated["user_id"] == user["id"]
|
||||
assert updated["status"] == "cancelled"
|
||||
|
||||
def test_download_policy_queues_release_without_creating_request(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="download")
|
||||
|
||||
payload = {
|
||||
"book_data": {
|
||||
"title": "Policy Download",
|
||||
"author": "Shelfmark",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "policy-download-1",
|
||||
},
|
||||
"context": {
|
||||
"source": "prowlarr",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "prowlarr",
|
||||
"source_id": "policy-download-release-1",
|
||||
"title": "Policy Download.epub",
|
||||
},
|
||||
}
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_queue_release(release_data, priority, user_id=None, username=None):
|
||||
captured["release_data"] = release_data
|
||||
captured["priority"] = priority
|
||||
captured["user_id"] = user_id
|
||||
captured["username"] = username
|
||||
return True, None
|
||||
|
||||
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):
|
||||
with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release):
|
||||
with patch("shelfmark.core.request_routes.notify_admin") as mock_notify_admin:
|
||||
with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user:
|
||||
resp = client.post("/api/requests", json=payload)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json["kind"] == "download"
|
||||
assert resp.json["status"] == "queued"
|
||||
assert resp.json["title"] == "Policy Download"
|
||||
assert resp.json["source"] == "prowlarr"
|
||||
assert resp.json["source_id"] == "policy-download-release-1"
|
||||
assert captured["priority"] == 0
|
||||
assert captured["user_id"] == user["id"]
|
||||
assert captured["username"] == user["username"]
|
||||
assert captured["release_data"]["source_id"] == "policy-download-release-1"
|
||||
assert main_module.user_db.list_requests(user_id=user["id"]) == []
|
||||
mock_notify_admin.assert_not_called()
|
||||
mock_notify_user.assert_not_called()
|
||||
|
||||
def test_batch_download_policy_queues_releases_without_creating_requests(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="download")
|
||||
|
||||
payloads = [
|
||||
{
|
||||
"book_data": {
|
||||
"title": "Batch Download One",
|
||||
"author": "Shelfmark",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "batch-download-1",
|
||||
},
|
||||
"context": {
|
||||
"source": "prowlarr",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "prowlarr",
|
||||
"source_id": "batch-download-release-1",
|
||||
"title": "Batch Download One.epub",
|
||||
},
|
||||
},
|
||||
{
|
||||
"book_data": {
|
||||
"title": "Batch Download Two",
|
||||
"author": "Shelfmark",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "batch-download-2",
|
||||
},
|
||||
"context": {
|
||||
"source": "prowlarr",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "prowlarr",
|
||||
"source_id": "batch-download-release-2",
|
||||
"title": "Batch Download Two.epub",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
queued: list[tuple[str, int, int | None, str | None]] = []
|
||||
|
||||
def fake_queue_release(release_data, priority, user_id=None, username=None):
|
||||
queued.append((release_data["source_id"], priority, user_id, username))
|
||||
return True, None
|
||||
|
||||
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):
|
||||
with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release):
|
||||
with patch("shelfmark.core.request_routes.notify_admin") as mock_notify_admin:
|
||||
resp = client.post("/api/requests/batch", json={"requests": payloads})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert [row["kind"] for row in resp.json] == ["download", "download"]
|
||||
assert [row["source_id"] for row in resp.json] == [
|
||||
"batch-download-release-1",
|
||||
"batch-download-release-2",
|
||||
]
|
||||
assert queued == [
|
||||
("batch-download-release-1", 0, user["id"], user["username"]),
|
||||
("batch-download-release-2", 0, user["id"], user["username"]),
|
||||
]
|
||||
assert main_module.user_db.list_requests(user_id=user["id"]) == []
|
||||
mock_notify_admin.assert_not_called()
|
||||
|
||||
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")
|
||||
@@ -1249,6 +1377,38 @@ class TestRequestCreationEdgeCases:
|
||||
assert resp.status_code == 403
|
||||
assert resp.json["code"] == "requests_unavailable"
|
||||
|
||||
def test_download_policy_without_concrete_release_returns_400(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="download")
|
||||
|
||||
payload = {
|
||||
"book_data": {
|
||||
"title": "Needs Release Selection",
|
||||
"author": "Shelfmark",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "needs-release-selection",
|
||||
},
|
||||
"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):
|
||||
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
|
||||
resp = client.post("/api/requests", json=payload)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json["code"] == "policy_requires_download"
|
||||
assert resp.json["required_mode"] == "download"
|
||||
mock_queue_release.assert_not_called()
|
||||
assert main_module.user_db.list_requests(user_id=user["id"]) == []
|
||||
|
||||
def test_blocked_policy_returns_403(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)
|
||||
|
||||
@@ -206,6 +206,101 @@ class TestProwlarrHandlerDownloadErrors:
|
||||
assert "client" in recorder.last_message.lower()
|
||||
|
||||
|
||||
class TestProwlarrHandlerSeedCriteria:
|
||||
"""Tests for seed criteria passed through from Prowlarr."""
|
||||
|
||||
def test_resolve_download_converts_seed_time_seconds_to_minutes(self):
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"minimumSeedTime": 259200,
|
||||
"minimumRatio": 1,
|
||||
},
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="seed-time-conversion",
|
||||
source="prowlarr",
|
||||
title="Test Book",
|
||||
)
|
||||
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
|
||||
assert request is not None
|
||||
assert request.seeding_time_limit == 4320
|
||||
assert request.ratio_limit == 1.0
|
||||
|
||||
def test_resolve_download_rounds_seed_time_up_to_next_minute(self):
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"minimumSeedTime": 61,
|
||||
},
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="seed-time-round-up",
|
||||
source="prowlarr",
|
||||
title="Test Book",
|
||||
)
|
||||
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
|
||||
assert request is not None
|
||||
assert request.seeding_time_limit == 2
|
||||
|
||||
def test_download_passes_seed_limits_to_client(self):
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
mock_client.find_existing.return_value = None
|
||||
mock_client.add_download.return_value = "download_id"
|
||||
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"minimumSeedTime": 259200,
|
||||
"minimumRatio": 1.25,
|
||||
},
|
||||
), patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_client",
|
||||
return_value=mock_client,
|
||||
), patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.remove_release",
|
||||
), patch.object(
|
||||
ProwlarrHandler,
|
||||
"_poll_and_complete",
|
||||
return_value=None,
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="seed-limit-pass-through",
|
||||
source="prowlarr",
|
||||
title="Test Book",
|
||||
)
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
call_kwargs = mock_client.add_download.call_args.kwargs
|
||||
assert call_kwargs["seeding_time_limit"] == 4320
|
||||
assert call_kwargs["ratio_limit"] == 1.25
|
||||
|
||||
|
||||
class TestProwlarrHandlerExistingDownload:
|
||||
"""Tests for handling existing downloads."""
|
||||
|
||||
|
||||
@@ -208,19 +208,38 @@ class TestDetectContentType:
|
||||
assert _detect_content_type_from_categories([{"id": 2000}], "ebook") == "other"
|
||||
|
||||
|
||||
class FakeTorznabClient:
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, object]] = []
|
||||
self.queries: list[str] = []
|
||||
|
||||
def get_enabled_indexers_detailed(self):
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"enable": True,
|
||||
"capabilities": {
|
||||
"categories": [
|
||||
{"id": 7000, "subCategories": []},
|
||||
{"id": 3030, "subCategories": []},
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def torznab_search(self, *, indexer_id: int, query: str, categories=None, search_type="book", limit=100, offset=0):
|
||||
del indexer_id, search_type, limit, offset
|
||||
self.calls.append((query, categories))
|
||||
self.queries.append(query)
|
||||
return []
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None):
|
||||
del restrict_to
|
||||
return []
|
||||
|
||||
|
||||
class TestProwlarrLocalizedQueries:
|
||||
def test_manual_query_still_applies_content_type_categories(self, monkeypatch):
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, object]] = []
|
||||
|
||||
def search(self, query: str, indexer_ids=None, categories=None):
|
||||
self.calls.append((query, categories))
|
||||
return []
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None):
|
||||
return []
|
||||
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
|
||||
def fake_get(key: str, default=None):
|
||||
@@ -232,7 +251,7 @@ class TestProwlarrLocalizedQueries:
|
||||
|
||||
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
|
||||
|
||||
fake_client = FakeClient()
|
||||
fake_client = FakeTorznabClient()
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
|
||||
|
||||
@@ -251,17 +270,6 @@ class TestProwlarrLocalizedQueries:
|
||||
assert fake_client.calls == [("my custom", [3030])]
|
||||
|
||||
def test_manual_query_expand_removes_categories(self, monkeypatch):
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.calls: list[tuple[str, object]] = []
|
||||
|
||||
def search(self, query: str, indexer_ids=None, categories=None):
|
||||
self.calls.append((query, categories))
|
||||
return []
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None):
|
||||
return []
|
||||
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
|
||||
def fake_get(key: str, default=None):
|
||||
@@ -273,7 +281,7 @@ class TestProwlarrLocalizedQueries:
|
||||
|
||||
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
|
||||
|
||||
fake_client = FakeClient()
|
||||
fake_client = FakeTorznabClient()
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
|
||||
|
||||
@@ -292,17 +300,6 @@ class TestProwlarrLocalizedQueries:
|
||||
assert fake_client.calls == [("my custom", None)]
|
||||
|
||||
def test_search_uses_localized_titles_when_available(self, monkeypatch):
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.queries: list[str] = []
|
||||
|
||||
def search(self, query: str, indexer_ids=None, categories=None):
|
||||
self.queries.append(query)
|
||||
return []
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None):
|
||||
return []
|
||||
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
|
||||
def fake_get(key: str, default=None):
|
||||
@@ -314,7 +311,7 @@ class TestProwlarrLocalizedQueries:
|
||||
|
||||
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
|
||||
|
||||
fake_client = FakeClient()
|
||||
fake_client = FakeTorznabClient()
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
|
||||
|
||||
@@ -336,17 +333,6 @@ class TestProwlarrLocalizedQueries:
|
||||
assert len(fake_client.queries) == 2
|
||||
|
||||
def test_search_does_not_override_search_title_for_english(self, monkeypatch):
|
||||
class FakeClient:
|
||||
def __init__(self):
|
||||
self.queries: list[str] = []
|
||||
|
||||
def search(self, query: str, indexer_ids=None, categories=None):
|
||||
self.queries.append(query)
|
||||
return []
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None):
|
||||
return []
|
||||
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
|
||||
def fake_get(key: str, default=None):
|
||||
@@ -358,7 +344,7 @@ class TestProwlarrLocalizedQueries:
|
||||
|
||||
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
|
||||
|
||||
fake_client = FakeClient()
|
||||
fake_client = FakeTorznabClient()
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user