Move search controls and add result modal
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -5,8 +5,10 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A
|
||||
## Features
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -916,9 +916,12 @@ def episode_language_summary(show_id, provider):
|
||||
return summary
|
||||
|
||||
|
||||
def search_anime(query, mode):
|
||||
def search_anime(query, mode, provider=None):
|
||||
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)
|
||||
shows = data.get("results") or []
|
||||
results = []
|
||||
|
||||
+2
-2
@@ -454,7 +454,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
<h2>Saved defaults</h2>
|
||||
<div class="stack muted">
|
||||
<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>
|
||||
</section>
|
||||
<section class="settings">
|
||||
@@ -476,7 +476,7 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<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>
|
||||
<button class="primary" id="saveConfigBtn" type="button">Save</button>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -640,11 +640,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
params = parse_qs(parsed.query)
|
||||
query = (params.get("q") or [""])[0].strip()
|
||||
mode = (params.get("mode") or [config["mode"]])[0].lower()
|
||||
provider = (params.get("provider") or [config.get("provider") or ""])[0].strip().lower()
|
||||
if not query:
|
||||
raise ValueError("Search query is required")
|
||||
if mode not in MODE_CHOICES:
|
||||
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":
|
||||
Handler._runtime(self)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
+103
-49
@@ -120,7 +120,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
}
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) 1fr;
|
||||
grid-template-columns: minmax(240px, 300px) 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
aside, main {
|
||||
@@ -173,16 +173,6 @@ INDEX_HTML = r"""<!doctype html>
|
||||
.controls {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -233,18 +223,26 @@ INDEX_HTML = r"""<!doctype html>
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.settings, .episodes-panel, .results-panel, .deps {
|
||||
.settings, .search-panel, .episodes-panel, .results-panel, .deps {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(29, 22, 47, 0.88), rgba(17, 13, 29, 0.94));
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.settings, .episodes-panel, .results-panel, .deps {
|
||||
.settings, .search-panel, .episodes-panel, .results-panel, .deps {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -385,6 +383,31 @@ INDEX_HTML = r"""<!doctype html>
|
||||
color: #ddd0ff;
|
||||
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 {
|
||||
align-items: end;
|
||||
}
|
||||
@@ -403,6 +426,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
.app { grid-template-columns: 1fr; }
|
||||
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.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)); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
@@ -418,33 +442,57 @@ INDEX_HTML = r"""<!doctype html>
|
||||
"Search",
|
||||
badge_id="serverBadge",
|
||||
) + 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>
|
||||
|
||||
<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>
|
||||
<h2 id="selectedTitle">Select a result</h2>
|
||||
<p class="muted" id="selectedMeta">Episodes will appear here.</p>
|
||||
</div>
|
||||
<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">
|
||||
<option value="watching">Watching</option>
|
||||
<option value="planned">Planned</option>
|
||||
@@ -452,15 +500,12 @@ INDEX_HTML = r"""<!doctype html>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="min-width: 160px;">Media type
|
||||
<label>Media type
|
||||
<select id="trackMediaType">
|
||||
<option value="tv">TV</option>
|
||||
<option value="movie">Movie</option>
|
||||
</select>
|
||||
</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 class="row">
|
||||
<label>Library name
|
||||
@@ -487,21 +532,12 @@ INDEX_HTML = r"""<!doctype html>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
const state = {
|
||||
config: { mode: "sub", quality: "best", download_dir: "" },
|
||||
config: { mode: "sub", quality: "best", download_dir: "", provider: "anikoto" },
|
||||
selected: null,
|
||||
episodes: [],
|
||||
searchRequestId: 0,
|
||||
@@ -510,6 +546,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
""" + BROWSER_API_HELPER + r"""
|
||||
const selectionModal = $("selectionModal");
|
||||
|
||||
function setNotice(text) {
|
||||
const notice = $("notice");
|
||||
@@ -544,6 +581,14 @@ INDEX_HTML = r"""<!doctype html>
|
||||
return labels[String(provider || "").toLowerCase()] || "Provider";
|
||||
}
|
||||
|
||||
function openSelectionModal() {
|
||||
selectionModal.hidden = false;
|
||||
}
|
||||
|
||||
function closeSelectionModal() {
|
||||
selectionModal.hidden = true;
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const el = $("results");
|
||||
el.innerHTML = "";
|
||||
@@ -630,6 +675,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
$("episodeChips").innerHTML = "";
|
||||
$("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;
|
||||
@@ -663,6 +709,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
const requestId = ++state.searchRequestId;
|
||||
const query = $("search-query").value.trim();
|
||||
if (!query) return setNotice("Type a title first.");
|
||||
state.config.provider = $("searchProvider").value;
|
||||
setNotice("Searching...");
|
||||
$("resultsMeta").textContent = "Searching...";
|
||||
state.selected = null;
|
||||
@@ -671,7 +718,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
$("trackBtn").disabled = true;
|
||||
$("results").innerHTML = "";
|
||||
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;
|
||||
renderResults(data.results);
|
||||
setNotice(`${data.results.length} results`);
|
||||
@@ -717,7 +764,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
media_type: $("trackMediaType").value,
|
||||
season: $("seasonInput").value || "1",
|
||||
mode: state.config.mode,
|
||||
quality: $("search-quality").value,
|
||||
quality: state.config.quality,
|
||||
episodes: $("episodesInput").value,
|
||||
download_dir: $("jobFolder").value || state.config.download_dir
|
||||
};
|
||||
@@ -732,8 +779,9 @@ INDEX_HTML = r"""<!doctype html>
|
||||
async function loadConfig() {
|
||||
state.config = await api("/api/config");
|
||||
$("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.quality = state.config.quality || "best";
|
||||
}
|
||||
|
||||
$("searchBtn").addEventListener("click", search);
|
||||
@@ -742,7 +790,13 @@ INDEX_HTML = r"""<!doctype html>
|
||||
});
|
||||
$("trackBtn").addEventListener("click", addSelectedToWatchlist);
|
||||
$("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] || ""; });
|
||||
$("latestEpBtn").addEventListener("click", () => {
|
||||
$("episodesInput").value = state.episodes[state.episodes.length - 1] || "";
|
||||
|
||||
Reference in New Issue
Block a user