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 - + + -
+ +
+

Anime Watchlist

+
+ +
+
+ + + + + + + + +
+
+ +
+
@@ -1465,30 +1581,91 @@ INDEX_HTML = r""" """ +# --- Watchlist Section --- + const watchlistContainer = document.getElementById('watchlist-container'); + if (watchlistContainer) { + fetchWatchlist(); + } -def install_signal_handlers(httpd): - def shutdown(_signum, _frame): - if DOWNLOAD_QUEUE.current_process: - try: - os.killpg(os.getpgid(DOWNLOAD_QUEUE.current_process.pid), signal.SIGTERM) - except OSError: - pass - threading.Thread(target=httpd.shutdown, daemon=True).start() +function fetchWatchlist() { + fetch('/api/watchlist/get') + .then(response => response.json()) + .then(data => { + const watchlistContainer = document.getElementById('watchlist-container'); + if (!watchlistContainer) return; + watchlistContainer.innerHTML = ''; - signal.signal(signal.SIGTERM, shutdown) - signal.signal(signal.SIGINT, shutdown) + if (Object.keys(data).length === 0) { + watchlistContainer.innerHTML = '

Your watchlist is empty. Add an anime using the form above.

'; + return; + } + Object.values(data).forEach(item => { + const card = document.createElement('div'); + card.className = 'result watchlist-item'; + card.innerHTML = ` +
${item.title}
+
+ ID: ${item.show_id} | Mode: ${item.mode.toUpperCase()} | Status: ${item.status.replace('_', ' ')} +
+
+ + +
+ `; + watchlistContainer.appendChild(card); + }); + }) + .catch(error => { + console.error('Error fetching watchlist:', error); + document.getElementById('watchlist-container').innerHTML = `

Error loading watchlist: ${error.message}

`; + }); +} -def main(): - host = os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1") - port = int(os.environ.get("ANI_CLI_WEB_PORT", "8421")) - httpd = ThreadingHTTPServer((host, port), Handler) - install_signal_handlers(httpd) - print(f"ani-cli web is running at http://{host}:{port}/") - print(f"Config: {CONFIG_PATH}") - print(f"Queue: {QUEUE_DB_PATH}") - httpd.serve_forever() +function addAnimeToWatchlist() { + const showId = document.getElementById('watchlist-show-id').value.trim(); + const mode = document.getElementById('watchlist-mode').value; + const title = document.getElementById('watchlist-title').value.trim(); + if (!showId || !mode || !title) { + alert('Please fill all fields.'); + return; + } -if __name__ == "__main__": - main() + fetch('/api/watchlist/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ show_id: showId, mode: mode, title: title }) + }) + .then(response => response.json()) + .then(data => { + alert(data.message); + fetchWatchlist(); // Refresh list + }) + .catch(error => { + console.error('Error adding to watchlist:', error); + alert('Failed to add to watchlist. Check console for details.'); + }); +} + +function checkWatchlistStatus(showId, mode) { + fetch('/api/watchlist/update-status', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ show_id: showId, mode: mode }) + }) + .then(response => response.json()) + .then(data => { + alert(data.message); + fetchWatchlist(); // Refresh list + }) + .catch(error => { + console.error('Error checking status:', error); + alert('Failed to check status.'); + }); +} + +function viewWatchlistItem(showId, mode) { + // Placeholder for detailed view logic + alert(`Viewing details for ${showId} (${mode}). This would trigger a detailed view panel.`); +}