feat: Implement Watchlist feature for tracking incomplete anime series
This commit is contained in:
@@ -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.`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user