diff --git a/README.md b/README.md index ca320eb..95a79a5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ hardware-accelerated video cells powered by libmpv. (NVIDIA Blackwell), 240 Hz G-Sync compatible, HDR hinting - **Emby integration** — streams directly from your Emby server with auto-transcode for 4K sources, favorites filtering, and per-cell - tag/favorite controls + tag/favorite controls (Jellyfin support is experimental — see Configuration) - **Web remote** — built-in dark-mode control page on port 8585 (phone/tablet — no app install needed) - **G-Sync isolation** — per-app NVIDIA Profile Inspector profile @@ -148,6 +148,7 @@ python tests/run_all.py | `test_config` | `config.ini` save/load round-trip, typed fields, frozen dataclass, scene presets | | `test_playlist` | Multi-source playout: per-group de-dup, refill/reshuffle, group independence | | `test_scenes` | Scene-preset serialization round-trip + malformed-input safety | +| `test_backends` | Emby/Jellyfin backend specs: Emby parity, Jellyfin auth-header + verified-live gate | ## License diff --git a/config.example.ini b/config.example.ini index c2528a9..6ae5542 100644 --- a/config.example.ini +++ b/config.example.ini @@ -1,11 +1,14 @@ [Login] -# Emby server URL (local network address) +# Media server URL (local network address) server_url = http://localhost:8096 -# Emby user credentials +# Server user credentials username = password = # Verify SSL certificates (set to false only for self-signed certs) verify_ssl = true +# Media backend: emby (default, fully supported) or jellyfin (experimental, +# not yet validated against a live server) +backend = emby [Settings] # Last used monitors (auto-saved, comma-separated screen names) diff --git a/hyperwall/app.py b/hyperwall/app.py index fd1a83b..5c67b8a 100644 --- a/hyperwall/app.py +++ b/hyperwall/app.py @@ -36,6 +36,7 @@ apply_env_overrides, ) from .emby import EmbyClient, CleanupWorker +from .backends import resolve_backend from .nvidia import ensure_nvidia_profile, maybe_relaunch_in_isolation from .wizard import SetupWizard from .wall import WallController, MouseIdleHider @@ -225,6 +226,7 @@ def main() -> None: # 7. Emby client client = EmbyClient( cfg.server_url, cfg.username, cfg.password, verify_ssl=cfg.verify_ssl, + backend=resolve_backend(cfg.backend), ) if not cfg.verify_ssl: import urllib3 @@ -283,6 +285,7 @@ def main() -> None: username=cfg.username, password=cfg.password, verify_ssl=cfg.verify_ssl, + backend=cfg.backend, last_screens=",".join(s.name() for s in settings["screens"]), last_libraries=",".join(settings["libraries"]), last_grid_rows=settings["grid_rows"], diff --git a/hyperwall/backends.py b/hyperwall/backends.py new file mode 100644 index 0000000..9a751da --- /dev/null +++ b/hyperwall/backends.py @@ -0,0 +1,111 @@ +""" +Hyperwall — media backend abstraction (Emby / Jellyfin). Pure, no network. + +Emby and Jellyfin share almost their entire REST surface (Jellyfin is an Emby +fork), so the differences this app cares about are small and captured here as a +`BackendSpec`: client identity, the auth request/token header names, and +whether the DIRECT stream URL must carry `static=true`. + +Keeping these as data (not scattered conditionals) gives one clean seam for +future divergence and makes the load-bearing choices explicit and testable. + +⚠️ VALIDATION GATE: `verified_live` marks whether a backend has been proven +against a real server from this codebase. Emby is verified (the app has always +run on it). Jellyfin is NOT yet — its spec encodes the documented-compatible +values, but `resolve_backend` warns and callers must not treat it as blessed +until someone confirms auth + playback against a live Jellyfin instance. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +logger = logging.getLogger("HyperWall") + + +@dataclass(frozen=True) +class BackendSpec: + """Per-backend knobs. Everything the Emby/Jellyfin split needs, as data.""" + + name: str # canonical id: "emby" | "jellyfin" + client_name: str # MediaBrowser Client="..." identity + auth_request_header: str # header carrying the MediaBrowser auth string + token_header: str # header carrying the session token on requests + requires_static_true: bool # DIRECT url must append &static=true + verified_live: bool # proven against a real server from this repo + + +# Emby — the app's original, fully-exercised backend. Byte-for-byte the +# behavior that shipped before this abstraction existed. +EMBY = BackendSpec( + name="emby", + client_name="HyperWall", + auth_request_header="X-Emby-Authorization", + token_header="X-Emby-Token", + requires_static_true=True, # Emby 4.9.5.0 returns 500 on /stream without it + verified_live=True, +) + +# Jellyfin — API-compatible fork. Uses the modern `Authorization` request +# header (Jellyfin 10.8+) while still accepting the X-Emby-Token session header +# for compatibility. static=true is supported by Jellyfin too. NOT yet verified +# against a live server — see the module validation gate. +JELLYFIN = BackendSpec( + name="jellyfin", + client_name="HyperWall", + auth_request_header="Authorization", + token_header="X-Emby-Token", + requires_static_true=True, + verified_live=False, +) + +_BACKENDS = {b.name: b for b in (EMBY, JELLYFIN)} + +DEFAULT_BACKEND = "emby" + + +def resolve_backend(name: str | None) -> BackendSpec: + """Return the BackendSpec for a config value, defaulting to Emby. + + Unknown names fall back to Emby with a warning (safe default — the app's + proven path). Selecting an unverified backend logs a loud warning so it's + never silently trusted. + """ + key = (name or DEFAULT_BACKEND).strip().lower() + spec = _BACKENDS.get(key) + if spec is None: + logger.warning( + "Unknown backend '%s' — falling back to '%s'. Valid: %s", + name, DEFAULT_BACKEND, ", ".join(sorted(_BACKENDS)), + ) + return EMBY + if not spec.verified_live: + logger.warning( + "Backend '%s' is NOT yet validated against a live server. " + "Auth/playback may differ — verify before relying on it.", spec.name, + ) + return spec + + +def auth_string(spec: BackendSpec, device_id: str, version: str) -> str: + """Build the MediaBrowser authorization string (header *value*).""" + return ( + f'MediaBrowser Client="{spec.client_name}", Device="PC", ' + f'DeviceId="{device_id}", Version="{version}"' + ) + + +def auth_request_headers( + spec: BackendSpec, device_id: str, version: str, +) -> dict[str, str]: + """Headers for the AuthenticateByName POST (Content-Type + auth string).""" + return { + "Content-Type": "application/json", + spec.auth_request_header: auth_string(spec, device_id, version), + } + + +def token_headers(spec: BackendSpec, token: str | None) -> dict[str, str]: + """Per-request auth header carrying the session token.""" + return {spec.token_header: token or ""} diff --git a/hyperwall/config.py b/hyperwall/config.py index fcd8d5a..35e7444 100644 --- a/hyperwall/config.py +++ b/hyperwall/config.py @@ -23,6 +23,7 @@ class HyperwallConfig: username: str password: str verify_ssl: bool = True + backend: str = "emby" # media backend: "emby" | "jellyfin" # ── Settings ── last_screens: str = "" @@ -63,6 +64,7 @@ def load(cls, path: str | None = None) -> HyperwallConfig: username=cfg.get("Login", "username", fallback=""), password=cfg.get("Login", "password", fallback=""), verify_ssl=cfg.getboolean("Login", "verify_ssl", fallback=True), + backend=cfg.get("Login", "backend", fallback="emby"), last_screens=cfg.get("Settings", "last_screens", fallback=""), last_libraries=cfg.get("Settings", "last_libraries", fallback=""), last_grid_rows=cfg.getint("Settings", "last_grid_rows", fallback=2), @@ -82,6 +84,7 @@ def _create_template(cls, path: str) -> None: "username": "", "password": "", "verify_ssl": "true", + "backend": "emby", } cfg["Settings"] = { "last_screens": "", @@ -104,6 +107,7 @@ def save(self, path: str | None = None) -> None: "username": self.username, "password": self.password, "verify_ssl": str(self.verify_ssl), + "backend": self.backend, } cfg["Settings"] = { "last_screens": self.last_screens, diff --git a/hyperwall/emby.py b/hyperwall/emby.py index 2fdcbef..cdf6505 100644 --- a/hyperwall/emby.py +++ b/hyperwall/emby.py @@ -17,6 +17,12 @@ from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot from . import VERSION_SHORT +from .backends import ( + EMBY, + BackendSpec, + auth_request_headers, + token_headers, +) logger = logging.getLogger("HyperWall") @@ -45,6 +51,7 @@ def __init__( username: str, password: str, verify_ssl: bool = True, + backend: "BackendSpec | None" = None, ): self.server_url = server_url.rstrip("/") self.username = username @@ -54,6 +61,7 @@ def __init__( self.user_id: str | None = None self._auth_lock = threading.Lock() self._device_id = f"hyperwall-{os.urandom(4).hex()}" + self.backend = backend or EMBY self._session = requests.Session() self._session.headers.update({ @@ -82,13 +90,9 @@ def authenticate(self) -> bool: try: r = self._session.post( f"{self.server_url}/Users/AuthenticateByName", - headers={ - "Content-Type": "application/json", - "X-Emby-Authorization": ( - f'MediaBrowser Client="HyperWall", Device="PC", ' - f'DeviceId="{self._device_id}", Version="{VERSION_SHORT}"' - ), - }, + headers=auth_request_headers( + self.backend, self._device_id, VERSION_SHORT, + ), json={"Username": self.username, "Pw": self._password}, timeout=10, verify=self.verify_ssl, @@ -109,7 +113,7 @@ def close(self) -> None: # ── HTTP helpers ────────────────────────────────────────────────────── def _headers(self) -> dict[str, str]: - return {"X-Emby-Token": self.access_token or ""} + return token_headers(self.backend, self.access_token) def get(self, path: str, **kw: Any) -> requests.Response: return self._session.get( diff --git a/hyperwall/urls.py b/hyperwall/urls.py index ba4995a..d93c1f9 100644 --- a/hyperwall/urls.py +++ b/hyperwall/urls.py @@ -48,12 +48,15 @@ def build_stream_url( api_key: str, session_id: str, transcode: bool, + static: bool = True, ) -> str: - """Build the Emby stream URL for an item. + """Build the media stream URL for an item. transcode=True → HLS master playlist (server-side transcode to 1080p h264). - transcode=False → DIRECT raw file via static=true (load-bearing — see - module docstring). + transcode=False → DIRECT raw file. When `static` is True (the Emby default, + load-bearing — see module docstring) the url carries + static=true; a backend that must not use it can pass + static=False. """ if transcode: return ( @@ -63,4 +66,7 @@ def build_stream_url( f"&MaxFramerate=30&VideoBitrate=12000000" f"&PlaySessionId={session_id}" ) - return f"{base}/Videos/{item_id}/stream?api_key={api_key}&static=true" + direct = f"{base}/Videos/{item_id}/stream?api_key={api_key}" + if static: + direct += "&static=true" + return direct diff --git a/hyperwall/wall.py b/hyperwall/wall.py index 5027335..e27bb34 100644 --- a/hyperwall/wall.py +++ b/hyperwall/wall.py @@ -240,6 +240,7 @@ def _build_url( url = build_stream_url( base=base, item_id=iid, api_key=key, session_id=sid, transcode=transcode, + static=self.client.backend.requires_static_true, ) if transcode: tag = "TRANSCODE/retry" if force_transcode else "TRANSCODE/auto" diff --git a/tests/run_all.py b/tests/run_all.py index 2d89612..5a0869f 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -25,6 +25,7 @@ "test_config", "test_playlist", "test_scenes", + "test_backends", ] diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..4fcd6fd --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,146 @@ +""" +Unit tests for hyperwall.backends (Epic 5) — Emby/Jellyfin abstraction. + +No PyQt / mpv / network. Run: python tests/test_backends.py + +The key guarantees: + - Emby behavior is byte-identical to what shipped pre-abstraction + (X-Emby-Authorization request header, X-Emby-Token session header, + MediaBrowser Client="HyperWall", static=true DIRECT url). + - Jellyfin is a distinct spec, explicitly marked NOT verified_live. + - Unknown backends fall back to Emby (safe default). +""" + +from __future__ import annotations + +import os +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +from hyperwall.backends import ( # noqa: E402 + EMBY, + JELLYFIN, + auth_request_headers, + auth_string, + resolve_backend, + token_headers, +) +from hyperwall.urls import build_stream_url # noqa: E402 + + +# ── resolution ──────────────────────────────────────────────────────────────── + +def test_resolve_default_is_emby(): + assert resolve_backend(None).name == "emby" + assert resolve_backend("").name == "emby" + + +def test_resolve_is_case_insensitive(): + assert resolve_backend("EMBY").name == "emby" + assert resolve_backend("Jellyfin").name == "jellyfin" + + +def test_unknown_falls_back_to_emby(): + assert resolve_backend("plex").name == "emby" + + +# ── Emby parity (must match pre-abstraction behavior byte-for-byte) ─────────── + +def test_emby_is_verified_live(): + assert EMBY.verified_live is True + + +def test_emby_auth_headers_unchanged(): + h = auth_request_headers(EMBY, device_id="dev123", version="10.0") + assert h["Content-Type"] == "application/json" + assert "X-Emby-Authorization" in h + assert h["X-Emby-Authorization"] == ( + 'MediaBrowser Client="HyperWall", Device="PC", ' + 'DeviceId="dev123", Version="10.0"' + ) + + +def test_emby_token_header_unchanged(): + assert token_headers(EMBY, "TOK") == {"X-Emby-Token": "TOK"} + assert token_headers(EMBY, None) == {"X-Emby-Token": ""} + + +def test_emby_direct_url_has_static_true(): + # LOAD-BEARING (Emby 4.9.5.0 500 workaround). + url = build_stream_url( + base="http://h", item_id="I", api_key="K", session_id="S", + transcode=False, static=EMBY.requires_static_true, + ) + assert "static=true" in url + + +# ── Jellyfin (distinct, NOT yet verified) ───────────────────────────────────── + +def test_jellyfin_not_verified_live(): + # Guard: must stay False until proven against a real server. + assert JELLYFIN.verified_live is False + + +def test_jellyfin_uses_authorization_header(): + h = auth_request_headers(JELLYFIN, device_id="d", version="10.0") + assert "Authorization" in h + assert "X-Emby-Authorization" not in h + + +def test_jellyfin_still_uses_static_true(): + # Jellyfin supports static=true; DIRECT path keeps it. + url = build_stream_url( + base="http://h", item_id="I", api_key="K", session_id="S", + transcode=False, static=JELLYFIN.requires_static_true, + ) + assert "static=true" in url + + +def test_auth_string_shape(): + s = auth_string(EMBY, "d", "10.0") + assert s.startswith('MediaBrowser Client="HyperWall"') + assert 'DeviceId="d"' in s + assert 'Version="10.0"' in s + + +# ── config integration ──────────────────────────────────────────────────────── + +def test_config_default_backend_is_emby(): + from hyperwall.config import HyperwallConfig + cfg = HyperwallConfig(server_url="http://h", username="u", password="p") + assert cfg.backend == "emby" + + +def test_config_backend_round_trips(): + import tempfile + from hyperwall.config import HyperwallConfig + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "config.ini") + HyperwallConfig( + server_url="http://h", username="u", password="p", + backend="jellyfin", + ).save(path) + loaded = HyperwallConfig.load(path) + assert loaded.backend == "jellyfin" + assert resolve_backend(loaded.backend).name == "jellyfin" + + +def run_all() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + passed = failed = 0 + for t in tests: + try: + t() + passed += 1 + print(f" PASS {t.__name__}") + except Exception as e: # noqa: BLE001 + failed += 1 + print(f" FAIL {t.__name__}: {e}") + print(f"\n{passed} passed, {failed} failed out of {len(tests)} tests.") + return failed + + +if __name__ == "__main__": + sys.exit(run_all()) diff --git a/tests/test_urls.py b/tests/test_urls.py index fc164ad..64f06ea 100644 --- a/tests/test_urls.py +++ b/tests/test_urls.py @@ -98,6 +98,16 @@ def test_direct_url_has_static_true(): assert "master.m3u8" not in url +def test_direct_url_static_false_omits_param(): + # A backend that must not use static=true can opt out; default stays True. + url = build_stream_url( + base="http://h", item_id="I", api_key="K", + session_id="S", transcode=False, static=False, + ) + assert "static=true" not in url + assert "/Videos/I/stream?" in url + + def test_transcode_url_is_hls_master(): url = build_stream_url( base="http://emby:8096", item_id="ID1", api_key="KEY",