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
## 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.
+10 -3
View File
@@ -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.
+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,
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
View File
@@ -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):
+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>
</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")) {
+12
View File
@@ -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()
+54 -1
View File
@@ -10,6 +10,7 @@ import sqlite3
import subprocess
import threading
import time
import re
from app_support import (
MAX_LOG_LINES,
@@ -291,7 +292,12 @@ class WatchlistRefreshJobs:
skipped = False
error_message = ""
try:
item = self.store.refresh(show_id, include_thumbnail=False)
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
else:
@@ -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
View File
@@ -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
View File
@@ -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));