The daily watch reconcile reads "absent from Plex's scan" as "Plex un-watched it" and deletes the row — but it walked the sections without asking _paginate whether the walk reached the server's total, so a Plex hiccup mid-walk (an empty page early) un-watched every title it failed to reach until the next complete run. The scan now reports completeness; an incomplete one still pulls in what it saw, re-pushes dirty rows, and clears nothing (logged as plex.watch.reconcile_incomplete). The HU overlay had the same shape: clear every row's HU text, then refill from the listing. It now reads the listing first and clears only after a complete one. The overlay moved to plex/overlay.py (plex/sync.py drops under the 800-line limit and off the size ratchet's list). Tests: plex/paths (23% -> 100%: prefix boundary, longest prefix, dead mount), the reconcile's clear/keep/re-push rules, the cut-short scan and listing (each seen red with the guard removed).
83 lines
3.0 KiB
Python
83 lines
3.0 KiB
Python
"""plex/paths — the Plex-side path → local mount translation (E32 S2: 23% covered, and a wrong answer
|
|
here reads as "media file not found" for a whole library at once, with nothing else failing).
|
|
|
|
Pure logic: the admin map is read through sysconfig, stubbed per test.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.plex import paths
|
|
|
|
|
|
@pytest.fixture
|
|
def path_map(monkeypatch):
|
|
def use(raw: str) -> None:
|
|
monkeypatch.setattr(paths.sysconfig, "get_str", lambda db, key: raw)
|
|
|
|
return use
|
|
|
|
|
|
def test_the_map_parses_both_separators_drops_junk_and_puts_the_longest_prefix_first():
|
|
raw = " /data=/mnt/a ,\n/data/movies = /mnt/m\nnot-a-pair,=/x,/y=, /data/tv=/mnt/tv "
|
|
assert paths.parse_map(raw) == [
|
|
("/data/movies", "/mnt/m"),
|
|
("/data/tv", "/mnt/tv"),
|
|
("/data", "/mnt/a"),
|
|
]
|
|
assert paths.parse_map("") == []
|
|
|
|
|
|
def test_the_longest_matching_prefix_wins(path_map):
|
|
path_map("/data=/mnt/a,/data/movies=/mnt/m")
|
|
assert paths.map_path(None, "/data/movies/Foo.mkv") == str(Path("/mnt/m") / "Foo.mkv")
|
|
assert paths.map_path(None, "/data/tv/Bar.mkv") == str(Path("/mnt/a") / "tv/Bar.mkv")
|
|
|
|
|
|
def test_a_prefix_matches_only_at_a_path_boundary(path_map):
|
|
# "/data/movies2" shares the characters of "/data/movies" but is another folder.
|
|
path_map("/data/movies=/mnt/m")
|
|
assert paths.map_path(None, "/data/movies2/Foo.mkv") == "/data/movies2/Foo.mkv"
|
|
|
|
|
|
def test_the_prefix_itself_and_a_trailing_slash_in_the_map(path_map):
|
|
path_map("/data/movies/=/mnt/m")
|
|
assert paths.map_path(None, "/data/movies/Foo.mkv") == str(Path("/mnt/m") / "Foo.mkv")
|
|
path_map("/data/movies=/mnt/m")
|
|
assert paths.map_path(None, "/data/movies") == "/mnt/m"
|
|
|
|
|
|
def test_no_map_leaves_the_path_as_plex_sees_it(path_map):
|
|
path_map("")
|
|
assert paths.map_path(None, "/data/movies/Foo.mkv") == "/data/movies/Foo.mkv"
|
|
|
|
|
|
def test_the_local_file_must_exist(path_map, tmp_path):
|
|
(tmp_path / "movies").mkdir()
|
|
(tmp_path / "movies" / "Foo.mkv").write_bytes(b"x")
|
|
path_map(f"/data={tmp_path}")
|
|
assert (
|
|
paths.local_media_path(None, "/data/movies/Foo.mkv")
|
|
== (tmp_path / "movies" / "Foo.mkv").resolve()
|
|
)
|
|
assert paths.local_media_path(None, "/data/movies/Missing.mkv") is None
|
|
assert paths.local_media_path(None, "/data/movies") is None # a folder is not a file
|
|
assert paths.local_media_path(None, None) is None
|
|
assert paths.local_media_path(None, "") is None
|
|
|
|
|
|
def test_a_path_that_cannot_be_resolved_is_logged_and_answered_none(path_map, monkeypatch, caplog):
|
|
path_map("")
|
|
|
|
def broken(self, strict=False):
|
|
raise OSError("dead mount")
|
|
|
|
monkeypatch.setattr(paths.Path, "resolve", broken)
|
|
assert paths.local_media_path(None, "/data/movies/Foo.mkv") is None
|
|
rec = next(
|
|
r for r in caplog.records if getattr(r, "event", None) == "plex.paths.resolve_failed"
|
|
)
|
|
# The title stays out of the log line: the path carries it.
|
|
assert "Foo" not in rec.getMessage() and rec.attrs == {"reason": "OSError"}
|