Add watchlist episode offset naming

This commit is contained in:
Dymas
2026-05-25 17:57:42 +02:00
parent d10ae832b3
commit 75262d261d
8 changed files with 210 additions and 14 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.45.0 - 2026-05-25
- Added an optional per-anime auto-download episode offset for split-part seasons, so queued watchlist downloads can rename files as `S02E13`, `S02E14`, and onward while still downloading the underlying source episodes normally.
- Added a migration-safe `auto_download_offset` watchlist column, exposed it in the watchlist auto-download editor, and applied it to both manual watchlist `Download all` jobs and automatic watchlist episode downloads.
## 0.44.1 - 2026-05-25 ## 0.44.1 - 2026-05-25
- Limited bulk watchlist refreshes to entries marked `Watching` or `Planned`, so manual `Refresh All` runs and scheduled refreshes skip `Finished` and `Dropped` shows. - Limited bulk watchlist refreshes to entries marked `Watching` or `Planned`, so manual `Refresh All` runs and scheduled refreshes skip `Finished` and `Dropped` shows.
+7 -2
View File
@@ -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.44.1` Current version: `0.45.0`
## Project Layout ## Project Layout
@@ -242,11 +242,16 @@ 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` opens a centered overlay with English alternate titles from AniDB when the watchlist refresh can match the show to an AniDB entry. 9. `Alternative titles` opens a centered overlay with English alternate titles from AniDB when the watchlist refresh can match the show to an AniDB entry.
10. `Auto-download` opens a centered overlay with per-series settings for language, quality, series number, and the sanitized library name used for automatically queued episodes. 10. `Auto-download` opens a centered overlay with per-series settings for language, quality, series number, optional episode offset, and the sanitized library name used for automatically queued episodes.
Auto-download layout note: `Name source` and `Name` share a wider half-and-half row inside the modal for easier editing. Auto-download layout note: `Name source` and `Name` share a wider half-and-half row inside the modal for easier editing.
11. Adding a show from Search reuses the current `Season` field as the initial auto-download series value for that new watchlist entry. 11. Adding a show from Search reuses the current `Season` field as the initial auto-download series value for that new watchlist entry.
12. Each watchlist entry can be tagged as `TV` or `Movie`, which changes where finished downloads are stored. 12. Each watchlist entry can be tagged as `TV` or `Movie`, which changes where finished downloads are stored.
Episode offset:
- Leave it empty to keep normal episode numbering.
- Set it to a starting number such as `13` when a split season continues inside a new folder, so downloaded files are named like `Show Name - S02E13`, `Show Name - S02E14`, and so on.
Status behavior: Status behavior:
- `Sub` and `Dub` pills turn green when that track looks complete. - `Sub` and `Dub` pills turn green when that track looks complete.
+1 -1
View File
@@ -1 +1 @@
0.44.1 0.45.0
+32 -7
View File
@@ -54,6 +54,7 @@ from app_support import (
migrate_legacy_state_storage, migrate_legacy_state_storage,
normalize_season, normalize_season,
normalize_config, normalize_config,
normalize_episode_offset,
normalize_media_type, normalize_media_type,
now_iso, now_iso,
sanitize_path_component, sanitize_path_component,
@@ -1217,6 +1218,7 @@ class WatchlistStore:
"auto_download_quality": str(config.get("auto_download_quality") or "best").strip().lower() or "best", "auto_download_quality": str(config.get("auto_download_quality") or "best").strip().lower() or "best",
"auto_download_name": sanitize_path_component(title, "Anime"), "auto_download_name": sanitize_path_component(title, "Anime"),
"auto_download_series": "1", "auto_download_series": "1",
"auto_download_offset": None,
"media_type": "tv", "media_type": "tv",
} }
@@ -1296,6 +1298,7 @@ class WatchlistStore:
auto_download_quality TEXT, auto_download_quality TEXT,
auto_download_name TEXT, auto_download_name TEXT,
auto_download_series TEXT, auto_download_series TEXT,
auto_download_offset TEXT,
downloaded_sub_episodes_json TEXT, downloaded_sub_episodes_json TEXT,
downloaded_dub_episodes_json TEXT, downloaded_dub_episodes_json TEXT,
thumbnail_path TEXT, thumbnail_path TEXT,
@@ -1342,6 +1345,8 @@ class WatchlistStore:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_name TEXT") conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_name TEXT")
if "auto_download_series" not in columns: if "auto_download_series" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_series TEXT") conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_series TEXT")
if "auto_download_offset" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_offset TEXT")
if "downloaded_sub_episodes_json" not in columns: if "downloaded_sub_episodes_json" not in columns:
conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_sub_episodes_json TEXT") conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_sub_episodes_json TEXT")
if "downloaded_dub_episodes_json" not in columns: if "downloaded_dub_episodes_json" not in columns:
@@ -1388,6 +1393,7 @@ class WatchlistStore:
item["auto_download_quality"] = quality if quality in QUALITY_CHOICES else defaults["auto_download_quality"] 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_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["auto_download_series"] = normalize_season(item.get("auto_download_series") or defaults["auto_download_series"])
item["auto_download_offset"] = normalize_episode_offset(item.get("auto_download_offset"))
item["downloaded_sub_episodes"] = decode_episode_values(item.get("downloaded_sub_episodes_json")) 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")) item["downloaded_dub_episodes"] = decode_episode_values(item.get("downloaded_dub_episodes_json"))
if item.get("animeschedule_route"): if item.get("animeschedule_route"):
@@ -1412,10 +1418,10 @@ 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, media_type, anidb_aid, anidb_title, media_type,
auto_download_mode, auto_download_quality, auto_download_name, auto_download_series, auto_download_mode, auto_download_quality, auto_download_name, auto_download_series, auto_download_offset,
downloaded_sub_episodes_json, downloaded_dub_episodes_json, downloaded_sub_episodes_json, downloaded_dub_episodes_json,
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,
@@ -1443,6 +1449,7 @@ class WatchlistStore:
auto_download_quality = excluded.auto_download_quality, auto_download_quality = excluded.auto_download_quality,
auto_download_name = excluded.auto_download_name, auto_download_name = excluded.auto_download_name,
auto_download_series = excluded.auto_download_series, auto_download_series = excluded.auto_download_series,
auto_download_offset = excluded.auto_download_offset,
downloaded_sub_episodes_json = excluded.downloaded_sub_episodes_json, downloaded_sub_episodes_json = excluded.downloaded_sub_episodes_json,
downloaded_dub_episodes_json = excluded.downloaded_dub_episodes_json, downloaded_dub_episodes_json = excluded.downloaded_dub_episodes_json,
thumbnail_path = excluded.thumbnail_path, thumbnail_path = excluded.thumbnail_path,
@@ -1477,6 +1484,7 @@ class WatchlistStore:
item.get("auto_download_quality"), item.get("auto_download_quality"),
item.get("auto_download_name"), item.get("auto_download_name"),
item.get("auto_download_series"), item.get("auto_download_series"),
normalize_episode_offset(item.get("auto_download_offset")),
item.get("downloaded_sub_episodes_json"), item.get("downloaded_sub_episodes_json"),
item.get("downloaded_dub_episodes_json"), item.get("downloaded_dub_episodes_json"),
item.get("thumbnail_path"), item.get("thumbnail_path"),
@@ -1533,6 +1541,7 @@ class WatchlistStore:
"auto_download_quality": None, "auto_download_quality": None,
"auto_download_name": None, "auto_download_name": None,
"auto_download_series": None, "auto_download_series": None,
"auto_download_offset": None,
"downloaded_sub_episodes_json": None, "downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": None, "downloaded_dub_episodes_json": None,
"thumbnail_path": None, "thumbnail_path": None,
@@ -1684,6 +1693,7 @@ class WatchlistStore:
"downloaded_dub_episodes_json": None, "downloaded_dub_episodes_json": None,
} }
item["auto_download_series"] = normalize_season(payload.get("auto_download_series") or item.get("auto_download_series") or "1") item["auto_download_series"] = normalize_season(payload.get("auto_download_series") or item.get("auto_download_series") or "1")
item["auto_download_offset"] = normalize_episode_offset(payload.get("auto_download_offset"))
self._upsert_conn(conn, item) self._upsert_conn(conn, item)
queued = self.schedule_refresh(show_id, source="initial") queued = self.schedule_refresh(show_id, source="initial")
@@ -2002,7 +2012,7 @@ class WatchlistStore:
self.schedule_refresh(normalized_show_id, source="sync") 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, series=None): def update_auto_download_settings(self, show_id, mode=None, quality=None, name=None, series=None, episode_offset=None):
existing = self.get(show_id) existing = self.get(show_id)
defaults = self._auto_download_defaults(existing["title"]) defaults = self._auto_download_defaults(existing["title"])
normalized_mode = str(mode or defaults["auto_download_mode"]).strip().lower() normalized_mode = str(mode or defaults["auto_download_mode"]).strip().lower()
@@ -2013,14 +2023,19 @@ class WatchlistStore:
raise ValueError("Unknown auto-download quality.") raise ValueError("Unknown auto-download quality.")
normalized_name = sanitize_path_component(name or defaults["auto_download_name"], defaults["auto_download_name"]) normalized_name = sanitize_path_component(name or defaults["auto_download_name"], defaults["auto_download_name"])
normalized_series = normalize_season(series or existing.get("auto_download_series") or defaults["auto_download_series"]) normalized_series = normalize_season(series or existing.get("auto_download_series") or defaults["auto_download_series"])
normalized_offset = (
normalize_episode_offset(existing.get("auto_download_offset"))
if episode_offset is None
else normalize_episode_offset(episode_offset)
)
with self.lock, self._connect() as conn: with self.lock, self._connect() as conn:
conn.execute( conn.execute(
""" """
UPDATE watchlist UPDATE watchlist
SET auto_download_mode = ?, auto_download_quality = ?, auto_download_name = ?, auto_download_series = ?, updated_at = ? SET auto_download_mode = ?, auto_download_quality = ?, auto_download_name = ?, auto_download_series = ?, auto_download_offset = ?, updated_at = ?
WHERE show_id = ? WHERE show_id = ?
""", """,
(normalized_mode, normalized_quality, normalized_name, normalized_series, now_iso(), existing["show_id"]), (normalized_mode, normalized_quality, normalized_name, normalized_series, normalized_offset, now_iso(), existing["show_id"]),
) )
return self.get(show_id) return self.get(show_id)
@@ -2248,6 +2263,7 @@ def download_watchlist_item(show_id, mode):
"anime_name": item.get("auto_download_name") or item["title"], "anime_name": item.get("auto_download_name") or item["title"],
"media_type": item.get("media_type") or "tv", "media_type": item.get("media_type") or "tv",
"season": item.get("auto_download_series") or "1", "season": item.get("auto_download_series") or "1",
"episode_offset": item.get("auto_download_offset"),
"index": match.get("index", 1), "index": match.get("index", 1),
"mode": normalized_mode, "mode": normalized_mode,
"quality": runtime["config"]["quality"], "quality": runtime["config"]["quality"],
@@ -2305,6 +2321,7 @@ def queue_watchlist_auto_download(item, refresh_source="manual"):
sanitize_path_component(item.get("title") or match.get("title") or "Anime", "Anime"), sanitize_path_component(item.get("title") or match.get("title") or "Anime", "Anime"),
) )
series = normalize_season(item.get("auto_download_series") or "1") series = normalize_season(item.get("auto_download_series") or "1")
episode_offset = normalize_episode_offset(item.get("auto_download_offset"))
jobs = [] jobs = []
for episode_spec in specs: for episode_spec in specs:
job = runtime["download_queue"].add( job = runtime["download_queue"].add(
@@ -2315,6 +2332,7 @@ def queue_watchlist_auto_download(item, refresh_source="manual"):
"anime_name": anime_name, "anime_name": anime_name,
"media_type": item.get("media_type") or "tv", "media_type": item.get("media_type") or "tv",
"season": series, "season": series,
"episode_offset": episode_offset,
"index": match.get("index", 1), "index": match.get("index", 1),
"mode": mode, "mode": mode,
"quality": quality, "quality": quality,
@@ -2393,9 +2411,16 @@ def update_watchlist_media_type(show_id, media_type):
return {"message": f"Saved {item['title']} as {item['media_type_label']}.", "item": item} return {"message": f"Saved {item['title']} as {item['media_type_label']}.", "item": item}
def update_watchlist_auto_download(show_id, mode=None, quality=None, name=None, series=None): def update_watchlist_auto_download(show_id, mode=None, quality=None, name=None, series=None, episode_offset=None):
ensure_runtime() ensure_runtime()
item = WATCHLIST.update_auto_download_settings(show_id, mode=mode, quality=quality, name=name, series=series) item = WATCHLIST.update_auto_download_settings(
show_id,
mode=mode,
quality=quality,
name=name,
series=series,
episode_offset=episode_offset,
)
return {"message": f"Saved auto-download settings for {item['title']}.", "item": item} return {"message": f"Saved auto-download settings for {item['title']}.", "item": item}
+31 -1
View File
@@ -20,7 +20,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.44.1" VERSION = "0.45.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"
@@ -709,6 +709,18 @@ def normalize_season(value):
return str(number) return str(number)
def normalize_episode_offset(value):
text = str(value or "").strip()
if not text:
return None
if not re.match(r"^[0-9]+$", text):
raise ValueError("Episode offset must be a positive number")
number = int(text, 10)
if number < 1:
raise ValueError("Episode offset must be a positive number")
return str(number)
def normalize_media_type(value, default="tv"): def normalize_media_type(value, default="tv"):
media_type = str(value or default).strip().lower() media_type = str(value or default).strip().lower()
return media_type if media_type in MEDIA_TYPE_CHOICES else default return media_type if media_type in MEDIA_TYPE_CHOICES else default
@@ -741,6 +753,21 @@ def episode_label(value):
return re.sub(r"[^0-9A-Za-z.]+", "-", text).strip("-") or "00" return re.sub(r"[^0-9A-Za-z.]+", "-", text).strip("-") or "00"
def apply_episode_offset(value, offset):
normalized_offset = normalize_episode_offset(offset)
text = str(value or "").strip()
if not normalized_offset or not text:
return text
match = re.match(r"^0*([0-9]+)(.*)$", text)
if not match:
return text
number = int(match.group(1) or "0", 10)
if number < 1:
return text
suffix = match.group(2) or ""
return f"{int(normalized_offset, 10) + number - 1}{suffix}"
def extract_episode_from_filename(path): def extract_episode_from_filename(path):
match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem) match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem)
return match.group(1) if match else None return match.group(1) if match else None
@@ -763,6 +790,7 @@ def finalize_library_files(job):
library_name = sanitize_path_component(job.get("anime_name") or job.get("title")) library_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
media_type = normalize_media_type(job.get("media_type")) media_type = normalize_media_type(job.get("media_type"))
season = season_label(job) season = season_label(job)
episode_offset = normalize_episode_offset(job.get("episode_offset"))
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
files = sorted( files = sorted(
[path for path in staging_dir.iterdir() if path.is_file()], [path for path in staging_dir.iterdir() if path.is_file()],
@@ -781,6 +809,7 @@ def finalize_library_files(job):
if ep is None: if ep is None:
ep = str(fallback_episode) ep = str(fallback_episode)
fallback_episode += 1 fallback_episode += 1
ep = apply_episode_offset(ep, episode_offset)
final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}" final_name = f"{library_name} - S{season}E{episode_label(ep)}{source.suffix}"
destination = unique_destination(target_dir / final_name) destination = unique_destination(target_dir / final_name)
shutil.move(str(source), str(destination)) shutil.move(str(source), str(destination))
@@ -824,6 +853,7 @@ def build_job(payload, config):
"anime_name": anime_name, "anime_name": anime_name,
"media_type": media_type, "media_type": media_type,
"season": normalize_season(payload.get("season")), "season": normalize_season(payload.get("season")),
"episode_offset": normalize_episode_offset(payload.get("episode_offset")),
"query": query, "query": query,
"result_index": index, "result_index": index,
"mode": mode, "mode": mode,
+1
View File
@@ -617,6 +617,7 @@ class Handler(BaseHTTPRequestHandler):
payload.get("quality"), payload.get("quality"),
payload.get("name"), payload.get("name"),
payload.get("series"), payload.get("series"),
payload.get("episode_offset"),
) )
) )
elif parsed.path == "/api/watchlist/update-all": elif parsed.path == "/api/watchlist/update-all":
+123 -1
View File
@@ -365,6 +365,42 @@ class QueueApiTests(unittest.TestCase):
self.assertFalse(source.exists()) self.assertFalse(source.exists())
self.assertTrue(Path(moved[0]).exists()) self.assertTrue(Path(moved[0]).exists())
def test_tv_finalizer_applies_episode_offset_to_named_files(self):
with tempfile.TemporaryDirectory() as temp_root:
job = APP.app_support.build_job(
{
"query": "Split Season Show",
"title": "Split Season Show",
"anime_name": "Split Season Show",
"media_type": "tv",
"mode": "sub",
"quality": "best",
"episodes": "1-2",
"download_dir": temp_root,
"season": "2",
"episode_offset": "13",
},
{"mode": "sub", "quality": "best", "download_dir": temp_root},
)
staging_dir = APP.app_support.job_staging_dir(job)
staging_dir.mkdir(parents=True, exist_ok=True)
first = staging_dir / "Split Season Show Episode 1.mp4"
second = staging_dir / "Split Season Show Episode 2.mp4"
first.write_bytes(b"ep1")
second.write_bytes(b"ep2")
moved = APP.app_support.finalize_library_files(job)
self.assertEqual(
moved,
[
f"{temp_root}/tv/Split Season Show/Season 02/Split Season Show - S02E13.mp4",
f"{temp_root}/tv/Split Season Show/Season 02/Split Season Show - S02E14.mp4",
],
)
self.assertTrue(Path(moved[0]).exists())
self.assertTrue(Path(moved[1]).exists())
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self): def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
queue = object.__new__(APP.DownloadQueue) queue = object.__new__(APP.DownloadQueue)
queue.lock = threading.RLock() queue.lock = threading.RLock()
@@ -1113,6 +1149,14 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(result["item"]["auto_download_series"], "4") self.assertEqual(result["item"]["auto_download_series"], "4")
def test_add_uses_requested_auto_download_offset(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
result = APP.WATCHLIST.add(
{"show_id": "show-23b", "title": "Queued Show", "category": "watching", "auto_download_offset": "13"}
)
self.assertEqual(result["item"]["auto_download_offset"], "13")
def test_add_uses_requested_media_type(self): def test_add_uses_requested_media_type(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)): with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
result = APP.WATCHLIST.add( result = APP.WATCHLIST.add(
@@ -1202,6 +1246,7 @@ class WatchlistCompletionTests(unittest.TestCase):
"anime_name": "Queue Show Library", "anime_name": "Queue Show Library",
"media_type": "movie", "media_type": "movie",
"season": "4", "season": "4",
"episode_offset": None,
"index": 3, "index": 3,
"mode": "dub", "mode": "dub",
"quality": "best", "quality": "best",
@@ -1699,6 +1744,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
"auto_download_quality": "best", "auto_download_quality": "best",
"auto_download_name": 'Queue Show: Alt/Name', "auto_download_name": 'Queue Show: Alt/Name',
"auto_download_series": "3", "auto_download_series": "3",
"auto_download_offset": "13",
"downloaded_dub_episodes": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], "downloaded_dub_episodes": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
"downloaded_sub_episodes": [], "downloaded_sub_episodes": [],
} }
@@ -1733,6 +1779,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
"anime_name": "Queue Show- Alt-Name", "anime_name": "Queue Show- Alt-Name",
"media_type": "tv", "media_type": "tv",
"season": "3", "season": "3",
"episode_offset": "13",
"index": 2, "index": 2,
"mode": "dub", "mode": "dub",
"quality": "best", "quality": "best",
@@ -1754,6 +1801,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
"auto_download_quality": "best", "auto_download_quality": "best",
"auto_download_name": "Backlog Show", "auto_download_name": "Backlog Show",
"auto_download_series": "1", "auto_download_series": "1",
"auto_download_offset": None,
"downloaded_dub_episodes": [], "downloaded_dub_episodes": [],
"downloaded_sub_episodes": [], "downloaded_sub_episodes": [],
} }
@@ -1778,6 +1826,76 @@ class AutoDownloadQueueTests(unittest.TestCase):
self.assertTrue(result["queued"]) self.assertTrue(result["queued"])
self.assertEqual(result["episodes"], ["1-3"]) self.assertEqual(result["episodes"], ["1-3"])
download_queue.add.assert_called_once_with(
{
"show_id": "show-99",
"query": "Backlog Show",
"title": "Backlog Show",
"anime_name": "Backlog Show",
"media_type": "tv",
"season": "1",
"episode_offset": None,
"index": 1,
"mode": "dub",
"quality": "best",
"episodes": "1-3",
"download_dir": "/tmp/downloads",
}
)
def test_queue_watchlist_auto_download_passes_episode_offset_to_jobs(self):
item = {
"show_id": "show-77",
"title": "Split Season Show",
"category": "watching",
"previous_last_checked": "2026-05-25T10:00:00+00:00",
"had_new_episodes": False,
"dub_episode_values": ["1", "2"],
"sub_episode_values": [],
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": "Split Season Show",
"auto_download_series": "2",
"auto_download_offset": "13",
"downloaded_dub_episodes": [],
"downloaded_sub_episodes": [],
}
download_queue = mock.Mock()
download_queue.covered_episodes.return_value = set()
download_queue.add.return_value = {"id": "job-split", "episodes": "1-2"}
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-77", "title": "Split Season Show", "query": "Split Season Show", "index": 1}],
):
result = APP.queue_watchlist_auto_download(item, refresh_source="manual")
self.assertTrue(result["queued"])
download_queue.add.assert_called_once_with(
{
"show_id": "show-77",
"query": "Split Season Show",
"title": "Split Season Show",
"anime_name": "Split Season Show",
"media_type": "tv",
"season": "2",
"episode_offset": "13",
"index": 1,
"mode": "dub",
"quality": "best",
"episodes": "1-2",
"download_dir": "/tmp/downloads",
}
)
class StartupBehaviorTests(unittest.TestCase): class StartupBehaviorTests(unittest.TestCase):
@@ -2119,13 +2237,16 @@ class HandlerRouteTests(unittest.TestCase):
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): def test_watchlist_auto_download_settings_post_returns_updated_item(self):
body = b'{"show_id":"show-1","mode":"dub","quality":"best","name":"Example Name","series":"2"}' body = b'{"show_id":"show-1","mode":"dub","quality":"best","name":"Example Name","series":"2","episode_offset":"13"}'
handler = DummyHandler("/api/watchlist/update-auto-download", body=body, content_length=len(body)) 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"}} 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)) handler.handler_context = mock.Mock(update_watchlist_auto_download=mock.Mock(return_value=payload))
APP.Handler.do_POST(handler) APP.Handler.do_POST(handler)
self.assertEqual(handler.json_payload, payload) self.assertEqual(handler.json_payload, payload)
self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_status, HTTPStatus.OK)
handler.handler_context.update_watchlist_auto_download.assert_called_once_with(
"show-1", "dub", "best", "Example Name", "2", "13"
)
def test_watchlist_media_type_post_returns_updated_item(self): def test_watchlist_media_type_post_returns_updated_item(self):
body = b'{"show_id":"show-1","media_type":"movie"}' body = b'{"show_id":"show-1","media_type":"movie"}'
@@ -2273,6 +2394,7 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn('class="wide-half">Name', APP.WATCHLIST_HTML) self.assertIn('class="wide-half">Name', APP.WATCHLIST_HTML)
self.assertIn("Custom", APP.WATCHLIST_HTML) self.assertIn("Custom", APP.WATCHLIST_HTML)
self.assertIn("Series", APP.WATCHLIST_HTML) self.assertIn("Series", APP.WATCHLIST_HTML)
self.assertIn("Episode offset", 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)
+10 -2
View File
@@ -830,6 +830,9 @@ WATCHLIST_HTML = r"""<!doctype html>
<label>Series <label>Series
<input class="auto-download-series" id="autoDownloadSeries" type="number" min="1" step="1" value="1"> <input class="auto-download-series" id="autoDownloadSeries" type="number" min="1" step="1" value="1">
</label> </label>
<label>Episode offset
<input class="auto-download-offset" id="autoDownloadOffset" type="number" min="1" step="1" placeholder="Optional">
</label>
<label class="wide-half">Name source <label class="wide-half">Name source
<select class="auto-download-name-select" id="autoDownloadNameSelect"></select> <select class="auto-download-name-select" id="autoDownloadNameSelect"></select>
</label> </label>
@@ -883,6 +886,7 @@ WATCHLIST_HTML = r"""<!doctype html>
const autoDownloadMode = document.getElementById("autoDownloadMode"); const autoDownloadMode = document.getElementById("autoDownloadMode");
const autoDownloadQuality = document.getElementById("autoDownloadQuality"); const autoDownloadQuality = document.getElementById("autoDownloadQuality");
const autoDownloadSeries = document.getElementById("autoDownloadSeries"); const autoDownloadSeries = document.getElementById("autoDownloadSeries");
const autoDownloadOffset = document.getElementById("autoDownloadOffset");
const autoDownloadNameSelect = document.getElementById("autoDownloadNameSelect"); const autoDownloadNameSelect = document.getElementById("autoDownloadNameSelect");
const autoDownloadName = document.getElementById("autoDownloadName"); const autoDownloadName = document.getElementById("autoDownloadName");
const autoDownloadSaveBtn = document.getElementById("autoDownloadSaveBtn"); const autoDownloadSaveBtn = document.getElementById("autoDownloadSaveBtn");
@@ -1083,6 +1087,7 @@ WATCHLIST_HTML = r"""<!doctype html>
autoDownloadMode.value = item.auto_download_mode || "dub"; autoDownloadMode.value = item.auto_download_mode || "dub";
autoDownloadQuality.value = item.auto_download_quality || "best"; autoDownloadQuality.value = item.auto_download_quality || "best";
autoDownloadSeries.value = item.auto_download_series || "1"; autoDownloadSeries.value = item.auto_download_series || "1";
autoDownloadOffset.value = item.auto_download_offset || "";
populateAutoDownloadNameChoices(item); populateAutoDownloadNameChoices(item);
autoDownloadOverlay.hidden = false; autoDownloadOverlay.hidden = false;
} }
@@ -1118,7 +1123,8 @@ WATCHLIST_HTML = r"""<!doctype html>
mode: autoDownloadMode.value, mode: autoDownloadMode.value,
quality: autoDownloadQuality.value, quality: autoDownloadQuality.value,
name: autoDownloadName.value, name: autoDownloadName.value,
series: autoDownloadSeries.value series: autoDownloadSeries.value,
episode_offset: autoDownloadOffset.value
}) })
}); });
const savedItem = data.item || {}; const savedItem = data.item || {};
@@ -1127,10 +1133,12 @@ WATCHLIST_HTML = r"""<!doctype html>
auto_download_mode: savedItem.auto_download_mode || autoDownloadMode.value, auto_download_mode: savedItem.auto_download_mode || autoDownloadMode.value,
auto_download_quality: savedItem.auto_download_quality || autoDownloadQuality.value, auto_download_quality: savedItem.auto_download_quality || autoDownloadQuality.value,
auto_download_name: savedItem.auto_download_name || autoDownloadName.value, auto_download_name: savedItem.auto_download_name || autoDownloadName.value,
auto_download_series: savedItem.auto_download_series || autoDownloadSeries.value auto_download_series: savedItem.auto_download_series || autoDownloadSeries.value,
auto_download_offset: savedItem.auto_download_offset || ""
}; };
populateAutoDownloadNameChoices(state.autoDownloadDraft); populateAutoDownloadNameChoices(state.autoDownloadDraft);
autoDownloadSeries.value = state.autoDownloadDraft.auto_download_series || autoDownloadSeries.value; autoDownloadSeries.value = state.autoDownloadDraft.auto_download_series || autoDownloadSeries.value;
autoDownloadOffset.value = state.autoDownloadDraft.auto_download_offset || "";
autoDownloadStatus.textContent = data.message || "Auto-download settings saved."; autoDownloadStatus.textContent = data.message || "Auto-download settings saved.";
setNotice(data.message || "Auto-download settings saved."); setNotice(data.message || "Auto-download settings saved.");
closeAutoDownloadOverlay(); closeAutoDownloadOverlay();