feat(docker): add Dockerfile, docker-compose and headless fallback for local testing

This commit is contained in:
bart
2026-11-02 21:42:26 +01:00
parent f66f874bae
commit 0aed952728
6 changed files with 156 additions and 12 deletions
+35
View File
@@ -0,0 +1,35 @@
FROM python:3.12-slim-bookworm
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1
# Install mpv, ffmpeg and system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
mpv \
ffmpeg \
curl \
ca-certificates \
libgl1 \
libegl1 \
libx11-6 \
libxext6 \
libasound2 \
pulseaudio-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install python dependencies and latest yt-dlp
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt yt-dlp
COPY src/ ./src/
COPY config.example.yaml ./config.example.yaml
# If no config.yaml exists at runtime, fallback to config.example.yaml
RUN cp config.example.yaml config.yaml
EXPOSE 8080 9090 8097
ENTRYPOINT ["python3", "-m", "src.main"]
CMD ["-c", "config.yaml"]
+29 -3
View File
@@ -65,10 +65,36 @@ flowchart TD
--- ---
## 📦 Installatie op de HTPC ## 🐳 Lokaal Testen met Docker
Je kunt de client direct lokaal testen met Docker en Docker Compose:
```bash
# 1. Start de container (lokaal)
docker compose up -d
# 2. Bekijk de logs
docker compose logs -f
# 3. Open de Web Remote in je browser:
# http://localhost:8097/
#
# 4. Of test Yatse vanaf je telefoon op hetzelfde Wi-Fi netwerk:
# Voeg Kodi toe met het IP-adres van je laptop op poort 8080!
```
### Display / X11 Doorkoppeling (Optioneel):
Als je wilt dat het MPV-videovenster op het scherm van je laptop verschijnt, geef Docker dan eenmalig toegang tot X11:
```bash
xhost +local:root
docker compose up -d
```
*Draai je zonder beeldscherm of op een headless server? De client detecteert dit automatisch en valt terug op headless mode (`--vo=null --ao=null`), of start handmatig met `MPV_VO=null docker compose up -d`.*
---
## 📦 Installatie op de HTPC Bare-Metal (Aanbevolen voor TV)
### 1. Snel installeren via installer script
Voer op de HTPC het installatiescript uit:
```bash ```bash
git clone https://git.bnhmeijer.nl/bart/jellyfin-client.git git clone https://git.bnhmeijer.nl/bart/jellyfin-client.git
+25
View File
@@ -0,0 +1,25 @@
services:
jellyfin-client:
build:
context: .
dockerfile: Dockerfile
container_name: jellyfin-client
restart: unless-stopped
ports:
- "8080:8080" # Kodi JSON-RPC (Yatse)
- "9090:9090" # Kodi JSON-RPC WebSocket
- "8097:8097" # Web Remote & REST API
environment:
- DISPLAY=${DISPLAY:-:0}
- MPV_VO=${MPV_VO:-gpu} # Change to 'null' for headless test without window
- MPV_HWDEC=${MPV_HWDEC:-auto} # 'auto', 'vaapi', 'nvdec', or 'no'
- MPV_FULLSCREEN=${MPV_FULLSCREEN:-false} # Windowed by default for desktop testing
volumes:
# Optional: Mount custom config.yaml if present
- ./config.yaml:/app/config.yaml:ro
# X11 Display passthrough to show video window on host screen
- /tmp/.X11-unix:/tmp/.X11-unix:ro
# Optional hardware acceleration devices (uncomment if available on Linux host)
# devices:
# - /dev/dri:/dev/dri
# - /dev/snd:/dev/snd
+19
View File
@@ -136,4 +136,23 @@ class AppConfig:
fetch_metadata=yt.get("fetch_metadata", cfg.youtube.fetch_metadata), fetch_metadata=yt.get("fetch_metadata", cfg.youtube.fetch_metadata),
) )
# Environment variable overrides (useful for Docker & CI)
if jf_url := os.environ.get("JELLYFIN_SERVER_URL"):
cfg.jellyfin.server_url = jf_url.rstrip("/")
if jf_user := os.environ.get("JELLYFIN_USERNAME"):
cfg.jellyfin.username = jf_user
if jf_pass := os.environ.get("JELLYFIN_PASSWORD"):
cfg.jellyfin.password = jf_pass
if jf_dev := os.environ.get("JELLYFIN_DEVICE_NAME"):
cfg.jellyfin.device_name = jf_dev
if mpv_vo := os.environ.get("MPV_VO"):
cfg.player.vo = mpv_vo
if mpv_hwdec := os.environ.get("MPV_HWDEC"):
cfg.player.hwdec = mpv_hwdec
if mpv_fs := os.environ.get("MPV_FULLSCREEN"):
cfg.player.fullscreen = mpv_fs.lower() in ("1", "true", "yes")
if mpv_ao := os.environ.get("MPV_AO"):
cfg.player.audio_device = mpv_ao
return cfg return cfg
+34 -9
View File
@@ -39,7 +39,24 @@ class MpvController:
logger.error("Error in status listener callback: %s", e) logger.error("Error in status listener callback: %s", e)
async def start(self) -> None: async def start(self) -> None:
"""Launch mpv in idle mode and establish IPC connection.""" """Launch mpv in idle mode and establish IPC connection, with fallback to headless mode."""
success = await self._spawn_mpv(vo=self.config.vo, hwdec=self.config.hwdec)
if not success and self.config.vo != "null":
logger.warning(
"MPV failed to start with vo=%s (possibly no active X11/Wayland display). Falling back to headless mode (--vo=null --ao=null)...",
self.config.vo,
)
success = await self._spawn_mpv(vo="null", hwdec="no", extra_fallback_args=["--ao=null"])
if not success:
logger.error("Unable to initialize MPV in either display or headless mode.")
async def _spawn_mpv(
self,
vo: str,
hwdec: str,
extra_fallback_args: Optional[List[str]] = None,
) -> bool:
sock_path = Path(self.config.ipc_socket) sock_path = Path(self.config.ipc_socket)
if sock_path.exists(): if sock_path.exists():
try: try:
@@ -51,15 +68,17 @@ class MpvController:
self.config.mpv_path, self.config.mpv_path,
"--idle=yes", "--idle=yes",
f"--input-ipc-server={self.config.ipc_socket}", f"--input-ipc-server={self.config.ipc_socket}",
f"--hwdec={self.config.hwdec}", f"--hwdec={hwdec}",
f"--vo={self.config.vo}", f"--vo={vo}",
"--fs" if self.config.fullscreen else "--fs=no", "--fs" if (self.config.fullscreen and vo != "null") else "--fs=no",
] ]
if self.config.audio_device and self.config.audio_device != "auto": if self.config.audio_device and self.config.audio_device != "auto" and not extra_fallback_args:
cmd.append(f"--audio-device={self.config.audio_device}") cmd.append(f"--audio-device={self.config.audio_device}")
cmd.extend(self.config.extra_args) cmd.extend(self.config.extra_args)
if extra_fallback_args:
cmd.extend(extra_fallback_args)
logger.info("Starting MPV: %s", " ".join(cmd)) logger.info("Starting MPV: %s", " ".join(cmd))
try: try:
@@ -70,25 +89,31 @@ class MpvController:
) )
except Exception as e: except Exception as e:
logger.error("Failed to spawn mpv process: %s", e) logger.error("Failed to spawn mpv process: %s", e)
return return False
# Wait for socket to become available # Wait for socket to become available
for _ in range(30): for _ in range(30):
if sock_path.exists(): if sock_path.exists():
break break
# If process already exited, abort early
if self.process.returncode is not None:
return False
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if not sock_path.exists(): if not sock_path.exists() or (self.process and self.process.returncode is not None):
logger.error("MPV IPC socket was not created at %s", self.config.ipc_socket) if self.process and self.process.returncode is not None:
return logger.warning("MPV process exited prematurely with code %s", self.process.returncode)
return False
try: try:
self._reader, self._writer = await asyncio.open_unix_connection(str(sock_path)) self._reader, self._writer = await asyncio.open_unix_connection(str(sock_path))
self._read_task = asyncio.create_task(self._socket_reader_loop()) self._read_task = asyncio.create_task(self._socket_reader_loop())
await self._setup_observers() await self._setup_observers()
logger.info("Successfully connected to MPV IPC socket") logger.info("Successfully connected to MPV IPC socket")
return True
except Exception as e: except Exception as e:
logger.error("Failed to connect to MPV IPC socket: %s", e) logger.error("Failed to connect to MPV IPC socket: %s", e)
return False
async def _setup_observers(self) -> None: async def _setup_observers(self) -> None:
"""Observe relevant MPV properties.""" """Observe relevant MPV properties."""
+14
View File
@@ -36,3 +36,17 @@ kodi_rpc:
assert cfg.kodi_rpc.http_port == 8088 assert cfg.kodi_rpc.http_port == 8088
finally: finally:
Path(f_path).unlink() Path(f_path).unlink()
def test_env_var_overrides(monkeypatch):
monkeypatch.setenv("JELLYFIN_SERVER_URL", "https://override.lan")
monkeypatch.setenv("JELLYFIN_USERNAME", "envuser")
monkeypatch.setenv("MPV_VO", "null")
monkeypatch.setenv("MPV_FULLSCREEN", "true")
cfg = AppConfig.load()
assert cfg.jellyfin.server_url == "https://override.lan"
assert cfg.jellyfin.username == "envuser"
assert cfg.player.vo == "null"
assert cfg.player.fullscreen is True