"""Jellyfin API client — uses Activity Log + PlaybackInfo.""" import os import re from datetime import datetime, timezone, timedelta import requests class JellyfinDebug: """Accumulate debug messages from API calls.""" def __init__(self): self.logs = [] def add(self, msg): self.logs.append(msg) def get_text(self): return '\n'.join(self.logs) _debug = JellyfinDebug() def _cfg(): return { "host": os.environ["JELLYFIN_HOST"].rstrip("/"), "key": os.environ["JELLYFIN_API_KEY"], "user": os.environ["JELLYFIN_USER_ID"], } def get_debug_logs(): """Return accumulated debug messages.""" return _debug.get_text() def clear_debug_logs(): """Clear debug logs.""" _debug.logs = [] def get_activity_logs(min_date: datetime) -> list: """Return activity log entries since min_date (UTC datetime).""" c = _cfg() url = f"{c['host']}/System/ActivityLog/Entries" params = { "api_key": c["key"], "hasUserId": "true", "limit": "200", "minDate": min_date.strftime("%Y-%m-%dT%H:%M:%S.000000Z"), } curl_cmd = f"curl --location '{url}?" curl_params = [] for k, v in params.items(): curl_params.append(f"{k}={v}") curl_cmd += "&".join(curl_params) curl_cmd += "' \\\n --header 'accept: application/json'" _debug.add(f"GET {url}") _debug.add(f" params: hasUserId=true, limit=200, minDate={params['minDate']}") _debug.add("") _debug.add("Curl command:") _debug.add(curl_cmd) _debug.add("") try: resp = requests.get(url, params=params, timeout=15) resp.raise_for_status() items = resp.json().get("Items", []) _debug.add(f"✓ Got {len(items)} activity log entries") return items except Exception as e: _debug.add(f"✗ Error: {e}") raise def get_playback_info(item_id: str) -> dict: """Return the first MediaSource dict for the given item.""" c = _cfg() url = f"{c['host']}/Items/{item_id}/PlaybackInfo" params = {"api_key": c["key"]} _debug.add(f"GET {url}") try: resp = requests.get(url, params=params, timeout=10) resp.raise_for_status() sources = resp.json().get("MediaSources", []) if not sources: raise RuntimeError(f"No MediaSources for item {item_id}") _debug.add(f" ✓ Got media source: {sources[0].get('Name', 'unknown')}") return sources[0] except Exception as e: _debug.add(f" ✗ Error: {e}") raise def extract_episode_number(name: str) -> int: """Parse the absolute episode number from a name like 'One Piece - S21E48 (939) - Title'.""" match = re.search(r'\((\d+)\)', name) if not match: raise RuntimeError(f"Could not extract episode number from: {name!r}") return int(match.group(1)) def find_last_one_piece_before(cutoff_utc: datetime) -> dict: """Return info about the last One Piece episode watched before cutoff_utc. Returns: {episode_number, item_id, watched_at (ISO str), media_name} """ c = _cfg() min_date = cutoff_utc - timedelta(days=14) cutoff_utc_str = cutoff_utc.isoformat() _debug.add(f"Searching for One Piece episodes before {cutoff_utc_str}") _debug.add(f"User ID: {c['user']}") _debug.add(f"Search window: {min_date.isoformat()} to {cutoff_utc_str}") _debug.add("") try: entries = get_activity_logs(min_date) except Exception as e: _debug.add(f"✗ Failed to fetch activity logs: {e}") raise _debug.add("") one_piece_count = 0 for entry in entries: entry_type = entry.get("Type") is_playback = entry_type == "VideoPlaybackStopped" user_id = entry.get("UserId") is_user = user_id == c["user"] name = entry.get("Name", "") is_one_piece = "One Piece" in name if is_playback and is_user and is_one_piece: one_piece_count += 1 date_str = entry.get("Date", "") try: entry_dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) is_before_cutoff = entry_dt < cutoff_utc if is_before_cutoff: _debug.add(f"✓ Found One Piece entry: {name}") _debug.add(f" Date: {date_str} (before cutoff)") item_id = entry.get("ItemId") if item_id: try: source = get_playback_info(item_id) ep_num = extract_episode_number(source["Name"]) _debug.add(f" Extracted episode: {ep_num}") _debug.add("") return { "episode_number": ep_num, "item_id": item_id, "watched_at": date_str, "media_name": source["Name"], } except Exception as e: _debug.add(f" ✗ Error extracting episode: {e}") else: _debug.add(f" No ItemId in entry") except ValueError as ve: _debug.add(f"✗ Failed to parse date {date_str}: {ve}") _debug.add(f"✗ No One Piece episode found before cutoff") _debug.add(f" Found {one_piece_count} One Piece entries total (some may be after cutoff)") raise RuntimeError("No One Piece episode found before the Sunday cutoff in the last 14 days")