"""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_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 start with '{user} 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(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 not entry.get("Name", "").startswith(f"{user_id.split('@')[0] if '@' in user_id else 'pat'} has finished playing One Piece"): # Try simple pattern: contains "has finished playing One Piece" 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