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 from ..clients.jellyfin import JellyfinClient from ..db import ( create_user_if_missing, get_user_by_username, set_user_auth_provider, set_user_jellyseerr_id, ) from ..runtime import get_runtime_settings from .jellyfin_identity import link_user from .user_cache import ( extract_jellyseerr_user_email, get_cached_jellyseerr_users, save_jellyfin_users_cache, ) logger = logging.getLogger(__name__) async def sync_jellyfin_users() -> int: runtime = get_runtime_settings() client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): raise HTTPException(status_code=400, detail="Jellyfin not configured") users = await client.get_users() if not isinstance(users, list): return 0 save_jellyfin_users_cache(users) # Jellyfin is the canonical source for local user objects; Seerr IDs are # matched as enrichment when possible. jellyseerr_users = get_cached_jellyseerr_users() 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, 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 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: 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 async def run_daily_jellyfin_sync() -> None: while True: delay = _seconds_until_midnight() await _sleep_seconds(delay) try: imported = await sync_jellyfin_users() logger.info("Jellyfin daily sync complete: imported=%s", imported) except HTTPException as exc: logger.warning("Jellyfin daily sync skipped: %s", exc.detail) except Exception: logger.exception("Jellyfin daily sync failed") def _seconds_until_midnight() -> float: from datetime import datetime, timedelta now = datetime.now() next_midnight = (now + timedelta(days=1)).replace( hour=0, minute=0, second=0, microsecond=0 ) return max((next_midnight - now).total_seconds(), 0.0) async def _sleep_seconds(delay: float) -> None: import asyncio await asyncio.sleep(delay)