feat(client): initial implementation of jellyfin htpc client with yatse remote and youtube playback
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
config.yaml
|
||||
/tmp/mpv-*.sock
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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 "=========================================================="
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
aiohttp>=3.9.0
|
||||
pyyaml>=6.0.1
|
||||
+139
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user