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:
Alex
2026-03-25 18:34:42 +00:00
committed by GitHub
parent 019d36b27e
commit 678c54cba2
11 changed files with 625 additions and 156 deletions
+31 -2
View File
@@ -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
+130 -6
View File
@@ -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():
+1 -25
View File
@@ -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 []
+21 -1
View File
@@ -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(
+97 -67
View File
@@ -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()
+39 -3
View File
@@ -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((
+5 -4
View File
@@ -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 }),
});
+12
View File
@@ -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 {
+160
View File
@@ -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)
+95
View File
@@ -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."""
+34 -48
View File
@@ -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)