182 lines
9.0 KiB
Python
182 lines
9.0 KiB
Python
import asyncio
|
|
import hashlib
|
|
import math
|
|
import sqlite3
|
|
import time
|
|
from collections import defaultdict
|
|
from contextlib import closing
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from .. import db
|
|
from ..clients.jellyfin import JellyfinClient
|
|
from ..clients.jellystat import JellystatClient, JellystatError
|
|
from ..runtime import get_runtime_settings
|
|
from .jellyfin_identity import link_user, linked_user_id
|
|
|
|
_cache: dict[tuple, tuple[float, dict]] = {}
|
|
CACHE_SECONDS = 60
|
|
|
|
|
|
def _date(value) -> datetime:
|
|
try:
|
|
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
|
|
except (ValueError, TypeError) as exc:
|
|
raise JellystatError("Jellystat returned an invalid history date") from exc
|
|
|
|
|
|
def _duration(value) -> float:
|
|
try:
|
|
result = float(value or 0)
|
|
if not math.isfinite(result) or result < 0:
|
|
raise ValueError()
|
|
return result
|
|
except (ValueError, TypeError, OverflowError) as exc:
|
|
raise JellystatError("Jellystat returned an invalid playback duration") from exc
|
|
|
|
|
|
async def resolve_identity(user: dict, runtime) -> str | None:
|
|
identity = await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
|
|
if identity:
|
|
return identity
|
|
if user.get("auth_provider") != "jellyfin":
|
|
return None
|
|
# Bootstrap existing Jellyfin accounts from the canonical server, using exact names.
|
|
# Local accounts and email-prefix matches cannot claim a Jellyfin identity.
|
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
|
if not client.configured():
|
|
return None
|
|
try:
|
|
users = await client.get_users()
|
|
except Exception as exc:
|
|
raise JellystatError("Could not resolve the linked Jellyfin account") from exc
|
|
matches = [entry for entry in users if isinstance(entry, dict)
|
|
and str(entry.get("Name") or "").strip().casefold() == user["username"].strip().casefold()] if isinstance(users, list) else []
|
|
if len(matches) != 1 or not matches[0].get("Id"):
|
|
return None
|
|
await asyncio.to_thread(link_user, user["username"], str(matches[0]["Id"]), runtime.jellyfin_base_url)
|
|
return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
|
|
|
|
|
|
def request_summary(user: dict, start: datetime, end: datetime) -> dict:
|
|
clause = "julianday(created_at) >= julianday(?) AND julianday(created_at) <= julianday(?)"
|
|
params = [start.isoformat(), end.isoformat()]
|
|
if user.get("jellyseerr_user_id") is not None:
|
|
clause += " AND requested_by_id = ?"
|
|
params.append(user["jellyseerr_user_id"])
|
|
else:
|
|
clause += " AND requested_by_id IS NULL AND lower(trim(requested_by)) = ?"
|
|
params.append(user["username"].strip().lower())
|
|
with closing(db._connect()) as conn, conn:
|
|
conn.row_factory = sqlite3.Row
|
|
counts = conn.execute(f"""SELECT COUNT(*) AS total,
|
|
COALESCE(SUM(media_type = 'movie'), 0) AS movies,
|
|
COALESCE(SUM(media_type = 'tv'), 0) AS tv,
|
|
COALESCE(SUM(status = 1), 0) AS pending,
|
|
COALESCE(SUM(status = 2), 0) AS approved,
|
|
COALESCE(SUM(status = 3), 0) AS declined FROM requests_cache WHERE {clause}""", params).fetchone()
|
|
recent = conn.execute(f"""SELECT request_id, title, media_type, status FROM requests_cache
|
|
WHERE {clause} ORDER BY created_at DESC LIMIT 5""", params).fetchall()
|
|
return {**dict(counts), "recent": [dict(row) for row in recent]}
|
|
|
|
|
|
def summarize(history: list, libraries: list, start: datetime, end: datetime) -> dict:
|
|
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
|
|
daily_seconds = defaultdict(float)
|
|
clients = defaultdict(float)
|
|
methods = defaultdict(float)
|
|
titles = {}
|
|
movie_ids, episode_ids, seen = set(), set(), set()
|
|
recent = []
|
|
seconds = 0.0
|
|
for row in history:
|
|
row_id = str(row.get("Id") or "")
|
|
if not row_id:
|
|
raise JellystatError("Jellystat returned history without an activity ID")
|
|
if row_id in seen:
|
|
continue
|
|
seen.add(row_id)
|
|
date = _date(row.get("ActivityDateInserted"))
|
|
# Defend against older upstream versions ignoring the range filter.
|
|
if not start <= date <= end:
|
|
continue
|
|
duration = _duration(row.get("PlaybackDuration"))
|
|
if duration <= 0:
|
|
continue
|
|
item_id = str(row.get("NowPlayingItemId") or row_id)
|
|
episode_id = row.get("EpisodeId")
|
|
library_type = library_types.get(str(row.get("ParentId")), "")
|
|
media_type = "episode" if episode_id else "movie" if library_type == "movies" else "other"
|
|
if media_type == "episode":
|
|
episode_ids.add(str(episode_id))
|
|
elif media_type == "movie":
|
|
movie_ids.add(item_id)
|
|
seconds += duration
|
|
daily_seconds[date.date().isoformat()] += duration
|
|
client = str(row.get("Client") or "Unknown player")[:200]
|
|
clients[client] += duration
|
|
method = str(row.get("PlayMethod") or "Unknown")
|
|
method = {"DirectPlay": "Direct play", "DirectStream": "Direct stream", "Transcode": "Transcode"}.get(method, "Other")
|
|
methods[method] += duration
|
|
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
|
|
series = str(row.get("SeriesName") or "")[:500]
|
|
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0})
|
|
title["minutes"] += duration / 60
|
|
title["plays"] += 1
|
|
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
|
|
"episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
|
|
"minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
|
|
"method": method})
|
|
count = (end.date() - start.date()).days + 1
|
|
daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
|
|
"minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
|
|
active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
|
|
longest = run = 0
|
|
for day in daily:
|
|
run = run + 1 if day["date"] in active_days else 0
|
|
longest = max(longest, run)
|
|
current = 0
|
|
cursor = end.date() if end.date().isoformat() in active_days else end.date() - timedelta(days=1)
|
|
while cursor.isoformat() in active_days:
|
|
current += 1
|
|
cursor -= timedelta(days=1)
|
|
top = sorted(titles.values(), key=lambda row: (-row["minutes"], row["title"]))[:6]
|
|
for row in top:
|
|
row["minutes"] = round(row["minutes"], 1)
|
|
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
|
|
"episodes": len(episode_ids), "active_days": len(active_days),
|
|
"current_streak": current, "longest_streak": longest},
|
|
"daily": daily, "top_titles": top,
|
|
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
|
|
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
|
|
"recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
|
|
|
|
|
|
async def get_insights(user: dict, days: int) -> dict:
|
|
runtime = await asyncio.to_thread(get_runtime_settings)
|
|
end = datetime.now(timezone.utc)
|
|
start = end - timedelta(days=days)
|
|
requests = await asyncio.to_thread(request_summary, user, start, end)
|
|
base = {"source": "Jellystat", "days": days, "timezone": "UTC", "requests": requests,
|
|
"is_admin": user.get("role") == "admin", "summary": None}
|
|
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
|
|
if not client.configured():
|
|
return {**base, "state": "not_configured"}
|
|
identity = await resolve_identity(user, runtime)
|
|
if not identity:
|
|
return {**base, "state": "unlinked"}
|
|
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
|
|
runtime.jellyfin_base_url, identity, days)
|
|
cached = _cache.get(key)
|
|
if cached and cached[0] > time.monotonic():
|
|
return {**base, **cached[1]}
|
|
history, libraries = await client.get_user_history(identity, start, end)
|
|
data = {**summarize(history, libraries, start, end), "state": "ready", "updated_at": end.isoformat(),
|
|
"period_start": start.isoformat(), "period_end": end.isoformat()}
|
|
for expired in [key for key, value in _cache.items() if value[0] <= time.monotonic()]:
|
|
_cache.pop(expired, None)
|
|
if len(_cache) >= 128:
|
|
_cache.pop(next(iter(_cache)))
|
|
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
|
|
return {**base, **data}
|