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