Track downloaded episodes for auto-download

This commit is contained in:
Dymas
2026-05-25 10:11:43 +02:00
parent baea85e24c
commit e48bc21944
6 changed files with 167 additions and 22 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.40.0 - 2026-05-25
- Added per-watchlist storage for downloaded sub and dub episode lists so auto-download can compare actual downloaded history instead of only refresh count deltas.
- Changed auto-download to queue any available episodes that are still missing after a later manual or scheduled refresh, including backlog episodes that existed before tracking started.
## 0.39.1 - 2026-05-25
- Replaced the auto-download name datalist with an explicit dropdown that lists the current title, AniDB alternative titles, and a `Custom` option.
+2 -2
View File
@@ -2,7 +2,7 @@
Small local web UI for a system-wide `ani-cli` install.
Current version: `0.39.1`
Current version: `0.40.0`
## Project Layout
@@ -255,7 +255,7 @@ Refresh behavior:
- 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.
- Auto-download uses stored downloaded-episode history per language, so it can queue any still-missing episodes after a later refresh, including backlog episodes that existed before the show was first tracked.
- Scheduled refresh runs log start, stop, success, and failure to stdout.
- `Refresh All` works one show at a time with a configurable delay.
- When a Discord webhook is configured, new-episode refresh results include one notification card per anime with sub and dub counts and a cached thumbnail attachment when available.
+1 -1
View File
@@ -1 +1 @@
0.39.1
0.40.0
+113 -14
View File
@@ -938,6 +938,61 @@ def episode_specs_for_values(values, available_episodes):
return specs
def encode_episode_values(values):
normalized = []
seen = set()
for value in values or []:
text = str(value or "").strip()
if text and text not in seen:
seen.add(text)
normalized.append(text)
return json.dumps(normalized, ensure_ascii=True)
def decode_episode_values(payload):
if not payload:
return []
try:
raw = json.loads(payload)
except (TypeError, ValueError, json.JSONDecodeError):
return []
normalized = []
seen = set()
for value in raw if isinstance(raw, list) else []:
text = str(value or "").strip()
if text and text not in seen:
seen.add(text)
normalized.append(text)
return normalized
def expand_episode_spec_against_available(episode_spec, available_episodes):
specs = [part for part in re.split(r"\s+", str(episode_spec or "").strip()) if part]
values = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
if not specs or not values:
return []
index_map = {value: index for index, value in enumerate(values)}
expanded = []
seen = set()
for spec in specs:
if "-" in spec:
start, end = [piece.strip() for piece in spec.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 spec in index_map and spec not in seen:
seen.add(spec)
expanded.append(spec)
return expanded
class WatchlistStore:
def __init__(self, config_getter=None, start_worker=True):
self.lock = threading.RLock()
@@ -1139,6 +1194,8 @@ class WatchlistStore:
auto_download_quality TEXT,
auto_download_name TEXT,
auto_download_series TEXT,
downloaded_sub_episodes_json TEXT,
downloaded_dub_episodes_json TEXT,
thumbnail_path TEXT,
thumbnail_checked_at TEXT
)
@@ -1181,6 +1238,10 @@ class WatchlistStore:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_name TEXT")
if "auto_download_series" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_series TEXT")
if "downloaded_sub_episodes_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_sub_episodes_json TEXT")
if "downloaded_dub_episodes_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_dub_episodes_json TEXT")
conn.execute(
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
)
@@ -1218,6 +1279,8 @@ class WatchlistStore:
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"])
item["auto_download_series"] = normalize_season(item.get("auto_download_series") or defaults["auto_download_series"])
item["downloaded_sub_episodes"] = decode_episode_values(item.get("downloaded_sub_episodes_json"))
item["downloaded_dub_episodes"] = decode_episode_values(item.get("downloaded_dub_episodes_json"))
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}"
@@ -1241,8 +1304,9 @@ class WatchlistStore:
anilist_id, anilist_title, anilist_site_url, alternative_titles_json,
anidb_aid, anidb_title,
auto_download_mode, auto_download_quality, auto_download_name, auto_download_series,
downloaded_sub_episodes_json, downloaded_dub_episodes_json,
thumbnail_path, thumbnail_checked_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(show_id) DO UPDATE SET
title = excluded.title,
category = excluded.category,
@@ -1269,6 +1333,8 @@ class WatchlistStore:
auto_download_quality = excluded.auto_download_quality,
auto_download_name = excluded.auto_download_name,
auto_download_series = excluded.auto_download_series,
downloaded_sub_episodes_json = excluded.downloaded_sub_episodes_json,
downloaded_dub_episodes_json = excluded.downloaded_dub_episodes_json,
thumbnail_path = excluded.thumbnail_path,
thumbnail_checked_at = excluded.thumbnail_checked_at
""",
@@ -1300,6 +1366,8 @@ class WatchlistStore:
item.get("auto_download_quality"),
item.get("auto_download_name"),
item.get("auto_download_series"),
item.get("downloaded_sub_episodes_json"),
item.get("downloaded_dub_episodes_json"),
item.get("thumbnail_path"),
item.get("thumbnail_checked_at"),
),
@@ -1353,6 +1421,8 @@ class WatchlistStore:
"auto_download_quality": None,
"auto_download_name": None,
"auto_download_series": None,
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
},
@@ -1485,6 +1555,8 @@ class WatchlistStore:
"anidb_title": None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": None,
}
self._upsert_conn(conn, item)
@@ -1675,29 +1747,53 @@ class WatchlistStore:
)
return self.schedule_refresh(existing["show_id"], source=source)
def mark_downloaded(self, show_id, title=""):
def mark_downloaded(self, show_id, title="", mode="", episodes=""):
normalized_show_id = str(show_id or "").strip()
if not normalized_show_id:
raise ValueError("Missing show_id for watchlist download sync.")
normalized_title = str(title or "").strip()
normalized_mode = str(mode or "").strip().lower()
episode_spec = str(episodes or "").strip()
now = now_iso()
downloaded_episode_values = []
if normalized_mode in MODE_CHOICES and episode_spec:
try:
available_episodes = episode_list(normalized_show_id, normalized_mode)
except Exception:
available_episodes = []
downloaded_episode_values = expand_episode_spec_against_available(episode_spec, available_episodes)
with self.lock, self._connect() as conn:
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (normalized_show_id,)).fetchone()
if row:
existing_item = self._row_to_item(row)
sub_payload = existing_item.get("downloaded_sub_episodes_json")
dub_payload = existing_item.get("downloaded_dub_episodes_json")
if normalized_mode == "sub" and downloaded_episode_values:
sub_payload = encode_episode_values(existing_item.get("downloaded_sub_episodes", []) + downloaded_episode_values)
elif normalized_mode == "dub" and downloaded_episode_values:
dub_payload = encode_episode_values(existing_item.get("downloaded_dub_episodes", []) + downloaded_episode_values)
conn.execute(
"""
UPDATE watchlist
SET category = 'finished', downloaded = 1, status = 'queued', status_message = ?, updated_at = ?
SET category = 'finished', downloaded = 1, status = 'queued', status_message = ?, updated_at = ?,
downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ?
WHERE show_id = ?
""",
("Queued refresh after successful download.", now, normalized_show_id),
("Queued refresh after successful download.", now, sub_payload, dub_payload, normalized_show_id),
)
else:
matched_row = self._find_download_title_match_conn(conn, normalized_title, exclude_show_id=normalized_show_id)
if matched_row is not None:
matched_item = self._row_to_item(matched_row)
previous_show_id = str(matched_row["show_id"]).strip()
resolved_title = normalized_title or str(matched_row["title"] or "").strip() or "Unknown Anime"
sub_payload = matched_item.get("downloaded_sub_episodes_json")
dub_payload = matched_item.get("downloaded_dub_episodes_json")
if normalized_mode == "sub" and downloaded_episode_values:
sub_payload = encode_episode_values(matched_item.get("downloaded_sub_episodes", []) + downloaded_episode_values)
elif normalized_mode == "dub" and downloaded_episode_values:
dub_payload = encode_episode_values(matched_item.get("downloaded_dub_episodes", []) + downloaded_episode_values)
conn.execute(
"""
UPDATE watchlist
@@ -1708,7 +1804,9 @@ class WatchlistStore:
downloaded = 1,
status = 'queued',
status_message = ?,
updated_at = ?
updated_at = ?,
downloaded_sub_episodes_json = ?,
downloaded_dub_episodes_json = ?
WHERE show_id = ?
""",
(
@@ -1716,6 +1814,8 @@ class WatchlistStore:
resolved_title,
"Queued refresh after successful download.",
now,
sub_payload,
dub_payload,
previous_show_id,
),
)
@@ -1751,6 +1851,8 @@ class WatchlistStore:
"anidb_aid": None,
"anidb_title": None,
"auto_download_series": None,
"downloaded_sub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "sub" else None,
"downloaded_dub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "dub" else None,
"thumbnail_path": None,
"thumbnail_checked_at": None,
}
@@ -2029,8 +2131,6 @@ def queue_watchlist_auto_download(item, refresh_source="manual"):
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()
@@ -2039,16 +2139,15 @@ def queue_watchlist_auto_download(item, refresh_source="manual"):
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"}
downloaded_episodes = set(item.get("downloaded_dub_episodes", []) if mode == "dub" else item.get("downloaded_sub_episodes", []))
if not available_episodes:
return {"queued": False, "reason": "mode_has_no_available_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]
missing = [episode for episode in available_episodes if episode not in downloaded_episodes and episode not in covered]
specs = episode_specs_for_values(missing, available_episodes)
if not specs:
return {"queued": False, "reason": "already_covered"}
return {"queued": False, "reason": "nothing_missing"}
query = str(item.get("title") or item.get("show_id") or "").strip()
if not query:
@@ -2139,7 +2238,7 @@ def sync_downloaded_job_to_watchlist(job):
if not show_id:
debug_log("watchlist.sync.resolve_failed", job=job, reason=reason)
raise ValueError(f"Could not resolve a watchlist show_id for the completed download: {reason}.")
return WATCHLIST.mark_downloaded(show_id, title=title)
return WATCHLIST.mark_downloaded(show_id, title=title, mode=job.get("mode"), episodes=job.get("episodes"))
def prepare_download_job(job):
config = ensure_runtime()["config"]
+1 -1
View File
@@ -19,7 +19,7 @@ from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web"
VERSION = "0.39.1"
VERSION = "0.40.0"
ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to"
+45 -4
View File
@@ -989,13 +989,14 @@ class WatchlistCompletionTests(unittest.TestCase):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
):
item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show")
), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3"]):
item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show", mode="dub", episodes="2-3")
self.assertEqual(item["category"], "finished")
self.assertTrue(item["downloaded"])
self.assertEqual(item["finished_badge"], "downloaded")
self.assertEqual(item["status"], "queued")
self.assertEqual(item["downloaded_dub_episodes"], ["2", "3"])
def test_mark_downloaded_creates_missing_show_in_finished_category(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object(
@@ -1069,7 +1070,7 @@ class WatchlistCompletionTests(unittest.TestCase):
) as mark_downloaded:
APP.sync_downloaded_job_to_watchlist(job)
mark_downloaded.assert_called_once_with("show-42", title="Queue Show")
mark_downloaded.assert_called_once_with("show-42", title="Queue Show", mode="sub", episodes=None)
def test_prepare_download_job_persists_resolved_show_id(self):
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show", "show_id": ""}
@@ -1511,7 +1512,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
"title": "Queue Show",
"category": "watching",
"previous_last_checked": "2026-05-25T10:00:00+00:00",
"had_new_episodes": True,
"had_new_episodes": False,
"previous_dub_count": 10,
"previous_sub_count": 10,
"dub_episode_values": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"],
@@ -1520,6 +1521,8 @@ class AutoDownloadQueueTests(unittest.TestCase):
"auto_download_quality": "best",
"auto_download_name": 'Queue Show: Alt/Name',
"auto_download_series": "3",
"downloaded_dub_episodes": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
"downloaded_sub_episodes": [],
}
download_queue = mock.Mock()
download_queue.covered_episodes.return_value = {"11"}
@@ -1559,6 +1562,44 @@ class AutoDownloadQueueTests(unittest.TestCase):
}
)
def test_queue_watchlist_auto_download_can_queue_existing_backlog_after_initial_seed_refresh(self):
item = {
"show_id": "show-99",
"title": "Backlog Show",
"category": "watching",
"previous_last_checked": "2026-05-25T10:00:00+00:00",
"had_new_episodes": False,
"dub_episode_values": ["1", "2", "3"],
"sub_episode_values": [],
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": "Backlog Show",
"auto_download_series": "1",
"downloaded_dub_episodes": [],
"downloaded_sub_episodes": [],
}
download_queue = mock.Mock()
download_queue.covered_episodes.return_value = set()
download_queue.add.return_value = {"id": "job-backlog", "episodes": "1-3"}
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-99", "title": "Backlog Show", "query": "Backlog Show", "index": 1}],
):
result = APP.queue_watchlist_auto_download(item, refresh_source="manual")
self.assertTrue(result["queued"])
self.assertEqual(result["episodes"], ["1-3"])
class StartupBehaviorTests(unittest.TestCase):
def test_main_does_not_eagerly_initialize_runtime(self):