diff --git a/CHANGELOG.md b/CHANGELOG.md index efe3aa3..b52c2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 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. +- Added a per-anime `Series` field to auto-download settings so queued episodes can target a chosen series/season number instead of always using `1`. + ## 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. diff --git a/README.md b/README.md index 616afd0..5075f83 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Small local web UI for a system-wide `ani-cli` install. -Current version: `0.39.0` +Current version: `0.39.1` ## Project Layout @@ -224,7 +224,7 @@ Main behavior: 7. Summary cards show total tracked shows plus `sub` and `dub` counts. 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. -10. `Auto-download` opens per-series settings for language, quality, and the sanitized library name used for automatically queued episodes. +10. `Auto-download` opens per-series settings for language, quality, series number, and the sanitized library name used for automatically queued episodes. Status behavior: diff --git a/VERSION b/VERSION index 4ef2eb0..d2e2400 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.39.0 +0.39.1 diff --git a/app.py b/app.py index c50125e..69b3a55 100644 --- a/app.py +++ b/app.py @@ -52,6 +52,7 @@ from app_support import ( debug_log, load_json, migrate_legacy_state_storage, + normalize_season, normalize_config, now_iso, sanitize_path_component, @@ -1060,6 +1061,7 @@ class WatchlistStore: "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"), + "auto_download_series": "1", } def _connect(self): @@ -1136,6 +1138,7 @@ class WatchlistStore: auto_download_mode TEXT, auto_download_quality TEXT, auto_download_name TEXT, + auto_download_series TEXT, thumbnail_path TEXT, thumbnail_checked_at TEXT ) @@ -1176,6 +1179,8 @@ class WatchlistStore: 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") + if "auto_download_series" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_series TEXT") conn.execute( "UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''" ) @@ -1212,6 +1217,7 @@ class WatchlistStore: 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"]) + item["auto_download_series"] = normalize_season(item.get("auto_download_series") or defaults["auto_download_series"]) 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}" @@ -1234,9 +1240,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, + auto_download_mode, auto_download_quality, auto_download_name, auto_download_series, thumbnail_path, thumbnail_checked_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(show_id) DO UPDATE SET title = excluded.title, category = excluded.category, @@ -1262,6 +1268,7 @@ class WatchlistStore: auto_download_mode = excluded.auto_download_mode, auto_download_quality = excluded.auto_download_quality, auto_download_name = excluded.auto_download_name, + auto_download_series = excluded.auto_download_series, thumbnail_path = excluded.thumbnail_path, thumbnail_checked_at = excluded.thumbnail_checked_at """, @@ -1292,6 +1299,7 @@ class WatchlistStore: item.get("auto_download_mode"), item.get("auto_download_quality"), item.get("auto_download_name"), + item.get("auto_download_series"), item.get("thumbnail_path"), item.get("thumbnail_checked_at"), ), @@ -1344,6 +1352,7 @@ class WatchlistStore: "auto_download_mode": None, "auto_download_quality": None, "auto_download_name": None, + "auto_download_series": None, "thumbnail_path": None, "thumbnail_checked_at": None, }, @@ -1741,6 +1750,7 @@ class WatchlistStore: "alternative_titles_json": None, "anidb_aid": None, "anidb_title": None, + "auto_download_series": None, "thumbnail_path": None, "thumbnail_checked_at": None, } @@ -1748,7 +1758,7 @@ class WatchlistStore: 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): + def update_auto_download_settings(self, show_id, mode=None, quality=None, name=None, series=None): existing = self.get(show_id) defaults = self._auto_download_defaults(existing["title"]) normalized_mode = str(mode or defaults["auto_download_mode"]).strip().lower() @@ -1758,14 +1768,15 @@ class WatchlistStore: 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"]) + normalized_series = normalize_season(series or existing.get("auto_download_series") or defaults["auto_download_series"]) with self.lock, self._connect() as conn: conn.execute( """ UPDATE watchlist - SET auto_download_mode = ?, auto_download_quality = ?, auto_download_name = ?, updated_at = ? + SET auto_download_mode = ?, auto_download_quality = ?, auto_download_name = ?, auto_download_series = ?, updated_at = ? WHERE show_id = ? """, - (normalized_mode, normalized_quality, normalized_name, now_iso(), existing["show_id"]), + (normalized_mode, normalized_quality, normalized_name, normalized_series, now_iso(), existing["show_id"]), ) return self.get(show_id) @@ -2051,6 +2062,7 @@ def queue_watchlist_auto_download(item, refresh_source="manual"): 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"), ) + series = normalize_season(item.get("auto_download_series") or "1") jobs = [] for episode_spec in specs: job = runtime["download_queue"].add( @@ -2059,7 +2071,7 @@ def queue_watchlist_auto_download(item, refresh_source="manual"): "query": match.get("query") or query, "title": match.get("title") or item["title"], "anime_name": anime_name, - "season": "1", + "season": series, "index": match.get("index", 1), "mode": mode, "quality": quality, @@ -2077,9 +2089,9 @@ def update_watchlist_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): +def update_watchlist_auto_download(show_id, mode=None, quality=None, name=None, series=None): ensure_runtime() - item = WATCHLIST.update_auto_download_settings(show_id, mode=mode, quality=quality, name=name) + item = WATCHLIST.update_auto_download_settings(show_id, mode=mode, quality=quality, name=name, series=series) return {"message": f"Saved auto-download settings for {item['title']}.", "item": item} diff --git a/app_support.py b/app_support.py index 3fc7d22..4be289b 100644 --- a/app_support.py +++ b/app_support.py @@ -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.0" +VERSION = "0.39.1" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/http_handler.py b/http_handler.py index f93dfc8..29687f7 100644 --- a/http_handler.py +++ b/http_handler.py @@ -529,6 +529,7 @@ class Handler(BaseHTTPRequestHandler): payload.get("mode"), payload.get("quality"), payload.get("name"), + payload.get("series"), ) ) elif parsed.path == "/api/watchlist/update-all": diff --git a/test_app.py b/test_app.py index d505678..a24ab5f 100644 --- a/test_app.py +++ b/test_app.py @@ -1519,6 +1519,7 @@ class AutoDownloadQueueTests(unittest.TestCase): "auto_download_mode": "dub", "auto_download_quality": "best", "auto_download_name": 'Queue Show: Alt/Name', + "auto_download_series": "3", } download_queue = mock.Mock() download_queue.covered_episodes.return_value = {"11"} @@ -1549,7 +1550,7 @@ class AutoDownloadQueueTests(unittest.TestCase): "query": "Queue Show", "title": "Queue Show", "anime_name": "Queue Show- Alt-Name", - "season": "1", + "season": "3", "index": 2, "mode": "dub", "quality": "best", @@ -1841,7 +1842,7 @@ class HandlerRouteTests(unittest.TestCase): 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"}' + body = b'{"show_id":"show-1","mode":"dub","quality":"best","name":"Example Name","series":"2"}' 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)) @@ -1947,6 +1948,9 @@ class TemplateHelperTests(unittest.TestCase): 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) + self.assertIn("Name source", APP.WATCHLIST_HTML) + self.assertIn("Custom", APP.WATCHLIST_HTML) + self.assertIn("Series", APP.WATCHLIST_HTML) def test_watchlist_thumbnail_warmup_skips_repeated_attempts(self): self.assertIn("thumbnailWarmupPending: new Set()", APP.WATCHLIST_HTML) diff --git a/watchlist_page.py b/watchlist_page.py index a056dfd..ac86055 100644 --- a/watchlist_page.py +++ b/watchlist_page.py @@ -1127,9 +1127,14 @@ WATCHLIST_HTML = r""" + +
@@ -1211,16 +1216,32 @@ WATCHLIST_HTML = r""" const autoDownloadPanel = card.querySelector(".auto-download-panel"); const autoDownloadMode = card.querySelector(".auto-download-mode"); const autoDownloadQuality = card.querySelector(".auto-download-quality"); + const autoDownloadSeries = card.querySelector(".auto-download-series"); + const autoDownloadNameSelect = card.querySelector(".auto-download-name-select"); 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) : [])]; + const nameSuggestions = []; + for (const name of [item.title, ...(Array.isArray(item.alternative_titles) ? item.alternative_titles.map((entry) => entry.value) : [])]) { + if (name && !nameSuggestions.includes(name)) nameSuggestions.push(name); + } for (const name of nameSuggestions) { const option = document.createElement("option"); option.value = name; - autoDownloadNameOptions.appendChild(option); + option.textContent = name; + autoDownloadNameSelect.appendChild(option); } + const customOption = document.createElement("option"); + customOption.value = "__custom__"; + customOption.textContent = "Custom"; + autoDownloadNameSelect.appendChild(customOption); + const matchedSuggestion = nameSuggestions.find((name) => name === (item.auto_download_name || "")); + autoDownloadNameSelect.value = matchedSuggestion || "__custom__"; + autoDownloadNameSelect.addEventListener("change", () => { + if (autoDownloadNameSelect.value !== "__custom__") { + autoDownloadName.value = autoDownloadNameSelect.value; + } + }); const categorySelect = card.querySelector(".category-select"); categorySelect.addEventListener("change", async () => { categorySelect.disabled = true; @@ -1256,10 +1277,14 @@ WATCHLIST_HTML = r""" show_id: item.show_id, mode: autoDownloadMode.value, quality: autoDownloadQuality.value, - name: autoDownloadName.value + name: autoDownloadName.value, + series: autoDownloadSeries.value }) }); autoDownloadName.value = (data.item || {}).auto_download_name || autoDownloadName.value; + autoDownloadSeries.value = (data.item || {}).auto_download_series || autoDownloadSeries.value; + const savedName = (data.item || {}).auto_download_name || ""; + autoDownloadNameSelect.value = nameSuggestions.includes(savedName) ? savedName : "__custom__"; setNotice(data.message || "Auto-download settings saved."); } catch (error) { setNotice(error.message);