Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions config.example.ini
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
3 changes: 3 additions & 0 deletions hyperwall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
111 changes: 111 additions & 0 deletions hyperwall/backends.py
Original file line number Diff line number Diff line change
@@ -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 ""}
4 changes: 4 additions & 0 deletions hyperwall/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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),
Expand All @@ -82,6 +84,7 @@ def _create_template(cls, path: str) -> None:
"username": "",
"password": "",
"verify_ssl": "true",
"backend": "emby",
}
cfg["Settings"] = {
"last_screens": "",
Expand All @@ -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,
Expand Down
20 changes: 12 additions & 8 deletions hyperwall/emby.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
14 changes: 10 additions & 4 deletions hyperwall/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
1 change: 1 addition & 0 deletions hyperwall/wall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions tests/run_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"test_config",
"test_playlist",
"test_scenes",
"test_backends",
]


Expand Down
Loading
Loading