Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-06 17:35:14 +02:00
commit fbf67431b6
12 changed files with 602 additions and 13 deletions

View file

@ -1 +1 @@
0.28.3 0.29.0

View file

@ -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&sectionId={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

View file

@ -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:

View file

@ -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,

View file

@ -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>
)} )}

View 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>
);
}

View file

@ -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>
); );
} }

View file

@ -28,12 +28,28 @@
"genre": "Genre", "genre": "Genre",
"year": "Jahr", "year": "Jahr",
"duration": "Länge", "duration": "Länge",
"durationOpt": { "short": "Unter 90 Min.", "mid": "90120 Min.", "long": "Über 2 Std." }, "durationOpt": {
"short": "Unter 90 Min.",
"mid": "90120 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",

View file

@ -28,12 +28,28 @@
"genre": "Genre", "genre": "Genre",
"year": "Year", "year": "Year",
"duration": "Length", "duration": "Length",
"durationOpt": { "short": "Under 90m", "mid": "90120m", "long": "Over 2h" }, "durationOpt": {
"short": "Under 90m",
"mid": "90120m",
"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",

View file

@ -28,12 +28,28 @@
"genre": "Műfaj", "genre": "Műfaj",
"year": "Év", "year": "Év",
"duration": "Hossz", "duration": "Hossz",
"durationOpt": { "short": "90 perc alatt", "mid": "90120 perc", "long": "2 óra felett" }, "durationOpt": {
"short": "90 perc alatt",
"mid": "90120 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",

View file

@ -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> =>

View file

@ -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",