Move search results into poster grid
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 0.45.15 - 2026-06-02
|
||||
|
||||
- Moved Search page results out of the sidebar and into a poster-style grid below the selection panel so hits now read like watchlist cards instead of a compact list.
|
||||
- Added a dedicated search thumbnail route that reuses AnimeSchedule or AniDB artwork, letting search results show cover images without needing to add the title to the watchlist first.
|
||||
|
||||
## 0.45.14 - 2026-05-26
|
||||
|
||||
- Added a full-width `Changelog` button under Config page Runtime info that opens a floating scrollable panel and loads the contents of the project's `CHANGELOG.md`.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
Local web UI for a system-wide `ani-cli` install.
|
||||
|
||||
Current version: `0.45.14`
|
||||
Current version: `0.45.15`
|
||||
|
||||
## What it does
|
||||
|
||||
- Search anime in `sub` or `dub`
|
||||
- Search anime in `sub` or `dub`, with poster-style results shown below the selection panel
|
||||
- Queue downloads with folder, quality, media type, season, and episode controls
|
||||
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
|
||||
- Refresh watchlist episode counts manually or on a schedule
|
||||
@@ -16,7 +16,7 @@ Current version: `0.45.14`
|
||||
|
||||
## Pages
|
||||
|
||||
- `/`: Search and queue downloads
|
||||
- `/`: Search and queue downloads, with poster-card search results below the selection box
|
||||
- `/queue`: Monitor jobs, inspect logs, retry failed jobs, remove finished jobs
|
||||
- `/watchlist`: Track shows, refresh them, download all available episodes, manage auto-download settings
|
||||
- `/config`: Save defaults, schedule refreshes, configure Jellyfin and Discord, view runtime info and the changelog
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import hashlib
|
||||
import gzip
|
||||
import json
|
||||
import mimetypes
|
||||
@@ -146,6 +147,12 @@ def cover_route(show_id):
|
||||
return f"/api/watchlist/thumb/{quote(str(show_id).strip(), safe='')}"
|
||||
|
||||
|
||||
def search_thumbnail_cache_key(title):
|
||||
normalized = normalize_title_key(title) or str(title or "").strip().casefold()
|
||||
digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
|
||||
return f"search-{digest}"
|
||||
|
||||
|
||||
def anime_source_url(show_id, title):
|
||||
normalized_show_id = str(show_id or "").strip()
|
||||
if not normalized_show_id:
|
||||
@@ -774,6 +781,32 @@ def fetch_anidb_cover_by_aid(aid, fallback_title=""):
|
||||
}
|
||||
|
||||
|
||||
def resolve_search_thumbnail(title):
|
||||
clean_title = str(title or "").strip()
|
||||
if not clean_title:
|
||||
raise ValueError("Missing title for thumbnail lookup")
|
||||
cache_key = search_thumbnail_cache_key(clean_title)
|
||||
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
for existing in THUMBNAIL_ROOT.glob(f"{cache_key}.*"):
|
||||
if existing.is_file():
|
||||
return existing
|
||||
cover = None
|
||||
last_error = None
|
||||
for resolver in (fetch_animeschedule_cover, fetch_anidb_cover):
|
||||
try:
|
||||
cover = resolver(clean_title)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if not cover:
|
||||
raise RuntimeError("Could not find a thumbnail for this search result") from last_error
|
||||
cached_name = cache_thumbnail_image(cache_key, cover["url"])
|
||||
path = thumbnail_file_path(cached_name)
|
||||
if not path or not path.exists():
|
||||
raise RuntimeError("Could not cache the search thumbnail")
|
||||
return path
|
||||
|
||||
|
||||
def cache_thumbnail_image(show_id, image_url):
|
||||
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
debug_log("thumbnail.download.request", show_id=show_id, image_url=image_url)
|
||||
@@ -2816,6 +2849,7 @@ Handler = build_handler_class(
|
||||
runtime_state=runtime_state,
|
||||
save_runtime_config=save_runtime_config,
|
||||
search_anime=search_anime,
|
||||
resolve_search_thumbnail=resolve_search_thumbnail,
|
||||
test_discord_webhook_config=test_discord_webhook_config,
|
||||
thumbnail_file_path=thumbnail_file_path,
|
||||
update_all_watchlist_statuses=update_all_watchlist_statuses,
|
||||
|
||||
@@ -62,6 +62,7 @@ class HandlerContext:
|
||||
runtime_state: object
|
||||
save_runtime_config: object
|
||||
search_anime: object
|
||||
resolve_search_thumbnail: object
|
||||
test_discord_webhook_config: object
|
||||
thumbnail_file_path: object
|
||||
update_all_watchlist_statuses: object
|
||||
@@ -558,6 +559,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if mode not in MODE_CHOICES:
|
||||
raise ValueError("Mode must be sub or dub")
|
||||
self.json({"results": Handler._context(self).search_anime(query, mode)})
|
||||
elif parsed.path == "/api/search/thumb":
|
||||
Handler._runtime(self)
|
||||
params = parse_qs(parsed.query)
|
||||
title = (params.get("title") or [""])[0].strip()
|
||||
if not title:
|
||||
raise ValueError("Title is required")
|
||||
path = Handler._context(self).resolve_search_thumbnail(title)
|
||||
self.file(path)
|
||||
elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"):
|
||||
runtime = Handler._runtime(self)
|
||||
config = runtime["config"]
|
||||
|
||||
+159
-25
@@ -135,9 +135,9 @@ INDEX_HTML = r"""<!doctype html>
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
}
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 {
|
||||
@@ -232,47 +232,119 @@ INDEX_HTML = r"""<!doctype html>
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.results {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.result, .settings, .episodes-panel, .deps {
|
||||
.settings, .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);
|
||||
}
|
||||
.result {
|
||||
.settings, .episodes-panel, .results-panel, .deps {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.results-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.result-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(32, 24, 54, 0.9) 0%, rgba(17, 13, 29, 0.96) 100%);
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
min-height: auto;
|
||||
min-width: 0;
|
||||
box-shadow: 0 18px 38px rgba(5, 2, 15, 0.32);
|
||||
text-align: left;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.result:hover {
|
||||
.result-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
transform: translateY(-1px);
|
||||
background: linear-gradient(180deg, rgba(38, 28, 64, 0.94) 0%, rgba(19, 14, 32, 0.98) 100%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.result.active {
|
||||
border-color: rgba(157, 123, 255, 0.34);
|
||||
.result-card.active {
|
||||
border-color: rgba(157, 123, 255, 0.58);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(157, 123, 255, 0.16),
|
||||
0 18px 38px rgba(5, 2, 15, 0.32);
|
||||
background: linear-gradient(180deg, rgba(48, 36, 80, 0.96), rgba(20, 15, 35, 0.96));
|
||||
}
|
||||
.cover-tile {
|
||||
aspect-ratio: 3 / 4;
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(157, 123, 255, 0.16);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(157, 123, 255, 0.28), transparent 44%),
|
||||
linear-gradient(180deg, #221734 0%, #0c0915 100%);
|
||||
}
|
||||
.cover-tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
.cover-tile.has-image img {
|
||||
opacity: 1;
|
||||
}
|
||||
.cover-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #d8e5ef;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(180deg, rgba(157, 123, 255, 0.24), rgba(9, 7, 15, 0.18));
|
||||
}
|
||||
.cover-tile.has-image .cover-fallback {
|
||||
display: none;
|
||||
}
|
||||
.result-title {
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
min-height: 2.7em;
|
||||
}
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.result-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings, .episodes-panel, .deps {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -304,15 +376,28 @@ INDEX_HTML = r"""<!doctype html>
|
||||
color: #ddd0ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.results-panel .toolbar {
|
||||
align-items: end;
|
||||
}
|
||||
.statline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.results-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 1160px) {
|
||||
.results-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; }
|
||||
aside { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.controls, .row, .toolbar { align-items: stretch; flex-direction: column; }
|
||||
.results-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.results-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -346,7 +431,6 @@ INDEX_HTML = r"""<!doctype html>
|
||||
<button class="primary" id="searchBtn" type="button">Search</button>
|
||||
</div>
|
||||
<div class="notice" id="notice"></div>
|
||||
<div class="results" id="results"></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
@@ -400,6 +484,15 @@ 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>
|
||||
|
||||
@@ -425,31 +518,70 @@ INDEX_HTML = r"""<!doctype html>
|
||||
$("search-mode").value = mode;
|
||||
}
|
||||
|
||||
function coverInitials(title) {
|
||||
const parts = String(title || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
if (!parts.length) return "AN";
|
||||
return parts.map((part) => part[0]).join("").slice(0, 2);
|
||||
}
|
||||
|
||||
function resultThumbnailUrl(title) {
|
||||
return `/api/search/thumb?title=${encodeURIComponent(title || "")}`;
|
||||
}
|
||||
|
||||
function renderResults(results) {
|
||||
const el = $("results");
|
||||
el.innerHTML = "";
|
||||
if (!results.length) {
|
||||
el.innerHTML = '<div class="empty">No matching anime found.</div>';
|
||||
$("resultsMeta").textContent = "No matching anime found.";
|
||||
return;
|
||||
}
|
||||
for (const item of results) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "result";
|
||||
btn.className = "result-card";
|
||||
btn.innerHTML = `
|
||||
<span class="result-title"></span>
|
||||
<span class="muted"></span>
|
||||
<div class="cover-tile">
|
||||
<img alt="" loading="lazy" decoding="async">
|
||||
<span class="cover-fallback"></span>
|
||||
</div>
|
||||
<div class="result-title"></div>
|
||||
<div class="result-meta">
|
||||
<span class="result-pill"></span>
|
||||
<span class="result-pill"></span>
|
||||
</div>
|
||||
`;
|
||||
btn.querySelector(".result-title").textContent = item.title;
|
||||
btn.querySelector(".muted").textContent = `${item.episodes} episodes · result ${item.index}`;
|
||||
btn.querySelector(".cover-fallback").textContent = coverInitials(item.title);
|
||||
const pills = btn.querySelectorAll(".result-pill");
|
||||
pills[0].textContent = `${item.episodes} episodes`;
|
||||
pills[1].textContent = `Result ${item.index}`;
|
||||
const img = btn.querySelector("img");
|
||||
const tile = btn.querySelector(".cover-tile");
|
||||
img.alt = item.title;
|
||||
img.src = resultThumbnailUrl(item.title);
|
||||
if (img.complete && img.naturalWidth > 0) {
|
||||
tile.classList.add("has-image");
|
||||
}
|
||||
img.addEventListener("load", () => {
|
||||
if (img.naturalWidth > 0) tile.classList.add("has-image");
|
||||
});
|
||||
img.addEventListener("error", () => {
|
||||
tile.classList.remove("has-image");
|
||||
});
|
||||
btn.addEventListener("click", () => selectResult(item, btn));
|
||||
el.appendChild(btn);
|
||||
}
|
||||
$("resultsMeta").textContent = `${results.length} matching anime`;
|
||||
}
|
||||
|
||||
async function selectResult(item, button) {
|
||||
const requestId = ++state.episodeRequestId;
|
||||
document.querySelectorAll(".result").forEach((node) => node.classList.remove("active"));
|
||||
document.querySelectorAll(".result-card").forEach((node) => node.classList.remove("active"));
|
||||
button.classList.add("active");
|
||||
state.selected = item;
|
||||
state.episodes = [];
|
||||
@@ -495,6 +627,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
if (!query) return setNotice("Type a title first.");
|
||||
setMode($("search-mode").value);
|
||||
setNotice("Searching...");
|
||||
$("resultsMeta").textContent = "Searching...";
|
||||
state.selected = null;
|
||||
state.episodeRequestId += 1;
|
||||
$("addBtn").disabled = true;
|
||||
@@ -508,6 +641,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
} catch (error) {
|
||||
if (requestId !== state.searchRequestId) return;
|
||||
setNotice(error.message);
|
||||
$("resultsMeta").textContent = "Search failed.";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -292,6 +292,21 @@ class NetworkGuardTests(unittest.TestCase):
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
remove_from_watchlist.assert_called_once_with("show-1")
|
||||
|
||||
def test_search_thumbnail_route_serves_cached_file(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
image_path = Path(temp_dir) / "search-thumb.jpg"
|
||||
image_path.write_bytes(b"fake-image-data")
|
||||
handler = DummyHandler("/api/search/thumb?title=Demo")
|
||||
handler.handler_context = mock.Mock(
|
||||
ensure_runtime=mock.Mock(),
|
||||
runtime_state=mock.Mock(return_value={"config": {}}),
|
||||
resolve_search_thumbnail=mock.Mock(return_value=image_path),
|
||||
)
|
||||
|
||||
APP.Handler.do_GET(handler)
|
||||
|
||||
self.assertEqual(handler.file_path, image_path)
|
||||
|
||||
def test_loopback_proxy_with_forwarded_remote_ip_requires_credentials(self):
|
||||
handler = DummyHandler("/api/config")
|
||||
handler.client_address = ("127.0.0.1", 8421)
|
||||
@@ -3217,6 +3232,12 @@ class TemplateHelperTests(unittest.TestCase):
|
||||
self.assertIn('query: state.selected.title,', APP.INDEX_HTML)
|
||||
self.assertNotIn('index: state.selected.index,', APP.INDEX_HTML)
|
||||
|
||||
def test_search_page_renders_poster_results_under_selection_panel(self):
|
||||
self.assertIn('class="results-grid" id="results"', APP.INDEX_HTML)
|
||||
self.assertIn("Select results", APP.INDEX_HTML)
|
||||
self.assertIn("resultThumbnailUrl(title)", APP.INDEX_HTML)
|
||||
self.assertIn('/api/search/thumb?title=', APP.INDEX_HTML)
|
||||
|
||||
def test_pages_share_serial_polling_helper(self):
|
||||
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML)
|
||||
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)
|
||||
|
||||
Reference in New Issue
Block a user