From 9e799049a766661ddcbc5152cd73dde3ab8279d5 Mon Sep 17 00:00:00 2001 From: Pat Teruel Date: Sun, 22 Feb 2026 15:39:50 +0800 Subject: [PATCH] major fix --- .env | 8 +-- .env.example | 6 +- ADJUSTMENT.md | 3 +- Dockerfile | 2 + app.py | 152 ++++++++++++++++++++++----------------- clients/jellyfin.py | 136 ++++++++++++++++++++--------------- clients/sonarr.py | 42 ----------- db.py | 99 +++++++++++++++++++++++++ docker-compose.yml | 5 ++ templates/index.html | 167 ++++++++++++++++--------------------------- 10 files changed, 339 insertions(+), 281 deletions(-) delete mode 100644 clients/sonarr.py create mode 100644 db.py diff --git a/.env b/.env index 13a5f3b..7180da7 100644 --- a/.env +++ b/.env @@ -1,12 +1,8 @@ # Jellyfin -JELLYFIN_HOST=https://saturday.local +JELLYFIN_HOST=http://saturday.local JELLYFIN_API_KEY=6a52409cf33945dcad3c89050b82cb65 JELLYFIN_USER_ID=d0e714841b1f4588bf7d90153460180b -# Sonarr -SONARR_HOST=http://sonarr.saturday.local/ -SONARR_API_KEY=0f5ecbd11c1d4ba3b1f87be0ea36eae4 -SONARR_SERIES_ID=one-piece - # App +TOTAL_EPISODES=1155 PORT=5000 diff --git a/.env.example b/.env.example index a0a7cca..240ec7c 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,6 @@ JELLYFIN_HOST=http://jellyfin:8096 JELLYFIN_API_KEY=your_jellyfin_api_key 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 +TOTAL_EPISODES=1155 PORT=5000 diff --git a/ADJUSTMENT.md b/ADJUSTMENT.md index c75eff1..5791436 100644 --- a/ADJUSTMENT.md +++ b/ADJUSTMENT.md @@ -2,7 +2,7 @@ This is complicated than I thought, so we'll adjust our approach. # Jellyfin available APIs for this ```bash -curl --location 'http://saturday.local/System/ActivityLog/Entries?api_key=&hasUserId=true&minDate=2026-02-20T16%3A00%3A00.000Z&limit=100' \ +curl --location 'http://saturday.local/System/ActivityLog/Entries?api_key=&hasUserId=true&limit=100' \ --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. @@ -31,6 +31,7 @@ And let's keep it extremely simple, let's ditch Sonarr API for this. // 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. - 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. diff --git a/Dockerfile b/Dockerfile index 42cb036..8a62401 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN mkdir -p /app/data + EXPOSE 5000 CMD ["python", "app.py"] diff --git a/app.py b/app.py index f5215bc..f515f7a 100644 --- a/app.py +++ b/app.py @@ -1,89 +1,113 @@ -from flask import Flask, render_template, jsonify, request -from datetime import date, timedelta +from flask import Flask, render_template, jsonify +from datetime import datetime, timezone, timedelta import os from dotenv import load_dotenv load_dotenv() -from clients import jellyfin, sonarr +import db +from clients import jellyfin app = Flask(__name__) +PHT = timezone(timedelta(hours=8)) -def last_saturday(from_date=None): - d = from_date or date.today() - # weekday(): Monday=0 ... Saturday=5 ... Sunday=6 - days_back = (d.weekday() - 5) % 7 - if days_back == 0: - days_back = 7 - return d - timedelta(days=days_back) + +def current_week(): + """Return (week_start, week_end, cutoff_utc) for the current week in PHT. + + week_start = most recent Sunday (PHT date) + 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 + ) + 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("/") def index(): - end = last_saturday() - start = end - timedelta(days=6) - return render_template("index.html", default_start=start.isoformat(), default_end=end.isoformat()) - - -@app.route("/api/fetch-episodes") -def fetch_episodes(): - """Return the absolute episode numbers for the two Saturdays bracketing the given date range.""" - start_str = request.args.get("start") - end_str = request.args.get("end") - - try: - end_date = date.fromisoformat(end_str) if end_str else last_saturday() - start_date = date.fromisoformat(start_str) if start_str else end_date - timedelta(days=6) - 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, - }) + week_start, week_end, _ = current_week() + config = { + "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", ""), + "TOTAL_EPISODES": os.environ.get("TOTAL_EPISODES", "1155"), + "DB_PATH": os.environ.get("DB_PATH", "/app/data/episodes.db"), + } + 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, + ) @app.route("/api/report") def report(): - data = fetch_episodes().get_json() - if "error" in data: - return jsonify(data), 502 + week_start, week_end, cutoff_utc = current_week() + total_episodes = int(os.environ.get("TOTAL_EPISODES", 1155)) - start = date.fromisoformat(data["start"]) - end = date.fromisoformat(data["end"]) - date_range = f"{start.strftime('%b %-d')}-{end.strftime('%-d')}" + try: + ep_info = jellyfin.find_last_one_piece_before(cutoff_utc) + except Exception as e: + return jsonify({"error": str(e)}), 502 - template = ( - f"ONE PIECE PROGRESS!!! ({date_range})\n" - f"Episode {data['latest_episode']}/{data['total_episodes']}\n" - f"Around {data['watched_this_week']} episodes watched this week!" + db.save_episode( + episode_number=ep_info["episode_number"], + week_start=week_start.isoformat(), + 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__": + db.init_db() port = int(os.environ.get("PORT", 5000)) app.run(host="0.0.0.0", port=port, debug=False) + diff --git a/clients/jellyfin.py b/clients/jellyfin.py index 67c52f9..0cc1573 100644 --- a/clients/jellyfin.py +++ b/clients/jellyfin.py @@ -1,68 +1,92 @@ -"""Jellyfin API client.""" +"""Jellyfin API client — uses Activity Log + PlaybackInfo.""" import os -from datetime import date, datetime, timezone +import re +from datetime import datetime, timezone, timedelta import requests -JELLYFIN_HOST = os.environ["JELLYFIN_HOST"].rstrip("/") -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, +def _cfg(): + return { + "host": os.environ["JELLYFIN_HOST"].rstrip("/"), + "key": os.environ["JELLYFIN_API_KEY"], + "user": os.environ["JELLYFIN_USER_ID"], } - resp = requests.get(url, headers=HEADERS, params=params, timeout=10) + + +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() - items = resp.json().get("Items", []) - if not items: - raise RuntimeError("One Piece series not found in Jellyfin") - return items[0]["Id"] + return resp.json().get("Items", []) -def get_last_watched_episode(up_to_date: date) -> dict: - """Return the last watched One Piece episode up to (and including) up_to_date. +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] - Returns a dict with keys: season, episode, name, jellyfin_id, watched_at + +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} """ - series_id = _get_one_piece_series_id() + c = _cfg() + min_date = cutoff_utc - timedelta(days=14) + entries = get_activity_logs(min_date) - 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: + 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 - played_dt = datetime.fromisoformat(played_str.replace("Z", "+00:00")) - if played_dt <= cutoff: - return { - "season": item["ParentIndexNumber"], - "episode": item["IndexNumber"], - "name": item.get("Name", ""), - "jellyfin_id": item["Id"], - "watched_at": played_str, - } - raise RuntimeError(f"No watched episodes found up to {up_to_date}") + 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") + diff --git a/clients/sonarr.py b/clients/sonarr.py deleted file mode 100644 index 99d78a4..0000000 --- a/clients/sonarr.py +++ /dev/null @@ -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) diff --git a/db.py b/db.py new file mode 100644 index 0000000..913801a --- /dev/null +++ b/db.py @@ -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] diff --git a/docker-compose.yml b/docker-compose.yml index 0297516..ef381b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,9 @@ services: - "5001:5000" env_file: - .env + volumes: + - episodes_data:/app/data restart: unless-stopped + +volumes: + episodes_data: diff --git a/templates/index.html b/templates/index.html index 4c770e3..5a513e0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,52 +14,40 @@ display: flex; flex-direction: column; 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 { background: #1c1c1c; border: 1px solid #2e2e2e; border-radius: 10px; - padding: 1.5rem; + padding: 1.4rem; width: 100%; - max-width: 540px; - margin-bottom: 1.2rem; + max-width: 500px; + 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 { - flex: 1; - padding: 0.6rem 1rem; + width: 100%; + padding: 0.7rem 1rem; border: none; border-radius: 6px; - font-size: 0.95rem; + font-size: 1rem; cursor: pointer; transition: opacity 0.15s; } - button:disabled { opacity: 0.5; cursor: not-allowed; } - #btn-fetch { background: #2563eb; color: #fff; } - #btn-report { background: #f97316; color: #fff; } - button:not(:disabled):hover { opacity: 0.85; } + button:disabled { opacity: 0.45; cursor: not-allowed; } + #btn-report { background: #f97316; color: #fff; font-weight: 600; } + #btn-report:not(:disabled):hover { opacity: 0.85; } .detail-grid { display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.6rem 1.2rem; + grid-template-columns: auto 1fr; + gap: 0.5rem 1.2rem; font-size: 0.9rem; + margin-bottom: 1rem; } - .detail-grid .label { color: #888; } - .detail-grid .value { color: #fff; font-weight: 600; } + .detail-grid .lbl { color: #888; white-space: nowrap; } + .detail-grid .val { color: #fff; font-weight: 600; } #report-box { width: 100%; background: #111; @@ -70,58 +58,50 @@ font-family: monospace; font-size: 0.95rem; white-space: pre-wrap; - min-height: 80px; - resize: vertical; + min-height: 72px; } #copy-btn { margin-top: 0.7rem; background: #16a34a; 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; } .hidden { display: none; } + .media-name { font-size: 0.78rem; color: #666; margin-top: 0.8rem; word-break: break-word; }

🏴‍☠️ 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 }}
+    
+
-
-
- - -
-
- - -
-
-
- - -
+
- - +