diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e11f2..3b24891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.52.7 - 2026-08-09 + +- Added a Subbed/Dubbed selector to the Search result action window. +- Saved the selected Search action mode for Watchlist auto-download settings and Queue jobs. + ## 0.52.6 - 2026-08-09 - Fixed Docker search failures by pinning Cheerio to the Node 18-compatible `1.0.0` release. diff --git a/README.md b/README.md index 77a32b2..3dbc044 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A - Prefer provider-supplied search artwork, with a local title-based thumbnail fallback when provider artwork is missing or broken. - Queue single episodes or batches in subbed or dubbed mode. - Monitor active Queue jobs with per-episode progress, segment counts, and live downloader output. -- Open result actions in a floating window for watchlist, media type, library name, season, episode range, and download folder choices. +- Open result actions in a floating window for watchlist, media type, subbed/dubbed mode, library name, season, episode range, and download folder choices. - Fall back across the other configured providers when an episode or stream cannot be resolved on the primary provider. - Save files with Jellyfin-friendly layout: `TV/Series Name/Season 01/Series Name - S01E01.mp4`. - Save English external subtitles when the provider exposes usable subtitle tracks. diff --git a/VERSION b/VERSION index f4b8938..69b61ad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.6 +0.52.7 diff --git a/app.py b/app.py index 443baf5..16f891a 100644 --- a/app.py +++ b/app.py @@ -2058,6 +2058,9 @@ class WatchlistStore: "downloaded_dub_episodes_json": None, } item["auto_download_series"] = normalize_season(payload.get("auto_download_series") or item.get("auto_download_series") or "1") + requested_mode = str(payload.get("auto_download_mode") or item.get("auto_download_mode") or "").strip().lower() + if requested_mode in MODE_CHOICES: + item["auto_download_mode"] = requested_mode item["auto_download_offset"] = normalize_episode_offset(payload.get("auto_download_offset")) item["auto_download_source_name"] = ( str(payload.get("auto_download_source_name") or item.get("auto_download_source_name") or title).strip() or title diff --git a/search_page.py b/search_page.py index d7308e7..7676dfb 100644 --- a/search_page.py +++ b/search_page.py @@ -508,17 +508,25 @@ INDEX_HTML = r"""
+ +
+
-
-
+
+
@@ -653,32 +661,23 @@ INDEX_HTML = r""" $("resultsMeta").textContent = `${results.length} matching anime`; } - async function selectResult(item, button) { + function modeAvailable(item, mode) { + return Boolean(item && item[`has_${mode}`]); + } + + async function loadSelectedEpisodes(mode) { const requestId = ++state.episodeRequestId; - document.querySelectorAll(".result-card").forEach((node) => node.classList.remove("active")); - button.classList.add("active"); - state.selected = item; state.episodes = []; - const preferredMode = state.config.mode === "dub" ? "dub" : "sub"; - const selectedMode = item[`has_${preferredMode}`] - ? preferredMode - : item.has_sub - ? "sub" - : item.has_dub - ? "dub" - : preferredMode; + const selectedMode = mode === "dub" ? "dub" : "sub"; state.config.mode = selectedMode; - $("selectedTitle").textContent = item.title; - $("animeNameInput").value = item.title; - $("seasonInput").value = $("seasonInput").value || "1"; - $("selectedMeta").textContent = "Loading episodes..."; + $("selectionMode").value = selectedMode; + $("selectedMeta").textContent = `Loading ${selectedMode.toUpperCase()} episodes...`; $("episodeChips").innerHTML = ""; + $("episodesInput").value = ""; $("addBtn").disabled = true; - $("trackBtn").disabled = false; - openSelectionModal(); try { - const data = await api(`/api/anime/${encodeURIComponent(item.id)}/episodes?mode=${selectedMode}`); - if (requestId !== state.episodeRequestId || state.selected !== item) return; + const data = await api(`/api/anime/${encodeURIComponent(state.selected.id)}/episodes?mode=${selectedMode}`); + if (requestId !== state.episodeRequestId || !state.selected) return; state.episodes = data.episodes; const first = state.episodes[0] || ""; const last = state.episodes[state.episodes.length - 1] || ""; @@ -687,11 +686,34 @@ INDEX_HTML = r""" renderEpisodeChips(); $("addBtn").disabled = !state.episodes.length; } catch (error) { - if (requestId !== state.episodeRequestId || state.selected !== item) return; + if (requestId !== state.episodeRequestId || !state.selected) return; $("selectedMeta").textContent = error.message; } } + async function selectResult(item, button) { + document.querySelectorAll(".result-card").forEach((node) => node.classList.remove("active")); + button.classList.add("active"); + state.selected = item; + const preferredMode = state.config.mode === "dub" ? "dub" : "sub"; + const selectedMode = item[`has_${preferredMode}`] + ? preferredMode + : item.has_sub + ? "sub" + : item.has_dub + ? "dub" + : preferredMode; + $("selectionMode").value = selectedMode; + $("selectionMode").querySelector('option[value="sub"]').disabled = !modeAvailable(item, "sub"); + $("selectionMode").querySelector('option[value="dub"]').disabled = !modeAvailable(item, "dub"); + $("selectedTitle").textContent = item.title; + $("animeNameInput").value = item.title; + $("seasonInput").value = $("seasonInput").value || "1"; + $("trackBtn").disabled = false; + openSelectionModal(); + await loadSelectedEpisodes(selectedMode); + } + function renderEpisodeChips() { const el = $("episodeChips"); el.innerHTML = ""; @@ -743,6 +765,7 @@ INDEX_HTML = r""" title: state.selected.title, category: $("trackCategory").value, media_type: $("trackMediaType").value, + auto_download_mode: $("selectionMode").value, auto_download_series: $("seasonInput").value || "1" }) }); @@ -764,7 +787,7 @@ INDEX_HTML = r""" anime_name: $("animeNameInput").value || state.selected.title, media_type: $("trackMediaType").value, season: $("seasonInput").value || "1", - mode: state.config.mode, + mode: $("selectionMode").value, quality: state.config.quality, episodes: $("episodesInput").value, download_dir: $("jobFolder").value || state.config.download_dir @@ -793,6 +816,9 @@ INDEX_HTML = r""" $("trackBtn").addEventListener("click", addSelectedToWatchlist); $("addBtn").addEventListener("click", addSelected); $("closeSelectionBtn").addEventListener("click", closeSelectionModal); + $("selectionMode").addEventListener("change", () => { + if (state.selected) loadSelectedEpisodes($("selectionMode").value); + }); selectionModal.addEventListener("click", (event) => { if (event.target === selectionModal) closeSelectionModal(); }); diff --git a/test_app.py b/test_app.py index bea9ebe..cfbd96e 100644 --- a/test_app.py +++ b/test_app.py @@ -1642,6 +1642,19 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(item["provider"], "pahe") self.assertEqual(item["provider_label"], "AnimePahe") + def test_watchlist_add_uses_selected_auto_download_mode(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": "anikoto:mode-show", + "provider": "anikoto", + "title": "Mode Show", + "auto_download_mode": "sub", + } + ) + + self.assertEqual(result["item"]["auto_download_mode"], "sub") + def test_watchlist_source_url_uses_anikoto_show_page(self): self.seed_watchlist_item(show_id="anikoto:provider-show", title="Provider Show", provider="anikoto") @@ -4041,6 +4054,12 @@ class TemplateHelperTests(unittest.TestCase): self.assertIn('setNotice(data.message || "Added to watchlist.");\n closeSelectionModal();', APP.INDEX_HTML) self.assertIn('setNotice("Added to queue. Open Queue to monitor progress.");\n closeSelectionModal();', APP.INDEX_HTML) + def test_search_page_selection_modal_controls_download_mode(self): + self.assertIn('select id="selectionMode"', APP.INDEX_HTML) + self.assertIn('auto_download_mode: $("selectionMode").value', APP.INDEX_HTML) + self.assertIn('mode: $("selectionMode").value', APP.INDEX_HTML) + self.assertIn('loadSelectedEpisodes($("selectionMode").value)', APP.INDEX_HTML) + def test_queue_page_includes_clear_failed_bulk_action(self): self.assertIn('id="clearFailedBtn"', APP.QUEUE_HTML) self.assertIn('/api/queue/clear-failed', APP.QUEUE_HTML)