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
+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,