Add search action mode selector
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+50
-24
@@ -508,17 +508,25 @@ INDEX_HTML = r"""<!doctype html>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Download mode
|
||||
<select id="selectionMode">
|
||||
<option value="sub">Subbed</option>
|
||||
<option value="dub">Dubbed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Library name
|
||||
<input id="animeNameInput" placeholder="Anime name">
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Season
|
||||
<input id="seasonInput" inputmode="numeric" pattern="[0-9]*" value="1">
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Episodes
|
||||
<input id="episodesInput" placeholder="1-12">
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Download folder
|
||||
<input id="jobFolder" placeholder="/home/me/Downloads/Anime">
|
||||
</label>
|
||||
@@ -653,32 +661,23 @@ INDEX_HTML = r"""<!doctype html>
|
||||
$("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"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
$("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();
|
||||
});
|
||||
|
||||
+19
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user