Align missing-episode searches and permit reviewed profile overrides
Magent CI/CD / deploy-beta (push) Successful in 50s
Magent CI/CD / verify (push) Successful in 2m4s
Magent CI/CD / deploy-prod (push) Skipped

This commit is contained in:
2026-09-11 20:26:57 +12:00
parent de25255ea8
commit 4f7853b17b
13 changed files with 376 additions and 200 deletions
+3
View File
@@ -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})
+2 -1
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+55
View File
@@ -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
+26 -15
View File
@@ -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):
+20 -1
View File
@@ -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()
+93
View File
@@ -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()