diff --git a/api/video/detail.py b/api/video/detail.py index cec423c0..e19c6ede 100644 --- a/api/video/detail.py +++ b/api/video/detail.py @@ -55,3 +55,14 @@ def register_routes(bp): logger.exception("refresh-art failed for show %s", show_id) res = {"ok": False, "reason": "error"} return jsonify(res) + + @bp.route("/detail/movie//refresh-art", methods=["POST"]) + def video_movie_refresh_art(movie_id): + """Lazy on-view backfill for a movie (cast / genres / backdrop / ratings).""" + try: + from core.video.enrichment.engine import get_video_enrichment_engine + res = get_video_enrichment_engine().refresh_movie_art(movie_id) + except Exception: + logger.exception("refresh-art failed for movie %s", movie_id) + res = {"ok": False, "reason": "error"} + return jsonify(res) diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 6f2b034a..d26f9338 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -93,6 +93,28 @@ class VideoEnrichmentEngine: logger.exception("refresh_show_art: episode cascade failed for show %s", show_id) return {"ok": True} + def refresh_movie_art(self, movie_id) -> dict: + """On-demand (lazy) backfill of a movie's cast / genres / backdrop / ratings + from TMDB when the detail page is opened and they're missing. Works + regardless of match status; caches the result.""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return {"ok": False, "reason": "tmdb_not_configured"} + info = self.db.movie_match_info(movie_id) + if not info: + return {"ok": False, "reason": "not_found"} + try: + result = w.client.match("movie", info.get("title"), info.get("year"), + known_id=info.get("tmdb_id")) + except Exception: + logger.exception("refresh_movie_art: match failed for movie %s", movie_id) + return {"ok": False, "reason": "match_error"} + if not result or not result.get("id"): + return {"ok": False, "reason": "no_match"} + self.db.enrichment_apply("tmdb", "movie", movie_id, matched=True, + external_id=result["id"], metadata=result.get("metadata")) + return {"ok": True} + def worker(self, service): return self.workers.get(service) diff --git a/database/video_database.py b/database/video_database.py index cac67c34..d6f7d5a0 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -315,6 +315,16 @@ class VideoDatabase: finally: conn.close() + def movie_match_info(self, movie_id: int) -> dict | None: + """Title/year/tmdb_id for one movie — for on-demand (lazy) refresh.""" + conn = self._get_connection() + try: + row = conn.execute("SELECT title, year, tmdb_id FROM movies WHERE id=?", + (movie_id,)).fetchone() + return dict(row) if row else None + finally: + conn.close() + def show_season_numbers(self, show_id: int) -> list: conn = self._get_connection() try: diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 14315dad..91edaf67 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -43,6 +43,7 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/detail/show/" in rules assert "/api/video/detail/movie/" in rules assert "/api/video/detail/show//refresh-art" in rules + assert "/api/video/detail/movie//refresh-art" in rules assert any(r.startswith("/api/video/backdrop/") for r in rules) diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index e8762110..4ce004c7 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -137,6 +137,27 @@ def test_show_match_info(db): assert db.show_match_info(999999) is None +def test_refresh_movie_art_backfills_cast_and_genres(db): + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 438631}) + + class C: + enabled = True + def match(self, kind, title, year, known_id=None): + assert kind == "movie" and known_id == 438631 + return {"id": 438631, "metadata": {"genres": ["Sci-Fi"], + "cast": [{"name": "Timothee", "tmdb_id": 1}]}} + + assert VideoEnrichmentEngine(db, {"tmdb": C()}).refresh_movie_art(mid)["ok"] is True + d = db.movie_detail(mid) + assert d["genres"] == ["Sci-Fi"] and d["cast"][0]["name"] == "Timothee" + + +def test_movie_match_info(db): + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "year": 2021, "tmdb_id": 438631}) + assert db.movie_match_info(mid) == {"title": "Dune", "year": 2021, "tmdb_id": 438631} + assert db.movie_match_info(999999) is None + + def test_enrichment_next_priority_pins_kind_first(db): db.upsert_movie("plex", {"server_id": "m1", "title": "M"}) db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 2a2022bb..9890be12 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -345,6 +345,21 @@ def test_show_detail_subpage_present(): assert "onclick" not in block +def test_movie_detail_subpage_present(): + block = _block( + _INDEX, r'
") + assert 'data-video-detail="movie"' in block + for hook in ('data-vd-backdrop', 'data-vd-title', 'data-vd-details', 'data-vd-cast'): + assert hook in block, hook + assert 'data-video-goto="video-library"' in block and "onclick" not in block + + +def test_library_movie_cards_are_clickable(): + # Both kinds drill in now (no show-only gate). + assert 'data-video-card-open="' in _LIB_JS + assert "kind === 'show' ?" not in _LIB_JS # the old show-only gate is gone + + def test_video_detail_module_referenced_and_isolated(): assert "video/video-detail.js" in _INDEX src = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8") diff --git a/webui/index.html b/webui/index.html index 0fff875c..c1a7d95d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -863,6 +863,38 @@
+ +