feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
from collections import defaultdict
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .. import db
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..clients.jellystat import JellystatClient
|
||||
from ..runtime import get_runtime_settings
|
||||
from .jellyfin_identity import source_key
|
||||
|
||||
MAX_USERS = 3000
|
||||
CONFIG_KEYS = ("jellyfin_base_url", "jellyfin_api_key", "jellyseerr_base_url",
|
||||
"jellyseerr_api_key", "jellystat_base_url", "jellystat_api_key")
|
||||
|
||||
|
||||
def normalized_id(value):
|
||||
value = str(value or "").lower().replace("-", "")
|
||||
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
|
||||
|
||||
|
||||
def name_key(value):
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def config_digest(runtime):
|
||||
return digest([getattr(runtime, key, None) for key in CONFIG_KEYS])
|
||||
|
||||
|
||||
def snapshot(conn):
|
||||
conn.row_factory = sqlite3.Row
|
||||
return {
|
||||
"users": [dict(row) for row in conn.execute(
|
||||
"SELECT id, username, role, auth_provider, jellyseerr_user_id FROM users ORDER BY id")],
|
||||
"links": [dict(row) for row in conn.execute(
|
||||
"SELECT source, local_user_id, jellyfin_user_id FROM jellyfin_user_links ORDER BY source, local_user_id")],
|
||||
"confirmations": [dict(row) for row in conn.execute(
|
||||
"SELECT * FROM user_identity_confirmations ORDER BY local_user_id")],
|
||||
# Detect settings changes between checking services and committing the reviewed links.
|
||||
"config_revision": digest([tuple(row) for row in conn.execute(
|
||||
"SELECT key, value FROM settings WHERE key IN (" + ",".join("?" for _ in CONFIG_KEYS) + ") ORDER BY key", CONFIG_KEYS)]),
|
||||
}
|
||||
|
||||
|
||||
def read_snapshot():
|
||||
with closing(db._connect()) as conn:
|
||||
return snapshot(conn)
|
||||
|
||||
|
||||
async def jellyfin_directory(runtime):
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
return {"state": "not_configured", "users": []}
|
||||
try:
|
||||
users, server = await asyncio.gather(client.get_users(), client.get_system_info())
|
||||
server_id = normalized_id(server.get("Id")) if isinstance(server, dict) else None
|
||||
if not server_id or not isinstance(users, list) or len(users) > MAX_USERS:
|
||||
raise ValueError()
|
||||
clean = []
|
||||
seen = set()
|
||||
for user in users:
|
||||
user_id = normalized_id(user.get("Id"))
|
||||
if not user_id or user_id in seen or normalized_id(user.get("ServerId")) != server_id:
|
||||
raise ValueError()
|
||||
seen.add(user_id)
|
||||
clean.append({"id": user_id, "name": str(user.get("Name") or "")[:200]})
|
||||
return {"state": "available", "server_id": server_id, "users": sorted(clean, key=lambda row: row["id"])}
|
||||
except Exception:
|
||||
return {"state": "unavailable", "users": []}
|
||||
|
||||
|
||||
async def seerr_directory(runtime):
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.base_url or not client.api_key:
|
||||
return {"state": "not_configured", "users": []}
|
||||
try:
|
||||
users = []
|
||||
seen = set()
|
||||
expected_total = None
|
||||
async with asyncio.timeout(20):
|
||||
for skip in range(0, MAX_USERS, 100):
|
||||
page = await client.get_users(take=100, skip=skip)
|
||||
total = page["pageInfo"]["results"]
|
||||
batch = page["results"]
|
||||
if type(total) is not int or total < 0 or total > MAX_USERS or not isinstance(batch, list):
|
||||
raise ValueError()
|
||||
if expected_total is not None and total != expected_total:
|
||||
raise ValueError()
|
||||
expected_total = total
|
||||
for user in batch:
|
||||
user_id = user.get("id")
|
||||
if type(user_id) is not int or user_id <= 0 or user_id in seen:
|
||||
raise ValueError()
|
||||
seen.add(user_id)
|
||||
users.append({"id": user_id, "name": str(user.get("displayName") or user.get("jellyfinUsername") or "")[:200],
|
||||
"jellyfin_id": normalized_id(user.get("jellyfinUserId"))})
|
||||
if len(users) == total:
|
||||
return {"state": "available", "users": sorted(users, key=lambda row: row["id"])}
|
||||
if len(batch) != 100 or len(users) > total:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
pass
|
||||
return {"state": "unavailable", "users": []}
|
||||
|
||||
|
||||
def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None, repair=False):
|
||||
original = local
|
||||
selections = selections or {}
|
||||
if repair:
|
||||
local = copy.deepcopy(local)
|
||||
for user in local['users']:
|
||||
if user['id'] in selections:
|
||||
user['jellyseerr_user_id'] = None
|
||||
local['links'] = [link for link in local['links'] if not (
|
||||
link['local_user_id'] in selections and link['source'] == source_key(runtime.jellyfin_base_url))]
|
||||
local['confirmations'] = [item for item in local['confirmations'] if item['local_user_id'] not in selections]
|
||||
if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
|
||||
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
|
||||
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
|
||||
jf_by_name = defaultdict(list)
|
||||
for row in jellyfin["users"]:
|
||||
jf_by_name[name_key(row["name"])].append(row["id"])
|
||||
seerr_by_id = {row["id"]: row for row in seerr["users"]}
|
||||
seerr_by_jf = defaultdict(list)
|
||||
for row in seerr["users"]:
|
||||
if row["jellyfin_id"]:
|
||||
seerr_by_jf[row["jellyfin_id"]].append(row)
|
||||
current_source = source_key(runtime.jellyfin_base_url)
|
||||
seerr_source = source_key(runtime.jellyseerr_base_url)
|
||||
links = {row["local_user_id"]: normalized_id(row["jellyfin_user_id"]) for row in local["links"] if row["source"] == current_source}
|
||||
confirmed = {row["local_user_id"]: row for row in local["confirmations"]}
|
||||
local_by_name, local_by_seerr = defaultdict(list), defaultdict(list)
|
||||
for user in local["users"]:
|
||||
local_by_name[name_key(user["username"])].append(user["id"])
|
||||
if user["jellyseerr_user_id"] is not None:
|
||||
local_by_seerr[user["jellyseerr_user_id"]].append(user["id"])
|
||||
rows = []
|
||||
for user in local["users"]:
|
||||
issues = []
|
||||
saved = confirmed.get(user["id"])
|
||||
linked = links.get(user["id"])
|
||||
stored_seerr = seerr_by_id.get(user["jellyseerr_user_id"])
|
||||
by_name = jf_by_name.get(name_key(user["username"]), [])
|
||||
basis = "none"
|
||||
candidate = None
|
||||
if saved:
|
||||
candidate = saved["jellyfin_user_id"]
|
||||
basis = "confirmed_id"
|
||||
if saved["jellyfin_server_id"] != jellyfin.get("server_id") or saved["seerr_source"] != seerr_source:
|
||||
issues.append("The confirmed server or Seerr connection has changed.")
|
||||
elif linked:
|
||||
candidate, basis = linked, "stored_jellyfin_id"
|
||||
elif stored_seerr and stored_seerr["jellyfin_id"]:
|
||||
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
|
||||
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
|
||||
candidate, basis = by_name[0], "suggested_username"
|
||||
if repair and user['id'] in selections and any(
|
||||
item['local_user_id'] == user['id'] and item['jellyfin_server_id'] != jellyfin.get('server_id')
|
||||
for item in original['confirmations']):
|
||||
issues.append('The Jellyfin server changed. A server migration requires separate review.')
|
||||
if user["id"] in selections:
|
||||
chosen = selections[user["id"]]
|
||||
if saved and chosen != saved["jellyfin_user_id"]:
|
||||
issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
|
||||
candidate, basis = chosen, "admin_selected"
|
||||
if len(local_by_name[name_key(user["username"])]) > 1:
|
||||
issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
|
||||
if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
|
||||
issues.append("Multiple Magent rows share the stored Seerr ID.")
|
||||
if len(by_name) > 1:
|
||||
issues.append("This name matches multiple distinct Jellyfin IDs.")
|
||||
if candidate and by_name and candidate not in by_name:
|
||||
issues.append("The stored ID and current Jellyfin username point to different accounts.")
|
||||
if linked and candidate and linked != candidate:
|
||||
issues.append("The stored Jellyfin link conflicts with the confirmed identity.")
|
||||
jf = jf_by_id.get(candidate)
|
||||
if candidate and not jf and jellyfin["state"] == "available":
|
||||
issues.append("The linked Jellyfin ID is absent from the current server.")
|
||||
expected_seerr = seerr_by_jf.get(candidate, [])
|
||||
if len(expected_seerr) > 1:
|
||||
issues.append("Multiple Seerr users reference the same Jellyfin ID.")
|
||||
if user["jellyseerr_user_id"] is not None and seerr["state"] == "available" and (
|
||||
len(expected_seerr) != 1 or expected_seerr[0]["id"] != user["jellyseerr_user_id"]
|
||||
):
|
||||
issues.append("The stored Seerr ID does not match Seerr's Jellyfin ID mapping.")
|
||||
if saved and user["jellyseerr_user_id"] != saved["seerr_user_id"]:
|
||||
issues.append("The stored Seerr ID has changed since confirmation.")
|
||||
js = jellystat.get(candidate, {"state": "not_checked"})
|
||||
rows.append({"user": user, "jellyfin": jf, "candidate_jellyfin_id": candidate,
|
||||
"stored_jellyfin_id": linked, "seerr": expected_seerr, "jellystat": js,
|
||||
"basis": basis, "issues": issues, "confirmed_at": saved["confirmed_at"] if saved else None,
|
||||
"can_confirm": False, "state": "unlinked"})
|
||||
candidates = defaultdict(list)
|
||||
for row in rows:
|
||||
if row["candidate_jellyfin_id"]:
|
||||
candidates[row["candidate_jellyfin_id"]].append(row)
|
||||
for row in rows:
|
||||
candidate = row["candidate_jellyfin_id"]
|
||||
if len(candidates.get(candidate, [])) > 1:
|
||||
row["issues"].append("Multiple Magent accounts resolve to this Jellyfin ID.")
|
||||
# Also protect IDs already reserved by a link/confirmation whose local user was deleted.
|
||||
if any(link["local_user_id"] != row["user"]["id"] and link["source"] == current_source
|
||||
and normalized_id(link["jellyfin_user_id"]) == candidate for link in local["links"]) or any(
|
||||
item["local_user_id"] != row["user"]["id"] and item["jellyfin_server_id"] == jellyfin.get("server_id")
|
||||
and item["jellyfin_user_id"] == candidate for item in local["confirmations"]):
|
||||
row["issues"].append("This Jellyfin ID is already reserved by another Magent account.")
|
||||
if row["issues"]:
|
||||
row["state"] = "conflict"
|
||||
elif jellyfin["state"] != "available" or seerr["state"] != "available" or (candidate and row["jellystat"]["state"] in {"unavailable", "not_configured"}):
|
||||
row["state"] = "unavailable"
|
||||
elif not row["jellyfin"] or not row["seerr"] or row["jellystat"]["state"] != "matched":
|
||||
row["state"] = "unlinked"
|
||||
elif row["confirmed_at"] and row["stored_jellyfin_id"] == candidate:
|
||||
row["state"] = "confirmed"
|
||||
else:
|
||||
row["state"] = "ready"
|
||||
row["can_confirm"] = True
|
||||
upstream = [{"platform": "Seerr", "id": str(row["id"]), "name": row["name"], "jellyfin_id": row["jellyfin_id"],
|
||||
"detail": "No current Jellyfin account has this ID."} for row in seerr["users"]
|
||||
if row["jellyfin_id"] not in jf_by_id and jellyfin["state"] == "available"]
|
||||
upstream += [{"platform": "Jellyfin", "id": row["id"], "name": row["name"], "jellyfin_id": row["id"],
|
||||
"detail": "No Magent account resolves to this ID."} for row in jellyfin["users"] if row["id"] not in candidates]
|
||||
services = {"jellyfin": jellyfin["state"], "seerr": seerr["state"],
|
||||
"jellystat": "not_configured" if not runtime.jellystat_base_url or not runtime.jellystat_api_key else
|
||||
"not_checked" if not jellystat else
|
||||
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
|
||||
report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
|
||||
"jellyfin_users": jellyfin["users"], "seerr_users": seerr["users"],
|
||||
"counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
|
||||
"jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
|
||||
**{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
|
||||
report["revision"] = digest([report, digest(original), config_digest(runtime), repair])
|
||||
report["checked_at"] = datetime.now(timezone.utc).isoformat()
|
||||
return report
|
||||
|
||||
|
||||
async def review_identities(selections=None, repair=False):
|
||||
runtime = await asyncio.to_thread(get_runtime_settings)
|
||||
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
|
||||
if len(local["users"]) > MAX_USERS:
|
||||
raise HTTPException(422, "The identity check supports up to 3,000 Magent accounts.")
|
||||
ids = {row["id"] for row in jf["users"]}
|
||||
ids.update(row["jellyfin_id"] for row in seerr["users"] if row["jellyfin_id"])
|
||||
ids.update(normalized_id(row["jellyfin_user_id"]) for row in local["links"])
|
||||
ids.discard(None)
|
||||
if len(ids) > MAX_USERS:
|
||||
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
|
||||
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
||||
js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
|
||||
return build_report(local, jf, seerr, js, runtime, selections, repair), local, runtime
|
||||
|
||||
|
||||
def save_confirmations(report, local, runtime, user_ids, admin, repair=False):
|
||||
rows = {row["user"]["id"]: row for row in report["rows"]}
|
||||
if any(user_id not in rows or not rows[user_id]["can_confirm"] for user_id in user_ids):
|
||||
raise HTTPException(409, "Some selected accounts cannot be confirmed. Run the check again and review the conflicts.")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
with closing(db._connect()) as conn, conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if digest(snapshot(conn)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
|
||||
raise HTTPException(409, "Accounts or settings changed during confirmation. Run the check again.")
|
||||
for user_id in user_ids:
|
||||
row = rows[user_id]
|
||||
jf_id = row["jellyfin"]["id"]
|
||||
seerr_id = row["seerr"][0]["id"]
|
||||
conn.execute("""INSERT INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(source,local_user_id) DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id""",
|
||||
(source_key(runtime.jellyfin_base_url), user_id, jf_id))
|
||||
conn.execute("UPDATE users SET jellyseerr_user_id=? WHERE id=?", (seerr_id, user_id))
|
||||
conn.execute("""INSERT INTO user_identity_confirmations
|
||||
(local_user_id,jellyfin_server_id,jellyfin_user_id,jellyfin_source,seerr_source,seerr_user_id,confirmed_at,confirmed_by)
|
||||
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(local_user_id) DO UPDATE SET
|
||||
jellyfin_source=excluded.jellyfin_source,confirmed_at=excluded.confirmed_at,confirmed_by=excluded.confirmed_by""",
|
||||
(user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
|
||||
seerr_id, now, admin["username"]))
|
||||
if repair:
|
||||
before_user = next(user for user in local['users'] if user['id'] == user_id)
|
||||
before = {'seerr_user_id': before_user['jellyseerr_user_id'],
|
||||
'links': [link for link in local['links'] if link['local_user_id'] == user_id],
|
||||
'confirmation': next((item for item in local['confirmations'] if item['local_user_id'] == user_id), None)}
|
||||
conn.execute("""UPDATE user_identity_confirmations SET jellyfin_server_id=?,jellyfin_user_id=?,
|
||||
jellyfin_source=?,seerr_source=?,seerr_user_id=? WHERE local_user_id=?""",
|
||||
(report['server_id'], jf_id, source_key(runtime.jellyfin_base_url),
|
||||
source_key(runtime.jellyseerr_base_url), seerr_id, user_id))
|
||||
conn.execute("""INSERT INTO user_identity_repairs
|
||||
(local_user_id,before_json,after_json,repaired_at,repaired_by) VALUES (?,?,?,?,?)""",
|
||||
(user_id, json.dumps(before, sort_keys=True), json.dumps({
|
||||
'jellyfin_server_id': report['server_id'], 'jellyfin_user_id': jf_id,
|
||||
'seerr_user_id': seerr_id}, sort_keys=True), now, admin['username']))
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from exc
|
||||
return {"confirmed": len(user_ids), "confirmed_at": now}
|
||||
|
||||
|
||||
async def confirm_identities(revision, user_ids, admin):
|
||||
report, local, runtime = await review_identities()
|
||||
if report["revision"] != revision:
|
||||
raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
|
||||
return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
|
||||
|
||||
|
||||
async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
|
||||
report, local, runtime = await review_identities({user_id: jellyfin_user_id})
|
||||
if revision is not None:
|
||||
if report["revision"] != revision:
|
||||
raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
|
||||
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
|
||||
return {"revision": report["revision"], "server_id": report["server_id"],
|
||||
"row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}
|
||||
|
||||
|
||||
async def repair_identity(user_id, jellyfin_user_id, revision=None, admin=None, create_seerr=False):
|
||||
report, local, runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
|
||||
row = next(row for row in report['rows'] if row['user']['id'] == user_id)
|
||||
importing = bool(create_seerr and row['state'] == 'unlinked' and row['jellyfin']
|
||||
and not row['seerr'] and row['jellystat']['state'] == 'matched'
|
||||
and report['services']['seerr'] == 'available')
|
||||
if importing and any(name_key(account['name']) == name_key(row['jellyfin']['name'])
|
||||
for account in report['seerr_users']):
|
||||
importing = False
|
||||
row['issues'].append('A Seerr account already has this name. Review its existing link before importing.')
|
||||
report['revision'] = digest([report['revision'], create_seerr])
|
||||
if revision is not None:
|
||||
if report['revision'] != revision:
|
||||
raise HTTPException(409, 'The repair preview changed. Check the selected account again.')
|
||||
if importing:
|
||||
if digest(await asyncio.to_thread(read_snapshot)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
|
||||
raise HTTPException(409, 'Accounts or settings changed. Preview the repair again.')
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
try:
|
||||
await client.post('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [jellyfin_user_id]})
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise HTTPException(502, 'The Seerr import could not be verified. Run a fresh check before trying again; an account may already have been imported.') from exc
|
||||
# Upstream and SQLite cannot share a transaction. Reconcile using live IDs;
|
||||
# never delete an imported account if the local save is blocked or interrupted.
|
||||
refreshed, _, fresh_runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
|
||||
if config_digest(fresh_runtime) != config_digest(runtime):
|
||||
raise HTTPException(409, 'Seerr import completed but settings changed. Check accounts again before saving Magent links.')
|
||||
try:
|
||||
return await asyncio.to_thread(save_confirmations, refreshed, local, runtime, [user_id], admin, True)
|
||||
except HTTPException as exc:
|
||||
raise HTTPException(409, 'Seerr import completed, but Magent links could not be saved. Run another check to review the imported account. No account was deleted.') from exc
|
||||
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin, True)
|
||||
before = next(user for user in local['users'] if user['id'] == user_id)
|
||||
linked = next((link['jellyfin_user_id'] for link in local['links'] if link['local_user_id'] == user_id
|
||||
and link['source'] == source_key(runtime.jellyfin_base_url)), None)
|
||||
row['can_confirm'] = row['can_confirm'] or importing
|
||||
return {'revision': report['revision'], 'server_id': report['server_id'], 'row': row,
|
||||
'action': 'import_seerr' if importing else 'repair_magent',
|
||||
'before': {'jellyfin_user_id': linked, 'seerr_user_id': before['jellyseerr_user_id']},
|
||||
'seerr_users': report['seerr_users'],
|
||||
'scope': ('Import this single Jellyfin account into Seerr, then verify and save Magent links. Existing Seerr accounts stay unchanged.' if importing else 'Repair Magent links only. Jellyfin and Jellystat IDs and Seerr accounts stay unchanged.')}
|
||||
Reference in New Issue
Block a user