From 62636319b16c2192a42298dd423ccad8afd9679f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 5 Jul 2026 02:32:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20P1=20frontend=20=E2=80=94=20Plex?= =?UTF-8?q?=20feed=20source,=20browse/search,=20show=20drill-down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlexBrowse.tsx: self-contained Plex mode (library scope tabs, own search + sort, poster-card grid with watch state + playability tint, paged; show → seasons/episodes drill-down). Playback announces (P2 wires the real player). - Feed source dropdown gains a 'Plex' option (gated on me.plex_enabled); App swaps for when librarySource==='plex' so the video-feed hooks don't run in Plex mode. me exposes plex_enabled. - api client (plexLibraries/plexBrowse/plexShow/plexImageUrl) + types; librarySource union + share-URL restore gain 'plex'; new plex i18n namespace en/hu/de. - Verified over HTTP (authed): libraries, browse+FTS, image proxy 200 image/jpeg. --- backend/app/routes/me.py | 3 + frontend/src/App.tsx | 4 + frontend/src/components/Feed.tsx | 4 + frontend/src/components/PlexBrowse.tsx | 297 +++++++++++++++++++++++++ frontend/src/i18n/locales/de/feed.json | 3 +- frontend/src/i18n/locales/de/plex.json | 22 ++ frontend/src/i18n/locales/en/feed.json | 3 +- frontend/src/i18n/locales/en/plex.json | 22 ++ frontend/src/i18n/locales/hu/feed.json | 3 +- frontend/src/i18n/locales/hu/plex.json | 22 ++ frontend/src/lib/api.ts | 72 +++++- frontend/src/lib/urlState.ts | 2 +- 12 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/PlexBrowse.tsx create mode 100644 frontend/src/i18n/locales/de/plex.json create mode 100644 frontend/src/i18n/locales/en/plex.json create mode 100644 frontend/src/i18n/locales/hu/plex.json diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index a810f89..caac1c5 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -12,6 +12,7 @@ from app.auth import ( optional_current_user, purge_user, ) +from app import sysconfig from app.db import get_db from app.models import Invite, User @@ -98,6 +99,8 @@ def get_me( # Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides # YouTube-access affordances and the onboarding nudge when it isn't configured). "google_enabled": google_enabled(db), + # Whether the optional Plex module is enabled (UI shows the Plex feed source when so). + "plex_enabled": sysconfig.get_bool(db, "plex_enabled"), "can_read": has_read_scope(user), "can_write": has_write_scope(user), "pending_invites": pending_invites, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 40792fd..8171375 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -62,6 +62,7 @@ import { CURRENT_VERSION } from "./lib/releaseNotes"; // so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages // never reach non-admins. All are rendered inside a boundary below. const Feed = lazy(() => import("./components/Feed")); +const PlexBrowse = lazy(() => import("./components/PlexBrowse")); const ChannelPage = lazy(() => import("./components/ChannelPage")); const Channels = lazy(() => import("./components/Channels")); const Playlists = lazy(() => import("./components/Playlists")); @@ -781,6 +782,8 @@ export default function App() { prefs={prefsCtl} onOpenWizard={() => setWizardOpen(true)} /> + ) : meQuery.data!.plex_enabled && filters.librarySource === "plex" ? ( + ) : ( setWizardOpen(true)} ytSearch={ytSearch} onYtSearch={enterYtSearch} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 04a0ef5..fce6e32 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -74,6 +74,7 @@ export default function Feed({ view, canRead, isDemo = false, + plexEnabled = false, onOpenWizard, ytSearch, onYtSearch, @@ -86,6 +87,8 @@ export default function Feed({ view: "grid" | "list"; canRead: boolean; isDemo?: boolean; + // When the optional Plex module is on, offer "Plex" as a feed source (App swaps to PlexBrowse). + plexEnabled?: boolean; onOpenWizard: () => void; // Live YouTube search: the active search term (null = normal feed). Entering a search and // leaving it both step browser history (the search is a feed sub-view with its own history @@ -560,6 +563,7 @@ export default function Feed({ + {plexEnabled && } diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx new file mode 100644 index 0000000..ff16521 --- /dev/null +++ b/frontend/src/components/PlexBrowse.tsx @@ -0,0 +1,297 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { ArrowLeft, Film, Search, Tv2 } from "lucide-react"; +import { api, type FeedFilters, type PlexCard } from "../lib/api"; +import { notify } from "../lib/notifications"; +import { useDebounced } from "../lib/useDebounced"; + +// Plex mode of the feed: browse/search the mirrored Plex library as feed-style cards, drill from a +// show into seasons/episodes. Rendered by App in place of when filters.librarySource==="plex" +// (so the video-feed hooks never run in Plex mode). Playback lands in P2 — for now a card just +// announces it; shows drill down. Self-contained: its own library scope + search + sort + paging. + +type Props = { + filters: FeedFilters; + setFilters: (f: FeedFilters) => void; +}; + +const PAGE = 40; + +function secs(n?: number | null): string { + if (!n) return ""; + const h = Math.floor(n / 3600); + const m = Math.floor((n % 3600) / 60); + return h ? `${h}h ${m}m` : `${m}m`; +} + +export default function PlexBrowse({ filters, setFilters }: Props) { + const { t } = useTranslation(); + const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries }); + const libs = useMemo(() => libsQ.data?.libraries ?? [], [libsQ.data]); + + const [library, setLibrary] = useState(null); + const [q, setQ] = useState(""); + const [sort, setSort] = useState("added"); + const [openShow, setOpenShow] = useState(null); + const dq = useDebounced(q.trim(), 350); + + // Default to the first library once loaded. + useEffect(() => { + if (library === null && libs.length) setLibrary(libs[0].key); + }, [libs, library]); + + const activeLib = libs.find((l) => l.key === library); + + const browseQ = useInfiniteQuery({ + queryKey: ["plex-browse", library, dq, sort], + enabled: !!library && !openShow, + initialPageParam: 0, + queryFn: ({ pageParam }) => + api.plexBrowse({ library: library!, q: dq || undefined, sort, offset: pageParam as number, limit: PAGE }), + getNextPageParam: (last) => { + const seen = last.offset + last.items.length; + return seen < last.total ? seen : undefined; + }, + }); + + const items = browseQ.data?.pages.flatMap((p) => p.items) ?? []; + const total = browseQ.data?.pages[0]?.total ?? 0; + + function exitPlex() { + setFilters({ ...filters, librarySource: "organic", q: "" }); + } + function onPlay(card: PlexCard) { + // P2 wires the real player; announce for now so the flow is visible in P1. + notify({ level: "info", message: t("plex.playerSoon", { title: card.title }) }); + } + function onCard(card: PlexCard) { + if (card.type === "show") setOpenShow(card.id); + else onPlay(card); + } + + if (openShow) { + return setOpenShow(null)} onPlay={onPlay} onExit={exitPlex} />; + } + + return ( +
+ {/* Toolbar: back-to-YouTube, library scope, search, sort */} +
+ +
+ +

+ {dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })} +

+ + {browseQ.isLoading ? ( +

{t("plex.loading")}

+ ) : items.length === 0 ? ( +

{t("plex.empty")}

+ ) : ( +
+ {items.map((c) => ( + onCard(c)} /> + ))} +
+ )} + + {browseQ.hasNextPage && ( +
+ +
+ )} +
+ ); +} + +const PLAYABLE_TINT: Record = { + direct: "bg-emerald-500", + remux: "bg-amber-500", + transcode: "bg-orange-600", +}; + +function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void }) { + const { t } = useTranslation(); + const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; + const pct = + inProgress && card.duration_seconds + ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) + : 0; + return ( + + ); +} + +function PlexShowView({ + showId, + onBack, + onPlay, + onExit, +}: { + showId: string; + onBack: () => void; + onPlay: (c: PlexCard) => void; + onExit: () => void; +}) { + const { t } = useTranslation(); + const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) }); + const d = q.data; + + return ( +
+
+ + +
+ + {q.isLoading || !d ? ( +

{t("plex.loading")}

+ ) : ( + <> +
+ +
+

{d.show.title}

+ {d.show.year &&

{d.show.year}

} + {d.show.summary && ( +

{d.show.summary}

+ )} +
+
+ + {d.seasons.map((se) => ( +
+

{se.title}

+
+ {se.episodes.map((ep) => { + const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; + return ( + + ); + })} +
+
+ ))} + + )} +
+ ); +} diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json index 0696650..8008e5a 100644 --- a/frontend/src/i18n/locales/de/feed.json +++ b/frontend/src/i18n/locales/de/feed.json @@ -36,7 +36,8 @@ "tip": "Danach filtern, wie Videos hierher kamen: nur deine Abos/Katalog, samt deiner über Live-YouTube-Suche gefundenen Videos, oder nur diese Suchergebnisse.", "organic": "Ohne Suchergebnisse", "all": "Mit Suchergebnissen", - "only": "Nur Suchergebnisse" + "only": "Nur Suchergebnisse", + "plex": "Plex" }, "yt": { "searchFor": "Auf YouTube suchen nach „{{query}}”", diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json new file mode 100644 index 0000000..533cda0 --- /dev/null +++ b/frontend/src/i18n/locales/de/plex.json @@ -0,0 +1,22 @@ +{ + "backToFeed": "YouTube-Feed", + "backToLibrary": "Zurück zur Bibliothek", + "searchPlaceholder": "Plex durchsuchen…", + "sort": { + "added": "Kürzlich hinzugefügt", + "title": "Titel" + }, + "count": "{{count}} Titel", + "searchCount": "{{count}} Treffer", + "loading": "Wird geladen…", + "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", + "loadMore": "Mehr laden", + "watched": "Angesehen", + "seasons": "{{count}} Staffeln", + "playerSoon": "Player kommt bald — „{{title}}“", + "playable": { + "direct": "Spielt direkt im Browser", + "remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)", + "transcode": "Braucht Transcoding (aufwendiger)" + } +} diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 6781ba1..6901be1 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -36,7 +36,8 @@ "tip": "Filter by how videos got here: your subscriptions/catalog only, plus videos you found via live YouTube search, or only those search results.", "organic": "Hide search results", "all": "Include search results", - "only": "Search results only" + "only": "Search results only", + "plex": "Plex" }, "yt": { "searchFor": "Search YouTube for “{{query}}”", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json new file mode 100644 index 0000000..cf9d575 --- /dev/null +++ b/frontend/src/i18n/locales/en/plex.json @@ -0,0 +1,22 @@ +{ + "backToFeed": "YouTube feed", + "backToLibrary": "Back to library", + "searchPlaceholder": "Search Plex…", + "sort": { + "added": "Recently added", + "title": "Title" + }, + "count": "{{count}} titles", + "searchCount": "{{count}} matches", + "loading": "Loading…", + "empty": "Nothing here yet. Run a Plex sync from the admin Config page.", + "loadMore": "Load more", + "watched": "Watched", + "seasons": "{{count}} seasons", + "playerSoon": "Player coming soon — “{{title}}”", + "playable": { + "direct": "Plays directly in the browser", + "remux": "Needs a light remux (no video re-encode)", + "transcode": "Needs transcoding (heavier)" + } +} diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 54e02b9..396fedd 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -36,7 +36,8 @@ "tip": "Szűrés aszerint, hogyan kerültek be a videók: csak a feliratkozásaid/katalógus, az élő YouTube-keresésből talált videóiddal együtt, vagy csak azok a keresési találatok.", "organic": "Keresés nélkül", "all": "Kereséssel együtt", - "only": "Csak keresésből" + "only": "Csak keresésből", + "plex": "Plex" }, "yt": { "searchFor": "Keresés a YouTube-on: „{{query}}”", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json new file mode 100644 index 0000000..611b2ca --- /dev/null +++ b/frontend/src/i18n/locales/hu/plex.json @@ -0,0 +1,22 @@ +{ + "backToFeed": "YouTube feed", + "backToLibrary": "Vissza a könyvtárhoz", + "searchPlaceholder": "Keresés a Plexben…", + "sort": { + "added": "Nemrég hozzáadott", + "title": "Cím" + }, + "count": "{{count}} cím", + "searchCount": "{{count}} találat", + "loading": "Betöltés…", + "empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.", + "loadMore": "Több betöltése", + "watched": "Megnézve", + "seasons": "{{count}} évad", + "playerSoon": "A lejátszó hamarosan jön — „{{title}}”", + "playable": { + "direct": "Közvetlenül játszható a böngészőben", + "remux": "Könnyű remux kell (nincs videó-újrakódolás)", + "transcode": "Transcode kell (nehezebb)" + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5f92a79..3114648 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -13,6 +13,7 @@ export interface Me { has_google: boolean; has_password: boolean; google_enabled: boolean; + plex_enabled: boolean; can_read: boolean; can_write: boolean; pending_invites: number; @@ -179,7 +180,7 @@ export interface FeedFilters { show: string; // Library (scope "all") only — provenance filter: "organic" (default) hides search- // discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my". - librarySource?: "organic" | "all" | "search"; + librarySource?: "organic" | "all" | "search" | "plex"; channelId?: string; channelName?: string; maxAgeDays?: number; @@ -623,6 +624,54 @@ export interface PlexTestResult { sections: PlexSection[]; } +// Plex browse/search/drill-down (the mirrored catalog rendered as feed-style cards). +export interface PlexLibrary { + key: string; + title: string; + kind: "movie" | "show"; + count: number; +} +export interface PlexCard { + id: string; + type: "movie" | "show" | "episode"; + title: string; + year?: number | null; + duration_seconds?: number | null; + thumb: string; + playable?: string; // direct | remux | transcode + status?: string; // new | watched | hidden + position_seconds?: number; + season_count?: number | null; // show + season_number?: number | null; // episode + episode_number?: number | null; // episode + summary?: string | null; // episode +} +export interface PlexBrowseResult { + kind: "movie" | "show"; + total: number; + offset: number; + limit: number; + items: PlexCard[]; +} +export interface PlexSeasonDetail { + id: string; + season_number: number | null; + title: string; + thumb: string; + episodes: PlexCard[]; +} +export interface PlexShowDetail { + show: { + id: string; + title: string; + summary?: string | null; + year?: number | null; + thumb: string; + art: string; + }; + seasons: PlexSeasonDetail[]; +} + // Admin Users & roles page. export interface AdminUserRow { id: number; @@ -936,6 +985,27 @@ export const api = { testEmail: (): Promise<{ sent: boolean; to: string }> => req("/api/admin/config/test-email", { method: "POST" }), testPlex: (): Promise => req("/api/plex/test", { method: "POST" }), + syncPlex: (): Promise> => req("/api/plex/sync", { method: "POST" }), + plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> => + req("/api/plex/libraries"), + plexBrowse: (p: { + library: string; + q?: string; + sort?: string; + offset?: number; + limit?: number; + }): Promise => { + const u = new URLSearchParams({ library: p.library }); + if (p.q) u.set("q", p.q); + if (p.sort) u.set("sort", p.sort); + if (p.offset) u.set("offset", String(p.offset)); + if (p.limit) u.set("limit", String(p.limit)); + return req(`/api/plex/browse?${u.toString()}`); + }, + plexShow: (id: string): Promise => + req(`/api/plex/show/${encodeURIComponent(id)}`), + plexImageUrl: (id: string, variant?: "thumb" | "art"): string => + `/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`, // --- admin: users & roles --- adminUsers: (): Promise => req("/api/admin/users"), setUserRole: (id: number, role: "user" | "admin"): Promise => diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 9265993..004e2df 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -61,7 +61,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my"; if (params.has("source")) { const s = params.get("source"); - f.librarySource = s === "all" || s === "search" ? s : "organic"; + f.librarySource = s === "all" || s === "search" || s === "plex" ? s : "organic"; } if (params.has("normal")) f.includeNormal = params.get("normal") !== "0"; if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";