Move search controls and add result modal

This commit is contained in:
Codex
2026-08-09 12:22:04 +02:00
parent 15a9ec1948
commit cb86f79da1
7 changed files with 132 additions and 65 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.51.4 - 2026-08-09
- Moved Search controls from the sidebar to a top-of-page search panel.
- Removed the Search page quality selector; queued downloads now use the saved Config quality default.
- Added a Search page provider dropdown for choosing Anikoto, AniNeko, or AnimePahe per search.
- Moved result actions into a floating window that opens after selecting a search result.
## 0.51.3 - 2026-08-09 ## 0.51.3 - 2026-08-09
- Removed the Episode mode selector from the Search page and now choose the selected result's episode language automatically from the saved Config default and available `SUB`/`DUB` flags. - Removed the Episode mode selector from the Search page and now choose the selected result's episode language automatically from the saved Config default and available `SUB`/`DUB` flags.
+2
View File
@@ -5,8 +5,10 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A
## Features ## Features
- Search anime through the configured provider and tag results with available `SUB` and `DUB` episode languages. - Search anime through the configured provider and tag results with available `SUB` and `DUB` episode languages.
- Choose the active search provider directly from the Search page.
- 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.
- Open result actions in a floating window for watchlist, media type, 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.51.3 0.51.4
+5 -2
View File
@@ -916,9 +916,12 @@ def episode_language_summary(show_id, provider):
return summary return summary
def search_anime(query, mode): def search_anime(query, mode, provider=None):
runtime = ensure_runtime() runtime = ensure_runtime()
provider = str((runtime.get("config") or {}).get("provider") or "anikoto").strip().lower() configured_provider = str((runtime.get("config") or {}).get("provider") or "anikoto").strip().lower()
provider = str(provider or configured_provider).strip().lower()
if provider not in provider_bridge.PROVIDERS:
provider = configured_provider if configured_provider in provider_bridge.PROVIDERS else "anikoto"
data = provider_bridge.search(query, provider=provider, page=1) data = provider_bridge.search(query, provider=provider, page=1)
shows = data.get("results") or [] shows = data.get("results") or []
results = [] results = []
+2 -2
View File
@@ -454,7 +454,7 @@ CONFIG_HTML = r"""<!doctype html>
<h2>Saved defaults</h2> <h2>Saved defaults</h2>
<div class="stack muted"> <div class="stack muted">
<p>These values preload the Search page and act as the fallback folder for new queue jobs.</p> <p>These values preload the Search page and act as the fallback folder for new queue jobs.</p>
<p>Mode and quality can still be changed on the Search page before queueing a download.</p> <p>Search uses these saved mode and quality defaults when opening result actions and queueing downloads.</p>
</div> </div>
</section> </section>
<section class="settings"> <section class="settings">
@@ -476,7 +476,7 @@ CONFIG_HTML = r"""<!doctype html>
<div class="toolbar"> <div class="toolbar">
<div> <div>
<h2>Defaults</h2> <h2>Defaults</h2>
<p class="muted">Manage the folder, mode, and quality that the Search page starts with.</p> <p class="muted">Manage the folder, mode, quality, and provider defaults used by Search and new queue jobs.</p>
</div> </div>
<button class="primary" id="saveConfigBtn" type="button">Save</button> <button class="primary" id="saveConfigBtn" type="button">Save</button>
</div> </div>
+2 -1
View File
@@ -640,11 +640,12 @@ class Handler(BaseHTTPRequestHandler):
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
query = (params.get("q") or [""])[0].strip() query = (params.get("q") or [""])[0].strip()
mode = (params.get("mode") or [config["mode"]])[0].lower() mode = (params.get("mode") or [config["mode"]])[0].lower()
provider = (params.get("provider") or [config.get("provider") or ""])[0].strip().lower()
if not query: if not query:
raise ValueError("Search query is required") raise ValueError("Search query is required")
if mode not in MODE_CHOICES: if mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub") raise ValueError("Mode must be sub or dub")
self.json({"results": Handler._context(self).search_anime(query, mode)}) self.json({"results": Handler._context(self).search_anime(query, mode, provider=provider)})
elif parsed.path == "/api/search/thumb": elif parsed.path == "/api/search/thumb":
Handler._runtime(self) Handler._runtime(self)
params = parse_qs(parsed.query) params = parse_qs(parsed.query)
+103 -49
View File
@@ -120,7 +120,7 @@ INDEX_HTML = r"""<!doctype html>
} }
.app { .app {
display: grid; display: grid;
grid-template-columns: minmax(320px, 380px) 1fr; grid-template-columns: minmax(240px, 300px) 1fr;
min-height: 100vh; min-height: 100vh;
} }
aside, main { aside, main {
@@ -173,16 +173,6 @@ INDEX_HTML = r"""<!doctype html>
.controls { .controls {
align-items: end; align-items: end;
} }
.search-box {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(180deg, rgba(31, 24, 50, 0.86), rgba(17, 13, 29, 0.92));
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.page-links { .page-links {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -233,18 +223,26 @@ INDEX_HTML = r"""<!doctype html>
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
} }
.settings, .episodes-panel, .results-panel, .deps { .settings, .search-panel, .episodes-panel, .results-panel, .deps {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 22px; border-radius: 22px;
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94)); background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
box-shadow: var(--shadow); box-shadow: var(--shadow);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
} }
.settings, .episodes-panel, .results-panel, .deps { .settings, .search-panel, .episodes-panel, .results-panel, .deps {
padding: 18px; padding: 18px;
display: grid; display: grid;
gap: 14px; gap: 14px;
} }
.search-panel {
align-items: end;
}
.search-panel .controls {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(170px, 220px) auto;
align-items: end;
}
.results-panel { .results-panel {
min-width: 0; min-width: 0;
} }
@@ -385,6 +383,31 @@ INDEX_HTML = r"""<!doctype html>
color: #ddd0ff; color: #ddd0ff;
font-size: 13px; font-size: 13px;
} }
.modal-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 22px;
background: rgba(4, 2, 10, 0.68);
backdrop-filter: blur(12px);
}
.modal-overlay[hidden] {
display: none;
}
.modal-window {
width: min(980px, 100%);
max-height: min(840px, calc(100vh - 44px));
overflow: auto;
border: 1px solid var(--line-strong);
border-radius: 22px;
background: linear-gradient(180deg, rgba(29, 22, 47, 0.98), rgba(13, 9, 24, 0.98));
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.52);
padding: 18px;
display: grid;
gap: 14px;
}
.results-panel .toolbar { .results-panel .toolbar {
align-items: end; align-items: end;
} }
@@ -403,6 +426,7 @@ INDEX_HTML = r"""<!doctype html>
.app { grid-template-columns: 1fr; } .app { grid-template-columns: 1fr; }
aside { border-right: 0; border-bottom: 1px solid var(--line); } aside { border-right: 0; border-bottom: 1px solid var(--line); }
.controls, .row, .toolbar { align-items: stretch; flex-direction: column; } .controls, .row, .toolbar { align-items: stretch; flex-direction: column; }
.search-panel .controls { grid-template-columns: 1fr; }
.results-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .results-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -418,33 +442,57 @@ INDEX_HTML = r"""<!doctype html>
"Search", "Search",
badge_id="serverBadge", badge_id="serverBadge",
) + render_page_links("search") + r""" ) + render_page_links("search") + r"""
<div class="search-box">
<label for="search-query">Anime Title / Keyword</label>
<input type="text" id="search-query" placeholder="e.g., Attack on Titan">
<label for="search-quality">Quality</label>
<select id="search-quality">
<option value="best">Best</option>
<option value="1080">1080p</option>
<option value="720">720p</option>
<option value="480">480p</option>
</select>
<button class="primary" id="searchBtn" type="button">Search</button>
</div>
<div class="notice" id="notice"></div>
</aside> </aside>
<main> <main>
<section class="episodes-panel" id="episodesPanel"> <section class="search-panel">
<div class="toolbar">
<div>
<h2>Search anime</h2>
<p class="muted">Choose a provider and search across subbed and dubbed results.</p>
</div>
</div>
<div class="controls">
<label for="search-query">Anime Title / Keyword
<input type="text" id="search-query" placeholder="e.g., Attack on Titan">
</label>
<label for="searchProvider">Provider
<select id="searchProvider">
<option value="anikoto">Anikoto</option>
<option value="anineko">AniNeko</option>
<option value="pahe">AnimePahe</option>
</select>
</label>
<button class="primary" id="searchBtn" type="button">Search</button>
</div>
<div class="notice" id="notice"></div>
</section>
<section class="results-panel">
<div class="toolbar">
<div>
<h2>Select results</h2>
<p class="muted" id="resultsMeta">Poster cards appear here after a search.</p>
</div>
</div>
<div class="results-grid" id="results"></div>
</section>
</main>
<div class="modal-overlay" id="selectionModal" hidden>
<section class="modal-window" role="dialog" aria-modal="true" aria-labelledby="selectedTitle">
<div class="toolbar"> <div class="toolbar">
<div> <div>
<h2 id="selectedTitle">Select a result</h2> <h2 id="selectedTitle">Select a result</h2>
<p class="muted" id="selectedMeta">Episodes will appear here.</p> <p class="muted" id="selectedMeta">Episodes will appear here.</p>
</div> </div>
<div class="row" style="flex:0 0 auto"> <div class="row" style="flex:0 0 auto">
<label style="min-width: 180px;">Watchlist category <button class="ghost" id="closeSelectionBtn" type="button">Close</button>
<button class="ghost" id="trackBtn" type="button" disabled>Add to watchlist</button>
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
</div>
</div>
<div class="row">
<label>Watchlist category
<select id="trackCategory"> <select id="trackCategory">
<option value="watching">Watching</option> <option value="watching">Watching</option>
<option value="planned">Planned</option> <option value="planned">Planned</option>
@@ -452,15 +500,12 @@ INDEX_HTML = r"""<!doctype html>
<option value="dropped">Dropped</option> <option value="dropped">Dropped</option>
</select> </select>
</label> </label>
<label style="min-width: 160px;">Media type <label>Media type
<select id="trackMediaType"> <select id="trackMediaType">
<option value="tv">TV</option> <option value="tv">TV</option>
<option value="movie">Movie</option> <option value="movie">Movie</option>
</select> </select>
</label> </label>
<button class="ghost" id="trackBtn" type="button" disabled>Add to watchlist</button>
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
</div>
</div> </div>
<div class="row"> <div class="row">
<label>Library name <label>Library name
@@ -487,21 +532,12 @@ INDEX_HTML = r"""<!doctype html>
</div> </div>
</div> </div>
</section> </section>
<section class="results-panel">
<div class="toolbar">
<div>
<h2>Select results</h2>
<p class="muted" id="resultsMeta">Poster cards appear here after a search.</p>
</div> </div>
</div> </div>
<div class="results-grid" id="results"></div>
</section>
</main>
</div>
<script> <script>
const state = { const state = {
config: { mode: "sub", quality: "best", download_dir: "" }, config: { mode: "sub", quality: "best", download_dir: "", provider: "anikoto" },
selected: null, selected: null,
episodes: [], episodes: [],
searchRequestId: 0, searchRequestId: 0,
@@ -510,6 +546,7 @@ INDEX_HTML = r"""<!doctype html>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
""" + BROWSER_API_HELPER + r""" """ + BROWSER_API_HELPER + r"""
const selectionModal = $("selectionModal");
function setNotice(text) { function setNotice(text) {
const notice = $("notice"); const notice = $("notice");
@@ -544,6 +581,14 @@ INDEX_HTML = r"""<!doctype html>
return labels[String(provider || "").toLowerCase()] || "Provider"; return labels[String(provider || "").toLowerCase()] || "Provider";
} }
function openSelectionModal() {
selectionModal.hidden = false;
}
function closeSelectionModal() {
selectionModal.hidden = true;
}
function renderResults(results) { function renderResults(results) {
const el = $("results"); const el = $("results");
el.innerHTML = ""; el.innerHTML = "";
@@ -630,6 +675,7 @@ INDEX_HTML = r"""<!doctype html>
$("episodeChips").innerHTML = ""; $("episodeChips").innerHTML = "";
$("addBtn").disabled = true; $("addBtn").disabled = true;
$("trackBtn").disabled = false; $("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(item.id)}/episodes?mode=${selectedMode}`);
if (requestId !== state.episodeRequestId || state.selected !== item) return; if (requestId !== state.episodeRequestId || state.selected !== item) return;
@@ -663,6 +709,7 @@ INDEX_HTML = r"""<!doctype html>
const requestId = ++state.searchRequestId; const requestId = ++state.searchRequestId;
const query = $("search-query").value.trim(); const query = $("search-query").value.trim();
if (!query) return setNotice("Type a title first."); if (!query) return setNotice("Type a title first.");
state.config.provider = $("searchProvider").value;
setNotice("Searching..."); setNotice("Searching...");
$("resultsMeta").textContent = "Searching..."; $("resultsMeta").textContent = "Searching...";
state.selected = null; state.selected = null;
@@ -671,7 +718,7 @@ INDEX_HTML = r"""<!doctype html>
$("trackBtn").disabled = true; $("trackBtn").disabled = true;
$("results").innerHTML = ""; $("results").innerHTML = "";
try { try {
const data = await api(`/api/search?q=${encodeURIComponent(query)}&mode=${state.config.mode}`); const data = await api(`/api/search?q=${encodeURIComponent(query)}&mode=${state.config.mode}&provider=${encodeURIComponent(state.config.provider)}`);
if (requestId !== state.searchRequestId) return; if (requestId !== state.searchRequestId) return;
renderResults(data.results); renderResults(data.results);
setNotice(`${data.results.length} results`); setNotice(`${data.results.length} results`);
@@ -717,7 +764,7 @@ INDEX_HTML = r"""<!doctype html>
media_type: $("trackMediaType").value, media_type: $("trackMediaType").value,
season: $("seasonInput").value || "1", season: $("seasonInput").value || "1",
mode: state.config.mode, mode: state.config.mode,
quality: $("search-quality").value, 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
}; };
@@ -732,8 +779,9 @@ INDEX_HTML = r"""<!doctype html>
async function loadConfig() { async function loadConfig() {
state.config = await api("/api/config"); state.config = await api("/api/config");
$("jobFolder").value = state.config.download_dir; $("jobFolder").value = state.config.download_dir;
$("search-quality").value = state.config.quality; $("searchProvider").value = state.config.provider || "anikoto";
state.config.mode = state.config.mode === "dub" ? "dub" : "sub"; state.config.mode = state.config.mode === "dub" ? "dub" : "sub";
state.config.quality = state.config.quality || "best";
} }
$("searchBtn").addEventListener("click", search); $("searchBtn").addEventListener("click", search);
@@ -742,7 +790,13 @@ INDEX_HTML = r"""<!doctype html>
}); });
$("trackBtn").addEventListener("click", addSelectedToWatchlist); $("trackBtn").addEventListener("click", addSelectedToWatchlist);
$("addBtn").addEventListener("click", addSelected); $("addBtn").addEventListener("click", addSelected);
$("search-quality").addEventListener("change", () => { state.config.quality = $("search-quality").value; }); $("closeSelectionBtn").addEventListener("click", closeSelectionModal);
selectionModal.addEventListener("click", (event) => {
if (event.target === selectionModal) closeSelectionModal();
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !selectionModal.hidden) closeSelectionModal();
});
$("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; }); $("firstEpBtn").addEventListener("click", () => { $("episodesInput").value = state.episodes[0] || ""; });
$("latestEpBtn").addEventListener("click", () => { $("latestEpBtn").addEventListener("click", () => {
$("episodesInput").value = state.episodes[state.episodes.length - 1] || ""; $("episodesInput").value = state.episodes[state.episodes.length - 1] || "";