diff --git a/VERSION b/VERSION index b79f04f..ae6dd4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.3 +0.29.0 diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py index 71e232d..2dd0af6 100644 --- a/backend/app/plex/client.py +++ b/backend/app/plex/client.py @@ -106,6 +106,59 @@ class PlexClient: mc = self._get(f"/library/collections/{rating_key}/children") return mc.get("Metadata", []) or [] + # --- Collection editing (P2 — write-back to Plex; all shared/global, admin-gated in the routes) --- + @property + def machine_id(self) -> str: + """Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached.""" + if not getattr(self, "_machine_id", None): + self._machine_id = self._get("/identity").get("machineIdentifier") or "" + return self._machine_id + + def _write(self, method: str, path: str) -> dict: + try: + r = self._http.request(method, f"{self.base}{path}") + r.raise_for_status() + except httpx.HTTPError as e: + raise PlexError(str(e)) from e + try: + return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {} + except ValueError: + return {} + + def _metadata_uri(self, *rating_keys: str) -> str: + keys = ",".join(str(k) for k in rating_keys) + return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}" + + def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict: + """Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey).""" + uri = quote(self._metadata_uri(item_rating_key), safe="") + mc = self._write( + "POST", + f"/library/collections?type=1&smart=0§ionId={section_key}" + f"&title={quote(title, safe='')}&uri={uri}", + ) + items = mc.get("Metadata", []) or [] + if not items: + raise PlexError("Plex did not return the created collection") + return items[0] + + def add_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None: + uri = quote(self._metadata_uri(item_rating_key), safe="") + self._write("PUT", f"/library/collections/{collection_rating_key}/items?uri={uri}") + + def remove_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None: + self._write("DELETE", f"/library/collections/{collection_rating_key}/children/{item_rating_key}") + + def rename_collection(self, section_key: str, collection_rating_key: str, title: str) -> None: + self._write( + "PUT", + f"/library/sections/{section_key}/all?type=18&id={collection_rating_key}" + f"&title.value={quote(title, safe='')}&title.locked=1", + ) + + def delete_collection(self, collection_rating_key: str) -> None: + self._write("DELETE", f"/library/collections/{collection_rating_key}") + def metadata(self, rating_key: str, markers: bool = False) -> dict | None: """Full metadata for one item; include intro/credit markers when requested.""" params = {"includeMarkers": 1} if markers else None diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 445ae3c..7cc3059 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -271,6 +271,46 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> db.flush() +def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) -> PlexCollection: + """Targeted re-sync of ONE collection after a write-back (create/add/remove/rename) — avoids the + full ~4-min library sync. Upserts the row (preserving `editable`) and reconciles ONLY this + collection's membership on the affected member rows.""" + meta = plex.metadata(rk) or {} + col = db.query(PlexCollection).filter_by(rating_key=rk).first() or PlexCollection(rating_key=rk) + if col.id is None: + db.add(col) + col.library_id = lib.id + col.title = (meta.get("title") or "").strip() or "Untitled" + col.summary = meta.get("summary") + col.thumb_key = meta.get("thumb") + col.child_count = meta.get("childCount") + col.smart = str(meta.get("smart")) == "1" + col.synced_at = datetime.now(timezone.utc) + Model = PlexItem if lib.kind == "movie" else PlexShow + new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")} + current = db.query(Model).filter(Model.collection_keys.contains([rk])).all() + for row in current: # drop rk from rows that are no longer members + if row.rating_key not in new_members: + row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None + to_add = new_members - {r.rating_key for r in current} + if to_add: + for row in db.query(Model).filter(Model.rating_key.in_(to_add)): + row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rk])) + db.flush() + return col + + +def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None: + """Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row.""" + Model = PlexItem if lib.kind == "movie" else PlexShow + for row in db.query(Model).filter(Model.collection_keys.contains([rk])): + row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None + col = db.query(PlexCollection).filter_by(rating_key=rk).first() + if col is not None: + db.delete(col) + db.flush() + + def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None: """Mirror a library's collections + membership. Reads never touch Plex afterwards. Membership is written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile: diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index cc280cc..a3673c4 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -429,6 +429,12 @@ def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None: return None +def _collection_editable(c: PlexCollection) -> bool: + """Effective editability: the admin marked it editable AND it's a plain manual collection — never + smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex.""" + return bool(c.editable) and not c.smart and _collection_source(c) == "collection" + + def _collection_card(c: PlexCollection) -> dict: return { "id": c.rating_key, @@ -437,7 +443,9 @@ def _collection_card(c: PlexCollection) -> dict: "thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None, "child_count": c.child_count, "smart": c.smart, + "source": _collection_source(c), "editable": c.editable, + "can_edit": _collection_editable(c), # whether the edit controls should be offered (admin UI) } @@ -462,6 +470,127 @@ def collections( return {"collections": [_collection_card(c) for c in rows]} +# --- Collection editing (P2, admin-only — writes back to Plex; changes are shared across all clients). +def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: + col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() + if col is None: + raise HTTPException(status_code=404, detail="Unknown collection") + if not _collection_editable(col): + raise HTTPException(status_code=403, detail="This collection is read-only") + return col + + +@router.post("/collections") +def create_collection(payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: + """Create a manual collection (seeded with one item — Plex needs at least one). Editable by default.""" + library = str(payload.get("library") or "") + title = (payload.get("title") or "").strip() + seed = str(payload.get("item_rating_key") or "") + if not title or not seed: + raise HTTPException(status_code=422, detail="title and item_rating_key are required") + lib = db.query(PlexLibrary).filter_by(plex_key=library).first() + if lib is None: + raise HTTPException(status_code=404, detail="Unknown library") + try: + with PlexClient(db) as plex: + new = plex.create_collection(lib.plex_key, title, seed) + col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey"))) + col.editable = True + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + raise HTTPException(status_code=502, detail=f"Plex write failed: {e}") + return _collection_card(col) + + +@router.post("/collections/{rating_key}/items/{item_rating_key}") +def collection_add_item( + rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + col = _editable_collection_or_403(db, rating_key) + lib = db.get(PlexLibrary, col.library_id) + try: + with PlexClient(db) as plex: + plex.add_collection_item(rating_key, str(item_rating_key)) + col = plex_sync.resync_collection(db, plex, lib, rating_key) + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + raise HTTPException(status_code=502, detail=f"Plex write failed: {e}") + return _collection_card(col) + + +@router.delete("/collections/{rating_key}/items/{item_rating_key}") +def collection_remove_item( + rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + col = _editable_collection_or_403(db, rating_key) + lib = db.get(PlexLibrary, col.library_id) + try: + with PlexClient(db) as plex: + plex.remove_collection_item(rating_key, str(item_rating_key)) + col = plex_sync.resync_collection(db, plex, lib, rating_key) + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + raise HTTPException(status_code=502, detail=f"Plex write failed: {e}") + return _collection_card(col) + + +@router.patch("/collections/{rating_key}") +def collection_rename( + rating_key: str, payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + col = _editable_collection_or_403(db, rating_key) + title = (payload.get("title") or "").strip() + if not title: + raise HTTPException(status_code=422, detail="title is required") + lib = db.get(PlexLibrary, col.library_id) + try: + with PlexClient(db) as plex: + plex.rename_collection(lib.plex_key, rating_key, title) + col = plex_sync.resync_collection(db, plex, lib, rating_key) + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + raise HTTPException(status_code=502, detail=f"Plex write failed: {e}") + return _collection_card(col) + + +@router.delete("/collections/{rating_key}") +def collection_delete( + rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + col = _editable_collection_or_403(db, rating_key) + lib = db.get(PlexLibrary, col.library_id) + try: + with PlexClient(db) as plex: + plex.delete_collection(rating_key) + plex_sync.delete_collection_local(db, lib, rating_key) + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + raise HTTPException(status_code=502, detail=f"Plex write failed: {e}") + return {"deleted": rating_key} + + +@router.post("/collections/{rating_key}/editable") +def collection_set_editable( + rating_key: str, payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: + """Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections + (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex.""" + col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() + if col is None: + raise HTTPException(status_code=404, detail="Unknown collection") + want = bool(payload.get("editable")) + if want and (col.smart or _collection_source(col) != "collection"): + raise HTTPException(status_code=400, detail="Smart / auto-list collections can't be made editable") + col.editable = want + db.commit() + return _collection_card(col) + + @router.get("/show/{rating_key}") def show_detail( rating_key: str, diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index d5e5fbf..1989433 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -146,6 +146,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt return ( sub.open({ kind: "player", id: infoId })} onPlayItem={(id) => sub.open({ kind: "player", id })} @@ -366,12 +367,14 @@ function PlexPosterCard({ function PlexInfoView({ id, + library, onBack, onPlay, onPlayItem, onFilter, }: { id: string; + library: string; onBack: () => void; onPlay: () => void; onPlayItem: (id: string) => void; @@ -397,9 +400,11 @@ function PlexInfoView({ q.refetch()} /> )} diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx new file mode 100644 index 0000000..0f300b1 --- /dev/null +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -0,0 +1,223 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Plus } from "lucide-react"; +import { api } from "../lib/api"; +import Modal from "./Modal"; +import { useConfirm } from "./ConfirmProvider"; + +// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE +// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to +// create a new collection seeded with this movie. Read-only collections (smart / IMDb-TMDb auto-lists) +// never appear here. Opened from the movie info page. +export default function PlexCollectionEditor({ + item, + library, + memberOf, + onClose, + onChanged, +}: { + item: { id: string; title: string }; + library: string; + memberOf: string[]; // collection ids this movie is already in + onClose: () => void; + onChanged: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const [members, setMembers] = useState>(() => new Set(memberOf)); + const [q, setQ] = useState(""); + const [newName, setNewName] = useState(""); + const [busy, setBusy] = useState(null); // collection id currently mutating + + const listQ = useQuery({ + queryKey: ["plex-collections", library], + queryFn: () => api.plexCollections(library), + }); + const editable = useMemo( + () => (listQ.data?.collections ?? []).filter((c) => c.can_edit), + [listQ.data], + ); + // Existing Plex collections you could take over (plain, non-smart, non-auto) but haven't marked yet. + const eligible = useMemo( + () => (listQ.data?.collections ?? []).filter((c) => !c.can_edit && !c.smart && c.source === "collection"), + [listQ.data], + ); + const term = q.trim().toLowerCase(); + const shown = term ? editable.filter((c) => c.title.toLowerCase().includes(term)) : editable; + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["plex-collections"] }); + qc.invalidateQueries({ queryKey: ["plex-item", item.id] }); + qc.invalidateQueries({ queryKey: ["plex-browse"] }); + onChanged(); + }; + + async function toggle(id: string) { + if (busy) return; + setBusy(id); + const inIt = members.has(id); + try { + if (inIt) await api.plexCollectionRemoveItem(id, item.id); + else await api.plexCollectionAddItem(id, item.id); + setMembers((prev) => { + const next = new Set(prev); + if (inIt) next.delete(id); + else next.add(id); + return next; + }); + refresh(); + } finally { + setBusy(null); + } + } + + async function markEditable(id: string) { + if (busy) return; + setBusy(id); + try { + await api.plexSetCollectionEditable(id, true); + refresh(); + } finally { + setBusy(null); + } + } + + async function createNew() { + const title = newName.trim(); + if (!title || busy) return; + setBusy("__new__"); + try { + const col = await api.plexCreateCollection(library, title, item.id); + setNewName(""); + setMembers((prev) => new Set(prev).add(col.id)); + refresh(); + } finally { + setBusy(null); + } + } + + async function removeCollection(id: string, title: string) { + if (!(await confirm({ message: t("plex.collEditor.deleteConfirm", { title }), danger: true }))) return; + setBusy(id); + try { + await api.plexDeleteCollection(id); + setMembers((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + refresh(); + } finally { + setBusy(null); + } + } + + return ( + + {/* Create new */} +
+ setNewName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && createNew()} + placeholder={t("plex.collEditor.newPlaceholder")} + className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-none focus:border-accent" + /> + +
+ + {/* Filter */} + {editable.length > 6 && ( + setQ(e.target.value)} + placeholder={t("plex.collEditor.search")} + className="glass-card mb-2 w-full rounded-lg px-3 py-1.5 text-sm outline-none focus:border-accent" + /> + )} + + {/* Editable collections with in/out toggle */} +
+ {listQ.isLoading ? ( +

{t("plex.loading")}

+ ) : shown.length === 0 ? ( +

{t("plex.collEditor.none")}

+ ) : ( + shown.map((c) => { + const inIt = members.has(c.id); + return ( +
+ + +
+ ); + }) + )} +
+ + {/* Take over an existing plain Plex collection (mark it "mine to edit"). */} + {eligible.length > 0 && ( +
+ + {t("plex.collEditor.others", { count: eligible.length })} + +
+ {eligible.map((c) => ( +
+ {c.title} + + {t("plex.collEditor.count", { count: c.child_count ?? 0 })} + + +
+ ))} +
+
+ )} + +

{t("plex.collEditor.note")}

+
+ ); +} diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index d11fb09..9193f63 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -5,6 +5,7 @@ import { useDismiss } from "../lib/useDismiss"; import { Check, ExternalLink, + FolderPlus, Play, RotateCcw, SlidersHorizontal, @@ -12,6 +13,7 @@ import { X, } from "lucide-react"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; +import PlexCollectionEditor from "./PlexCollectionEditor"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -30,6 +32,8 @@ type Props = { onFilter?: (patch: Partial) => void; // Play a sibling title from a collection strip (opens its player). onPlayItem?: (id: string) => void; + // The item's library key — enables the admin "Manage collections" editor (page variant, movies). + library?: string; }; // A metadata value that filters the grid when clicked (if onFilter is wired), else plain text. @@ -67,6 +71,7 @@ export default function PlexInfo({ onStateChange, onFilter, onPlayItem, + library, }: Props) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -88,6 +93,8 @@ export default function PlexInfo({ return next; }); }; + const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin"; + const [editorOpen, setEditorOpen] = useState(false); const [customizing, setCustomizing] = useState(false); const custBtnRef = useRef(null); const custMenuRef = useRef(null); @@ -360,6 +367,15 @@ export default function PlexInfo({ {t("plex.info.clearResume")} )} + {isAdmin && detail.kind === "movie" && library && ( + + )} )} @@ -488,6 +504,15 @@ export default function PlexInfo({ ))} + {editorOpen && library && ( + c.id)} + onClose={() => setEditorOpen(false)} + onChanged={() => onStateChange?.()} + /> + )} ); } diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 8c9bd70..97cede6 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -28,12 +28,28 @@ "genre": "Genre", "year": "Jahr", "duration": "Länge", - "durationOpt": { "short": "Unter 90 Min.", "mid": "90–120 Min.", "long": "Über 2 Std." }, + "durationOpt": { + "short": "Unter 90 Min.", + "mid": "90–120 Min.", + "long": "Über 2 Std." + }, "added": "Zu Plex hinzugefügt", - "addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" }, + "addedOpt": { + "24h": "24 Std.", + "1w": "1 Woche", + "1m": "1 Monat", + "6m": "6 Monate", + "1y": "1 Jahr" + }, "contentRating": "Altersfreigabe", - "match": { "any": "Beliebig", "all": "Alle" }, - "dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" }, + "match": { + "any": "Beliebig", + "all": "Alle" + }, + "dir": { + "desc": "↓ Absteigend", + "asc": "↑ Aufsteigend" + }, "collection": "Sammlung", "collectionSearch": "Sammlungen suchen…", "collectionEmpty": "Keine Sammlungen" @@ -59,6 +75,20 @@ "count": "{{count}} Titel", "added": "hinzugefügt" }, + "collEditor": { + "manage": "Sammlungen", + "title": "Sammlungen — {{title}}", + "newPlaceholder": "Name der neuen Sammlung…", + "create": "Erstellen", + "search": "Sammlungen filtern…", + "none": "Noch keine bearbeitbaren Sammlungen. Erstelle oben eine.", + "count": "{{count}} Titel", + "delete": "Löschen", + "deleteConfirm": "Sammlung „{{title}}“ aus Plex löschen? Betrifft alle Benutzer.", + "note": "Änderungen werden in Plex geschrieben und für alle Clients/Benutzer sichtbar.", + "others": "{{count}} vorhandene Sammlungen verwalten…", + "makeEditable": "Verwalten" + }, "player": { "loading": "Wird geladen…", "back": "Zurück", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index ce7d554..4c19ef0 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -28,12 +28,28 @@ "genre": "Genre", "year": "Year", "duration": "Length", - "durationOpt": { "short": "Under 90m", "mid": "90–120m", "long": "Over 2h" }, + "durationOpt": { + "short": "Under 90m", + "mid": "90–120m", + "long": "Over 2h" + }, "added": "Added to Plex", - "addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" }, + "addedOpt": { + "24h": "24h", + "1w": "1 week", + "1m": "1 month", + "6m": "6 months", + "1y": "1 year" + }, "contentRating": "Age rating", - "match": { "any": "Any", "all": "All" }, - "dir": { "desc": "↓ Descending", "asc": "↑ Ascending" }, + "match": { + "any": "Any", + "all": "All" + }, + "dir": { + "desc": "↓ Descending", + "asc": "↑ Ascending" + }, "collection": "Collection", "collectionSearch": "Search collections…", "collectionEmpty": "No collections" @@ -59,6 +75,20 @@ "count": "{{count}} titles", "added": "added" }, + "collEditor": { + "manage": "Collections", + "title": "Collections — {{title}}", + "newPlaceholder": "New collection name…", + "create": "Create", + "search": "Filter collections…", + "none": "No editable collections yet. Create one above.", + "count": "{{count}} items", + "delete": "Delete", + "deleteConfirm": "Delete the collection \"{{title}}\" from Plex? This affects all users.", + "note": "Changes are written to Plex and shared across all clients and users.", + "others": "Manage {{count}} existing collections…", + "makeEditable": "Manage" + }, "player": { "loading": "Loading…", "back": "Back", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 3868d93..05b48a0 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -28,12 +28,28 @@ "genre": "Műfaj", "year": "Év", "duration": "Hossz", - "durationOpt": { "short": "90 perc alatt", "mid": "90–120 perc", "long": "2 óra felett" }, + "durationOpt": { + "short": "90 perc alatt", + "mid": "90–120 perc", + "long": "2 óra felett" + }, "added": "Plexhez adva", - "addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" }, + "addedOpt": { + "24h": "24 óra", + "1w": "1 hét", + "1m": "1 hónap", + "6m": "6 hónap", + "1y": "1 év" + }, "contentRating": "Korhatár", - "match": { "any": "Bármelyik", "all": "Mind" }, - "dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" }, + "match": { + "any": "Bármelyik", + "all": "Mind" + }, + "dir": { + "desc": "↓ Csökkenő", + "asc": "↑ Növekvő" + }, "collection": "Kollekció", "collectionSearch": "Kollekciók keresése…", "collectionEmpty": "Nincs kollekció" @@ -59,6 +75,20 @@ "count": "{{count}} cím", "added": "hozzáadva" }, + "collEditor": { + "manage": "Kollekciók", + "title": "Kollekciók — {{title}}", + "newPlaceholder": "Új kollekció neve…", + "create": "Létrehozás", + "search": "Kollekciók szűrése…", + "none": "Még nincs szerkeszthető kollekció. Hozz létre egyet fent.", + "count": "{{count}} elem", + "delete": "Törlés", + "deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót a Plexből? Ez minden felhasználót érint.", + "note": "A változtatások a Plexbe íródnak, és minden kliensen/felhasználónál megjelennek.", + "others": "{{count}} meglévő kollekció kezelése…", + "makeEditable": "Kezelem" + }, "player": { "loading": "Betöltés…", "back": "Vissza", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 487255a..ba8aa98 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -726,7 +726,9 @@ export interface PlexCollection { thumb?: string | null; child_count?: number | null; smart: boolean; + source?: string; editable: boolean; + can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection } export interface PlexCastMember { name: string; @@ -1149,6 +1151,19 @@ export const api = { req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`), plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> => req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`), + // --- Collection editing (admin only; writes back to Plex) --- + plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise => + req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }), + plexCollectionAddItem: (rk: string, itemRk: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "POST" }), + plexCollectionRemoveItem: (rk: string, itemRk: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }), + plexRenameCollection: (rk: string, title: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "PATCH", body: JSON.stringify({ title }) }), + plexDeleteCollection: (rk: string): Promise<{ deleted: string }> => + req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }), + plexSetCollectionEditable: (rk: string, editable: boolean): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }), plexShow: (id: string): Promise => req(`/api/plex/show/${encodeURIComponent(id)}`), plexItem: (id: string): Promise => diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 1df6e07..2b5d1f8 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,15 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.29.0", + date: "2026-07-06", + summary: "Plex collections — admins can now create and curate them.", + features: [ + "Plex collections (admin): a movie's info page has a “Collections” button to create a new collection (seeded with that movie), add or remove the movie from your collections, and delete a collection. Changes are written back to Plex, so they show up on every client and for everyone.", + "Plex collections (admin): existing plain Plex collections can be “taken over” (marked as yours to manage) from the same dialog. Smart collections and external auto-lists (IMDb Top 250, TMDb Trending, …) stay read-only — they're regenerated outside Plex. Editing is admin-only, since collections are shared library-wide.", + ], + }, { version: "0.28.3", date: "2026-07-06",