Align missing-episode searches and permit reviewed profile overrides
This commit is contained in:
@@ -66,6 +66,9 @@ class SonarrClient(ApiClient):
|
||||
timeout_seconds=90.0,
|
||||
)
|
||||
|
||||
async def search_episode_releases(self, episode_id: int) -> Optional[Any]:
|
||||
return await self.get('/api/v3/release', params={'episodeId': episode_id}, timeout_seconds=90.0)
|
||||
|
||||
async def search(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
return await self.post("/api/v3/command", payload={"name": "SeriesSearch", "seriesId": series_id})
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Live account permissions. Invite access uses the existing users column."""
|
||||
from .db import _connect
|
||||
|
||||
FEATURES = ("stats", "requests", "new_requests", "issues", "invites")
|
||||
FEATURES = ("stats", "requests", "new_requests", "issues", "invites", "ignore_profile_limits")
|
||||
|
||||
|
||||
def permissions(user: dict) -> dict[str, bool]:
|
||||
if user.get("role") == "admin":
|
||||
return dict.fromkeys(FEATURES, True)
|
||||
values = dict.fromkeys(FEATURES, True)
|
||||
values["ignore_profile_limits"] = False
|
||||
values["invites"] = bool(user.get("invite_management_enabled", False))
|
||||
with _connect() as conn:
|
||||
rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
|
||||
|
||||
+61
-168
@@ -1,3 +1,4 @@
|
||||
from ..services import manual_releases
|
||||
from ..services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome, series_search_outcome
|
||||
from ..feature_guards import require_request_access
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -1658,34 +1659,7 @@ def _format_rejections(rejections: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]:
|
||||
if isinstance(response, list):
|
||||
if not response:
|
||||
return False, "the collector returned no download decision"
|
||||
reasons: List[str] = []
|
||||
for item in response:
|
||||
accepted, reason = _release_push_accepted(item)
|
||||
if accepted:
|
||||
return True, None
|
||||
if reason:
|
||||
reasons.append(reason)
|
||||
return False, "; ".join(dict.fromkeys(reasons)) or "rejected"
|
||||
if not isinstance(response, dict):
|
||||
return True, None
|
||||
rejections = response.get("rejections") or response.get("rejectionReasons")
|
||||
reason = _format_rejections(rejections)
|
||||
if reason:
|
||||
return False, reason
|
||||
if response.get("rejected") is True:
|
||||
return False, "rejected"
|
||||
if response.get("downloadAllowed") is False:
|
||||
return False, "download not allowed"
|
||||
if response.get("approved") is False:
|
||||
return False, "not approved"
|
||||
return True, None
|
||||
|
||||
|
||||
def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
||||
def _filter_arr_release_results(results: Any, include_rejected: bool = False) -> List[Dict[str, Any]]:
|
||||
if not isinstance(results, list):
|
||||
return []
|
||||
keep: List[Dict[str, Any]] = []
|
||||
@@ -1696,14 +1670,8 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
||||
key = (item.get("indexerId"), item.get("guid"))
|
||||
if not key[0] or not key[1] or key in seen:
|
||||
continue
|
||||
rejections = item.get("rejections")
|
||||
if (
|
||||
item.get("approved") is not True
|
||||
or item.get("rejected") is True
|
||||
or item.get("temporarilyRejected") is True
|
||||
or item.get("downloadAllowed") is False
|
||||
or isinstance(rejections, list) and bool(rejections)
|
||||
):
|
||||
accepted, override, reasons = manual_releases.decision(item)
|
||||
if not accepted and not include_rejected:
|
||||
continue
|
||||
seen.add(key)
|
||||
quality_payload = item.get("quality")
|
||||
@@ -1728,7 +1696,7 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
||||
"downloadUrl": item.get("downloadUrl"),
|
||||
"magnetUrl": item.get("magnetUrl"),
|
||||
"protocol": item.get("protocol"),
|
||||
"approved": item.get("approved"),
|
||||
"approved": accepted,
|
||||
"rejected": item.get("rejected"),
|
||||
"temporarilyRejected": item.get("temporarilyRejected"),
|
||||
"rejections": item.get("rejections"),
|
||||
@@ -1736,46 +1704,20 @@ def _filter_arr_release_results(results: Any) -> List[Dict[str, Any]]:
|
||||
"fullSeason": item.get("fullSeason"),
|
||||
"seasonNumber": item.get("seasonNumber"),
|
||||
"quality": quality_name,
|
||||
"requiresOverride": override,
|
||||
"selectable": accepted or override,
|
||||
"rejections": reasons,
|
||||
"episodeNumbers": item.get("mappedEpisodeNumbers") or item.get("episodeNumbers"),
|
||||
"customFormatScore": item.get("customFormatScore"),
|
||||
}
|
||||
)
|
||||
releases = keep[:30]
|
||||
keep.sort(key=lambda item: (not bool(item.get("approved")), not item["requiresOverride"]))
|
||||
releases = keep[:200]
|
||||
for index, release in enumerate(releases):
|
||||
release["bestPick"] = index == 0
|
||||
release["bestPick"] = index == 0 and release.get("approved") is True
|
||||
return releases
|
||||
|
||||
|
||||
def _build_release_push_payload(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
title = payload.get("title")
|
||||
download_url = payload.get("downloadUrl")
|
||||
magnet_url = payload.get("magnetUrl")
|
||||
protocol = str(payload.get("protocol") or "").strip().lower()
|
||||
if protocol not in {"torrent", "usenet"}:
|
||||
protocol = "torrent" if magnet_url or str(download_url or "").startswith("magnet:") else "usenet"
|
||||
if not isinstance(title, str) or not title.strip() or not download_url and not magnet_url:
|
||||
return None
|
||||
publish_date = payload.get("publishDate")
|
||||
if not isinstance(publish_date, str) or not publish_date.strip():
|
||||
publish_date = datetime.now(timezone.utc).isoformat()
|
||||
result: Dict[str, Any] = {
|
||||
"title": title.strip(),
|
||||
"protocol": protocol,
|
||||
"publishDate": publish_date,
|
||||
"indexer": payload.get("indexer") or "Magent manual selection",
|
||||
}
|
||||
if isinstance(download_url, str) and download_url.strip():
|
||||
if download_url.startswith("magnet:"):
|
||||
result["magnetUrl"] = download_url
|
||||
else:
|
||||
result["downloadUrl"] = download_url
|
||||
if isinstance(magnet_url, str) and magnet_url.strip():
|
||||
result["magnetUrl"] = magnet_url
|
||||
for key in ("guid", "infoUrl", "size", "seeders", "leechers"):
|
||||
if payload.get(key) is not None:
|
||||
result[key] = payload[key]
|
||||
return result
|
||||
|
||||
|
||||
def _missing_episode_ids_by_season(episodes: Any) -> Dict[int, List[int]]:
|
||||
if not isinstance(episodes, list):
|
||||
return {}
|
||||
@@ -3356,7 +3298,11 @@ async def accept_request_language(request_id: str, payload: dict, user: dict = D
|
||||
|
||||
|
||||
@router.post("/{request_id}/actions/search")
|
||||
async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user), offset: int = 0) -> dict:
|
||||
if offset < 0:
|
||||
raise HTTPException(400, 'Search offset must be zero or greater.')
|
||||
total_missing = 0
|
||||
next_offset = None
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
@@ -3387,15 +3333,23 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
||||
message,
|
||||
)
|
||||
return {"status": "ok", "message": message, "collector": collector, "releases": []}
|
||||
searches = await asyncio.gather(
|
||||
*(sonarr.search_releases(int(arr_item["id"]), season) for season in season_numbers)
|
||||
)
|
||||
season_results = [item for item in searches if isinstance(item, list)]
|
||||
longest_result = max((len(item) for item in season_results), default=0)
|
||||
for position in range(longest_result):
|
||||
for search_results in season_results:
|
||||
if position < len(search_results):
|
||||
results.append(search_results[position])
|
||||
missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]]
|
||||
total_missing = len(missing_ids)
|
||||
batch = missing_ids[offset:offset + 20]
|
||||
next_offset = offset + len(batch) if offset + len(batch) < total_missing else None
|
||||
semaphore = asyncio.Semaphore(3)
|
||||
async def search_episode(identity):
|
||||
async with semaphore:
|
||||
found = await sonarr.search_episode_releases(identity)
|
||||
if not isinstance(found, list):
|
||||
raise HTTPException(502, 'Sonarr did not return valid episode search results. Try again.')
|
||||
return found
|
||||
searches = await asyncio.gather(*(search_episode(identity) for identity in batch))
|
||||
# Interleave per-episode rankings so a prolific episode cannot hide the others.
|
||||
for position in range(max((len(items) for items in searches), default=0)):
|
||||
for items in searches:
|
||||
if position < len(items):
|
||||
results.append(items[position])
|
||||
elif snapshot.request_type == RequestType.movie:
|
||||
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
if not radarr.configured():
|
||||
@@ -3432,28 +3386,27 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=detail) from exc
|
||||
|
||||
releases = _filter_arr_release_results(results)
|
||||
releases = _filter_arr_release_results(results, include_rejected=True)
|
||||
approved = sum(not r['requiresOverride'] and r['selectable'] for r in releases)
|
||||
source = runtime.sonarr_base_url if collector == 'Sonarr' else runtime.radarr_base_url
|
||||
override_allowed = manual_releases.can_override(user)
|
||||
for release in releases:
|
||||
if release['selectable'] and (not release['requiresOverride'] or override_allowed):
|
||||
release['selectionToken'] = manual_releases.issue_selection(release, request_id, user, source, arr_item['id'])
|
||||
for key in ('downloadUrl', 'magnetUrl'):
|
||||
release.pop(key, None)
|
||||
rejection_reasons = sorted({str(reason) for result in results for reason in (result.get('rejections') or [])})[:8]
|
||||
result_message = (f"{collector} approved {len(releases)} releases against its assigned quality profile."
|
||||
if releases else f"No approved releases were found. " + (' '.join(rejection_reasons) if rejection_reasons else 'The indexers returned no suitable results. Try again later or review the audio language.'))
|
||||
|
||||
await asyncio.to_thread(
|
||||
save_action,
|
||||
request_id,
|
||||
"search_releases",
|
||||
"Search and choose a download",
|
||||
"ok",
|
||||
f"{collector} approved {len(releases)} releases against its assigned quality profile.",
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"collector": collector,
|
||||
"qualityFiltered": True,
|
||||
"message": result_message,
|
||||
"outcome": "matches" if releases else "attention",
|
||||
"rejectionReasons": rejection_reasons,
|
||||
"releases": releases,
|
||||
}
|
||||
message = (f'{len(releases)} releases shown; {approved} meet the assigned profile. Review the reasons on other releases.'
|
||||
if releases else 'No releases were returned for the missing content. Try again later or check the indexers.')
|
||||
if len(results) > len(releases):
|
||||
message += ' Duplicate results are combined; up to 200 ranked releases are shown.'
|
||||
if total_missing:
|
||||
message += f' Searched episodes {offset + 1}?{min(offset + 20, total_missing)} of {total_missing} missing monitored episodes.'
|
||||
await asyncio.to_thread(save_action, request_id, 'search_releases', 'Search and choose a download', 'ok', message)
|
||||
return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message,
|
||||
'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons,
|
||||
'canIgnoreProfileLimits': override_allowed, 'nextOffset': next_offset,
|
||||
'totalMissingEpisodes': total_missing, 'releases': releases}
|
||||
|
||||
|
||||
@router.post("/{request_id}/actions/search_auto")
|
||||
@@ -3473,23 +3426,10 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
||||
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Sonarr not configured")
|
||||
target_profile_id = _quality_profile_id(runtime.sonarr_quality_profile_id)
|
||||
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
|
||||
profile_message = None
|
||||
series_id = _quality_profile_id(arr_item.get("id"))
|
||||
if target_profile_id and series_id and current_profile_id != target_profile_id:
|
||||
series = await client.get_series(series_id)
|
||||
if not isinstance(series, dict):
|
||||
raise HTTPException(status_code=502, detail="Could not load Sonarr series before search")
|
||||
series["qualityProfileId"] = target_profile_id
|
||||
await client.update_series(series)
|
||||
profile_message = f"Sonarr quality profile updated to {target_profile_id} before search."
|
||||
episodes = await client.get_episodes(int(arr_item["id"]))
|
||||
missing_by_season = _missing_episode_ids_by_season(episodes)
|
||||
if not missing_by_season:
|
||||
message = "No missing monitored episodes found."
|
||||
if profile_message:
|
||||
message = f"{profile_message} {message}"
|
||||
await asyncio.to_thread(
|
||||
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
||||
)
|
||||
@@ -3504,8 +3444,6 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
||||
)
|
||||
outcome = await series_search_outcome(client, int(arr_item["id"]), [item['response'] for item in responses])
|
||||
message = outcome['message']
|
||||
if profile_message:
|
||||
message = f"{profile_message} {message}"
|
||||
await asyncio.to_thread(
|
||||
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
||||
)
|
||||
@@ -3514,27 +3452,9 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
|
||||
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
if not client.configured():
|
||||
raise HTTPException(status_code=400, detail="Radarr not configured")
|
||||
target_profile_id = _quality_profile_id(runtime.radarr_quality_profile_id)
|
||||
current_profile_id = _quality_profile_id(arr_item.get("qualityProfileId"))
|
||||
if current_profile_id and current_profile_id != target_profile_id:
|
||||
profiles = await client.get_quality_profiles()
|
||||
current = next((p for p in profiles if p.get("id") == current_profile_id), {})
|
||||
if is_original_profile(current):
|
||||
target_profile_id = current_profile_id
|
||||
profile_message = None
|
||||
movie_id = _quality_profile_id(arr_item.get("id"))
|
||||
if target_profile_id and movie_id and current_profile_id != target_profile_id:
|
||||
movie = await client.get_movie(movie_id)
|
||||
if not isinstance(movie, dict):
|
||||
raise HTTPException(status_code=502, detail="Could not load Radarr movie before search")
|
||||
movie["qualityProfileId"] = target_profile_id
|
||||
await client.update_movie(movie)
|
||||
profile_message = f"Radarr quality profile updated to {target_profile_id} before search."
|
||||
response = await client.search(int(arr_item["id"]))
|
||||
outcome = await movie_search_outcome(client, int(arr_item["id"]), response)
|
||||
message = outcome['message']
|
||||
if profile_message:
|
||||
message = f"{profile_message} {message}"
|
||||
await asyncio.to_thread(
|
||||
save_action, request_id, "search_auto", "Search and auto-download", "ok", message
|
||||
)
|
||||
@@ -3786,11 +3706,16 @@ async def action_grab(
|
||||
if not arr_client.configured():
|
||||
raise HTTPException(status_code=400, detail=f"{service_label} not configured")
|
||||
|
||||
arr_item = snapshot.raw.get('arr', {}).get('item') or {}
|
||||
source = runtime.sonarr_base_url if service_label == 'Sonarr' else runtime.radarr_base_url
|
||||
receipt = manual_releases.verify_selection(payload, request_id, user, source, arr_item.get('id'))
|
||||
release_title = receipt.get('title')
|
||||
arr_error: Optional[str] = None
|
||||
try:
|
||||
response = await arr_client.grab_release(str(guid), arr_indexer_id)
|
||||
action_message = (
|
||||
f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
|
||||
+ (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '')
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
save_action, request_id, "grab", "Download selected release", "ok", action_message
|
||||
@@ -3804,40 +3729,8 @@ async def action_grab(
|
||||
_log_arr_http_error(service_label, "release grab", exc)
|
||||
status_code = exc.response.status_code if exc.response is not None else None
|
||||
arr_error = _format_upstream_error(service_label, exc)
|
||||
push_payload = _build_release_push_payload(payload) if status_code == 404 else None
|
||||
if push_payload is not None:
|
||||
logger.info(
|
||||
"%s release cache miss; retrying through release push request_id=%s",
|
||||
service_label,
|
||||
request_id,
|
||||
)
|
||||
try:
|
||||
response = await arr_client.push_release(push_payload)
|
||||
accepted, rejection = _release_push_accepted(response)
|
||||
if accepted:
|
||||
action_message = (
|
||||
f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
save_action,
|
||||
request_id,
|
||||
"grab",
|
||||
"Download selected release",
|
||||
"ok",
|
||||
action_message,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": action_message,
|
||||
"response": {"collector": service_label, "queued": True},
|
||||
}
|
||||
arr_error = rejection or f"{service_label} rejected the selected release"
|
||||
except httpx.HTTPStatusError as push_exc:
|
||||
_log_arr_http_error(service_label, "release push", push_exc)
|
||||
arr_error = _format_upstream_error(service_label, push_exc)
|
||||
except Exception as push_exc:
|
||||
logger.exception("%s release push failed request_id=%s", service_label, request_id)
|
||||
arr_error = str(push_exc)
|
||||
if status_code == 404:
|
||||
raise HTTPException(409, 'The collector no longer has this release cached. Search again before downloading.') from exc
|
||||
except Exception as exc:
|
||||
logger.exception("%s release grab failed request_id=%s", service_label, request_id)
|
||||
arr_error = str(exc)
|
||||
|
||||
@@ -86,7 +86,7 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
|
||||
kept = next(account for account in accounts if account['id'] == keep_id)
|
||||
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
|
||||
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
|
||||
overrides.get((account['id'], key), True) for account in accounts) for key in FEATURES}
|
||||
overrides.get((account['id'], key), key != 'ignore_profile_limits') for account in accounts) for key in FEATURES}
|
||||
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
|
||||
try:
|
||||
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Manual collector decisions and short-lived, request-bound selection receipts."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
from ..config import settings
|
||||
|
||||
|
||||
def can_override(user):
|
||||
return user.get('role') == 'admin' or (user.get('features') or {}).get('ignore_profile_limits') is True
|
||||
|
||||
|
||||
def decision(item):
|
||||
reasons = [str(r) for r in (item.get('rejections') or [])]
|
||||
accepted = (item.get('approved') is True and not reasons and not item.get('rejected')
|
||||
and not item.get('temporarilyRejected') and item.get('downloadAllowed') is not False)
|
||||
# Unknown/operational rejections remain blocked. This permission only relaxes profile limits.
|
||||
profile_only = bool(reasons) and all(any(term in reason.lower() for term in (
|
||||
'quality profile', 'not wanted in profile', 'custom format', 'minimum score',
|
||||
'quality is not', 'quality for', 'language', 'maximum size', 'minimum size',
|
||||
'larger than', 'smaller than', 'size limit', 'release profile',
|
||||
)) for reason in reasons)
|
||||
override = not accepted and profile_only and item.get('downloadAllowed') is not False and not item.get('temporarilyRejected')
|
||||
return accepted, override, reasons
|
||||
|
||||
|
||||
def source_id(url):
|
||||
return hashlib.sha256(str(url).rstrip('/').encode()).hexdigest()
|
||||
|
||||
|
||||
def issue_selection(release, request_id, user, source, item_id):
|
||||
return jwt.encode({'aud': 'manual-release', 'sub': user['username'], 'request': str(request_id),
|
||||
'source': source_id(source), 'item': item_id, 'guid': release['guid'],
|
||||
'indexer': release['indexerId'], 'title': release.get('title'),
|
||||
'override': release['requiresOverride'], 'rejections': release['rejections'],
|
||||
'exp': datetime.now(timezone.utc) + timedelta(minutes=10)},
|
||||
settings.jwt_secret, algorithm='HS256')
|
||||
|
||||
|
||||
def verify_selection(payload, request_id, user, source, item_id):
|
||||
try:
|
||||
receipt = jwt.decode(payload.get('selectionToken', ''), settings.jwt_secret,
|
||||
algorithms=['HS256'], audience='manual-release')
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise HTTPException(409, 'This release selection expired or is invalid. Search again before downloading.') from exc
|
||||
if (receipt.get('sub') != user.get('username') or receipt.get('request') != str(request_id)
|
||||
or receipt.get('source') != source_id(source) or receipt.get('item') != item_id
|
||||
or receipt.get('guid') != payload.get('guid') or receipt.get('indexer') != payload.get('indexerId')):
|
||||
raise HTTPException(409, 'This release does not belong to this account and request. Search again.')
|
||||
if receipt.get('override'):
|
||||
if not can_override(user):
|
||||
raise HTTPException(403, 'Ignore profile limits is disabled for your account.')
|
||||
if payload.get('ignoreProfileLimits') is not True:
|
||||
raise HTTPException(400, 'Explicitly confirm ignoring the profile limits for this release.')
|
||||
return receipt
|
||||
@@ -1128,6 +1128,18 @@ class ArrAddPayloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
from backend.app.config import settings
|
||||
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
|
||||
secret.start()
|
||||
self.addCleanup(secret.stop)
|
||||
|
||||
def selection(self, payload, request_id, source):
|
||||
payload['selectionToken'] = requests_router.manual_releases.issue_selection(
|
||||
{**payload, 'requiresOverride': False, 'rejections': []}, request_id,
|
||||
{'username': 'viewer'}, source, None)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _runtime() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
@@ -1155,7 +1167,7 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
|
||||
]
|
||||
),
|
||||
search_releases=AsyncMock(
|
||||
search_episode_releases=AsyncMock(
|
||||
side_effect=[
|
||||
[
|
||||
{
|
||||
@@ -1194,15 +1206,17 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
"3909", user={"username": "viewer", "role": "user"}
|
||||
)
|
||||
|
||||
sonarr.search_releases.assert_any_await(42, 1)
|
||||
sonarr.search_releases.assert_any_await(42, 2)
|
||||
sonarr.search_episode_releases.assert_any_await(101)
|
||||
sonarr.search_episode_releases.assert_any_await(201)
|
||||
self.assertEqual(result["collector"], "Sonarr")
|
||||
self.assertEqual(len(result["releases"]), 1)
|
||||
self.assertEqual(len(result["releases"]), 2)
|
||||
self.assertTrue(result["releases"][0]["fullSeason"])
|
||||
self.assertEqual(result["releases"][0]["seasonNumber"], 1)
|
||||
self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p")
|
||||
self.assertTrue(result["releases"][0]["bestPick"])
|
||||
self.assertTrue(result["qualityFiltered"])
|
||||
self.assertFalse(result["qualityFiltered"])
|
||||
self.assertNotIn("selectionToken", result["releases"][1])
|
||||
self.assertTrue(result["releases"][1]["requiresOverride"])
|
||||
|
||||
async def test_movie_manual_search_uses_radarr(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
@@ -1292,14 +1306,14 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
requests_router, "save_action"
|
||||
):
|
||||
result = await requests_router.action_grab(
|
||||
"3909", payload, user={"username": "viewer", "role": "user"}
|
||||
"3909", self.selection(payload, "3909", self._runtime().sonarr_base_url), user={"username": "viewer", "role": "user"}
|
||||
)
|
||||
|
||||
sonarr.grab_release.assert_awaited_once_with("season-one", 7)
|
||||
sonarr.push_release.assert_not_awaited()
|
||||
self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
|
||||
|
||||
async def test_stale_movie_release_still_routes_through_radarr_push(self) -> None:
|
||||
async def test_stale_movie_release_requires_fresh_search(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
request_id="4000",
|
||||
title="Example Movie",
|
||||
@@ -1335,15 +1349,12 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
|
||||
requests_router, "save_action"
|
||||
):
|
||||
result = await requests_router.action_grab(
|
||||
"4000", payload, user={"username": "viewer", "role": "user"}
|
||||
)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await requests_router.action_grab(
|
||||
"4000", self.selection(payload, "4000", self._runtime().radarr_base_url), user={"username": "viewer", "role": "user"})
|
||||
self.assertEqual(error.exception.status_code, 409)
|
||||
radarr.push_release.assert_not_awaited()
|
||||
|
||||
radarr.push_release.assert_awaited_once()
|
||||
pushed = radarr.push_release.await_args.args[0]
|
||||
self.assertEqual(pushed["downloadUrl"], "http://prowlarr.test/download/1")
|
||||
self.assertEqual(pushed["protocol"], "torrent")
|
||||
self.assertEqual(result["response"], {"collector": "Radarr", "queued": True})
|
||||
|
||||
|
||||
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
|
||||
|
||||
@@ -26,7 +26,7 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
||||
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
||||
|
||||
def test_defaults_persist_and_invites_share_existing_setting(self):
|
||||
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False))
|
||||
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
|
||||
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
||||
db.init_db()
|
||||
fresh = db.get_user_by_username(self.user['username'])
|
||||
@@ -118,3 +118,22 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
||||
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
||||
|
||||
def test_manual_override_permission_is_checked_again_at_download(self):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from backend.app.models import Snapshot, RequestType
|
||||
from backend.app.services import manual_releases
|
||||
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
|
||||
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
|
||||
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
|
||||
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
|
||||
collector.grab_release.assert_not_awaited()
|
||||
update_permissions({'ignore_profile_limits':True},self.user['username'])
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
|
||||
update_permissions({'ignore_profile_limits':False},self.user['username'])
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
|
||||
collector.grab_release.assert_awaited_once()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException
|
||||
from backend.app.config import settings
|
||||
from backend.app.services import manual_releases as manual
|
||||
from backend.app.routers import requests
|
||||
from backend.app.models import Snapshot, RequestType
|
||||
from backend.app.feature_access import permissions, update_permissions
|
||||
from backend.app import db
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class ManualSelectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
secret = patch.object(settings, 'jwt_secret', 'manual-selection-test-secret-123456789')
|
||||
secret.start(); self.addCleanup(secret.stop)
|
||||
self.user = {'username': 'viewer', 'role': 'user', 'features': {'ignore_profile_limits': True}}
|
||||
self.release = {'guid': 'release', 'indexerId': 7, 'title': 'Example', 'requiresOverride': True,
|
||||
'rejections': ['WEBDL-2160p is not wanted in profile']}
|
||||
self.payload = {**self.release, 'ignoreProfileLimits': True,
|
||||
'selectionToken': manual.issue_selection(self.release, '42', self.user, 'http://sonarr', 55)}
|
||||
|
||||
def test_profile_only_rejections_are_overridable(self):
|
||||
for reason in ['WEBDL-2160p is not wanted in profile', 'Custom format score below minimum', 'File is larger than maximum size', 'Language is not wanted']:
|
||||
self.assertTrue(manual.decision({'approved': False, 'rejections': [reason]})[1])
|
||||
for reason in ['Unknown series', 'Release is blocklisted', 'No download client available', 'Already in queue']:
|
||||
self.assertFalse(manual.decision({'rejections': [self.release['rejections'][0], reason]})[1])
|
||||
self.assertFalse(manual.decision({'approved': True, 'downloadAllowed': False})[0])
|
||||
|
||||
def test_receipt_binds_request_user_source_item_and_release(self):
|
||||
self.assertTrue(manual.verify_selection(self.payload, '42', self.user, 'http://sonarr', 55)['override'])
|
||||
attempts = [({**self.payload, 'guid': 'other'}, '42', self.user, 'http://sonarr', 55),
|
||||
(self.payload, '43', self.user, 'http://sonarr', 55),
|
||||
(self.payload, '42', {**self.user, 'username': 'other'}, 'http://sonarr', 55),
|
||||
(self.payload, '42', self.user, 'http://other', 55),
|
||||
(self.payload, '42', self.user, 'http://sonarr', 56),
|
||||
({**self.payload, 'selectionToken': 'forged'}, '42', self.user, 'http://sonarr', 55)]
|
||||
for args in attempts:
|
||||
with self.assertRaises(HTTPException): manual.verify_selection(*args)
|
||||
|
||||
def test_permission_revocation_and_literal_confirmation_enforced(self):
|
||||
for payload, user, code in [(self.payload, {**self.user, 'features': {}}, 403),
|
||||
({**self.payload, 'ignoreProfileLimits': 'true'}, self.user, 400)]:
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
manual.verify_selection(payload, '42', user, 'http://sonarr', 55)
|
||||
self.assertEqual(error.exception.status_code, code)
|
||||
|
||||
|
||||
class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def test_default_off_individual_and_bulk(self):
|
||||
for name in ('one', 'two'): db.create_user(name, 'Password123!', role='user')
|
||||
one, two = [db.get_user_by_username(n) for n in ('one', 'two')]
|
||||
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||
update_permissions({'ignore_profile_limits': True}, 'one')
|
||||
self.assertTrue(permissions(one)['ignore_profile_limits'])
|
||||
self.assertFalse(permissions(two)['ignore_profile_limits'])
|
||||
update_permissions({'ignore_profile_limits': False})
|
||||
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||
|
||||
|
||||
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_episode_batch_is_bounded_and_exposes_next_page(self):
|
||||
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
|
||||
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
|
||||
active = peak = 0
|
||||
async def search(identity):
|
||||
nonlocal active, peak
|
||||
active += 1; peak = max(peak, active)
|
||||
await asyncio.sleep(0.001); active -= 1
|
||||
return []
|
||||
sonarr = SimpleNamespace(configured=lambda: True, get_episodes=AsyncMock(return_value=episodes), search_episode_releases=AsyncMock(side_effect=search))
|
||||
runtime = SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||
snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||
with patch.object(requests, 'get_runtime_settings', return_value=runtime), patch.object(requests, 'build_snapshot', new=AsyncMock(return_value=snapshot)), patch.object(requests,'SonarrClient',return_value=sonarr), patch.object(requests,'save_action'):
|
||||
first = await requests.action_search('42', {'username':'viewer','role':'user'})
|
||||
second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=20)
|
||||
self.assertEqual(first['nextOffset'],20); self.assertIsNone(second['nextOffset'])
|
||||
self.assertEqual(sonarr.search_episode_releases.await_count,25)
|
||||
self.assertLessEqual(peak,3)
|
||||
self.assertEqual(first['totalMissingEpisodes'],25)
|
||||
|
||||
async def test_auto_search_preserves_current_profile(self):
|
||||
for kind, service in [(RequestType.tv,'SonarrClient'),(RequestType.movie,'RadarrClient')]:
|
||||
client=SimpleNamespace(configured=lambda:True, update_series=AsyncMock(), update_movie=AsyncMock(),
|
||||
get_episodes=AsyncMock(return_value=[{'id':1,'seasonNumber':1,'monitored':True,'hasFile':False}]),
|
||||
search_episodes=AsyncMock(return_value={'id':1}), search=AsyncMock(return_value={'id':1}))
|
||||
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test',radarr_base_url='http://radarr',radarr_api_key='test',sonarr_quality_profile_id=6,radarr_quality_profile_id=6)
|
||||
snapshot=Snapshot(request_id='42',request_type=kind,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,service,return_value=client),patch.object(requests,'save_action'),patch.object(requests,'series_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})),patch.object(requests,'movie_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})):
|
||||
await requests.action_search_auto('42',{'username':'admin','role':'admin'})
|
||||
client.update_series.assert_not_awaited(); client.update_movie.assert_not_awaited()
|
||||
Reference in New Issue
Block a user