diff --git a/backend/app/plex/lookups.py b/backend/app/plex/lookups.py index fceba665..577f0ba6 100644 --- a/backend/app/plex/lookups.py +++ b/backend/app/plex/lookups.py @@ -1,11 +1,16 @@ """Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place.""" +from typing import TypeVar + from sqlalchemy import select from sqlalchemy.orm import Session +_M = TypeVar("_M") -def by_rating_key(db: Session, model, rating_key: str): + +def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None: """The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex - tables (PlexItem / PlexShow / PlexSeason / PlexCollection).""" + tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call + sites keep their `Model | None` static type.""" return db.execute( select(model).where(model.rating_key == str(rating_key)) ).scalars().first() diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index cdfa0711..f626f7a0 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -19,6 +19,7 @@ from app import sysconfig from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow from app.plex import people from app.plex.client import PlexClient, PlexError, PlexNotConfigured +from app.plex.lookups import by_rating_key log = logging.getLogger("siftlode.plex") @@ -382,13 +383,7 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) collection's membership on the affected member rows.""" meta = plex.metadata(rk) or {} col = _upsert_collection( - db, - db.execute( - select(PlexCollection).where(PlexCollection.rating_key == rk) - ).scalars().first(), - rk, - lib.id, - meta, + db, by_rating_key(db, PlexCollection, rk), rk, lib.id, meta ) 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")} @@ -415,9 +410,7 @@ def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None: select(Model).where(Model.collection_keys.contains([rk])) ).scalars(): row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None - col = db.execute( - select(PlexCollection).where(PlexCollection.rating_key == rk) - ).scalars().first() + col = by_rating_key(db, PlexCollection, rk) if col is not None: db.delete(col) db.flush() diff --git a/backend/tests/test_plex_query_conversion.py b/backend/tests/test_plex_query_conversion.py index d4b2ee98..d11392b9 100644 --- a/backend/tests/test_plex_query_conversion.py +++ b/backend/tests/test_plex_query_conversion.py @@ -285,6 +285,34 @@ class TestPlaylistAndCollectionEndpoints: _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}") assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == [] + def test_playlist_bulk_add_and_remove(self, db, monkeypatch): + # Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's + # synchronize_session=False path, which the single add/remove roundtrip never exercises. + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([self._movie(db, lib.id, rk, rk.upper()) for rk in ("m1", "m2", "m3")]) + user = User(email="bulk@example.com") + db.add(user) + db.commit() + + created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", {"title": "Bulk"}) + pid = created.json()["id"] + + added = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items/bulk", + {"item_rating_keys": ["m1", "m2", "m3"]}) + assert added.status_code == 200, added.text + assert added.json() == {"added": 3, "item_count": 3} + + removed = _request(db, user, monkeypatch, "POST", + f"/api/plex/playlists/{pid}/items/remove-bulk", + {"item_rating_keys": ["m2"]}) + assert removed.status_code == 200, removed.text + assert removed.json() == {"removed": 1, "item_count": 2} + + detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() + assert [it["id"] for it in detail["items"]] == ["m1", "m3"] # input order preserved, m2 gone + def test_collections_list(self, db, monkeypatch): from app.models import PlexCollection lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)