feat: Implement Watchlist feature for tracking incomplete anime series

This commit is contained in:
Dymas
2026-05-11 15:22:20 +02:00
parent 5f0c8c81b1
commit b8f087a7b0
2 changed files with 260 additions and 75 deletions
+251 -74
View File
@@ -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"""<!doctype html>
<span class="badge" id="serverBadge">local</span>
</div>
<section class="search-box">
<div class="segmented" role="group" aria-label="Translation">
<button id="subBtn" type="button" data-mode="sub">Original</button>
<button id="dubBtn" type="button" data-mode="dub">Dubbed</button>
</div>
<label>Anime search
<input id="query" type="search" placeholder="Search anime">
</label>
<div class="controls">
<label>Quality
<select id="quality">
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
<option value="360">360p</option>
<option value="worst">Worst</option>
</select>
</label>
<button class="primary" id="searchBtn" type="button">Search</button>
</div>
<p class="notice" id="notice"></p>
</section>
<!-- Search and Search Options -->
<div class="search-box">
<label for="search-query">Anime Title / Keyword</label>
<input type="text" id="search-query" placeholder="e.g., Attack on Titan">
<label for="search-mode">Mode</label>
<select id="search-mode">
<option value="sub">Subtitled (Sub)</option>
<option value="dub">Dubbed (Dub)</option>
</select>
<label for="search-quality">Quality</label>
<select id="search-quality">
<option value="best">Best</option>
<option value="1080p">1080p</option>
<option value="720p">720p</option>
<option value="480p">480p</option>
</select>
<button class="primary" onclick="searchAnime()">Search</button>
</div>
<section class="results" id="results"></section>
<!-- Watchlist Section -->
<div class="settings">
<h3>Anime Watchlist</h3>
<div class="topline">
<button class="primary" onclick="document.getElementById('watchlist-form').dispatchEvent(new Event('submit'))">Add to Watchlist</button>
</div>
<form id="watchlist-form" onsubmit="event.preventDefault(); addAnimeToWatchlist();">
<label for="watchlist-title">Anime Title</label>
<input type="text" id="watchlist-title" placeholder="Anime Title" required>
<label for="watchlist-show-id">Show ID (from search)</label>
<input type="text" id="watchlist-show-id" placeholder="Show ID" required>
<label for="watchlist-mode">Mode</label>
<select id="watchlist-mode">
<option value="sub">Subtitled (Sub)</option>
<option value="dub">Dubbed (Dub)</option>
</select>
</form>
<div id="watchlist-container" class="results">
<!-- Watchlist items will be loaded here -->
</div>
</div>
</aside>
<main>
@@ -1465,30 +1581,91 @@ INDEX_HTML = r"""<!doctype html>
</html>
"""
# --- 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 = '<p class="muted">Your watchlist is empty. Add an anime using the form above.</p>';
return;
}
Object.values(data).forEach(item => {
const card = document.createElement('div');
card.className = 'result watchlist-item';
card.innerHTML = `
<div class="result-title">${item.title}</div>
<div class="muted">
ID: ${item.show_id} | Mode: ${item.mode.toUpperCase()} | Status: <span class="badge ${item.status.includes('ready') ? 'ok' : item.status.includes('error') ? 'bad' : ''}">${item.status.replace('_', ' ')}</span>
</div>
<div class="topline">
<button class="primary" onclick="checkWatchlistStatus('${item.show_id}', '${item.mode}')">Check Status</button>
<button class="ghost" onclick="viewWatchlistItem('${item.show_id}', '${item.mode}')">View Details</button>
</div>
`;
watchlistContainer.appendChild(card);
});
})
.catch(error => {
console.error('Error fetching watchlist:', error);
document.getElementById('watchlist-container').innerHTML = `<p class="badge bad">Error loading watchlist: ${error.message}</p>`;
});
}
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.`);
}