diff --git a/app.py b/app.py index f515f7a..41ea299 100644 --- a/app.py +++ b/app.py @@ -13,6 +13,37 @@ app = Flask(__name__) PHT = timezone(timedelta(hours=8)) +def _sunday_midnight_pht_for(now_pht=None): + """Return the Sunday midnight (start of day) in PHT used for week-start logic. + + This function treats Sunday as belonging to the previous week (used for producing + week ranges like "Feb 15-21" when today is Sunday Feb 22). + """ + now_pht = now_pht or datetime.now(PHT) + wd = now_pht.weekday() + days_since_sunday = (wd + 1) % 7 + if wd == 6: + days_since_sunday = 7 + sunday_midnight_pht = (now_pht - timedelta(days=days_since_sunday)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + return sunday_midnight_pht + + +def _latest_sunday_midnight_pht(now_pht=None): + """Return the latest Sunday midnight (start of day) in PHT including today if it's Sunday. + + This is for computing the exact UTC cutoff timestamps for a given latest Sunday. + """ + now_pht = now_pht or datetime.now(PHT) + wd = now_pht.weekday() + days_since_sunday = (wd + 1) % 7 # Sunday -> 0 + sunday_midnight_pht = (now_pht - timedelta(days=days_since_sunday)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + return sunday_midnight_pht + + def current_week(): """Return (week_start, week_end, cutoff_utc) for the current week in PHT. @@ -20,17 +51,34 @@ def current_week(): week_end = that Sunday + 6 days (Saturday) cutoff_utc = Sunday 00:00 PHT expressed in UTC (= Saturday 16:00 UTC) """ - now_pht = datetime.now(PHT) - days_since_sunday = (now_pht.weekday() + 1) % 7 - sunday_midnight_pht = (now_pht - timedelta(days=days_since_sunday)).replace( - hour=0, minute=0, second=0, microsecond=0 - ) + sunday_midnight_pht = _sunday_midnight_pht_for() week_start = sunday_midnight_pht.date() week_end = week_start + timedelta(days=6) cutoff_utc = sunday_midnight_pht.astimezone(timezone.utc) return week_start, week_end, cutoff_utc +def _iso_z(dt): + """Format timezone-aware datetime to 'YYYY-MM-DDTHH:MM:SS.mmmZ' in UTC.""" + s = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f") + return s[:-3] + "Z" + + +def get_latest_sunday(): + """Return latest Sunday start-of-day PHT expressed in UTC ISO Z format (inclusive).""" + sunday_midnight_pht = _latest_sunday_midnight_pht() + cutoff_utc = sunday_midnight_pht.astimezone(timezone.utc) + return _iso_z(cutoff_utc) + + +def get_previous_sunday(): + """Return the Sunday prior to the latest Sunday (UTC ISO Z).""" + sunday_midnight_pht = _latest_sunday_midnight_pht() + prev_midnight = sunday_midnight_pht - timedelta(weeks=1) + prev_cutoff_utc = prev_midnight.astimezone(timezone.utc) + return _iso_z(prev_cutoff_utc) + + def format_date_range(week_start, week_end) -> str: """Format date range: 'Feb 8-14' (same month) or 'Mar 29 - Apr 4' (different).""" if week_start.month == week_end.month: @@ -48,12 +96,16 @@ def index(): "TOTAL_EPISODES": os.environ.get("TOTAL_EPISODES", "1155"), "DB_PATH": os.environ.get("DB_PATH", "/app/data/episodes.db"), } + latest = get_latest_sunday() + previous = get_previous_sunday() return render_template( "index.html", week_start=week_start.isoformat(), week_end=week_end.isoformat(), date_range=format_date_range(week_start, week_end), config=config, + latest_sunday=latest, + previous_sunday=previous, ) @@ -61,11 +113,16 @@ def index(): def report(): week_start, week_end, cutoff_utc = current_week() total_episodes = int(os.environ.get("TOTAL_EPISODES", 1155)) + latest = get_latest_sunday() + previous = get_previous_sunday() + + jellyfin.clear_debug_logs() try: ep_info = jellyfin.find_last_one_piece_before(cutoff_utc) except Exception as e: - return jsonify({"error": str(e)}), 502 + debug_output = jellyfin.get_debug_logs() + return jsonify({"error": str(e), "debug": debug_output}), 502 db.save_episode( episode_number=ep_info["episode_number"], @@ -87,6 +144,8 @@ def report(): f"Around {watched_this_week} episodes watched this week!" ) + debug_output = jellyfin.get_debug_logs() + return jsonify({ "week_start": week_start.isoformat(), "week_end": week_end.isoformat(), @@ -98,6 +157,9 @@ def report(): "media_name": ep_info.get("media_name"), "watched_at": ep_info.get("watched_at"), "report": report_text, + "latest_sunday": latest, + "previous_sunday": previous, + "debug": debug_output, }) diff --git a/clients/jellyfin.py b/clients/jellyfin.py index 0cc1573..7e5ca5c 100644 --- a/clients/jellyfin.py +++ b/clients/jellyfin.py @@ -4,6 +4,22 @@ 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("/"), @@ -12,6 +28,16 @@ def _cfg(): } +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() @@ -19,12 +45,33 @@ def get_activity_logs(min_date: datetime) -> list: params = { "api_key": c["key"], "hasUserId": "true", - "limit": 200, + "limit": "200", "minDate": min_date.strftime("%Y-%m-%dT%H:%M:%S.000000Z"), } - resp = requests.get(url, params=params, timeout=15) - resp.raise_for_status() - return resp.json().get("Items", []) + + 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: @@ -32,12 +79,20 @@ def get_playback_info(item_id: str) -> dict: c = _cfg() url = f"{c['host']}/Items/{item_id}/PlaybackInfo" params = {"api_key": c["key"]} - 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}") - return sources[0] + + _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: @@ -55,38 +110,61 @@ def find_last_one_piece_before(cutoff_utc: datetime) -> dict: """ c = _cfg() min_date = cutoff_utc - timedelta(days=14) - entries = get_activity_logs(min_date) - + 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: - if entry.get("Type") != "VideoPlaybackStopped": - continue - if entry.get("UserId") != c["user"]: - continue - if "One Piece" not in entry.get("Name", ""): - continue - - date_str = entry.get("Date", "") - try: - entry_dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) - except ValueError: - continue - - if entry_dt >= cutoff_utc: - continue - - item_id = entry.get("ItemId") - if not item_id: - continue - - 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"], - } - + 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") diff --git a/db.py b/db.py index 913801a..21e7666 100644 --- a/db.py +++ b/db.py @@ -45,7 +45,10 @@ def _seed(con): from datetime import date PHT = timezone(timedelta(hours=8)) now_pht = datetime.now(PHT) - days_since_sunday = (now_pht.weekday() + 1) % 7 + wd = now_pht.weekday() + days_since_sunday = (wd + 1) % 7 + if wd == 6: + days_since_sunday = 7 current_sunday = (now_pht - timedelta(days=days_since_sunday)).date() prev_sunday = current_sunday - timedelta(weeks=1) prev_saturday = prev_sunday + timedelta(days=6) diff --git a/templates/index.html b/templates/index.html index 5a513e0..a87fbc7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -24,7 +24,7 @@ border-radius: 10px; padding: 1.4rem; width: 100%; - max-width: 500px; + max-width: 1000px; margin-bottom: 1rem; } button { @@ -76,17 +76,6 @@

🏴‍☠️ One Piece Progress Reporter

Week: {{ date_range }}  ({{ week_start }} → {{ week_end }})
-
-

Environment

-
-JELLYFIN_HOST={{ config.JELLYFIN_HOST }}
-JELLYFIN_API_KEY={{ config.JELLYFIN_API_KEY }}
-JELLYFIN_USER_ID={{ config.JELLYFIN_USER_ID }}
-TOTAL_EPISODES={{ config.TOTAL_EPISODES }}
-DB_PATH={{ config.DB_PATH }}
-    
-
-
@@ -104,6 +93,20 @@ DB_PATH={{ config.DB_PATH }}
+
+

Debug

+
+LATEST_SUNDAY={{ latest_sunday }}
+PREVIOUS_SUNDAY={{ previous_sunday }}
+JELLYFIN_HOST={{ config.JELLYFIN_HOST }}
+JELLYFIN_USER_ID={{ config.JELLYFIN_USER_ID }}
+TOTAL_EPISODES={{ config.TOTAL_EPISODES }}
+DB_PATH={{ config.DB_PATH }}
+
+(Report output will appear here after Generate Report is clicked)
+    
+
+