feat(plex): P1 frontend — Plex feed source, browse/search, show drill-down
- 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 <Feed> for <PlexBrowse> 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.
This commit is contained in:
parent
63c6e782c8
commit
62636319b1
12 changed files with 452 additions and 5 deletions
|
|
@ -12,6 +12,7 @@ from app.auth import (
|
||||||
optional_current_user,
|
optional_current_user,
|
||||||
purge_user,
|
purge_user,
|
||||||
)
|
)
|
||||||
|
from app import sysconfig
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Invite, User
|
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
|
# 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).
|
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||||
"google_enabled": google_enabled(db),
|
"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_read": has_read_scope(user),
|
||||||
"can_write": has_write_scope(user),
|
"can_write": has_write_scope(user),
|
||||||
"pending_invites": pending_invites,
|
"pending_invites": pending_invites,
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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 <Suspense> boundary below.
|
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
|
||||||
const Feed = lazy(() => import("./components/Feed"));
|
const Feed = lazy(() => import("./components/Feed"));
|
||||||
|
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
|
||||||
const ChannelPage = lazy(() => import("./components/ChannelPage"));
|
const ChannelPage = lazy(() => import("./components/ChannelPage"));
|
||||||
const Channels = lazy(() => import("./components/Channels"));
|
const Channels = lazy(() => import("./components/Channels"));
|
||||||
const Playlists = lazy(() => import("./components/Playlists"));
|
const Playlists = lazy(() => import("./components/Playlists"));
|
||||||
|
|
@ -781,6 +782,8 @@ export default function App() {
|
||||||
prefs={prefsCtl}
|
prefs={prefsCtl}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
) : meQuery.data!.plex_enabled && filters.librarySource === "plex" ? (
|
||||||
|
<PlexBrowse filters={filters} setFilters={setFilters} />
|
||||||
) : (
|
) : (
|
||||||
<Feed
|
<Feed
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
@ -788,6 +791,7 @@ export default function App() {
|
||||||
view={view}
|
view={view}
|
||||||
canRead={meQuery.data!.can_read}
|
canRead={meQuery.data!.can_read}
|
||||||
isDemo={meQuery.data!.is_demo}
|
isDemo={meQuery.data!.is_demo}
|
||||||
|
plexEnabled={meQuery.data!.plex_enabled}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
ytSearch={ytSearch}
|
ytSearch={ytSearch}
|
||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export default function Feed({
|
||||||
view,
|
view,
|
||||||
canRead,
|
canRead,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
|
plexEnabled = false,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
ytSearch,
|
ytSearch,
|
||||||
onYtSearch,
|
onYtSearch,
|
||||||
|
|
@ -86,6 +87,8 @@ export default function Feed({
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
|
// When the optional Plex module is on, offer "Plex" as a feed source (App swaps to PlexBrowse).
|
||||||
|
plexEnabled?: boolean;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
// Live YouTube search: the active search term (null = normal feed). Entering a search and
|
// 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
|
// 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({
|
||||||
<option value="organic">{t("feed.source.organic")}</option>
|
<option value="organic">{t("feed.source.organic")}</option>
|
||||||
<option value="all">{t("feed.source.all")}</option>
|
<option value="all">{t("feed.source.all")}</option>
|
||||||
<option value="search">{t("feed.source.only")}</option>
|
<option value="search">{t("feed.source.only")}</option>
|
||||||
|
{plexEnabled && <option value="plex">{t("feed.source.plex")}</option>}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
297
frontend/src/components/PlexBrowse.tsx
Normal file
297
frontend/src/components/PlexBrowse.tsx
Normal file
|
|
@ -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 <Feed> 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<string | null>(null);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [sort, setSort] = useState("added");
|
||||||
|
const [openShow, setOpenShow] = useState<string | null>(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 <PlexShowView showId={openShow} onBack={() => setOpenShow(null)} onPlay={onPlay} onExit={exitPlex} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-[1600px] mx-auto">
|
||||||
|
{/* Toolbar: back-to-YouTube, library scope, search, sort */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={exitPlex}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||||
|
title={t("plex.backToFeed")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
{t("plex.backToFeed")}
|
||||||
|
</button>
|
||||||
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
{libs.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.key}
|
||||||
|
onClick={() => setLibrary(l.key)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition ${
|
||||||
|
l.key === library ? "bg-accent text-white" : "glass-card glass-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.kind === "movie" ? <Film className="w-3.5 h-3.5" /> : <Tv2 className="w-3.5 h-3.5" />}
|
||||||
|
{l.title}
|
||||||
|
<span className="opacity-60">{l.count.toLocaleString()}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative ml-auto">
|
||||||
|
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t("plex.searchPlaceholder")}
|
||||||
|
className="bg-card border border-border rounded-lg pl-8 pr-3 py-1.5 text-sm w-56 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value)}
|
||||||
|
className="bg-card border border-border rounded-lg px-2 py-1.5 text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="added">{t("plex.sort.added")}</option>
|
||||||
|
<option value="title">{t("plex.sort.title")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted mb-3">
|
||||||
|
{dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{browseQ.isLoading ? (
|
||||||
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm">{t("plex.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
|
{items.map((c) => (
|
||||||
|
<PlexPosterCard key={c.id} card={c} onClick={() => onCard(c)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{browseQ.hasNextPage && (
|
||||||
|
<div className="flex justify-center mt-5">
|
||||||
|
<button
|
||||||
|
onClick={() => browseQ.fetchNextPage()}
|
||||||
|
disabled={browseQ.isFetchingNextPage}
|
||||||
|
className="glass-card glass-hover px-4 py-2 rounded-xl text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{browseQ.isFetchingNextPage ? t("plex.loading") : t("plex.loadMore")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYABLE_TINT: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<button onClick={onClick} className="group text-left">
|
||||||
|
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
|
||||||
|
<img
|
||||||
|
src={card.thumb}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
{card.status === "watched" && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
|
||||||
|
{t("plex.watched")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.type === "show" && card.season_count != null && (
|
||||||
|
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
|
||||||
|
{t("plex.seasons", { count: card.season_count })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.playable && (
|
||||||
|
<span
|
||||||
|
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||||
|
title={t(`plex.playable.${card.playable}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{pct > 0 && (
|
||||||
|
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||||
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{[card.year, secs(card.duration_seconds)].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-4 max-w-[1200px] mx-auto">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
{t("plex.backToLibrary")}
|
||||||
|
</button>
|
||||||
|
<button onClick={onExit} className="text-xs text-muted hover:text-fg ml-1">
|
||||||
|
{t("plex.backToFeed")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{q.isLoading || !d ? (
|
||||||
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 mb-6">
|
||||||
|
<img
|
||||||
|
src={d.show.thumb}
|
||||||
|
alt=""
|
||||||
|
className="w-32 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-xl font-semibold">{d.show.title}</h1>
|
||||||
|
{d.show.year && <p className="text-sm text-muted">{d.show.year}</p>}
|
||||||
|
{d.show.summary && (
|
||||||
|
<p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{d.seasons.map((se) => (
|
||||||
|
<div key={se.id} className="mb-6">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">{se.title}</h2>
|
||||||
|
<div className="flex flex-col divide-y divide-border/60">
|
||||||
|
{se.episodes.map((ep) => {
|
||||||
|
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ep.id}
|
||||||
|
onClick={() => onPlay(ep)}
|
||||||
|
className="flex items-center gap-3 py-2 text-left group"
|
||||||
|
>
|
||||||
|
<div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0">
|
||||||
|
<img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
|
||||||
|
{ep.status === "watched" && (
|
||||||
|
<span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium truncate">
|
||||||
|
<span className="text-muted mr-1.5">{ep.episode_number}.</span>
|
||||||
|
{ep.title}
|
||||||
|
</div>
|
||||||
|
{ep.summary && (
|
||||||
|
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[11px] text-muted mt-0.5">{secs(ep.duration_seconds)}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
"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",
|
"organic": "Ohne Suchergebnisse",
|
||||||
"all": "Mit Suchergebnissen",
|
"all": "Mit Suchergebnissen",
|
||||||
"only": "Nur Suchergebnisse"
|
"only": "Nur Suchergebnisse",
|
||||||
|
"plex": "Plex"
|
||||||
},
|
},
|
||||||
"yt": {
|
"yt": {
|
||||||
"searchFor": "Auf YouTube suchen nach „{{query}}”",
|
"searchFor": "Auf YouTube suchen nach „{{query}}”",
|
||||||
|
|
|
||||||
22
frontend/src/i18n/locales/de/plex.json
Normal file
22
frontend/src/i18n/locales/de/plex.json
Normal file
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
"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",
|
"organic": "Hide search results",
|
||||||
"all": "Include search results",
|
"all": "Include search results",
|
||||||
"only": "Search results only"
|
"only": "Search results only",
|
||||||
|
"plex": "Plex"
|
||||||
},
|
},
|
||||||
"yt": {
|
"yt": {
|
||||||
"searchFor": "Search YouTube for “{{query}}”",
|
"searchFor": "Search YouTube for “{{query}}”",
|
||||||
|
|
|
||||||
22
frontend/src/i18n/locales/en/plex.json
Normal file
22
frontend/src/i18n/locales/en/plex.json
Normal file
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
"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",
|
"organic": "Keresés nélkül",
|
||||||
"all": "Kereséssel együtt",
|
"all": "Kereséssel együtt",
|
||||||
"only": "Csak keresésből"
|
"only": "Csak keresésből",
|
||||||
|
"plex": "Plex"
|
||||||
},
|
},
|
||||||
"yt": {
|
"yt": {
|
||||||
"searchFor": "Keresés a YouTube-on: „{{query}}”",
|
"searchFor": "Keresés a YouTube-on: „{{query}}”",
|
||||||
|
|
|
||||||
22
frontend/src/i18n/locales/hu/plex.json
Normal file
22
frontend/src/i18n/locales/hu/plex.json
Normal file
|
|
@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ export interface Me {
|
||||||
has_google: boolean;
|
has_google: boolean;
|
||||||
has_password: boolean;
|
has_password: boolean;
|
||||||
google_enabled: boolean;
|
google_enabled: boolean;
|
||||||
|
plex_enabled: boolean;
|
||||||
can_read: boolean;
|
can_read: boolean;
|
||||||
can_write: boolean;
|
can_write: boolean;
|
||||||
pending_invites: number;
|
pending_invites: number;
|
||||||
|
|
@ -179,7 +180,7 @@ export interface FeedFilters {
|
||||||
show: string;
|
show: string;
|
||||||
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
||||||
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
||||||
librarySource?: "organic" | "all" | "search";
|
librarySource?: "organic" | "all" | "search" | "plex";
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
channelName?: string;
|
channelName?: string;
|
||||||
maxAgeDays?: number;
|
maxAgeDays?: number;
|
||||||
|
|
@ -623,6 +624,54 @@ export interface PlexTestResult {
|
||||||
sections: PlexSection[];
|
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.
|
// Admin Users & roles page.
|
||||||
export interface AdminUserRow {
|
export interface AdminUserRow {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -936,6 +985,27 @@ export const api = {
|
||||||
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
||||||
req("/api/admin/config/test-email", { method: "POST" }),
|
req("/api/admin/config/test-email", { method: "POST" }),
|
||||||
testPlex: (): Promise<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
|
testPlex: (): Promise<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
|
||||||
|
syncPlex: (): Promise<Record<string, unknown>> => 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<PlexBrowseResult> => {
|
||||||
|
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<PlexShowDetail> =>
|
||||||
|
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 ---
|
// --- admin: users & roles ---
|
||||||
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
|
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
|
||||||
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
|
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
|
||||||
|
|
|
||||||
|
|
@ -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("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
|
||||||
if (params.has("source")) {
|
if (params.has("source")) {
|
||||||
const s = params.get("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("normal")) f.includeNormal = params.get("normal") !== "0";
|
||||||
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue