fix(plex): a server without a machine id refuses the collection write by name

The missing identifier became server:/// in the write URI. Client tests over a MockTransport
(errors, paging, cutoff, write URIs, image transcode) and admin-route tests (connection test,
enabled-section sampling, no server URL in a 422, the one-time watch import).
This commit is contained in:
2026-10-03 21:53:06 +02:00
parent 378c4daf0f
commit 2ab9ff6a42
3 changed files with 357 additions and 2 deletions
+7 -2
View File
@@ -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:
+154
View File
@@ -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]
+196
View File
@@ -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="<html>login</html>"),
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")