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
+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")