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
|
||||
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),
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user