diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-06 17:20:35 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-06 17:20:35 -0700 |
| commit | 2d298f982408f222ad344b2aa9c18bbe7dc70f12 (patch) | |
| tree | 7347dff5f45789189032bee2821c3cd314b69625 | |
| parent | 4bbafdd460945eb506ddb07b9068731245708812 (diff) | |
Add TMDB poster picker to film detail page
- New movie_images() async function in services/tmdb.py fetches poster
URLs from TMDB /movie/{id}/images endpoint, filtering for English
and no-text posters only
- New GET /tmdb/posters endpoint returns list of available posters for
a TMDB ID
- New POST /films/{film_id}/poster endpoint to save selected poster
(mirrors the stars endpoint pattern)
- Add "Change Poster" button on detail page (only shown if film has a
TMDB ID) that opens a 3-column grid of posters
- Selected poster gets accent border, main image updates instantly, no
page reload needed
- Posters are cached per load to avoid refetching on re-open
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
| -rw-r--r-- | routers/films.py | 17 | ||||
| -rw-r--r-- | routers/tmdb.py | 15 | ||||
| -rw-r--r-- | services/tmdb.py | 20 | ||||
| -rw-r--r-- | templates/detail.html | 96 |
4 files changed, 147 insertions, 1 deletions
diff --git a/routers/films.py b/routers/films.py index ec56c0a..4ac023c 100644 --- a/routers/films.py +++ b/routers/films.py @@ -467,6 +467,23 @@ async def update_film_stars(film_id: int, request: Request, db: Session = Depend return {"film_id": film.id, "stars": film.stars} +@router.post("/films/{film_id}/poster") +async def update_film_poster(film_id: int, request: Request, db: Session = Depends(get_db)): + film = _get_film_or_404(db, film_id) + try: + payload = await request.json() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail="Request body must be JSON.") from exc + + poster_url = payload.get("poster_url", "").strip() + if not poster_url: + raise HTTPException(status_code=400, detail="poster_url is required.") + + film.poster_url = poster_url + db.commit() + return {"film_id": film.id, "poster_url": film.poster_url} + + @router.get("/director/{director_name}") async def director_detail(director_name: str, request: Request, db: Session = Depends(get_db)): films = _director_films(db, director_name) diff --git a/routers/tmdb.py b/routers/tmdb.py index 522c1d0..5f7943f 100644 --- a/routers/tmdb.py +++ b/routers/tmdb.py @@ -3,7 +3,7 @@ from dotenv import load_dotenv from fastapi import APIRouter, Query from fastapi.responses import JSONResponse -from services.tmdb import TMDBNotConfiguredError, search_movies +from services.tmdb import TMDBNotConfiguredError, search_movies, movie_images load_dotenv() @@ -30,3 +30,16 @@ async def search_tmdb(q: str = Query(..., min_length=2)): "results": [], }, ) + + +@router.get("/posters") +async def tmdb_posters(tmdb_id: int = Query(...)): + try: + async with httpx.AsyncClient(timeout=10.0) as client: + urls = await movie_images(client, tmdb_id) + return {"posters": urls} + except TMDBNotConfiguredError: + return JSONResponse( + status_code=503, + content={"error": "TMDB_API_KEY is not configured.", "posters": []}, + ) diff --git a/services/tmdb.py b/services/tmdb.py index 3618168..3574f57 100644 --- a/services/tmdb.py +++ b/services/tmdb.py @@ -84,6 +84,26 @@ async def movie_detail(client: httpx.AsyncClient, movie_id: int, key: str | None return response.json() +async def movie_images(client: httpx.AsyncClient, movie_id: int) -> list[str]: + """Return list of poster URLs for a TMDB movie ID.""" + try: + url = f"https://api.themoviedb.org/3/movie/{movie_id}/images" + response = await client.get( + url, + params={"api_key": api_key(), "include_image_language": "en,null"}, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + return [] + + return [ + poster_url(p["file_path"]) + for p in data.get("posters", []) + if p.get("file_path") + ] + + def movie_payload(movie: dict, fallback: dict | None = None) -> dict: fallback = fallback or {} release_date = movie.get("release_date") or fallback.get("release_date") diff --git a/templates/detail.html b/templates/detail.html index 59779d8..20897b0 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -12,6 +12,23 @@ <span>{{ film.title[:1] }}</span> {% endif %} </div> + + {% if film.tmdb_id %} + <button + type="button" + id="change-poster-btn" + class="secondary-button" + style="width: 100%; margin-top: 12px;" + data-tmdb-id="{{ film.tmdb_id }}" + data-film-id="{{ film.id }}" + >Change Poster</button> + + <div id="poster-picker" style="display: none; margin-top: 12px;"> + <p id="poster-picker-status" style="color: var(--muted); font-size: 0.86rem; margin: 0 0 10px;">Loading posters…</p> + <div id="poster-grid" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;"></div> + </div> + {% endif %} + <div class="detail-aside-meta"> <div> <span class="summary-label">Shelf</span> @@ -122,4 +139,83 @@ </div> </section> </article> + +{% if film.tmdb_id %} +<script> +(function () { + const btn = document.getElementById("change-poster-btn"); + const picker = document.getElementById("poster-picker"); + const grid = document.getElementById("poster-grid"); + const status = document.getElementById("poster-picker-status"); + const filmId = btn.dataset.filmId; + const tmdbId = btn.dataset.tmdbId; + + let loaded = false; + + btn.addEventListener("click", async () => { + if (picker.style.display === "none") { + picker.style.display = "block"; + btn.textContent = "Close"; + } else { + picker.style.display = "none"; + btn.textContent = "Change Poster"; + return; + } + + if (loaded) return; + status.textContent = "Loading posters…"; + status.style.display = "block"; + + try { + const resp = await fetch(`/tmdb/posters?tmdb_id=${tmdbId}`); + if (!resp.ok) throw new Error("fetch failed"); + const data = await resp.json(); + status.style.display = "none"; + + if (!data.posters.length) { + status.textContent = "No posters found."; + status.style.display = "block"; + return; + } + + grid.innerHTML = data.posters.map((url) => ` + <button type="button" class="poster-option" data-url="${url}" style="padding: 0; border: 2px solid transparent; border-radius: 6px; overflow: hidden; cursor: pointer; background: none;"> + <img src="${url}" alt="Poster option" loading="lazy" style="width: 100%; display: block; aspect-ratio: 2/3; object-fit: cover;"> + </button> + `).join(""); + + grid.querySelectorAll(".poster-option").forEach((optBtn) => { + optBtn.addEventListener("click", async () => { + const url = optBtn.dataset.url; + + grid.querySelectorAll(".poster-option").forEach((b) => b.style.borderColor = "transparent"); + optBtn.style.borderColor = "var(--accent)"; + + try { + const saveResp = await fetch(`/films/${filmId}/poster`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ poster_url: url }), + }); + if (!saveResp.ok) return; + + const posterImg = document.querySelector(".poster-large img"); + if (posterImg) posterImg.src = url; + picker.style.display = "none"; + btn.textContent = "Change Poster"; + } catch (err) { + console.error("Failed to save poster", err); + } + }); + }); + + loaded = true; + } catch (err) { + status.textContent = "Failed to load posters."; + console.error(err); + } + }); +})(); +</script> +{% endif %} {% endblock %} |
