major fix

This commit is contained in:
Pat Teruel committed 2026-02-22 15:39:50 +08:00
1 parent 929088cef0
commit 9e799049a7
10 files changed
+344 -286

No files matched your search

+2 -6
View File
@@ -1,12 +1,8 @@
# Jellyfin # Jellyfin
JELLYFIN_HOST=https://saturday.local JELLYFIN_HOST=http://saturday.local
JELLYFIN_API_KEY=6a52409cf33945dcad3c89050b82cb65 JELLYFIN_API_KEY=6a52409cf33945dcad3c89050b82cb65
JELLYFIN_USER_ID=d0e714841b1f4588bf7d90153460180b JELLYFIN_USER_ID=d0e714841b1f4588bf7d90153460180b
# Sonarr
SONARR_HOST=http://sonarr.saturday.local/
SONARR_API_KEY=0f5ecbd11c1d4ba3b1f87be0ea36eae4
SONARR_SERIES_ID=one-piece
# App # App
TOTAL_EPISODES=1155
PORT=5000 PORT=5000
+1 -5
View File
@@ -3,10 +3,6 @@ JELLYFIN_HOST=http://jellyfin:8096
JELLYFIN_API_KEY=your_jellyfin_api_key JELLYFIN_API_KEY=your_jellyfin_api_key
JELLYFIN_USER_ID=your_jellyfin_user_id JELLYFIN_USER_ID=your_jellyfin_user_id
# Sonarr
SONARR_HOST=http://sonarr:8989
SONARR_API_KEY=your_sonarr_api_key
SONARR_SERIES_ID=your_one_piece_series_id
# App # App
TOTAL_EPISODES=1155
PORT=5000 PORT=5000
+2 -1
View File
@@ -2,7 +2,7 @@ This is complicated than I thought, so we'll adjust our approach.
# Jellyfin available APIs for this # Jellyfin available APIs for this
```bash ```bash
curl --location 'http://saturday.local/System/ActivityLog/Entries?api_key=<apikey></apikey>&hasUserId=true&minDate=2026-02-20T16%3A00%3A00.000Z&limit=100' \ curl --location 'http://saturday.local/System/ActivityLog/Entries?api_key=<apikey></apikey>&hasUserId=true&limit=100' \
--header 'accept: application/json' --header 'accept: application/json'
``` ```
- It doesn't even accept specific dates UP to a certain point. we could theoretically take the activities from a given mindate and reverse the list, but we can't be sure if the earliest item is the earliest for that date. - It doesn't even accept specific dates UP to a certain point. we could theoretically take the activities from a given mindate and reverse the list, but we can't be sure if the earliest item is the earliest for that date.
@@ -31,6 +31,7 @@ And let's keep it extremely simple, let's ditch Sonarr API for this.
// other activity logs // other activity logs
] ]
``` ```
- Ensure that the log matches the UserId given on the env as well
- Based on the sample log above, we can see that the user "pat" has finished playing an episode of One Piece on Apple TV. The ItemId is "9c543413ef481048f33c5bd0f4a6bd8c", and the date of the activity is "2026-02-21T14:10:23.4402504Z". We can use this information to determine which episode was watched and when it was watched. - Based on the sample log above, we can see that the user "pat" has finished playing an episode of One Piece on Apple TV. The ItemId is "9c543413ef481048f33c5bd0f4a6bd8c", and the date of the activity is "2026-02-21T14:10:23.4402504Z". We can use this information to determine which episode was watched and when it was watched.
- If for example I ran this script on Feberuary 24, and I alraedy watched another episode of One Piece, we have to take the last episode watched before the Sunday, which is February 22, 2026 12:00:00 PM PHT or February 21, 2026 16:00:00 UTC. So we have to filter the activity logs to only include those that occurred before that date and time. - If for example I ran this script on Feberuary 24, and I alraedy watched another episode of One Piece, we have to take the last episode watched before the Sunday, which is February 22, 2026 12:00:00 PM PHT or February 21, 2026 16:00:00 UTC. So we have to filter the activity logs to only include those that occurred before that date and time.
- The data doesn't contain the episode number, so we have to extract the playback info from the given ItemId e.g. - The data doesn't contain the episode number, so we have to extract the playback info from the given ItemId e.g.
+2
View File
@@ -7,6 +7,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN mkdir -p /app/data
EXPOSE 5000 EXPOSE 5000
CMD ["python", "app.py"] CMD ["python", "app.py"]
+88 -64
View File
@@ -1,89 +1,113 @@
from flask import Flask, render_template, jsonify, request from flask import Flask, render_template, jsonify
from datetime import date, timedelta from datetime import datetime, timezone, timedelta
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
from clients import jellyfin, sonarr import db
from clients import jellyfin
app = Flask(__name__) app = Flask(__name__)
PHT = timezone(timedelta(hours=8))
def last_saturday(from_date=None):
d = from_date or date.today() def current_week():
# weekday(): Monday=0 ... Saturday=5 ... Sunday=6 """Return (week_start, week_end, cutoff_utc) for the current week in PHT.
days_back = (d.weekday() - 5) % 7
if days_back == 0: week_start = most recent Sunday (PHT date)
days_back = 7 week_end = that Sunday + 6 days (Saturday)
return d - timedelta(days=days_back) 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
)
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 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:
return f"{week_start.strftime('%b %-d')}-{week_end.strftime('%-d')}"
return f"{week_start.strftime('%b %-d')} - {week_end.strftime('%b %-d')}"
@app.route("/") @app.route("/")
def index(): def index():
end = last_saturday() week_start, week_end, _ = current_week()
start = end - timedelta(days=6) config = {
return render_template("index.html", default_start=start.isoformat(), default_end=end.isoformat()) "JELLYFIN_HOST": os.environ.get("JELLYFIN_HOST", ""),
"JELLYFIN_API_KEY": os.environ.get("JELLYFIN_API_KEY", ""),
"JELLYFIN_USER_ID": os.environ.get("JELLYFIN_USER_ID", ""),
@app.route("/api/fetch-episodes") "TOTAL_EPISODES": os.environ.get("TOTAL_EPISODES", "1155"),
def fetch_episodes(): "DB_PATH": os.environ.get("DB_PATH", "/app/data/episodes.db"),
"""Return the absolute episode numbers for the two Saturdays bracketing the given date range.""" }
start_str = request.args.get("start") return render_template(
end_str = request.args.get("end") "index.html",
week_start=week_start.isoformat(),
try: week_end=week_end.isoformat(),
end_date = date.fromisoformat(end_str) if end_str else last_saturday() date_range=format_date_range(week_start, week_end),
start_date = date.fromisoformat(start_str) if start_str else end_date - timedelta(days=6) config=config,
except ValueError as e: )
return jsonify({"error": str(e)}), 400
try:
latest_ep = jellyfin.get_last_watched_episode(end_date)
previous_ep = jellyfin.get_last_watched_episode(start_date - timedelta(days=1))
except Exception as e:
return jsonify({"error": f"Jellyfin error: {e}"}), 502
try:
latest_abs = sonarr.get_absolute_episode(latest_ep["season"], latest_ep["episode"])
previous_abs = sonarr.get_absolute_episode(previous_ep["season"], previous_ep["episode"])
except Exception as e:
return jsonify({"error": f"Sonarr error: {e}"}), 502
total_episodes = sonarr.get_total_episodes()
watched_this_week = latest_abs - previous_abs
return jsonify({
"start": start_date.isoformat(),
"end": end_date.isoformat(),
"latest_episode": latest_abs,
"previous_episode": previous_abs,
"watched_this_week": watched_this_week,
"total_episodes": total_episodes,
"latest_jellyfin": latest_ep,
"previous_jellyfin": previous_ep,
})
@app.route("/api/report") @app.route("/api/report")
def report(): def report():
data = fetch_episodes().get_json() week_start, week_end, cutoff_utc = current_week()
if "error" in data: total_episodes = int(os.environ.get("TOTAL_EPISODES", 1155))
return jsonify(data), 502
start = date.fromisoformat(data["start"]) try:
end = date.fromisoformat(data["end"]) ep_info = jellyfin.find_last_one_piece_before(cutoff_utc)
date_range = f"{start.strftime('%b %-d')}-{end.strftime('%-d')}" except Exception as e:
return jsonify({"error": str(e)}), 502
template = ( db.save_episode(
f"ONE PIECE PROGRESS!!! ({date_range})\n" episode_number=ep_info["episode_number"],
f"Episode {data['latest_episode']}/{data['total_episodes']}\n" week_start=week_start.isoformat(),
f"Around {data['watched_this_week']} episodes watched this week!" week_end=week_end.isoformat(),
item_id=ep_info.get("item_id"),
media_name=ep_info.get("media_name"),
watched_at=ep_info.get("watched_at"),
) )
data["report"] = template
return jsonify(data) prev = db.get_previous_episode(week_start.isoformat())
previous_episode = prev["episode_number"] if prev else ep_info["episode_number"]
watched_this_week = ep_info["episode_number"] - previous_episode
date_range = format_date_range(week_start, week_end)
report_text = (
f"ONE PIECE PROGRESS!!! ({date_range})\n"
f"Episode {ep_info['episode_number']}/{total_episodes}\n"
f"Around {watched_this_week} episodes watched this week!"
)
return jsonify({
"week_start": week_start.isoformat(),
"week_end": week_end.isoformat(),
"date_range": date_range,
"latest_episode": ep_info["episode_number"],
"previous_episode": previous_episode,
"watched_this_week": watched_this_week,
"total_episodes": total_episodes,
"media_name": ep_info.get("media_name"),
"watched_at": ep_info.get("watched_at"),
"report": report_text,
})
@app.route("/api/history")
def history():
return jsonify(db.get_all_history())
if __name__ == "__main__": if __name__ == "__main__":
db.init_db()
port = int(os.environ.get("PORT", 5000)) port = int(os.environ.get("PORT", 5000))
app.run(host="0.0.0.0", port=port, debug=False) app.run(host="0.0.0.0", port=port, debug=False)
+86 -62
View File
@@ -1,68 +1,92 @@
"""Jellyfin API client.""" """Jellyfin API client — uses Activity Log + PlaybackInfo."""
import os import os
from datetime import date, datetime, timezone import re
from datetime import datetime, timezone, timedelta
import requests import requests
JELLYFIN_HOST = os.environ["JELLYFIN_HOST"].rstrip("/") def _cfg():
JELLYFIN_API_KEY = os.environ["JELLYFIN_API_KEY"]
JELLYFIN_USER_ID = os.environ["JELLYFIN_USER_ID"]
HEADERS = {"X-Emby-Token": JELLYFIN_API_KEY}
def _get_one_piece_series_id():
"""Return the Jellyfin item ID for One Piece."""
url = f"{JELLYFIN_HOST}/Users/{JELLYFIN_USER_ID}/Items"
params = {
"searchTerm": "One Piece",
"IncludeItemTypes": "Series",
"Recursive": "true",
"Limit": 5,
}
resp = requests.get(url, headers=HEADERS, params=params, timeout=10)
resp.raise_for_status()
items = resp.json().get("Items", [])
if not items:
raise RuntimeError("One Piece series not found in Jellyfin")
return items[0]["Id"]
def get_last_watched_episode(up_to_date: date) -> dict:
"""Return the last watched One Piece episode up to (and including) up_to_date.
Returns a dict with keys: season, episode, name, jellyfin_id, watched_at
"""
series_id = _get_one_piece_series_id()
url = f"{JELLYFIN_HOST}/Users/{JELLYFIN_USER_ID}/Items"
params = {
"ParentId": series_id,
"IncludeItemTypes": "Episode",
"Recursive": "true",
"Filters": "IsPlayed",
"SortBy": "DatePlayed",
"SortOrder": "Descending",
"Fields": "UserData",
"Limit": 200,
}
resp = requests.get(url, headers=HEADERS, params=params, timeout=15)
resp.raise_for_status()
items = resp.json().get("Items", [])
cutoff = datetime(up_to_date.year, up_to_date.month, up_to_date.day, 23, 59, 59, tzinfo=timezone.utc)
for item in items:
played_str = item.get("UserData", {}).get("LastPlayedDate")
if not played_str:
continue
played_dt = datetime.fromisoformat(played_str.replace("Z", "+00:00"))
if played_dt <= cutoff:
return { return {
"season": item["ParentIndexNumber"], "host": os.environ["JELLYFIN_HOST"].rstrip("/"),
"episode": item["IndexNumber"], "key": os.environ["JELLYFIN_API_KEY"],
"name": item.get("Name", ""), "user": os.environ["JELLYFIN_USER_ID"],
"jellyfin_id": item["Id"],
"watched_at": played_str,
} }
raise RuntimeError(f"No watched episodes found up to {up_to_date}")
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"),
}
resp = requests.get(url, params=params, timeout=15)
resp.raise_for_status()
return resp.json().get("Items", [])
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"]}
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]
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_before(cutoff_utc: datetime) -> dict:
"""Return info about the last One Piece episode watched before cutoff_utc.
Returns: {episode_number, item_id, watched_at (ISO str), media_name}
"""
c = _cfg()
min_date = cutoff_utc - timedelta(days=14)
entries = get_activity_logs(min_date)
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"],
}
raise RuntimeError("No One Piece episode found before the Sunday cutoff in the last 14 days")
-42
View File
@@ -1,42 +0,0 @@
"""Sonarr API client."""
import os
import requests
SONARR_HOST = os.environ["SONARR_HOST"].rstrip("/")
SONARR_API_KEY = os.environ["SONARR_API_KEY"]
SONARR_SERIES_ID = os.environ["SONARR_SERIES_ID"]
HEADERS = {"X-Api-Key": SONARR_API_KEY}
def _get_episodes():
"""Fetch all episodes for the configured One Piece series."""
url = f"{SONARR_HOST}/api/v3/episode"
params = {"seriesId": SONARR_SERIES_ID}
resp = requests.get(url, headers=HEADERS, params=params, timeout=30)
resp.raise_for_status()
return resp.json()
def get_absolute_episode(season: int, episode: int) -> int:
"""Return the absolute (series-wide) episode number for the given season/episode."""
episodes = _get_episodes()
for ep in episodes:
if ep.get("seasonNumber") == season and ep.get("episodeNumber") == episode:
abs_num = ep.get("absoluteEpisodeNumber")
if abs_num is None:
raise RuntimeError(
f"Sonarr has no absolute episode number for S{season:02d}E{episode:02d}"
)
return abs_num
raise RuntimeError(f"Episode S{season:02d}E{episode:02d} not found in Sonarr")
def get_total_episodes() -> int:
"""Return total number of episodes in the series according to Sonarr."""
episodes = _get_episodes()
# Exclude specials (season 0)
regular = [ep for ep in episodes if ep.get("seasonNumber", 0) != 0 and ep.get("hasFile")]
if not regular:
regular = [ep for ep in episodes if ep.get("seasonNumber", 0) != 0]
return len(regular)
+99
View File
@@ -0,0 +1,99 @@
"""SQLite persistence for weekly episode tracking."""
import os
import sqlite3
from datetime import datetime, timezone, timedelta
from contextlib import contextmanager
DB_PATH = os.environ.get("DB_PATH", "/app/data/episodes.db")
SEED_EPISODE = 928
@contextmanager
def _conn():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
try:
yield con
con.commit()
finally:
con.close()
def init_db():
"""Create table and seed with the initial previous-week episode if empty."""
with _conn() as con:
con.execute("""
CREATE TABLE IF NOT EXISTS weekly_episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
episode_number INTEGER NOT NULL,
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
item_id TEXT,
media_name TEXT,
watched_at TEXT,
recorded_at TEXT DEFAULT (datetime('now'))
)
""")
count = con.execute("SELECT COUNT(*) FROM weekly_episodes").fetchone()[0]
if count == 0:
_seed(con)
def _seed(con):
"""Seed with episode 928 for the week before the current week."""
from datetime import date
PHT = timezone(timedelta(hours=8))
now_pht = datetime.now(PHT)
days_since_sunday = (now_pht.weekday() + 1) % 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)
con.execute(
"INSERT INTO weekly_episodes (episode_number, week_start, week_end, media_name) VALUES (?,?,?,?)",
(SEED_EPISODE, prev_sunday.isoformat(), prev_saturday.isoformat(), "seed"),
)
def save_episode(episode_number: int, week_start: str, week_end: str,
item_id: str = None, media_name: str = None, watched_at: str = None):
"""Insert or replace the episode record for this week."""
with _conn() as con:
existing = con.execute(
"SELECT id FROM weekly_episodes WHERE week_start = ?", (week_start,)
).fetchone()
if existing:
con.execute(
"""UPDATE weekly_episodes
SET episode_number=?, item_id=?, media_name=?, watched_at=?, recorded_at=datetime('now')
WHERE week_start=?""",
(episode_number, item_id, media_name, watched_at, week_start),
)
else:
con.execute(
"""INSERT INTO weekly_episodes
(episode_number, week_start, week_end, item_id, media_name, watched_at)
VALUES (?,?,?,?,?,?)""",
(episode_number, week_start, week_end, item_id, media_name, watched_at),
)
def get_previous_episode(week_start: str) -> dict | None:
"""Return the most recent episode record strictly before week_start."""
with _conn() as con:
row = con.execute(
"""SELECT * FROM weekly_episodes
WHERE week_start < ?
ORDER BY week_start DESC LIMIT 1""",
(week_start,),
).fetchone()
return dict(row) if row else None
def get_all_history() -> list:
"""Return all weekly episode records, newest first."""
with _conn() as con:
rows = con.execute(
"SELECT * FROM weekly_episodes ORDER BY week_start DESC"
).fetchall()
return [dict(r) for r in rows]
+5
View File
@@ -5,4 +5,9 @@ services:
- "5001:5000" - "5001:5000"
env_file: env_file:
- .env - .env
volumes:
- episodes_data:/app/data
restart: unless-stopped restart: unless-stopped
volumes:
episodes_data:
+59 -106
View File
@@ -14,52 +14,40 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 2rem 1rem; padding: 2.5rem 1rem;
} }
h1 { font-size: 1.6rem; margin-bottom: 1.5rem; color: #f97316; } h1 { font-size: 1.5rem; margin-bottom: 0.4rem; color: #f97316; }
.week-label { font-size: 0.9rem; color: #888; margin-bottom: 1.8rem; }
.card { .card {
background: #1c1c1c; background: #1c1c1c;
border: 1px solid #2e2e2e; border: 1px solid #2e2e2e;
border-radius: 10px; border-radius: 10px;
padding: 1.5rem; padding: 1.4rem;
width: 100%; width: 100%;
max-width: 540px; max-width: 500px;
margin-bottom: 1.2rem; margin-bottom: 1rem;
} }
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.3rem; }
.row { display: flex; gap: 1rem; margin-bottom: 1rem; }
.row > div { flex: 1; }
input[type="date"] {
width: 100%;
padding: 0.5rem 0.7rem;
background: #2a2a2a;
border: 1px solid #3e3e3e;
border-radius: 6px;
color: #e8e8e8;
font-size: 0.95rem;
}
.btn-row { display: flex; gap: 0.8rem; }
button { button {
flex: 1; width: 100%;
padding: 0.6rem 1rem; padding: 0.7rem 1rem;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
font-size: 0.95rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
button:disabled { opacity: 0.5; cursor: not-allowed; } button:disabled { opacity: 0.45; cursor: not-allowed; }
#btn-fetch { background: #2563eb; color: #fff; } #btn-report { background: #f97316; color: #fff; font-weight: 600; }
#btn-report { background: #f97316; color: #fff; } #btn-report:not(:disabled):hover { opacity: 0.85; }
button:not(:disabled):hover { opacity: 0.85; }
.detail-grid { .detail-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: auto 1fr;
gap: 0.6rem 1.2rem; gap: 0.5rem 1.2rem;
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 1rem;
} }
.detail-grid .label { color: #888; } .detail-grid .lbl { color: #888; white-space: nowrap; }
.detail-grid .value { color: #fff; font-weight: 600; } .detail-grid .val { color: #fff; font-weight: 600; }
#report-box { #report-box {
width: 100%; width: 100%;
background: #111; background: #111;
@@ -70,58 +58,50 @@
font-family: monospace; font-family: monospace;
font-size: 0.95rem; font-size: 0.95rem;
white-space: pre-wrap; white-space: pre-wrap;
min-height: 80px; min-height: 72px;
resize: vertical;
} }
#copy-btn { #copy-btn {
margin-top: 0.7rem; margin-top: 0.7rem;
background: #16a34a; background: #16a34a;
color: #fff; color: #fff;
flex: none;
width: 100%;
} }
.status { font-size: 0.85rem; color: #f59e0b; min-height: 1.2rem; margin-top: 0.5rem; } #copy-btn:not(:disabled):hover { opacity: 0.85; }
.status { font-size: 0.85rem; color: #f59e0b; min-height: 1.2rem; margin-top: 0.6rem; text-align: center; }
.error { color: #f87171; } .error { color: #f87171; }
.hidden { display: none; } .hidden { display: none; }
.media-name { font-size: 0.78rem; color: #666; margin-top: 0.8rem; word-break: break-word; }
</style> </style>
</head> </head>
<body> <body>
<h1>🏴‍☠️ One Piece Progress Reporter</h1> <h1>🏴‍☠️ One Piece Progress Reporter</h1>
<div class="week-label">Week: <strong>{{ date_range }}</strong> &nbsp;({{ week_start }} → {{ week_end }})</div>
<div class="card" style="margin-top:0.8rem;">
<h2 style="font-size:0.95rem;color:#aaa;margin-bottom:0.5rem;">Environment</h2>
<pre id="env" style="white-space:pre-wrap;font-family:monospace;font-size:0.85rem;color:#ddd;background:#0b0b0b;border-radius:6px;padding:0.6rem;">
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 }}
</pre>
</div>
<div class="card"> <div class="card">
<div class="row">
<div>
<label for="start">Week Start</label>
<input type="date" id="start" value="{{ default_start }}" />
</div>
<div>
<label for="end">Week End (Saturday)</label>
<input type="date" id="end" value="{{ default_end }}" />
</div>
</div>
<div class="btn-row">
<button id="btn-fetch">Fetch Episodes</button>
<button id="btn-report">Generate Report</button> <button id="btn-report">Generate Report</button>
</div>
<div id="status" class="status"></div> <div id="status" class="status"></div>
</div> </div>
<div class="card hidden" id="details-card"> <div class="card hidden" id="details-card">
<h2 style="font-size:1rem;margin-bottom:0.8rem;color:#aaa;">Episode Details</h2>
<div class="detail-grid"> <div class="detail-grid">
<span class="label">Latest episode</span> <span class="value" id="d-latest">—</span> <span class="lbl">Last episode watched</span><span class="val" id="d-latest">—</span>
<span class="label">Previous week</span> <span class="value" id="d-previous">—</span> <span class="lbl">Previous week ended at</span><span class="val" id="d-previous">—</span>
<span class="label">Watched this week</span><span class="value" id="d-watched">—</span> <span class="lbl">Watched this week</span><span class="val" id="d-watched">—</span>
<span class="label">Total in series</span> <span class="value" id="d-total">—</span> <span class="lbl">Total in series</span><span class="val" id="d-total">—</span>
<span class="label">Jellyfin (latest)</span><span class="value" id="d-jf-latest">—</span>
<span class="label">Jellyfin (prev)</span> <span class="value" id="d-jf-prev">—</span>
</div> </div>
</div>
<div class="card hidden" id="report-card">
<h2 style="font-size:1rem;margin-bottom:0.8rem;color:#aaa;">Report</h2>
<div id="report-box"></div> <div id="report-box"></div>
<button id="copy-btn">📋 Copy to Clipboard</button> <button id="copy-btn">📋 Copy to Clipboard</button>
<div id="media-name" class="media-name hidden"></div>
</div> </div>
<script> <script>
@@ -133,69 +113,42 @@
el.classList.toggle('error', isError); el.classList.toggle('error', isError);
} }
function setLoading(on) { $('btn-report').addEventListener('click', async () => {
$('btn-fetch').disabled = on; $('btn-report').disabled = true;
$('btn-report').disabled = on; setStatus('Fetching from Jellyfin…');
} $('details-card').classList.add('hidden');
try {
const resp = await fetch('/api/report');
const data = await resp.json();
if (data.error) throw new Error(data.error);
function showDetails(data) {
$('d-latest').textContent = data.latest_episode; $('d-latest').textContent = data.latest_episode;
$('d-previous').textContent = data.previous_episode; $('d-previous').textContent = data.previous_episode;
$('d-watched').textContent = data.watched_this_week; $('d-watched').textContent = data.watched_this_week;
$('d-total').textContent = data.total_episodes; $('d-total').textContent = data.total_episodes;
$('d-jf-latest').textContent = `S${String(data.latest_jellyfin.season).padStart(2,'0')}E${String(data.latest_jellyfin.episode).padStart(2,'0')}`;
$('d-jf-prev').textContent = `S${String(data.previous_jellyfin.season).padStart(2,'0')}E${String(data.previous_jellyfin.episode).padStart(2,'0')}`;
$('details-card').classList.remove('hidden');
}
async function doFetch() {
setLoading(true);
setStatus('Fetching episode data…');
$('details-card').classList.add('hidden');
$('report-card').classList.add('hidden');
try {
const start = $('start').value;
const end = $('end').value;
const resp = await fetch(`/api/fetch-episodes?start=${start}&end=${end}`);
const data = await resp.json();
if (data.error) throw new Error(data.error);
showDetails(data);
setStatus('');
} catch (e) {
setStatus(e.message, true);
} finally {
setLoading(false);
}
}
async function doReport() {
setLoading(true);
setStatus('Generating report…');
$('report-card').classList.add('hidden');
try {
const start = $('start').value;
const end = $('end').value;
const resp = await fetch(`/api/report?start=${start}&end=${end}`);
const data = await resp.json();
if (data.error) throw new Error(data.error);
showDetails(data);
$('report-box').textContent = data.report; $('report-box').textContent = data.report;
$('report-card').classList.remove('hidden');
if (data.media_name) {
$('media-name').textContent = '📁 ' + data.media_name;
$('media-name').classList.remove('hidden');
}
$('details-card').classList.remove('hidden');
setStatus(''); setStatus('');
} catch (e) { } catch (e) {
setStatus(e.message, true); setStatus(e.message, true);
} finally { } finally {
setLoading(false); $('btn-report').disabled = false;
}
} }
});
$('btn-fetch').addEventListener('click', doFetch);
$('btn-report').addEventListener('click', doReport);
$('copy-btn').addEventListener('click', () => { $('copy-btn').addEventListener('click', () => {
navigator.clipboard.writeText($('report-box').textContent) navigator.clipboard.writeText($('report-box').textContent)
.then(() => setStatus('Copied!')) .then(() => setStatus('Copied to clipboard!'))
.catch(() => setStatus('Copy failed', true)); .catch(() => setStatus('Copy failed — select text manually', true));
}); });
</script> </script>
</body> </body>
</html> </html>