From 690e17611c72139157a246b78680545b306a1e0a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 22:57:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20rich=20media=20info=20=E2=80=94?= =?UTF-8?q?=20info=20page,=20in-player=20info=20overlay,=20IMDb=20+=20cast?= =?UTF-8?q?=20photos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (no migration): item_detail now also returns IMDb score + id/url (from the Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast photos from Plex's public metadata CDN (keeps third-party requests off the browser). Frontend: reusable PlexInfo component in two forms — a full info page opened from a card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume cover the watched-reset gap. i18n en/hu/de. --- VERSION | 2 +- backend/app/routes/plex.py | 92 +++++++- frontend/src/components/PlexBrowse.tsx | 79 ++++++- frontend/src/components/PlexInfo.tsx | 296 +++++++++++++++++++++++++ frontend/src/components/PlexPlayer.tsx | 40 +++- frontend/src/i18n/locales/de/plex.json | 14 ++ frontend/src/i18n/locales/en/plex.json | 14 ++ frontend/src/i18n/locales/hu/plex.json | 14 ++ frontend/src/lib/api.ts | 15 +- frontend/src/lib/releaseNotes.ts | 11 + 10 files changed, 568 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/PlexInfo.tsx diff --git a/VERSION b/VERSION index 286d5b0..94a5fe4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.0 \ No newline at end of file +0.25.0 \ No newline at end of file diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 7a761f2..b33e79c 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -7,7 +7,9 @@ catalog sync. """ import logging from datetime import datetime, timezone +from urllib.parse import quote +import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi.responses import FileResponse from sqlalchemy import and_, func, or_ @@ -339,6 +341,82 @@ _PROGRESS_MIN_S = 5 _FINISH_MARGIN_S = 30 +# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted) +# so the user's browser makes no third-party request (privacy + self-host CSP friendliness). +_PERSON_IMG_HOST = "https://metadata-static.plex.tv/" + + +def _rich_meta(meta: dict) -> dict: + """Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content + rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any + missing field is just omitted.""" + # Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating. + imdb_rating = None + for r in meta.get("Rating") or []: + if str(r.get("image") or "").startswith("imdb://"): + try: + imdb_rating = round(float(r.get("value")), 1) + except (TypeError, ValueError): + pass + break + if imdb_rating is None: + try: + imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1) + except (TypeError, ValueError): + imdb_rating = None + # IMDb id (tt…) from the Guid list → a clickable link. + imdb_id = None + for g in meta.get("Guid") or []: + gid = str(g.get("id") or "") + if gid.startswith("imdb://"): + imdb_id = gid.split("imdb://", 1)[1] + break + cast = [] + for role in (meta.get("Role") or [])[:24]: + name = role.get("tag") + if not name: + continue + thumb = str(role.get("thumb") or "") + cast.append( + { + "name": name, + "role": role.get("role"), + "thumb": f"/api/plex/person-image?u={quote(thumb, safe='')}" + if thumb.startswith(_PERSON_IMG_HOST) + else None, + } + ) + return { + "imdb_rating": imdb_rating, + "imdb_id": imdb_id, + "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, + "content_rating": meta.get("contentRating"), + "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], + "directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4], + "studio": meta.get("studio"), + "tagline": meta.get("tagline"), + "cast": cast, + } + + +@router.get("/person-image") +def person_image(u: str, _: User = Depends(current_user)) -> Response: + """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).""" + if not u.startswith(_PERSON_IMG_HOST): + raise HTTPException(status_code=400, detail="Unsupported image host") + try: + with httpx.Client(timeout=10.0, follow_redirects=True) as c: + r = c.get(u) + r.raise_for_status() + except httpx.HTTPError: + raise HTTPException(status_code=502, detail="Image fetch failed") + return Response( + content=r.content, + media_type=r.headers.get("content-type", "image/jpeg"), + headers={"Cache-Control": "public, max-age=604800"}, + ) + + def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: if it.kind != "episode" or it.show_id is None: return None, None @@ -367,14 +445,14 @@ def item_detail( it = _item_or_404(db, rating_key) st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() - cast: list[str] = [] + rich: dict = {} markers: list[dict] = [] audio_streams: list[dict] = [] subtitle_streams: list[dict] = [] try: with PlexClient(db) as plex: meta = plex.metadata(it.rating_key, markers=True) or {} - cast = [r.get("tag") for r in (meta.get("Role") or []) if r.get("tag")][:12] + rich = _rich_meta(meta) for m in meta.get("Marker") or []: if m.get("type") in ("intro", "credits"): markers.append( @@ -419,7 +497,15 @@ def item_detail( "playable": it.playable, "thumb": f"/api/plex/image/{it.rating_key}", "art": f"/api/plex/image/{it.rating_key}?variant=art", - "cast": cast, + "cast": rich.get("cast", []), + "imdb_rating": rich.get("imdb_rating"), + "imdb_id": rich.get("imdb_id"), + "imdb_url": rich.get("imdb_url"), + "content_rating": rich.get("content_rating"), + "genres": rich.get("genres", []), + "directors": rich.get("directors", []), + "studio": rich.get("studio"), + "tagline": rich.get("tagline"), "markers": markers, "audio_streams": audio_streams, "subtitle_streams": subtitle_streams, diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 96862ad..9b9024c 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -1,15 +1,20 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, CheckCircle2, Play } from "lucide-react"; +import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react"; import { api, type PlexCard } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview } from "../lib/history"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); +const PlexInfo = lazy(() => import("./PlexInfo")); -type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string }; +type Sub = + | { kind: "grid" } + | { kind: "show"; id: string } + | { kind: "player"; id: string } + | { kind: "info"; id: string }; // The Plex module's content area: browse/search the mirrored library as poster cards, drill from a // show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the @@ -83,6 +88,10 @@ export default function PlexBrowse({ q, library, show, sort }: Props) { if (card.type === "show") sub.open({ kind: "show", id: card.id }); else sub.open({ kind: "player", id: card.id }); } + function onInfo(card: PlexCard) { + scrollRef.current = scroller()?.scrollTop ?? 0; + sub.open({ kind: "info", id: card.id }); + } async function toggleWatched(card: PlexCard) { await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched"); qc.invalidateQueries({ queryKey: ["plex-browse"] }); @@ -95,6 +104,16 @@ export default function PlexBrowse({ q, library, show, sort }: Props) { ); } + if (sub.view.kind === "info") { + const infoId = sub.view.id; + return ( + sub.open({ kind: "player", id: infoId })} + /> + ); + } if (sub.view.kind === "show") { return ( {items.map((c) => ( - onCard(c)} onToggleWatched={toggleWatched} /> + onCard(c)} + onInfo={() => onInfo(c)} + onToggleWatched={toggleWatched} + /> ))} )} @@ -138,10 +163,12 @@ const PLAYABLE_TINT: Record = { function PlexPosterCard({ card, onOpen, + onInfo, onToggleWatched, }: { card: PlexCard; onOpen: () => void; + onInfo: () => void; onToggleWatched: (c: PlexCard) => void; }) { const { t } = useTranslation(); @@ -200,6 +227,19 @@ function PlexPosterCard({ )} + {/* Media info page (movies/episodes). */} + {isPlayable && ( + + )} {/* Watched badge when not hovering (the toggle replaces it on hover). */} {card.status === "watched" && ( @@ -229,6 +269,39 @@ function PlexPosterCard({ ); } +function PlexInfoView({ + id, + onBack, + onPlay, +}: { + id: string; + onBack: () => void; + onPlay: () => void; +}) { + const { t } = useTranslation(); + const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) }); + return ( +
+
+ +
+ {q.isLoading || !q.data ? ( +

{t("plex.loading")}

+ ) : ( + {t("plex.loading")}

}> + +
+ )} +
+ ); +} + function PlexShowView({ showId, onBack, diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx new file mode 100644 index 0000000..2b8f71e --- /dev/null +++ b/frontend/src/components/PlexInfo.tsx @@ -0,0 +1,296 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Check, + ExternalLink, + Play, + RotateCcw, + SlidersHorizontal, + Star, + X, +} from "lucide-react"; +import { api, type PlexItemDetail } from "../lib/api"; + +// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, +// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from +// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay +// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable +// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row. + +type Props = { + detail: PlexItemDetail; + variant: "page" | "overlay"; + onPlay?: () => void; // page: start/resume playback + onClose?: () => void; // overlay: dismiss + onStateChange?: () => void; // after a watch-state change, let the opener refresh +}; + +function fmtDur(s?: number | null): string { + if (!s) return ""; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return h ? `${h}h ${m}m` : `${m}m`; +} + +export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange }: Props) { + const { t } = useTranslation(); + const qc = useQueryClient(); + + // Personal display prefs (default ON). Stored per-user; the backend merges any pref key. + const prefs = (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ?? + {}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean }; + const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false); + const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false); + const [customizing, setCustomizing] = useState(false); + const savePref = (patch: Record) => { + api.savePrefs(patch).catch(() => {}); + qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => + m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, + ); + }; + + const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; + const setState = async (status: "new" | "watched") => { + await api.plexSetState(detail.id, status).catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-item", detail.id] }); + qc.invalidateQueries({ queryKey: ["plex-browse"] }); + qc.invalidateQueries({ queryKey: ["plex-show"] }); + onStateChange?.(); + }; + + const meta = [ + detail.year, + detail.content_rating, + fmtDur(detail.duration_seconds), + detail.studio, + ].filter(Boolean); + + const cast = showCast ? detail.cast : []; + const overlay = variant === "overlay"; + + return ( +
+ {/* Faint art backdrop (page only, toggleable). */} + {!overlay && artBg && detail.art && ( +
+ +
+
+ )} + +
+ {/* Header: close (overlay) / customize */} +
+
+

+ {detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title} +

+ {detail.kind === "episode" && ( +

+ S{detail.season_number}·E{detail.episode_number} — {detail.title} +

+ )} +
+
+ + {customizing && ( +
+ { + setArtBg((v) => !v); + savePref({ plexInfoArtBg: !artBg }); + }} + /> + { + setShowCast((v) => !v); + savePref({ plexInfoCast: !showCast }); + }} + /> +
+ )} +
+ {overlay && onClose && ( + + )} +
+ +
+ {/* Poster */} + + +
+ {/* Meta line + IMDb rating/link */} +
+ {meta.map((m, i) => ( + + {m} + + ))} + {detail.imdb_rating != null && ( + + + {detail.imdb_rating.toFixed(1)} + {detail.imdb_url && } + + )} +
+ + {detail.tagline && ( +

+ {detail.tagline} +

+ )} + + {detail.genres.length > 0 && ( +
+ {detail.genres.map((g) => ( + + {g} + + ))} +
+ )} + + {detail.directors.length > 0 && ( +

+ {t("plex.info.director")}: {detail.directors.join(", ")} +

+ )} + + {detail.summary && ( +

+ {detail.summary} +

+ )} + + {/* Page actions: Play/Resume + watch controls */} + {!overlay && ( +
+ {onPlay && ( + + )} + {detail.status === "watched" ? ( + + ) : ( + + )} + {inProgress && ( + + )} +
+ )} +
+
+ + {/* Cast row (toggleable), circular photos. */} + {cast.length > 0 && ( +
+

+ {t("plex.info.cast")} +

+
+ {cast.map((c, i) => ( +
+
+ {c.thumb ? ( + + ) : ( +
+ {c.name.charAt(0)} +
+ )} +
+
+ {c.name} +
+ {c.role && ( +
+ {c.role} +
+ )} +
+ ))} +
+
+ )} +
+
+ ); +} + +function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { + return ( + + ); +} diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 28f62ed..0df196c 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Hls from "hls.js"; @@ -6,6 +6,7 @@ import { ArrowLeft, Clock, Download, + Info, Keyboard, Maximize, Minimize, @@ -22,6 +23,9 @@ import { } from "lucide-react"; import { api, type PlexMarker } from "../lib/api"; +// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. +const PlexInfo = lazy(() => import("./PlexInfo")); + // The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw // (native
)} + setInfoOpen((v) => !v)}> + + setHelpOpen((v) => !v)}> @@ -686,6 +703,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) { [["↑", "↓"], "plex.player.keys.volume"], [["M"], "plex.player.keys.mute"], [["F"], "plex.player.keys.fullscreen"], + [["I"], "plex.player.keys.info"], [["⌫", "Esc"], "plex.player.keys.back"], [["H"], "plex.player.keys.help"], ] as [string[], string][] @@ -708,6 +726,26 @@ export default function PlexPlayer({ itemId, onClose }: Props) { )} + + {/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */} + {infoOpen && detail && ( +
{ + e.stopPropagation(); + setInfoOpen(false); + }} + > +
e.stopPropagation()} + > + + setInfoOpen(false)} /> + +
+
+ )} ); } diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index a41342b..473eaa9 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -53,6 +53,7 @@ "errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen.", "stop": "Stopp", "help": "Tastenkürzel", + "infoBtn": "Medieninfo", "keys": { "playPause": "Wiedergabe / Pause", "seek": "10 Sekunden spulen", @@ -60,10 +61,23 @@ "volume": "Lautstärke lauter / leiser", "mute": "Stumm", "fullscreen": "Vollbild", + "info": "Medieninfo", "back": "Stopp & zurück zum Feed", "help": "Diese Hilfe anzeigen" } }, + "info": { + "title": "Medieninfo", + "customize": "Ansicht anpassen", + "prefArtBg": "Dezenter Hintergrund", + "prefCast": "Besetzung", + "openImdb": "Bei IMDb öffnen", + "director": "Regie", + "cast": "Besetzung & Crew", + "markWatched": "Als gesehen markieren", + "markUnwatched": "Gesehen zurücknehmen", + "clearResume": "Fortsetzungsposition löschen" + }, "playable": { "direct": "Spielt direkt im Browser", "remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 7c85b68..2030ba1 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -53,6 +53,7 @@ "errGeneric": "Playback couldn't start. Please try again.", "stop": "Stop", "help": "Keyboard shortcuts", + "infoBtn": "Media info", "keys": { "playPause": "Play / pause", "seek": "Seek 10 seconds", @@ -60,10 +61,23 @@ "volume": "Volume up / down", "mute": "Mute", "fullscreen": "Fullscreen", + "info": "Media info", "back": "Stop & back to feed", "help": "Show this help" } }, + "info": { + "title": "Media info", + "customize": "Customize view", + "prefArtBg": "Faint backdrop", + "prefCast": "Cast & crew", + "openImdb": "Open on IMDb", + "director": "Director", + "cast": "Cast & crew", + "markWatched": "Mark watched", + "markUnwatched": "Mark unwatched", + "clearResume": "Clear resume position" + }, "playable": { "direct": "Plays directly in the browser", "remux": "Needs a light remux (no video re-encode)", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 8162267..c3c60d0 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -53,6 +53,7 @@ "errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.", "stop": "Leállítás", "help": "Billentyűparancsok", + "infoBtn": "Média infó", "keys": { "playPause": "Lejátszás / szünet", "seek": "Tekerés 10 másodperc", @@ -60,10 +61,23 @@ "volume": "Hangerő fel / le", "mute": "Némítás", "fullscreen": "Teljes képernyő", + "info": "Média infó", "back": "Leállítás és vissza a feedre", "help": "Súgó megjelenítése" } }, + "info": { + "title": "Média infó", + "customize": "Nézet testreszabása", + "prefArtBg": "Halvány háttér", + "prefCast": "Szereplők", + "openImdb": "Megnyitás az IMDb-n", + "director": "Rendező", + "cast": "Szereplők & stáb", + "markWatched": "Megjelölés megnézettként", + "markUnwatched": "Megnézett visszavonása", + "clearResume": "Folytatási pozíció törlése" + }, "playable": { "direct": "Közvetlenül játszható a böngészőben", "remux": "Könnyű remux kell (nincs videó-újrakódolás)", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a8df7d..2d56c16 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -695,7 +695,15 @@ export interface PlexItemDetail { playable: string; // direct | remux | transcode thumb: string; art: string; - cast: string[]; + cast: PlexCastMember[]; + imdb_rating?: number | null; + imdb_id?: string | null; + imdb_url?: string | null; + content_rating?: string | null; + genres: string[]; + directors: string[]; + studio?: string | null; + tagline?: string | null; markers: PlexMarker[]; audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[]; subtitle_streams: { ord: number; label: string; language?: string | null }[]; @@ -707,6 +715,11 @@ export interface PlexItemDetail { prev_id?: string | null; next_id?: string | null; } +export interface PlexCastMember { + name: string; + role?: string | null; + thumb?: string | null; // proxied person-image url, or null when Plex has no photo +} export interface PlexPlaySession { mode: "direct" | "hls"; url: string; diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index a005b2c..bb579ac 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,17 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.25.0", + date: "2026-07-05", + summary: "Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos.", + features: [ + "Plex: every movie/episode now has a media info page (the “i” button on a card) with the poster, IMDb score + a link to IMDb, content rating, genres, director, a full cast list with photos, and the synopsis — plus Play/Resume and watch controls.", + "Plex player: press “I” (or the info button) for the same rich info as an overlay, without stopping playback.", + "Plex info: two view preferences you can toggle and that stick — a faint art backdrop and the cast & crew row.", + "Plex: you can now mark a title unwatched or clear its resume position from the info page.", + ], + }, { version: "0.24.0", date: "2026-07-05",