Add watchlist auto-download settings
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user