Merge: promote dev to prod
This commit is contained in:
commit
fbf67431b6
12 changed files with 602 additions and 13 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.28.3
|
0.29.0
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,59 @@ class PlexClient:
|
||||||
mc = self._get(f"/library/collections/{rating_key}/children")
|
mc = self._get(f"/library/collections/{rating_key}/children")
|
||||||
return mc.get("Metadata", []) or []
|
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:
|
def metadata(self, rating_key: str, markers: bool = False) -> dict | None:
|
||||||
"""Full metadata for one item; include intro/credit markers when requested."""
|
"""Full metadata for one item; include intro/credit markers when requested."""
|
||||||
params = {"includeMarkers": 1} if markers else None
|
params = {"includeMarkers": 1} if markers else None
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,46 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||||
db.flush()
|
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:
|
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
|
"""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:
|
written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile:
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,12 @@ def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None:
|
||||||
return 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:
|
def _collection_card(c: PlexCollection) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": c.rating_key,
|
"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,
|
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
|
||||||
"child_count": c.child_count,
|
"child_count": c.child_count,
|
||||||
"smart": c.smart,
|
"smart": c.smart,
|
||||||
|
"source": _collection_source(c),
|
||||||
"editable": c.editable,
|
"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]}
|
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}")
|
@router.get("/show/{rating_key}")
|
||||||
def show_detail(
|
def show_detail(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
||||||
return (
|
return (
|
||||||
<PlexInfoView
|
<PlexInfoView
|
||||||
id={infoId}
|
id={infoId}
|
||||||
|
library={library}
|
||||||
onBack={sub.back}
|
onBack={sub.back}
|
||||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||||
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
||||||
|
|
@ -366,12 +367,14 @@ function PlexPosterCard({
|
||||||
|
|
||||||
function PlexInfoView({
|
function PlexInfoView({
|
||||||
id,
|
id,
|
||||||
|
library,
|
||||||
onBack,
|
onBack,
|
||||||
onPlay,
|
onPlay,
|
||||||
onPlayItem,
|
onPlayItem,
|
||||||
onFilter,
|
onFilter,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
|
library: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
onPlayItem: (id: string) => void;
|
onPlayItem: (id: string) => void;
|
||||||
|
|
@ -397,9 +400,11 @@ function PlexInfoView({
|
||||||
<PlexInfo
|
<PlexInfo
|
||||||
detail={q.data}
|
detail={q.data}
|
||||||
variant="page"
|
variant="page"
|
||||||
|
library={library}
|
||||||
onPlay={onPlay}
|
onPlay={onPlay}
|
||||||
onPlayItem={onPlayItem}
|
onPlayItem={onPlayItem}
|
||||||
onFilter={onFilter}
|
onFilter={onFilter}
|
||||||
|
onStateChange={() => q.refetch()}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
223
frontend/src/components/PlexCollectionEditor.tsx
Normal file
223
frontend/src/components/PlexCollectionEditor.tsx
Normal file
|
|
@ -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<Set<string>>(() => new Set(memberOf));
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [busy, setBusy] = useState<string | null>(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 (
|
||||||
|
<Modal title={t("plex.collEditor.title", { title: item.title })} onClose={onClose}>
|
||||||
|
{/* Create new */}
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={createNew}
|
||||||
|
disabled={!newName.trim() || !!busy}
|
||||||
|
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("plex.collEditor.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
{editable.length > 6 && (
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => 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 */}
|
||||||
|
<div className="max-h-80 space-y-1 overflow-y-auto">
|
||||||
|
{listQ.isLoading ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted">{t("plex.collEditor.none")}</p>
|
||||||
|
) : (
|
||||||
|
shown.map((c) => {
|
||||||
|
const inIt = members.has(c.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggle(c.id)}
|
||||||
|
disabled={!!busy}
|
||||||
|
className={`inline-flex min-w-0 flex-1 items-center gap-2 text-left disabled:opacity-50 ${
|
||||||
|
inIt ? "text-accent" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${
|
||||||
|
inIt ? "border-accent bg-accent/20" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{inIt && <Check className="h-3.5 w-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{c.title}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted">
|
||||||
|
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeCollection(c.id, c.title)}
|
||||||
|
disabled={!!busy}
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-muted hover:text-red-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("plex.collEditor.delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Take over an existing plain Plex collection (mark it "mine to edit"). */}
|
||||||
|
{eligible.length > 0 && (
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="cursor-pointer text-xs text-muted hover:text-fg">
|
||||||
|
{t("plex.collEditor.others", { count: eligible.length })}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 max-h-40 space-y-1 overflow-y-auto">
|
||||||
|
{eligible.map((c) => (
|
||||||
|
<div key={c.id} className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{c.title}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted">
|
||||||
|
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => markEditable(c.id)}
|
||||||
|
disabled={!!busy}
|
||||||
|
className="glass-card glass-hover shrink-0 rounded px-2 py-0.5 text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("plex.collEditor.makeEditable")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-muted">{t("plex.collEditor.note")}</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useDismiss } from "../lib/useDismiss";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
FolderPlus,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
|
@ -12,6 +13,7 @@ import {
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
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,
|
// 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
|
// 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<PlexFilters>) => void;
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
// Play a sibling title from a collection strip (opens its player).
|
// Play a sibling title from a collection strip (opens its player).
|
||||||
onPlayItem?: (id: string) => void;
|
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.
|
// 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,
|
onStateChange,
|
||||||
onFilter,
|
onFilter,
|
||||||
onPlayItem,
|
onPlayItem,
|
||||||
|
library,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
@ -88,6 +93,8 @@ export default function PlexInfo({
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [customizing, setCustomizing] = useState(false);
|
const [customizing, setCustomizing] = useState(false);
|
||||||
const custBtnRef = useRef<HTMLButtonElement>(null);
|
const custBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const custMenuRef = useRef<HTMLDivElement>(null);
|
const custMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -360,6 +367,15 @@ export default function PlexInfo({
|
||||||
{t("plex.info.clearResume")}
|
{t("plex.info.clearResume")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && detail.kind === "movie" && library && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditorOpen(true)}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-4 h-4" />
|
||||||
|
{t("plex.collEditor.manage")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -488,6 +504,15 @@ export default function PlexInfo({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{editorOpen && library && (
|
||||||
|
<PlexCollectionEditor
|
||||||
|
item={{ id: detail.id, title: detail.title }}
|
||||||
|
library={library}
|
||||||
|
memberOf={(detail.collections ?? []).map((c) => c.id)}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onChanged={() => onStateChange?.()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,28 @@
|
||||||
"genre": "Genre",
|
"genre": "Genre",
|
||||||
"year": "Jahr",
|
"year": "Jahr",
|
||||||
"duration": "Länge",
|
"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",
|
"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",
|
"contentRating": "Altersfreigabe",
|
||||||
"match": { "any": "Beliebig", "all": "Alle" },
|
"match": {
|
||||||
"dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" },
|
"any": "Beliebig",
|
||||||
|
"all": "Alle"
|
||||||
|
},
|
||||||
|
"dir": {
|
||||||
|
"desc": "↓ Absteigend",
|
||||||
|
"asc": "↑ Aufsteigend"
|
||||||
|
},
|
||||||
"collection": "Sammlung",
|
"collection": "Sammlung",
|
||||||
"collectionSearch": "Sammlungen suchen…",
|
"collectionSearch": "Sammlungen suchen…",
|
||||||
"collectionEmpty": "Keine Sammlungen"
|
"collectionEmpty": "Keine Sammlungen"
|
||||||
|
|
@ -59,6 +75,20 @@
|
||||||
"count": "{{count}} Titel",
|
"count": "{{count}} Titel",
|
||||||
"added": "hinzugefügt"
|
"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": {
|
"player": {
|
||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,28 @@
|
||||||
"genre": "Genre",
|
"genre": "Genre",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"duration": "Length",
|
"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",
|
"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",
|
"contentRating": "Age rating",
|
||||||
"match": { "any": "Any", "all": "All" },
|
"match": {
|
||||||
"dir": { "desc": "↓ Descending", "asc": "↑ Ascending" },
|
"any": "Any",
|
||||||
|
"all": "All"
|
||||||
|
},
|
||||||
|
"dir": {
|
||||||
|
"desc": "↓ Descending",
|
||||||
|
"asc": "↑ Ascending"
|
||||||
|
},
|
||||||
"collection": "Collection",
|
"collection": "Collection",
|
||||||
"collectionSearch": "Search collections…",
|
"collectionSearch": "Search collections…",
|
||||||
"collectionEmpty": "No collections"
|
"collectionEmpty": "No collections"
|
||||||
|
|
@ -59,6 +75,20 @@
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
"added": "added"
|
"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": {
|
"player": {
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,28 @@
|
||||||
"genre": "Műfaj",
|
"genre": "Műfaj",
|
||||||
"year": "Év",
|
"year": "Év",
|
||||||
"duration": "Hossz",
|
"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",
|
"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",
|
"contentRating": "Korhatár",
|
||||||
"match": { "any": "Bármelyik", "all": "Mind" },
|
"match": {
|
||||||
"dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" },
|
"any": "Bármelyik",
|
||||||
|
"all": "Mind"
|
||||||
|
},
|
||||||
|
"dir": {
|
||||||
|
"desc": "↓ Csökkenő",
|
||||||
|
"asc": "↑ Növekvő"
|
||||||
|
},
|
||||||
"collection": "Kollekció",
|
"collection": "Kollekció",
|
||||||
"collectionSearch": "Kollekciók keresése…",
|
"collectionSearch": "Kollekciók keresése…",
|
||||||
"collectionEmpty": "Nincs kollekció"
|
"collectionEmpty": "Nincs kollekció"
|
||||||
|
|
@ -59,6 +75,20 @@
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
"added": "hozzáadva"
|
"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": {
|
"player": {
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"back": "Vissza",
|
"back": "Vissza",
|
||||||
|
|
|
||||||
|
|
@ -726,7 +726,9 @@ export interface PlexCollection {
|
||||||
thumb?: string | null;
|
thumb?: string | null;
|
||||||
child_count?: number | null;
|
child_count?: number | null;
|
||||||
smart: boolean;
|
smart: boolean;
|
||||||
|
source?: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
|
||||||
}
|
}
|
||||||
export interface PlexCastMember {
|
export interface PlexCastMember {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -1149,6 +1151,19 @@ export const api = {
|
||||||
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
||||||
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
||||||
req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`),
|
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<PlexCollection> =>
|
||||||
|
req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),
|
||||||
|
plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
||||||
|
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "POST" }),
|
||||||
|
plexCollectionRemoveItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
|
||||||
|
req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
|
||||||
|
plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
|
||||||
|
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<PlexCollection> =>
|
||||||
|
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
|
||||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.28.3",
|
||||||
date: "2026-07-06",
|
date: "2026-07-06",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue