feat(docker): add Dockerfile, docker-compose and headless fallback for local testing
This commit is contained in:
+35
@@ -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"]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user