diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 6101b2f..6b870e7 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -8,8 +8,8 @@ catalog sync. import logging from fastapi import APIRouter, Depends, HTTPException, Query, Response -from sqlalchemy import func -from sqlalchemy.orm import Session +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session, aliased from app import sysconfig from app.auth import current_user @@ -130,14 +130,15 @@ def browse( library: str, q: str | None = None, sort: str = "added", + show: str = "all", offset: int = 0, limit: int = Query(default=40, ge=1, le=100), user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """List a library's movies (leaves) or shows, with optional FTS search + sorting. - Movie libraries return playable movie cards; show libraries return show cards (drill in via - /show/{id}).""" + """List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user + watch-state filter (`show`: all|unwatched|in_progress|watched — movie libraries only). Movie + libraries return playable movie cards; show libraries return show cards (drill in via /show).""" lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() if lib is None: raise HTTPException(status_code=404, detail="Unknown Plex library") @@ -147,6 +148,17 @@ def browse( query = db.query(model) if lib.kind == "movie": query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie") + # Per-user watch-state filter (movies only; a show isn't a single watch unit). + st = aliased(PlexState) + query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) + if show == "watched": + query = query.filter(st.status == "watched") + elif show == "in_progress": + query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")) + elif show == "unwatched": + query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))) + else: # all — hide only the explicitly hidden + query = query.filter(or_(st.status.is_(None), st.status != "hidden")) else: query = query.filter(PlexShow.library_id == lib.id) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8171375..c441be1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -63,6 +63,7 @@ import { CURRENT_VERSION } from "./lib/releaseNotes"; // never reach non-admins. All are rendered inside a boundary below. const Feed = lazy(() => import("./components/Feed")); const PlexBrowse = lazy(() => import("./components/PlexBrowse")); +const PlexSidebar = lazy(() => import("./components/PlexSidebar")); const ChannelPage = lazy(() => import("./components/ChannelPage")); const Channels = lazy(() => import("./components/Channels")); const Playlists = lazy(() => import("./components/Playlists")); @@ -242,6 +243,10 @@ export default function App() { const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed"); const channelsView: ChannelsView = channelsViewRaw === "discovery" ? "discovery" : "subscribed"; + // Plex module filters (its own left-sidebar filter section) — per-account persisted. + const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, ""); + const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all"); + const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added"); // Bumped to tell the channel manager to drop a stale column filter when we send the user // there to see a specific set (the header's "without full history" link). const [channelsFilterReset, setChannelsFilterReset] = useState(0); @@ -713,6 +718,20 @@ export default function App() { onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} /> )} + {page === "plex" && meQuery.data!.plex_enabled && ( + + setFilterCollapsed(!filterCollapsed)} + /> + + )}
{channelView ? ( @@ -782,8 +801,8 @@ export default function App() { prefs={prefsCtl} onOpenWizard={() => setWizardOpen(true)} /> - ) : meQuery.data!.plex_enabled && filters.librarySource === "plex" ? ( - + ) : page === "plex" && meQuery.data!.plex_enabled ? ( + ) : ( setWizardOpen(true)} ytSearch={ytSearch} onYtSearch={enterYtSearch} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index fce6e32..04a0ef5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -74,7 +74,6 @@ export default function Feed({ view, canRead, isDemo = false, - plexEnabled = false, onOpenWizard, ytSearch, onYtSearch, @@ -87,8 +86,6 @@ 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 @@ -563,7 +560,6 @@ export default function Feed({ - {plexEnabled && } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 2967d18..a5b93a4 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -26,10 +26,14 @@ export default function Header({ const { t } = useTranslation(); const canSearchYt = !me.is_demo; const trimmedQ = filters.q.trim(); + // The search box serves both the YouTube feed and the Plex module (integrated search); the live + // YouTube-search escalation (Enter / button) is feed-only. + const isSearchPage = page === "feed" || page === "plex"; + const isYtCapable = page === "feed" && canSearchYt; return (
- {page === "feed" ? ( + {isSearchPage ? (
@@ -40,15 +44,16 @@ export default function Header({ // When a search first appears, rank the feed by relevance — set atomically with // the query (race-free vs. per-keystroke updates). Only overrides the default // "newest" sort; a custom sort the user chose is left alone. Cleared in Feed. - const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest"; + const startSearch = + page === "feed" && !filters.q.trim() && !!q.trim() && filters.sort === "newest"; setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) }); }} onKeyDown={(e) => { - // Enter runs a live YouTube search for the typed term (the box itself filters - // the local catalog as you type; Enter escalates to the YouTube API). - if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ); + // On the feed, Enter escalates the typed term to a live YouTube search (the box + // itself filters the local catalog / Plex library as you type). + if (e.key === "Enter" && trimmedQ && isYtCapable) onYtSearch(trimmedQ); }} - placeholder={t("header.searchPlaceholder")} + placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")} className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent" /> {filters.q && ( @@ -62,7 +67,7 @@ export default function Header({ )}
- {trimmedQ && canSearchYt && ( + {trimmedQ && isYtCapable && ( -
-

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

{browseQ.isLoading ? (

{t("plex.loading")}

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

{t("plex.empty")}

+

{dq ? t("plex.noMatches") : t("plex.empty")}

) : (
{items.map((c) => ( @@ -137,17 +90,8 @@ export default function PlexBrowse({ filters, setFilters }: Props) {
)} - {browseQ.hasNextPage && ( -
- -
- )} +
+ {isFetchingNextPage &&

{t("plex.loading")}

}
); } @@ -166,12 +110,18 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) : 0; return ( - ); } @@ -208,12 +156,10 @@ 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) }); @@ -221,18 +167,13 @@ function PlexShowView({ return (
-
- - -
+ {q.isLoading || !d ? (

{t("plex.loading")}

@@ -282,7 +223,7 @@ function PlexShowView({ {ep.summary && (
{ep.summary}
)} -
{secs(ep.duration_seconds)}
+
{dur(ep.duration_seconds)}
); diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx new file mode 100644 index 0000000..9d14f34 --- /dev/null +++ b/frontend/src/components/PlexSidebar.tsx @@ -0,0 +1,135 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronRight, Film, SlidersHorizontal, Tv2 } from "lucide-react"; +import { api } from "../lib/api"; + +// The Plex module's left filter column (mirrors the feed Sidebar's shell): pick a library, filter +// by watch state (movie libraries only), and sort. State is owned by App (per-account persisted). + +type Props = { + library: string; + setLibrary: (v: string) => void; + show: string; + setShow: (v: string) => void; + sort: string; + setSort: (v: string) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}; + +const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; +const SORT_OPTS = ["added", "title"] as const; + +export default function PlexSidebar({ + library, + setLibrary, + show, + setShow, + sort, + setSort, + collapsed, + onToggleCollapse, +}: Props) { + const { t } = useTranslation(); + const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries }); + const libs = libsQ.data?.libraries ?? []; + const activeLib = libs.find((l) => l.key === library); + + // Default to the first library once loaded (or if the stored one vanished). + useEffect(() => { + if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key); + }, [libs, library, setLibrary]); + + const isMovieLib = activeLib?.kind === "movie"; + const activeCount = (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 533cda0..6fdf507 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -1,4 +1,5 @@ { + "navLabel": "Plex", "backToFeed": "YouTube-Feed", "backToLibrary": "Zurück zur Bibliothek", "searchPlaceholder": "Plex durchsuchen…", @@ -6,8 +7,20 @@ "added": "Kürzlich hinzugefügt", "title": "Titel" }, + "filter": { + "library": "Bibliothek", + "show": "Anzeige", + "showOpt": { + "all": "Alle", + "unwatched": "Ungesehen", + "in_progress": "Läuft", + "watched": "Gesehen" + }, + "sort": "Sortierung" + }, "count": "{{count}} Titel", "searchCount": "{{count}} Treffer", + "noMatches": "Keine Treffer.", "loading": "Wird geladen…", "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", "loadMore": "Mehr laden", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index cf9d575..63cac4a 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -1,4 +1,5 @@ { + "navLabel": "Plex", "backToFeed": "YouTube feed", "backToLibrary": "Back to library", "searchPlaceholder": "Search Plex…", @@ -6,8 +7,20 @@ "added": "Recently added", "title": "Title" }, + "filter": { + "library": "Library", + "show": "Show", + "showOpt": { + "all": "All", + "unwatched": "Unwatched", + "in_progress": "In progress", + "watched": "Watched" + }, + "sort": "Sort" + }, "count": "{{count}} titles", "searchCount": "{{count}} matches", + "noMatches": "No matches.", "loading": "Loading…", "empty": "Nothing here yet. Run a Plex sync from the admin Config page.", "loadMore": "Load more", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 611b2ca..df833d2 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -1,4 +1,5 @@ { + "navLabel": "Plex", "backToFeed": "YouTube feed", "backToLibrary": "Vissza a könyvtárhoz", "searchPlaceholder": "Keresés a Plexben…", @@ -6,8 +7,20 @@ "added": "Nemrég hozzáadott", "title": "Cím" }, + "filter": { + "library": "Könyvtár", + "show": "Megjelenítés", + "showOpt": { + "all": "Mind", + "unwatched": "Nem nézett", + "in_progress": "Folyamatban", + "watched": "Megnézett" + }, + "sort": "Rendezés" + }, "count": "{{count}} cím", "searchCount": "{{count}} találat", + "noMatches": "Nincs 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", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3114648..a310c0b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -992,12 +992,14 @@ export const api = { library: string; q?: string; sort?: string; + show?: 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.show && p.show !== "all") u.set("show", p.show); 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()}`); diff --git a/frontend/src/lib/pageMeta.ts b/frontend/src/lib/pageMeta.ts index 19dd9d9..250a6e2 100644 --- a/frontend/src/lib/pageMeta.ts +++ b/frontend/src/lib/pageMeta.ts @@ -18,6 +18,8 @@ export function pageTitleKey(page: Page): string { return "downloads.navLabel"; case "stats": return "header.usageStats"; + case "plex": + return "plex.navLabel"; case "scheduler": return "header.scheduler"; case "config": diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 7a45901..18c613b 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -23,6 +23,9 @@ export const LS = { filterCollapsed: "siftlode.filterCollapsed", playlist: "siftlode.playlist", plSort: "siftlode.plSort", + plexLibrary: "siftlode.plexLibrary", + plexShow: "siftlode.plexShow", + plexSort: "siftlode.plexSort", notifHistory: "siftlode.notifications", notifSettings: "siftlode.notifSettings", onboardingDismissed: "siftlode.onboarding.dismissed", diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 004e2df..3cfec21 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -101,6 +101,7 @@ export const PAGES = [ "notifications", "messages", "downloads", + "plex", ] as const; export type Page = (typeof PAGES)[number];