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")
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
|||
return (
|
||||
<PlexInfoView
|
||||
id={infoId}
|
||||
library={library}
|
||||
onBack={sub.back}
|
||||
onPlay={() => 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({
|
|||
<PlexInfo
|
||||
detail={q.data}
|
||||
variant="page"
|
||||
library={library}
|
||||
onPlay={onPlay}
|
||||
onPlayItem={onPlayItem}
|
||||
onFilter={onFilter}
|
||||
onStateChange={() => q.refetch()}
|
||||
/>
|
||||
</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 {
|
||||
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<PlexFilters>) => 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<HTMLButtonElement>(null);
|
||||
const custMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -360,6 +367,15 @@ export default function PlexInfo({
|
|||
{t("plex.info.clearResume")}
|
||||
</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>
|
||||
|
|
@ -488,6 +504,15 @@ export default function PlexInfo({
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<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> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue