Add search action mode selector

This commit is contained in:
Codex
2026-08-09 18:38:41 +02:00
parent d9ccf71901
commit 0641542c73
6 changed files with 79 additions and 26 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # 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 ## 0.52.6 - 2026-08-09
- Fixed Docker search failures by pinning Cheerio to the Node 18-compatible `1.0.0` release. - Fixed Docker search failures by pinning Cheerio to the Node 18-compatible `1.0.0` release.
+1 -1
View File
@@ -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. - 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. - Queue single episodes or batches in subbed or dubbed mode.
- Monitor active Queue jobs with per-episode progress, segment counts, and live downloader output. - 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. - 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 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. - Save English external subtitles when the provider exposes usable subtitle tracks.
+1 -1
View File
@@ -1 +1 @@
0.52.6 0.52.7
+3
View File
@@ -2058,6 +2058,9 @@ 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")
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_offset"] = normalize_episode_offset(payload.get("auto_download_offset"))
item["auto_download_source_name"] = ( item["auto_download_source_name"] = (
str(payload.get("auto_download_source_name") or item.get("auto_download_source_name") or title).strip() or title str(payload.get("auto_download_source_name") or item.get("auto_download_source_name") or title).strip() or title
+50 -24
View File
@@ -508,17 +508,25 @@ INDEX_HTML = r"""<!doctype html>
</label> </label>
</div> </div>
<div class="row"> <div class="row">
<label>Download mode
<select id="selectionMode">
<option value="sub">Subbed</option>
<option value="dub">Dubbed</option>
</select>
</label>
<label>Library name <label>Library name
<input id="animeNameInput" placeholder="Anime name"> <input id="animeNameInput" placeholder="Anime name">
</label> </label>
</div>
<div class="row">
<label>Season <label>Season
<input id="seasonInput" inputmode="numeric" pattern="[0-9]*" value="1"> <input id="seasonInput" inputmode="numeric" pattern="[0-9]*" value="1">
</label> </label>
</div>
<div class="row">
<label>Episodes <label>Episodes
<input id="episodesInput" placeholder="1-12"> <input id="episodesInput" placeholder="1-12">
</label> </label>
</div>
<div class="row">
<label>Download folder <label>Download folder
<input id="jobFolder" placeholder="/home/me/Downloads/Anime"> <input id="jobFolder" placeholder="/home/me/Downloads/Anime">
</label> </label>
@@ -653,32 +661,23 @@ INDEX_HTML = r"""<!doctype html>
$("resultsMeta").textContent = `${results.length} matching anime`; $("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; const requestId = ++state.episodeRequestId;
document.querySelectorAll(".result-card").forEach((node) => node.classList.remove("active"));
button.classList.add("active");
state.selected = item;
state.episodes = []; state.episodes = [];
const preferredMode = state.config.mode === "dub" ? "dub" : "sub"; const selectedMode = mode === "dub" ? "dub" : "sub";
const selectedMode = item[`has_${preferredMode}`]
? preferredMode
: item.has_sub
? "sub"
: item.has_dub
? "dub"
: preferredMode;
state.config.mode = selectedMode; state.config.mode = selectedMode;
$("selectedTitle").textContent = item.title; $("selectionMode").value = selectedMode;
$("animeNameInput").value = item.title; $("selectedMeta").textContent = `Loading ${selectedMode.toUpperCase()} episodes...`;
$("seasonInput").value = $("seasonInput").value || "1";
$("selectedMeta").textContent = "Loading episodes...";
$("episodeChips").innerHTML = ""; $("episodeChips").innerHTML = "";
$("episodesInput").value = "";
$("addBtn").disabled = true; $("addBtn").disabled = true;
$("trackBtn").disabled = false;
openSelectionModal();
try { try {
const data = await api(`/api/anime/${encodeURIComponent(item.id)}/episodes?mode=${selectedMode}`); const data = await api(`/api/anime/${encodeURIComponent(state.selected.id)}/episodes?mode=${selectedMode}`);
if (requestId !== state.episodeRequestId || state.selected !== item) return; if (requestId !== state.episodeRequestId || !state.selected) return;
state.episodes = data.episodes; state.episodes = data.episodes;
const first = state.episodes[0] || ""; const first = state.episodes[0] || "";
const last = state.episodes[state.episodes.length - 1] || ""; const last = state.episodes[state.episodes.length - 1] || "";
@@ -687,11 +686,34 @@ INDEX_HTML = r"""<!doctype html>
renderEpisodeChips(); renderEpisodeChips();
$("addBtn").disabled = !state.episodes.length; $("addBtn").disabled = !state.episodes.length;
} catch (error) { } catch (error) {
if (requestId !== state.episodeRequestId || state.selected !== item) return; if (requestId !== state.episodeRequestId || !state.selected) return;
$("selectedMeta").textContent = error.message; $("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() { function renderEpisodeChips() {
const el = $("episodeChips"); const el = $("episodeChips");
el.innerHTML = ""; el.innerHTML = "";
@@ -743,6 +765,7 @@ INDEX_HTML = r"""<!doctype html>
title: state.selected.title, title: state.selected.title,
category: $("trackCategory").value, category: $("trackCategory").value,
media_type: $("trackMediaType").value, media_type: $("trackMediaType").value,
auto_download_mode: $("selectionMode").value,
auto_download_series: $("seasonInput").value || "1" auto_download_series: $("seasonInput").value || "1"
}) })
}); });
@@ -764,7 +787,7 @@ INDEX_HTML = r"""<!doctype html>
anime_name: $("animeNameInput").value || state.selected.title, anime_name: $("animeNameInput").value || state.selected.title,
media_type: $("trackMediaType").value, media_type: $("trackMediaType").value,
season: $("seasonInput").value || "1", season: $("seasonInput").value || "1",
mode: state.config.mode, mode: $("selectionMode").value,
quality: state.config.quality, quality: state.config.quality,
episodes: $("episodesInput").value, episodes: $("episodesInput").value,
download_dir: $("jobFolder").value || state.config.download_dir download_dir: $("jobFolder").value || state.config.download_dir
@@ -793,6 +816,9 @@ INDEX_HTML = r"""<!doctype html>
$("trackBtn").addEventListener("click", addSelectedToWatchlist); $("trackBtn").addEventListener("click", addSelectedToWatchlist);
$("addBtn").addEventListener("click", addSelected); $("addBtn").addEventListener("click", addSelected);
$("closeSelectionBtn").addEventListener("click", closeSelectionModal); $("closeSelectionBtn").addEventListener("click", closeSelectionModal);
$("selectionMode").addEventListener("change", () => {
if (state.selected) loadSelectedEpisodes($("selectionMode").value);
});
selectionModal.addEventListener("click", (event) => { selectionModal.addEventListener("click", (event) => {
if (event.target === selectionModal) closeSelectionModal(); if (event.target === selectionModal) closeSelectionModal();
}); });
+19
View File
@@ -1642,6 +1642,19 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(item["provider"], "pahe") self.assertEqual(item["provider"], "pahe")
self.assertEqual(item["provider_label"], "AnimePahe") 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): def test_watchlist_source_url_uses_anikoto_show_page(self):
self.seed_watchlist_item(show_id="anikoto:provider-show", title="Provider Show", provider="anikoto") 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(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) 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): def test_queue_page_includes_clear_failed_bulk_action(self):
self.assertIn('id="clearFailedBtn"', APP.QUEUE_HTML) self.assertIn('id="clearFailedBtn"', APP.QUEUE_HTML)
self.assertIn('/api/queue/clear-failed', APP.QUEUE_HTML) self.assertIn('/api/queue/clear-failed', APP.QUEUE_HTML)