"""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 = None, limit: int = 200) -> list: """Return activity log entries. Optionally filtered by min_date. Args: min_date: Optional minimum date filter (UTC datetime) limit: Number of entries to fetch (default 200) """ c = _cfg() url = f"{c['host']}/System/ActivityLog/Entries" params = { "api_key": c["key"], "hasUserId": "true", "limit": str(limit), } if min_date: params["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={limit}" + (f", minDate={params['minDate']}" if min_date else "")) _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_in_entries(entries: list, cutoff_utc: datetime, user_id: str) -> dict: """Extract the last One Piece episode from a list of entries, watched before cutoff_utc. Returns {episode_number, item_id, watched_at, media_name} or raises RuntimeError if not found. """ candidates = [] for entry in entries: if entry.get("Type") != "VideoPlaybackStopped": continue if entry.get("UserId") != user_id: continue if "has finished playing One Piece" not in entry.get("Name", ""): continue date_str = entry.get("Date", "") try: entry_dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) if entry_dt < cutoff_utc: candidates.append((entry_dt, entry)) except ValueError: continue if not candidates: raise RuntimeError("No matching One Piece episodes found") # Sort by date descending (newest first) candidates.sort(key=lambda x: x[0], reverse=True) latest_dt, latest_entry = candidates[0] name = latest_entry.get("Name", "") date_str = latest_entry.get("Date", "") item_id = latest_entry.get("ItemId") if not item_id: raise RuntimeError("Entry has no ItemId") source = get_playback_info(item_id) ep_num = extract_episode_number(source["Name"]) return { "episode_number": ep_num, "item_id": item_id, "watched_at": date_str, "media_name": source["Name"], "name": name, } def find_last_one_piece_before_previous_sunday(previous_sunday_utc: datetime, user_id: str) -> dict: """Find the latest One Piece episode watched BEFORE the previous Sunday. Uses exponential backoff: starts with limit=500, doubles until found or limit >= 32000. Returns: {episode_number, item_id, watched_at, media_name} """ _debug.add(f"Searching for episode watched before previous Sunday: {previous_sunday_utc.isoformat()}") _debug.add(f"Using exponential backoff: 500 → 1000 → 2000 → 4000 → 8000 → 16000 → 32000") _debug.add("") limit = 500 max_limit = 32000 attempt = 0 while limit <= max_limit: attempt += 1 _debug.add(f"Attempt {attempt}: Fetching {limit} activity log entries...") try: entries = get_activity_logs(limit=limit) except Exception as e: _debug.add(f"✗ Failed to fetch: {e}") raise _debug.add(f"✓ Got {len(entries)} entries, searching...") _debug.add("") try: result = _find_last_one_piece_in_entries(entries, previous_sunday_utc, user_id) _debug.add(f"✓ Found: {result['name']}") _debug.add(f" Watched at: {result['watched_at']}") _debug.add(f" Extracted episode: {result['episode_number']}") _debug.add("") return result except RuntimeError as e: _debug.add(f"✗ {e} (trying next limit...)") _debug.add("") limit *= 2 _debug.add(f"✗ Exceeded max limit ({max_limit}) without finding episode") raise RuntimeError(f"Could not find previous Sunday episode after searching up to {max_limit} entries") def find_last_one_piece_episode(cutoff_utc: datetime, user_id: str) -> dict: """Find the last One Piece episode watched BEFORE cutoff_utc by the given user. Filters: 1. Entry date must be strictly before cutoff_utc 2. UserId must match 3. Name must contain 'has finished playing One Piece' 4. Type must be VideoPlaybackStopped Returns the most recent matching entry. Returns: {episode_number, item_id, watched_at (ISO str), media_name} """ min_date = cutoff_utc - timedelta(days=14) _debug.add(f"Searching for One Piece episodes watched before: {cutoff_utc.isoformat()}") _debug.add(f"User ID: {user_id}") _debug.add(f"Search window: {min_date.isoformat()} to {cutoff_utc.isoformat()}") _debug.add("") try: entries = get_activity_logs(limit=200, min_date=min_date) except Exception as e: _debug.add(f"✗ Failed to fetch activity logs: {e}") raise _debug.add("") # Filter: Type == VideoPlaybackStopped, UserId matches, Date before cutoff, Name pattern candidates = [] for entry in entries: if entry.get("Type") != "VideoPlaybackStopped": continue if entry.get("UserId") != user_id: continue if "has finished playing One Piece" not in entry.get("Name", ""): continue date_str = entry.get("Date", "") try: entry_dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) if entry_dt < cutoff_utc: candidates.append((entry_dt, entry)) except ValueError: continue if not candidates: _debug.add(f"✗ No matching One Piece episodes found before cutoff") raise RuntimeError("No One Piece episode found before the Sunday cutoff") # Sort by date descending (newest first) and take the first candidates.sort(key=lambda x: x[0], reverse=True) latest_dt, latest_entry = candidates[0] name = latest_entry.get("Name", "") date_str = latest_entry.get("Date", "") item_id = latest_entry.get("ItemId") _debug.add(f"✓ Found {len(candidates)} matching One Piece entries") _debug.add(f"✓ Latest: {name}") _debug.add(f" Watched at: {date_str}") _debug.add("") if not item_id: _debug.add(f"✗ No ItemId in entry") raise RuntimeError("Entry has no ItemId") 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}") raise