Add search action mode selector
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user