fix(plex): resolve deep-review findings on the Discover/Collections batch

Round of USER-triggered deep-review fixes (11 CONFIRMED + 1 PLAUSIBLE, all
verified) on the 22-commit branch:

- collections: split the mutate-then-resync error surface. A resync READ that
  fails after a successful Plex mutation no longer reports a false 422 "Plex
  write failed" + rollback; it returns 502 "sync pending" (the next full sync
  reconciles the mirror). New _write_or_pending_error helper; delete is exempt
  (no Plex read). Covered by new happy-path / 422-rollback / 502 tests.
- New-collection dup-name guard is now advisory only: existingNames is the
  search-scoped, all-library union, but the backend enforces uniqueness PER
  library — so it no longer blocks a valid same-name collection in another
  library, and the backend 409 is localized to nameTaken.
- member-remove now confirms before the write-through (matches delete-collection).
- add-title / new-collection library search render an error state on request
  failure instead of a misleading "No matches".
- history: seed navCounter past the current entry's persisted sfScroll on load
  so post-reload ids can't collide with back-stack entries and mis-restore scroll.
- PageScroller: LRU-cap the navId-keyed savedScroll map (was unbounded).
- share isIntrinsicReadOnly between the browse tile and the info page so the
  lock/edit affordance can't drift.
This commit is contained in:
2026-08-23 06:57:33 +02:00
parent b71b33654a
commit 6af238c2ea
10 changed files with 227 additions and 21 deletions
+29 -4
View File
@@ -208,6 +208,23 @@ def _reject_duplicate_name(
raise HTTPException(status_code=409, detail="A collection with this name already exists") raise HTTPException(status_code=409, detail="A collection with this name already exists")
def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
"""Map a PlexError raised inside a mutate-then-resync block to the honest status. If the mutating
call itself failed (``wrote`` False) the write never landed → 422 "Plex write failed". If the
write succeeded but the follow-up resync READ (Plex metadata/children) failed, the change IS on
the Plex server → don't claim a write failure; report 502 "sync pending" (the next full sync
reconciles the local mirror)."""
if wrote:
return HTTPException(
status_code=502,
detail=(
"Collection updated in Plex, but refreshing the local copy failed; "
f"it will reconcile on the next sync ({e})"
),
)
return HTTPException(status_code=422, detail=f"Plex write failed: {e}")
class CreateCollectionIn(BaseModel): class CreateCollectionIn(BaseModel):
library: str = "" library: str = ""
title: str = "" title: str = ""
@@ -239,15 +256,17 @@ def create_collection(
if lib is None: if lib is None:
raise HTTPException(status_code=404, detail="Unknown library") raise HTTPException(status_code=404, detail="Unknown library")
_reject_duplicate_name(db, lib.id, title) _reject_duplicate_name(db, lib.id, title)
wrote = False
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
new = plex.create_collection(lib.plex_key, title, seed) new = plex.create_collection(lib.plex_key, title, seed)
wrote = True
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey"))) col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
col.editable = True col.editable = True
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") raise _write_or_pending_error(wrote, e)
return _collection_card(col) return _collection_card(col)
@@ -260,14 +279,16 @@ def collection_add_item(
) -> dict: ) -> dict:
col = _editable_collection_or_403(db, rating_key) col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id) lib = db.get(PlexLibrary, col.library_id)
wrote = False
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
plex.add_collection_item(rating_key, str(item_rating_key)) plex.add_collection_item(rating_key, str(item_rating_key))
wrote = True
col = plex_sync.resync_collection(db, plex, lib, rating_key) col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") raise _write_or_pending_error(wrote, e)
return _collection_card(col) return _collection_card(col)
@@ -280,14 +301,16 @@ def collection_remove_item(
) -> dict: ) -> dict:
col = _editable_collection_or_403(db, rating_key) col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id) lib = db.get(PlexLibrary, col.library_id)
wrote = False
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
plex.remove_collection_item(rating_key, str(item_rating_key)) plex.remove_collection_item(rating_key, str(item_rating_key))
wrote = True
col = plex_sync.resync_collection(db, plex, lib, rating_key) col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") raise _write_or_pending_error(wrote, e)
return _collection_card(col) return _collection_card(col)
@@ -308,14 +331,16 @@ def collection_rename(
raise HTTPException(status_code=400, detail="title is required") raise HTTPException(status_code=400, detail="title is required")
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key) _reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
lib = db.get(PlexLibrary, col.library_id) lib = db.get(PlexLibrary, col.library_id)
wrote = False
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
plex.rename_collection(lib.plex_key, rating_key, title) plex.rename_collection(lib.plex_key, rating_key, title)
wrote = True
col = plex_sync.resync_collection(db, plex, lib, rating_key) col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") raise _write_or_pending_error(wrote, e)
return _collection_card(col) return _collection_card(col)
@@ -5,12 +5,15 @@ DB lane (`db` fixture) — skipped without Postgres.
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import select
import app.state as state import app.state as state
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.main import app from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
from app.plex.client import PlexError
from app.routes.plex import collections as coll_routes
def _seed(db): def _seed(db):
@@ -142,6 +145,130 @@ def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
assert [i["id"] for i in d["items"]] == ["early", "later"] # release-year order assert [i["id"] for i in d["items"]] == ["early", "later"] # release-year order
# --- Write-through collection mutations (create/add/remove/rename write to Plex, then resync the local
# mirror). We stub PlexClient so the DB lane can drive the happy path, the 422-on-mutation-failure
# rollback, and the 502-on-resync-failure split (a successful write whose follow-up refresh read fails
# must NOT be reported as a write failure). ------------------------------------------------------------
class _CollStub:
"""A PlexClient stand-in recording calls; `fail_on` makes one method raise PlexError."""
def __init__(self, fail_on: str | None = None):
self.fail_on = fail_on
self.calls: list[str] = []
def __enter__(self):
return self
def __exit__(self, *a):
return False
def _maybe(self, name: str):
self.calls.append(name)
if self.fail_on == name:
raise PlexError("boom")
def create_collection(self, section_key, title, item_rating_key):
self._maybe("create_collection")
return {"ratingKey": "NEW"}
def add_collection_item(self, rk, item):
self._maybe("add_collection_item")
def remove_collection_item(self, rk, item):
self._maybe("remove_collection_item")
def rename_collection(self, section_key, rk, title):
self._maybe("rename_collection")
def delete_collection(self, rk):
self._maybe("delete_collection")
def _resync_ok(db, plex, lib, rk):
"""Stand in for the post-write refresh: return the existing local row (no live Plex read)."""
return db.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
def _seed_editable(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
# Plain, admin-editable collection (title isn't a provider word, not smart → source "collection").
db.add(PlexCollection(rating_key="A", library_id=lib.id, title="My Coll", editable=True,
child_count=1))
db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1",
collection_keys=["A"]))
user = User(email="mut@example.com")
db.add(user)
db.commit()
return lib, user
def _admin_client(db, user, monkeypatch, stub, resync=_resync_ok):
monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
app.dependency_overrides[admin_user] = lambda: user
return TestClient(app)
def test_rename_writes_through_to_plex(db, monkeypatch):
_lib, user = _seed_editable(db)
stub = _CollStub()
client = _admin_client(db, user, monkeypatch, stub)
try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 200, r.text
assert "rename_collection" in stub.calls
finally:
app.dependency_overrides.clear()
def test_remove_item_writes_through_to_plex(db, monkeypatch):
_lib, user = _seed_editable(db)
stub = _CollStub()
client = _admin_client(db, user, monkeypatch, stub)
try:
r = client.delete("/api/plex/collections/A/items/m1")
assert r.status_code == 200, r.text
assert "remove_collection_item" in stub.calls
finally:
app.dependency_overrides.clear()
def test_mutation_failure_returns_422_and_keeps_local_row(db, monkeypatch):
_lib, user = _seed_editable(db)
stub = _CollStub(fail_on="rename_collection")
client = _admin_client(db, user, monkeypatch, stub)
try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 422, r.text
finally:
app.dependency_overrides.clear()
row = db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
assert row.title == "My Coll" # rolled back — the write never landed
def test_resync_failure_after_successful_write_returns_502(db, monkeypatch):
# The mutation landed on Plex; only the follow-up refresh READ failed → 502 "sync pending", NOT a
# false 422 "write failed". Regression guard for the mutate-then-resync error split.
_lib, user = _seed_editable(db)
stub = _CollStub()
def _resync_boom(db_, plex, lib_, rk):
raise PlexError("read failed")
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom)
try:
r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
assert r.status_code == 502, r.text
assert "rename_collection" in stub.calls # the write DID happen
finally:
app.dependency_overrides.clear()
def test_show_collection_gets_montage_art_from_shows(db, monkeypatch): def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
# A show-library collection must get its montage from PlexShow members (else it renders blank) — the # A show-library collection must get its montage from PlexShow members (else it renders blank) — the
# A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration). # A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration).
+13 -1
View File
@@ -8,6 +8,18 @@ import { ensureNavId, peekNavId, subscribeNav } from "../lib/history";
// top WITHIN the same entry. Module-level so a view's position survives its scroller unmounting // top WITHIN the same entry. Module-level so a view's position survives its scroller unmounting
// (opening a channel page swaps in a different shell/scroller) and is restored when it returns. // (opening a channel page swaps in a different shell/scroller) and is restored when it returns.
const savedScroll = new Map<string, number>(); const savedScroll = new Map<string, number>();
// The key embeds an ever-increasing history-entry id, so without a bound this map would grow one
// entry per scrolled navigation for the life of the session. Cap it with a small LRU (Map preserves
// insertion order): re-insert on write to mark most-recently-used, evict the oldest past the cap.
const SCROLL_MEMORY_CAP = 200;
function rememberScroll(key: string, top: number): void {
savedScroll.delete(key);
savedScroll.set(key, top);
if (savedScroll.size > SCROLL_MEMORY_CAP) {
const oldest = savedScroll.keys().next().value;
if (oldest !== undefined) savedScroll.delete(oldest);
}
}
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame // Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise // or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
@@ -120,7 +132,7 @@ export default function PageScroller({
if (!el) return; if (!el) return;
const onScroll = () => { const onScroll = () => {
if (scrollKeyRef.current == null) return; if (scrollKeyRef.current == null) return;
savedScroll.set(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop); rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
}; };
el.addEventListener("scroll", onScroll, { passive: true }); el.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("popstate", resolve); window.addEventListener("popstate", resolve);
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react"; import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
@@ -67,8 +67,9 @@ export default function PlexCollectionView({
// The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly. // The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly.
const playable = items.filter((c) => c.type !== "show").map((c) => c.id); const playable = items.filter((c) => c.type !== "show").map((c) => c.id);
const canEdit = isAdmin && !!d?.can_edit; const canEdit = isAdmin && !!d?.can_edit;
const canEnableEdit = // A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
isAdmin && !d?.can_edit && !d?.editable && !d?.smart && d?.source === "collection"; // isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
const canEnableEdit = isAdmin && !d?.can_edit && !d?.editable && !!d && !isIntrinsicReadOnly(d);
const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
const toggleWatched = (card: PlexCard) => const toggleWatched = (card: PlexCard) =>
@@ -120,6 +121,9 @@ export default function PlexCollectionView({
} }
async function doRemove(card: PlexCard) { async function doRemove(card: PlexCard) {
// Write-through to the shared Plex library — confirm first, matching the delete-collection guard.
if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
return;
setBusy(true); setBusy(true);
try { try {
await api.plexCollectionRemoveItem(collectionId, card.id); await api.plexCollectionRemoveItem(collectionId, card.id);
@@ -381,6 +385,8 @@ function AddItemPanel({
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p> <p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
) : q.isLoading ? ( ) : q.isLoading ? (
<LoadingState label={t("plex.loading")} /> <LoadingState label={t("plex.loading")} />
) : q.isError ? (
<p className="text-muted text-sm">{t("common.loadError")}</p>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p> <p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
) : ( ) : (
+25 -12
View File
@@ -4,9 +4,16 @@ import type { TFunction } from "i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react"; import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexCollection } from "../lib/api"; import {
api,
HttpError,
isIntrinsicReadOnly,
type PlexCard,
type PlexCollection,
} from "../lib/api";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./QueryState"; import { LoadingState, StateMessage } from "./QueryState";
import PosterTile from "./PosterTile"; import PosterTile from "./PosterTile";
@@ -21,12 +28,6 @@ function collectionMeta(c: PlexCollection, t: TFunction): string {
return parts.join(" · "); return parts.join(" · ");
} }
// A read-only collection (a smart or external auto-list — imdb/tmdb/…) can never be edited; mark it up
// front on the tile, mirroring the badge on the info page.
function isReadOnlyCollection(c: PlexCollection): boolean {
return !!c.smart || (!!c.source && c.source !== "collection");
}
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles, // The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the // each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections // title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
@@ -87,8 +88,12 @@ function NewCollectionPanel({
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const debounced = useDebounced(term.trim(), 300); const debounced = useDebounced(term.trim(), 300);
const trimmed = name.trim(); const trimmed = name.trim();
const dup = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase()); // Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
const nameOk = trimmed.length > 0 && !dup; // backend enforces uniqueness PER library (Plex only conflates same-named collections within one
// section). So a match here is a hint, not a hard block — it must not gate creation, or a valid
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
const nameOk = trimmed.length > 0;
const q = useQuery({ const q = useQuery({
queryKey: qk.plexLibrary("collection-new", debounced), queryKey: qk.plexLibrary("collection-new", debounced),
queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }), queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }),
@@ -103,7 +108,13 @@ function NewCollectionPanel({
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
onCreated(col.id); onCreated(col.id);
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); // The backend is authoritative on per-library name uniqueness — localize its 409 to nameTaken
// (notifyActionError would otherwise surface the server's raw English detail).
if (e instanceof HttpError && e.status === 409) {
notify({ level: "error", message: t("plex.collection.nameTaken") });
} else {
notifyActionError(e, t("plex.collection.editFailed"));
}
setBusy(false); setBusy(false);
} }
} }
@@ -128,7 +139,7 @@ function NewCollectionPanel({
aria-label={t("plex.collection.newCollection")} aria-label={t("plex.collection.newCollection")}
className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/> />
{dup ? ( {dupHint ? (
<p className="mt-1 mb-2 text-xs text-danger">{t("plex.collection.nameTaken")}</p> <p className="mt-1 mb-2 text-xs text-danger">{t("plex.collection.nameTaken")}</p>
) : ( ) : (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p> <p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p>
@@ -146,6 +157,8 @@ function NewCollectionPanel({
</div> </div>
{!nameOk || debounced.length === 0 ? null : q.isLoading ? ( {!nameOk || debounced.length === 0 ? null : q.isLoading ? (
<LoadingState label={t("plex.loading")} /> <LoadingState label={t("plex.loading")} />
) : q.isError ? (
<p className="text-muted text-sm">{t("common.loadError")}</p>
) : results.length === 0 ? ( ) : results.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p> <p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
) : ( ) : (
@@ -244,7 +257,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
art={c.art} art={c.art}
subtitle={collectionMeta(c, t)} subtitle={collectionMeta(c, t)}
topRight={ topRight={
isReadOnlyCollection(c) ? ( isIntrinsicReadOnly(c) ? (
<Lock <Lock
className="h-3.5 w-3.5 text-white/80 drop-shadow" className="h-3.5 w-3.5 text-white/80 drop-shadow"
aria-label={t("plex.collection.readOnly")} aria-label={t("plex.collection.readOnly")}
+1
View File
@@ -10,6 +10,7 @@
"delete": "Delete", "delete": "Delete",
"enableEdit": "Enable editing", "enableEdit": "Enable editing",
"removeItem": "Remove from collection", "removeItem": "Remove from collection",
"removeConfirm": "Remove \"{{title}}\" from this collection? This affects all users on Plex.",
"readOnly": "Read-only", "readOnly": "Read-only",
"empty": "This collection has no items.", "empty": "This collection has no items.",
"more": "More", "more": "More",
+1
View File
@@ -10,6 +10,7 @@
"delete": "Törlés", "delete": "Törlés",
"enableEdit": "Szerkesztés engedélyezése", "enableEdit": "Szerkesztés engedélyezése",
"removeItem": "Eltávolítás a kollekcióból", "removeItem": "Eltávolítás a kollekcióból",
"removeConfirm": "Eltávolítod a(z) „{{title}}” elemet ebből a kollekcióból? Ez a Plexen minden felhasználót érint.",
"readOnly": "Csak olvasható", "readOnly": "Csak olvasható",
"empty": "Ennek a kollekciónak nincsenek elemei.", "empty": "Ennek a kollekciónak nincsenek elemei.",
"more": "Több", "more": "Több",
+1
View File
@@ -56,6 +56,7 @@ export {
plexFilterCount, plexFilterCount,
coercePlexFilters, coercePlexFilters,
plexFiltersSignature, plexFiltersSignature,
isIntrinsicReadOnly,
} from "./api/plex"; } from "./api/plex";
import { messagingApi } from "./api/messaging"; import { messagingApi } from "./api/messaging";
export type * from "./api/messaging"; export type * from "./api/messaging";
+6
View File
@@ -187,6 +187,12 @@ export interface PlexCollection {
art?: string[]; // up to 2 poster rating_keys for the browse-tile montage (browse union only) art?: string[]; // up to 2 poster rating_keys for the browse-tile montage (browse union only)
total_runtime_s?: number; // total runtime of all items, for the tile sub-line (browse union only) total_runtime_s?: number; // total runtime of all items, for the tile sub-line (browse union only)
} }
/** A collection Plex regenerates outside a user's control — a smart collection or an external auto-list
* (imdb/tmdb/tvdb/trakt/…) — can never be edited. The single source of truth for the lock badge shown
* on both the browse tile and the info page, so the two surfaces can't drift. */
export function isIntrinsicReadOnly(c: Pick<PlexCollection, "smart" | "source">): boolean {
return !!c.smart || (!!c.source && c.source !== "collection");
}
// The Collection info page: the card fields + hero aggregates + the ordered members. // The Collection info page: the card fields + hero aggregates + the ordered members.
export interface PlexCollectionDetail extends PlexCollection { export interface PlexCollectionDetail extends PlexCollection {
content_rating?: string | null; content_rating?: string | null;
+15 -1
View File
@@ -21,6 +21,15 @@ if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"; window.history.scrollRestoration = "manual";
} }
let navCounter = 0; let navCounter = 0;
// sfScroll ids are persisted into window.history.state and survive a full page reload, but this
// module-level counter resets to 0 on every load. Seed it past the current entry's id so freshly
// minted ids can't collide with ids already carried by pre-reload back-stack entries (ids are
// assigned in forward order, so the current entry's id is ≥ every ancestor's) — a collision would
// otherwise cross-contaminate PageScroller's per-entry scroll offsets after a reload + navigation.
if (typeof window !== "undefined") {
const seed = (window.history.state as { sfScroll?: number } | null)?.sfScroll;
if (typeof seed === "number") navCounter = seed;
}
/** A fresh entry id — stamp it into the state of every pushState that opens a new main-content view. */ /** A fresh entry id — stamp it into the state of every pushState that opens a new main-content view. */
export function nextNavId(): number { export function nextNavId(): number {
return ++navCounter; return ++navCounter;
@@ -29,7 +38,12 @@ export function nextNavId(): number {
* initial load, or an entry pushed without one — so the id survives Back/Forward. */ * initial load, or an entry pushed without one — so the id survives Back/Forward. */
export function ensureNavId(): number { export function ensureNavId(): number {
const st = (window.history.state ?? {}) as { sfScroll?: number }; const st = (window.history.state ?? {}) as { sfScroll?: number };
if (typeof st.sfScroll === "number") return st.sfScroll; if (typeof st.sfScroll === "number") {
// Keep the counter ahead of any id we observe (e.g. a Back landed on a higher-id entry after a
// reload seeded us low) so the next mint never duplicates a live entry's id.
if (st.sfScroll > navCounter) navCounter = st.sfScroll;
return st.sfScroll;
}
const id = nextNavId(); const id = nextNavId();
window.history.replaceState({ ...st, sfScroll: id }, ""); window.history.replaceState({ ...st, sfScroll: id }, "");
return id; return id;