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"""
-