Reconcile verified account IDs and make language repairs observable
Magent CI/CD / verify (push) Successful in 1m50s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-11 19:56:32 +12:00
parent 38169b881e
commit de25255ea8
25 changed files with 593 additions and 160 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ class RadarrClient(ApiClient):
return await self.get("/api/v3/qualityprofile")
async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]:
return await self.get("/api/v3/queue", params={"movieId": movie_id})
return await self.get("/api/v3/queue", params={"movieIds": movie_id, "pageSize": 1000})
async def search_releases(self, movie_id: int) -> Optional[Any]:
return await self.get(
+4
View File
@@ -1078,6 +1078,10 @@ def create_user_if_missing(
if any(str(row[0]).strip().casefold() == username.casefold()
for row in conn.execute("SELECT username FROM users")):
return False
if jellyseerr_user_id is not None and conn.execute(
"SELECT 1 FROM users WHERE jellyseerr_user_id=?", (jellyseerr_user_id,)
).fetchone():
return False
cursor = conn.execute(
"""
INSERT OR IGNORE INTO users (
+6 -48
View File
@@ -880,28 +880,10 @@ async def jellyseerr_users_sync() -> Dict[str, Any]:
if not jellyseerr_users:
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
from ..services.jellyfin_sync import sync_jellyfin_users
imported = await sync_jellyfin_users()
return {"status": "ok", "matched": len(jellyseerr_users), "skipped": 0, "imported": imported, "total": len(jellyseerr_users)}
updated = 0
skipped = 0
users = get_all_users()
for user in users:
if user.get("jellyseerr_user_id") is not None:
skipped += 1
continue
username = user.get("username") or ""
matched_id = match_jellyseerr_user_id(username, candidate_to_id)
matched_seerr_user = find_matching_jellyseerr_user(username, jellyseerr_users)
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
if matched_email:
set_user_email(username, matched_email)
updated += 1
else:
skipped += 1
return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
for key in ("email", "username", "displayName", "name"):
@@ -922,33 +904,9 @@ async def jellyseerr_users_resync() -> Dict[str, Any]:
if not jellyseerr_users:
return {"status": "ok", "imported": 0, "cleared": 0}
cleared = delete_non_admin_users()
imported = 0
for user in jellyseerr_users:
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
username = _pick_jellyseerr_username(user)
if not username:
continue
email = extract_jellyseerr_user_email(user)
created = create_user_if_missing(
username,
"jellyseerr-user",
role="user",
email=email,
auth_provider="jellyseerr",
jellyseerr_user_id=user_id,
)
if created:
imported += 1
else:
set_user_jellyseerr_id(username, user_id)
if email:
set_user_email(username, email)
return {"status": "ok", "imported": imported, "cleared": cleared}
from ..services.jellyfin_sync import sync_jellyfin_users
imported = await sync_jellyfin_users()
return {"status": "ok", "imported": imported, "cleared": 0}
@router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]:
+13 -1
View File
@@ -733,6 +733,13 @@ async def jellyfin_login(
if not isinstance(auth_response, dict) or not auth_response.get("User"):
_record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
from ..services.jellyfin_identity import user_for_identity
identity_owner = user_for_identity(auth_response['User'].get('Id'), runtime.jellyfin_base_url)
if identity_owner:
preferred_match = identity_owner
user = identity_owner
canonical_username = identity_owner['username']
_assert_user_can_login(user)
if not preferred_match:
create_user_if_missing(
canonical_username,
@@ -815,8 +822,13 @@ async def jellyseerr_login(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
id_matches = [row for row in get_all_users() if jellyseerr_user_id is not None and row.get('jellyseerr_user_id') == jellyseerr_user_id]
if len(id_matches) > 1:
raise HTTPException(409, 'Multiple Magent accounts claim this Seerr identity. Ask an administrator to repair the links.')
ci_matches = get_users_by_username_ci(form_data.username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
preferred_match = id_matches[0] if id_matches else _pick_preferred_ci_user_match(ci_matches, form_data.username)
if preferred_match and preferred_match.get('jellyseerr_user_id') not in (None, jellyseerr_user_id):
raise HTTPException(409, 'The account name and authenticated identity disagree. Ask an administrator to repair the links.')
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
if not preferred_match:
create_user_if_missing(
+75 -15
View File
@@ -1,4 +1,4 @@
from ..services.request_language import language_info, original_profile, is_original_profile
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
import asyncio
@@ -3180,6 +3180,13 @@ async def create_request(
if not isinstance(details, dict):
raise HTTPException(status_code=502, detail="Invalid response from Seerr media lookup")
language = language_info(details)
accept_original = payload.get("acceptOriginalLanguage", False)
if not isinstance(accept_original, bool):
raise HTTPException(400, "The language choice must be true or false.")
if accept_original and not language:
raise HTTPException(409, "The original language could not be verified. Reload this title.")
media_info = details.get("mediaInfo") if isinstance(details.get("mediaInfo"), dict) else {}
requests_list = media_info.get("requests")
existing_request: Optional[Dict[str, Any]] = None
@@ -3195,6 +3202,8 @@ async def create_request(
year = int(date_value[:4])
if isinstance(existing_request, dict):
if accept_original:
raise HTTPException(409, 'This title is already requested. Open its request and choose Use original audio & search to update the existing movie.')
existing_request_id = _quality_profile_id(existing_request.get("id"))
existing_status = existing_request.get("status")
if existing_request_id is not None:
@@ -3229,16 +3238,12 @@ async def create_request(
detail=f"Season selection is not available for this series: {invalid_seasons}",
)
language = language_info(details)
accept_original = payload.get("acceptOriginalLanguage", False)
if not isinstance(accept_original, bool):
raise HTTPException(400, "The language choice must be true or false.")
if accept_original and not language:
raise HTTPException(409, "The original language could not be verified. Reload this title.")
destination = await _resolve_request_destination(runtime, client, media_type)
if accept_original and media_type == "movie":
destination["profile_id"] = await original_profile(
RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), destination["profile_id"])
# Seerr does not update an already-existing Radarr movie's profile on request creation.
await apply_original_to_movie(RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key), tmdb_id)
try:
created = await client.create_request(
@@ -3299,6 +3304,57 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
return triage_snapshot(snapshot)
async def _request_language_context(request_id, user):
runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
await _ensure_request_access(seerr, int(request_id), user)
request = await seerr.get_request(request_id)
if not isinstance(request, dict) or request.get('type') != 'movie':
return runtime, None, None
tmdb_id = (request.get('media') or {}).get('tmdbId')
if not isinstance(tmdb_id, int):
raise HTTPException(502, 'Seerr did not return the movie identity.')
details = await seerr.get_movie(tmdb_id)
return runtime, tmdb_id, language_info(details or {})
@router.get("/{request_id}/language")
async def request_language(request_id: str, user: dict = Depends(get_current_user)):
runtime, tmdb_id, language = await _request_language_context(request_id, user)
if not language:
return {'language': None}
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
movies = await radarr.get_movie_by_tmdb_id(tmdb_id)
movie = next((m for m in (movies or []) if m.get('tmdbId') == tmdb_id), None)
profiles = await radarr.get_quality_profiles() if movie else []
profile = next((p for p in profiles if p['id'] == movie['qualityProfileId']), {}) if movie else {}
return {'language': language, 'originalEnabled': is_original_profile(profile),
'canChange': bool(movie) and _user_can_use_search_auto(user),
'profileLanguage': (profile.get('language') or {}).get('name')}
@router.post("/{request_id}/actions/language")
async def accept_request_language(request_id: str, payload: dict, user: dict = Depends(get_current_user)):
if not _user_can_use_search_auto(user):
raise HTTPException(403, 'Search and download changes are disabled for this account.')
if payload.get('acceptOriginalLanguage') is not True:
raise HTTPException(400, 'Explicitly accept original-language audio before continuing.')
runtime, tmdb_id, language = await _request_language_context(request_id, user)
if not language:
raise HTTPException(409, 'This request has no verified non-English original language.')
if payload.get('languageCode') != language['code']:
raise HTTPException(409, 'The language metadata changed. Reload the request and review it again.')
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
profile_id = await apply_original_to_movie(radarr, tmdb_id)
if profile_id is None:
raise HTTPException(409, 'The movie is not in Radarr yet. Recheck the pipeline first.')
await asyncio.to_thread(save_action, request_id, 'original_language', 'Accept original-language audio',
'ok', f"Original-language audio accepted ({language['code']}); Radarr profile {profile_id} verified.")
result = await action_search_auto(request_id, user)
result['message'] = 'Original-language audio enabled. ' + result['message']
return result
@router.post("/{request_id}/actions/search")
async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings()
@@ -3377,6 +3433,9 @@ 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)
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,
@@ -3390,10 +3449,9 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
"status": "ok",
"collector": collector,
"qualityFiltered": True,
"message": (
f"{collector} approved {len(releases)} release{'s' if len(releases) != 1 else ''} "
"against its assigned quality profile."
),
"message": result_message,
"outcome": "matches" if releases else "attention",
"rejectionReasons": rejection_reasons,
"releases": releases,
}
@@ -3444,13 +3502,14 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
responses.append(
{"season": season_number, "episodeCount": len(episode_ids), "response": response}
)
message = "Search sent to Sonarr."
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
)
return {"status": "ok", "message": message, "searched": responses}
return {"status": outcome["status"], "message": message, "searched": responses}
if snapshot.request_type.value == "movie":
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if not client.configured():
@@ -3472,13 +3531,14 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get
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"]))
message = "Search sent to Radarr."
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
)
return {"status": "ok", "message": message, "response": response}
return {"status": outcome["status"], "message": message, "response": response}
raise HTTPException(status_code=400, detail="Unknown request type")
+19 -13
View File
@@ -1,4 +1,4 @@
"""Reviewed consolidation of same-name Jellyfin accounts, entirely within Magent."""
"""Reviewed consolidation of accounts sharing a verified Jellyfin ID, entirely within Magent."""
import asyncio
import json
from contextlib import closing
@@ -30,14 +30,19 @@ def account_state(conn, ids):
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
def identity_group(report, target):
identity = target['candidate_jellyfin_id']
return [row for row in report['rows'] if identity and row['candidate_jellyfin_id'] == identity]
def build_preview(report, local, runtime, state, user_id, keep_id=None):
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
if not target:
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
group = [row for row in report['rows'] if review.name_key(row['user']['username']) == review.name_key(target['user']['username'])]
group = identity_group(report, target)
ids = {row['user']['id'] for row in group}
if len(ids) < 2:
raise HTTPException(409, 'No same-name duplicate group remains. Run the account check again.')
raise HTTPException(409, 'No duplicate identity group remains. Run the account check again.')
jf_id = target['candidate_jellyfin_id']
source = source_key(runtime.jellyfin_base_url)
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
@@ -50,14 +55,14 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
problems.append('Restore all three media-service connections before consolidating accounts.')
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
if target['jellyfin'] and review.name_key(target['jellyfin']['name']) != review.name_key(target['user']['username']):
problems.append('The current Jellyfin name does not match this duplicate group.')
if len([account for account in report['jellyfin_users'] if review.name_key(account['name']) == review.name_key(target['user']['username'])]) != 1:
problems.append('The name must identify exactly one current Jellyfin account.')
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
for row in group:
if row['user']['role'] != 'user' or row['user']['auth_provider'] != 'jellyfin':
problems.append('Only non-admin Jellyfin sign-in accounts can use duplicate consolidation.')
if row['basis'] not in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}:
problems.append('Every account needs a stored Jellyfin or Seerr ID; names alone cannot authorize consolidation.')
if any('different accounts' in issue or 'multiple distinct Jellyfin' in issue for issue in row['issues']):
problems.append('A name and stored identity disagree. Resolve that mapping before consolidation.')
if row['user']['role'] != 'user' or row['user']['auth_provider'] not in {'jellyfin', 'jellyseerr'}:
problems.append('Only non-admin Jellyfin or Seerr accounts can use duplicate consolidation.')
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
for link in local['links']:
@@ -76,7 +81,7 @@ def build_preview(report, local, runtime, state, user_id, keep_id=None):
problems.append('Another confirmation owns this identity.')
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
problems.append('An account outside this same-name group also claims the identity.')
problems.append('An account outside this identity group also claims the identity.')
accounts = [account for account in state['users'] if account['id'] in ids]
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']}
@@ -105,7 +110,8 @@ async def prepare(user_id, keep_id=None):
target = next((row for row in local['users'] if row['id'] == user_id), None)
if not target:
raise HTTPException(404, 'Account not found.')
ids = sorted(row['id'] for row in local['users'] if review.name_key(row['username']) == review.name_key(target['username']))
report_target = next(row for row in report['rows'] if row['user']['id'] == user_id)
ids = sorted(row['user']['id'] for row in identity_group(report, report_target))
with closing(db._connect()) as conn:
conn.execute('BEGIN')
if review.digest(review.snapshot(conn)) != review.digest(local):
@@ -146,7 +152,7 @@ def consolidate(preview, report, local, runtime, state, admin):
for name in old_values:
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if review.name_key(row['username']) == review.name_key(values['username'])]
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if row['username'] in names]
for entry in activity:
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
for entry in activity:
@@ -165,7 +171,7 @@ def consolidate(preview, report, local, runtime, state, admin):
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
conn.execute('DELETE FROM users WHERE id=?', (identity,))
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
conn.execute('''UPDATE users SET username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
conn.execute('''UPDATE users SET auth_provider='jellyfin',username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
values['features']['invites'], values['expires_at'], last_login, keep))
+13
View File
@@ -36,3 +36,16 @@ def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> Non
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
(source_key(base_url), user["id"], str(jellyfin_user_id)),
)
def user_for_identity(jellyfin_user_id: str, base_url: str | None):
"""Resolve a verified upstream login to its existing local account."""
if not jellyfin_user_id or not base_url:
return None
with closing(db._connect()) as conn:
rows = conn.execute("SELECT local_user_id FROM jellyfin_user_links WHERE source=? AND lower(replace(jellyfin_user_id,'-',''))=?",
(source_key(base_url), str(jellyfin_user_id).replace('-', '').lower())).fetchall()
if len(rows) > 1:
from fastapi import HTTPException
raise HTTPException(409, 'Multiple accounts claim this Jellyfin ID. Ask an administrator to repair the links.')
return db.get_user_by_id(rows[0][0]) if rows else None
+45 -35
View File
@@ -1,4 +1,9 @@
import logging
from collections import Counter
from contextlib import closing
from .. import db
from .jellyfin_identity import source_key
from .identity_review import normalized_id, name_key
from fastapi import HTTPException
@@ -6,18 +11,14 @@ from ..clients.jellyfin import JellyfinClient
from ..db import (
create_user_if_missing,
get_user_by_username,
set_user_email,
set_user_auth_provider,
set_user_jellyseerr_id,
)
from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user
from .user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
)
@@ -36,43 +37,52 @@ async def sync_jellyfin_users() -> int:
# Jellyfin is the canonical source for local user objects; Seerr IDs are
# matched as enrichment when possible.
jellyseerr_users = get_cached_jellyseerr_users()
candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or [])
imported = 0
name_counts = Counter(name_key(row.get('Name')) for row in users if isinstance(row, dict))
with closing(db._connect()) as conn:
links = [dict(zip(('local_id', 'jf_id'), row)) for row in conn.execute(
'SELECT local_user_id,jellyfin_user_id FROM jellyfin_user_links WHERE source=?', (source_key(runtime.jellyfin_base_url),))]
for user in users:
if not isinstance(user, dict):
continue
name = user.get("Name")
if not name:
name, jf_id = user.get('Name'), normalized_id(user.get('Id'))
if not name or not jf_id or name_counts[name_key(name)] != 1:
continue
matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None
matched_seerr_user = find_matching_jellyseerr_user(name, jellyseerr_users or [])
matched_email = extract_jellyseerr_user_email(matched_seerr_user)
created = create_user_if_missing(
name,
"jellyfin-user",
role="user",
email=matched_email,
auth_provider="jellyfin",
jellyseerr_user_id=matched_id,
)
if created:
imported += 1
else:
matches = [row for row in (jellyseerr_users or []) if normalized_id(row.get('jellyfinUserId')) == jf_id]
if len(matches) > 1:
continue
matched = matches[0] if matches else None
matched_id = matched.get('id') if matched else None
owners = [row['local_id'] for row in links if normalized_id(row['jf_id']) == jf_id]
if len(owners) > 1:
continue
existing = db.get_user_by_id(owners[0]) if owners else None
if not existing and matched_id is not None:
candidates = [row for row in db.get_all_users() if row.get('jellyseerr_user_id') == matched_id]
if len(candidates) > 1:
continue
existing = candidates[0] if candidates else None
if not existing:
existing = get_user_by_username(name)
if (
existing
and str(existing.get("role") or "user").strip().lower() != "admin"
and str(existing.get("auth_provider") or "local").strip().lower() != "jellyfin"
):
set_user_auth_provider(name, "jellyfin")
if matched_id is not None:
set_user_jellyseerr_id(name, matched_id)
if matched_email:
set_user_email(name, matched_email)
if user.get("Id"):
local_user = get_user_by_username(name)
if local_user and local_user.get("auth_provider") == "jellyfin":
link_user(name, str(user["Id"]), runtime.jellyfin_base_url)
if existing:
existing_links = [normalized_id(row['jf_id']) for row in links if row['local_id'] == existing['id']]
if existing_links and any(value != jf_id for value in existing_links):
continue
if existing.get('role') == 'admin' or existing.get('auth_provider') == 'local':
continue
canonical = existing['username']
# Never overwrite a stored Seerr identity on name evidence.
if existing.get('jellyseerr_user_id') not in (None, matched_id):
continue
set_user_auth_provider(canonical, 'jellyfin')
else:
canonical = name
if create_user_if_missing(canonical, 'jellyfin-user', auth_provider='jellyfin',
jellyseerr_user_id=matched_id, email=extract_jellyseerr_user_email(matched)):
imported += 1
if matched_id is not None:
set_user_jellyseerr_id(canonical, matched_id)
link_user(canonical, jf_id, runtime.jellyfin_base_url)
return imported
+69
View File
@@ -58,3 +58,72 @@ async def original_profile(client, default_id):
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
return result["id"]
async def apply_original_to_movie(client, tmdb_id):
movies = await client.get_movie_by_tmdb_id(tmdb_id)
if not isinstance(movies, list):
raise HTTPException(502, "Radarr did not return the movie list.")
matches = [movie for movie in movies if movie.get('tmdbId') == tmdb_id]
if not matches:
return None
if len(matches) != 1:
raise HTTPException(409, "Radarr returned multiple movies for this identity.")
movie = matches[0]
profile_id = await original_profile(client, movie['qualityProfileId'])
if movie['qualityProfileId'] != profile_id:
movie['qualityProfileId'] = profile_id
await client.update_movie(movie)
verified = await client.get_movie(movie['id'])
if not verified or verified.get('qualityProfileId') != profile_id:
raise HTTPException(502, "Radarr did not save the original-language choice. Try again before searching.")
return profile_id
async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
command_id = command.get('id') if isinstance(command, dict) else None
if not isinstance(command_id, int):
return {'status': 'searching', 'message': 'Search submitted; download confirmation is not available yet. Recheck the request shortly.'}
for attempt in range(attempts):
state = await client.get(f'/api/v3/command/{command_id}')
status = str((state or {}).get('status', '')).lower()
queue = await client.get_queue(movie_id)
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
matching = [item for item in records if item.get('movieId') == movie_id]
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
return {'status': 'attention', 'message': 'Radarr found a download, but it reports a download or import problem. Open the pipeline details to review it.'}
if matching:
return {'status': 'downloading', 'message': 'Radarr has a download queued for this movie. The pipeline will track its progress.'}
if status in {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'Radarr could not complete the search. Check service health or try Search and choose a download.'}
if status == 'completed':
movie = await client.get_movie(movie_id)
if (movie or {}).get('hasFile'):
return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
return {'status': 'attention', 'message': 'Search finished, but no download appeared in Radarr. Use Search and choose a download to see matching releases and rejection reasons. For a foreign-language title, review the audio choice above.'}
if attempt + 1 < attempts:
await asyncio.sleep(delay)
return {'status': 'searching', 'message': 'Radarr is still searching. No download is confirmed yet; the pipeline will keep checking. You can close this window.'}
async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
ids = [item.get('id') for item in commands if isinstance(item, dict) and isinstance(item.get('id'), int)]
if not ids:
return {'status': 'searching', 'message': 'Search submitted to Sonarr; no download is confirmed yet. Recheck the pipeline shortly.'}
for attempt in range(attempts):
states = await asyncio.gather(*(client.get(f'/api/v3/command/{identity}') for identity in ids))
queue = await client.get_queue(series_id)
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
matching = [item for item in records if item.get('seriesId') == series_id]
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
return {'status': 'attention', 'message': 'Sonarr has a download with a reported problem. Review the pipeline details.'}
if matching:
return {'status': 'downloading', 'message': 'Sonarr has downloads queued for this show. The pipeline will track their progress.'}
statuses = {str((state or {}).get('status', '')).lower() for state in states}
if statuses & {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
if statuses == {'completed'}:
return {'status': 'attention', 'message': 'Sonarr finished searching, but no download is visible yet. Use Search and choose a download to review available releases and rejection reasons.'}
if attempt + 1 < attempts:
await asyncio.sleep(delay)
return {'status': 'searching', 'message': 'Sonarr is still searching. No download is confirmed yet; you can close this window and follow the pipeline.'}