Reconcile verified account IDs and make language repairs observable
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
Reference in New Issue
Block a user