diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 756321c..ab80ddb 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -1,10 +1,11 @@ -import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react"; +import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react"; +import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react"; import { api, type PlexCard, type PlexFilters, type PlexPerson } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview } from "../lib/history"; +import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); @@ -461,6 +462,9 @@ function PlexShowView({ const { t } = useTranslation(); const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) }); const d = q.data; + // "Add to playlist" dialog target (single episode / whole season / whole show); null = closed. + const [addTarget, setAddTarget] = useState(null); + const allEpisodeKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id)); return (
@@ -488,41 +492,74 @@ function PlexShowView({ {d.show.summary && (

{d.show.summary}

)} + {allEpisodeKeys.length > 0 && ( + + )}
{d.seasons.map((se) => (
-

{se.title}

+
+

{se.title}

+ {se.episodes.length > 0 && ( + + )} +
{se.episodes.map((ep) => { const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; return ( - +
+
+ {ep.episode_number}. + {ep.title} +
+ {ep.summary && ( +
{ep.summary}
+ )} +
{dur(ep.duration_seconds)}
+
+ + +
); })}
@@ -530,6 +567,8 @@ function PlexShowView({ ))} )} + + {addTarget && setAddTarget(null)} />} ); } diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index dd1ca07..248a8e8 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -526,7 +526,10 @@ export default function PlexInfo({ /> )} {playlistOpen && ( - setPlaylistOpen(false)} /> + setPlaylistOpen(false)} + /> )} ); diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index e78dc3e..fc6c629 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -1,42 +1,60 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Plus } from "lucide-react"; +import { Check, Minus, Plus } from "lucide-react"; import { api, type PlexPlaylist } from "../lib/api"; import Modal from "./Modal"; -// "Add this title to a playlist" dialog — per-user (every user has their own playlists), opened from -// the movie info page. Lists my playlists with an in/out toggle (seeded from the backend's has_item) -// and a field to create a new one seeded with this title. Playlists are personal (no admin gate). -export default function PlexPlaylistAdd({ - item, - onClose, -}: { - item: { id: string; title: string }; - onClose: () => void; -}) { +// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a +// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist. +export type PlexAddTarget = + | { kind: "single"; ratingKey: string; title: string } + | { kind: "group"; ratingKeys: string[]; title: string }; + +// "Add to playlist" dialog — per-user (every user has their own playlists). Opened from the movie info +// page (single) and the show page (single episode / whole season / whole show). Lists my playlists with +// an in/out (or partial) state and a field to create a new one seeded with the target. +export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTarget; onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const [newName, setNewName] = useState(""); const [busy, setBusy] = useState(false); - const [override, setOverride] = useState>(new Map()); // optimistic toggle state + // Optimistic per-playlist in-count override (0..size), so a toggle reflects instantly. + const [override, setOverride] = useState>(new Map()); + + const isGroup = target.kind === "group"; + const size = isGroup ? new Set(target.ratingKeys).size : 1; const listQ = useQuery({ - queryKey: ["plex-playlists", "contains", item.id], - queryFn: () => api.plexPlaylists(item.id), + queryKey: isGroup + ? ["plex-playlists", "group", target.ratingKeys] + : ["plex-playlists", "contains", target.ratingKey], + queryFn: () => + isGroup ? api.plexPlaylists(undefined, target.ratingKeys) : api.plexPlaylists(target.ratingKey), }); const playlists = listQ.data?.playlists ?? []; - const isIn = (p: PlexPlaylist) => (override.has(p.id) ? override.get(p.id)! : !!p.has_item); const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] }); + // How many of the target a playlist currently holds (0..size). + function inCount(p: PlexPlaylist): number { + const o = override.get(p.id); + if (o !== undefined) return o; + return isGroup ? p.group_in ?? 0 : p.has_item ? 1 : 0; + } + async function toggle(p: PlexPlaylist) { if (busy) return; setBusy(true); - const cur = isIn(p); + const allIn = inCount(p) >= size; try { - if (cur) await api.plexPlaylistRemoveItem(p.id, item.id); - else await api.plexPlaylistAddItem(p.id, item.id); - setOverride((m) => new Map(m).set(p.id, !cur)); + if (isGroup) { + if (allIn) await api.plexPlaylistRemoveBulk(p.id, target.ratingKeys); + else await api.plexPlaylistAddBulk(p.id, target.ratingKeys); + } else { + if (allIn) await api.plexPlaylistRemoveItem(p.id, target.ratingKey); + else await api.plexPlaylistAddItem(p.id, target.ratingKey); + } + setOverride((m) => new Map(m).set(p.id, allIn ? 0 : size)); refresh(); } finally { setBusy(false); @@ -48,9 +66,15 @@ export default function PlexPlaylistAdd({ if (!title || busy) return; setBusy(true); try { - const pl = await api.plexCreatePlaylist(title, item.id); + if (isGroup) { + const pl = await api.plexCreatePlaylist(title); + await api.plexPlaylistAddBulk(pl.id, target.ratingKeys); + setOverride((m) => new Map(m).set(pl.id, size)); + } else { + const pl = await api.plexCreatePlaylist(title, target.ratingKey); + setOverride((m) => new Map(m).set(pl.id, 1)); + } setNewName(""); - setOverride((m) => new Map(m).set(pl.id, true)); refresh(); } finally { setBusy(false); @@ -58,7 +82,7 @@ export default function PlexPlaylistAdd({ } return ( - +
{t("plex.playlistAdd.none")}

) : ( playlists.map((p) => { - const inIt = isIn(p); + const n = inCount(p); + const all = n >= size; + const partial = n > 0 && n < size; return ( ); }) diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 86083d3..f04ea08 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -1,12 +1,300 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, ChevronDown, ChevronUp, Pencil, Play, Trash2, X } from "lucide-react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + ArrowLeft, + ChevronDown, + ChevronRight, + GripVertical, + ListTree, + Pencil, + Play, + Rows3, + Trash2, + X, +} from "lucide-react"; import { api, type PlexCard } from "../lib/api"; +import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; -// A playlist's ordered items — reorder (up/down), remove, rename, delete, and "Play all" (which opens -// the player with the whole ordered list as its queue). Rendered as a PlexBrowse subview. +// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the +// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat +// list. Reorder is drag & drop (a whole show-group moves as one block; episodes reorder within their +// show). Two layouts — Accordion (A) and Tree (C) — are a per-account preference. Rendered as a +// PlexBrowse subview; "Play all" opens the player with the whole ordered list as its queue. + +type Layout = "accordion" | "tree"; +type Season = { key: string; number: number | null; items: PlexCard[] }; +type Block = + | { kind: "movie"; id: string; item: PlexCard } + | { kind: "show"; id: string; showKey: string; title: string; items: PlexCard[]; seasons: Season[] }; + +function buildSeasons(items: PlexCard[]): Season[] { + const map = new Map(); + for (const it of items) { + const num = it.season_number ?? null; + const key = String(num ?? "?"); + let s = map.get(key); + if (!s) { + s = { key, number: num, items: [] }; + map.set(key, s); + } + s.items.push(it); + } + return [...map.values()]; +} + +// Turn the flat ordered list into blocks: contiguous episodes of one show group together (then split +// into seasons for display); everything else (movies) is its own block. +function buildBlocks(items: PlexCard[]): Block[] { + const blocks: Block[] = []; + for (const it of items) { + if (it.type === "episode") { + const showKey = it.show_id || it.show_title || "?"; + const last = blocks[blocks.length - 1]; + if (last && last.kind === "show" && last.showKey === showKey) { + last.items.push(it); + } else { + blocks.push({ + kind: "show", + id: `show:${showKey}`, + showKey, + title: it.show_title || it.title, + items: [it], + seasons: [], + }); + } + } else { + blocks.push({ kind: "movie", id: it.id, item: it }); + } + } + for (const b of blocks) if (b.kind === "show") b.seasons = buildSeasons(b.items); + return blocks; +} + +const flatten = (blocks: Block[]): PlexCard[] => + blocks.flatMap((b) => (b.kind === "movie" ? [b.item] : b.items)); + +// Callbacks + shared state passed down to the module-scope block/row components. +type Ctx = { + layout: Layout; + busy: boolean; + expandedShows: Set; + collapsedSeasons: Set; + toggleShow: (k: string) => void; + toggleSeason: (k: string) => void; + onPlay: (rk: string) => void; + onRemove: (keys: string[]) => void; +}; + +function Poster({ src, className }: { src: string; className: string }) { + return ( +
+ +
+ ); +} + +function DragHandle(props: Record) { + return ( + + ); +} + +function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: ep.id, + disabled: ctx.busy, + }); + const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 }; + const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; + return ( +
+ + {ep.episode_number} + + +
+ ); +} + +function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: item.id, + disabled: ctx.busy, + }); + const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 }; + const dense = ctx.layout === "tree"; + return ( +
+ + + +
+ ); +} + +function ShowGroup({ block, ctx }: { block: Extract; ctx: Ctx }) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: block.id, + disabled: ctx.busy, + }); + const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1 }; + const open = ctx.expandedShows.has(block.showKey); + const dense = ctx.layout === "tree"; + const seasonLabel = (s: Season) => + s.number != null ? t("plex.playlist.season", { n: s.number }) : t("plex.playlist.seasonUnknown"); + + return ( +
+ {/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */} +
+ + + {!dense && ( + + )} + + +
+ + {open && ( +
+ i.id)} strategy={verticalListSortingStrategy}> + {block.seasons.map((s) => { + const sKey = `${block.showKey}:${s.key}`; + const sOpen = !dense || !ctx.collapsedSeasons.has(sKey); + return ( +
+
+ {dense && ( + + )} + + {seasonLabel(s)} · {s.items.length} + + +
+ {sOpen && ( +
+ {s.items.map((ep) => ( + + ))} +
+ )} +
+ ); + })} +
+
+ )} +
+ ); +} + export default function PlexPlaylistView({ playlistId, onBack, @@ -22,38 +310,52 @@ export default function PlexPlaylistView({ const [renaming, setRenaming] = useState(false); const [name, setName] = useState(""); const [busy, setBusy] = useState(false); + const [layout, setLayout] = useState(() => + getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion", + ); + useEffect(() => { + setAccountRaw(LS.plexPlaylistLayout, layout); + }, [layout]); + // Show groups start collapsed (compact headers) so a long series doesn't force endless scrolling; + // seasons start expanded once their show is opened. Both are just local view state. + const [expandedShows, setExpandedShows] = useState>(new Set()); + const [collapsedSeasons, setCollapsedSeasons] = useState>(new Set()); const q = useQuery({ queryKey: ["plex-playlist", playlistId], queryFn: () => api.plexPlaylist(playlistId) }); - const items: PlexCard[] = q.data?.items ?? []; - const order = items.map((i) => i.id); + // Local optimistic copy so drag/remove are instant; re-synced whenever the query returns. + const [items, setItems] = useState([]); + useEffect(() => { + if (q.data) setItems(q.data.items); + }, [q.data]); + const blocks = useMemo(() => buildBlocks(items), [items]); + const order = useMemo(() => items.map((i) => i.id), [items]); + const invalidate = () => { qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] }); qc.invalidateQueries({ queryKey: ["plex-playlists"] }); }; - async function move(idx: number, dir: -1 | 1) { - const to = idx + dir; - if (busy || to < 0 || to >= order.length) return; + function commit(next: PlexCard[]) { + setItems(next); + api + .plexReorderPlaylist(playlistId, next.map((i) => i.id)) + .then(() => qc.invalidateQueries({ queryKey: ["plex-playlists"] })); + } + + async function onRemove(keys: string[]) { + if (busy || !keys.length) return; setBusy(true); - const next = [...order]; - [next[idx], next[to]] = [next[to], next[idx]]; + const set = new Set(keys); + setItems((prev) => prev.filter((i) => !set.has(i.id))); try { - await api.plexReorderPlaylist(playlistId, next); - invalidate(); - } finally { - setBusy(false); - } - } - async function remove(itemRk: string) { - if (busy) return; - setBusy(true); - try { - await api.plexPlaylistRemoveItem(playlistId, itemRk); + if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]); + else await api.plexPlaylistRemoveBulk(playlistId, keys); invalidate(); } finally { setBusy(false); } } + async function rename() { const title = name.trim(); if (!title) return; @@ -69,8 +371,79 @@ export default function PlexPlaylistView({ onBack(); } + const toggleShow = (k: string) => + setExpandedShows((s) => { + const n = new Set(s); + n.has(k) ? n.delete(k) : n.add(k); + return n; + }); + const toggleSeason = (k: string) => + setCollapsedSeasons((s) => { + const n = new Set(s); + n.has(k) ? n.delete(k) : n.add(k); + return n; + }); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + function onDragEnd(e: DragEndEvent) { + const { active, over } = e; + if (!over || active.id === over.id) return; + const aId = String(active.id); + const oId = String(over.id); + const blockIds = new Set(blocks.map((b) => b.id)); + if (blockIds.has(aId)) { + // Moving a whole block (movie or show). Resolve the drop target to a block. + let oBlockId = oId; + if (!blockIds.has(oId)) { + const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === oId)); + if (!b) return; + oBlockId = b.id; + } + const from = blocks.findIndex((b) => b.id === aId); + const to = blocks.findIndex((b) => b.id === oBlockId); + if (from < 0 || to < 0) return; + commit(flatten(arrayMove(blocks, from, to))); + } else { + // Moving an episode — only within its own show block. + const b = blocks.find((bl) => bl.kind === "show" && bl.items.some((it) => it.id === aId)); + if (!b || b.kind !== "show" || !b.items.some((it) => it.id === oId)) return; + const from = b.items.findIndex((it) => it.id === aId); + const to = b.items.findIndex((it) => it.id === oId); + commit(flatten(blocks.map((bl) => (bl.id === b.id ? { ...bl, items: arrayMove(b.items, from, to) } : bl)))); + } + } + + const ctx: Ctx = { + layout, + busy, + expandedShows, + collapsedSeasons, + toggleShow, + toggleSeason, + onPlay: (rk) => onPlay(rk, order), + onRemove, + }; + + const LayoutBtn = ({ v, icon, label }: { v: Layout; icon: ReactNode; label: string }) => ( + + ); + return ( -
+
-
- - - -
- - ))} - + + b.id)} strategy={verticalListSortingStrategy}> +
+ {blocks.map((b) => + b.kind === "movie" ? ( + + ) : ( + + ), + )} +
+
+
)}
diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index ddee937..91489ca 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -158,7 +158,8 @@ "newPlaceholder": "Name der neuen Playlist…", "create": "Erstellen", "none": "Noch keine Playlists. Erstelle oben eine.", - "count": "{{count}} Titel" + "count": "{{count}} Titel", + "groupProgress": "{{in}} / {{size}}" }, "playlist": { "section": "Playlists", @@ -172,6 +173,16 @@ "empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.", "up": "Nach oben", "down": "Nach unten", - "remove": "Entfernen" + "remove": "Entfernen", + "layoutAccordion": "Akkordeon-Ansicht", + "layoutTree": "Baum-Ansicht", + "removeShow": "Ganze Serie entfernen", + "removeSeason": "Ganze Staffel entfernen", + "episodes": "{{count}} Folgen", + "season": "Staffel {{n}}", + "seasonUnknown": "Folgen", + "addShow": "Ganze Serie zu einer Playlist", + "addSeason": "Staffel zu einer Playlist", + "addEpisode": "Folge zu einer Playlist" } } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 7984589..8601de1 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -158,7 +158,8 @@ "newPlaceholder": "New playlist name…", "create": "Create", "none": "No playlists yet. Create one above.", - "count": "{{count}} items" + "count": "{{count}} items", + "groupProgress": "{{in}} / {{size}}" }, "playlist": { "section": "Playlists", @@ -172,6 +173,16 @@ "empty": "This playlist is empty. Add titles from their info page.", "up": "Move up", "down": "Move down", - "remove": "Remove" + "remove": "Remove", + "layoutAccordion": "Accordion view", + "layoutTree": "Tree view", + "removeShow": "Remove whole show", + "removeSeason": "Remove whole season", + "episodes": "{{count}} episodes", + "season": "Season {{n}}", + "seasonUnknown": "Episodes", + "addShow": "Add whole show to a playlist", + "addSeason": "Add season to a playlist", + "addEpisode": "Add episode to a playlist" } } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index bbb7350..a753fbe 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -158,7 +158,8 @@ "newPlaceholder": "Új lista neve…", "create": "Létrehozás", "none": "Még nincs lista. Hozz létre egyet fent.", - "count": "{{count}} elem" + "count": "{{count}} elem", + "groupProgress": "{{in}} / {{size}}" }, "playlist": { "section": "Lejátszási listák", @@ -172,6 +173,16 @@ "empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.", "up": "Fel", "down": "Le", - "remove": "Eltávolítás" + "remove": "Eltávolítás", + "layoutAccordion": "Harmonika nézet", + "layoutTree": "Fa nézet", + "removeShow": "Egész sorozat eltávolítása", + "removeSeason": "Egész évad eltávolítása", + "episodes": "{{count}} epizód", + "season": "{{n}}. évad", + "seasonUnknown": "Epizódok", + "addShow": "Egész sorozat listához adása", + "addSeason": "Évad listához adása", + "addEpisode": "Epizód listához adása" } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c49ebd0..feca869 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -653,6 +653,7 @@ export interface PlexCard { season_number?: number | null; // episode episode_number?: number | null; // episode show_title?: string | null; // episode (in a mixed playlist) + show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season) summary?: string | null; // episode } export interface PlexBrowseResult { @@ -737,6 +738,7 @@ export interface PlexPlaylist { item_count: number; thumb?: string | null; has_item?: boolean; // only when the list was fetched with ?contains= + group_in?: number; // only when fetched with ?contains_group=: how many of the group it holds } export interface PlexPlaylistDetail { id: number; @@ -1178,11 +1180,25 @@ export const api = { plexSetCollectionEditable: (rk: string, editable: boolean): Promise => req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }), // --- Playlists (Siftlode-native, per-user, ordered) --- - plexPlaylists: (contains?: string): Promise<{ playlists: PlexPlaylist[] }> => - req(`/api/plex/playlists${contains ? `?contains=${encodeURIComponent(contains)}` : ""}`), + // `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole + // season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog). + plexPlaylists: ( + contains?: string, + containsGroup?: string[], + ): Promise<{ playlists: PlexPlaylist[]; group_size?: number }> => { + const p = new URLSearchParams(); + if (contains) p.set("contains", contains); + if (containsGroup) p.set("contains_group", containsGroup.join(",")); + const qs = p.toString(); + return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`); + }, plexPlaylist: (id: number): Promise => req(`/api/plex/playlists/${id}`), plexCreatePlaylist: (title: string, item_rating_key?: string): Promise => req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }), + plexPlaylistAddBulk: (id: number, itemRks: string[]): Promise<{ added: number; item_count: number }> => + req(`/api/plex/playlists/${id}/items/bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }), + plexPlaylistRemoveBulk: (id: number, itemRks: string[]): Promise<{ removed: number; item_count: number }> => + req(`/api/plex/playlists/${id}/items/remove-bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }), plexRenamePlaylist: (id: number, title: string): Promise => req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }), plexDeletePlaylist: (id: number): Promise<{ deleted: number }> => diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 3e3de01..48162ce 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -27,6 +27,7 @@ export const LS = { plexShow: "siftlode.plexShow", plexSort: "siftlode.plexSort", plexFilters: "siftlode.plexFilters", + plexPlaylistLayout: "siftlode.plexPlaylistLayout", notifHistory: "siftlode.notifications", notifSettings: "siftlode.notifSettings", onboardingDismissed: "siftlode.onboarding.dismissed",