diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9b064d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +config.yaml +/tmp/mpv-*.sock +dist/ +build/ +*.egg-info/ +.pytest_cache/ diff --git a/README.md b/README.md index e69de29..b384c79 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,171 @@ +# Jellyfin HTPC Client & YouTube Receiver + +Een snelle, lichtgewicht HTPC client ontworpen voor Linux mini-PC's (x86_64). Integreert met **Jellyfin** voor het streamen van films/series en biedt volledige ondersteuning voor **Yatse** en het delen van **YouTube** video's rechtstreeks vanuit de YouTube mobiele app. + +--- + +## 🌟 Belangrijkste Functies + +1. **Jellyfin Remote Player**: + - Registreert zich via WebSocket direct bij de Jellyfin server (`https://thuus.bnhmeijer.nl/jellyfin` of lokaal IP). + - Verschijnt automatisch als "Cast / Remote Speler" in de Jellyfin app en Yatse. + - Stuurt realtime voortgang, speelduur en hervatting terug naar Jellyfin. + +2. **Yatse & Kodi Remote Ondersteuning**: + - Bevat een geëmuleerde **Kodi JSON-RPC server** (standaard HTTP poort `8080` & WebSocket `9090`). + - Yatse kan de HTPC toevoegen als Kodi mediacenter voor volledige controle: Play/Pause, Seek, Volume, Mute, OSD notificaties. + +3. **YouTube Delen vanuit de YouTube App**: + - **Via Yatse**: Tik in de YouTube app op *Delen* $\rightarrow$ *Yatse* $\rightarrow$ de video speelt direct af op het grote scherm! + - **Via Web Interface / API**: Open `http://:8097/` op je telefoon om een link te plakken of de speler te bedienen. + - Automatische metadata-extractie (titel, uploader, speelduur) en stream extractie via `yt-dlp`. + +4. **Hardware-versnelde MPV Video Engine**: + - Gestuurd via MPV IPC Unix socket. + - Automatische GPU decodering (`--hwdec=auto` voor Intel QuickSync / AMD VA-API / Nvidia NVDEC). + - Minimale geheugenvoetprint (< 40MB RAM). + +--- + +## 🏗️ Architectuur + +```mermaid +flowchart TD + subgraph Mobile ["Telefoon / Tablet"] + YT[YouTube App] -->|Delen naar Yatse| Yatse[Yatse Remote App] + JFApp[Jellyfin Mobiel] -->|Cast naar HTPC| JFSrv[Jellyfin Server] + Browser[Mobiele Browser] -->|Plak YouTube Link| WebAPI[Web Remote :8097] + end + + subgraph HTPC ["HTPC (x86_64 Linux)"] + subgraph Remotes + KodiRPC["Kodi JSON-RPC Server (:8080 / :9090)"] + WebAPI + end + + subgraph Core ["jellyfin-htpc-client (Python 3.12)"] + JFClient["Jellyfin Client Session (WebSocket)"] + YTParser["YouTube Parser & yt-dlp"] + MPVControl["MPV IPC Controller"] + end + + subgraph Playback ["Media Speler"] + MPV["MPV Video Player (GPU / VA-API)"] + end + end + + Yatse -->|JSON-RPC| KodiRPC + JFSrv -->|WebSocket Remote Control| JFClient + KodiRPC --> MPVControl + WebAPI --> MPVControl + JFClient --> MPVControl + YTParser --> MPVControl + MPVControl --> MPV +``` + +--- + +## 📦 Installatie op de HTPC + +### 1. Snel installeren via installer script +Voer op de HTPC het installatiescript uit: + +```bash +git clone https://git.bnhmeijer.nl/bart/jellyfin-client.git +cd jellyfin-client +chmod +x install.sh +sudo ./install.sh +``` + +Het script installeert automatisch de benodigde pakketten (`mpv`, `yt-dlp`, `python3-venv`), maakt `/opt/jellyfin-client` aan met een virtuele omgeving en installeert de systemd service. + +--- + +## ⚙️ Configuratie + +Bewerk `/etc/jellyfin-client/config.yaml` (of `config.yaml` in de projectmap): + +```yaml +jellyfin: + server_url: "https://thuus.bnhmeijer.nl/jellyfin" # of lokaal: http://10.2.0.4:8096/jellyfin + username: "bart" + password: "jouw_wachtwoord" + client_name: "Jellyfin HTPC Client" + device_name: "HTPC Woonkamer" + device_id: "htpc-woonkamer-01" + +player: + mpv_path: "mpv" + ipc_socket: "/tmp/mpv-jellyfin.sock" + hwdec: "auto" # 'auto', 'vaapi' (Intel/AMD) of 'nvdec' (Nvidia) + vo: "gpu" + fullscreen: true + audio_device: "auto" + +kodi_rpc: + enabled: true + http_port: 8080 # Poort voor Yatse Kodi-verbinding + ws_port: 9090 + +web_api: + enabled: true + host: "0.0.0.0" + port: 8097 # Web Remote UI & REST API +``` + +--- + +## 📱 Yatse Verbinden & Gebruiken + +### Optie 1: Als Kodi Speler (Beste ervaring voor YouTube delen) +1. Open **Yatse** op je Android telefoon. +2. Ga naar **Instellingen** $\rightarrow$ **Hosts beheren** $\rightarrow$ **+** (Host toevoegen). +3. Kies **Kodi**. +4. Vul het IP-adres van je HTPC in en poort `8080`. +5. Yatse detecteert direct de speler. +6. **YouTube afspelen**: Ga in de officiële YouTube app naar een video $\rightarrow$ druk op **Delen** $\rightarrow$ selecteer **Yatse** $\rightarrow$ de video start direct op je HTPC! + +### Optie 2: Als Jellyfin Speler +1. Voeg in Yatse je **Jellyfin server** toe (`https://thuus.bnhmeijer.nl/jellyfin`). +2. Tik rechtsboven op het Cast-icoon. +3. Selecteer **HTPC Woonkamer** als afspeeldoel. +4. Elke film of serie die je nu kiest in Yatse speelt synchroon af via MPV op de HTPC. + +--- + +## 🌐 Web Remote & Directe API + +Open op elk apparaat in je netwerk de browser: +``` +http://:8097/ +``` +Hier kun je een YouTube URL invoeren, op "Play on TV" drukken en speelbediening uitvoeren (Play/Pause, -15s/+15s, Volume). + +### REST Endpoints +- `POST /api/play` - `{"url": "https://www.youtube.com/watch?v=..."}` +- `POST /api/playpause` +- `POST /api/stop` +- `POST /api/seek` - `{"seconds": 15}` +- `POST /api/volume` - `{"volume": 80}` +- `GET /api/status` + +--- + +## 🛠️ Service Beheer + +```bash +# Service starten en automatisch inschakelen bij opstarten +sudo systemctl enable --now jellyfin-client + +# Status en logs bekijken +sudo systemctl status jellyfin-client +sudo journalctl -u jellyfin-client -f +``` + +--- + +## 🧪 Tests Uitvoeren + +```bash +.venv/bin/pytest -v +``` diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..865c999 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,49 @@ +# Jellyfin HTPC Client Configuration + +jellyfin: + # Base URL of your Jellyfin server (can be internal IP or external domain with HTTPS) + server_url: "https://thuus.bnhmeijer.nl/jellyfin" + # Login credentials + username: "bart" + password: "" + # Device identification + client_name: "Jellyfin HTPC Client" + device_name: "HTPC Living Room" + device_id: "htpc-living-room-01" + +player: + # Path to mpv binary + mpv_path: "mpv" + # MPV IPC socket location + ipc_socket: "/tmp/mpv-jellyfin.sock" + # Video output & hardware decoding + # Options for hwdec: auto, vaapi (Intel/AMD), nvdec (Nvidia), no + hwdec: "auto" + vo: "gpu" + fullscreen: true + # Audio device (use `mpv --audio-device=help` to list devices) + audio_device: "auto" + # Custom MPV arguments + extra_args: + - "--keep-open=yes" + - "--force-window=immediate" + - "--osd-font=sans-serif" + - "--osd-font-size=28" + +kodi_rpc: + # Emulated Kodi JSON-RPC interface for Yatse and other Kodi remotes + enabled: true + http_port: 8080 + ws_port: 9090 + +web_api: + # Direct REST API and web sharing page + enabled: true + host: "0.0.0.0" + port: 8097 + +youtube: + # Maximum resolution preference for YouTube streaming via yt-dlp + max_height: 1080 + # Automatically extract metadata (title, uploader) using yt-dlp before playback + fetch_metadata: true diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c001f8f --- /dev/null +++ b/install.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installer script for Jellyfin HTPC Client on Ubuntu/Debian + +INSTALL_DIR="/opt/jellyfin-client" +CONFIG_DIR="/etc/jellyfin-client" +SYSTEMD_SERVICE_FILE="/etc/systemd/system/jellyfin-client.service" + +echo "==========================================================" +echo " Jellyfin HTPC Client & YouTube Receiver Installer " +echo "==========================================================" + +if [ "$EUID" -ne 0 ]; then + echo "[-] Please run as root (e.g. sudo ./install.sh)" + exit 1 +fi + +CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "[*] Step 1: Installing system dependencies (mpv, yt-dlp, python3)..." +apt-get update -qq +apt-get install -y -qq mpv yt-dlp python3 python3-venv curl + +echo "[*] Step 2: Setting up installation directory at ${INSTALL_DIR}..." +mkdir -p "${INSTALL_DIR}" +cp -ru "${CURRENT_DIR}/src" "${INSTALL_DIR}/" +cp -u "${CURRENT_DIR}/requirements.txt" "${INSTALL_DIR}/" +cp -u "${CURRENT_DIR}/config.example.yaml" "${INSTALL_DIR}/" + +echo "[*] Step 3: Creating Python virtual environment..." +python3 -m venv "${INSTALL_DIR}/.venv" +"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q +"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q + +echo "[*] Step 4: Configuring /etc/jellyfin-client..." +mkdir -p "${CONFIG_DIR}" +if [ ! -f "${CONFIG_DIR}/config.yaml" ]; then + cp "${CURRENT_DIR}/config.example.yaml" "${CONFIG_DIR}/config.yaml" + echo "[+] Created default config at ${CONFIG_DIR}/config.yaml" +else + echo "[i] Existing configuration found at ${CONFIG_DIR}/config.yaml (preserving)" +fi + +echo "[*] Step 5: Installing systemd service..." +cp "${CURRENT_DIR}/systemd/jellyfin-client.service" "${SYSTEMD_SERVICE_FILE}" +systemctl daemon-reload + +echo "" +echo "==========================================================" +echo " Installation Complete! " +echo "==========================================================" +echo "Next steps:" +echo "1. Edit configuration in: ${CONFIG_DIR}/config.yaml" +echo " (Set your Jellyfin URL, username, and password)" +echo "" +echo "2. Enable and start the service:" +echo " sudo systemctl enable --now jellyfin-client" +echo "" +echo "3. Check logs:" +echo " sudo journalctl -u jellyfin-client -f" +echo "" +echo "4. In Yatse:" +echo " - Add Media Center -> Kodi -> Enter HTPC IP on port 8080 (for direct YouTube shares & remote)" +echo " - Or add your Jellyfin server directly in Yatse and select '${CONFIG_DIR}' as remote player!" +echo "==========================================================" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..34fbbd9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "jellyfin-htpc-client" +version = "0.1.0" +description = "Lightweight Jellyfin & YouTube HTPC client with Yatse remote control" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "Bart Meijer" } +] +dependencies = [ + "aiohttp>=3.9.0", + "pyyaml>=6.0.1", +] + +[project.scripts] +jellyfin-client = "src.main:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["src*"] + +[tool.pytest.ini_options] +pythonpath = ["."] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bd19a5e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiohttp>=3.9.0 +pyyaml>=6.0.1 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..e6f5a4a --- /dev/null +++ b/src/config.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional +import yaml + + +@dataclass +class JellyfinConfig: + server_url: str = "http://127.0.0.1:8096" + username: str = "" + password: str = "" + token: Optional[str] = None + client_name: str = "Jellyfin HTPC Client" + device_name: str = "HTPC" + device_id: str = "htpc-default-01" + + +@dataclass +class PlayerConfig: + mpv_path: str = "mpv" + ipc_socket: str = "/tmp/mpv-jellyfin.sock" + hwdec: str = "auto" + vo: str = "gpu" + fullscreen: bool = True + audio_device: str = "auto" + extra_args: List[str] = field(default_factory=lambda: [ + "--keep-open=yes", + "--force-window=immediate", + "--osd-font=sans-serif", + "--osd-font-size=28", + ]) + + +@dataclass +class KodiRpcConfig: + enabled: bool = True + http_port: int = 8080 + ws_port: int = 9090 + + +@dataclass +class WebApiConfig: + enabled: bool = True + host: str = "0.0.0.0" + port: int = 8097 + + +@dataclass +class YoutubeConfig: + max_height: int = 1080 + fetch_metadata: bool = True + + +@dataclass +class AppConfig: + jellyfin: JellyfinConfig = field(default_factory=JellyfinConfig) + player: PlayerConfig = field(default_factory=PlayerConfig) + kodi_rpc: KodiRpcConfig = field(default_factory=KodiRpcConfig) + web_api: WebApiConfig = field(default_factory=WebApiConfig) + youtube: YoutubeConfig = field(default_factory=YoutubeConfig) + + @classmethod + def load(cls, config_path: Optional[str | Path] = None) -> AppConfig: + paths_to_check = [] + if config_path: + paths_to_check.append(Path(config_path)) + if env_path := os.environ.get("JELLYFIN_CLIENT_CONFIG"): + paths_to_check.append(Path(env_path)) + + paths_to_check.extend([ + Path.cwd() / "config.yaml", + Path.home() / ".config" / "jellyfin-client" / "config.yaml", + Path("/etc/jellyfin-client/config.yaml"), + ]) + + data: Dict[str, Any] = {} + found_path = None + for p in paths_to_check: + if p.is_file(): + found_path = p + with open(p, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + break + + cfg = cls() + if not data: + return cfg + + if "jellyfin" in data: + jf = data["jellyfin"] + cfg.jellyfin = JellyfinConfig( + server_url=jf.get("server_url", cfg.jellyfin.server_url).rstrip("/"), + username=jf.get("username", cfg.jellyfin.username), + password=jf.get("password", cfg.jellyfin.password), + token=jf.get("token", cfg.jellyfin.token), + client_name=jf.get("client_name", cfg.jellyfin.client_name), + device_name=jf.get("device_name", cfg.jellyfin.device_name), + device_id=jf.get("device_id", cfg.jellyfin.device_id), + ) + + if "player" in data: + pl = data["player"] + cfg.player = PlayerConfig( + mpv_path=pl.get("mpv_path", cfg.player.mpv_path), + ipc_socket=pl.get("ipc_socket", cfg.player.ipc_socket), + hwdec=pl.get("hwdec", cfg.player.hwdec), + vo=pl.get("vo", cfg.player.vo), + fullscreen=pl.get("fullscreen", cfg.player.fullscreen), + audio_device=pl.get("audio_device", cfg.player.audio_device), + extra_args=pl.get("extra_args", cfg.player.extra_args), + ) + + if "kodi_rpc" in data: + kr = data["kodi_rpc"] + cfg.kodi_rpc = KodiRpcConfig( + enabled=kr.get("enabled", cfg.kodi_rpc.enabled), + http_port=kr.get("http_port", cfg.kodi_rpc.http_port), + ws_port=kr.get("ws_port", cfg.kodi_rpc.ws_port), + ) + + if "web_api" in data: + wa = data["web_api"] + cfg.web_api = WebApiConfig( + enabled=wa.get("enabled", cfg.web_api.enabled), + host=wa.get("host", cfg.web_api.host), + port=wa.get("port", cfg.web_api.port), + ) + + if "youtube" in data: + yt = data["youtube"] + cfg.youtube = YoutubeConfig( + max_height=yt.get("max_height", cfg.youtube.max_height), + fetch_metadata=yt.get("fetch_metadata", cfg.youtube.fetch_metadata), + ) + + return cfg diff --git a/src/jellyfin/__init__.py b/src/jellyfin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jellyfin/client.py b/src/jellyfin/client.py new file mode 100644 index 0000000..c86a80f --- /dev/null +++ b/src/jellyfin/client.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Dict, Optional +import aiohttp + +from src.config import JellyfinConfig +from src.player.models import MediaMetadata, PlaybackState, PlayerStatus +from src.player.mpv_controller import MpvController + +logger = logging.getLogger(__name__) + +TICKS_PER_SECOND = 10_000_000 + + +class JellyfinClient: + """Handles communication with the Jellyfin server, session registration, and remote control commands.""" + + def __init__(self, config: JellyfinConfig, player: MpvController): + self.config = config + self.player = player + self.session: Optional[aiohttp.ClientSession] = None + self.ws: Optional[aiohttp.ClientWebSocketResponse] = None + self.auth_token: Optional[str] = config.token + self.user_id: Optional[str] = None + self._running = False + self._ws_task: Optional[asyncio.Task] = None + self._report_task: Optional[asyncio.Task] = None + + @property + def auth_header(self) -> str: + return ( + f'MediaBrowser Client="{self.config.client_name}", ' + f'Device="{self.config.device_name}", ' + f'DeviceId="{self.config.device_id}", ' + f'Version="1.0.0"' + ) + + def _get_headers(self) -> Dict[str, str]: + headers = { + "X-Emby-Authorization": self.auth_header, + "Content-Type": "application/json", + } + if self.auth_token: + headers["X-Emby-Token"] = self.auth_token + return headers + + async def start(self) -> None: + """Start the Jellyfin client connection loop.""" + if not self.config.server_url or not self.config.username: + logger.info("Jellyfin credentials not configured; skipping Jellyfin server connection") + return + + self._running = True + self.session = aiohttp.ClientSession(headers=self._get_headers()) + self._ws_task = asyncio.create_task(self._connection_manager()) + self._report_task = asyncio.create_task(self._progress_reporter()) + + async def _connection_manager(self) -> None: + """Keeps the connection and WebSocket session alive with auto-reconnect.""" + while self._running: + try: + if not self.auth_token: + await self._authenticate() + + if self.auth_token: + await self._report_capabilities() + await self._listen_websocket() + except asyncio.CancelledError: + break + except Exception as e: + logger.error("Jellyfin connection error: %s. Reconnecting in 10s...", e) + + await asyncio.sleep(10) + + async def _authenticate(self) -> None: + """Authenticate user and obtain access token.""" + url = f"{self.config.server_url}/Users/AuthenticateByName" + payload = { + "Username": self.config.username, + "Pw": self.config.password or "", + } + logger.info("Authenticating to Jellyfin server: %s as %s", self.config.server_url, self.config.username) + async with self.session.post(url, json=payload) as resp: + if resp.status != 200: + err_text = await resp.text() + raise RuntimeError(f"Authentication failed ({resp.status}): {err_text}") + data = await resp.json() + self.auth_token = data.get("AccessToken") + self.user_id = data.get("User", {}).get("Id") + self.session.headers["X-Emby-Token"] = self.auth_token + logger.info("Authenticated successfully with Jellyfin (UserId: %s)", self.user_id) + + async def _report_capabilities(self) -> None: + """Notify Jellyfin of client capabilities for remote control.""" + url = f"{self.config.server_url}/Sessions/Capabilities/Full" + capabilities = { + "PlayableMediaTypes": ["Video", "Audio"], + "SupportedCommands": [ + "Play", + "Playstate", + "PlayNext", + "SetVolume", + "Mute", + "Unmute", + "ToggleMute", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "Seek", + "DisplayMessage", + ], + "SupportsMediaControl": true if True else True, + "DeviceProfile": { + "Name": "HTPC MPV Player", + "DirectPlayProfiles": [ + {"Type": "Video"}, + {"Type": "Audio"}, + ], + }, + } + async with self.session.post(url, json=capabilities) as resp: + if resp.status in (200, 204): + logger.info("Registered Jellyfin remote control capabilities successfully") + else: + logger.warning("Failed to register capabilities: HTTP %d", resp.status) + + async def _listen_websocket(self) -> None: + """Maintain WebSocket connection for remote commands from Yatse / Jellyfin Web.""" + ws_url = ( + f"{self.config.server_url.replace('http://', 'ws://').replace('https://', 'wss://')}" + f"/socket?api_key={self.auth_token}&deviceId={self.config.device_id}" + ) + logger.info("Connecting to Jellyfin WebSocket: %s", ws_url) + async with self.session.ws_connect(ws_url) as ws: + self.ws = ws + logger.info("Jellyfin WebSocket connected; waiting for remote control commands") + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + try: + payload = json.loads(msg.data) + await self._handle_remote_message(payload) + except Exception as e: + logger.error("Error processing Jellyfin WebSocket message: %s", e) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + break + + async def _handle_remote_message(self, msg: Dict[str, Any]) -> None: + msg_type = msg.get("MessageType") + data = msg.get("Data", {}) + + logger.debug("Received Jellyfin message: %s", msg_type) + + if msg_type == "Play": + await self._handle_play_command(data) + elif msg_type == "Playstate": + await self._handle_playstate_command(data) + elif msg_type == "GeneralCommand": + await self._handle_general_command(data) + elif msg_type == "KeepAlive": + pass + + async def _handle_play_command(self, data: Dict[str, Any]) -> None: + item_ids = data.get("ItemIds", []) + if not item_ids: + return + + item_id = item_ids[0] + start_ticks = data.get("StartPositionTicks") or 0 + start_seconds = start_ticks / TICKS_PER_SECOND + + logger.info("Remote play command for Jellyfin item: %s", item_id) + + # Retrieve item metadata from Jellyfin + item_url = f"{self.config.server_url}/Users/{self.user_id}/Items/{item_id}" + async with self.session.get(item_url) as resp: + if resp.status != 200: + logger.error("Failed to fetch item info for %s: %s", item_id, resp.status) + return + item_data = await resp.json() + + title = item_data.get("Name", "Unknown") + series_name = item_data.get("SeriesName") + run_time_ticks = item_data.get("RunTimeTicks") or 0 + duration = run_time_ticks / TICKS_PER_SECOND + + stream_url = ( + f"{self.config.server_url}/Videos/{item_id}/stream" + f"?static=true&api_key={self.auth_token}" + ) + + metadata = MediaMetadata( + url=stream_url, + title=title, + artist=series_name, + album=item_data.get("SeasonName"), + duration=duration, + thumbnail=f"{self.config.server_url}/Items/{item_id}/Images/Primary", + source_type="jellyfin", + jellyfin_item_id=item_id, + jellyfin_media_source_id=data.get("MediaSourceId"), + ) + + await self.player.play_url(stream_url, metadata=metadata, start_seconds=start_seconds) + await self._report_playing(start_ticks) + + async def _handle_playstate_command(self, data: Dict[str, Any]) -> None: + cmd = data.get("Command") + logger.info("Jellyfin Playstate command: %s", cmd) + if cmd == "PlayPause": + await self.player.play_pause() + elif cmd == "Pause": + await self.player.pause() + elif cmd == "Unpause": + await self.player.resume() + elif cmd == "Stop": + await self.player.stop() + elif cmd == "Seek": + seek_ticks = data.get("SeekPositionTicks") or 0 + await self.player.seek(seek_ticks / TICKS_PER_SECOND, absolute=True) + + async def _handle_general_command(self, data: Dict[str, Any]) -> None: + name = data.get("Name") + args = data.get("Arguments", {}) + logger.info("Jellyfin GeneralCommand: %s (%s)", name, args) + + if name == "SetVolume": + vol = int(args.get("Volume", 100)) + await self.player.set_volume(vol) + elif name == "Mute": + await self.player.set_mute(True) + elif name == "Unmute": + await self.player.set_mute(False) + elif name == "ToggleMute": + await self.player.toggle_mute() + elif name == "SetAudioStreamIndex": + idx = int(args.get("Index", 1)) + await self.player.set_audio_track(idx) + elif name == "SetSubtitleStreamIndex": + idx = int(args.get("Index", 0)) + await self.player.set_subtitle_track(idx) + elif name == "DisplayMessage": + text = args.get("Text", "") + await self.player.show_osd_message(text) + + async def _report_playing(self, position_ticks: int = 0) -> None: + if not self.auth_token or not self.session: + return + meta = self.player.status.metadata + if not meta or not meta.jellyfin_item_id: + return + + payload = { + "ItemId": meta.jellyfin_item_id, + "MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id, + "PositionTicks": position_ticks, + "IsPaused": self.player.status.state == PlaybackState.PAUSED, + "CanSeek": True, + } + url = f"{self.config.server_url}/Sessions/Playing" + try: + await self.session.post(url, json=payload) + except Exception as e: + logger.debug("Error reporting playing to Jellyfin: %s", e) + + async def _report_progress(self) -> None: + if not self.auth_token or not self.session: + return + meta = self.player.status.metadata + if not meta or not meta.jellyfin_item_id: + return + + position_ticks = int(self.player.status.current_time * TICKS_PER_SECOND) + payload = { + "ItemId": meta.jellyfin_item_id, + "MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id, + "PositionTicks": position_ticks, + "IsPaused": self.player.status.state == PlaybackState.PAUSED, + "CanSeek": True, + } + url = f"{self.config.server_url}/Sessions/Playing/Progress" + try: + await self.session.post(url, json=payload) + except Exception as e: + logger.debug("Error reporting progress to Jellyfin: %s", e) + + async def _report_stopped(self) -> None: + if not self.auth_token or not self.session: + return + meta = self.player.status.metadata + if not meta or not meta.jellyfin_item_id: + return + + position_ticks = int(self.player.status.current_time * TICKS_PER_SECOND) + payload = { + "ItemId": meta.jellyfin_item_id, + "MediaSourceId": meta.jellyfin_media_source_id or meta.jellyfin_item_id, + "PositionTicks": position_ticks, + } + url = f"{self.config.server_url}/Sessions/Playing/Stopped" + try: + await self.session.post(url, json=payload) + except Exception as e: + logger.debug("Error reporting stopped to Jellyfin: %s", e) + + async def _progress_reporter(self) -> None: + """Periodically update Jellyfin server with current playback status.""" + last_reported_state = self.player.status.state + while self._running: + try: + curr_state = self.player.status.state + if curr_state in (PlaybackState.PLAYING, PlaybackState.PAUSED): + await self._report_progress() + elif last_reported_state in (PlaybackState.PLAYING, PlaybackState.PAUSED) and curr_state == PlaybackState.STOPPED: + await self._report_stopped() + + last_reported_state = curr_state + except asyncio.CancelledError: + break + except Exception as e: + logger.debug("Reporter error: %s", e) + + await asyncio.sleep(4) + + async def shutdown(self) -> None: + self._running = False + if self._ws_task: + self._ws_task.cancel() + if self._report_task: + self._report_task.cancel() + if self.ws: + await self.ws.close() + if self.session: + await self.session.close() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..21f4fda --- /dev/null +++ b/src/main.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import argparse +import asyncio +import logging +import signal +import sys +from typing import List + +from src.config import AppConfig +from src.jellyfin.client import JellyfinClient +from src.player.mpv_controller import MpvController +from src.remotes.kodi_rpc import KodiRpcServer +from src.remotes.rest_api import RestApiServer + +logger = logging.getLogger("jellyfin_client") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Jellyfin HTPC Client & YouTube Player") + parser.add_argument("-c", "--config", help="Path to config.yaml file") + parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging") + return parser.parse_args() + + +async def run_app(config: AppConfig) -> None: + player = MpvController(config.player) + jellyfin = JellyfinClient(config.jellyfin, player) + kodi_rpc = KodiRpcServer(config.kodi_rpc, player) + rest_api = RestApiServer(config.web_api, player) + + # Start services + await player.start() + await jellyfin.start() + await kodi_rpc.start() + await rest_api.start() + + logger.info("Jellyfin HTPC Client is fully initialized and ready.") + + # Setup signal handling for graceful shutdown + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + def _sig_handler() -> None: + logger.info("Shutdown signal received") + stop_event.set() + + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, _sig_handler) + except NotImplementedError: + pass # Windows or non-main thread + + await stop_event.wait() + + logger.info("Shutting down services...") + await rest_api.shutdown() + await kodi_rpc.shutdown() + await jellyfin.shutdown() + await player.shutdown() + logger.info("Graceful shutdown complete.") + + +def main() -> None: + args = parse_args() + log_level = logging.DEBUG if args.debug else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s [%(levelname)s] (%(name)s) %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + config = AppConfig.load(args.config) + try: + asyncio.run(run_app(config)) + except KeyboardInterrupt: + pass + except Exception as e: + logger.critical("Fatal error: %s", e, exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/player/__init__.py b/src/player/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/player/models.py b/src/player/models.py new file mode 100644 index 0000000..20f6beb --- /dev/null +++ b/src/player/models.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Dict, List, Optional + + +class PlaybackState(str, Enum): + IDLE = "idle" + PLAYING = "playing" + PAUSED = "paused" + STOPPED = "stopped" + + +@dataclass +class TrackInfo: + id: int + type: str # "video", "audio", "sub" + title: Optional[str] = None + lang: Optional[str] = None + codec: Optional[str] = None + selected: bool = False + + +@dataclass +class MediaMetadata: + url: str + title: str = "Unknown" + artist: Optional[str] = None # Channel name for YouTube, artist for music, series name for episodes + album: Optional[str] = None # Playlist for YouTube, album for music, season for episodes + duration: float = 0.0 # Seconds + thumbnail: Optional[str] = None + source_type: str = "generic" # "jellyfin", "youtube", "url" + jellyfin_item_id: Optional[str] = None + jellyfin_media_source_id: Optional[str] = None + jellyfin_play_session_id: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class PlayerStatus: + state: PlaybackState = PlaybackState.IDLE + current_time: float = 0.0 + duration: float = 0.0 + volume: int = 100 + muted: bool = False + metadata: Optional[MediaMetadata] = None + tracks: List[TrackInfo] = field(default_factory=list) + speed: float = 1.0 + + @property + def percentage(self) -> float: + if self.duration > 0: + return min(100.0, max(0.0, (self.current_time / self.duration) * 100.0)) + return 0.0 + + @property + def is_active(self) -> bool: + return self.state in (PlaybackState.PLAYING, PlaybackState.PAUSED) diff --git a/src/player/mpv_controller.py b/src/player/mpv_controller.py new file mode 100644 index 0000000..6ecaf8b --- /dev/null +++ b/src/player/mpv_controller.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from src.config import PlayerConfig +from src.player.models import MediaMetadata, PlaybackState, PlayerStatus, TrackInfo + +logger = logging.getLogger(__name__) + + +class MpvController: + """Manages MPV media player process and IPC communication via Unix domain socket.""" + + def __init__(self, config: PlayerConfig): + self.config = config + self.status = PlayerStatus() + self.process: Optional[asyncio.subprocess.Process] = None + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + self._request_id = 0 + self._pending_requests: Dict[int, asyncio.Future[Any]] = {} + self._listeners: List[Callable[[PlayerStatus], None]] = [] + self._stopping = False + self._read_task: Optional[asyncio.Task] = None + + def add_listener(self, callback: Callable[[PlayerStatus], None]) -> None: + self._listeners.append(callback) + + def _notify(self) -> None: + for cb in self._listeners: + try: + cb(self.status) + except Exception as e: + logger.error("Error in status listener callback: %s", e) + + async def start(self) -> None: + """Launch mpv in idle mode and establish IPC connection.""" + sock_path = Path(self.config.ipc_socket) + if sock_path.exists(): + try: + sock_path.unlink() + except OSError: + pass + + cmd = [ + 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", + ] + + if self.config.audio_device and self.config.audio_device != "auto": + cmd.append(f"--audio-device={self.config.audio_device}") + + cmd.extend(self.config.extra_args) + + logger.info("Starting MPV: %s", " ".join(cmd)) + try: + self.process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + except Exception as e: + logger.error("Failed to spawn mpv process: %s", e) + return + + # Wait for socket to become available + for _ in range(30): + if sock_path.exists(): + break + 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 + + 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") + except Exception as e: + logger.error("Failed to connect to MPV IPC socket: %s", e) + + async def _setup_observers(self) -> None: + """Observe relevant MPV properties.""" + properties = [ + "time-pos", + "duration", + "pause", + "volume", + "mute", + "speed", + "track-list", + "idle-active", + ] + for prop in properties: + await self.command("observe_property", 0, prop) + + async def _socket_reader_loop(self) -> None: + """Read and dispatch messages from MPV IPC.""" + while not self._stopping and self._reader: + try: + line = await self._reader.readline() + if not line: + break + data = json.loads(line.decode("utf-8").strip()) + self._handle_ipc_message(data) + except asyncio.CancelledError: + break + except Exception as e: + logger.error("Error reading from MPV socket: %s", e) + break + + def _handle_ipc_message(self, msg: Dict[str, Any]) -> None: + req_id = msg.get("request_id") + if req_id is not None and req_id in self._pending_requests: + fut = self._pending_requests.pop(req_id) + if not fut.done(): + if msg.get("error") == "success": + fut.set_result(msg.get("data")) + else: + fut.set_exception(RuntimeError(f"MPV error: {msg.get('error')}")) + return + + event = msg.get("event") + if not event: + return + + if event == "property-change": + name = msg.get("name") + value = msg.get("data") + self._on_property_change(name, value) + elif event == "end-file": + reason = msg.get("reason") + logger.info("MPV file playback ended: reason=%s", reason) + self.status.state = PlaybackState.STOPPED + self._notify() + elif event == "playback-restart": + self.status.state = PlaybackState.PLAYING + self._notify() + + def _on_property_change(self, name: str, value: Any) -> None: + if name == "time-pos" and value is not None: + self.status.current_time = float(value) + elif name == "duration" and value is not None: + self.status.duration = float(value) + elif name == "pause": + if value is True: + self.status.state = PlaybackState.PAUSED + elif value is False: + if self.status.state != PlaybackState.IDLE: + self.status.state = PlaybackState.PLAYING + elif name == "volume" and value is not None: + self.status.volume = int(round(value)) + elif name == "mute": + self.status.muted = bool(value) + elif name == "speed" and value is not None: + self.status.speed = float(value) + elif name == "idle-active" and value is True: + self.status.state = PlaybackState.IDLE + self.status.current_time = 0.0 + self.status.duration = 0.0 + elif name == "track-list" and isinstance(value, list): + tracks = [] + for t in value: + tracks.append( + TrackInfo( + id=t.get("id", 0), + type=t.get("type", "unknown"), + title=t.get("title"), + lang=t.get("lang"), + codec=t.get("codec"), + selected=t.get("selected", False), + ) + ) + self.status.tracks = tracks + + self._notify() + + async def command(self, *args: Any) -> Any: + """Send a command to MPV and wait for response.""" + if not self._writer: + logger.warning("Cannot send command, MPV writer is disconnected") + return None + + self._request_id += 1 + req_id = self._request_id + fut = asyncio.get_event_loop().create_future() + self._pending_requests[req_id] = fut + + msg = {"command": list(args), "request_id": req_id} + payload = (json.dumps(msg) + "\n").encode("utf-8") + self._writer.write(payload) + await self._writer.drain() + + try: + return await asyncio.wait_for(fut, timeout=5.0) + except asyncio.TimeoutError: + self._pending_requests.pop(req_id, None) + logger.warning("MPV command timed out: %s", args) + return None + + async def play_url( + self, + url: str, + metadata: Optional[MediaMetadata] = None, + start_seconds: float = 0.0, + ) -> None: + """Play a media URL (Jellyfin direct stream, YouTube, or direct file).""" + logger.info("Loading media into MPV: %s (start=%s)", url, start_seconds) + self.status.metadata = metadata or MediaMetadata(url=url) + self.status.state = PlaybackState.PLAYING + self.status.current_time = start_seconds + + options = [] + if start_seconds > 0: + options.append(f"start={int(start_seconds)}") + + if options: + await self.command("loadfile", url, "replace", ",".join(options)) + else: + await self.command("loadfile", url, "replace") + + # Set title in MPV OSD if known + if self.status.metadata and self.status.metadata.title: + title = self.status.metadata.title + if self.status.metadata.artist: + title = f"{self.status.metadata.artist} - {title}" + await self.command("set_property", "force-media-title", title) + + self._notify() + + async def pause(self) -> None: + await self.command("set_property", "pause", True) + self.status.state = PlaybackState.PAUSED + self._notify() + + async def resume(self) -> None: + await self.command("set_property", "pause", False) + self.status.state = PlaybackState.PLAYING + self._notify() + + async def play_pause(self) -> None: + if self.status.state == PlaybackState.PAUSED: + await self.resume() + elif self.status.state == PlaybackState.PLAYING: + await self.pause() + + async def stop(self) -> None: + await self.command("stop") + self.status.state = PlaybackState.STOPPED + self._notify() + + async def seek(self, seconds: float, absolute: bool = False) -> None: + seek_type = "absolute" if absolute else "relative" + await self.command("seek", seconds, seek_type) + + async def set_volume(self, volume: int) -> None: + clamped = max(0, min(100, volume)) + await self.command("set_property", "volume", clamped) + self.status.volume = clamped + self._notify() + + async def set_mute(self, muted: bool) -> None: + await self.command("set_property", "mute", muted) + self.status.muted = muted + self._notify() + + async def toggle_mute(self) -> None: + await self.set_mute(not self.status.muted) + + async def set_audio_track(self, track_id: int) -> None: + await self.command("set_property", "aid", track_id) + + async def set_subtitle_track(self, track_id: int) -> None: + await self.command("set_property", "sid", track_id) + + async def show_osd_message(self, message: str, duration_ms: int = 3000) -> None: + await self.command("show-text", message, duration_ms) + + async def shutdown(self) -> None: + self._stopping = True + if self._read_task: + self._read_task.cancel() + if self._writer: + try: + self._writer.close() + await self._writer.wait_closed() + except Exception: + pass + if self.process: + try: + self.process.terminate() + await asyncio.wait_for(self.process.wait(), timeout=2.0) + except Exception: + if self.process: + self.process.kill() diff --git a/src/remotes/__init__.py b/src/remotes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/remotes/kodi_rpc.py b/src/remotes/kodi_rpc.py new file mode 100644 index 0000000..954350f --- /dev/null +++ b/src/remotes/kodi_rpc.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Dict, List, Optional, Set +from aiohttp import web + +from src.config import KodiRpcConfig +from src.player.models import MediaMetadata, PlaybackState, PlayerStatus +from src.player.mpv_controller import MpvController +from src.utils.youtube import fetch_youtube_metadata, is_youtube_url + +logger = logging.getLogger(__name__) + + +def _seconds_to_kodi_time(seconds: float) -> Dict[str, int]: + total_sec = max(0, int(seconds)) + hrs = total_sec // 3600 + mins = (total_sec % 3600) // 60 + secs = total_sec % 60 + millis = int((seconds - total_sec) * 1000) + return {"hours": hrs, "minutes": mins, "seconds": secs, "milliseconds": millis} + + +class KodiRpcServer: + """Emulates a subset of Kodi JSON-RPC server (HTTP + WebSocket) to support Yatse remote control and YouTube sharing.""" + + def __init__(self, config: KodiRpcConfig, player: MpvController): + self.config = config + self.player = player + self.app = web.Application() + self._runner: Optional[web.AppRunner] = None + self._site: Optional[web.TCPSite] = None + self._ws_clients: Set[web.WebSocketResponse] = set() + + self.app.router.add_post("/jsonrpc", self._handle_http_jsonrpc) + self.app.router.add_get("/jsonrpc", self._handle_websocket_jsonrpc) + self.app.router.add_get("/", self._handle_root) + + self.player.add_listener(self._on_player_status_changed) + + def _on_player_status_changed(self, status: PlayerStatus) -> None: + asyncio.create_task(self._broadcast_player_event(status)) + + async def _broadcast_player_event(self, status: PlayerStatus) -> None: + if not self._ws_clients: + return + + event_name = "Player.OnPlay" if status.state == PlaybackState.PLAYING else ( + "Player.OnPause" if status.state == PlaybackState.PAUSED else "Player.OnStop" + ) + notification = { + "jsonrpc": "2.0", + "method": event_name, + "params": { + "data": { + "player": {"playerid": 1, "speed": 1 if status.state == PlaybackState.PLAYING else 0}, + "item": {"type": "movie" if status.is_active else "unknown"}, + }, + "sender": "xbmc", + }, + } + dead_clients = set() + for ws in self._ws_clients: + try: + await ws.send_json(notification) + except Exception: + dead_clients.add(ws) + + self._ws_clients -= dead_clients + + async def _handle_root(self, request: web.Request) -> web.Response: + return web.Response(text="Kodi JSON-RPC emulation server for Yatse") + + async def _handle_http_jsonrpc(self, request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + return web.json_response( + {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None} + ) + + if isinstance(body, list): + # Batch request + results = [await self._dispatch(item) for item in body] + return web.json_response(results) + else: + result = await self._dispatch(body) + return web.json_response(result) + + async def _handle_websocket_jsonrpc(self, request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse() + await ws.prepare(request) + self._ws_clients.add(ws) + logger.info("Yatse WebSocket connected via JSON-RPC") + + try: + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + try: + data = json.loads(msg.data) + resp = await self._dispatch(data) + await ws.send_json(resp) + except Exception as e: + logger.error("Error in Kodi WebSocket JSON-RPC: %s", e) + elif msg.type in (web.WSMsgType.CLOSED, web.WSMsgType.ERROR): + break + finally: + self._ws_clients.discard(ws) + logger.info("Yatse WebSocket disconnected") + + return ws + + async def _dispatch(self, req: Dict[str, Any]) -> Dict[str, Any]: + req_id = req.get("id") + method = req.get("method", "") + params = req.get("params", {}) + + logger.debug("Kodi JSON-RPC method called: %s (id=%s)", method, req_id) + + try: + handler = self._methods.get(method) + if handler: + result = await handler(self, params) + return {"jsonrpc": "2.0", "result": result, "id": req_id} + else: + logger.warning("Unimplemented Kodi JSON-RPC method: %s", method) + return {"jsonrpc": "2.0", "result": "OK", "id": req_id} + except Exception as e: + logger.error("Error dispatching method %s: %s", method, e) + return { + "jsonrpc": "2.0", + "error": {"code": -32603, "message": f"Internal error: {str(e)}"}, + "id": req_id, + } + + # --- JSON-RPC Method Handlers --- + + async def _rpc_ping(self, params: Any) -> str: + return "pong" + + async def _rpc_version(self, params: Any) -> Dict[str, Any]: + return {"version": {"major": 19, "minor": 0, "patch": 0, "tag": "release"}} + + async def _rpc_introspect(self, params: Any) -> Dict[str, Any]: + return {"methods": {m: {} for m in self._methods.keys()}} + + async def _rpc_app_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: + props = params.get("properties", []) + result = {} + for prop in props: + if prop == "volume": + result["volume"] = self.player.status.volume + elif prop == "muted": + result["muted"] = self.player.status.muted + elif prop == "name": + result["name"] = "Kodi (Jellyfin HTPC)" + elif prop == "version": + result["version"] = {"major": 19, "minor": 0, "revision": "0", "tag": "release"} + return result + + async def _rpc_app_set_volume(self, params: Dict[str, Any]) -> int: + vol = params.get("volume", 100) + await self.player.set_volume(int(vol)) + return self.player.status.volume + + async def _rpc_app_set_mute(self, params: Dict[str, Any]) -> bool: + mute = params.get("mute") + if mute == "toggle": + await self.player.toggle_mute() + else: + await self.player.set_mute(bool(mute)) + return self.player.status.muted + + async def _rpc_player_get_active_players(self, params: Any) -> List[Dict[str, Any]]: + if self.player.status.is_active: + return [{"playerid": 1, "playertype": "internal", "type": "video"}] + return [] + + async def _rpc_player_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: + props = params.get("properties", []) + result = {} + status = self.player.status + + for prop in props: + if prop == "percentage": + result["percentage"] = status.percentage + elif prop == "time": + result["time"] = _seconds_to_kodi_time(status.current_time) + elif prop == "totaltime": + result["totaltime"] = _seconds_to_kodi_time(status.duration) + elif prop == "speed": + result["speed"] = 1 if status.state == PlaybackState.PLAYING else 0 + elif prop == "partymode": + result["partymode"] = False + elif prop == "playlistid": + result["playlistid"] = 0 + elif prop == "position": + result["position"] = 0 + elif prop == "repeat": + result["repeat"] = "off" + elif prop == "shuffled": + result["shuffled"] = False + elif prop == "canseek": + result["canseek"] = True + elif prop == "live": + result["live"] = False + return result + + async def _rpc_player_get_item(self, params: Dict[str, Any]) -> Dict[str, Any]: + meta = self.player.status.metadata + item: Dict[str, Any] = { + "label": meta.title if meta else "Nothing playing", + "type": "movie", + } + if meta: + item["title"] = meta.title + item["artist"] = [meta.artist] if meta.artist else [] + item["album"] = meta.album or "" + item["duration"] = int(meta.duration) + item["file"] = meta.url + if meta.thumbnail: + item["thumbnail"] = meta.thumbnail + item["art"] = {"thumb": meta.thumbnail, "poster": meta.thumbnail} + + return {"item": item} + + async def _rpc_player_play_pause(self, params: Dict[str, Any]) -> Dict[str, Any]: + play_arg = params.get("play") + if play_arg is True or play_arg == "true": + await self.player.resume() + elif play_arg is False or play_arg == "false": + await self.player.pause() + else: + await self.player.play_pause() + + speed = 1 if self.player.status.state == PlaybackState.PLAYING else 0 + return {"speed": speed} + + async def _rpc_player_stop(self, params: Any) -> str: + await self.player.stop() + return "OK" + + async def _rpc_player_seek(self, params: Dict[str, Any]) -> Dict[str, Any]: + value = params.get("value") + status = self.player.status + + if isinstance(value, (int, float)): + # Percentage + target = (float(value) / 100.0) * status.duration + await self.player.seek(target, absolute=True) + elif isinstance(value, dict): + # Time object + hrs = value.get("hours", 0) + mins = value.get("minutes", 0) + secs = value.get("seconds", 0) + target = hrs * 3600 + mins * 60 + secs + await self.player.seek(target, absolute=True) + elif value == "smallforward": + await self.player.seek(10, absolute=False) + elif value == "smallbackward": + await self.player.seek(-10, absolute=False) + elif value == "bigforward": + await self.player.seek(60, absolute=False) + elif value == "bigbackward": + await self.player.seek(-60, absolute=False) + + return { + "percentage": self.player.status.percentage, + "time": _seconds_to_kodi_time(self.player.status.current_time), + "totaltime": _seconds_to_kodi_time(self.player.status.duration), + } + + async def _rpc_player_open(self, params: Dict[str, Any]) -> str: + item = params.get("item", {}) + file_url = item.get("file") or item.get("path") + if not file_url: + raise ValueError("No file or path provided in Player.Open") + + logger.info("Yatse Player.Open requested URL: %s", file_url) + + metadata: Optional[MediaMetadata] = None + if is_youtube_url(file_url): + logger.info("Detected YouTube media via Yatse share: %s", file_url) + metadata = await fetch_youtube_metadata(file_url) + + await self.player.play_url(file_url, metadata=metadata) + return "OK" + + async def _rpc_playlist_add(self, params: Dict[str, Any]) -> str: + item = params.get("item", {}) + file_url = item.get("file") + if file_url: + await self._rpc_player_open({"item": {"file": file_url}}) + return "OK" + + async def _rpc_playlist_get_items(self, params: Any) -> Dict[str, Any]: + meta = self.player.status.metadata + items = [] + if meta: + items.append({"title": meta.title, "file": meta.url, "duration": int(meta.duration)}) + return {"items": items, "limits": {"start": 0, "total": len(items), "end": len(items)}} + + async def _rpc_gui_show_notification(self, params: Dict[str, Any]) -> str: + title = params.get("title", "") + msg = params.get("message", "") + text = f"{title}: {msg}" if title else msg + await self.player.show_osd_message(text) + return "OK" + + async def _rpc_input_back(self, params: Any) -> str: + await self.player.stop() + return "OK" + + _methods = { + "JSONRPC.Ping": _rpc_ping, + "JSONRPC.Version": _rpc_version, + "JSONRPC.Introspect": _rpc_introspect, + "Application.GetProperties": _rpc_app_get_properties, + "Application.SetVolume": _rpc_app_set_volume, + "Application.SetMute": _rpc_app_set_mute, + "Player.GetActivePlayers": _rpc_player_get_active_players, + "Player.GetProperties": _rpc_player_get_properties, + "Player.GetItem": _rpc_player_get_item, + "Player.PlayPause": _rpc_player_play_pause, + "Player.Stop": _rpc_player_stop, + "Player.Seek": _rpc_player_seek, + "Player.Open": _rpc_player_open, + "Playlist.Add": _rpc_playlist_add, + "Playlist.GetItems": _rpc_playlist_get_items, + "GUI.ShowNotification": _rpc_gui_show_notification, + "Input.Back": _rpc_input_back, + } + + async def start(self) -> None: + if not self.config.enabled: + logger.info("Kodi RPC emulation server is disabled in config") + return + + self._runner = web.AppRunner(self.app) + await self._runner.setup() + self._site = web.TCPSite(self._runner, "0.0.0.0", self.config.http_port) + await self._site.start() + logger.info( + "Kodi JSON-RPC emulation server listening on http://0.0.0.0:%d (Yatse compatible)", + self.config.http_port, + ) + + async def shutdown(self) -> None: + if self._runner: + await self._runner.cleanup() diff --git a/src/remotes/rest_api.py b/src/remotes/rest_api.py new file mode 100644 index 0000000..0008acb --- /dev/null +++ b/src/remotes/rest_api.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional +from aiohttp import web + +from src.config import WebApiConfig +from src.player.models import MediaMetadata, PlaybackState +from src.player.mpv_controller import MpvController +from src.utils.youtube import fetch_youtube_metadata, is_youtube_url + +logger = logging.getLogger(__name__) + +HTML_TEMPLATE = """ + + + + + HTPC Media Remote + + + +
+

HTPC Media Player

+ +
+
+ + + +
+
+ +
+
Nothing Playing
+
-
+ +
+
+
+
+ 00:00 + 00:00 +
+ +
+ + + + +
+ +
+ + +
+
+
+ + + + +""" + + +class RestApiServer: + """Provides HTTP REST API endpoints and web interface for playback control and URL sharing.""" + + def __init__(self, config: WebApiConfig, player: MpvController): + self.config = config + self.player = player + self.app = web.Application() + self._runner: Optional[web.AppRunner] = None + self._site: Optional[web.TCPSite] = None + + self.app.router.add_get("/", self._handle_index) + self.app.router.add_get("/api/status", self._handle_status) + self.app.router.add_post("/api/play", self._handle_play) + self.app.router.add_post("/api/playpause", self._handle_playpause) + self.app.router.add_post("/api/pause", self._handle_pause) + self.app.router.add_post("/api/resume", self._handle_resume) + self.app.router.add_post("/api/stop", self._handle_stop) + self.app.router.add_post("/api/seek", self._handle_seek) + self.app.router.add_post("/api/volume", self._handle_volume) + + async def _handle_index(self, request: web.Request) -> web.Response: + return web.Response(text=HTML_TEMPLATE, content_type="text/html") + + async def _handle_status(self, request: web.Request) -> web.Response: + st = self.player.status + meta = st.metadata + data = { + "state": st.state.value, + "current_time": st.current_time, + "duration": st.duration, + "percentage": st.percentage, + "volume": st.volume, + "muted": st.muted, + "title": meta.title if meta else None, + "artist": meta.artist if meta else None, + "album": meta.album if meta else None, + "thumbnail": meta.thumbnail if meta else None, + "source_type": meta.source_type if meta else None, + } + return web.json_response(data) + + async def _handle_play(self, request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + + url = body.get("url") + if not url: + return web.json_response({"error": "Missing 'url' parameter"}, status=400) + + metadata: Optional[MediaMetadata] = None + if is_youtube_url(url): + logger.info("REST API received YouTube URL: %s", url) + metadata = await fetch_youtube_metadata(url) + else: + logger.info("REST API received media URL: %s", url) + metadata = MediaMetadata(url=url, title=body.get("title") or url, source_type="url") + + start_time = float(body.get("start", 0.0)) + await self.player.play_url(url, metadata=metadata, start_seconds=start_time) + return web.json_response({"status": "playing", "title": metadata.title if metadata else url}) + + async def _handle_playpause(self, request: web.Request) -> web.Response: + await self.player.play_pause() + return web.json_response({"status": self.player.status.state.value}) + + async def _handle_pause(self, request: web.Request) -> web.Response: + await self.player.pause() + return web.json_response({"status": "paused"}) + + async def _handle_resume(self, request: web.Request) -> web.Response: + await self.player.resume() + return web.json_response({"status": "playing"}) + + async def _handle_stop(self, request: web.Request) -> web.Response: + await self.player.stop() + return web.json_response({"status": "stopped"}) + + async def _handle_seek(self, request: web.Request) -> web.Response: + body = await request.json() + seconds = float(body.get("seconds", 0.0)) + absolute = bool(body.get("absolute", False)) + await self.player.seek(seconds, absolute=absolute) + return web.json_response({"current_time": self.player.status.current_time}) + + async def _handle_volume(self, request: web.Request) -> web.Response: + body = await request.json() + vol = int(body.get("volume", 100)) + await self.player.set_volume(vol) + return web.json_response({"volume": self.player.status.volume}) + + async def start(self) -> None: + if not self.config.enabled: + logger.info("REST API server is disabled in config") + return + + self._runner = web.AppRunner(self.app) + await self._runner.setup() + self._site = web.TCPSite(self._runner, self.config.host, self.config.port) + await self._site.start() + logger.info("REST API & Web Remote listening on http://%s:%d", self.config.host, self.config.port) + + async def shutdown(self) -> None: + if self._runner: + await self._runner.cleanup() diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/youtube.py b/src/utils/youtube.py new file mode 100644 index 0000000..53ef8ed --- /dev/null +++ b/src/utils/youtube.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import re +from typing import Optional, Tuple +from urllib.parse import parse_qs, unquote, urlparse + +from src.player.models import MediaMetadata + +logger = logging.getLogger(__name__) + +YOUTUBE_REGEX = re.compile( + r"(?:https?://)?(?:www\.|m\.)?(?:youtube\.com/(?:watch\?.*v=|embed/|v/|shorts/)|youtu\.be/)([\w-]{11})", + re.IGNORECASE, +) + +KODI_PLUGIN_YOUTUBE_REGEX = re.compile( + r"plugin://plugin\.video\.youtube/(?:play/)?\?.*video_id=([\w-]{11})", + re.IGNORECASE, +) + + +def extract_youtube_video_id(url_or_text: str) -> Optional[str]: + """Extract an 11-character YouTube video ID from various URL formats or text.""" + if not url_or_text: + return None + + url_clean = url_or_text.strip() + + # Check for Kodi YouTube plugin URL: plugin://plugin.video.youtube/play/?video_id=... + match = KODI_PLUGIN_YOUTUBE_REGEX.search(url_clean) + if match: + return match.group(1) + + # Check for standard YouTube URL + match = YOUTUBE_REGEX.search(url_clean) + if match: + return match.group(1) + + # Check query string params (e.g., if inside a redirect or wrapped URL) + try: + parsed = urlparse(url_clean) + qs = parse_qs(parsed.query) + if "v" in qs and qs["v"]: + vid = qs["v"][0] + if len(vid) == 11: + return vid + if "video_id" in qs and qs["video_id"]: + vid = qs["video_id"][0] + if len(vid) == 11: + return vid + except Exception: + pass + + return None + + +def is_youtube_url(url_or_text: str) -> bool: + return extract_youtube_video_id(url_or_text) is not None + + +def get_canonical_youtube_url(video_id: str) -> str: + return f"https://www.youtube.com/watch?v={video_id}" + + +async def fetch_youtube_metadata(video_url_or_id: str) -> Optional[MediaMetadata]: + """Fetch video title, channel, duration, and thumbnail using yt-dlp.""" + video_id = extract_youtube_video_id(video_url_or_id) + canonical_url = get_canonical_youtube_url(video_id) if video_id else video_url_or_id + + try: + proc = await asyncio.create_subprocess_exec( + "yt-dlp", + "--dump-json", + "--no-playlist", + "--skip-download", + canonical_url, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8.0) + + if proc.returncode != 0: + logger.warning("yt-dlp returned code %d: %s", proc.returncode, stderr.decode().strip()) + return None + + info = json.loads(stdout.decode("utf-8")) + title = info.get("title", "YouTube Video") + uploader = info.get("uploader") or info.get("channel") or "YouTube" + duration = float(info.get("duration") or 0.0) + thumbnail = info.get("thumbnail") + + return MediaMetadata( + url=canonical_url, + title=title, + artist=uploader, + album="YouTube", + duration=duration, + thumbnail=thumbnail, + source_type="youtube", + extra={"youtube_id": video_id, "view_count": info.get("view_count")}, + ) + except asyncio.TimeoutError: + logger.warning("Timeout fetching metadata with yt-dlp for %s", canonical_url) + except Exception as e: + logger.warning("Error fetching metadata via yt-dlp: %s", e) + + # Fallback if yt-dlp is unavailable or times out + return MediaMetadata( + url=canonical_url, + title=f"YouTube Video ({video_id})" if video_id else "YouTube Video", + artist="YouTube", + source_type="youtube", + extra={"youtube_id": video_id}, + ) diff --git a/systemd/jellyfin-client.service b/systemd/jellyfin-client.service new file mode 100644 index 0000000..94b486b --- /dev/null +++ b/systemd/jellyfin-client.service @@ -0,0 +1,21 @@ +[Unit] +Description=Jellyfin HTPC Client & YouTube Receiver +After=network-online.target sound.target graphical-session.target +Wants=network-online.target + +[Service] +Type=simple +Environment=DISPLAY=:0 +Environment=WAYLAND_DISPLAY=wayland-0 +# Pass path to config if not in default location +# Environment=JELLYFIN_CLIENT_CONFIG=/etc/jellyfin-client/config.yaml +ExecStart=/opt/jellyfin-client/.venv/bin/python3 -m src.main +WorkingDirectory=/opt/jellyfin-client +Restart=always +RestartSec=5s + +# Process security and limits +LimitNOFILE=65536 + +[Install] +WantedBy=default.target diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3ca7443 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,38 @@ +import tempfile +from pathlib import Path +from src.config import AppConfig + + +def test_default_config(): + cfg = AppConfig.load() + assert cfg.jellyfin.client_name == "Jellyfin HTPC Client" + assert cfg.player.hwdec == "auto" + assert cfg.kodi_rpc.http_port == 8080 + assert cfg.web_api.port == 8097 + + +def test_custom_yaml_config(): + yaml_content = """ +jellyfin: + server_url: "https://media.lan" + username: "myuser" + password: "mypassword" +player: + hwdec: "vaapi" + fullscreen: false +kodi_rpc: + http_port: 8088 +""" + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f: + f.write(yaml_content) + f_path = f.name + + try: + cfg = AppConfig.load(f_path) + assert cfg.jellyfin.server_url == "https://media.lan" + assert cfg.jellyfin.username == "myuser" + assert cfg.player.hwdec == "vaapi" + assert cfg.player.fullscreen is False + assert cfg.kodi_rpc.http_port == 8088 + finally: + Path(f_path).unlink() diff --git a/tests/test_kodi_rpc.py b/tests/test_kodi_rpc.py new file mode 100644 index 0000000..a1d8883 --- /dev/null +++ b/tests/test_kodi_rpc.py @@ -0,0 +1,83 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +from src.config import KodiRpcConfig +from src.player.models import MediaMetadata, PlaybackState, PlayerStatus +from src.player.mpv_controller import MpvController +from src.remotes.kodi_rpc import KodiRpcServer + + +@pytest.fixture +def mock_player(): + player = MagicMock(spec=MpvController) + player.status = PlayerStatus( + state=PlaybackState.PLAYING, + current_time=65.5, + duration=300.0, + volume=85, + muted=False, + metadata=MediaMetadata( + url="https://www.youtube.com/watch?v=dQw4w9WgXcQ", + title="Never Gonna Give You Up", + artist="Rick Astley", + duration=300.0, + ) + ) + player.play_url = AsyncMock() + player.pause = AsyncMock() + player.resume = AsyncMock() + player.play_pause = AsyncMock() + player.stop = AsyncMock() + player.seek = AsyncMock() + player.set_volume = AsyncMock() + player.set_mute = AsyncMock() + player.toggle_mute = AsyncMock() + player.add_listener = MagicMock() + return player + + +@pytest.mark.asyncio +async def test_kodi_rpc_ping(mock_player): + rpc = KodiRpcServer(KodiRpcConfig(), mock_player) + resp = await rpc._dispatch({"jsonrpc": "2.0", "method": "JSONRPC.Ping", "id": 1}) + assert resp["result"] == "pong" + assert resp["id"] == 1 + + +@pytest.mark.asyncio +async def test_kodi_rpc_active_players(mock_player): + rpc = KodiRpcServer(KodiRpcConfig(), mock_player) + resp = await rpc._dispatch({"jsonrpc": "2.0", "method": "Player.GetActivePlayers", "id": 2}) + assert len(resp["result"]) == 1 + assert resp["result"][0]["playerid"] == 1 + assert resp["result"][0]["type"] == "video" + + +@pytest.mark.asyncio +async def test_kodi_rpc_player_properties(mock_player): + rpc = KodiRpcServer(KodiRpcConfig(), mock_player) + resp = await rpc._dispatch({ + "jsonrpc": "2.0", + "method": "Player.GetProperties", + "params": {"playerid": 1, "properties": ["percentage", "time", "totaltime", "speed"]}, + "id": 3 + }) + result = resp["result"] + assert result["speed"] == 1 + assert result["time"]["minutes"] == 1 + assert result["time"]["seconds"] == 5 + assert result["totaltime"]["minutes"] == 5 + + +@pytest.mark.asyncio +async def test_kodi_rpc_open_youtube(mock_player): + rpc = KodiRpcServer(KodiRpcConfig(), mock_player) + resp = await rpc._dispatch({ + "jsonrpc": "2.0", + "method": "Player.Open", + "params": {"item": {"file": "plugin://plugin.video.youtube/play/?video_id=dQw4w9WgXcQ"}}, + "id": 4 + }) + assert resp["result"] == "OK" + assert mock_player.play_url.called + args, kwargs = mock_player.play_url.call_args + assert "dQw4w9WgXcQ" in args[0] diff --git a/tests/test_youtube.py b/tests/test_youtube.py new file mode 100644 index 0000000..aa4687b --- /dev/null +++ b/tests/test_youtube.py @@ -0,0 +1,42 @@ +import pytest +from src.utils.youtube import extract_youtube_video_id, is_youtube_url, get_canonical_youtube_url + + +def test_extract_standard_watch_url(): + url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + assert extract_youtube_video_id(url) == "dQw4w9WgXcQ" + assert is_youtube_url(url) is True + + +def test_extract_short_url(): + url = "https://youtu.be/dQw4w9WgXcQ?si=abcdef123456" + assert extract_youtube_video_id(url) == "dQw4w9WgXcQ" + assert is_youtube_url(url) is True + + +def test_extract_shorts_url(): + url = "https://youtube.com/shorts/dQw4w9WgXcQ" + assert extract_youtube_video_id(url) == "dQw4w9WgXcQ" + assert is_youtube_url(url) is True + + +def test_extract_kodi_plugin_url(): + url = "plugin://plugin.video.youtube/play/?video_id=dQw4w9WgXcQ" + assert extract_youtube_video_id(url) == "dQw4w9WgXcQ" + assert is_youtube_url(url) is True + + +def test_extract_embed_url(): + url = "https://www.youtube.com/embed/dQw4w9WgXcQ" + assert extract_youtube_video_id(url) == "dQw4w9WgXcQ" + assert is_youtube_url(url) is True + + +def test_non_youtube_url(): + url = "https://example.com/video.mp4" + assert extract_youtube_video_id(url) is None + assert is_youtube_url(url) is False + + +def test_canonical_url(): + assert get_canonical_youtube_url("dQw4w9WgXcQ") == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"