286 lines
9.2 KiB
Python
286 lines
9.2 KiB
Python
"""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
|
|
|