Add watchlist auto-download settings

This commit is contained in:
Dymas
2026-05-25 09:50:20 +02:00
parent 46af0ff47d
commit 3d42657837
10 changed files with 555 additions and 45 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.39.0 - 2026-05-25
- Added watchlist auto-download support with global Config controls plus per-anime mode, quality, and library-name settings hidden behind a card-level editor.
- Limited auto-download to `Watching` entries after later manual or scheduled refreshes, and only queue episodes that are newly discovered and not already pending or downloaded.
## 0.38.0 - 2026-05-25 ## 0.38.0 - 2026-05-25
- Switched watchlist `Alternative titles` from AniList lookups to AniDB title-dump matching and now keep only English alternate titles. - Switched watchlist `Alternative titles` from AniList lookups to AniDB title-dump matching and now keep only English alternate titles.
+10 -3
View File
@@ -2,7 +2,7 @@
Small local web UI for a system-wide `ani-cli` install. Small local web UI for a system-wide `ani-cli` install.
Current version: `0.38.0` Current version: `0.39.0`
## Project Layout ## Project Layout
@@ -123,6 +123,7 @@ Container notes:
- Track shows on `/watchlist`. - Track shows on `/watchlist`.
- Refresh watchlist episode counts in the background. - Refresh watchlist episode counts in the background.
- Schedule periodic watchlist refresh jobs. - Schedule periodic watchlist refresh jobs.
- Auto-download newly discovered episodes for `Watching` entries after later refreshes.
- Send Discord webhook notifications for refresh discoveries and failures. - Send Discord webhook notifications for refresh discoveries and failures.
- Send Discord webhook notifications for completed downloads. - Send Discord webhook notifications for completed downloads.
- Cache watchlist thumbnails locally. - Cache watchlist thumbnails locally.
@@ -181,8 +182,10 @@ Use it to set:
4. Scheduled watchlist refresh enable or disable. 4. Scheduled watchlist refresh enable or disable.
5. Scheduled refresh period in minutes. 5. Scheduled refresh period in minutes.
6. Delay between shows during `Refresh All`. 6. Delay between shows during `Refresh All`.
7. Discord webhook URL. 7. Global auto-download enable or disable.
8. Discord notification events plus a test action. 8. Default auto-download language and quality.
9. Discord webhook URL.
10. Discord notification events plus a test action.
Saved settings are written to `.ani-cli-web/config.json`. Saved settings are written to `.ani-cli-web/config.json`.
@@ -221,6 +224,7 @@ Main behavior:
7. Summary cards show total tracked shows plus `sub` and `dub` counts. 7. Summary cards show total tracked shows plus `sub` and `dub` counts.
8. Pagination shows 30 cards per page. 8. Pagination shows 30 cards per page.
9. `Alternative titles` expands English alternate titles from AniDB when the watchlist refresh can match the show to an AniDB entry. 9. `Alternative titles` expands English alternate titles from AniDB when the watchlist refresh can match the show to an AniDB entry.
10. `Auto-download` opens per-series settings for language, quality, and the sanitized library name used for automatically queued episodes.
Status behavior: Status behavior:
@@ -249,6 +253,9 @@ Refresh behavior:
- Per-show refreshes are queued in the background. - Per-show refreshes are queued in the background.
- Queued refresh state survives restarts. - Queued refresh state survives restarts.
- Duplicate refresh queueing is avoided. - Duplicate refresh queueing is avoided.
- Auto-download does not trigger when a show is first added to the watchlist.
- Auto-download only runs for entries in `Watching` after later manual refreshes or scheduled refreshes.
- Auto-download only queues newly found episodes that are not already pending or previously downloaded through the app.
- Scheduled refresh runs log start, stop, success, and failure to stdout. - Scheduled refresh runs log start, stop, success, and failure to stdout.
- `Refresh All` works one show at a time with a configurable delay. - `Refresh All` works one show at a time with a configurable delay.
- When a Discord webhook is configured, new-episode refresh results include one notification card per anime with sub and dub counts and a cached thumbnail attachment when available. - When a Discord webhook is configured, new-episode refresh results include one notification card per anime with sub and dub counts and a cached thumbnail attachment when available.
+1 -1
View File
@@ -1 +1 @@
0.38.0 0.39.0
+187 -20
View File
@@ -36,6 +36,7 @@ from app_support import (
DEFAULT_CONFIG, DEFAULT_CONFIG,
MAX_JSON_BODY_BYTES, MAX_JSON_BODY_BYTES,
MODE_CHOICES, MODE_CHOICES,
QUALITY_CHOICES,
send_discord_webhook_event, send_discord_webhook_event,
STATE_DB_PATH, STATE_DB_PATH,
THUMBNAIL_RETRY_SECONDS, THUMBNAIL_RETRY_SECONDS,
@@ -912,9 +913,35 @@ def watchlist_status_message(sub_count, dub_count, expected_count=0, airing_stat
return ". ".join(parts) + "." return ". ".join(parts) + "."
def episode_specs_for_values(values, available_episodes):
ordered = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
targets = {str(value).strip() for value in values or [] if str(value).strip()}
if not ordered or not targets:
return []
indices = [index for index, value in enumerate(ordered) if value in targets]
if not indices:
return []
ranges = []
start = indices[0]
end = indices[0]
for index in indices[1:]:
if index == end + 1:
end = index
continue
ranges.append((start, end))
start = end = index
ranges.append((start, end))
specs = []
for start, end in ranges:
specs.append(ordered[start] if start == end else f"{ordered[start]}-{ordered[end]}")
return specs
class WatchlistStore: class WatchlistStore:
def __init__(self, start_worker=True): def __init__(self, config_getter=None, start_worker=True):
self.lock = threading.RLock() self.lock = threading.RLock()
self.config_getter = config_getter or (lambda: dict(DEFAULT_CONFIG))
self.auto_download_fn = None
self.thumbnail_events = {} self.thumbnail_events = {}
self.refresh_pending = [] self.refresh_pending = []
self.refresh_pending_set = set() self.refresh_pending_set = set()
@@ -953,13 +980,13 @@ class WatchlistStore:
def _next_pending_refresh(self): def _next_pending_refresh(self):
with self.lock: with self.lock:
if not self.refresh_pending: if not self.refresh_pending:
return None return None, None
show_id = self.refresh_pending.pop(0) show_id, source = self.refresh_pending.pop(0)
self.refresh_pending_set.discard(show_id) self.refresh_pending_set.discard(show_id)
self.refresh_inflight_set.add(show_id) self.refresh_inflight_set.add(show_id)
return show_id return show_id, source
def _enqueue_refresh_locked(self, show_id): def _enqueue_refresh_locked(self, show_id, source="manual"):
normalized_show_id = str(show_id or "").strip() normalized_show_id = str(show_id or "").strip()
if ( if (
not normalized_show_id not normalized_show_id
@@ -967,7 +994,7 @@ class WatchlistStore:
or normalized_show_id in self.refresh_inflight_set or normalized_show_id in self.refresh_inflight_set
): ):
return False return False
self.refresh_pending.append(normalized_show_id) self.refresh_pending.append((normalized_show_id, str(source or "manual").strip().lower() or "manual"))
self.refresh_pending_set.add(normalized_show_id) self.refresh_pending_set.add(normalized_show_id)
return True return True
@@ -977,7 +1004,9 @@ class WatchlistStore:
return False return False
removed = False removed = False
if normalized_show_id in self.refresh_pending_set: if normalized_show_id in self.refresh_pending_set:
self.refresh_pending = [value for value in self.refresh_pending if value != normalized_show_id] self.refresh_pending = [
value for value in self.refresh_pending if str((value[0] if isinstance(value, tuple) else value) or "").strip() != normalized_show_id
]
self.refresh_pending_set.discard(normalized_show_id) self.refresh_pending_set.discard(normalized_show_id)
removed = True removed = True
if normalized_show_id in self.refresh_inflight_set: if normalized_show_id in self.refresh_inflight_set:
@@ -996,35 +1025,43 @@ class WatchlistStore:
""" """
).fetchall() ).fetchall()
for row in rows: for row in rows:
self._enqueue_refresh_locked(str(row["show_id"]).strip()) self._enqueue_refresh_locked(str(row["show_id"]).strip(), source="manual")
if self.refresh_pending: if self.refresh_pending:
self.refresh_wakeup.set() self.refresh_wakeup.set()
def _refresh_loop(self): def _refresh_loop(self):
while not self.refresh_stop_event.is_set(): while not self.refresh_stop_event.is_set():
show_id = self._next_pending_refresh() show_id, refresh_source = self._next_pending_refresh()
if not show_id: if not show_id:
self.refresh_wakeup.wait(2) self.refresh_wakeup.wait(2)
self.refresh_wakeup.clear() self.refresh_wakeup.clear()
continue continue
try: try:
self.refresh(show_id) self.refresh(show_id, refresh_source=refresh_source or "manual")
except Exception as exc: except Exception as exc:
debug_log("watchlist.refresh_worker.error", show_id=show_id, error=exc) debug_log("watchlist.refresh_worker.error", show_id=show_id, error=exc)
finally: finally:
with self.lock: with self.lock:
self.refresh_inflight_set.discard(show_id) self.refresh_inflight_set.discard(show_id)
def schedule_refresh(self, show_id): def schedule_refresh(self, show_id, source="manual"):
normalized_show_id = str(show_id or "").strip() normalized_show_id = str(show_id or "").strip()
if not normalized_show_id: if not normalized_show_id:
raise ValueError("Missing show_id.") raise ValueError("Missing show_id.")
self.ensure_worker() self.ensure_worker()
with self.lock: with self.lock:
self._enqueue_refresh_locked(normalized_show_id) self._enqueue_refresh_locked(normalized_show_id, source=source)
self.refresh_wakeup.set() self.refresh_wakeup.set()
return self.get(normalized_show_id) return self.get(normalized_show_id)
def _auto_download_defaults(self, title):
config = self.config_getter() or {}
return {
"auto_download_mode": str(config.get("auto_download_mode") or "dub").strip().lower() or "dub",
"auto_download_quality": str(config.get("auto_download_quality") or "best").strip().lower() or "best",
"auto_download_name": sanitize_path_component(title, "Anime"),
}
def _connect(self): def _connect(self):
conn = sqlite3.connect(STATE_DB_PATH, timeout=30) conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@@ -1096,6 +1133,9 @@ class WatchlistStore:
alternative_titles_json TEXT, alternative_titles_json TEXT,
anidb_aid TEXT, anidb_aid TEXT,
anidb_title TEXT, anidb_title TEXT,
auto_download_mode TEXT,
auto_download_quality TEXT,
auto_download_name TEXT,
thumbnail_path TEXT, thumbnail_path TEXT,
thumbnail_checked_at TEXT thumbnail_checked_at TEXT
) )
@@ -1130,6 +1170,12 @@ class WatchlistStore:
conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_site_url TEXT") conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_site_url TEXT")
if "alternative_titles_json" not in columns: if "alternative_titles_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN alternative_titles_json TEXT") conn.execute("ALTER TABLE watchlist ADD COLUMN alternative_titles_json TEXT")
if "auto_download_mode" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_mode TEXT")
if "auto_download_quality" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_quality TEXT")
if "auto_download_name" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_name TEXT")
conn.execute( conn.execute(
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''" "UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
) )
@@ -1139,6 +1185,7 @@ class WatchlistStore:
def _row_to_item(self, row): def _row_to_item(self, row):
item = dict(row) item = dict(row)
defaults = self._auto_download_defaults(item.get("title"))
item["category"] = normalize_watchlist_category(item.get("category")) item["category"] = normalize_watchlist_category(item.get("category"))
item["category_label"] = watchlist_category_label(item["category"]) item["category_label"] = watchlist_category_label(item["category"])
item["downloaded"] = bool(int(item.get("downloaded") or 0)) item["downloaded"] = bool(int(item.get("downloaded") or 0))
@@ -1160,6 +1207,11 @@ class WatchlistStore:
item["thumbnail_url"] = cover_route(item.get("show_id")) item["thumbnail_url"] = cover_route(item.get("show_id"))
item["alternative_titles"] = decode_alternative_titles(item.get("alternative_titles_json")) item["alternative_titles"] = decode_alternative_titles(item.get("alternative_titles_json"))
item["has_alternative_titles"] = bool(item["alternative_titles"]) item["has_alternative_titles"] = bool(item["alternative_titles"])
mode = str(item.get("auto_download_mode") or defaults["auto_download_mode"]).strip().lower()
quality = str(item.get("auto_download_quality") or defaults["auto_download_quality"]).strip().lower()
item["auto_download_mode"] = mode if mode in MODE_CHOICES else defaults["auto_download_mode"]
item["auto_download_quality"] = quality if quality in QUALITY_CHOICES else defaults["auto_download_quality"]
item["auto_download_name"] = sanitize_path_component(item.get("auto_download_name") or defaults["auto_download_name"], defaults["auto_download_name"])
if item.get("animeschedule_route"): if item.get("animeschedule_route"):
title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title" title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title"
item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}" item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}"
@@ -1182,8 +1234,9 @@ class WatchlistStore:
animeschedule_route, animeschedule_title, animeschedule_route, animeschedule_title,
anilist_id, anilist_title, anilist_site_url, alternative_titles_json, anilist_id, anilist_title, anilist_site_url, alternative_titles_json,
anidb_aid, anidb_title, anidb_aid, anidb_title,
auto_download_mode, auto_download_quality, auto_download_name,
thumbnail_path, thumbnail_checked_at thumbnail_path, thumbnail_checked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(show_id) DO UPDATE SET ON CONFLICT(show_id) DO UPDATE SET
title = excluded.title, title = excluded.title,
category = excluded.category, category = excluded.category,
@@ -1206,6 +1259,9 @@ class WatchlistStore:
alternative_titles_json = excluded.alternative_titles_json, alternative_titles_json = excluded.alternative_titles_json,
anidb_aid = excluded.anidb_aid, anidb_aid = excluded.anidb_aid,
anidb_title = excluded.anidb_title, anidb_title = excluded.anidb_title,
auto_download_mode = excluded.auto_download_mode,
auto_download_quality = excluded.auto_download_quality,
auto_download_name = excluded.auto_download_name,
thumbnail_path = excluded.thumbnail_path, thumbnail_path = excluded.thumbnail_path,
thumbnail_checked_at = excluded.thumbnail_checked_at thumbnail_checked_at = excluded.thumbnail_checked_at
""", """,
@@ -1233,6 +1289,9 @@ class WatchlistStore:
item.get("alternative_titles_json"), item.get("alternative_titles_json"),
item.get("anidb_aid"), item.get("anidb_aid"),
item.get("anidb_title"), item.get("anidb_title"),
item.get("auto_download_mode"),
item.get("auto_download_quality"),
item.get("auto_download_name"),
item.get("thumbnail_path"), item.get("thumbnail_path"),
item.get("thumbnail_checked_at"), item.get("thumbnail_checked_at"),
), ),
@@ -1282,6 +1341,9 @@ class WatchlistStore:
"alternative_titles_json": None, "alternative_titles_json": None,
"anidb_aid": None, "anidb_aid": None,
"anidb_title": None, "anidb_title": None,
"auto_download_mode": None,
"auto_download_quality": None,
"auto_download_name": None,
"thumbnail_path": None, "thumbnail_path": None,
"thumbnail_checked_at": None, "thumbnail_checked_at": None,
}, },
@@ -1388,6 +1450,7 @@ class WatchlistStore:
now = now_iso() now = now_iso()
item = { item = {
**self._auto_download_defaults(title),
"show_id": show_id, "show_id": show_id,
"title": title, "title": title,
"category": normalize_watchlist_category(payload.get("category")), "category": normalize_watchlist_category(payload.get("category")),
@@ -1416,14 +1479,15 @@ class WatchlistStore:
} }
self._upsert_conn(conn, item) self._upsert_conn(conn, item)
queued = self.schedule_refresh(show_id) queued = self.schedule_refresh(show_id, source="initial")
return {"message": f"Added {queued['title']} to the watchlist. Refresh queued.", "item": queued, "created": True} return {"message": f"Added {queued['title']} to the watchlist. Refresh queued.", "item": queued, "created": True}
def refresh(self, show_id, include_thumbnail=True): def refresh(self, show_id, include_thumbnail=True, refresh_source="manual"):
existing = self.get(show_id) existing = self.get(show_id)
normalized_show_id = existing["show_id"] normalized_show_id = existing["show_id"]
previous_sub_count = int(existing.get("sub_count") or 0) previous_sub_count = int(existing.get("sub_count") or 0)
previous_dub_count = int(existing.get("dub_count") or 0) previous_dub_count = int(existing.get("dub_count") or 0)
previous_last_checked = existing.get("last_checked")
now = now_iso() now = now_iso()
request_timeout = self._refresh_request_timeout() request_timeout = self._refresh_request_timeout()
try: try:
@@ -1561,9 +1625,17 @@ class WatchlistStore:
item = self.get(normalized_show_id) item = self.get(normalized_show_id)
item["previous_sub_count"] = previous_sub_count item["previous_sub_count"] = previous_sub_count
item["previous_dub_count"] = previous_dub_count item["previous_dub_count"] = previous_dub_count
item["previous_last_checked"] = previous_last_checked
item["sub_delta"] = max(0, int(item.get("sub_count") or 0) - previous_sub_count) item["sub_delta"] = max(0, int(item.get("sub_count") or 0) - previous_sub_count)
item["dub_delta"] = max(0, int(item.get("dub_count") or 0) - previous_dub_count) item["dub_delta"] = max(0, int(item.get("dub_count") or 0) - previous_dub_count)
item["had_new_episodes"] = bool(item["sub_delta"] or item["dub_delta"]) item["had_new_episodes"] = bool(item["sub_delta"] or item["dub_delta"])
item["sub_episode_values"] = sub_episodes if 'sub_episodes' in locals() else []
item["dub_episode_values"] = dub_episodes if 'dub_episodes' in locals() else []
if self.auto_download_fn is not None:
try:
item["auto_download_result"] = self.auto_download_fn(item, refresh_source=refresh_source)
except Exception as exc:
debug_log("watchlist.auto_download.error", show_id=normalized_show_id, source=refresh_source, error=exc)
return item return item
def refresh_all(self): def refresh_all(self):
@@ -1584,7 +1656,7 @@ class WatchlistStore:
) )
return self.get(show_id) return self.get(show_id)
def queue_refresh(self, show_id, status_message="Queued watchlist refresh."): def queue_refresh(self, show_id, status_message="Queued watchlist refresh.", source="manual"):
existing = self.get(show_id) existing = self.get(show_id)
now = now_iso() now = now_iso()
with self.lock, self._connect() as conn: with self.lock, self._connect() as conn:
@@ -1592,7 +1664,7 @@ class WatchlistStore:
"UPDATE watchlist SET status = 'queued', status_message = ?, updated_at = ? WHERE show_id = ?", "UPDATE watchlist SET status = 'queued', status_message = ?, updated_at = ? WHERE show_id = ?",
(str(status_message or "Queued watchlist refresh.").strip(), now, existing["show_id"]), (str(status_message or "Queued watchlist refresh.").strip(), now, existing["show_id"]),
) )
return self.schedule_refresh(existing["show_id"]) return self.schedule_refresh(existing["show_id"], source=source)
def mark_downloaded(self, show_id, title=""): def mark_downloaded(self, show_id, title=""):
normalized_show_id = str(show_id or "").strip() normalized_show_id = str(show_id or "").strip()
@@ -1645,6 +1717,7 @@ class WatchlistStore:
self._drop_refresh_locked(previous_show_id) self._drop_refresh_locked(previous_show_id)
else: else:
existing = { existing = {
**self._auto_download_defaults(normalized_title or "Unknown Anime"),
"show_id": normalized_show_id, "show_id": normalized_show_id,
"title": normalized_title or "Unknown Anime", "title": normalized_title or "Unknown Anime",
"category": "finished", "category": "finished",
@@ -1672,9 +1745,30 @@ class WatchlistStore:
"thumbnail_checked_at": None, "thumbnail_checked_at": None,
} }
self._upsert_conn(conn, existing) self._upsert_conn(conn, existing)
self.schedule_refresh(normalized_show_id) self.schedule_refresh(normalized_show_id, source="sync")
return self.get(normalized_show_id) return self.get(normalized_show_id)
def update_auto_download_settings(self, show_id, mode=None, quality=None, name=None):
existing = self.get(show_id)
defaults = self._auto_download_defaults(existing["title"])
normalized_mode = str(mode or defaults["auto_download_mode"]).strip().lower()
normalized_quality = str(quality or defaults["auto_download_quality"]).strip().lower()
if normalized_mode not in MODE_CHOICES:
raise ValueError("Auto-download mode must be sub or dub.")
if normalized_quality not in QUALITY_CHOICES:
raise ValueError("Unknown auto-download quality.")
normalized_name = sanitize_path_component(name or defaults["auto_download_name"], defaults["auto_download_name"])
with self.lock, self._connect() as conn:
conn.execute(
"""
UPDATE watchlist
SET auto_download_mode = ?, auto_download_quality = ?, auto_download_name = ?, updated_at = ?
WHERE show_id = ?
""",
(normalized_mode, normalized_quality, normalized_name, now_iso(), existing["show_id"]),
)
return self.get(show_id)
def remove(self, show_id): def remove(self, show_id):
existing = self.get(show_id) existing = self.get(show_id)
thumb_path = thumbnail_file_path(existing.get("thumbnail_path")) thumb_path = thumbnail_file_path(existing.get("thumbnail_path"))
@@ -1867,7 +1961,7 @@ def get_watchlist(page=1, per_page=30, category=None):
def update_watchlist_status(show_id, _mode=None): def update_watchlist_status(show_id, _mode=None):
ensure_runtime() ensure_runtime()
item = WATCHLIST.queue_refresh(show_id) item = WATCHLIST.queue_refresh(show_id, source="manual")
return {"message": f"Queued refresh for {item['title']}.", "item": item} return {"message": f"Queued refresh for {item['title']}.", "item": item}
@@ -1912,12 +2006,83 @@ def download_watchlist_item(show_id, mode):
} }
def queue_watchlist_auto_download(item, refresh_source="manual"):
source = str(refresh_source or "manual").strip().lower() or "manual"
if source not in {"manual", "scheduled"}:
return {"queued": False, "reason": f"source:{source}"}
runtime = ensure_runtime()
config = runtime["config"]
if not bool(config.get("auto_download_enabled")):
return {"queued": False, "reason": "config_disabled"}
if normalize_watchlist_category(item.get("category")) != "watching":
return {"queued": False, "reason": "category"}
if not item.get("previous_last_checked"):
return {"queued": False, "reason": "initial_refresh"}
if not item.get("had_new_episodes"):
return {"queued": False, "reason": "no_new_episodes"}
mode = str(item.get("auto_download_mode") or config.get("auto_download_mode") or "dub").strip().lower()
quality = str(item.get("auto_download_quality") or config.get("auto_download_quality") or "best").strip().lower()
if mode not in MODE_CHOICES:
mode = "dub"
if quality not in QUALITY_CHOICES:
quality = "best"
available_episodes = list(item.get(f"{mode}_episode_values") or [])
previous_count = int(item.get("previous_dub_count") or 0) if mode == "dub" else int(item.get("previous_sub_count") or 0)
new_episodes = available_episodes[previous_count:] if previous_count < len(available_episodes) else []
if not new_episodes:
return {"queued": False, "reason": "mode_has_no_new_episodes"}
covered = runtime["download_queue"].covered_episodes(item.get("show_id"), mode, available_episodes)
missing = [episode for episode in new_episodes if episode not in covered]
specs = episode_specs_for_values(missing, available_episodes)
if not specs:
return {"queued": False, "reason": "already_covered"}
query = str(item.get("title") or item.get("show_id") or "").strip()
if not query:
return {"queued": False, "reason": "missing_title"}
results = search_anime(query, mode)
match = next((candidate for candidate in results if str(candidate.get("id") or "").strip() == str(item.get("show_id") or "").strip()), None)
if match is None:
return {"queued": False, "reason": f"search_match_missing:{mode}"}
anime_name = sanitize_path_component(
item.get("auto_download_name") or item.get("title") or match.get("title") or "Anime",
sanitize_path_component(item.get("title") or match.get("title") or "Anime", "Anime"),
)
jobs = []
for episode_spec in specs:
job = runtime["download_queue"].add(
{
"show_id": item["show_id"],
"query": match.get("query") or query,
"title": match.get("title") or item["title"],
"anime_name": anime_name,
"season": "1",
"index": match.get("index", 1),
"mode": mode,
"quality": quality,
"episodes": episode_spec,
"download_dir": config["download_dir"],
}
)
jobs.append(job)
return {"queued": True, "mode": mode, "quality": quality, "episodes": specs, "jobs": jobs}
def update_watchlist_category(show_id, category): def update_watchlist_category(show_id, category):
ensure_runtime() ensure_runtime()
item = WATCHLIST.update_category(show_id, category) item = WATCHLIST.update_category(show_id, category)
return {"message": f"Moved {item['title']} to {item['category_label']}.", "item": item} return {"message": f"Moved {item['title']} to {item['category_label']}.", "item": item}
def update_watchlist_auto_download(show_id, mode=None, quality=None, name=None):
ensure_runtime()
item = WATCHLIST.update_auto_download_settings(show_id, mode=mode, quality=quality, name=name)
return {"message": f"Saved auto-download settings for {item['title']}.", "item": item}
def update_all_watchlist_statuses(): def update_all_watchlist_statuses():
ensure_runtime() ensure_runtime()
return WATCHLIST_REFRESH.start() return WATCHLIST_REFRESH.start()
@@ -2027,13 +2192,14 @@ def build_runtime(start_workers=None):
worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) and background_workers_enabled() worker_state = background_workers_enabled() if start_workers is None else bool(start_workers) and background_workers_enabled()
config = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG)) config = normalize_config(load_json(CONFIG_PATH, DEFAULT_CONFIG))
write_json(CONFIG_PATH, config) write_json(CONFIG_PATH, config)
watchlist = WatchlistStore(start_worker=worker_state) watchlist = WatchlistStore(config_getter=lambda: get_config_snapshot(config), start_worker=worker_state)
download_queue = DownloadQueue( download_queue = DownloadQueue(
config_getter=lambda: get_config_snapshot(config), config_getter=lambda: get_config_snapshot(config),
watchlist_sync_fn=sync_downloaded_job_to_watchlist, watchlist_sync_fn=sync_downloaded_job_to_watchlist,
job_prepare_fn=prepare_download_job, job_prepare_fn=prepare_download_job,
start_worker=worker_state, start_worker=worker_state,
) )
watchlist.auto_download_fn = queue_watchlist_auto_download
watchlist_refresh = WatchlistRefreshJobs( watchlist_refresh = WatchlistRefreshJobs(
watchlist, watchlist,
config_getter=lambda: get_config_snapshot(config), config_getter=lambda: get_config_snapshot(config),
@@ -2156,6 +2322,7 @@ Handler = build_handler_class(
test_discord_webhook_config=test_discord_webhook_config, test_discord_webhook_config=test_discord_webhook_config,
thumbnail_file_path=thumbnail_file_path, thumbnail_file_path=thumbnail_file_path,
update_all_watchlist_statuses=update_all_watchlist_statuses, update_all_watchlist_statuses=update_all_watchlist_statuses,
update_watchlist_auto_download=update_watchlist_auto_download,
update_watchlist_category=update_watchlist_category, update_watchlist_category=update_watchlist_category,
update_watchlist_status=update_watchlist_status, update_watchlist_status=update_watchlist_status,
upload_watchlist_thumbnail=upload_watchlist_thumbnail, upload_watchlist_thumbnail=upload_watchlist_thumbnail,
+16 -1
View File
@@ -19,7 +19,7 @@ from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web" APP_NAME = "ani-cli-web"
VERSION = "0.38.0" VERSION = "0.39.0"
ALLANIME_BASE = "allanime.day" ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to" ALLANIME_REFERER = "https://allmanga.to"
@@ -140,6 +140,9 @@ DEFAULT_CONFIG = {
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES, "watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
"watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS, "watchlist_refresh_delay_seconds": WATCHLIST_REFRESH_DELAY_DEFAULT_SECONDS,
"auto_download_enabled": False,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"discord_webhook_url": "", "discord_webhook_url": "",
"discord_webhook_events": [], "discord_webhook_events": [],
} }
@@ -285,6 +288,11 @@ def normalize_config(data):
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled") auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes") auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds") refresh_delay_seconds = config.get("watchlist_refresh_delay_seconds")
auto_download_enabled = config.get("auto_download_enabled")
auto_download_mode = str(config.get("auto_download_mode", "dub")).lower()
auto_download_quality = str(config.get("auto_download_quality", "best")).lower()
if auto_download_quality.endswith("p") and auto_download_quality[:-1].isdigit():
auto_download_quality = auto_download_quality[:-1]
webhook_url = str(config.get("discord_webhook_url") or "").strip() webhook_url = str(config.get("discord_webhook_url") or "").strip()
webhook_events = config.get("discord_webhook_events") webhook_events = config.get("discord_webhook_events")
@@ -292,6 +300,10 @@ def normalize_config(data):
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"} auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
else: else:
auto_refresh_enabled = bool(auto_refresh_enabled) auto_refresh_enabled = bool(auto_refresh_enabled)
if isinstance(auto_download_enabled, str):
auto_download_enabled = auto_download_enabled.strip().lower() in {"1", "true", "yes", "on"}
else:
auto_download_enabled = bool(auto_download_enabled)
try: try:
auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES) auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES)
@@ -316,6 +328,9 @@ def normalize_config(data):
config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled config["watchlist_auto_refresh_enabled"] = auto_refresh_enabled
config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes config["watchlist_auto_refresh_minutes"] = auto_refresh_minutes
config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds config["watchlist_refresh_delay_seconds"] = refresh_delay_seconds
config["auto_download_enabled"] = auto_download_enabled
config["auto_download_mode"] = auto_download_mode if auto_download_mode in MODE_CHOICES else "dub"
config["auto_download_quality"] = auto_download_quality if auto_download_quality in QUALITY_CHOICES else "best"
if isinstance(webhook_events, str): if isinstance(webhook_events, str):
webhook_events = [part.strip() for part in webhook_events.split(",")] webhook_events = [part.strip() for part in webhook_events.split(",")]
elif not isinstance(webhook_events, list): elif not isinstance(webhook_events, list):
+41
View File
@@ -377,6 +377,38 @@ CONFIG_HTML = r"""<!doctype html>
<div class="field-hint">When enabled, the server periodically starts the existing background Refresh All job using this interval. Bulk refreshes now wait this many seconds between shows to avoid flooding upstream sources.</div> <div class="field-hint">When enabled, the server periodically starts the existing background Refresh All job using this interval. Bulk refreshes now wait this many seconds between shows to avoid flooding upstream sources.</div>
</section> </section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Auto-download</h2>
<p class="muted">Automatically queue newly discovered episodes for shows in the `Watching` category after manual or scheduled refreshes.</p>
</div>
</div>
<div class="form-grid">
<label>Automatic queueing
<select id="autoDownloadEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</label>
<label>Default language
<select id="autoDownloadMode">
<option value="dub">Dubbed (Dub)</option>
<option value="sub">Subtitled (Sub)</option>
</select>
</label>
<label>Default quality
<select id="autoDownloadQuality">
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
</select>
</label>
</div>
<div class="field-hint">Adding a new watchlist entry does not auto-download anything. Auto-download only reacts to later manual refreshes or scheduled refreshes that find new episodes.</div>
</section>
<section class="settings settings-main"> <section class="settings settings-main">
<div class="toolbar"> <div class="toolbar">
<div> <div>
@@ -412,6 +444,9 @@ CONFIG_HTML = r"""<!doctype html>
watchlist_auto_refresh_enabled: false, watchlist_auto_refresh_enabled: false,
watchlist_auto_refresh_minutes: 60, watchlist_auto_refresh_minutes: 60,
watchlist_refresh_delay_seconds: 5, watchlist_refresh_delay_seconds: 5,
auto_download_enabled: false,
auto_download_mode: "dub",
auto_download_quality: "best",
discord_webhook_url: "", discord_webhook_url: "",
discord_webhook_events: [] discord_webhook_events: []
} }
@@ -436,6 +471,9 @@ CONFIG_HTML = r"""<!doctype html>
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true", watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value, watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").value,
watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value, watchlist_refresh_delay_seconds: $("watchlistRefreshDelaySeconds").value,
auto_download_enabled: $("autoDownloadEnabled").value === "true",
auto_download_mode: $("autoDownloadMode").value,
auto_download_quality: $("autoDownloadQuality").value,
discord_webhook_url: $("discordWebhookUrl").value, discord_webhook_url: $("discordWebhookUrl").value,
discord_webhook_events: selectedWebhookEvents() discord_webhook_events: selectedWebhookEvents()
}; };
@@ -449,6 +487,9 @@ CONFIG_HTML = r"""<!doctype html>
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled)); $("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes; $("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
$("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds; $("watchlistRefreshDelaySeconds").value = data.watchlist_refresh_delay_seconds;
$("autoDownloadEnabled").value = String(Boolean(data.auto_download_enabled));
$("autoDownloadMode").value = data.auto_download_mode;
$("autoDownloadQuality").value = data.auto_download_quality;
$("discordWebhookUrl").value = data.discord_webhook_url || ""; $("discordWebhookUrl").value = data.discord_webhook_url || "";
const selected = new Set(data.discord_webhook_events || []); const selected = new Set(data.discord_webhook_events || []);
for (const input of document.querySelectorAll(".webhook-event")) { for (const input of document.querySelectorAll(".webhook-event")) {
+12
View File
@@ -51,6 +51,7 @@ class HandlerContext:
test_discord_webhook_config: object test_discord_webhook_config: object
thumbnail_file_path: object thumbnail_file_path: object
update_all_watchlist_statuses: object update_all_watchlist_statuses: object
update_watchlist_auto_download: object
update_watchlist_category: object update_watchlist_category: object
update_watchlist_status: object update_watchlist_status: object
upload_watchlist_thumbnail: object upload_watchlist_thumbnail: object
@@ -519,6 +520,17 @@ class Handler(BaseHTTPRequestHandler):
Handler._runtime(self) Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id") payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category"))) self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category")))
elif parsed.path == "/api/watchlist/update-auto-download":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(
Handler._context(self).update_watchlist_auto_download(
payload["show_id"],
payload.get("mode"),
payload.get("quality"),
payload.get("name"),
)
)
elif parsed.path == "/api/watchlist/update-all": elif parsed.path == "/api/watchlist/update-all":
Handler._runtime(self) Handler._runtime(self)
result = Handler._context(self).update_all_watchlist_statuses() result = Handler._context(self).update_all_watchlist_statuses()
+53
View File
@@ -10,6 +10,7 @@ import sqlite3
import subprocess import subprocess
import threading import threading
import time import time
import re
from app_support import ( from app_support import (
MAX_LOG_LINES, MAX_LOG_LINES,
@@ -291,6 +292,11 @@ class WatchlistRefreshJobs:
skipped = False skipped = False
error_message = "" error_message = ""
try: try:
try:
item = self.store.refresh(show_id, include_thumbnail=False, refresh_source=source_name)
except TypeError as exc:
if "refresh_source" not in str(exc):
raise
item = self.store.refresh(show_id, include_thumbnail=False) item = self.store.refresh(show_id, include_thumbnail=False)
if item is None: if item is None:
skipped = True skipped = True
@@ -813,6 +819,53 @@ class DownloadQueue:
self.wakeup.set() self.wakeup.set()
return job return job
def _expand_episode_spec(self, episode_spec, available_episodes):
spec = str(episode_spec or "").strip()
values = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
if not spec or not values:
return []
expanded = []
seen = set()
parts = [part for part in re.split(r"\s+", spec) if part]
index_map = {value: index for index, value in enumerate(values)}
for part in parts:
if "-" in part:
start, end = [piece.strip() for piece in part.split("-", 1)]
if start in index_map and end in index_map:
start_index = index_map[start]
end_index = index_map[end]
if start_index > end_index:
start_index, end_index = end_index, start_index
for value in values[start_index : end_index + 1]:
if value not in seen:
seen.add(value)
expanded.append(value)
continue
if part in index_map and part not in seen:
seen.add(part)
expanded.append(part)
return expanded
def covered_episodes(self, show_id, mode, available_episodes):
normalized_show_id = str(show_id or "").strip()
normalized_mode = str(mode or "").strip().lower()
if not normalized_show_id or not normalized_mode:
return set()
with self.lock, self._connect() as conn:
rows = conn.execute(
"""
SELECT episodes
FROM jobs
WHERE show_id = ? AND mode = ? AND status IN ('pending', 'running', 'done')
ORDER BY created_at ASC
""",
(normalized_show_id, normalized_mode),
).fetchall()
covered = set()
for row in rows:
covered.update(self._expand_episode_spec((row["episodes"] if isinstance(row, sqlite3.Row) else row[0]), available_episodes))
return covered
def retry(self, job_id): def retry(self, job_id):
with self.lock: with self.lock:
job = self._find(job_id) job = self._find(job_id)
+126 -15
View File
@@ -975,19 +975,19 @@ class WatchlistCompletionTests(unittest.TestCase):
def test_queue_refresh_marks_item_queued_without_sync_refresh(self): def test_queue_refresh_marks_item_queued_without_sync_refresh(self):
self.seed_watchlist_item(show_id="show-11", title="Refresh Show", category="watching", status="updated") self.seed_watchlist_item(show_id="show-11", title="Refresh Show", category="watching", status="updated")
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object( with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("queue_refresh should not refresh synchronously") APP.WATCHLIST, "refresh", side_effect=AssertionError("queue_refresh should not refresh synchronously")
): ):
item = APP.WATCHLIST.queue_refresh("show-11", status_message="Queued from test.") item = APP.WATCHLIST.queue_refresh("show-11", status_message="Queued from test.")
schedule_refresh.assert_called_once_with("show-11") schedule_refresh.assert_called_once_with("show-11", source="manual")
self.assertEqual(item["status"], "queued") self.assertEqual(item["status"], "queued")
self.assertEqual(item["status_message"], "Queued from test.") self.assertEqual(item["status_message"], "Queued from test.")
def test_mark_downloaded_moves_existing_category_to_finished_and_sets_download_flag(self): def test_mark_downloaded_moves_existing_category_to_finished_and_sets_download_flag(self):
self.seed_watchlist_item(show_id="show-9", title="Queue Show", category="planned", downloaded=False) self.seed_watchlist_item(show_id="show-9", title="Queue Show", category="planned", downloaded=False)
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object( with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
): ):
item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show") item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show")
@@ -998,7 +998,7 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(item["status"], "queued") self.assertEqual(item["status"], "queued")
def test_mark_downloaded_creates_missing_show_in_finished_category(self): def test_mark_downloaded_creates_missing_show_in_finished_category(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object( with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
): ):
item = APP.WATCHLIST.mark_downloaded("show-10", title="Downloaded Show") item = APP.WATCHLIST.mark_downloaded("show-10", title="Downloaded Show")
@@ -1011,7 +1011,7 @@ class WatchlistCompletionTests(unittest.TestCase):
def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self): def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self):
self.seed_watchlist_item(show_id="legacy-show-9", title="Queue Show", category="watching", downloaded=False) self.seed_watchlist_item(show_id="legacy-show-9", title="Queue Show", category="watching", downloaded=False)
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)), mock.patch.object( with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
): ):
item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show") item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show")
@@ -1025,12 +1025,12 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"]) self.assertEqual(APP.WATCHLIST.all_show_ids(), ["resolved-show-9"])
def test_add_queues_refresh_without_sync_refresh(self): def test_add_queues_refresh_without_sync_refresh(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id: APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object( with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)) as schedule_refresh, mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("add should not refresh synchronously") APP.WATCHLIST, "refresh", side_effect=AssertionError("add should not refresh synchronously")
): ):
result = APP.WATCHLIST.add({"show_id": "show-22", "title": "Queued Show", "category": "watching"}) result = APP.WATCHLIST.add({"show_id": "show-22", "title": "Queued Show", "category": "watching"})
schedule_refresh.assert_called_once_with("show-22") schedule_refresh.assert_called_once_with("show-22", source="initial")
self.assertTrue(result["created"]) self.assertTrue(result["created"])
self.assertEqual(result["item"]["status"], "queued") self.assertEqual(result["item"]["status"], "queued")
self.assertIn("Refresh queued", result["message"]) self.assertIn("Refresh queued", result["message"])
@@ -1196,7 +1196,7 @@ class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
restored = APP.WatchlistStore(start_worker=False) restored = APP.WatchlistStore(start_worker=False)
self.assertEqual(restored.refresh_pending, ["show-a", "show-b"]) self.assertEqual(restored.refresh_pending, [("show-a", "manual"), ("show-b", "manual")])
self.assertEqual(restored.refresh_pending_set, {"show-a", "show-b"}) self.assertEqual(restored.refresh_pending_set, {"show-a", "show-b"})
@@ -1226,7 +1226,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
store = object.__new__(APP.WatchlistStore) store = object.__new__(APP.WatchlistStore)
store.lock = threading.RLock() store.lock = threading.RLock()
store.thumbnail_events = {} store.thumbnail_events = {}
store.refresh_pending = ["show-2"] store.refresh_pending = [("show-2", "manual")]
store.refresh_pending_set = {"show-2"} store.refresh_pending_set = {"show-2"}
store.refresh_inflight_set = set() store.refresh_inflight_set = set()
store.refresh_wakeup = threading.Event() store.refresh_wakeup = threading.Event()
@@ -1235,15 +1235,15 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
store.worker_enabled = False store.worker_enabled = False
calls = [] calls = []
def fake_refresh(show_id): def fake_refresh(show_id, refresh_source="manual"):
calls.append(show_id) calls.append((show_id, refresh_source))
store.refresh_stop_event.set() store.refresh_stop_event.set()
store.refresh = fake_refresh store.refresh = fake_refresh
APP.WatchlistStore._refresh_loop(store) APP.WatchlistStore._refresh_loop(store)
self.assertEqual(calls, ["show-2"]) self.assertEqual(calls, [("show-2", "manual")])
self.assertEqual(store.refresh_inflight_set, set()) self.assertEqual(store.refresh_inflight_set, set())
self.assertEqual(store.refresh_pending, []) self.assertEqual(store.refresh_pending, [])
self.assertEqual(store.refresh_pending_set, set()) self.assertEqual(store.refresh_pending_set, set())
@@ -1253,7 +1253,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
store.lock = threading.RLock() store.lock = threading.RLock()
thumbnail_event = threading.Event() thumbnail_event = threading.Event()
store.thumbnail_events = {"show-3": thumbnail_event} store.thumbnail_events = {"show-3": thumbnail_event}
store.refresh_pending = ["show-3", "show-4"] store.refresh_pending = [("show-3", "manual"), ("show-4", "manual")]
store.refresh_pending_set = {"show-3", "show-4"} store.refresh_pending_set = {"show-3", "show-4"}
store.refresh_inflight_set = {"show-3"} store.refresh_inflight_set = {"show-3"}
store._connect = APP.WATCHLIST._connect store._connect = APP.WATCHLIST._connect
@@ -1277,7 +1277,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
result = APP.WatchlistStore.remove(store, "show-3") result = APP.WatchlistStore.remove(store, "show-3")
self.assertEqual(result["item"]["show_id"], "show-3") self.assertEqual(result["item"]["show_id"], "show-3")
self.assertEqual(store.refresh_pending, ["show-4"]) self.assertEqual(store.refresh_pending, [("show-4", "manual")])
self.assertEqual(store.refresh_pending_set, {"show-4"}) self.assertEqual(store.refresh_pending_set, {"show-4"})
self.assertEqual(store.refresh_inflight_set, set()) self.assertEqual(store.refresh_inflight_set, set())
self.assertTrue(thumbnail_event.is_set()) self.assertTrue(thumbnail_event.is_set())
@@ -1294,6 +1294,9 @@ class ConfigSnapshotTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60, "watchlist_auto_refresh_minutes": 60,
"watchlist_refresh_delay_seconds": 5, "watchlist_refresh_delay_seconds": 5,
"auto_download_enabled": False,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"discord_webhook_url": "", "discord_webhook_url": "",
"discord_webhook_events": [], "discord_webhook_events": [],
} }
@@ -1309,6 +1312,9 @@ class ConfigSnapshotTests(unittest.TestCase):
self.assertFalse(config["watchlist_auto_refresh_enabled"]) self.assertFalse(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60) self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 5) self.assertEqual(config["watchlist_refresh_delay_seconds"], 5)
self.assertFalse(config["auto_download_enabled"])
self.assertEqual(config["auto_download_mode"], "dub")
self.assertEqual(config["auto_download_quality"], "best")
self.assertEqual(config["discord_webhook_url"], "") self.assertEqual(config["discord_webhook_url"], "")
self.assertEqual(config["discord_webhook_events"], []) self.assertEqual(config["discord_webhook_events"], [])
@@ -1318,11 +1324,17 @@ class ConfigSnapshotTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": "true", "watchlist_auto_refresh_enabled": "true",
"watchlist_auto_refresh_minutes": "15", "watchlist_auto_refresh_minutes": "15",
"watchlist_refresh_delay_seconds": "7", "watchlist_refresh_delay_seconds": "7",
"auto_download_enabled": "true",
"auto_download_mode": "sub",
"auto_download_quality": "720p",
} }
) )
self.assertTrue(config["watchlist_auto_refresh_enabled"]) self.assertTrue(config["watchlist_auto_refresh_enabled"])
self.assertEqual(config["watchlist_auto_refresh_minutes"], 15) self.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(config["watchlist_refresh_delay_seconds"], 7) self.assertEqual(config["watchlist_refresh_delay_seconds"], 7)
self.assertTrue(config["auto_download_enabled"])
self.assertEqual(config["auto_download_mode"], "sub")
self.assertEqual(config["auto_download_quality"], "720")
def test_normalize_config_filters_discord_webhook_events(self): def test_normalize_config_filters_discord_webhook_events(self):
config = APP.normalize_config( config = APP.normalize_config(
@@ -1475,6 +1487,78 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
start.assert_not_called() start.assert_not_called()
class AutoDownloadQueueTests(unittest.TestCase):
def test_queue_watchlist_auto_download_skips_initial_refresh(self):
item = {
"show_id": "show-1",
"title": "Example Show",
"category": "watching",
"previous_last_checked": None,
"had_new_episodes": True,
}
with mock.patch.object(
APP,
"ensure_runtime",
return_value={"config": {"auto_download_enabled": True}, "download_queue": mock.Mock()},
):
result = APP.queue_watchlist_auto_download(item, refresh_source="initial")
self.assertFalse(result["queued"])
self.assertEqual(result["reason"], "source:initial")
def test_queue_watchlist_auto_download_queues_only_missing_new_episodes(self):
item = {
"show_id": "show-42",
"title": "Queue Show",
"category": "watching",
"previous_last_checked": "2026-05-25T10:00:00+00:00",
"had_new_episodes": True,
"previous_dub_count": 10,
"previous_sub_count": 10,
"dub_episode_values": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
"sub_episode_values": ["1", "2", "3"],
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": 'Queue Show: Alt/Name',
}
download_queue = mock.Mock()
download_queue.covered_episodes.return_value = {"11"}
download_queue.add.side_effect = [
{"id": "job-12", "episodes": "12"},
]
runtime = {
"config": {
"auto_download_enabled": True,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"download_dir": "/tmp/downloads",
},
"download_queue": download_queue,
}
with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object(
APP,
"search_anime",
return_value=[{"id": "show-42", "title": "Queue Show", "query": "Queue Show", "index": 2}],
):
result = APP.queue_watchlist_auto_download(item, refresh_source="scheduled")
self.assertTrue(result["queued"])
self.assertEqual(result["episodes"], ["12"])
download_queue.add.assert_called_once_with(
{
"show_id": "show-42",
"query": "Queue Show",
"title": "Queue Show",
"anime_name": "Queue Show- Alt-Name",
"season": "1",
"index": 2,
"mode": "dub",
"quality": "best",
"episodes": "12",
"download_dir": "/tmp/downloads",
}
)
class StartupBehaviorTests(unittest.TestCase): class StartupBehaviorTests(unittest.TestCase):
def test_main_does_not_eagerly_initialize_runtime(self): def test_main_does_not_eagerly_initialize_runtime(self):
server = mock.Mock() server = mock.Mock()
@@ -1535,6 +1619,9 @@ class StartupBehaviorTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": False, "watchlist_auto_refresh_enabled": False,
"watchlist_auto_refresh_minutes": 60, "watchlist_auto_refresh_minutes": 60,
"watchlist_refresh_delay_seconds": 5, "watchlist_refresh_delay_seconds": 5,
"auto_download_enabled": False,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"discord_webhook_url": "", "discord_webhook_url": "",
"discord_webhook_events": [], "discord_webhook_events": [],
} }
@@ -1547,6 +1634,9 @@ class StartupBehaviorTests(unittest.TestCase):
"watchlist_auto_refresh_enabled": True, "watchlist_auto_refresh_enabled": True,
"watchlist_auto_refresh_minutes": 15, "watchlist_auto_refresh_minutes": 15,
"watchlist_refresh_delay_seconds": 8, "watchlist_refresh_delay_seconds": 8,
"auto_download_enabled": True,
"auto_download_mode": "sub",
"auto_download_quality": "720",
"discord_webhook_url": "https://discord.example/webhook", "discord_webhook_url": "https://discord.example/webhook",
"discord_webhook_events": ["runtime_error"], "discord_webhook_events": ["runtime_error"],
} }
@@ -1557,6 +1647,9 @@ class StartupBehaviorTests(unittest.TestCase):
self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"]) self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"])
self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15) self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15)
self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8) self.assertEqual(APP.CONFIG["watchlist_refresh_delay_seconds"], 8)
self.assertTrue(APP.CONFIG["auto_download_enabled"])
self.assertEqual(APP.CONFIG["auto_download_mode"], "sub")
self.assertEqual(APP.CONFIG["auto_download_quality"], "720")
self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.example/webhook") self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.example/webhook")
self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"]) self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"])
APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with() APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with()
@@ -1594,13 +1687,16 @@ class HandlerRouteTests(unittest.TestCase):
APP.initialize_runtime(start_workers=False) APP.initialize_runtime(start_workers=False)
def test_config_post_accepts_watchlist_schedule_fields(self): def test_config_post_accepts_watchlist_schedule_fields(self):
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9"}' body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720"}'
handler = DummyHandler("/api/config", body=body, content_length=len(body)) handler = DummyHandler("/api/config", body=body, content_length=len(body))
APP.Handler.do_POST(handler) APP.Handler.do_POST(handler)
self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"]) self.assertTrue(handler.json_payload["watchlist_auto_refresh_enabled"])
self.assertEqual(handler.json_payload["watchlist_auto_refresh_minutes"], 25) self.assertEqual(handler.json_payload["watchlist_auto_refresh_minutes"], 25)
self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9) self.assertEqual(handler.json_payload["watchlist_refresh_delay_seconds"], 9)
self.assertTrue(handler.json_payload["auto_download_enabled"])
self.assertEqual(handler.json_payload["auto_download_mode"], "dub")
self.assertEqual(handler.json_payload["auto_download_quality"], "720")
def test_config_post_accepts_discord_webhook_fields(self): def test_config_post_accepts_discord_webhook_fields(self):
body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}' body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}'
@@ -1744,6 +1840,15 @@ class HandlerRouteTests(unittest.TestCase):
self.assertEqual(handler.json_payload, payload) self.assertEqual(handler.json_payload, payload)
self.assertEqual(handler.json_status, HTTPStatus.CREATED) self.assertEqual(handler.json_status, HTTPStatus.CREATED)
def test_watchlist_auto_download_settings_post_returns_updated_item(self):
body = b'{"show_id":"show-1","mode":"dub","quality":"best","name":"Example Name"}'
handler = DummyHandler("/api/watchlist/update-auto-download", body=body, content_length=len(body))
payload = {"message": "Saved auto-download settings for Show 1.", "item": {"show_id": "show-1"}}
handler.handler_context = mock.Mock(update_watchlist_auto_download=mock.Mock(return_value=payload))
APP.Handler.do_POST(handler)
self.assertEqual(handler.json_payload, payload)
self.assertEqual(handler.json_status, HTTPStatus.OK)
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self): def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
handler = DummyHandler("/api/config") handler = DummyHandler("/api/config")
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object( with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
@@ -1837,6 +1942,12 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn('altTitlesPanel.classList.toggle("open")', APP.WATCHLIST_HTML) self.assertIn('altTitlesPanel.classList.toggle("open")', APP.WATCHLIST_HTML)
self.assertIn("AniDB English alternative titles were not available for this show.", APP.WATCHLIST_HTML) self.assertIn("AniDB English alternative titles were not available for this show.", APP.WATCHLIST_HTML)
def test_watchlist_page_renders_auto_download_editor(self):
self.assertIn("Auto-download", APP.WATCHLIST_HTML)
self.assertIn('api("/api/watchlist/update-auto-download"', APP.WATCHLIST_HTML)
self.assertIn("Save auto-download", APP.WATCHLIST_HTML)
self.assertIn("Manual add does not trigger it", APP.WATCHLIST_HTML)
def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self): def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self):
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML) self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML) self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML)
+103 -4
View File
@@ -551,6 +551,38 @@ WATCHLIST_HTML = r"""<!doctype html>
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
.auto-download-panel {
display: none;
grid-column: 1 / -1;
gap: 10px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
padding: 12px;
}
.auto-download-panel.visible {
display: grid;
}
.auto-download-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.auto-download-panel label {
font-size: 10px;
}
.auto-download-panel input,
.auto-download-panel select {
min-height: 36px;
padding: 0 10px;
font-size: 13px;
}
.auto-download-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.category-editor label { .category-editor label {
font-size: 10px; font-size: 10px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -562,7 +594,7 @@ WATCHLIST_HTML = r"""<!doctype html>
} }
.card-actions { .card-actions {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px; gap: 8px;
} }
.card-actions button { .card-actions button {
@@ -613,6 +645,7 @@ WATCHLIST_HTML = r"""<!doctype html>
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.watchlist-grid { grid-template-columns: 1fr; } .watchlist-grid { grid-template-columns: 1fr; }
.auto-download-grid { grid-template-columns: 1fr; }
} }
</style> </style>
</head> </head>
@@ -1031,6 +1064,12 @@ WATCHLIST_HTML = r"""<!doctype html>
renderPager(); renderPager();
return; return;
} }
const qualityOptions = `
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
`;
for (const item of items) { for (const item of items) {
const card = document.createElement("article"); const card = document.createElement("article");
const checked = item.last_checked ? new Date(item.last_checked).toLocaleString() : "Never"; const checked = item.last_checked ? new Date(item.last_checked).toLocaleString() : "Never";
@@ -1070,11 +1109,33 @@ WATCHLIST_HTML = r"""<!doctype html>
<div class="card-actions"> <div class="card-actions">
<button class="primary" type="button">Refresh</button> <button class="primary" type="button">Refresh</button>
<button class="ghost" type="button">Download all</button> <button class="ghost" type="button">Download all</button>
<button class="ghost" type="button">Auto-download</button>
<button class="danger" type="button">Remove</button> <button class="danger" type="button">Remove</button>
<div class="download-picker"> <div class="download-picker">
<button class="ghost" type="button">Sub</button> <button class="ghost" type="button">Sub</button>
<button class="ghost" type="button">Dub</button> <button class="ghost" type="button">Dub</button>
</div> </div>
<div class="auto-download-panel">
<div class="muted">Used only while this anime is in the Watching tab. Manual add does not trigger it; later refreshes do.</div>
<div class="auto-download-grid">
<label>Language
<select class="auto-download-mode">
<option value="dub"${item.auto_download_mode === "dub" ? " selected" : ""}>Dub</option>
<option value="sub"${item.auto_download_mode === "sub" ? " selected" : ""}>Sub</option>
</select>
</label>
<label>Quality
<select class="auto-download-quality">${qualityOptions.replace(`value="${item.auto_download_quality}"`, `value="${item.auto_download_quality}" selected`)}</select>
</label>
<label>Name
<input class="auto-download-name" type="text" list="auto-download-name-options-${item.show_id}">
<datalist id="auto-download-name-options-${item.show_id}"></datalist>
</label>
</div>
<div class="auto-download-actions">
<button class="ghost auto-download-save" type="button">Save auto-download</button>
</div>
</div>
</div> </div>
`; `;
card.querySelector(".cover-fallback").textContent = coverInitials(item.title); card.querySelector(".cover-fallback").textContent = coverInitials(item.title);
@@ -1142,10 +1203,24 @@ WATCHLIST_HTML = r"""<!doctype html>
const actionButtons = card.querySelector(".card-actions").querySelectorAll("button"); const actionButtons = card.querySelector(".card-actions").querySelectorAll("button");
const refreshBtn = actionButtons[0]; const refreshBtn = actionButtons[0];
const downloadBtn = actionButtons[1]; const downloadBtn = actionButtons[1];
const removeBtn = actionButtons[2]; const autoDownloadBtn = actionButtons[2];
const subBtn = actionButtons[3]; const removeBtn = actionButtons[3];
const dubBtn = actionButtons[4]; const subBtn = actionButtons[4];
const dubBtn = actionButtons[5];
const downloadPicker = card.querySelector(".download-picker"); const downloadPicker = card.querySelector(".download-picker");
const autoDownloadPanel = card.querySelector(".auto-download-panel");
const autoDownloadMode = card.querySelector(".auto-download-mode");
const autoDownloadQuality = card.querySelector(".auto-download-quality");
const autoDownloadName = card.querySelector(".auto-download-name");
const autoDownloadSave = card.querySelector(".auto-download-save");
const autoDownloadNameOptions = card.querySelector(`#auto-download-name-options-${CSS.escape(item.show_id)}`);
autoDownloadName.value = item.auto_download_name || "";
const nameSuggestions = [item.title, ...(Array.isArray(item.alternative_titles) ? item.alternative_titles.map((entry) => entry.value) : [])];
for (const name of nameSuggestions) {
const option = document.createElement("option");
option.value = name;
autoDownloadNameOptions.appendChild(option);
}
const categorySelect = card.querySelector(".category-select"); const categorySelect = card.querySelector(".category-select");
categorySelect.addEventListener("change", async () => { categorySelect.addEventListener("change", async () => {
categorySelect.disabled = true; categorySelect.disabled = true;
@@ -1168,6 +1243,30 @@ WATCHLIST_HTML = r"""<!doctype html>
const visible = downloadPicker.classList.toggle("visible"); const visible = downloadPicker.classList.toggle("visible");
downloadBtn.textContent = visible ? "Cancel download" : "Download all"; downloadBtn.textContent = visible ? "Cancel download" : "Download all";
}); });
autoDownloadBtn.addEventListener("click", () => {
const visible = autoDownloadPanel.classList.toggle("visible");
autoDownloadBtn.textContent = visible ? "Hide auto-download" : "Auto-download";
});
autoDownloadSave.addEventListener("click", async () => {
autoDownloadSave.disabled = true;
try {
const data = await api("/api/watchlist/update-auto-download", {
method: "POST",
body: JSON.stringify({
show_id: item.show_id,
mode: autoDownloadMode.value,
quality: autoDownloadQuality.value,
name: autoDownloadName.value
})
});
autoDownloadName.value = (data.item || {}).auto_download_name || autoDownloadName.value;
setNotice(data.message || "Auto-download settings saved.");
} catch (error) {
setNotice(error.message);
} finally {
autoDownloadSave.disabled = false;
}
});
subBtn.addEventListener("click", () => downloadWatchlistItem(item, "sub", downloadPicker, downloadBtn)); subBtn.addEventListener("click", () => downloadWatchlistItem(item, "sub", downloadPicker, downloadBtn));
dubBtn.addEventListener("click", () => downloadWatchlistItem(item, "dub", downloadPicker, downloadBtn)); dubBtn.addEventListener("click", () => downloadWatchlistItem(item, "dub", downloadPicker, downloadBtn));
removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title)); removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title));