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:
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user