diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e29419..fd90081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index 91e4850..3d15d3b 100644 --- a/README.md +++ b/README.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 diff --git a/VERSION b/VERSION index 00de776..9e97f91 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.14 +0.45.15 diff --git a/app.py b/app.py index d345aee..171f576 100644 --- a/app.py +++ b/app.py @@ -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, diff --git a/http_handler.py b/http_handler.py index b980f9f..09f5d21 100644 --- a/http_handler.py +++ b/http_handler.py @@ -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"] diff --git a/search_page.py b/search_page.py index 504737a..3cc8aa5 100644 --- a/search_page.py +++ b/search_page.py @@ -135,9 +135,9 @@ INDEX_HTML = r""" } 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""" 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""" 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; } } @@ -346,7 +431,6 @@ INDEX_HTML = r"""
-
@@ -400,6 +484,15 @@ INDEX_HTML = r""" +
+
+
+

Select results

+

Poster cards appear here after a search.

+
+
+
+
@@ -425,31 +518,70 @@ INDEX_HTML = r""" $("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 = '
No matching anime found.
'; + $("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 = ` - - +
+ + +
+
+
+ + +
`; 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""" 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""" } catch (error) { if (requestId !== state.searchRequestId) return; setNotice(error.message); + $("resultsMeta").textContent = "Search failed."; } } diff --git a/test_app.py b/test_app.py index a82ea5f..cf426ee 100644 --- a/test_app.py +++ b/test_app.py @@ -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)