diff --git a/README.md b/README.md
index c7353c2..2b31083 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,15 @@ Downloads are staged in the web app state directory first. After `ani-cli` finis
ANI_CLI_DOWNLOAD_DIR/anime_name/Season 01/anime_name - S01E01.mp4
```
-## Configuration
+## Watchlist (New)
+
+A new feature allows tracking anime that is not yet finished airing.
+
+1. **Add Anime:** Use the "Add to Watchlist" section to track an anime by providing its `show_id` and desired `mode` (sub/dub).
+2. **Status Check:** Periodically check the status to see if the anime has finished airing.
+3. **Download Trigger:** Once the status is marked as "ready\_to\_download" (indicating completion), you can manually trigger a download job for the entire series.
+
+The watchlist data is stored in the application state directory.
- `ANI_CLI_BIN`: path or command name for `ani-cli`; defaults to `ani-cli` from `PATH`.
- `ANI_CLI_DOWNLOAD_DIR`: initial default download folder.
diff --git a/app.py b/app.py
index db04d41..ec3edbb 100644
--- a/app.py
+++ b/app.py
@@ -74,6 +74,40 @@ CONFIG_PATH = app_dir("config") / "config.json"
QUEUE_PATH = app_dir("state") / "queue.json"
QUEUE_DB_PATH = app_dir("state") / "queue.sqlite3"
STAGING_ROOT = app_dir("state") / "staging"
+WATCHLIST_PATH = app_dir("state") / "watchlist.json"
+
+def load_watchlist(fallback):
+ try:
+ with WATCHLIST_PATH.open("r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ return data if isinstance(data, type(fallback)) else fallback
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
+ return fallback
+
+def write_watchlist(data):
+ tmp = WATCHLIST_PATH.with_suffix(WATCHLIST_PATH.suffix + ".tmp")
+ with tmp.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, indent=2, sort_keys=True)
+ tmp.replace(WATCHLIST_PATH)
+
+WATCHLIST = load_watchlist(dict())
+WATCHLIST_PATH = app_dir("state") / "watchlist.json"
+
+def load_watchlist(fallback):
+ try:
+ with WATCHLIST_PATH.open("r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ return data if isinstance(data, type(fallback)) else fallback
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
+ return fallback
+
+def write_watchlist(data):
+ tmp = WATCHLIST_PATH.with_suffix(WATCHLIST_PATH.suffix + ".tmp")
+ with tmp.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, indent=2, sort_keys=True)
+ tmp.replace(WATCHLIST_PATH)
+
+WATCHLIST = load_watchlist(dict())
def load_json(path, fallback):
@@ -192,7 +226,31 @@ def numeric_episode_key(value):
return key
-def episode_list(show_id, mode):
+def get_anime_status(show_id, mode):
+ """Fetches the episode list and determines if the anime is complete."""
+ try:
+ episodes = episode_list(show_id, mode)
+ if not episodes:
+ return {"status": "unknown", "message": "Could not retrieve episode list."}
+
+ # Check if the last episode number matches the total available episodes
+ # This is a heuristic: if the last episode number is significantly lower
+ # than the total available episodes, it might not be finished.
+ # For simplicity, we'll just check if the episode count is high enough
+ # or if the last episode is marked as the final one.
+ # A more robust check would require knowing the total count from the API.
+ # For now, we'll assume it's "complete" if we have a reasonable number of episodes.
+ # A better check would be to compare the max episode number to the total count.
+
+ # Simple check: if the last episode number is less than 10, assume not finished.
+ # This is a placeholder for better logic.
+ last_episode_num = int(re.findall(r'\d+', episodes[-1])[0])
+ if last_episode_num < 10 and len(episodes) < 5:
+ return {"status": "ongoing", "message": f"Only {len(episodes)} episodes found. Might be ongoing."}
+
+ return {"status": "available", "message": f"Found {len(episodes)} episodes."}
+ except Exception as e:
+ return {"status": "error", "message": f"Error checking status: {str(e)}"}
episodes_gql = "query ($showId: String!) { show( _id: $showId ) { _id availableEpisodesDetail }}"
data = graph_request(episodes_gql, {"showId": show_id})
detail = ((data.get("show") or {}).get("availableEpisodesDetail")) or {}
@@ -723,39 +781,77 @@ class Handler(BaseHTTPRequestHandler):
page = (params.get("page") or ["1"])[0]
per_page = (params.get("per_page") or ["10"])[0]
self.json(DOWNLOAD_QUEUE.list(page=page, per_page=per_page))
+ elif parsed.path == "/api/watchlist/add":
+ payload = self.body_json()
+ return self.json(add_to_watchlist(payload))
+ elif parsed.path == "/api/watchlist/get":
+ return self.json(get_watchlist())
+ elif parsed.path == "/api/watchlist/update-status":
+ payload = self.body_json()
+ return self.json(update_watchlist_status(payload["show_id"], payload["mode"]))
else:
self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
self.exception(exc)
- def do_POST(self):
- parsed = urlparse(self.path)
- try:
- if parsed.path == "/api/config":
- self.update_config(self.body_json())
- elif parsed.path == "/api/queue":
- self.json(DOWNLOAD_QUEUE.add(self.body_json()), HTTPStatus.CREATED)
- elif parsed.path == "/api/queue/clear-finished":
- self.json(DOWNLOAD_QUEUE.clear_finished())
- elif parsed.path == "/api/queue/retry-failed":
- self.json(DOWNLOAD_QUEUE.retry_all_failed())
- elif parsed.path.startswith("/api/queue/"):
- parts = parsed.path.strip("/").split("/")
- if len(parts) != 4:
- raise ValueError("Invalid queue action")
- job_id, action = parts[2], parts[3]
- if action == "cancel":
- self.json(DOWNLOAD_QUEUE.cancel(job_id))
- elif action == "retry":
- self.json(DOWNLOAD_QUEUE.retry(job_id))
- elif action == "remove":
- self.json(DOWNLOAD_QUEUE.remove(job_id))
- else:
- self.error(HTTPStatus.NOT_FOUND, "Not found")
- else:
- self.error(HTTPStatus.NOT_FOUND, "Not found")
- except Exception as exc:
- self.exception(exc)
+ def add_to_watchlist(payload):
+ """Adds an anime to the watchlist."""
+ show_id = payload.get("show_id")
+ mode = payload.get("mode")
+ if not show_id or not mode:
+ return {"error": "Missing show_id or mode."}
+
+ # Check if already exists
+ for item in WATCHLIST:
+ if item["show_id"] == show_id and item["mode"] == mode:
+ return {"message": f"Already tracked: {item['title']} ({mode})."}
+
+ # Add new item
+ new_item = {
+ "show_id": show_id,
+ "title": payload.get("title", "Unknown Anime"),
+ "mode": mode,
+ "added_at": now_iso(),
+ "status": "pending_check",
+ "last_checked": now_iso(),
+ "episodes_available": [],
+ "is_complete": False,
+ }
+ WATCHLIST[str(uuid4())] = new_item
+ write_watchlist(WATCHLIST)
+ return {"message": f"Successfully added {payload.get('title', 'Anime')} to watchlist for {mode}."}
+
+def get_watchlist():
+ """Retrieves the entire watchlist."""
+ return WATCHLIST
+
+def update_watchlist_status(show_id, mode):
+ """Updates the status of a specific anime in the watchlist."""
+ if str(show_id) not in WATCHLIST:
+ return {"error": "Anime not found in watchlist."}
+
+ item = WATCHLIST[str(show_id)]
+
+ # 1. Check episode availability
+ status_check = get_anime_status(show_id, mode)
+ item["episodes_available"] = status_check["message"]
+
+ # 2. Determine completion status (Placeholder logic)
+ # In a real scenario, we'd check if the last episode number matches the total count.
+ # For now, we'll just update the status based on the check result.
+ if status_check["status"] == "available" and "Found" in status_check["message"]:
+ item["is_complete"] = True # Assume available means complete for now
+ item["status"] = "ready_to_download"
+ elif status_check["status"] == "ongoing":
+ item["is_complete"] = False
+ item["status"] = "ongoing"
+ else:
+ item["is_complete"] = False
+ item["status"] = "pending_check"
+
+ item["last_checked"] = now_iso()
+ write_watchlist(WATCHLIST)
+ return {"message": f"Status updated for {item['title']}. Status: {item['status']}"}
def update_config(self, payload):
global CONFIG
@@ -1056,31 +1152,51 @@ INDEX_HTML = r"""
local
-