Move search results into poster grid

This commit is contained in:
Dymas
2026-06-02 11:06:13 +02:00
parent d075aa11f9
commit 94740ebb76
7 changed files with 232 additions and 29 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # 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 ## 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`. - 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`.
+3 -3
View File
@@ -2,11 +2,11 @@
Local web UI for a system-wide `ani-cli` install. Local web UI for a system-wide `ani-cli` install.
Current version: `0.45.14` Current version: `0.45.15`
## What it does ## 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 - Queue downloads with folder, quality, media type, season, and episode controls
- Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories - Track shows in a watchlist with `Watching`, `Planned`, `Finished`, and `Dropped` categories
- Refresh watchlist episode counts manually or on a schedule - Refresh watchlist episode counts manually or on a schedule
@@ -16,7 +16,7 @@ Current version: `0.45.14`
## Pages ## 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 - `/queue`: Monitor jobs, inspect logs, retry failed jobs, remove finished jobs
- `/watchlist`: Track shows, refresh them, download all available episodes, manage auto-download settings - `/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 - `/config`: Save defaults, schedule refreshes, configure Jellyfin and Discord, view runtime info and the changelog
+1 -1
View File
@@ -1 +1 @@
0.45.14 0.45.15
+34
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import base64 import base64
import hashlib
import gzip import gzip
import json import json
import mimetypes import mimetypes
@@ -146,6 +147,12 @@ def cover_route(show_id):
return f"/api/watchlist/thumb/{quote(str(show_id).strip(), safe='')}" 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): def anime_source_url(show_id, title):
normalized_show_id = str(show_id or "").strip() normalized_show_id = str(show_id or "").strip()
if not normalized_show_id: 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): def cache_thumbnail_image(show_id, image_url):
THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True) THUMBNAIL_ROOT.mkdir(parents=True, exist_ok=True)
debug_log("thumbnail.download.request", show_id=show_id, image_url=image_url) 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, runtime_state=runtime_state,
save_runtime_config=save_runtime_config, save_runtime_config=save_runtime_config,
search_anime=search_anime, search_anime=search_anime,
resolve_search_thumbnail=resolve_search_thumbnail,
test_discord_webhook_config=test_discord_webhook_config, test_discord_webhook_config=test_discord_webhook_config,
thumbnail_file_path=thumbnail_file_path, thumbnail_file_path=thumbnail_file_path,
update_all_watchlist_statuses=update_all_watchlist_statuses, update_all_watchlist_statuses=update_all_watchlist_statuses,
+9
View File
@@ -62,6 +62,7 @@ class HandlerContext:
runtime_state: object runtime_state: object
save_runtime_config: object save_runtime_config: object
search_anime: object search_anime: object
resolve_search_thumbnail: object
test_discord_webhook_config: object test_discord_webhook_config: object
thumbnail_file_path: object thumbnail_file_path: object
update_all_watchlist_statuses: object update_all_watchlist_statuses: object
@@ -558,6 +559,14 @@ class Handler(BaseHTTPRequestHandler):
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)})
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"): elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"):
runtime = Handler._runtime(self) runtime = Handler._runtime(self)
config = runtime["config"] config = runtime["config"]
+159 -25
View File
@@ -135,9 +135,9 @@ INDEX_HTML = r"""<!doctype html>
} }
main { main {
display: grid; display: grid;
grid-template-rows: auto auto 1fr;
gap: 20px; gap: 20px;
min-width: 0; min-width: 0;
align-content: start;
} }
h1, h2, h3, p { margin: 0; } h1, h2, h3, p { margin: 0; }
h1 { h1 {
@@ -232,47 +232,119 @@ INDEX_HTML = r"""<!doctype html>
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
} }
.results { .settings, .episodes-panel, .results-panel, .deps {
display: grid;
gap: 10px;
min-width: 0;
}
.result, .settings, .episodes-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);
} }
.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; padding: 14px;
display: grid; 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; 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); 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 { .result-card.active {
border-color: rgba(157, 123, 255, 0.34); 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)); 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 { .result-title {
text-align: left;
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
overflow-wrap: anywhere; 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 { .muted {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
} }
.settings, .episodes-panel, .deps {
padding: 18px;
display: grid;
gap: 14px;
}
.chips { .chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -304,15 +376,28 @@ INDEX_HTML = r"""<!doctype html>
color: #ddd0ff; color: #ddd0ff;
font-size: 13px; font-size: 13px;
} }
.results-panel .toolbar {
align-items: end;
}
.statline { .statline {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; 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) { @media (max-width: 900px) {
.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; }
.results-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.results-grid { grid-template-columns: 1fr; }
} }
</style> </style>
</head> </head>
@@ -346,7 +431,6 @@ INDEX_HTML = r"""<!doctype html>
<button class="primary" id="searchBtn" type="button">Search</button> <button class="primary" id="searchBtn" type="button">Search</button>
</div> </div>
<div class="notice" id="notice"></div> <div class="notice" id="notice"></div>
<div class="results" id="results"></div>
</aside> </aside>
<main> <main>
@@ -400,6 +484,15 @@ 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 class="results-grid" id="results"></div>
</section>
</main> </main>
</div> </div>
@@ -425,31 +518,70 @@ INDEX_HTML = r"""<!doctype html>
$("search-mode").value = mode; $("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) { function renderResults(results) {
const el = $("results"); const el = $("results");
el.innerHTML = ""; el.innerHTML = "";
if (!results.length) { if (!results.length) {
el.innerHTML = '<div class="empty">No matching anime found.</div>'; el.innerHTML = '<div class="empty">No matching anime found.</div>';
$("resultsMeta").textContent = "No matching anime found.";
return; return;
} }
for (const item of results) { for (const item of results) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "result"; btn.className = "result-card";
btn.innerHTML = ` btn.innerHTML = `
<span class="result-title"></span> <div class="cover-tile">
<span class="muted"></span> <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(".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)); btn.addEventListener("click", () => selectResult(item, btn));
el.appendChild(btn); el.appendChild(btn);
} }
$("resultsMeta").textContent = `${results.length} matching anime`;
} }
async function selectResult(item, button) { async function selectResult(item, button) {
const requestId = ++state.episodeRequestId; 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"); button.classList.add("active");
state.selected = item; state.selected = item;
state.episodes = []; state.episodes = [];
@@ -495,6 +627,7 @@ INDEX_HTML = r"""<!doctype html>
if (!query) return setNotice("Type a title first."); if (!query) return setNotice("Type a title first.");
setMode($("search-mode").value); setMode($("search-mode").value);
setNotice("Searching..."); setNotice("Searching...");
$("resultsMeta").textContent = "Searching...";
state.selected = null; state.selected = null;
state.episodeRequestId += 1; state.episodeRequestId += 1;
$("addBtn").disabled = true; $("addBtn").disabled = true;
@@ -508,6 +641,7 @@ INDEX_HTML = r"""<!doctype html>
} catch (error) { } catch (error) {
if (requestId !== state.searchRequestId) return; if (requestId !== state.searchRequestId) return;
setNotice(error.message); setNotice(error.message);
$("resultsMeta").textContent = "Search failed.";
} }
} }
+21
View File
@@ -292,6 +292,21 @@ class NetworkGuardTests(unittest.TestCase):
self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_status, HTTPStatus.OK)
remove_from_watchlist.assert_called_once_with("show-1") 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): def test_loopback_proxy_with_forwarded_remote_ip_requires_credentials(self):
handler = DummyHandler("/api/config") handler = DummyHandler("/api/config")
handler.client_address = ("127.0.0.1", 8421) 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.assertIn('query: state.selected.title,', APP.INDEX_HTML)
self.assertNotIn('index: state.selected.index,', 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): def test_pages_share_serial_polling_helper(self):
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML) self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML)
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML) self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)