diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 2bdda81..065a2a1 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -473,14 +473,22 @@ def stream_session( @router.get("/stream/{rating_key}/file") def stream_file( rating_key: str, + download: bool = False, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> FileResponse: - """Serve the raw local media file with HTTP range support (direct-playable files).""" + """Serve the raw local media file with HTTP range support. Used both for direct-play (inline) + and for the player's "download original" button (`?download=1` → attachment). The download is + the untouched physical file — no re-encode/repackage.""" it = _item_or_404(db, rating_key) src = plex_paths.local_media_path(db, it.file_path) if src is None: raise HTTPException(status_code=404, detail="Media file not found on disk") + if download: + # A clean, safe download name from the title + the real file extension. + ext = src.suffix or (f".{it.container}" if it.container else "") + base = "".join(c for c in (it.title or "video") if c.isalnum() or c in " -_.").strip() or "video" + return FileResponse(str(src), filename=f"{base}{ext}") return FileResponse(str(src)) # Starlette honours the Range header (206) diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 7697f11..f54cd19 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense, useEffect, useRef, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { ArrowLeft } from "lucide-react"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, CheckCircle2, Play } from "lucide-react"; import { api, type PlexCard } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview } from "../lib/history"; @@ -30,6 +30,7 @@ function dur(n?: number | null): string { export default function PlexBrowse({ q, library, show, sort }: Props) { const { t } = useTranslation(); + const qc = useQueryClient(); const dq = useDebounced(q.trim(), 350); const sub = useHistorySubview({ kind: "grid" }); @@ -68,6 +69,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 }); } + async function toggleWatched(card: PlexCard) { + await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched"); + qc.invalidateQueries({ queryKey: ["plex-browse"] }); + } if (sub.view.kind === "player") { return ( @@ -99,7 +104,7 @@ export default function PlexBrowse({ q, library, show, sort }: Props) { ) : (
{items.map((c) => ( - onCard(c)} /> + onCard(c)} onToggleWatched={toggleWatched} /> ))}
)} @@ -116,21 +121,40 @@ const PLAYABLE_TINT: Record = { transcode: "bg-orange-600", }; -function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void }) { +function PlexPosterCard({ + card, + onOpen, + onToggleWatched, +}: { + card: PlexCard; + onOpen: () => void; + onToggleWatched: (c: PlexCard) => void; +}) { const { t } = useTranslation(); const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; + const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down const pct = inProgress && card.duration_seconds ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) : 0; return ( - + )} + {/* Watched badge when not hovering (the toggle replaces it on hover). */} {card.status === "watched" && ( - + {t("plex.watched")} )} @@ -162,7 +211,7 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void
{card.title}
{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}
- + ); } diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index f04704c..428cc2c 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { 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"; import { ArrowLeft, + Download, Maximize, Minimize, Pause, @@ -222,6 +223,16 @@ export default function PlexPlayer({ itemId, onClose }: Props) { [], ); + // Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link. + const download = useCallback(() => { + const a = document.createElement("a"); + a.href = api.plexDownloadUrl(id); + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + }, [id]); + // Auto-advance to the next episode when one finishes. useEffect(() => { const video = videoRef.current; @@ -234,11 +245,21 @@ export default function PlexPlayer({ itemId, onClose }: Props) { return () => video.removeEventListener("ended", onEnded); }, [id, detail, go]); + // Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key). + const wake = useCallback(() => { + setUiVisible(true); + if (hideTimer.current) window.clearTimeout(hideTimer.current); + hideTimer.current = window.setTimeout(() => { + if (!videoRef.current?.paused) setUiVisible(false); + }, 3000); + }, []); + // --- keyboard -------------------------------------------------------------------------------- useEffect(() => { const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; + wake(); // keyboard use also reveals the controls switch (e.key) { case " ": case "k": @@ -279,16 +300,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose]); - - // Auto-hide the controls while playing. - const wake = useCallback(() => { - setUiVisible(true); - if (hideTimer.current) window.clearTimeout(hideTimer.current); - hideTimer.current = window.setTimeout(() => { - if (!videoRef.current?.paused) setUiVisible(false); - }, 3000); - }, []); + }, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]); const activeMarker: PlexMarker | undefined = detail?.markers.find( (m) => abs >= m.start_s && abs < m.end_s - 1, @@ -384,46 +396,35 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
- - + {detail?.kind === "episode" && ( <> - - + )}
- +
- +
); } + +// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the +// viewport, unlike a native title tooltip on the bottom control bar). +function Ctrl({ + label, + onClick, + disabled, + children, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + children: ReactNode; +}) { + return ( +
+ + + {label} + +
+ ); +} diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 11b2fb7..3057c47 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -25,6 +25,11 @@ "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", "loadMore": "Mehr laden", "watched": "Angesehen", + "play": "Abspielen", + "resume": "Fortsetzen", + "openShow": "Serie öffnen", + "markWatched": "Als gesehen markieren", + "markUnwatched": "Als ungesehen markieren", "seasons": "{{count}} Staffeln", "playerSoon": "Player kommt bald — „{{title}}“", "player": { @@ -37,7 +42,8 @@ "prev": "Vorherige Folge", "next": "Nächste Folge", "mute": "Stumm (m)", - "fullscreen": "Vollbild (f)" + "fullscreen": "Vollbild (f)", + "download": "Originaldatei herunterladen" }, "playable": { "direct": "Spielt direkt im Browser", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 90918b7..9dcf85d 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -25,6 +25,11 @@ "empty": "Nothing here yet. Run a Plex sync from the admin Config page.", "loadMore": "Load more", "watched": "Watched", + "play": "Play", + "resume": "Resume", + "openShow": "Open show", + "markWatched": "Mark watched", + "markUnwatched": "Mark unwatched", "seasons": "{{count}} seasons", "playerSoon": "Player coming soon — “{{title}}”", "player": { @@ -37,7 +42,8 @@ "prev": "Previous episode", "next": "Next episode", "mute": "Mute (m)", - "fullscreen": "Fullscreen (f)" + "fullscreen": "Fullscreen (f)", + "download": "Download original file" }, "playable": { "direct": "Plays directly in the browser", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index aca5083..6038ad7 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -25,6 +25,11 @@ "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", + "play": "Lejátszás", + "resume": "Folytatás", + "openShow": "Sorozat megnyitása", + "markWatched": "Megnézettnek jelöl", + "markUnwatched": "Nem-nézettnek jelöl", "seasons": "{{count}} évad", "playerSoon": "A lejátszó hamarosan jön — „{{title}}”", "player": { @@ -37,7 +42,8 @@ "prev": "Előző rész", "next": "Következő rész", "mute": "Némítás (m)", - "fullscreen": "Teljes képernyő (f)" + "fullscreen": "Teljes képernyő (f)", + "download": "Eredeti fájl letöltése" }, "playable": { "direct": "Közvetlenül játszható a böngészőben", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d85937d..49bccd0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1054,6 +1054,8 @@ export const api = { body: JSON.stringify({ status }), }), plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`, + plexDownloadUrl: (id: string): string => + `/api/plex/stream/${encodeURIComponent(id)}/file?download=1`, plexImageUrl: (id: string, variant?: "thumb" | "art"): string => `/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`, // --- admin: users & roles ---