diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py index 8701b6ce..50365f94 100644 --- a/backend/app/plex/client.py +++ b/backend/app/plex/client.py @@ -136,9 +136,14 @@ class PlexClient: # --- 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.""" + """Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached. + Without one the uri would read `server:///…`, a malformed write Plex answers in its own way; + it is refused here, by name (E32 S2).""" if not getattr(self, "_machine_id", None): - self._machine_id = self._get("/identity").get("machineIdentifier") or "" + mid = self._get("/identity").get("machineIdentifier") + if not mid: + raise PlexError("Plex did not report its machine identifier") + self._machine_id = mid return self._machine_id def _write(self, method: str, path: str) -> dict: diff --git a/backend/tests/test_plex_admin_routes.py b/backend/tests/test_plex_admin_routes.py new file mode 100644 index 00000000..6de5f0aa --- /dev/null +++ b/backend/tests/test_plex_admin_routes.py @@ -0,0 +1,154 @@ +"""routes/plex/admin — the connection test and the owner's watch-sync link (E32 S2: 37% covered). + +The connection test is the admin's one early warning that the media mount or path map is wrong, and +it must not quote the server's URL back in a toast. The watch link runs the one-time "Plex is master" +import exactly once. Plex itself is a stub. +""" + +from sqlalchemy import select + +import app.routes.plex.admin as admin_routes +from app import sysconfig +from app.models import PlexLink +from app.plex.client import PlexError, PlexNotConfigured + + +class _Plex: + def __init__(self, *, sections=None, leaf=None, fail=None): + self._sections = sections or [] + self._leaf = leaf + self._fail = fail + self.section_items_calls: list[str] = [] + + def __enter__(self): + if self._fail: + raise self._fail + return self + + def __exit__(self, *exc): + return False + + def server_info(self): + return {"friendlyName": "Basement", "version": "1.40"} + + def sections(self): + return self._sections + + def section_items(self, key, *_args): + self.section_items_calls.append(key) + return ([self._leaf] if self._leaf else []), 1 + + +SECTIONS = [ + {"key": 1, "title": "Movies", "type": "movie", "count": 10}, + {"key": 2, "title": "Shows", "type": "show", "count": 3}, + {"key": 3, "title": "Music", "type": "artist", "count": 99}, +] +LEAF = {"Media": [{"Part": [{"file": "/data/movies/a.mkv"}]}]} + + +def _use(monkeypatch, plex): + monkeypatch.setattr(admin_routes, "PlexClient", lambda db: plex) + + +def test_the_connection_test_lists_movie_and_show_sections_and_probes_one_file( + db, as_user, make_user, monkeypatch, tmp_path +): + (tmp_path / "a.mkv").write_bytes(b"x") + sysconfig.set_value(db, "plex_path_map", f"/data/movies={tmp_path}") + sysconfig.set_value(db, "plex_libraries", "4") + db.commit() + # Only section 4 is enabled; the movie section listed first (1) must not be the one sampled. + other = {"key": 4, "title": "Kids", "type": "movie", "count": 2} + plex = _Plex(sections=[*SECTIONS, other], leaf=LEAF) + _use(monkeypatch, plex) + r = as_user(make_user(role="admin")).post("/api/plex/test") + assert r.status_code == 200, r.text + body = r.json() + assert body["server_name"] == "Basement" + assert [s["key"] for s in body["sections"]] == ["1", "2", "4"] # music is not ours + assert body["media_check"] == { + "checked": True, + "ok": True, + "plex_path": "/data/movies/a.mkv", + "local_path": str(tmp_path / "a.mkv"), + } + assert plex.section_items_calls == ["4"] # only an enabled movie section is sampled + + +def test_a_wrong_mount_is_reported_and_an_empty_library_is_not_checked( + db, as_user, make_user, monkeypatch, tmp_path +): + sysconfig.set_value(db, "plex_path_map", f"/data/movies={tmp_path}") + db.commit() + client = as_user(make_user(role="admin")) + _use(monkeypatch, _Plex(sections=SECTIONS, leaf=LEAF)) # the file is not on the mount + check = client.post("/api/plex/test").json()["media_check"] + assert check["checked"] is True and check["ok"] is False + + _use(monkeypatch, _Plex(sections=SECTIONS)) + assert client.post("/api/plex/test").json()["media_check"] == {"checked": False} + + +def test_a_failed_connection_says_so_without_quoting_the_server( + db, as_user, make_user, monkeypatch +): + client = as_user(make_user(role="admin")) + _use(monkeypatch, _Plex(fail=PlexNotConfigured())) + r = client.post("/api/plex/test") + assert r.status_code == 400 + assert r.json()["detail"]["reason"] == "plex_not_configured" + + _use(monkeypatch, _Plex(fail=PlexError("GET http://10.0.0.5:32400/secret refused"))) + r = client.post("/api/plex/test") + assert r.status_code == 422 + assert r.json()["detail"]["reason"] == "plex_unreachable" + assert "10.0.0.5" not in r.text + + +def test_plex_admin_routes_refuse_a_regular_user(db, as_user, make_user, monkeypatch): + _use(monkeypatch, _Plex(sections=SECTIONS)) + client = as_user(make_user()) + for path in ("/api/plex/test", "/api/plex/sync", "/api/plex/watch/import"): + assert client.post(path).status_code == 403, path + assert client.post("/api/plex/watch/link", json={"enabled": True}).status_code == 403 + + +def test_the_watch_link_imports_once_and_needs_plex_on(db, as_user, make_user, monkeypatch): + imports: list[int] = [] + + def fake_import(db_, link): + imports.append(link.user_id) + link.initial_import_done = True + db_.commit() + return {"imported": 7} + + monkeypatch.setattr(admin_routes.plex_watch, "import_owner_watch_state", fake_import) + owner = make_user(role="admin") + client = as_user(owner) + + r = client.post("/api/plex/watch/link", json={"enabled": True}) + assert r.status_code == 409 and r.json()["detail"]["reason"] == "plex_disabled" + assert client.post("/api/plex/watch/import").json()["detail"]["reason"] == "watch_sync_off" + + sysconfig.set_value(db, "plex_enabled", True) + db.commit() + first = client.post("/api/plex/watch/link", json={"enabled": True}) + assert first.status_code == 200 + assert first.json()["import"] == {"imported": 7} + assert first.json()["linked"] and first.json()["sync_enabled"] and first.json()["uses_admin"] + + assert client.post("/api/plex/watch/link", json={"enabled": False}).json()["import"] is None + again = client.post("/api/plex/watch/link", json={"enabled": True}) + assert again.json()["import"] is None # the one-time import does not run twice + assert imports == [owner.id] + + status = client.get("/api/plex/watch/link") + assert status.status_code == 200 + assert status.json()["initial_import_done"] is True + links = db.execute(select(PlexLink).where(PlexLink.user_id == owner.id)).scalars().all() + assert len(links) == 1 # toggling never creates a second link + + rerun = client.post("/api/plex/watch/import") + assert rerun.status_code == 200 and rerun.json()["import"] == {"imported": 7} + assert imports == [owner.id, owner.id] diff --git a/backend/tests/test_plex_client.py b/backend/tests/test_plex_client.py new file mode 100644 index 00000000..94262c1a --- /dev/null +++ b/backend/tests/test_plex_client.py @@ -0,0 +1,196 @@ +"""plex/client — the one door to the Plex server (E32 S2: 31% covered). + +What matters here is what the callers cannot see: a failure must surface as PlexError (never as an +empty answer), the paged reads must stop where they should, and the write URIs must be well formed. +The network is an httpx MockTransport; the configuration is stubbed. +""" + +import httpx +import pytest + +from app.plex import client as plex_client +from app.plex.client import PlexClient, PlexError, PlexNotConfigured + + +@pytest.fixture +def plex(monkeypatch): + """A PlexClient on a stub server; set `routes[path] = handler-or-json` per test.""" + routes: dict = {} + seen: list[httpx.Request] = [] + + def transport(request: httpx.Request) -> httpx.Response: + seen.append(request) + answer = routes.get(request.url.path) + if answer is None: + return httpx.Response(404) + if callable(answer): + return answer(request) + return httpx.Response(200, json={"MediaContainer": answer}) + + real = httpx.Client + monkeypatch.setattr( + plex_client.httpx, + "Client", + lambda **kw: real(transport=httpx.MockTransport(transport), **kw), + ) + conf = {"plex_server_url": "http://plex.test:32400/", "plex_server_token": "tok"} + monkeypatch.setattr(plex_client.sysconfig, "get_str", lambda db, key: conf[key]) + c = PlexClient(None) # type: ignore[arg-type] # the stubbed config never reads the session + c.routes, c.seen = routes, seen # type: ignore[attr-defined] + yield c + c.close() + + +def test_an_unconfigured_server_is_refused_up_front(monkeypatch): + monkeypatch.setattr(plex_client.sysconfig, "get_str", lambda db, key: "") + with pytest.raises(PlexNotConfigured): + PlexClient(None) # type: ignore[arg-type] + + +def test_reads_carry_the_token_and_unwrap_the_container(plex): + plex.routes["/library/sections"] = {"Directory": [{"key": "1", "type": "movie"}]} + assert plex.sections() == [{"key": "1", "type": "movie"}] + req = plex.seen[-1] + assert str(req.url) == "http://plex.test:32400/library/sections" + assert req.headers["x-plex-token"] == "tok" + assert req.headers["accept"] == "application/json" + + +@pytest.mark.parametrize( + "answer", + [ + lambda r: httpx.Response(500), + lambda r: httpx.Response(200, text="login"), + lambda r: (_ for _ in ()).throw(httpx.ConnectError("down", request=r)), + ], + ids=["http-500", "not-json", "unreachable"], +) +def test_a_failed_read_is_a_plex_error_never_an_empty_answer(plex, answer): + plex.routes["/library/sections"] = answer + with pytest.raises(PlexError): + plex.sections() + + +def test_a_section_page_asks_for_guids_and_reports_the_server_total(plex): + plex.routes["/library/sections/7/all"] = {"Metadata": [{"ratingKey": "1"}], "totalSize": 250} + items, total = plex.section_items("7", 1, 200, 50) + assert items == [{"ratingKey": "1"}] and total == 250 + params = plex.seen[-1].url.params + assert params["includeGuids"] == "1" and params["type"] == "1" + assert params["X-Plex-Container-Start"] == "200" and params["X-Plex-Container-Size"] == "50" + + plex.routes["/library/sections/7/all"] = {"Metadata": [], "size": 0} + assert plex.section_items("7", 2) == ([], 0) + + +def test_related_flattens_the_hubs_and_degrades_to_empty(plex): + plex.routes["/library/metadata/5/related"] = { + "Hub": [{"Metadata": [{"ratingKey": "a"}]}, {"Metadata": [{"ratingKey": "b"}]}, {}] + } + assert [m["ratingKey"] for m in plex.related("5")] == ["a", "b"] + plex.routes["/library/metadata/5/related"] = lambda r: httpx.Response(500) + assert plex.related("5") == [] + + +def test_metadata_is_the_first_item_or_none(plex): + plex.routes["/library/metadata/9"] = {"Metadata": [{"ratingKey": "9", "title": "X"}]} + assert plex.metadata("9", markers=True) == {"ratingKey": "9", "title": "X"} + assert plex.seen[-1].url.params["includeMarkers"] == "1" + plex.routes["/library/metadata/9"] = {} + assert plex.metadata("9") is None + + +def _history(rows_by_start: dict[int, list[dict]]): + def answer(request: httpx.Request) -> httpx.Response: + start = int(request.url.params["X-Plex-Container-Start"]) + return httpx.Response( + 200, json={"MediaContainer": {"Metadata": rows_by_start.get(start, [])}} + ) + + return answer + + +def test_watch_history_pages_until_the_cutoff_and_keeps_only_the_owner(plex): + plex.routes["/status/sessions/history/all"] = _history( + { + 0: [ + {"ratingKey": "1", "viewedAt": 500, "accountID": 2}, + {"ratingKey": "2", "viewedAt": 400, "accountID": 1}, + ], + 2: [ + {"ratingKey": "3", "viewedAt": 300, "accountID": 1}, + {"ratingKey": "4", "viewedAt": 100, "accountID": 1}, + ], + 4: [{"ratingKey": "never", "viewedAt": 50, "accountID": 1}], + } + ) + rows = plex.watch_history(min_viewed_at=200, account_id=1, size=2) + assert [r["ratingKey"] for r in rows] == ["2", "3"] # page 2 reached, the cutoff stops it + assert [int(r.url.params["X-Plex-Container-Start"]) for r in plex.seen] == [0, 2] + + +def test_watch_history_stops_on_a_short_page_and_at_the_page_cap(plex): + plex.routes["/status/sessions/history/all"] = _history({0: [{"ratingKey": "1", "viewedAt": 9}]}) + assert [r["ratingKey"] for r in plex.watch_history(size=2)] == ["1"] + assert len(plex.seen) == 1 + + full = [{"ratingKey": "r", "viewedAt": 9}, {"ratingKey": "r", "viewedAt": 9}] + plex.routes["/status/sessions/history/all"] = lambda r: httpx.Response( + 200, json={"MediaContainer": {"Metadata": full}} + ) + plex.seen.clear() + assert len(plex.watch_history(size=2, max_pages=3)) == 6 + assert len(plex.seen) == 3 + + +def test_a_collection_is_created_with_a_well_formed_uri(plex): + plex.routes["/identity"] = {"machineIdentifier": "abc"} + plex.routes["/library/collections"] = {"Metadata": [{"ratingKey": "c1"}]} + assert plex.create_collection("3", "Best of: 90s", "42") == {"ratingKey": "c1"} + params = plex.seen[-1].url.params + assert plex.seen[-1].method == "POST" + assert params["title"] == "Best of: 90s" and params["sectionId"] == "3" + assert params["uri"] == "server://abc/com.plexapp.plugins.library/library/metadata/42" + + plex.routes["/library/collections"] = {} + with pytest.raises(PlexError): + plex.create_collection("3", "Empty", "42") + assert [r.url.path for r in plex.seen].count("/identity") == 1 # cached + + +def test_a_server_without_a_machine_id_refuses_the_write(plex): + """No machineIdentifier used to become `server:///…` in the write URI — a malformed request + Plex answers in its own way. It is refused here, named (E32 S2).""" + plex.routes["/identity"] = {} + plex.routes["/library/collections"] = {"Metadata": [{"ratingKey": "c1"}]} + with pytest.raises(PlexError, match="machine"): + plex.create_collection("3", "T", "42") + assert all(r.url.path == "/identity" for r in plex.seen) + + +def test_watch_state_writes_target_the_library_item(plex): + for path in ("/:/scrobble", "/:/unscrobble", "/:/timeline"): + plex.routes[path] = lambda r: httpx.Response(200) # Plex answers with an empty body + plex.scrobble("42") + assert plex.seen[-1].url.params["key"] == "42" + plex.unscrobble("42") + assert plex.seen[-1].url.path == "/:/unscrobble" + plex.set_timeline("42", 61_500.7, 120_000) # type: ignore[arg-type] + p = plex.seen[-1].url.params + assert p["key"] == "/library/metadata/42" and p["time"] == "61500" and p["state"] == "stopped" + plex.routes["/:/scrobble"] = lambda r: httpx.Response(401) + with pytest.raises(PlexError): + plex.scrobble("42") + + +def test_an_image_is_resized_by_plex_when_a_box_is_given(plex): + plex.routes["/photo/:/transcode"] = lambda r: httpx.Response( + 200, content=b"small", headers={"Content-Type": "image/webp"} + ) + plex.routes["/library/metadata/1/thumb/2"] = lambda r: httpx.Response(200, content=b"full") + assert plex.image_bytes("/library/metadata/1/thumb/2", 300, 450) == (b"small", "image/webp") + assert plex.seen[-1].url.params["url"] == "/library/metadata/1/thumb/2" + body, ctype = plex.image_bytes("/library/metadata/1/thumb/2") + assert body == b"full" and ctype == "image/jpeg" + with pytest.raises(PlexError): + plex.raw_get("/library/streams/404")