Reconcile verified account IDs and make language repairs observable
This commit is contained in:
@@ -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