Initial work
This commit is contained in:
commit
929088cef0
13 files changed
+613
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
# Jellyfin
|
||||||
|
JELLYFIN_HOST=https://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
|
||||||
|
PORT=5000
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Jellyfin
|
||||||
|
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
|
||||||
|
PORT=5000
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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=<apikey></apikey>&hasUserId=true&minDate=2026-02-20T16%3A00%3A00.000Z&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.
|
||||||
|
|
||||||
|
So we'll limit our scope to only run this app on that sunday or within few days after, at least not over 7 days; ideally I'd run this within 3 days after the last Saturday.
|
||||||
|
|
||||||
|
Also I may have to persistently keep some record of the last episode watched, so we'll need an SQLite database to store the details.
|
||||||
|
|
||||||
|
And let's keep it extremely simple, let's ditch Sonarr API for this.
|
||||||
|
|
||||||
|
# New method:
|
||||||
|
0. Before all these of course, we have to take the latest Sunday date e.g. today is February 24, 2026, so the latest Sunday is February 22, 2026 12:00:00 PM PHT or February 21, 2026 16:00:00 UTC. We can calculate this date by using the current date and adjusting it to the nearest Sunday.
|
||||||
|
1. Take activity logs from the last 7 days, and get the last One Piece episode watched before the Sunday.
|
||||||
|
- Although the sunday must be in UTC converted from PHT, so e.g. February 22, 2026 12:00:00 PM PHT is February 21, 2026 16:00:00 UTC, so we have to make sure we get the accurate activity log e.g.
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Id": 15658,
|
||||||
|
"Name": "pat has finished playing One Piece - The Straw Hats Run! Save the Captive Tonoyasu! on Apple TV",
|
||||||
|
"Type": "VideoPlaybackStopped",
|
||||||
|
"ItemId": "9c543413ef481048f33c5bd0f4a6bd8c",
|
||||||
|
"Date": "2026-02-21T14:10:23.4402504Z",
|
||||||
|
"UserId": "d0e714841b1f4588bf7d90153460180b",
|
||||||
|
"Severity": "Information"
|
||||||
|
},
|
||||||
|
// other activity logs
|
||||||
|
]
|
||||||
|
```
|
||||||
|
- 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.
|
||||||
|
```bash
|
||||||
|
curl --location 'http://saturday.local/Items/<itemid>/PlaybackInfo?api_key=<apikey>' \
|
||||||
|
--header 'accept: application/json'
|
||||||
|
|
||||||
|
# response:
|
||||||
|
{
|
||||||
|
"MediaSources": [
|
||||||
|
{
|
||||||
|
"Protocol": "File",
|
||||||
|
"Id": "9c543413ef481048f33c5bd0f4a6bd8c",
|
||||||
|
"Path": "/media/ANIME/[ONGOING]/One Piece/Season 21/One Piece - S21E48 (939) - The Straw Hats Run! Save the Captive Tonoyasu!.mkv",
|
||||||
|
"Type": "Default",
|
||||||
|
"Container": "mkv",
|
||||||
|
"Size": 465009687,
|
||||||
|
"Name": "One Piece - S21E48 (939) - The Straw Hats Run! Save the Captive Tonoyasu!",
|
||||||
|
// other media source details
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- using the data from the MediaSources, we can extract the episode number (e.g. 939) from the "Name" field, which contains the episode title and number. We can then use this information to determine which episode was watched and when it was watched.
|
||||||
|
- We can then store this information in an SQLite database, along with the date and time of the activity, so that we can keep track of which episodes have been watched and when they were watched. This will allow us to easily determine which episode to watch next on the following Saturday.
|
||||||
|
|
||||||
|
|
||||||
|
as for the initial "previous saturday" episode I watched, it was 928 so let's just seed it.
|
||||||
|
|
||||||
|
All in all, I'm just expecting the output for the report to be:
|
||||||
|
> ONE PIECE PROGRESS!!! (Feb 8-14)
|
||||||
|
> Episode 928/1155
|
||||||
|
> Around 10 episodes watched this week!
|
||||||
|
|
||||||
|
Literally the template is:
|
||||||
|
> ONE PIECE PROGRESS!!! (MMM D1-D2)
|
||||||
|
> Episode X/Y
|
||||||
|
> Around Z episodes watched this week!
|
||||||
|
- Note: The date range depends if the date overlaps with the previous month e.g. Mar 29 - Apr 4; if not, then just within the same month e.g. Feb 8-14. The first date is the previous Sunday, and the second date is the following Saturday.
|
||||||
|
- X is of course the last episode we extracted based on the method above, and Y is the latest total episode; let's make this manually configurable until we figure out how we can extract this from Sonarr. the current latest episode is 1155, we'll seed this as well.
|
||||||
|
|
||||||
|
# Conclusion and Summary
|
||||||
|
- New approach:
|
||||||
|
1. Calculate the latest Sunday date based on the current date.
|
||||||
|
2. Fetch activity logs from the last 7 days and filter them to find the last One Piece episode watched before the calculated Sunday date.
|
||||||
|
3. Extract the episode number from the media source information using the ItemId from the activity log.
|
||||||
|
4. Store the episode number and the date of the activity in an SQLite database for future reference.
|
||||||
|
5. Generate a report based on the stored data, showing the progress of One Piece episodes watched within the week, including the date range, the last episode watched, the total number of episodes, and the number of episodes watched during the week.
|
||||||
|
- This approach allows us to accurately track the progress of One Piece episodes watched each week, while also providing a clear and concise report for easy reference. By using an SQLite database, we can persistently store the episode information and easily retrieve it for future reports, ensuring that we have a comprehensive record of our One Piece viewing history.
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Problem
|
||||||
|
Every week at Sunday, I write a One Piece progress report of the episodes I watched in the past week.
|
||||||
|
I want to automate this process by creating a script that generates a report based on the episodes I watched.
|
||||||
|
|
||||||
|
# Current Process
|
||||||
|
1. I open Jellyfin to see the latest episode suggested on the homepage.
|
||||||
|
2. The jellyfin episode displayed is in format Season X Episode Y.
|
||||||
|
3. I need the episode number that's actually for the whole series, so in order to get that, I need to check Sonarr to see the episode number for the whole series.
|
||||||
|
4. I then take:
|
||||||
|
- Latest episode I watched on Saturday e.g. 925
|
||||||
|
- Last episode I watched on the previous Saturday e.g. 915
|
||||||
|
- Deduct the number of episodes I watched in the past week, which is 925 - 915 = 10.
|
||||||
|
- Take the date range for the past week, which is Feb 8-14.
|
||||||
|
5. Based on that data, I'd write something like:
|
||||||
|
> ONE PIECE PROGRESS!!! (Feb 8-14)
|
||||||
|
> Episode 928/1155
|
||||||
|
> Around 10 episodes watched this week!
|
||||||
|
|
||||||
|
# Proposed Solution
|
||||||
|
1. We can use python or javascript to create a script that automates this process.
|
||||||
|
2. The script will:
|
||||||
|
- Get the latest Saturday's date and the previous Saturday's date.
|
||||||
|
- Fetch the latest episode the specified user watched from Jellyfin for One Piece using its API.
|
||||||
|
- Also fetch the latest episode the specified user watched from Jellyfin for One Piece from the previous week using its API.
|
||||||
|
- Find the episode number for the whole series from Sonarr using its API given the season and episode number from Jellyfin.
|
||||||
|
- Calculate the number of episodes watched in the past week by deducting the previous week's episode number from the latest episode number.
|
||||||
|
- Generate a templated text to be copied and pasted into the progress report e.g. ONE PIECE PROGRESS!!! etc etc as specified at #5 of the current process.
|
||||||
|
3. The script is just run on a web page, so the web page would give me a detailed report specified at #4 of the current process, and also a templated text that I can copy and paste into my progress report.
|
||||||
|
|
||||||
|
# Stack
|
||||||
|
1. One single container that runs a web server (e.g. Flask for Python or Express for JavaScript) to serve the web page and handle API requests.
|
||||||
|
2. No other dependencies needed besides an existing running container for Jellyfin and Sonarr, which the script will interact with via their APIs.
|
||||||
|
3. Env file where I can specify:
|
||||||
|
- Jellyfin API key and user ID
|
||||||
|
- Sonarr API key and series ID for One Piece
|
||||||
|
- Host names for Jellyfin and Sonarr
|
||||||
|
4. The web page will have a simple form to input the date range (defaulting to the past week) and a button to generate the report. The generated report will be displayed on the page for easy copying.
|
||||||
|
- Auto-fill the date range based on the current date, so that it defaults to the past week (e.g. if today is Feb 15, it will default to Feb 8-14).
|
||||||
|
- The form will also have a button to fetch the latest episode data from Jellyfin and Sonarr, so that the user can easily get the latest episode numbers without having to manually check the APIs
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Media Episodes Viewer — One Piece Progress Reporter
|
||||||
|
|
||||||
|
A minimal single-container web app that generates a weekly One Piece progress report by querying Jellyfin and Sonarr.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and fill in your values:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `JELLYFIN_HOST` | Base URL of your Jellyfin server (e.g. `http://192.168.1.10:8096`) |
|
||||||
|
| `JELLYFIN_API_KEY` | Jellyfin API key (Dashboard → Advanced → API Keys) |
|
||||||
|
| `JELLYFIN_USER_ID` | Your Jellyfin user ID (visible in Dashboard → Users → click your user) |
|
||||||
|
| `SONARR_HOST` | Base URL of your Sonarr server (e.g. `http://192.168.1.10:8989`) |
|
||||||
|
| `SONARR_API_KEY` | Sonarr API key (Settings → General → Security) |
|
||||||
|
| `SONARR_SERIES_ID` | Sonarr internal series ID for One Piece (from the URL when viewing the series) |
|
||||||
|
|
||||||
|
### 2. Build and run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:5000](http://localhost:5000) in your browser.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. The date range defaults to the previous week (Mon–Sat).
|
||||||
|
2. Click **Fetch Episodes** to retrieve episode numbers from Jellyfin/Sonarr without generating the report yet.
|
||||||
|
3. Click **Generate Report** to fetch data and produce the formatted text.
|
||||||
|
4. Click **Copy to Clipboard** to copy the report.
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
```
|
||||||
|
ONE PIECE PROGRESS!!! (Feb 8-14)
|
||||||
|
Episode 928/1155
|
||||||
|
Around 10 episodes watched this week!
|
||||||
|
```
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from flask import Flask, render_template, jsonify, request
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from clients import jellyfin, sonarr
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/report")
|
||||||
|
def report():
|
||||||
|
data = fetch_episodes().get_json()
|
||||||
|
if "error" in data:
|
||||||
|
return jsonify(data), 502
|
||||||
|
|
||||||
|
start = date.fromisoformat(data["start"])
|
||||||
|
end = date.fromisoformat(data["end"])
|
||||||
|
date_range = f"{start.strftime('%b %-d')}-{end.strftime('%-d')}"
|
||||||
|
|
||||||
|
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!"
|
||||||
|
)
|
||||||
|
data["report"] = template
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
port = int(os.environ.get("PORT", 5000))
|
||||||
|
app.run(host="0.0.0.0", port=port, debug=False)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,68 @@
|
|||||||
|
"""Jellyfin API client."""
|
||||||
|
import os
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
"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}")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5001:5000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask==3.1.0
|
||||||
|
requests==2.32.3
|
||||||
|
python-dotenv==1.0.1
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>One Piece Progress Report</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #e8e8e8;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.6rem; margin-bottom: 1.5rem; color: #f97316; }
|
||||||
|
.card {
|
||||||
|
background: #1c1c1c;
|
||||||
|
border: 1px solid #2e2e2e;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 540px;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
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; }
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.6rem 1.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.detail-grid .label { color: #888; }
|
||||||
|
.detail-grid .value { color: #fff; font-weight: 600; }
|
||||||
|
#report-box {
|
||||||
|
width: 100%;
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #2e2e2e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
color: #f0f0f0;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
#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; }
|
||||||
|
.error { color: #f87171; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🏴☠️ One Piece Progress Reporter</h1>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<span class="label">Latest episode</span> <span class="value" id="d-latest">—</span>
|
||||||
|
<span class="label">Previous week</span> <span class="value" id="d-previous">—</span>
|
||||||
|
<span class="label">Watched this week</span><span class="value" id="d-watched">—</span>
|
||||||
|
<span class="label">Total in series</span> <span class="value" 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 class="card hidden" id="report-card">
|
||||||
|
<h2 style="font-size:1rem;margin-bottom:0.8rem;color:#aaa;">Report</h2>
|
||||||
|
<div id="report-box"></div>
|
||||||
|
<button id="copy-btn">📋 Copy to Clipboard</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
function setStatus(msg, isError = false) {
|
||||||
|
const el = $('status');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.toggle('error', isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(on) {
|
||||||
|
$('btn-fetch').disabled = on;
|
||||||
|
$('btn-report').disabled = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetails(data) {
|
||||||
|
$('d-latest').textContent = data.latest_episode;
|
||||||
|
$('d-previous').textContent = data.previous_episode;
|
||||||
|
$('d-watched').textContent = data.watched_this_week;
|
||||||
|
$('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-card').classList.remove('hidden');
|
||||||
|
setStatus('');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message, true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('btn-fetch').addEventListener('click', doFetch);
|
||||||
|
$('btn-report').addEventListener('click', doReport);
|
||||||
|
$('copy-btn').addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText($('report-box').textContent)
|
||||||
|
.then(() => setStatus('Copied!'))
|
||||||
|
.catch(() => setStatus('Copy failed', true));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user