feat(client): initial implementation of jellyfin htpc client with yatse remote and youtube playback

This commit is contained in:
bart
2026-11-02 21:33:42 +01:00
parent 8d6270038f
commit f66f874bae
23 changed files with 2246 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.venv/
__pycache__/
*.pyc
config.yaml
/tmp/mpv-*.sock
dist/
build/
*.egg-info/
.pytest_cache/
+171
View File
@@ -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://<htpc-ip>: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://<htpc-ip>: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
```
+49
View File
@@ -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
Executable
+66
View File
@@ -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 "=========================================================="
+30
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
aiohttp>=3.9.0
pyyaml>=6.0.1
View File
+139
View File
@@ -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
View File
+335
View File
@@ -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()
+84
View File
@@ -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()
View File
+59
View File
@@ -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)
+306
View File
@@ -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()
View File
+352
View File
@@ -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()
+343
View File
@@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTPC Media Remote</title>
<style>
:root {
--bg: #0f172a;
--card-bg: #1e293b;
--accent: #38bdf8;
--text: #f8fafc;
--text-muted: #94a3b8;
--button-bg: #334155;
}
body {
margin: 0;
padding: 1.5rem;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
box-sizing: border-box;
}
.container {
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.card {
background: var(--card-bg);
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
h1 {
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
color: var(--accent);
text-align: center;
}
.title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
}
.artist {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0 0 1rem 0;
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--button-bg);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.3s;
}
.time-info {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 0.5rem;
}
button {
background: var(--button-bg);
color: var(--text);
border: none;
border-radius: 0.5rem;
padding: 0.75rem 1.25rem;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:active {
background: var(--accent);
color: #000;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
input[type="text"] {
width: 100%;
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid var(--button-bg);
background: #0b1120;
color: var(--text);
font-size: 1rem;
box-sizing: border-box;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.share-btn {
background: var(--accent);
color: #0b1120;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<h1>HTPC Media Player</h1>
<div class="card">
<div class="input-group">
<label for="urlInput" style="font-size: 0.9rem; color: var(--text-muted);">Share YouTube / Media URL:</label>
<input type="text" id="urlInput" placeholder="Paste YouTube link here..." />
<button class="share-btn" onclick="playUrl()">Play on TV</button>
</div>
</div>
<div class="card">
<div class="title" id="mediaTitle">Nothing Playing</div>
<div class="artist" id="mediaArtist">-</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="time-info">
<span id="currentTime">00:00</span>
<span id="totalTime">00:00</span>
</div>
<div class="controls">
<button onclick="control('seek', {seconds: -15})">-15s</button>
<button onclick="control('playpause')">⏯ Play/Pause</button>
<button onclick="control('stop')">⏹ Stop</button>
<button onclick="control('seek', {seconds: 15})">+15s</button>
</div>
<div style="margin-top: 1.25rem;">
<label style="font-size: 0.85rem; color: var(--text-muted);">Volume: <span id="volLabel">100%</span></label>
<input type="range" id="volRange" min="0" max="100" value="100" onchange="setVolume(this.value)" />
</div>
</div>
</div>
<script>
function fmtTime(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m < 10 ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s}`;
}
async function updateStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('mediaTitle').innerText = data.title || (data.state === 'idle' ? 'Ready to Play' : 'Unknown Media');
document.getElementById('mediaArtist').innerText = data.artist || (data.source_type ? `Source: ${data.source_type}` : '-');
document.getElementById('progressFill').style.width = `${data.percentage || 0}%`;
document.getElementById('currentTime').innerText = fmtTime(data.current_time);
document.getElementById('totalTime').innerText = fmtTime(data.duration);
document.getElementById('volRange').value = data.volume;
document.getElementById('volLabel').innerText = `${data.volume}%`;
} catch (e) {
console.error(e);
}
}
async function playUrl() {
const input = document.getElementById('urlInput');
const url = input.value.trim();
if (!url) return;
await fetch('/api/play', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url})
});
input.value = '';
setTimeout(updateStatus, 500);
}
async function control(action, body = {}) {
await fetch(`/api/${action}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
setTimeout(updateStatus, 300);
}
async function setVolume(vol) {
await fetch('/api/volume', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({volume: parseInt(vol)})
});
}
setInterval(updateStatus, 2000);
updateStatus();
</script>
</body>
</html>
"""
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()
View File
+117
View File
@@ -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},
)
+21
View File
@@ -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
+38
View File
@@ -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()
+83
View File
@@ -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]
+42
View File
@@ -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"