116 lines
4.5 KiB
Python
116 lines
4.5 KiB
Python
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)
|