Files
media-episode-viewer/clients/jellyfin.py
T
2026-02-22 16:07:56 +08:00

190 lines
5.9 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) -> 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