summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 17:20:35 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-06 17:20:35 -0700
commit2d298f982408f222ad344b2aa9c18bbe7dc70f12 (patch)
tree7347dff5f45789189032bee2821c3cd314b69625
parent4bbafdd460945eb506ddb07b9068731245708812 (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.py17
-rw-r--r--routers/tmdb.py15
-rw-r--r--services/tmdb.py20
-rw-r--r--templates/detail.html96
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 %}