Improve auto-download title and series controls
This commit is contained in:
@@ -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,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:
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user