From d3cf97d56d4726fb417e18ef70ec7a649ed05e36 Mon Sep 17 00:00:00 2001 From: kodi Date: Mon, 9 Mar 2026 09:32:28 +0100 Subject: [PATCH] feat (ui): toevoegen van banner en series info - 01 --- app/api/tvdb.py | 107 ++++++++++++++++++++++++++++++++++++++++++ app/static/app.js | 30 +++++++++--- app/static/styles.css | 27 +++++++++-- 3 files changed, 155 insertions(+), 9 deletions(-) diff --git a/app/api/tvdb.py b/app/api/tvdb.py index dd67e89..c972356 100644 --- a/app/api/tvdb.py +++ b/app/api/tvdb.py @@ -97,6 +97,113 @@ def _fetch_aired_episodes(client: TvdbClient, series_id: int) -> list[dict]: ) +def _status_name(value) -> str | None: + if isinstance(value, dict): + name = value.get("name") + if isinstance(name, str) and name.strip(): + return name.strip() + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _pick_banner_url(artworks: list[dict]) -> str | None: + banner_candidates = [] + fallback_candidates = [] + + for artwork in artworks: + if not isinstance(artwork, dict): + continue + image = artwork.get("image") or artwork.get("thumbnail") + if not isinstance(image, str) or not image.strip(): + continue + + width = artwork.get("width") + height = artwork.get("height") + score = artwork.get("score") or 0 + try: + width = int(width) if width is not None else 0 + height = int(height) if height is not None else 0 + except (TypeError, ValueError): + width, height = 0, 0 + try: + score = float(score) + except (TypeError, ValueError): + score = 0.0 + + ratio = (width / height) if width > 0 and height > 0 else 0.0 + record = (score, width, image.strip()) + + if ratio >= 1.4: + banner_candidates.append(record) + else: + fallback_candidates.append(record) + + if banner_candidates: + banner_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) + return banner_candidates[0][2] + + if fallback_candidates: + fallback_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) + return fallback_candidates[0][2] + + return None + + +@router.get("/series/{series_id}/summary") +def get_series_summary(series_id: int): + client = TvdbClient() + try: + extended_payload = client.get(f"/series/{series_id}/extended") + base_payload = client.get(f"/series/{series_id}") + + extended_data = extended_payload.get("data", {}) if isinstance(extended_payload, dict) else {} + base_data = base_payload.get("data", {}) if isinstance(base_payload, dict) else {} + + artworks = extended_data.get("artworks") + artworks = artworks if isinstance(artworks, list) else [] + + banner_url = _pick_banner_url(artworks) + poster_url = ( + base_data.get("image") + or extended_data.get("image") + or None + ) + + first_aired = ( + extended_data.get("firstAired") + or base_data.get("firstAired") + or None + ) + network = ( + extended_data.get("latestNetwork", {}).get("name") + if isinstance(extended_data.get("latestNetwork"), dict) + else None + ) or extended_data.get("network") or base_data.get("network") + status = _status_name(extended_data.get("status")) or _status_name(base_data.get("status")) + overview = ( + extended_data.get("overview") + or base_data.get("overview") + or None + ) + slug = extended_data.get("slug") or base_data.get("slug") + tvdb_id = extended_data.get("id") or base_data.get("id") or series_id + + return { + "id": tvdb_id, + "banner_url": banner_url, + "poster_url": poster_url, + "first_aired": first_aired, + "network": network, + "status": status, + "overview": overview, + "slug": slug, + "tvdb_url": f"https://www.thetvdb.com/series/{slug}" if slug else None, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/series/{series_id}/episodes") def get_series_episodes( series_id: int, diff --git a/app/static/app.js b/app/static/app.js index 863be99..c581308 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -4,6 +4,7 @@ const state = { sessionId: initSessionId(), selectedSeries: null, + selectedSeriesSummary: null, episodes: [], selectedEpisodes: [], selectedFiles: [], @@ -117,8 +118,12 @@ } function buildTvdbUrl(item) { + const summary = state.selectedSeriesSummary || {}; + const summaryUrl = (summary.tvdb_url || "").toString().trim(); + if (summaryUrl) return summaryUrl; + const raw = item.raw || {}; - const slug = (raw.slug || "").toString().trim(); + const slug = (summary.slug || raw.slug || "").toString().trim(); if (slug) return `https://www.thetvdb.com/series/${encodeURIComponent(slug)}`; const tvdbId = (raw.tvdb_id || item.id || "").toString().trim(); @@ -133,8 +138,9 @@ return; } const raw = item.raw || {}; - const imageUrl = (raw.image_url || "").toString().trim(); - const overview = (raw.overview || "").toString().trim(); + const summary = state.selectedSeriesSummary || {}; + const imageUrl = (summary.banner_url || summary.poster_url || raw.image_url || "").toString().trim(); + const overview = (summary.overview || raw.overview || "").toString().trim(); const tvdbUrl = buildTvdbUrl(item); if (imageUrl) { @@ -145,9 +151,9 @@ el.seriesPoster.classList.add("hidden"); } - el.seriesFirstAired.textContent = fallbackText(raw.first_air_time); - el.seriesNetwork.textContent = fallbackText(raw.network); - el.seriesStatus.textContent = fallbackText(raw.status); + el.seriesFirstAired.textContent = fallbackText(summary.first_aired || raw.first_air_time); + el.seriesNetwork.textContent = fallbackText(summary.network || raw.network); + el.seriesStatus.textContent = fallbackText(summary.status || raw.status); el.seriesOverview.textContent = overview || "No overview available."; if (tvdbUrl) { @@ -161,6 +167,11 @@ el.seriesDetails.classList.remove("hidden"); } + async function loadSeriesSummary(seriesId) { + const data = await api(`/api/tvdb/series/${encodeURIComponent(seriesId)}/summary`); + state.selectedSeriesSummary = data || null; + } + function updateMeta() { const epCount = state.selectedEpisodes.length; const fileCount = state.selectedFiles.length; @@ -262,8 +273,15 @@ const right = document.createElement("div"); right.appendChild(makeBtn("Select", async () => { state.selectedSeries = item; + state.selectedSeriesSummary = null; el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; renderSelectedSeriesDetails(); + try { + await loadSeriesSummary(item.id); + renderSelectedSeriesDetails(); + } catch (_err) { + // Keep UI responsive with search payload fallback if summary lookup fails. + } await loadEpisodes(); })); li.appendChild(left); diff --git a/app/static/styles.css b/app/static/styles.css index 510490b..6f0fb28 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -299,6 +299,21 @@ button.secondary { #panelSearch #searchResults { margin-bottom: 10px; + height: 220px; + max-height: 220px; + overflow-y: auto; +} + +#panelSearch #searchResults li { + min-height: 38px; + height: 38px; + align-items: center; +} + +#panelSearch #searchResults li > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .series-details { @@ -308,12 +323,18 @@ button.secondary { .series-media { margin-bottom: 8px; + width: 100%; + padding: 0 4px; } .series-media img { - width: 96px; - height: 144px; - object-fit: cover; + width: 100%; + max-width: none; + height: 92px; + object-fit: contain; + object-position: center center; + display: block; + margin: 0; border-radius: 6px; border: 1px solid #d7dee9; background: #f8fafc;