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