diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c36ac89 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index b384c79..f060c5a 100644 --- a/README.md +++ b/README.md @@ -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 git clone https://git.bnhmeijer.nl/bart/jellyfin-client.git diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6641620 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/src/config.py b/src/config.py index e6f5a4a..142224e 100644 --- a/src/config.py +++ b/src/config.py @@ -136,4 +136,23 @@ class AppConfig: 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 diff --git a/src/player/mpv_controller.py b/src/player/mpv_controller.py index 6ecaf8b..3f9d9af 100644 --- a/src/player/mpv_controller.py +++ b/src/player/mpv_controller.py @@ -39,7 +39,24 @@ class MpvController: logger.error("Error in status listener callback: %s", e) 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) if sock_path.exists(): try: @@ -51,15 +68,17 @@ class MpvController: self.config.mpv_path, "--idle=yes", f"--input-ipc-server={self.config.ipc_socket}", - f"--hwdec={self.config.hwdec}", - f"--vo={self.config.vo}", - "--fs" if self.config.fullscreen else "--fs=no", + f"--hwdec={hwdec}", + f"--vo={vo}", + "--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.extend(self.config.extra_args) + if extra_fallback_args: + cmd.extend(extra_fallback_args) logger.info("Starting MPV: %s", " ".join(cmd)) try: @@ -70,25 +89,31 @@ class MpvController: ) except Exception as e: logger.error("Failed to spawn mpv process: %s", e) - return + return False # Wait for socket to become available for _ in range(30): if sock_path.exists(): break + # If process already exited, abort early + if self.process.returncode is not None: + return False await asyncio.sleep(0.1) - if not sock_path.exists(): - logger.error("MPV IPC socket was not created at %s", self.config.ipc_socket) - return + if not sock_path.exists() or (self.process and self.process.returncode is not None): + if self.process and self.process.returncode is not None: + logger.warning("MPV process exited prematurely with code %s", self.process.returncode) + return False try: self._reader, self._writer = await asyncio.open_unix_connection(str(sock_path)) self._read_task = asyncio.create_task(self._socket_reader_loop()) await self._setup_observers() logger.info("Successfully connected to MPV IPC socket") + return True except Exception as e: logger.error("Failed to connect to MPV IPC socket: %s", e) + return False async def _setup_observers(self) -> None: """Observe relevant MPV properties.""" diff --git a/tests/test_config.py b/tests/test_config.py index 3ca7443..ebd21cb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -36,3 +36,17 @@ kodi_rpc: assert cfg.kodi_rpc.http_port == 8088 finally: 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 +