diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 7accc91..2bdda81 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -6,6 +6,7 @@ plex.tv account, and playback is a local file. Admin endpoints test the connecti catalog sync. """ import logging +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi.responses import FileResponse @@ -294,6 +295,158 @@ def _item_or_404(db: Session, rating_key: str) -> PlexItem: return it +# Progress thresholds (mirror the YouTube player): ignore the first few seconds, and treat the +# last stretch as "finished" (mark watched, clear the resume point). +_PROGRESS_MIN_S = 5 +_FINISH_MARGIN_S = 30 + + +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 + keys = [ + r[0] + for r in db.query(PlexItem.rating_key) + .filter_by(show_id=it.show_id, kind="episode") + .order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst()) + .all() + ] + try: + i = keys.index(it.rating_key) + except ValueError: + return None, None + return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None) + + +@router.get("/item/{rating_key}") +def item_detail( + rating_key: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode + prev/next, and the per-user resume position + watch status.""" + 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] = [] + markers: 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] + for m in meta.get("Marker") or []: + if m.get("type") in ("intro", "credits"): + markers.append( + { + "type": m.get("type"), + "start_s": round((m.get("startTimeOffset") or 0) / 1000, 1), + "end_s": round((m.get("endTimeOffset") or 0) / 1000, 1), + } + ) + except (PlexError, PlexNotConfigured): + pass # metadata extras are best-effort; playback works without them + + prev_id, next_id = _episode_nav(db, it) + show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None + return { + "id": it.rating_key, + "kind": it.kind, + "title": it.title, + "summary": it.summary, + "year": it.year, + "duration_seconds": it.duration_s, + "playable": it.playable, + "thumb": f"/api/plex/image/{it.rating_key}", + "art": f"/api/plex/image/{it.rating_key}?variant=art", + "cast": cast, + "markers": markers, + "status": st.status if st else "new", + "position_seconds": st.position_seconds if st else 0, + "show_title": show.title if show else None, + "season_number": it.season_number, + "episode_number": it.episode_number, + "prev_id": prev_id, + "next_id": next_id, + } + + +@router.post("/item/{rating_key}/progress") +def item_progress( + rating_key: str, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Checkpoint the resume position while playing (near the end → mark watched + clear it).""" + it = _item_or_404(db, rating_key) + try: + position = max(0, int(payload.get("position_seconds") or 0)) + duration = int(payload.get("duration_seconds") or 0) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="position_seconds must be an integer") + now = datetime.now(timezone.utc) + st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() + + near_end = duration > 0 and position > duration - _FINISH_MARGIN_S + if near_end: + if st is None: + st = PlexState(user_id=user.id, item_id=it.id) + db.add(st) + st.status = "watched" + st.watched_at = now + st.position_seconds = 0 + st.progress_updated_at = now + db.commit() + return {"status": "watched", "position_seconds": 0} + + if position < _PROGRESS_MIN_S: + if st is not None and st.status == "new": + db.delete(st) + elif st is not None: + st.position_seconds = 0 + st.progress_updated_at = now + db.commit() + return {"position_seconds": 0} + + if st is None: + st = PlexState(user_id=user.id, item_id=it.id) + db.add(st) + st.position_seconds = position + st.progress_updated_at = now + db.commit() + return {"position_seconds": position} + + +@router.post("/item/{rating_key}/state") +def item_state( + rating_key: str, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Set the per-user watch status (new|watched|hidden).""" + it = _item_or_404(db, rating_key) + status = payload.get("status") + if status not in ("new", "watched", "hidden"): + raise HTTPException(status_code=400, detail="Invalid status") + st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() + if status == "new": + if st is not None: + db.delete(st) + db.commit() + return {"status": "new"} + if st is None: + st = PlexState(user_id=user.id, item_id=it.id) + db.add(st) + st.status = status + if status == "watched": + st.watched_at = datetime.now(timezone.utc) + st.position_seconds = 0 + db.commit() + return {"status": status} + + @router.post("/stream/{rating_key}/session") def stream_session( rating_key: str, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 33392a9..813b685 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@tanstack/react-query": "^5.51.0", "@tanstack/react-virtual": "^3.14.3", "clsx": "^2.1.1", + "hls.js": "^1.6.16", "i18next": "^23.11.5", "lucide-react": "^0.408.0", "react": "^18.3.1", @@ -1855,6 +1856,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a2cfc6a..897d71f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.51.0", "@tanstack/react-virtual": "^3.14.3", "clsx": "^2.1.1", + "hls.js": "^1.6.16", "i18next": "^23.11.5", "lucide-react": "^0.408.0", "react": "^18.3.1", diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 854bb22..7697f11 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -1,12 +1,16 @@ -import { useEffect, useRef, type CSSProperties } from "react"; +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 { api, type PlexCard } from "../lib/api"; -import { notify } from "../lib/notifications"; 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")); + +type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; 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 // search term comes from the shared top Header search box (q). A show drill-down rides history @@ -27,11 +31,11 @@ function dur(n?: number | null): string { export default function PlexBrowse({ q, library, show, sort }: Props) { const { t } = useTranslation(); const dq = useDebounced(q.trim(), 350); - const sub = useHistorySubview(null); // open show ratingKey (null = grid) + const sub = useHistorySubview({ kind: "grid" }); const browseQ = useInfiniteQuery({ queryKey: ["plex-browse", library, dq, sort, show], - enabled: !!library && sub.view === null, + enabled: !!library && sub.view.kind === "grid", initialPageParam: 0, queryFn: ({ pageParam }) => api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }), @@ -60,16 +64,26 @@ export default function PlexBrowse({ q, library, show, sort }: Props) { return () => io.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); - function onPlay(card: PlexCard) { - notify({ level: "info", message: t("plex.playerSoon", { title: card.title }) }); - } function onCard(card: PlexCard) { - if (card.type === "show") sub.open(card.id); - else onPlay(card); + if (card.type === "show") sub.open({ kind: "show", id: card.id }); + else sub.open({ kind: "player", id: card.id }); } - if (sub.view) { - return ; + if (sub.view.kind === "player") { + return ( + }> + + + ); + } + if (sub.view.kind === "show") { + return ( + sub.open({ kind: "player", id: ep.id })} + /> + ); } return ( diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx new file mode 100644 index 0000000..f04704c --- /dev/null +++ b/frontend/src/components/PlexPlayer.tsx @@ -0,0 +1,456 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Hls from "hls.js"; +import { + ArrowLeft, + Maximize, + Minimize, + Pause, + Play, + RotateCcw, + SkipBack, + SkipForward, + Volume2, + VolumeX, +} from "lucide-react"; +import { api, type PlexMarker } from "../lib/api"; + +// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw +// (native