Add watchlist auto-download settings
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
|
||||
- Switched watchlist `Alternative titles` from AniList lookups to AniDB title-dump matching and now keep only English alternate titles.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Small local web UI for a system-wide `ani-cli` install.
|
||||
|
||||
Current version: `0.38.0`
|
||||
Current version: `0.39.0`
|
||||
|
||||
## Project Layout
|
||||
|
||||
@@ -123,6 +123,7 @@ Container notes:
|
||||
- Track shows on `/watchlist`.
|
||||
- Refresh watchlist episode counts in the background.
|
||||
- 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 completed downloads.
|
||||
- Cache watchlist thumbnails locally.
|
||||
@@ -181,8 +182,10 @@ Use it to set:
|
||||
4. Scheduled watchlist refresh enable or disable.
|
||||
5. Scheduled refresh period in minutes.
|
||||
6. Delay between shows during `Refresh All`.
|
||||
7. Discord webhook URL.
|
||||
8. Discord notification events plus a test action.
|
||||
7. Global auto-download enable or disable.
|
||||
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`.
|
||||
|
||||
@@ -221,6 +224,7 @@ Main behavior:
|
||||
7. Summary cards show total tracked shows plus `sub` and `dub` counts.
|
||||
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.
|
||||
10. `Auto-download` opens per-series settings for language, quality, and the sanitized library name used for automatically queued episodes.
|
||||
|
||||
Status behavior:
|
||||
|
||||
@@ -249,6 +253,9 @@ Refresh behavior:
|
||||
- Per-show refreshes are queued in the background.
|
||||
- Queued refresh state survives restarts.
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
@@ -36,6 +36,7 @@ from app_support import (
|
||||
DEFAULT_CONFIG,
|
||||
MAX_JSON_BODY_BYTES,
|
||||
MODE_CHOICES,
|
||||
QUALITY_CHOICES,
|
||||
send_discord_webhook_event,
|
||||
STATE_DB_PATH,
|
||||
THUMBNAIL_RETRY_SECONDS,
|
||||
@@ -912,9 +913,35 @@ def watchlist_status_message(sub_count, dub_count, expected_count=0, airing_stat
|
||||
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:
|
||||
def __init__(self, start_worker=True):
|
||||
def __init__(self, config_getter=None, start_worker=True):
|
||||
self.lock = threading.RLock()
|
||||
self.config_getter = config_getter or (lambda: dict(DEFAULT_CONFIG))
|
||||
self.auto_download_fn = None
|
||||
self.thumbnail_events = {}
|
||||
self.refresh_pending = []
|
||||
self.refresh_pending_set = set()
|
||||
@@ -953,13 +980,13 @@ class WatchlistStore:
|
||||
def _next_pending_refresh(self):
|
||||
with self.lock:
|
||||
if not self.refresh_pending:
|
||||
return None
|
||||
show_id = self.refresh_pending.pop(0)
|
||||
return None, None
|
||||
show_id, source = self.refresh_pending.pop(0)
|
||||
self.refresh_pending_set.discard(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()
|
||||
if (
|
||||
not normalized_show_id
|
||||
@@ -967,7 +994,7 @@ class WatchlistStore:
|
||||
or normalized_show_id in self.refresh_inflight_set
|
||||
):
|
||||
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)
|
||||
return True
|
||||
|
||||
@@ -977,7 +1004,9 @@ class WatchlistStore:
|
||||
return False
|
||||
removed = False
|
||||
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)
|
||||
removed = True
|
||||
if normalized_show_id in self.refresh_inflight_set:
|
||||
@@ -996,35 +1025,43 @@ class WatchlistStore:
|
||||
"""
|
||||
).fetchall()
|
||||
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:
|
||||
self.refresh_wakeup.set()
|
||||
|
||||
def _refresh_loop(self):
|
||||
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:
|
||||
self.refresh_wakeup.wait(2)
|
||||
self.refresh_wakeup.clear()
|
||||
continue
|
||||
try:
|
||||
self.refresh(show_id)
|
||||
self.refresh(show_id, refresh_source=refresh_source or "manual")
|
||||
except Exception as exc:
|
||||
debug_log("watchlist.refresh_worker.error", show_id=show_id, error=exc)
|
||||
finally:
|
||||
with self.lock:
|
||||
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()
|
||||
if not normalized_show_id:
|
||||
raise ValueError("Missing show_id.")
|
||||
self.ensure_worker()
|
||||
with self.lock:
|
||||
self._enqueue_refresh_locked(normalized_show_id)
|
||||
self._enqueue_refresh_locked(normalized_show_id, source=source)
|
||||
self.refresh_wakeup.set()
|
||||
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):
|
||||
conn = sqlite3.connect(STATE_DB_PATH, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -1096,6 +1133,9 @@ class WatchlistStore:
|
||||
alternative_titles_json TEXT,
|
||||
anidb_aid TEXT,
|
||||
anidb_title TEXT,
|
||||
auto_download_mode TEXT,
|
||||
auto_download_quality TEXT,
|
||||
auto_download_name TEXT,
|
||||
thumbnail_path TEXT,
|
||||
thumbnail_checked_at TEXT
|
||||
)
|
||||
@@ -1130,6 +1170,12 @@ class WatchlistStore:
|
||||
conn.execute("ALTER TABLE watchlist ADD COLUMN anilist_site_url TEXT")
|
||||
if "alternative_titles_json" not in columns:
|
||||
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(
|
||||
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
|
||||
)
|
||||
@@ -1139,6 +1185,7 @@ class WatchlistStore:
|
||||
|
||||
def _row_to_item(self, row):
|
||||
item = dict(row)
|
||||
defaults = self._auto_download_defaults(item.get("title"))
|
||||
item["category"] = normalize_watchlist_category(item.get("category"))
|
||||
item["category_label"] = watchlist_category_label(item["category"])
|
||||
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["alternative_titles"] = decode_alternative_titles(item.get("alternative_titles_json"))
|
||||
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"):
|
||||
title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title"
|
||||
item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}"
|
||||
@@ -1182,8 +1234,9 @@ class WatchlistStore:
|
||||
animeschedule_route, animeschedule_title,
|
||||
anilist_id, anilist_title, anilist_site_url, alternative_titles_json,
|
||||
anidb_aid, anidb_title,
|
||||
auto_download_mode, auto_download_quality, auto_download_name,
|
||||
thumbnail_path, thumbnail_checked_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(show_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
category = excluded.category,
|
||||
@@ -1206,6 +1259,9 @@ class WatchlistStore:
|
||||
alternative_titles_json = excluded.alternative_titles_json,
|
||||
anidb_aid = excluded.anidb_aid,
|
||||
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_checked_at = excluded.thumbnail_checked_at
|
||||
""",
|
||||
@@ -1233,6 +1289,9 @@ class WatchlistStore:
|
||||
item.get("alternative_titles_json"),
|
||||
item.get("anidb_aid"),
|
||||
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_checked_at"),
|
||||
),
|
||||
@@ -1282,6 +1341,9 @@ class WatchlistStore:
|
||||
"alternative_titles_json": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
"auto_download_mode": None,
|
||||
"auto_download_quality": None,
|
||||
"auto_download_name": None,
|
||||
"thumbnail_path": None,
|
||||
"thumbnail_checked_at": None,
|
||||
},
|
||||
@@ -1388,6 +1450,7 @@ class WatchlistStore:
|
||||
|
||||
now = now_iso()
|
||||
item = {
|
||||
**self._auto_download_defaults(title),
|
||||
"show_id": show_id,
|
||||
"title": title,
|
||||
"category": normalize_watchlist_category(payload.get("category")),
|
||||
@@ -1416,14 +1479,15 @@ class WatchlistStore:
|
||||
}
|
||||
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}
|
||||
|
||||
def refresh(self, show_id, include_thumbnail=True):
|
||||
def refresh(self, show_id, include_thumbnail=True, refresh_source="manual"):
|
||||
existing = self.get(show_id)
|
||||
normalized_show_id = existing["show_id"]
|
||||
previous_sub_count = int(existing.get("sub_count") or 0)
|
||||
previous_dub_count = int(existing.get("dub_count") or 0)
|
||||
previous_last_checked = existing.get("last_checked")
|
||||
now = now_iso()
|
||||
request_timeout = self._refresh_request_timeout()
|
||||
try:
|
||||
@@ -1561,9 +1625,17 @@ class WatchlistStore:
|
||||
item = self.get(normalized_show_id)
|
||||
item["previous_sub_count"] = previous_sub_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["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["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
|
||||
|
||||
def refresh_all(self):
|
||||
@@ -1584,7 +1656,7 @@ class WatchlistStore:
|
||||
)
|
||||
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)
|
||||
now = now_iso()
|
||||
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 = ?",
|
||||
(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=""):
|
||||
normalized_show_id = str(show_id or "").strip()
|
||||
@@ -1645,6 +1717,7 @@ class WatchlistStore:
|
||||
self._drop_refresh_locked(previous_show_id)
|
||||
else:
|
||||
existing = {
|
||||
**self._auto_download_defaults(normalized_title or "Unknown Anime"),
|
||||
"show_id": normalized_show_id,
|
||||
"title": normalized_title or "Unknown Anime",
|
||||
"category": "finished",
|
||||
@@ -1672,9 +1745,30 @@ class WatchlistStore:
|
||||
"thumbnail_checked_at": None,
|
||||
}
|
||||
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)
|
||||
|
||||
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):
|
||||
existing = self.get(show_id)
|
||||
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):
|
||||
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}
|
||||
|
||||
|
||||
@@ -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):
|
||||
ensure_runtime()
|
||||
item = WATCHLIST.update_category(show_id, category)
|
||||
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():
|
||||
ensure_runtime()
|
||||
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()
|
||||
config = normalize_config(load_json(CONFIG_PATH, DEFAULT_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(
|
||||
config_getter=lambda: get_config_snapshot(config),
|
||||
watchlist_sync_fn=sync_downloaded_job_to_watchlist,
|
||||
job_prepare_fn=prepare_download_job,
|
||||
start_worker=worker_state,
|
||||
)
|
||||
watchlist.auto_download_fn = queue_watchlist_auto_download
|
||||
watchlist_refresh = WatchlistRefreshJobs(
|
||||
watchlist,
|
||||
config_getter=lambda: get_config_snapshot(config),
|
||||
@@ -2156,6 +2322,7 @@ Handler = build_handler_class(
|
||||
test_discord_webhook_config=test_discord_webhook_config,
|
||||
thumbnail_file_path=thumbnail_file_path,
|
||||
update_all_watchlist_statuses=update_all_watchlist_statuses,
|
||||
update_watchlist_auto_download=update_watchlist_auto_download,
|
||||
update_watchlist_category=update_watchlist_category,
|
||||
update_watchlist_status=update_watchlist_status,
|
||||
upload_watchlist_thumbnail=upload_watchlist_thumbnail,
|
||||
|
||||
+16
-1
@@ -19,7 +19,7 @@ from uuid import uuid4
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||
APP_NAME = "ani-cli-web"
|
||||
VERSION = "0.38.0"
|
||||
VERSION = "0.39.0"
|
||||
ALLANIME_BASE = "allanime.day"
|
||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||
ALLANIME_REFERER = "https://allmanga.to"
|
||||
@@ -140,6 +140,9 @@ DEFAULT_CONFIG = {
|
||||
"watchlist_auto_refresh_enabled": False,
|
||||
"watchlist_auto_refresh_minutes": WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES,
|
||||
"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_events": [],
|
||||
}
|
||||
@@ -285,6 +288,11 @@ def normalize_config(data):
|
||||
auto_refresh_enabled = config.get("watchlist_auto_refresh_enabled")
|
||||
auto_refresh_minutes = config.get("watchlist_auto_refresh_minutes")
|
||||
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_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"}
|
||||
else:
|
||||
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:
|
||||
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_minutes"] = auto_refresh_minutes
|
||||
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):
|
||||
webhook_events = [part.strip() for part in webhook_events.split(",")]
|
||||
elif not isinstance(webhook_events, list):
|
||||
|
||||
@@ -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>
|
||||
</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">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
@@ -412,6 +444,9 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
watchlist_auto_refresh_enabled: false,
|
||||
watchlist_auto_refresh_minutes: 60,
|
||||
watchlist_refresh_delay_seconds: 5,
|
||||
auto_download_enabled: false,
|
||||
auto_download_mode: "dub",
|
||||
auto_download_quality: "best",
|
||||
discord_webhook_url: "",
|
||||
discord_webhook_events: []
|
||||
}
|
||||
@@ -436,6 +471,9 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
watchlist_auto_refresh_enabled: $("watchlistAutoRefreshEnabled").value === "true",
|
||||
watchlist_auto_refresh_minutes: $("watchlistAutoRefreshMinutes").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_events: selectedWebhookEvents()
|
||||
};
|
||||
@@ -449,6 +487,9 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
$("watchlistAutoRefreshEnabled").value = String(Boolean(data.watchlist_auto_refresh_enabled));
|
||||
$("watchlistAutoRefreshMinutes").value = data.watchlist_auto_refresh_minutes;
|
||||
$("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 || "";
|
||||
const selected = new Set(data.discord_webhook_events || []);
|
||||
for (const input of document.querySelectorAll(".webhook-event")) {
|
||||
|
||||
@@ -51,6 +51,7 @@ class HandlerContext:
|
||||
test_discord_webhook_config: object
|
||||
thumbnail_file_path: object
|
||||
update_all_watchlist_statuses: object
|
||||
update_watchlist_auto_download: object
|
||||
update_watchlist_category: object
|
||||
update_watchlist_status: object
|
||||
upload_watchlist_thumbnail: object
|
||||
@@ -519,6 +520,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
Handler._runtime(self)
|
||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||
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":
|
||||
Handler._runtime(self)
|
||||
result = Handler._context(self).update_all_watchlist_statuses()
|
||||
|
||||
@@ -10,6 +10,7 @@ import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import re
|
||||
|
||||
from app_support import (
|
||||
MAX_LOG_LINES,
|
||||
@@ -291,6 +292,11 @@ class WatchlistRefreshJobs:
|
||||
skipped = False
|
||||
error_message = ""
|
||||
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)
|
||||
if item is None:
|
||||
skipped = True
|
||||
@@ -813,6 +819,53 @@ class DownloadQueue:
|
||||
self.wakeup.set()
|
||||
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):
|
||||
with self.lock:
|
||||
job = self._find(job_id)
|
||||
|
||||
+126
-15
@@ -975,19 +975,19 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
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")
|
||||
|
||||
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")
|
||||
):
|
||||
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_message"], "Queued from test.")
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
):
|
||||
item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show")
|
||||
@@ -998,7 +998,7 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
self.assertEqual(item["status"], "queued")
|
||||
|
||||
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")
|
||||
):
|
||||
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):
|
||||
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")
|
||||
):
|
||||
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"])
|
||||
|
||||
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")
|
||||
):
|
||||
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.assertEqual(result["item"]["status"], "queued")
|
||||
self.assertIn("Refresh queued", result["message"])
|
||||
@@ -1196,7 +1196,7 @@ class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
|
||||
|
||||
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"})
|
||||
|
||||
|
||||
@@ -1226,7 +1226,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
|
||||
store = object.__new__(APP.WatchlistStore)
|
||||
store.lock = threading.RLock()
|
||||
store.thumbnail_events = {}
|
||||
store.refresh_pending = ["show-2"]
|
||||
store.refresh_pending = [("show-2", "manual")]
|
||||
store.refresh_pending_set = {"show-2"}
|
||||
store.refresh_inflight_set = set()
|
||||
store.refresh_wakeup = threading.Event()
|
||||
@@ -1235,15 +1235,15 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
|
||||
store.worker_enabled = False
|
||||
calls = []
|
||||
|
||||
def fake_refresh(show_id):
|
||||
calls.append(show_id)
|
||||
def fake_refresh(show_id, refresh_source="manual"):
|
||||
calls.append((show_id, refresh_source))
|
||||
store.refresh_stop_event.set()
|
||||
|
||||
store.refresh = fake_refresh
|
||||
|
||||
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_pending, [])
|
||||
self.assertEqual(store.refresh_pending_set, set())
|
||||
@@ -1253,7 +1253,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
|
||||
store.lock = threading.RLock()
|
||||
thumbnail_event = threading.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_inflight_set = {"show-3"}
|
||||
store._connect = APP.WATCHLIST._connect
|
||||
@@ -1277,7 +1277,7 @@ class WatchlistRefreshQueueTests(unittest.TestCase):
|
||||
result = APP.WatchlistStore.remove(store, "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_inflight_set, set())
|
||||
self.assertTrue(thumbnail_event.is_set())
|
||||
@@ -1294,6 +1294,9 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
"watchlist_auto_refresh_enabled": False,
|
||||
"watchlist_auto_refresh_minutes": 60,
|
||||
"watchlist_refresh_delay_seconds": 5,
|
||||
"auto_download_enabled": False,
|
||||
"auto_download_mode": "dub",
|
||||
"auto_download_quality": "best",
|
||||
"discord_webhook_url": "",
|
||||
"discord_webhook_events": [],
|
||||
}
|
||||
@@ -1309,6 +1312,9 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
self.assertFalse(config["watchlist_auto_refresh_enabled"])
|
||||
self.assertEqual(config["watchlist_auto_refresh_minutes"], 60)
|
||||
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_events"], [])
|
||||
|
||||
@@ -1318,11 +1324,17 @@ class ConfigSnapshotTests(unittest.TestCase):
|
||||
"watchlist_auto_refresh_enabled": "true",
|
||||
"watchlist_auto_refresh_minutes": "15",
|
||||
"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.assertEqual(config["watchlist_auto_refresh_minutes"], 15)
|
||||
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):
|
||||
config = APP.normalize_config(
|
||||
@@ -1475,6 +1487,78 @@ class WatchlistAutoRefreshSchedulerTests(unittest.TestCase):
|
||||
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):
|
||||
def test_main_does_not_eagerly_initialize_runtime(self):
|
||||
server = mock.Mock()
|
||||
@@ -1535,6 +1619,9 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
"watchlist_auto_refresh_enabled": False,
|
||||
"watchlist_auto_refresh_minutes": 60,
|
||||
"watchlist_refresh_delay_seconds": 5,
|
||||
"auto_download_enabled": False,
|
||||
"auto_download_mode": "dub",
|
||||
"auto_download_quality": "best",
|
||||
"discord_webhook_url": "",
|
||||
"discord_webhook_events": [],
|
||||
}
|
||||
@@ -1547,6 +1634,9 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
"watchlist_auto_refresh_enabled": True,
|
||||
"watchlist_auto_refresh_minutes": 15,
|
||||
"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_events": ["runtime_error"],
|
||||
}
|
||||
@@ -1557,6 +1647,9 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
self.assertTrue(APP.CONFIG["watchlist_auto_refresh_enabled"])
|
||||
self.assertEqual(APP.CONFIG["watchlist_auto_refresh_minutes"], 15)
|
||||
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_events"], ["runtime_error"])
|
||||
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)
|
||||
|
||||
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))
|
||||
APP.Handler.do_POST(handler)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
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_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):
|
||||
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_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):
|
||||
handler = DummyHandler("/api/config")
|
||||
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("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):
|
||||
self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML)
|
||||
self.assertIn("function thumbnailWarmupToken(item)", APP.WATCHLIST_HTML)
|
||||
|
||||
+103
-4
@@ -551,6 +551,38 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
display: grid;
|
||||
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 {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -562,7 +594,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
}
|
||||
.card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.card-actions button {
|
||||
@@ -613,6 +645,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.watchlist-grid { grid-template-columns: 1fr; }
|
||||
.auto-download-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -1031,6 +1064,12 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
renderPager();
|
||||
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) {
|
||||
const card = document.createElement("article");
|
||||
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">
|
||||
<button class="primary" type="button">Refresh</button>
|
||||
<button class="ghost" type="button">Download all</button>
|
||||
<button class="ghost" type="button">Auto-download</button>
|
||||
<button class="danger" type="button">Remove</button>
|
||||
<div class="download-picker">
|
||||
<button class="ghost" type="button">Sub</button>
|
||||
<button class="ghost" type="button">Dub</button>
|
||||
</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>
|
||||
`;
|
||||
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 refreshBtn = actionButtons[0];
|
||||
const downloadBtn = actionButtons[1];
|
||||
const removeBtn = actionButtons[2];
|
||||
const subBtn = actionButtons[3];
|
||||
const dubBtn = actionButtons[4];
|
||||
const autoDownloadBtn = actionButtons[2];
|
||||
const removeBtn = actionButtons[3];
|
||||
const subBtn = actionButtons[4];
|
||||
const dubBtn = actionButtons[5];
|
||||
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");
|
||||
categorySelect.addEventListener("change", async () => {
|
||||
categorySelect.disabled = true;
|
||||
@@ -1168,6 +1243,30 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
const visible = downloadPicker.classList.toggle("visible");
|
||||
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));
|
||||
dubBtn.addEventListener("click", () => downloadWatchlistItem(item, "dub", downloadPicker, downloadBtn));
|
||||
removeBtn.addEventListener("click", () => removeWatchlistItem(item.show_id, item.title));
|
||||
|
||||
Reference in New Issue
Block a user