| … | … | @@ -0,0 +1,128 @@ |
| 1 | """Tell Jellyfin when a library file is added or removed (labs#13). |
| 2 | |
| 3 | Measured 2026-10-04: Jellyfin's real-time monitor picked up S02E06 by itself |
| 4 | three seconds before the ingest finished logging, so for ADDS this is a |
| 5 | backstop. It exists for the cases the monitor is not proven on -- deletes and |
| 6 | subtitle sidecars -- and for a monitor that misses an event (inotify on a USB |
| 7 | exFAT volume is not something to bet on silently). |
| 8 | |
| 9 | It must never fail or delay the thing it reports: no config -> nothing; any |
| 10 | error -> logged and dropped. |
| 11 | |
| 12 | Run: .venv/bin/python3 -m pytest test_jellyfin_notify.py -q |
| 13 | """ |
| 14 | |
| 15 | import json |
| 16 | import os |
| 17 | import threading |
| 18 | |
| 19 | import pytest |
| 20 | |
| 21 | import addon |
| 22 | |
| 23 | |
| 24 | @pytest.fixture |
| 25 | def config(tmp_path, monkeypatch): |
| 26 | path = tmp_path / "jellyfin.json" |
| 27 | path.write_text(json.dumps({"url": "http://jf:8096", "api_key": "k123"})) |
| 28 | monkeypatch.setattr(addon, "JELLYFIN_CONFIG", str(path)) |
| 29 | monkeypatch.setattr(addon, "_jellyfin_cfg", None) |
| 30 | return path |
| 31 | |
| 32 | |
| 33 | @pytest.fixture |
| 34 | def posts(monkeypatch): |
| 35 | sent = [] |
| 36 | |
| 37 | class Resp: |
| 38 | status_code = 204 |
| 39 | |
| 40 | def raise_for_status(self): |
| 41 | pass |
| 42 | |
| 43 | def post(url, json=None, headers=None, timeout=None): |
| 44 | sent.append({"url": url, "json": json, "headers": headers, "timeout": timeout}) |
| 45 | return Resp() |
| 46 | monkeypatch.setattr(addon.requests, "post", post) |
| 47 | return sent |
| 48 | |
| 49 | |
| 50 | def test_it_posts_a_targeted_media_update(config, posts): |
| 51 | addon._notify_jellyfin("/mnt/usb/Shows/A/a.mp4", "Created") |
| 52 | (p,) = posts |
| 53 | assert p["url"] == "http://jf:8096/Library/Media/Updated" |
| 54 | assert p["headers"]["X-Emby-Token"] == "k123" |
| 55 | assert p["json"] == {"Updates": [{"Path": "/mnt/usb/Shows/A/a.mp4", |
| 56 | "UpdateType": "Created"}]} |
| 57 | assert p["timeout"] and p["timeout"] <= 10 |
| 58 | |
| 59 | |
| 60 | def test_no_config_means_no_call(tmp_path, monkeypatch, posts): |
| 61 | monkeypatch.setattr(addon, "JELLYFIN_CONFIG", str(tmp_path / "absent.json")) |
| 62 | monkeypatch.setattr(addon, "_jellyfin_cfg", None) |
| 63 | addon._notify_jellyfin("/x.mp4", "Created") |
| 64 | assert posts == [] |
| 65 | |
| 66 | |
| 67 | def test_a_jellyfin_failure_never_raises(config, monkeypatch): |
| 68 | def down(*a, **k): |
| 69 | raise addon.requests.ConnectionError("refused") |
| 70 | monkeypatch.setattr(addon.requests, "post", down) |
| 71 | addon._notify_jellyfin("/x.mp4", "Deleted") # must not raise |
| 72 | |
| 73 | |
| 74 | def test_a_bad_config_never_raises(tmp_path, monkeypatch, posts): |
| 75 | bad = tmp_path / "jellyfin.json" |
| 76 | bad.write_text("{not json") |
| 77 | monkeypatch.setattr(addon, "JELLYFIN_CONFIG", str(bad)) |
| 78 | monkeypatch.setattr(addon, "_jellyfin_cfg", None) |
| 79 | addon._notify_jellyfin("/x.mp4", "Created") |
| 80 | assert posts == [] |
| 81 | |
| 82 | |
| 83 | def test_the_suite_never_reaches_the_real_jellyfin(): |
| 84 | """conftest points JELLYFIN_CONFIG at nothing for every test. On stortron |
| 85 | the real jellyfin.json sits beside addon.py; without the guard any test |
| 86 | reaching an ingest would report temp paths to the production server.""" |
| 87 | assert not os.path.exists(addon.JELLYFIN_CONFIG) |
| 88 | |
| 89 | |
| 90 | # --- wiring ----------------------------------------------------------------- |
| 91 | |
| 92 | META = {"tmdb_id": "1", "title": "Show", "year": "2020", |
| 93 | "episode_title": "Six", "runtime": 30} |
| 94 | |
| 95 | |
| 96 | def test_a_finished_ingest_reports_created(tmp_path, monkeypatch): |
| 97 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib")) |
| 98 | monkeypatch.setattr(addon, "INGEST_QUEUE_FILE", str(tmp_path / "q.json")) |
| 99 | monkeypatch.setattr(addon, "INGEST_HISTORY_FILE", str(tmp_path / "h.jsonl")) |
| 100 | monkeypatch.setattr(addon, "_library_meta", lambda *a, **k: dict(META)) |
| 101 | monkeypatch.setattr(addon, "_enough_space", lambda: True) |
| 102 | monkeypatch.setattr(addon, "_library_has", lambda *a, **k: False) |
| 103 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: "http://cdn/m") |
| 104 | monkeypatch.setattr(addon, "_cap_variant", lambda u, h=None: u) |
| 105 | monkeypatch.setattr(addon, "_fetch_subtitles_for", lambda *a, **k: None) |
| 106 | |
| 107 | def remux(url, dest): |
| 108 | os.makedirs(os.path.dirname(dest), exist_ok=True) |
| 109 | open(dest, "wb").write(b"x") |
| 110 | monkeypatch.setattr(addon, "_remux_to_mp4", remux) |
| 111 | seen = [] |
| 112 | monkeypatch.setattr(addon, "_notify_jellyfin", lambda p, t: seen.append((p, t))) |
| 113 | assert addon._run_ingest("series", "tmdb:1", 2, 6, 1080) == "done" |
| 114 | assert len(seen) == 1 and seen[0][1] == "Created" |
| 115 | assert seen[0][0].endswith("Show (2020) - S02E06 - Six.mp4") |
| 116 | |
| 117 | |
| 118 | def test_a_failed_ingest_reports_nothing(tmp_path, monkeypatch): |
| 119 | monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path / "lib")) |
| 120 | monkeypatch.setattr(addon, "_library_meta", lambda *a, **k: dict(META)) |
| 121 | monkeypatch.setattr(addon, "_enough_space", lambda: True) |
| 122 | monkeypatch.setattr(addon, "_library_has", lambda *a, **k: False) |
| 123 | monkeypatch.setattr(addon, "_resolve_vidcore", lambda *a, **k: None) |
| 124 | monkeypatch.setattr(addon, "_ingest_from_hdhub", lambda *a, **k: "no usable file") |
| 125 | seen = [] |
| 126 | monkeypatch.setattr(addon, "_notify_jellyfin", lambda p, t: seen.append((p, t))) |
| 127 | assert addon._run_ingest("series", "tmdb:1", 2, 6, 1080).startswith("failed") |
| 128 | assert seen == [] |