Improve auto-download title and series controls

This commit is contained in:
Dymas
2026-05-25 10:02:32 +02:00
parent 3d42657837
commit baea85e24c
8 changed files with 67 additions and 20 deletions
+5
View File
@@ -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.
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -1 +1 @@
0.39.0
0.39.1
+20 -8
View File
@@ -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}
+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.0"
VERSION = "0.39.1"
ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to"
+1
View File
@@ -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":
+6 -2
View File
@@ -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)
+31 -6
View File
@@ -1127,9 +1127,14 @@ WATCHLIST_HTML = r"""<!doctype html>
<label>Quality
<select class="auto-download-quality">${qualityOptions.replace(`value="${item.auto_download_quality}"`, `value="${item.auto_download_quality}" selected`)}</select>
</label>
<label>Series
<input class="auto-download-series" type="number" min="1" step="1" value="${item.auto_download_series || "1"}">
</label>
<label>Name source
<select class="auto-download-name-select"></select>
</label>
<label>Name
<input class="auto-download-name" type="text" list="auto-download-name-options-${item.show_id}">
<datalist id="auto-download-name-options-${item.show_id}"></datalist>
<input class="auto-download-name" type="text">
</label>
</div>
<div class="auto-download-actions">
@@ -1211,16 +1216,32 @@ WATCHLIST_HTML = r"""<!doctype html>
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"""<!doctype html>
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);