Compare commits

..
3 Commits
Author SHA1 Message Date
bart 4f2cdbd817 feat(kiosk): integrate libmpv, full Yatse JSON-RPC & EventServer, and YouTube playback 2026-11-04 23:26:08 +01:00
bart d77cdfedab new 2026-11-04 19:41:25 +01:00
bart 99cbc00357 eerste versie 2026-11-03 22:21:03 +01:00
37 changed files with 12960 additions and 2163 deletions
+1
View File
@@ -7,3 +7,4 @@ dist/
build/ build/
*.egg-info/ *.egg-info/
.pytest_cache/ .pytest_cache/
jellyfin-kiosk-rs/target
+42
View File
@@ -0,0 +1,42 @@
# AI Agent & Contributor Guidelines: Rust Dependency & Feature Minimization
## Core Principle: Minimal Features & Fast Compilation
To ensure fast compilation times and prevent binary and dependency bloat on development and embedded/kiosk hardware:
1. **Always use `default-features = false` by default** on all external crates unless explicitly proven unnecessary.
2. **Never enable meta-features or "all" feature flags**:
- ❌ Never use `tokio = { features = ["full"] }`.
- ❌ Never use `egui_extras = { features = ["all_loaders"] }`.
- ❌ Never enable default formats in the `image` crate (pulls in `rayon`, `exr`, `tiff`, `dds`, `hdr`, etc.).
3. **Avoid heavy rasterization / rendering stacks**:
- Do **NOT** enable `svg` or `resvg` in `egui_extras`. The `resvg`/`usvg`/`tiny-skia` dependency tree adds dozens of crates and heavily impacts compilation time.
- Do **NOT** enable `wgpu` in `eframe`. Use `glow` (OpenGL) only.
4. **Iterate with `cargo check`**:
- Always run `cargo check` instead of `cargo build` during development cycles to test type checking and borrow checking in seconds.
---
## Approved Dependency Feature Matrix
When editing or updating `jellyfin-kiosk-rs/Cargo.toml`, adhere strictly to the following configurations:
| Crate | Approved Configuration | Rationale / Prohibited Features |
| :--- | :--- | :--- |
| `eframe` | `default-features = false, features = ["default_fonts", "glow", "wayland", "x11"]` | Keep `wgpu` disabled. Only build necessary windowing and OpenGL backends. |
| `egui_extras` | `default-features = false, features = ["http", "image"]` | Only needed for fetching/rendering HTTP posters & backdrops. **Never enable `all_loaders` or `svg`**. |
| `image` | `default-features = false, features = ["jpeg", "png"]` | Jellyfin images are JPEGs/PNGs. Disabling default features avoids `rayon` and heavy decoders. |
| `tokio` | `default-features = false, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"]` | Never use `"full"`. `fs`, `process`, and `signal` from Tokio are not used (standard library equivalents are used). |
| `axum` | `default-features = false, features = ["http1", "json", "tokio"]` | The REST API only handles JSON POST/GET requests and Tokio serving. No `form` or `query` parsers needed. |
| `reqwest` | `default-features = false, features = ["json", "default-tls", "http2"]` | Minimal HTTP client features required for communication with the Jellyfin server. |
| `tracing-subscriber`| `features = ["env-filter"]` | Standard lightweight log filtering and formatting. |
| `rumqttc` | `default-features = false` | MQTT client for Home Assistant discovery and state reporting. Avoids rustls/aws-lc-rs crypto bloat. |
---
## Verification Before Committing
Whenever dependencies or features are changed:
1. Run `cargo check` in `jellyfin-kiosk-rs` to ensure compilation without warnings/errors.
2. Check dependency count in `Cargo.lock` (`grep "name = " Cargo.lock | wc -l`) to confirm no unexpected transitive bloat was introduced.
3. Verify that `cargo build` links successfully.
+18
View File
@@ -47,3 +47,21 @@ youtube:
max_height: 1080 max_height: 1080
# Automatically extract metadata (title, uploader) using yt-dlp before playback # Automatically extract metadata (title, uploader) using yt-dlp before playback
fetch_metadata: true fetch_metadata: true
# Google OAuth 2.0 Client ID (Google Cloud Console -> APIs -> Credentials, type: TVs and Limited Input / Desktop)
client_id: ""
client_secret: ""
# Pad voor het opslaan van de ingelogde YouTube sessie
token_path: "youtube_token.json"
mqtt:
# Enable MQTT client and Home Assistant auto-discovery
enabled: false
# MQTT broker host/IP
host: "localhost"
port: 1883
username: ""
password: ""
# Home Assistant MQTT discovery prefix (default: homeassistant)
discovery_prefix: "homeassistant"
# Base topic prefix for state updates and commands
topic_prefix: "jellyfin-kiosk"
+8 -10
View File
@@ -5,14 +5,11 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: jellyfin-client container_name: jellyfin-client
restart: unless-stopped restart: unless-stopped
ports: network_mode: host
- "8080:8080" # Kodi JSON-RPC (Yatse)
- "9090:9090" # Kodi JSON-RPC WebSocket & TCP Socket
- "9777:9777/udp" # Kodi EventServer (UDP)
- "8097:8097" # Web Remote & REST API
environment: environment:
- DISPLAY=${DISPLAY:-:0} - DISPLAY=${DISPLAY:-:0}
- MPV_VO=${MPV_VO:-null} # 'null' ensures stable background test; set to 'gpu' with xhost for video window - MPV_VO=${MPV_VO:-gpu} # 'gpu' for video window, 'null' for headless
- MPV_HWDEC=${MPV_HWDEC:-auto} # 'auto', 'vaapi', 'nvdec', or 'no' - MPV_HWDEC=${MPV_HWDEC:-auto} # 'auto', 'vaapi', 'nvdec', or 'no'
- MPV_FULLSCREEN=${MPV_FULLSCREEN:-false} # Windowed by default for desktop testing - MPV_FULLSCREEN=${MPV_FULLSCREEN:-false} # Windowed by default for desktop testing
volumes: volumes:
@@ -20,7 +17,8 @@ services:
- ./config.yaml:/app/config.yaml:ro - ./config.yaml:/app/config.yaml:ro
# X11 Display passthrough to show video window on host screen # X11 Display passthrough to show video window on host screen
- /tmp/.X11-unix:/tmp/.X11-unix:ro - /tmp/.X11-unix:/tmp/.X11-unix:ro
# Optional hardware acceleration devices (uncomment if available on Linux host) # Hardware acceleration devices
# devices: devices:
# - /dev/dri:/dev/dri - /dev/dri:/dev/dri
# - /dev/snd:/dev/snd - /dev/snd:/dev/snd
+1
View File
@@ -0,0 +1 @@
../AGENTS.md
+4201
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "jellyfin-kiosk-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
eframe = { version = "0.29", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
egui_extras = { version = "0.29", default-features = false, features = ["http", "image"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
tokio = { version = "1.40", default-features = false, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
axum = { version = "0.7", default-features = false, features = ["http1", "json", "tokio"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls", "http2"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
raw-window-handle = "0.6"
rumqttc = { version = "0.25", default-features = false }
libc = "0.2"
libmpv-sys = { version = "3.1", default-features = false }
+31
View File
@@ -0,0 +1,31 @@
use std::path::Path;
fn main() {
let out_dir = std::env::var("OUT_DIR").unwrap();
let lib_dir = Path::new(&out_dir).join("libmpv_link");
let _ = std::fs::create_dir_all(&lib_dir);
let candidates = [
"/lib/x86_64-linux-gnu/libmpv.so.2",
"/usr/lib/x86_64-linux-gnu/libmpv.so.2",
"/usr/lib64/libmpv.so.2",
"/lib/x86_64-linux-gnu/libmpv.so.1",
"/usr/lib/x86_64-linux-gnu/libmpv.so.1",
"/usr/lib/libmpv.so.2",
"/usr/lib/libmpv.so.1",
];
let dest = lib_dir.join("libmpv.so");
for candidate in &candidates {
if Path::new(candidate).exists() {
let _ = std::fs::remove_file(&dest);
#[cfg(unix)]
let _ = std::os::unix::fs::symlink(candidate, &dest);
println!("cargo:rustc-link-search=native={}", lib_dir.display());
break;
}
}
println!("cargo:rustc-link-lib=mpv");
println!("cargo:rerun-if-changed=build.rs");
}
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub jellyfin: JellyfinConfig,
#[serde(default)]
pub player: PlayerConfig,
#[serde(default)]
pub kodi_rpc: KodiRpcConfig,
#[serde(default)]
pub mqtt: MqttConfig,
#[serde(default)]
pub youtube: YouTubeConfig,
}
#[derive(Debug, Clone, Deserialize)]
pub struct JellyfinConfig {
pub server_url: String,
pub username: String,
pub password: Option<String>,
#[serde(default = "default_client_name")]
pub client_name: String,
#[serde(default = "default_device_name")]
pub device_name: String,
#[serde(default = "default_device_id")]
pub device_id: String,
}
fn default_client_name() -> String {
"Jellyfin HTPC Kiosk".to_string()
}
fn default_device_name() -> String {
"HTPC Kiosk".to_string()
}
fn default_device_id() -> String {
"htpc-kiosk-01".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct PlayerConfig {
#[serde(default = "default_mpv_path")]
pub mpv_path: String,
#[serde(default = "default_ipc_socket")]
pub ipc_socket: String,
#[serde(default = "default_hwdec")]
pub hwdec: String,
#[serde(default = "default_vo")]
pub vo: String,
#[serde(default = "default_fullscreen")]
pub fullscreen: bool,
}
fn default_mpv_path() -> String {
"mpv".to_string()
}
fn default_ipc_socket() -> String {
"/tmp/mpv-kiosk.sock".to_string()
}
fn default_hwdec() -> String {
"auto".to_string()
}
fn default_vo() -> String {
"gpu".to_string()
}
fn default_fullscreen() -> bool {
true
}
impl Default for PlayerConfig {
fn default() -> Self {
Self {
mpv_path: default_mpv_path(),
ipc_socket: default_ipc_socket(),
hwdec: default_hwdec(),
vo: default_vo(),
fullscreen: default_fullscreen(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct KodiRpcConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_http_port")]
pub http_port: u16,
#[serde(default = "default_ws_port")]
pub ws_port: u16,
}
fn default_true() -> bool {
true
}
fn default_http_port() -> u16 {
8080
}
fn default_ws_port() -> u16 {
9090
}
impl Default for KodiRpcConfig {
fn default() -> Self {
Self {
enabled: default_true(),
http_port: default_http_port(),
ws_port: default_ws_port(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MqttConfig {
#[serde(default = "default_false")]
pub enabled: bool,
#[serde(default = "default_mqtt_host")]
pub host: String,
#[serde(default = "default_mqtt_port")]
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default = "default_discovery_prefix")]
pub discovery_prefix: String,
#[serde(default = "default_topic_prefix")]
pub topic_prefix: String,
pub client_id: Option<String>,
}
fn default_false() -> bool {
false
}
fn default_mqtt_host() -> String {
"localhost".to_string()
}
fn default_mqtt_port() -> u16 {
1883
}
fn default_discovery_prefix() -> String {
"homeassistant".to_string()
}
fn default_topic_prefix() -> String {
"jellyfin-kiosk".to_string()
}
impl Default for MqttConfig {
fn default() -> Self {
Self {
enabled: default_false(),
host: default_mqtt_host(),
port: default_mqtt_port(),
username: None,
password: None,
discovery_prefix: default_discovery_prefix(),
topic_prefix: default_topic_prefix(),
client_id: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct YouTubeConfig {
#[serde(default = "default_max_height")]
pub max_height: u32,
#[serde(default = "default_true")]
pub fetch_metadata: bool,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default = "default_youtube_token_path")]
pub token_path: String,
}
fn default_max_height() -> u32 {
1080
}
fn default_youtube_token_path() -> String {
"youtube_token.json".to_string()
}
impl Default for YouTubeConfig {
fn default() -> Self {
Self {
max_height: default_max_height(),
fetch_metadata: default_true(),
client_id: String::new(),
client_secret: None,
token_path: default_youtube_token_path(),
}
}
}
impl AppConfig {
pub fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let candidates = [
PathBuf::from("config.yaml"),
PathBuf::from("../config.yaml"),
PathBuf::from("/etc/jellyfin-client/config.yaml"),
PathBuf::from("config.example.yaml"),
PathBuf::from("../config.example.yaml"),
];
for path in &candidates {
if path.exists() {
let content = std::fs::read_to_string(path)?;
let cfg: AppConfig = serde_yaml::from_str(&content)?;
tracing::info!("Loaded config from {}", path.display());
return Ok(cfg);
}
}
Err("No config.yaml found in search paths".into())
}
}
+757
View File
@@ -0,0 +1,757 @@
use std::sync::Arc;
use tokio::net::UdpSocket;
use crate::player::PlayerController;
use crate::yatse::{RemoteNavAction, RemoteNavSender};
// Official Kodi EventServer Packet Types
pub const PT_HELO: u16 = 0x01;
pub const PT_BYE: u16 = 0x02;
pub const PT_BUTTON: u16 = 0x03;
pub const _PT_MOUSE: u16 = 0x04;
pub const PT_PING: u16 = 0x05;
pub const _PT_BROADCAST: u16 = 0x06;
pub const _PT_NOTIFICATION: u16 = 0x07;
pub const _PT_BLOB: u16 = 0x08;
pub const _PT_LOG: u16 = 0x09;
pub const PT_ACTION: u16 = 0x0A; // 10 in decimal!
// Button packet flags
pub const _PTB_USE_NAME: u16 = 0x01;
pub const PTB_DOWN: u16 = 0x02;
pub const PTB_UP: u16 = 0x04;
pub const HEADER_SIZE: usize = 32;
pub struct KodiEventServer {
port: u16,
player: Arc<PlayerController>,
nav_tx: RemoteNavSender,
}
impl KodiEventServer {
pub fn new(port: u16, player: Arc<PlayerController>, nav_tx: impl Into<RemoteNavSender>) -> Self {
Self { port, player, nav_tx: nav_tx.into() }
}
pub async fn start(&self) -> Result<u16, std::io::Error> {
let addr = format!("0.0.0.0:{}", self.port);
let socket = match UdpSocket::bind(&addr).await {
Ok(s) => {
let bound_port = s.local_addr()?.port();
tracing::info!("Kodi EventServer listening on UDP 0.0.0.0:{}", bound_port);
(Arc::new(s), bound_port)
}
Err(e) => {
tracing::warn!("Failed to bind Kodi EventServer on UDP {}: {}", addr, e);
return Err(e);
}
};
let (socket, bound_port) = socket;
let player = self.player.clone();
let nav_tx = self.nav_tx.clone();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
loop {
let (len, peer) = match socket.recv_from(&mut buf).await {
Ok(res) => res,
Err(e) => {
tracing::warn!("EventServer recv error: {}", e);
continue;
}
};
if len < HEADER_SIZE {
continue;
}
if &buf[0..4] != b"XBMC" {
continue;
}
let majver = buf[4];
let minver = buf[5];
let pkttype = u16::from_be_bytes([buf[6], buf[7]]);
let _seq = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]);
let _maxseq = u32::from_be_bytes([buf[12], buf[13], buf[14], buf[15]]);
let paylen = u16::from_be_bytes([buf[16], buf[17]]) as usize;
let client_token = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
let payload = if len >= HEADER_SIZE + paylen {
&buf[HEADER_SIZE..HEADER_SIZE + paylen]
} else {
&buf[HEADER_SIZE..len]
};
tracing::info!(
"EventServer packet from {}: ver={}.{}, type=0x{:02X}, paylen={}, token={}",
peer, majver, minver, pkttype, paylen, client_token
);
match pkttype {
PT_HELO | PT_PING => {
let dev_name = payload
.split(|&b| b == 0)
.next()
.map(|s| String::from_utf8_lossy(s).to_string())
.unwrap_or_default();
tracing::info!("EventServer HELO/PING from {} (device: '{}')", peer, dev_name);
// Echo ping ack back to client (Yatse expects this handshake)
let mut ack = [0u8; HEADER_SIZE];
ack[0..4].copy_from_slice(b"XBMC");
ack[4] = 2; // majver
ack[5] = 0; // minver
ack[6..8].copy_from_slice(&PT_PING.to_be_bytes());
ack[8..12].copy_from_slice(&1u32.to_be_bytes());
ack[12..16].copy_from_slice(&1u32.to_be_bytes());
ack[16..18].copy_from_slice(&0u16.to_be_bytes());
ack[18..22].copy_from_slice(&client_token.to_be_bytes());
let _ = socket.send_to(&ack, peer).await;
tracing::info!("EventServer sent PING-ACK to {}", peer);
}
PT_BUTTON => {
if payload.len() >= 6 {
// Format: code(2B) + flags(2B) + amount(2B) + map_name(null-term) + button_name(null-term)
let bcode = u16::from_be_bytes([payload[0], payload[1]]);
let flags = u16::from_be_bytes([payload[2], payload[3]]);
let _amount = u16::from_be_bytes([payload[4], payload[5]]);
let is_down = (flags & PTB_DOWN) != 0 || (flags & PTB_UP) == 0;
if !is_down {
// Ignore key release
continue;
}
let str_data = &payload[6..];
let mut parts = str_data.split(|&b| b == 0);
let map_name = parts
.next()
.map(|s| String::from_utf8_lossy(s).to_string())
.unwrap_or_default();
let btn_name = parts
.next()
.map(|s| String::from_utf8_lossy(s).to_lowercase())
.unwrap_or_default();
tracing::info!(
"EventServer BUTTON: code=0x{:04X}, flags=0x{:04X}, map='{}', btn='{}'",
bcode, flags, map_name, btn_name
);
dispatch_button_or_code(&btn_name, bcode, &nav_tx, &player);
}
}
PT_ACTION => {
if payload.len() >= 2 {
// Format: action_type(1B) + action_string(null-terminated string)
let action_type = payload[0];
let action_str = String::from_utf8_lossy(&payload[1..])
.trim_matches('\0')
.trim()
.to_string();
tracing::info!("EventServer ACTION: type={}, action='{}'", action_type, action_str);
dispatch_action(&action_str, &nav_tx, &player);
}
}
PT_BYE => {
tracing::info!("EventServer client BYE from {}", peer);
}
_ => {
tracing::debug!("EventServer unhandled packet type: 0x{:02X}", pkttype);
}
}
}
});
Ok(bound_port)
}
}
#[allow(dead_code)]
pub fn build_packet_header(pkttype: u16, paylen: u16, client_token: u32) -> [u8; HEADER_SIZE] {
let mut header = [0u8; HEADER_SIZE];
header[0..4].copy_from_slice(b"XBMC");
header[4] = 2; // majver
header[5] = 0; // minver
header[6..8].copy_from_slice(&pkttype.to_be_bytes());
header[8..12].copy_from_slice(&1u32.to_be_bytes());
header[12..16].copy_from_slice(&1u32.to_be_bytes());
header[16..18].copy_from_slice(&paylen.to_be_bytes());
header[18..22].copy_from_slice(&client_token.to_be_bytes());
header
}
#[allow(dead_code)]
pub fn build_helo_packet(device_name: &str, token: u32) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(device_name.as_bytes());
payload.push(0);
// icon_type (0 = none), port (0), reserved (8)
payload.push(0);
payload.extend_from_slice(&0u16.to_be_bytes());
payload.extend_from_slice(&[0u8; 8]);
let header = build_packet_header(PT_HELO, payload.len() as u16, token);
let mut packet = header.to_vec();
packet.extend_from_slice(&payload);
packet
}
#[allow(dead_code)]
pub fn build_ping_packet(token: u32) -> Vec<u8> {
let header = build_packet_header(PT_PING, 0, token);
header.to_vec()
}
#[allow(dead_code)]
pub fn build_button_packet(
code: u16,
flags: u16,
map_name: &str,
button_name: &str,
token: u32,
) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&code.to_be_bytes());
payload.extend_from_slice(&flags.to_be_bytes());
payload.extend_from_slice(&0u16.to_be_bytes());
payload.extend_from_slice(map_name.as_bytes());
payload.push(0);
payload.extend_from_slice(button_name.as_bytes());
payload.push(0);
let header = build_packet_header(PT_BUTTON, payload.len() as u16, token);
let mut packet = header.to_vec();
packet.extend_from_slice(&payload);
packet
}
#[allow(dead_code)]
pub fn build_action_packet(action_str: &str, token: u32) -> Vec<u8> {
let mut payload = Vec::new();
payload.push(1); // ACTION_EXECBUILTIN
payload.extend_from_slice(action_str.as_bytes());
payload.push(0);
let header = build_packet_header(PT_ACTION, payload.len() as u16, token);
let mut packet = header.to_vec();
packet.extend_from_slice(&payload);
packet
}
pub fn dispatch_button_or_code(
btn: &str,
code: u16,
nav_tx: &RemoteNavSender,
player: &Arc<PlayerController>,
) {
let lower = btn.to_lowercase();
match lower.as_str() {
"up" | "bigstepforward" | "cursorup" => {
let _ = nav_tx.send(RemoteNavAction::Up);
}
"down" | "bigstepback" | "cursordown" => {
let _ = nav_tx.send(RemoteNavAction::Down);
}
"left" | "stepback" | "cursorleft" => {
let _ = nav_tx.send(RemoteNavAction::Left);
}
"right" | "stepforward" | "cursorright" => {
let _ = nav_tx.send(RemoteNavAction::Right);
}
"select" | "enter" | "ok" => {
let _ = nav_tx.send(RemoteNavAction::Select);
}
"back" | "parentdir" | "previousmenu" | "escape" => {
let _ = nav_tx.send(RemoteNavAction::Back);
}
"title" | "topmenu" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"menu" | "contextmenu" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"togglefullscreen" | "fullscreen" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"osd" | "showosd" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"subtitles" | "nextsubtitle" => {
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
}
"audionextlanguage" | "nextaudio" | "cycleaudio" => {
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
}
"play" | "pause" | "playpause" => {
let p = player.clone();
tokio::spawn(async move { p.play_pause().await; });
}
"stop" => {
let _ = nav_tx.send(RemoteNavAction::Stop);
}
"volumeup" | "vol_up" => {
let p = player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
"volumedown" | "vol_down" => {
let p = player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
"mute" | "volumemute" | "togglemute" => {
let p = player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
"info" | "showcodecinfo" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
_ => {
// Fallback to raw keycode if button string was not matched
match code {
0xF010 | 0xF050 | 273 => { let _ = nav_tx.send(RemoteNavAction::Up); }
0xF011 | 0xF051 | 274 => { let _ = nav_tx.send(RemoteNavAction::Down); }
0xF012 | 0xF052 | 276 => { let _ = nav_tx.send(RemoteNavAction::Left); }
0xF013 | 0xF053 | 275 => { let _ = nav_tx.send(RemoteNavAction::Right); }
0xF001 | 0xF014 | 13 => { let _ = nav_tx.send(RemoteNavAction::Select); }
0xF016 | 27 | 8 => { let _ = nav_tx.send(RemoteNavAction::Back); }
0xF020 | 0xF022 => {
let p = player.clone();
tokio::spawn(async move { p.play_pause().await; });
}
0xF021 => {
let _ = nav_tx.send(RemoteNavAction::Stop);
}
0xF02B => {
let p = player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
0xF02C => {
let p = player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
0xF02D => {
let p = player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
_ => {}
}
}
}
}
pub fn normalize_action(action: &str) -> String {
let mut lower = action.trim().to_lowercase();
if lower.starts_with("action(") && lower.ends_with(')') {
lower = lower[7..lower.len() - 1].trim().to_string();
}
if lower.ends_with("()") {
lower = lower[..lower.len() - 2].trim().to_string();
}
lower
}
pub fn dispatch_action(
action: &str,
nav_tx: &RemoteNavSender,
player: &Arc<PlayerController>,
) {
let lower = action.trim().to_lowercase();
// Check window activation first
if lower.starts_with("activatewindow") {
if lower.contains("tvshowtitles") || lower.contains("tvshows") || lower.contains("shows") || lower.contains("series") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(3));
} else if lower.contains("movietitles") || lower.contains("videos") || lower.contains("movies") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(2));
} else if lower.contains("music") || lower.contains("songs") || lower.contains("artists") || lower.contains("albums") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(4));
} else if lower.contains("pictures") || lower.contains("photos") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(5));
} else if lower.contains("livetv") || lower.contains("channels") || lower.contains("tv") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(1));
} else if lower.contains("home") {
let _ = nav_tx.send(RemoteNavAction::ActivateSection(0));
}
return;
}
let norm = normalize_action(action);
match norm.as_str() {
"up" | "cursorup" | "3" => {
let _ = nav_tx.send(RemoteNavAction::Up);
}
"down" | "cursordown" | "4" => {
let _ = nav_tx.send(RemoteNavAction::Down);
}
"left" | "cursorleft" | "1" | "stepback" | "smallstepback" => {
let _ = nav_tx.send(RemoteNavAction::Left);
}
"right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => {
let _ = nav_tx.send(RemoteNavAction::Right);
}
"bigstepforward" => {
let p = player.clone();
tokio::spawn(async move { p.seek(60.0).await; });
}
"bigstepback" => {
let p = player.clone();
tokio::spawn(async move { p.seek(-60.0).await; });
}
"select" | "enter" | "7" | "selectitem" => {
let _ = nav_tx.send(RemoteNavAction::Select);
}
"back" | "parentdir" | "previousmenu" | "close" | "9" | "10" | "escape" => {
let _ = nav_tx.send(RemoteNavAction::Back);
}
"title" | "topmenu" | "11" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"menu" | "contextmenu" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"togglefullscreen" | "fullscreen" | "12" => {
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
}
"osd" | "showosd" | "114" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"nextsubtitle" | "subtitles" | "showsubtitles" | "122" => {
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
}
"audionextlanguage" | "nextaudio" | "nextaudiostream" | "cycleaudio" => {
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
}
"play" | "pause" | "playpause" | "15" => {
let p = player.clone();
tokio::spawn(async move { p.play_pause().await; });
}
"stop" | "14" => {
let _ = nav_tx.send(RemoteNavAction::Stop);
}
"volumeup" | "vol_up" | "88" => {
let p = player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
"volumedown" | "vol_down" | "89" => {
let p = player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
"mute" | "togglemute" | "volumemute" | "13" => {
let p = player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
"info" | "showcodecinfo" => {
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
}
"skipprevious" | "prev" => {
let p = player.clone();
tokio::spawn(async move { p.seek_time(0.0).await; });
}
"skipnext" | "next" => {
let p = player.clone();
tokio::spawn(async move { p.seek(60.0).await; });
}
other => {
tracing::info!("EventServer unhandled action: '{}' (norm: '{}')", action, other);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::PlayerConfig;
use tokio::sync::mpsc;
use tokio::time::{timeout, Duration};
#[test]
fn test_build_packet_header() {
let hdr = build_packet_header(PT_PING, 10, 0xAABBCCDD);
assert_eq!(&hdr[0..4], b"XBMC");
assert_eq!(hdr[4], 2);
assert_eq!(hdr[5], 0);
assert_eq!(u16::from_be_bytes([hdr[6], hdr[7]]), PT_PING);
assert_eq!(u32::from_be_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]), 1);
assert_eq!(u32::from_be_bytes([hdr[12], hdr[13], hdr[14], hdr[15]]), 1);
assert_eq!(u16::from_be_bytes([hdr[16], hdr[17]]), 10);
assert_eq!(u32::from_be_bytes([hdr[18], hdr[19], hdr[20], hdr[21]]), 0xAABBCCDD);
}
#[tokio::test]
async fn test_dispatch_button_navigation() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let nav_sender: RemoteNavSender = nav_tx.into();
let cases = vec![
("up", RemoteNavAction::Up),
("bigstepforward", RemoteNavAction::Up),
("cursorup", RemoteNavAction::Up),
("down", RemoteNavAction::Down),
("bigstepback", RemoteNavAction::Down),
("cursordown", RemoteNavAction::Down),
("left", RemoteNavAction::Left),
("stepback", RemoteNavAction::Left),
("cursorleft", RemoteNavAction::Left),
("right", RemoteNavAction::Right),
("stepforward", RemoteNavAction::Right),
("cursorright", RemoteNavAction::Right),
("select", RemoteNavAction::Select),
("enter", RemoteNavAction::Select),
("ok", RemoteNavAction::Select),
("back", RemoteNavAction::Back),
("parentdir", RemoteNavAction::Back),
("previousmenu", RemoteNavAction::Back),
("escape", RemoteNavAction::Back),
];
for (btn, expected) in cases {
dispatch_button_or_code(btn, 0, &nav_sender, &player);
let action = nav_rx.recv().await.expect("Expected nav action");
assert_eq!(action, expected, "Button '{}' should map to {:?}", btn, expected);
}
}
#[tokio::test]
async fn test_dispatch_button_raw_keycodes() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let nav_sender: RemoteNavSender = nav_tx.into();
let cases = vec![
(0xF010, RemoteNavAction::Up),
(0xF050, RemoteNavAction::Up),
(273, RemoteNavAction::Up),
(0xF011, RemoteNavAction::Down),
(0xF051, RemoteNavAction::Down),
(274, RemoteNavAction::Down),
(0xF012, RemoteNavAction::Left),
(0xF052, RemoteNavAction::Left),
(276, RemoteNavAction::Left),
(0xF013, RemoteNavAction::Right),
(0xF053, RemoteNavAction::Right),
(275, RemoteNavAction::Right),
(0xF001, RemoteNavAction::Select),
(0xF014, RemoteNavAction::Select),
(13, RemoteNavAction::Select),
(0xF016, RemoteNavAction::Back),
(27, RemoteNavAction::Back),
(8, RemoteNavAction::Back),
];
for (code, expected) in cases {
dispatch_button_or_code("unknown_btn", code, &nav_sender, &player);
let action = nav_rx.recv().await.expect("Expected nav action for keycode");
assert_eq!(action, expected, "Keycode 0x{:04X} should map to {:?}", code, expected);
}
}
#[tokio::test]
async fn test_dispatch_action_navigation() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let nav_sender: RemoteNavSender = nav_tx.into();
let cases = vec![
("up", RemoteNavAction::Up),
("action(3)", RemoteNavAction::Up),
("cursorup", RemoteNavAction::Up),
("down", RemoteNavAction::Down),
("action(4)", RemoteNavAction::Down),
("cursordown", RemoteNavAction::Down),
("left", RemoteNavAction::Left),
("action(1)", RemoteNavAction::Left),
("cursorleft", RemoteNavAction::Left),
("right", RemoteNavAction::Right),
("action(2)", RemoteNavAction::Right),
("cursorright", RemoteNavAction::Right),
("select", RemoteNavAction::Select),
("enter", RemoteNavAction::Select),
("action(7)", RemoteNavAction::Select),
("selectitem", RemoteNavAction::Select),
("back", RemoteNavAction::Back),
("action(9)", RemoteNavAction::Back),
("action(10)", RemoteNavAction::Back),
("previousmenu", RemoteNavAction::Back),
("parentdir", RemoteNavAction::Back),
("close", RemoteNavAction::Back),
("ActivateWindow(Videos,TvShowTitles)", RemoteNavAction::ActivateSection(3)),
("ActivateWindow(Videos,MovieTitles)", RemoteNavAction::ActivateSection(2)),
("ActivateWindow(Music)", RemoteNavAction::ActivateSection(4)),
("ActivateWindow(Pictures)", RemoteNavAction::ActivateSection(5)),
("ActivateWindow(Home)", RemoteNavAction::ActivateSection(0)),
];
for (act, expected) in cases {
dispatch_action(act, &nav_sender, &player);
let action = nav_rx.recv().await.expect("Expected nav action for action string");
assert_eq!(action, expected, "Action '{}' should map to {:?}", act, expected);
}
}
#[tokio::test]
async fn test_udp_helo_and_ping_ack() {
let (nav_tx, _nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player, nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
// 1. Send HELO
let token = 0x12345678;
let helo_pkt = build_helo_packet("Test Yatse Device", token);
client.send_to(&helo_pkt, &server_addr).await.expect("Send HELO");
// Expect PING ACK reply
let mut buf = [0u8; 128];
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut buf))
.await
.expect("Timeout waiting for HELO ack")
.expect("Recv HELO ack");
assert!(len >= HEADER_SIZE);
assert_eq!(&buf[0..4], b"XBMC");
let resp_type = u16::from_be_bytes([buf[6], buf[7]]);
assert_eq!(resp_type, PT_PING);
let resp_token = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
assert_eq!(resp_token, token);
// 2. Send PING
let ping_token = 0x87654321;
let ping_pkt = build_ping_packet(ping_token);
client.send_to(&ping_pkt, &server_addr).await.expect("Send PING");
let (len2, _) = timeout(Duration::from_secs(2), client.recv_from(&mut buf))
.await
.expect("Timeout waiting for PING ack")
.expect("Recv PING ack");
assert!(len2 >= HEADER_SIZE);
assert_eq!(&buf[0..4], b"XBMC");
let resp_type2 = u16::from_be_bytes([buf[6], buf[7]]);
assert_eq!(resp_type2, PT_PING);
let resp_token2 = u32::from_be_bytes([buf[18], buf[19], buf[20], buf[21]]);
assert_eq!(resp_token2, ping_token);
}
#[tokio::test]
async fn test_udp_button_events_over_socket() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player, nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
// Send 'Up' button (DOWN flag)
let btn_pkt = build_button_packet(0, PTB_DOWN, "R1", "up", 1);
client.send_to(&btn_pkt, &server_addr).await.expect("Send button");
let action = timeout(Duration::from_secs(2), nav_rx.recv())
.await
.expect("Timeout waiting for button up")
.expect("Channel closed");
assert_eq!(action, RemoteNavAction::Up);
// Send 'Select' button (DOWN flag)
let btn_pkt = build_button_packet(0, PTB_DOWN, "R1", "select", 1);
client.send_to(&btn_pkt, &server_addr).await.expect("Send button");
let action = timeout(Duration::from_secs(2), nav_rx.recv())
.await
.expect("Timeout waiting for button select")
.expect("Channel closed");
assert_eq!(action, RemoteNavAction::Select);
// Send key release ONLY (PTB_UP flag, without PTB_DOWN) -> Should be ignored
let release_pkt = build_button_packet(0, PTB_UP, "R1", "up", 1);
client.send_to(&release_pkt, &server_addr).await.expect("Send button release");
// Wait short time and ensure no action was dispatched
let res = timeout(Duration::from_millis(100), nav_rx.recv()).await;
assert!(res.is_err(), "Key release should be ignored and not trigger an action");
}
#[tokio::test]
async fn test_udp_action_events_over_socket() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player, nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
// Send ACTION "Action(1)" which is Left
let action_pkt = build_action_packet("Action(1)", 1);
client.send_to(&action_pkt, &server_addr).await.expect("Send action");
let action = timeout(Duration::from_secs(2), nav_rx.recv())
.await
.expect("Timeout waiting for action Left")
.expect("Channel closed");
assert_eq!(action, RemoteNavAction::Left);
}
#[tokio::test]
async fn test_udp_malformed_packets_gracefully_ignored() {
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player, nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
// Packet smaller than header size
client.send_to(b"too small", &server_addr).await.expect("Send small");
// Packet with invalid signature
let mut bad_sig = build_button_packet(0, PTB_DOWN, "R1", "up", 1);
bad_sig[0..4].copy_from_slice(b"NOPE");
client.send_to(&bad_sig, &server_addr).await.expect("Send bad sig");
// Immediately following with a valid packet must still succeed
let valid_pkt = build_button_packet(0, PTB_DOWN, "R1", "right", 1);
client.send_to(&valid_pkt, &server_addr).await.expect("Send valid");
let action = timeout(Duration::from_secs(2), nav_rx.recv())
.await
.expect("Timeout waiting for action Right")
.expect("Channel closed");
assert_eq!(action, RemoteNavAction::Right);
}
#[tokio::test]
async fn test_udp_action_mute() {
let (nav_tx, _nav_rx) = mpsc::unbounded_channel();
let player = PlayerController::new(PlayerConfig::default());
let server = KodiEventServer::new(0, player.clone(), nav_tx);
let port = server.start().await.expect("Failed to start EventServer on port 0");
let client = UdpSocket::bind("127.0.0.1:0").await.expect("Failed to bind client UDP");
let server_addr = format!("127.0.0.1:{}", port);
assert!(!player.status.lock().await.is_muted);
// Yatse sends "Mute()" action over EventServer
let mute_pkt = build_action_packet("Mute()", 1);
client.send_to(&mute_pkt, &server_addr).await.expect("Send Mute()");
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(player.status.lock().await.is_muted);
// Send second "Mute()" to unmute
client.send_to(&mute_pkt, &server_addr).await.expect("Send second Mute()");
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!player.status.lock().await.is_muted);
}
}
+183
View File
@@ -0,0 +1,183 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, RwLock, Semaphore};
use eframe::egui::{ColorImage, Context, TextureHandle, TextureOptions};
#[derive(Clone)]
pub enum ImageStatus {
Loaded(TextureHandle),
Loading,
Failed,
}
fn hash_url(url: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut hasher);
hasher.finish()
}
struct CacheInner {
textures: HashMap<String, (u64, TextureHandle)>,
in_flight: HashSet<String>,
failed: HashSet<String>,
access_counter: u64,
}
pub struct AsyncImageCache {
inner: Mutex<CacheInner>,
tx: mpsc::UnboundedSender<(String, Option<ColorImage>)>,
rx: Mutex<mpsc::UnboundedReceiver<(String, Option<ColorImage>)>>,
http_client: reqwest::Client,
token: Arc<RwLock<Option<String>>>,
semaphore: Arc<Semaphore>,
}
impl AsyncImageCache {
pub fn new(token: Arc<RwLock<Option<String>>>) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
Self {
inner: Mutex::new(CacheInner {
textures: HashMap::new(),
in_flight: HashSet::new(),
failed: HashSet::new(),
access_counter: 0,
}),
tx,
rx: Mutex::new(rx),
http_client: reqwest::Client::new(),
token,
semaphore: Arc::new(Semaphore::new(10)),
}
}
pub fn update(&self, ctx: &Context) {
let mut any_new = false;
if let Ok(mut rx_guard) = self.rx.try_lock() {
while let Ok((url, opt_img)) = rx_guard.try_recv() {
if let Ok(mut inner) = self.inner.lock() {
inner.in_flight.remove(&url);
match opt_img {
Some(color_image) => {
inner.access_counter += 1;
let counter = inner.access_counter;
let handle = ctx.load_texture(&url, color_image, TextureOptions::LINEAR);
inner.textures.insert(url, (counter, handle));
any_new = true;
}
None => {
inner.failed.insert(url);
}
}
}
}
}
// Limit cached textures to 500
if let Ok(mut inner) = self.inner.lock() {
if inner.textures.len() > 500 {
let mut entries: Vec<(String, u64)> = inner
.textures
.iter()
.map(|(k, (c, _))| (k.clone(), *c))
.collect();
entries.sort_by_key(|(_, c)| *c);
let to_remove = entries.len() - 400;
for (k, _) in entries.into_iter().take(to_remove) {
inner.textures.remove(&k);
}
}
}
if any_new {
ctx.request_repaint();
}
}
pub fn get_or_load(&self, ctx: &Context, url: &str) -> ImageStatus {
if url.is_empty() {
return ImageStatus::Failed;
}
if let Ok(mut inner) = self.inner.lock() {
inner.access_counter += 1;
let current_count = inner.access_counter;
if let Some((counter, handle)) = inner.textures.get_mut(url) {
*counter = current_count;
return ImageStatus::Loaded(handle.clone());
}
if inner.failed.contains(url) {
return ImageStatus::Failed;
}
if inner.in_flight.contains(url) {
return ImageStatus::Loading;
}
inner.in_flight.insert(url.to_string());
} else {
return ImageStatus::Loading;
}
let url_clone = url.to_string();
let tx = self.tx.clone();
let client = self.http_client.clone();
let sem = self.semaphore.clone();
let ctx_clone = ctx.clone();
let token_opt = self.token.try_read().ok().and_then(|g| g.clone());
tokio::spawn(async move {
let _permit = sem.acquire().await;
let cache_dir = std::path::Path::new("/tmp/jellyfin-cache");
let file_name = format!("{:016x}.cache", hash_url(&url_clone));
let cache_file = cache_dir.join(file_name);
let mut bytes_opt = if let Ok(data) = std::fs::read(&cache_file) {
Some(data)
} else {
None
};
if bytes_opt.is_none() {
let mut req = client.get(&url_clone);
if let Some(ref t) = token_opt {
req = req.header("X-Emby-Token", t);
req = req.header("Authorization", format!("MediaBrowser Token=\"{}\"", t));
}
if let Ok(resp) = req.send().await {
if resp.status().is_success() {
if let Ok(b) = resp.bytes().await {
let vec_bytes = b.to_vec();
let _ = std::fs::create_dir_all(cache_dir);
let _ = std::fs::write(&cache_file, &vec_bytes);
bytes_opt = Some(vec_bytes);
}
}
}
}
let color_img = if let Some(bytes) = bytes_opt {
tokio::task::spawn_blocking(move || {
if let Ok(dyn_img) = image::load_from_memory(&bytes) {
let rgba = dyn_img.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
let pixels = rgba.into_raw();
Some(ColorImage::from_rgba_unmultiplied(size, &pixels))
} else {
None
}
})
.await
.unwrap_or(None)
} else {
None
};
let _ = tx.send((url_clone, color_img));
ctx_clone.request_repaint();
});
ImageStatus::Loading
}
}
+645
View File
@@ -0,0 +1,645 @@
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::config::JellyfinConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardLayout {
Poster, // 2:3 ratio (Movies, TV Shows) - wat kleinere plaatjes (135 x 200)
Square, // 1:1 ratio (Music Albums, Artists) - vierkant (160 x 160)
LandscapeThumb,// 16:9 ratio (Verder kijken, Music Videos) - liggend (250 x 140)
PhotoLarge, // Foto albums - wat grotere plaatjes (280 x 190)
}
#[derive(Debug, Clone)]
pub enum PlayableTarget {
Stream {
url: String,
title: String,
media_type: String,
artist: Option<String>,
genre: Option<String>,
},
Photo { url: String, title: String },
OpenFolder { folder_id: String, name: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem {
pub id: String,
pub name: String,
pub sort_name: Option<String>,
pub item_type: String,
pub series_name: Option<String>,
pub artist_name: Option<String>,
pub genre: Option<String>,
pub index_number: Option<u32>,
pub year: Option<u32>,
pub overview: Option<String>,
pub rating: Option<f32>,
pub runtime_ticks: Option<u64>,
pub played_percentage: Option<f32>,
pub is_folder: bool,
pub has_primary_image: bool,
pub has_backdrop_image: bool,
pub parent_primary_image_item_id: Option<String>,
pub parent_backdrop_item_id: Option<String>,
}
impl MediaItem {
pub fn primary_image_id(&self) -> &str {
if let Some(ref p_id) = self.parent_primary_image_item_id {
if p_id.starts_with("http://") || p_id.starts_with("https://") {
return p_id;
}
}
if let Some(ref b_id) = self.parent_backdrop_item_id {
if b_id.starts_with("http://") || b_id.starts_with("https://") {
return b_id;
}
}
if self.has_primary_image {
&self.id
} else if let Some(ref p_id) = self.parent_primary_image_item_id {
p_id
} else if let Some(ref b_id) = self.parent_backdrop_item_id {
b_id
} else {
&self.id
}
}
pub fn poster_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
format!("{}/Items/{}/Images/Primary?fillWidth=320&fillHeight=480&quality=85", server_url.trim_end_matches('/'), img)
}
pub fn thumb_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
format!("{}/Items/{}/Images/Primary?fillWidth=480&fillHeight=270&quality=85", server_url.trim_end_matches('/'), img)
}
pub fn square_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
format!("{}/Items/{}/Images/Primary?fillWidth=300&fillHeight=300&quality=85", server_url.trim_end_matches('/'), img)
}
pub fn photo_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
format!("{}/Items/{}/Images/Primary?fillWidth=1280&quality=90", server_url.trim_end_matches('/'), img)
}
pub fn backdrop_url(&self, server_url: &str) -> String {
let img = self.primary_image_id();
if img.starts_with("http://") || img.starts_with("https://") {
return img.to_string();
}
let base = server_url.trim_end_matches('/');
if self.has_backdrop_image {
format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, self.id)
} else if let Some(ref p_id) = self.parent_backdrop_item_id {
format!("{}/Items/{}/Images/Backdrop/0?maxWidth=1920&quality=85", base, p_id)
} else {
format!("{}/Items/{}/Images/Primary?maxWidth=1280&quality=85", base, self.id)
}
}
pub fn stream_url(&self, server_url: &str, token: &str) -> String {
let base = server_url.trim_end_matches('/');
if self.item_type == "Audio" || self.item_type == "MusicAlbum" {
format!("{}/Audio/{}/stream?static=true&api_key={}", base, self.id, token)
} else {
format!("{}/Videos/{}/stream?static=true&api_key={}", base, self.id, token)
}
}
pub fn formatted_runtime(&self) -> String {
if let Some(ticks) = self.runtime_ticks {
let total_mins = ticks / (10_000_000 * 60);
let hours = total_mins / 60;
let mins = total_mins % 60;
if hours > 0 {
format!("{}u {}m", hours, mins)
} else {
format!("{}m", mins)
}
} else {
String::new()
}
}
pub fn sort_letter(&self) -> char {
let text = self.sort_name.as_deref().unwrap_or(&self.name);
let trimmed = text.trim().trim_start_matches(|c: char| !c.is_alphanumeric());
for c in trimmed.chars() {
if c.is_ascii_digit() {
return '#';
}
if c.is_ascii_alphabetic() {
return c.to_ascii_uppercase();
}
}
'#'
}
}
pub fn sort_media_items_alphabetically(items: &mut [MediaItem]) {
items.sort_by(|a, b| {
let la = a.sort_letter();
let lb = b.sort_letter();
let order_a = if la == '#' { 0 } else { (la as u32) + 1 };
let order_b = if lb == '#' { 0 } else { (lb as u32) + 1 };
order_a.cmp(&order_b).then_with(|| {
let na = a.sort_name.as_deref().unwrap_or(&a.name).trim_start_matches(|c: char| !c.is_alphanumeric());
let nb = b.sort_name.as_deref().unwrap_or(&b.name).trim_start_matches(|c: char| !c.is_alphanumeric());
na.to_lowercase().cmp(&nb.to_lowercase())
})
});
}
#[derive(Debug, Clone, Default)]
pub struct MediaLibrary {
// Dashboard (Home)
pub resume_items: Vec<MediaItem>,
pub latest_movies: Vec<MediaItem>,
pub latest_shows: Vec<MediaItem>,
pub latest_music: Vec<MediaItem>,
// Libraries
pub all_movies: Vec<MediaItem>,
pub all_shows: Vec<MediaItem>,
pub music_artists: Vec<MediaItem>,
pub music_albums: Vec<MediaItem>,
pub music_videos: Vec<MediaItem>,
pub photos: Vec<MediaItem>,
pub live_channels: Vec<MediaItem>,
pub favorites: Vec<MediaItem>,
}
#[derive(Clone)]
pub struct JellyfinClient {
pub config: JellyfinConfig,
client: reqwest::Client,
pub token: Arc<RwLock<Option<String>>>,
pub user_id: Arc<RwLock<Option<String>>>,
}
impl JellyfinClient {
pub fn new(config: JellyfinConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
token: Arc::new(RwLock::new(None)),
user_id: Arc::new(RwLock::new(None)),
}
}
fn auth_header_value(&self, token: Option<&str>) -> String {
let base = format!(
"MediaBrowser Client=\"{}\", Device=\"{}\", DeviceId=\"{}\", Version=\"1.0.0\"",
self.config.client_name, self.config.device_name, self.config.device_id
);
if let Some(t) = token {
format!("{}, Token=\"{}\"", base, t)
} else {
base
}
}
pub async fn authenticate(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/Users/AuthenticateByName", self.config.server_url.trim_end_matches('/'));
let auth_val = self.auth_header_value(None);
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?);
let payload = serde_json::json!({
"Username": self.config.username,
"Pw": self.config.password.clone().unwrap_or_default(),
});
let resp = self.client.post(&url)
.headers(headers)
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await?;
return Err(format!("Jellyfin authentication failed: {}", err).into());
}
let data: serde_json::Value = resp.json().await?;
let token = data["AccessToken"].as_str().ok_or("No AccessToken in response")?.to_string();
let user_id = data["User"]["Id"].as_str().ok_or("No User.Id in response")?.to_string();
*self.token.write().await = Some(token);
*self.user_id.write().await = Some(user_id);
tracing::info!("Authenticated successfully with Jellyfin as {}", self.config.username);
Ok(())
}
async fn get_authed_headers(&self) -> Result<HeaderMap, Box<dyn std::error::Error + Send + Sync>> {
let token_guard = self.token.read().await;
let token = token_guard.as_deref().ok_or("Not authenticated")?;
let auth_val = self.auth_header_value(Some(token));
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_val)?);
headers.insert("X-Emby-Token", HeaderValue::from_str(token)?);
Ok(headers)
}
pub async fn fetch_folder_items(&self, folder_id: &str) -> Vec<MediaItem> {
let base_url = self.config.server_url.trim_end_matches('/');
if let Ok(headers) = self.get_authed_headers().await {
let user_id = self.user_id.read().await.clone().unwrap_or_default();
// 1. Try standard folder items with parentId
let url = format!(
"{}/Users/{}/Items?parentId={}&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, user_id, folder_id
);
let mut items = fetch_endpoint(&self.client, &url, &headers).await;
// 2. If empty, maybe this is a TV Show (Series): fetch seasons
if items.is_empty() {
let url_seasons = format!(
"{}/Shows/{}/Seasons?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, folder_id, user_id
);
items = fetch_endpoint(&self.client, &url_seasons, &headers).await;
}
// 3. If empty, maybe this is a Season: fetch episodes
if items.is_empty() {
let url_episodes = format!(
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, folder_id, user_id
);
items = fetch_endpoint(&self.client, &url_episodes, &headers).await;
}
// 4. If empty, maybe this is an artist: try fetching albums via albumArtistIds
if items.is_empty() {
let url_albums = format!(
"{}/Users/{}/Items?albumArtistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, user_id, folder_id
);
items = fetch_endpoint(&self.client, &url_albums, &headers).await;
}
// 5. If still empty, try artistIds for albums
if items.is_empty() {
let url_albums2 = format!(
"{}/Users/{}/Items?artistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, user_id, folder_id
);
items = fetch_endpoint(&self.client, &url_albums2, &headers).await;
}
// 6. If still empty (e.g. ABBA, *NSYNC who have no albums, only compilation tracks), fetch individual audio songs directly
if items.is_empty() {
let url_songs = format!(
"{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
base_url, user_id, folder_id
);
items = fetch_endpoint(&self.client, &url_songs, &headers).await;
}
// If the folder items are Audio tracks, Seasons, or Episodes, sort them by index_number
if items.iter().any(|i| i.item_type == "Audio" || i.item_type == "Season" || i.item_type == "Episode") {
items.sort_by_key(|i| i.index_number.unwrap_or(u32::MAX));
} else {
sort_media_items_alphabetically(&mut items);
}
return items;
}
Vec::new()
}
pub async fn resolve_playable_item(&self, item: &MediaItem) -> Result<PlayableTarget, Box<dyn std::error::Error + Send + Sync>> {
let base_url = self.config.server_url.trim_end_matches('/');
let token = {
let guard = self.token.read().await;
guard.clone().unwrap_or_default()
};
// 0. YouTube Video or direct stream URL
if item.item_type == "YouTubeVideo"
|| item.id.starts_with("https://www.youtube.com")
|| item.id.starts_with("https://youtu.be")
{
return Ok(PlayableTarget::Stream {
url: item.id.clone(),
title: item.name.clone(),
media_type: "stream".to_string(),
artist: item.series_name.clone(),
genre: item.genre.clone(),
});
}
// 1. Photo item -> view photo
if item.item_type == "Photo" {
return Ok(PlayableTarget::Photo {
url: item.photo_url(base_url),
title: item.name.clone(),
});
}
// 2. Series, Season, PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI
if item.item_type == "Series"
|| item.item_type == "Season"
|| item.item_type == "PhotoAlbum"
|| item.item_type == "MusicArtist"
|| item.item_type == "MusicAlbum"
|| item.is_folder
{
return Ok(PlayableTarget::OpenFolder {
folder_id: item.id.clone(),
name: item.name.clone(),
});
}
// 5. Standard playable stream (Movie, Episode, Video, MusicVideo, Audio, TvChannel)
let (media_type, artist) = match item.item_type.as_str() {
"Audio" => ("music".to_string(), item.artist_name.clone()),
"TvChannel" => ("stream".to_string(), None),
"MusicVideo" => ("video".to_string(), item.artist_name.clone()),
_ => ("video".to_string(), None),
};
Ok(PlayableTarget::Stream {
url: item.stream_url(base_url, &token),
title: item.name.clone(),
media_type,
artist,
genre: item.genre.clone(),
})
}
pub async fn fetch_all_media(&self) -> Result<MediaLibrary, Box<dyn std::error::Error + Send + Sync>> {
if self.token.read().await.is_none() {
self.authenticate().await?;
}
let user_id = {
let guard = self.user_id.read().await;
guard.clone().ok_or("Missing user_id")?
};
let headers = self.get_authed_headers().await?;
let base_url = self.config.server_url.trim_end_matches('/');
// 1. Fetch Views to discover all library categories
let views_url = format!("{}/Users/{}/Views", base_url, user_id);
let views_resp: serde_json::Value = self.client.get(&views_url)
.headers(headers.clone())
.send()
.await?
.json()
.await?;
let mut movies_id: Option<String> = None;
let mut shows_id: Option<String> = None;
let mut music_id: Option<String> = None;
let mut photo_id: Option<String> = None;
let mut music_video_id: Option<String> = None;
if let Some(items) = views_resp["Items"].as_array() {
for v in items {
let col_type = v["CollectionType"].as_str().unwrap_or("");
let name = v["Name"].as_str().unwrap_or("").to_lowercase();
let id = v["Id"].as_str().unwrap_or("").to_string();
if col_type == "movies" || name.contains("movie") || name.contains("film") {
if movies_id.is_none() { movies_id = Some(id); }
} else if col_type == "tvshows" || name.contains("show") || name.contains("serie") {
if shows_id.is_none() { shows_id = Some(id); }
} else if col_type == "music" && !name.contains("video") {
if music_id.is_none() { music_id = Some(id); }
} else if col_type == "musicvideos" || name.contains("music video") {
if music_video_id.is_none() { music_video_id = Some(id); }
} else if col_type == "homevideos" || col_type == "photos" || name.contains("foto") || name.contains("photo") {
if photo_id.is_none() { photo_id = Some(id); }
}
}
}
// 2. Fetch Resume / Next Up
let resume_url = format!(
"{}/UserItems/Resume?userId={}&limit=16&fields=Overview,PrimaryImageAspectRatio,SeriesName,ParentBackdropItemId,UserData",
base_url, user_id
);
let resume_items = fetch_endpoint(&self.client, &resume_url, &headers).await;
// 3. Fetch Latest & All Movies
let mut latest_movies = Vec::new();
let mut all_movies = Vec::new();
if let Some(ref m_id) = movies_id {
let url_latest = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
base_url, user_id, m_id
);
latest_movies = fetch_endpoint(&self.client, &url_latest, &headers).await;
let url_all = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Movie&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
base_url, user_id, m_id
);
all_movies = fetch_endpoint(&self.client, &url_all, &headers).await;
sort_media_items_alphabetically(&mut all_movies);
}
// 4. Fetch Latest & All Shows
let mut latest_shows = Vec::new();
let mut all_shows = Vec::new();
if let Some(ref s_id) = shows_id {
let url_latest = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
base_url, user_id, s_id
);
latest_shows = fetch_endpoint(&self.client, &url_latest, &headers).await;
let url_all = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=Series&sortBy=SortName&sortOrder=Ascending&limit=1000&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,UserData,SortName",
base_url, user_id, s_id
);
all_shows = fetch_endpoint(&self.client, &url_all, &headers).await;
sort_media_items_alphabetically(&mut all_shows);
}
// 5. Fetch Music (Latest Albums, Artists, and All Albums)
let mut latest_music = Vec::new();
let mut music_artists = Vec::new();
let mut music_albums = Vec::new();
if let Some(ref mus_id) = music_id {
let url_latest_mus = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=DateCreated&sortOrder=Descending&limit=25&fields=Overview,ProductionYear,AlbumArtist,SortName",
base_url, user_id, mus_id
);
latest_music = fetch_endpoint(&self.client, &url_latest_mus, &headers).await;
// Fetch artists using dedicated /Artists endpoint first
let url_artists = format!(
"{}/Artists?userId={}&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName",
base_url, user_id
);
music_artists = fetch_endpoint(&self.client, &url_artists, &headers).await;
if music_artists.is_empty() {
let url_artists_fb = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicArtist&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,SortName",
base_url, user_id, mus_id
);
music_artists = fetch_endpoint(&self.client, &url_artists_fb, &headers).await;
}
sort_media_items_alphabetically(&mut music_artists);
let url_albums = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&sortOrder=Ascending&limit=2000&fields=Overview,ProductionYear,AlbumArtist,SortName",
base_url, user_id, mus_id
);
music_albums = fetch_endpoint(&self.client, &url_albums, &headers).await;
sort_media_items_alphabetically(&mut music_albums);
}
// 6. Fetch Music Videos
let mut music_videos = Vec::new();
if let Some(ref mv_id) = music_video_id {
let url_mv = format!(
"{}/Users/{}/Items?parentId={}&includeItemTypes=MusicVideo&sortBy=DateCreated&sortOrder=Descending&limit=200&fields=Overview,Artist,SortName",
base_url, user_id, mv_id
);
music_videos = fetch_endpoint(&self.client, &url_mv, &headers).await;
sort_media_items_alphabetically(&mut music_videos);
}
// 7. Fetch Photos / Home Videos
let mut photos = Vec::new();
if let Some(ref p_id) = photo_id {
let url_photo = format!(
"{}/Users/{}/Items?parentId={}&sortBy=DateCreated&sortOrder=Descending&limit=500&fields=Overview,SortName",
base_url, user_id, p_id
);
photos = fetch_endpoint(&self.client, &url_photo, &headers).await;
sort_media_items_alphabetically(&mut photos);
}
// 8. Fetch Live TV Channels
let url_live = format!("{}/LiveTv/Channels?userId={}&limit=100", base_url, user_id);
let live_channels = fetch_endpoint(&self.client, &url_live, &headers).await;
// 9. Fetch Favorites
let url_fav = format!(
"{}/Users/{}/Items?userId={}&isFavorite=true&recursive=true&limit=200&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,SortName",
base_url, user_id, user_id
);
let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await;
sort_media_items_alphabetically(&mut favorites);
Ok(MediaLibrary {
resume_items,
latest_movies,
latest_shows,
latest_music,
all_movies,
all_shows,
music_artists,
music_albums,
music_videos,
photos,
live_channels,
favorites,
})
}
}
async fn fetch_endpoint(client: &reqwest::Client, url: &str, headers: &HeaderMap) -> Vec<MediaItem> {
if let Ok(resp) = client.get(url).headers(headers.clone()).send().await {
if let Ok(json) = resp.json::<serde_json::Value>().await {
return parse_items(&json);
}
}
Vec::new()
}
fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
let mut out = Vec::new();
if let Some(items) = json["Items"].as_array() {
for it in items {
let id = it["Id"].as_str().unwrap_or("").to_string();
let name = it["Name"].as_str().unwrap_or("Onbekend").to_string();
let item_type = it["Type"].as_str().unwrap_or("Video").to_string();
let series_name = it["SeriesName"].as_str().map(|s| s.to_string());
let artist_name = it["AlbumArtist"].as_str().or_else(|| it["Artist"].as_str()).map(|s| s.to_string());
let genre = it["Genres"].as_array().and_then(|arr| {
let list: Vec<&str> = arr.iter().filter_map(|g| g.as_str()).collect();
if list.is_empty() {
None
} else {
Some(list.join(", "))
}
}).or_else(|| it["Genre"].as_str().map(|s| s.to_string()));
let year = it["ProductionYear"].as_u64().map(|y| y as u32);
let overview = it["Overview"].as_str().map(|s| s.to_string());
let rating = it["CommunityRating"].as_f64().map(|r| r as f32);
let runtime_ticks = it["RunTimeTicks"].as_u64();
let played_percentage = it["UserData"]["PlayedPercentage"].as_f64().map(|p| p as f32);
let is_folder = it["IsFolder"].as_bool().unwrap_or(false);
let has_primary_image = it["ImageTags"]["Primary"].is_string();
let has_backdrop_image = it["BackdropImageTags"].as_array().map_or(false, |b| !b.is_empty());
let parent_primary_image_item_id = it["ParentPrimaryImageItemId"]
.as_str()
.or_else(|| it["AlbumId"].as_str())
.or_else(|| it["SeriesId"].as_str())
.map(|s| s.to_string());
let parent_backdrop_item_id = it["ParentBackdropItemId"]
.as_str()
.or_else(|| it["SeriesId"].as_str())
.map(|s| s.to_string());
let sort_name = it["SortName"].as_str().map(|s| s.to_string());
let index_number = it["IndexNumber"].as_u64().map(|n| n as u32);
if !id.is_empty() {
out.push(MediaItem {
id,
name,
sort_name,
item_type,
series_name,
artist_name,
genre,
index_number,
year,
overview,
rating,
runtime_ticks,
played_percentage,
is_folder,
has_primary_image,
has_backdrop_image,
parent_primary_image_item_id,
parent_backdrop_item_id,
});
}
}
}
out
}
+158
View File
@@ -0,0 +1,158 @@
use eframe::egui::Vec2;
use tokio::sync::mpsc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod app;
mod config;
mod eventserver;
mod image_cache;
mod jellyfin;
mod mqtt;
mod player;
mod yatse;
mod youtube;
use app::KioskApp;
use config::AppConfig;
use eventserver::KodiEventServer;
use jellyfin::JellyfinClient;
use player::PlayerController;
use yatse::{create_yatse_router, YatseServerState};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.with(tracing_subscriber::fmt::layer())
.init();
tracing::info!("Starting Jellyfin HTPC Kiosk (Rust)...");
// 1. Load Configuration
let config = AppConfig::load()?;
// 2. Initialize Media Player (MPV Controller)
let player = PlayerController::new(config.player.clone());
// 3. Navigation channel (Yatse Remote -> UI) with immediate egui repaint trigger
let (nav_tx_raw, nav_rx) = mpsc::unbounded_channel();
let egui_ctx_holder: std::sync::Arc<std::sync::Mutex<Option<eframe::egui::Context>>> =
std::sync::Arc::new(std::sync::Mutex::new(None));
let nav_tx = crate::yatse::RemoteNavSender::new(nav_tx_raw, egui_ctx_holder.clone());
// 4. Start Yatse Kodi JSON-RPC Server & EventServer
if config.kodi_rpc.enabled {
let yatse_state = YatseServerState {
player: player.clone(),
nav_tx: nav_tx.clone(),
};
let app_router = create_yatse_router(yatse_state.clone());
let port = config.kodi_rpc.http_port;
tokio::spawn(async move {
let addr = format!("0.0.0.0:{}", port);
tracing::info!("Yatse Kodi JSON-RPC server listening on http://{}", addr);
if let Ok(listener) = tokio::net::TcpListener::bind(&addr).await {
let _ = axum::serve(listener, app_router).await;
}
});
// Start Kodi TCP JSON-RPC socket server on port 9090 for Yatse
let ws_port = config.kodi_rpc.ws_port;
crate::yatse::start_tcp_jsonrpc_server(ws_port, yatse_state).await;
// Start Kodi EventServer (UDP 9777) for Yatse
let event_server = KodiEventServer::new(9777, player.clone(), nav_tx);
let _ = event_server.start().await;
// Zeroconf / mDNS Advertisement for Yatse automatic discovery
tokio::spawn(async move {
let hostname = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| std::env::var("HOSTNAME").unwrap_or_else(|_| "kiosk".into()));
let service_name = format!("Kodi ({})", hostname);
// Announce JSON-RPC HTTP on port 8080
let _http_child = std::process::Command::new("avahi-publish-service")
.args(["-s", &service_name, "_xbmc-jsonrpc-h._tcp", &port.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
// Announce EventServer UDP on port 9777
let _event_child = std::process::Command::new("avahi-publish-service")
.args(["-s", &service_name, "_xbmc-events._udp", "9777"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
tracing::info!("Published Zeroconf services for Yatse discovery: '{}'", service_name);
});
}
// 5. Start MQTT Service & Home Assistant Discovery if enabled
if config.mqtt.enabled {
mqtt::MqttService::start(config.clone(), player.clone());
}
// 6. Initialize Jellyfin Client
let client = JellyfinClient::new(config.jellyfin.clone());
// 7. Launch Native GUI via eframe (runs on main thread)
let native_options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_title("Jellyfin HTPC Kiosk")
.with_inner_size(Vec2::new(1280.0, 720.0))
.with_fullscreen(config.player.fullscreen),
..Default::default()
};
let app_config = config.clone();
let app_client = client.clone();
let app_player = player.clone();
let egui_ctx_for_nav = egui_ctx_holder.clone();
// Run eframe app (blocks until window is closed)
tokio::task::block_in_place(|| {
eframe::run_native(
"Jellyfin HTPC Kiosk",
native_options,
Box::new(move |cc| {
*egui_ctx_for_nav.lock().unwrap() = Some(cc.egui_ctx.clone());
// Install egui image loaders (http, png, jpg)
egui_extras::install_image_loaders(&cc.egui_ctx);
let mut window_id: Option<u64> = None;
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
if let Ok(handle) = cc.window_handle() {
match handle.as_raw() {
RawWindowHandle::Xlib(h) => {
window_id = Some(h.window as u64);
}
RawWindowHandle::Xcb(h) => {
window_id = Some(h.window.get() as u64);
}
_ => {}
}
}
if let Some(wid) = window_id {
let p = app_player.clone();
tokio::spawn(async move {
p.set_window_id(Some(wid)).await;
});
}
Ok(Box::new(KioskApp::new(
app_config,
app_client,
app_player,
nav_rx,
window_id,
)))
}),
)
})
.map_err(|e| format!("GUI runtime error: {}", e))?;
Ok(())
}
+449
View File
@@ -0,0 +1,449 @@
use std::sync::Arc;
use std::time::Duration;
use rumqttc::{AsyncClient, LastWill, MqttOptions, Packet, QoS};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::config::{AppConfig, MqttConfig};
use crate::player::{PlaybackState, PlayerController, PlayerStatus};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MqttPlaybackState {
pub state: String,
pub media_type: String,
pub title: String,
pub artist: String,
pub genre: String,
pub stream_title: String,
}
impl MqttPlaybackState {
pub fn from_status(st: &PlayerStatus) -> Self {
if st.state == PlaybackState::Stopped {
Self {
state: "stopped".to_string(),
media_type: String::new(),
title: String::new(),
artist: String::new(),
genre: String::new(),
stream_title: String::new(),
}
} else {
let state_str = match st.state {
PlaybackState::Playing => "playing",
PlaybackState::Paused => "paused",
PlaybackState::Stopped => "stopped",
};
Self {
state: state_str.to_string(),
media_type: st.media_type.clone(),
title: st.title.clone(),
artist: st.artist.clone(),
genre: st.genre.clone(),
stream_title: st.stream_title.clone(),
}
}
}
}
pub struct MqttService;
impl MqttService {
pub fn start(config: AppConfig, player: Arc<PlayerController>) {
tokio::spawn(async move {
run_mqtt_loop(config, player).await;
});
}
}
async fn run_mqtt_loop(config: AppConfig, player: Arc<PlayerController>) {
let mqtt_cfg = &config.mqtt;
let device_id = &config.jellyfin.device_id;
let device_name = &config.jellyfin.device_name;
let client_id = mqtt_cfg.client_id.clone().unwrap_or_else(|| {
format!("{}-{}", mqtt_cfg.topic_prefix, device_id)
});
let avail_topic = format!("{}/{}/availability", mqtt_cfg.topic_prefix, device_id);
let command_topic = format!("{}/{}/command", mqtt_cfg.topic_prefix, device_id);
let mut mqtt_opts = MqttOptions::new(&client_id, &mqtt_cfg.host, mqtt_cfg.port);
mqtt_opts.set_keep_alive(Duration::from_secs(15));
if let (Some(u), Some(p)) = (&mqtt_cfg.username, &mqtt_cfg.password) {
if !u.is_empty() {
mqtt_opts.set_credentials(u, p);
}
}
mqtt_opts.set_last_will(LastWill::new(
&avail_topic,
"offline",
QoS::AtLeastOnce,
true,
));
tracing::info!(
"Starting MQTT Service: broker={}:{}, client_id='{}', device='{}'",
mqtt_cfg.host,
mqtt_cfg.port,
client_id,
device_name
);
let mut status_rx = player.status_watch_rx();
let mut last_published: Option<MqttPlaybackState> = None;
loop {
let (client, mut eventloop) = AsyncClient::new(mqtt_opts.clone(), 30);
let mut is_connected = false;
loop {
tokio::select! {
event_res = eventloop.poll() => {
match event_res {
Ok(rumqttc::Event::Incoming(Packet::ConnAck(_))) => {
is_connected = true;
tracing::info!("MQTT broker connected ({}:{})", mqtt_cfg.host, mqtt_cfg.port);
// 1. Announce online availability
let _ = client.publish(&avail_topic, QoS::AtLeastOnce, true, "online").await;
// 2. Subscribe to control commands
let _ = client.subscribe(&command_topic, QoS::AtLeastOnce).await;
// 3. Publish Home Assistant Discovery configs
publish_homeassistant_discovery(&client, mqtt_cfg, device_id, device_name).await;
// 4. Publish current playback state
let current_st = {
let guard = player.status.lock().await;
MqttPlaybackState::from_status(&guard)
};
publish_playback_state(&client, mqtt_cfg, device_id, &current_st).await;
last_published = Some(current_st);
}
Ok(rumqttc::Event::Incoming(Packet::Publish(p))) => {
if p.topic == command_topic {
if let Ok(cmd_str) = std::str::from_utf8(&p.payload) {
handle_remote_command(&player, cmd_str.trim()).await;
}
}
}
Ok(_) => {}
Err(e) => {
tracing::warn!("MQTT connection lost or error: {}. Reconnecting...", e);
tokio::time::sleep(Duration::from_secs(3)).await;
break; // break inner loop to re-create client/eventloop
}
}
}
changed_res = status_rx.changed() => {
if changed_res.is_ok() && is_connected {
let st = status_rx.borrow().clone();
let new_state = MqttPlaybackState::from_status(&st);
if last_published.as_ref() != Some(&new_state) {
publish_playback_state(&client, mqtt_cfg, device_id, &new_state).await;
last_published = Some(new_state);
}
}
}
}
}
}
}
pub async fn publish_homeassistant_discovery(
client: &AsyncClient,
mqtt_cfg: &MqttConfig,
device_id: &str,
device_name: &str,
) {
let state_topic = format!("{}/{}/state", mqtt_cfg.topic_prefix, device_id);
let avail_topic = format!("{}/{}/availability", mqtt_cfg.topic_prefix, device_id);
let device_obj = json!({
"identifiers": [device_id],
"name": device_name,
"model": "Jellyfin HTPC Kiosk",
"manufacturer": "Jellyfin",
"sw_version": env!("CARGO_PKG_VERSION")
});
let availability_obj = json!([{
"topic": avail_topic,
"payload_available": "online",
"payload_not_available": "offline"
}]);
struct SensorConfig<'a> {
object_id: &'a str,
name: &'a str,
value_template: &'a str,
icon: &'a str,
}
let sensors = [
SensorConfig {
object_id: "media_type",
name: "Media Type",
value_template: "{{ value_json.media_type }}",
icon: "mdi:play-box",
},
SensorConfig {
object_id: "title",
name: "Title",
value_template: "{{ value_json.title }}",
icon: "mdi:movie-open",
},
SensorConfig {
object_id: "artist",
name: "Artist",
value_template: "{{ value_json.artist }}",
icon: "mdi:account-music",
},
SensorConfig {
object_id: "genre",
name: "Genre",
value_template: "{{ value_json.genre }}",
icon: "mdi:tag-text-outline",
},
SensorConfig {
object_id: "stream_title",
name: "Stream Title",
value_template: "{{ value_json.stream_title }}",
icon: "mdi:radio",
},
SensorConfig {
object_id: "playback_state",
name: "Playback State",
value_template: "{{ value_json.state }}",
icon: "mdi:play-pause",
},
];
for s in &sensors {
let disc_topic = format!(
"{}/sensor/{}/{}/config",
mqtt_cfg.discovery_prefix, device_id, s.object_id
);
let payload = json!({
"name": format!("{} {}", device_name, s.name),
"unique_id": format!("{}_{}", device_id, s.object_id),
"state_topic": state_topic,
"value_template": s.value_template,
"json_attributes_topic": state_topic,
"icon": s.icon,
"device": device_obj,
"availability": availability_obj
});
if let Err(e) = client.publish(&disc_topic, QoS::AtLeastOnce, true, payload.to_string()).await {
tracing::warn!("Failed to publish Home Assistant discovery for {}: {}", s.object_id, e);
} else {
tracing::debug!("Published HA discovery: {}", disc_topic);
}
}
tracing::info!("Published Home Assistant MQTT discovery for device '{}'", device_id);
}
pub async fn publish_playback_state(
client: &AsyncClient,
mqtt_cfg: &MqttConfig,
device_id: &str,
state: &MqttPlaybackState,
) {
let payload = json!({
"state": state.state,
"media_type": state.media_type,
"title": state.title,
"artist": state.artist,
"genre": state.genre,
"stream_title": state.stream_title,
});
let state_topic = format!("{}/{}/state", mqtt_cfg.topic_prefix, device_id);
let _ = client.publish(&state_topic, QoS::AtLeastOnce, true, payload.to_string()).await;
// Subtopics for direct subscriptions
let _ = client.publish(format!("{}/{}/media_type", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.media_type.as_str()).await;
let _ = client.publish(format!("{}/{}/title", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.title.as_str()).await;
let _ = client.publish(format!("{}/{}/artist", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.artist.as_str()).await;
let _ = client.publish(format!("{}/{}/genre", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.genre.as_str()).await;
let _ = client.publish(format!("{}/{}/stream_title", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.stream_title.as_str()).await;
let _ = client.publish(format!("{}/{}/playback_state", mqtt_cfg.topic_prefix, device_id), QoS::AtLeastOnce, true, state.state.as_str()).await;
tracing::info!(
"MQTT state published: media_type='{}', title='{}', artist='{}', genre='{}', stream_title='{}', state='{}'",
state.media_type,
state.title,
state.artist,
state.genre,
state.stream_title,
state.state
);
}
async fn handle_remote_command(player: &Arc<PlayerController>, cmd: &str) {
tracing::info!("Received MQTT remote command: '{}'", cmd);
match cmd {
"play_pause" | "toggle" => player.play_pause().await,
"pause" => {
let st = player.status.lock().await;
if st.state == PlaybackState::Playing {
drop(st);
player.play_pause().await;
}
}
"play" => {
let st = player.status.lock().await;
if st.state == PlaybackState::Paused {
drop(st);
player.play_pause().await;
}
}
"stop" => player.stop().await,
"toggle_mute" | "mute" => player.toggle_mute().await,
s if s.starts_with("volume:") => {
if let Ok(vol) = s["volume:".len()..].trim().parse::<f64>() {
player.set_volume(vol).await;
}
}
_ => {
tracing::warn!("Unrecognized MQTT command: '{}'", cmd);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::player::PlaybackState;
#[test]
fn test_stopped_state_has_empty_media_type_and_metadata() {
let st = PlayerStatus {
state: PlaybackState::Stopped,
media_type: "music".to_string(),
title: "Previous Track".to_string(),
artist: "Previous Artist".to_string(),
genre: "Rock".to_string(),
stream_title: "".to_string(),
time_pos: 0.0,
duration: 0.0,
volume: 100.0,
is_muted: false,
error_message: None,
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "stopped");
assert_eq!(mqtt_state.media_type, "");
assert_eq!(mqtt_state.title, "");
assert_eq!(mqtt_state.artist, "");
assert_eq!(mqtt_state.genre, "");
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_playing_music_state() {
let st = PlayerStatus {
state: PlaybackState::Playing,
media_type: "music".to_string(),
title: "Bohemian Rhapsody".to_string(),
artist: "Queen".to_string(),
genre: "Rock".to_string(),
stream_title: "".to_string(),
time_pos: 42.0,
duration: 354.0,
volume: 85.0,
is_muted: false,
error_message: None,
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "playing");
assert_eq!(mqtt_state.media_type, "music");
assert_eq!(mqtt_state.title, "Bohemian Rhapsody");
assert_eq!(mqtt_state.artist, "Queen");
assert_eq!(mqtt_state.genre, "Rock");
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_playing_stream_with_icy_metadata() {
let st = PlayerStatus {
state: PlaybackState::Playing,
media_type: "stream".to_string(),
title: "Radio Veronica".to_string(),
artist: "Pink Floyd".to_string(),
genre: "Classic Rock".to_string(),
stream_title: "Pink Floyd - Comfortably Numb".to_string(),
time_pos: 120.0,
duration: 0.0,
volume: 90.0,
is_muted: false,
error_message: None,
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "playing");
assert_eq!(mqtt_state.media_type, "stream");
assert_eq!(mqtt_state.title, "Radio Veronica");
assert_eq!(mqtt_state.artist, "Pink Floyd");
assert_eq!(mqtt_state.genre, "Classic Rock");
assert_eq!(mqtt_state.stream_title, "Pink Floyd - Comfortably Numb");
}
#[test]
fn test_playing_video_state() {
let st = PlayerStatus {
state: PlaybackState::Playing,
media_type: "video".to_string(),
title: "Interstellar".to_string(),
artist: "".to_string(),
genre: "Sci-Fi".to_string(),
stream_title: "".to_string(),
time_pos: 1000.0,
duration: 10140.0,
volume: 100.0,
is_muted: false,
error_message: None,
};
let mqtt_state = MqttPlaybackState::from_status(&st);
assert_eq!(mqtt_state.state, "playing");
assert_eq!(mqtt_state.media_type, "video");
assert_eq!(mqtt_state.title, "Interstellar");
assert_eq!(mqtt_state.artist, "");
assert_eq!(mqtt_state.genre, "Sci-Fi");
assert_eq!(mqtt_state.stream_title, "");
}
#[test]
fn test_yaml_config_deserialization() {
let yaml_str = r#"
jellyfin:
server_url: "https://jellyfin.example.com"
username: "testuser"
device_id: "living-room-01"
device_name: "Living Room Kiosk"
mqtt:
enabled: true
host: "192.168.1.50"
port: 1883
username: "mqtt_user"
password: "mqtt_password"
discovery_prefix: "homeassistant"
topic_prefix: "kiosk"
"#;
let cfg: AppConfig = serde_yaml::from_str(yaml_str).expect("Failed to deserialize AppConfig");
assert!(cfg.mqtt.enabled);
assert_eq!(cfg.mqtt.host, "192.168.1.50");
assert_eq!(cfg.mqtt.port, 1883);
assert_eq!(cfg.mqtt.username.as_deref(), Some("mqtt_user"));
assert_eq!(cfg.mqtt.password.as_deref(), Some("mqtt_password"));
assert_eq!(cfg.mqtt.discovery_prefix, "homeassistant");
assert_eq!(cfg.mqtt.topic_prefix, "kiosk");
}
}
+762
View File
@@ -0,0 +1,762 @@
use serde::{Deserialize, Serialize};
use std::ffi::{CStr, CString};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::config::PlayerConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PlaybackState {
Stopped,
Playing,
Paused,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlayerStatus {
pub state: PlaybackState,
pub media_type: String,
pub title: String,
pub artist: String,
pub genre: String,
pub stream_title: String,
pub time_pos: f64,
pub duration: f64,
pub volume: f64,
pub is_muted: bool,
pub error_message: Option<String>,
}
impl Default for PlayerStatus {
fn default() -> Self {
Self {
state: PlaybackState::Stopped,
media_type: String::new(),
title: String::new(),
artist: String::new(),
genre: String::new(),
stream_title: String::new(),
time_pos: 0.0,
duration: 0.0,
volume: 100.0,
is_muted: false,
error_message: None,
}
}
}
#[derive(Clone, Copy)]
pub struct MpvHandle(pub *mut libmpv_sys::mpv_handle);
unsafe impl Send for MpvHandle {}
unsafe impl Sync for MpvHandle {}
impl MpvHandle {
pub fn cmd(&self, args: &[&str]) {
if self.0.is_null() {
return;
}
let c_strings: Vec<CString> = args.iter().filter_map(|s| CString::new(*s).ok()).collect();
let mut ptrs: Vec<*const std::ffi::c_char> = c_strings.iter().map(|c| c.as_ptr()).collect();
ptrs.push(std::ptr::null());
unsafe {
libmpv_sys::mpv_command(self.0, ptrs.as_mut_ptr());
}
}
pub fn set_option_string(&self, name: &str, value: &str) {
if self.0.is_null() {
return;
}
if let (Ok(n), Ok(v)) = (CString::new(name), CString::new(value)) {
unsafe {
libmpv_sys::mpv_set_option_string(self.0, n.as_ptr(), v.as_ptr());
}
}
}
}
#[derive(Clone, Copy)]
pub struct MpvRenderContext(pub *mut libmpv_sys::mpv_render_context);
unsafe impl Send for MpvRenderContext {}
unsafe impl Sync for MpvRenderContext {}
impl MpvRenderContext {
pub unsafe fn render(&self, params: &mut [libmpv_sys::mpv_render_param]) {
if !self.0.is_null() {
libmpv_sys::mpv_render_context_render(self.0, params.as_mut_ptr());
}
}
pub unsafe fn report_swap(&self) {
if !self.0.is_null() {
libmpv_sys::mpv_render_context_report_swap(self.0);
}
}
}
type GetProcAddressFn = unsafe extern "C" fn(name: *const u8) -> *mut std::ffi::c_void;
unsafe extern "C" fn get_proc_address_cb(
_ctx: *mut std::ffi::c_void,
name: *const std::ffi::c_char,
) -> *mut std::ffi::c_void {
if name.is_null() {
return std::ptr::null_mut();
}
// 1. Try glXGetProcAddressARB
let glx_name = b"glXGetProcAddressARB\0";
let glx_ptr = libc::dlsym(std::ptr::null_mut(), glx_name.as_ptr() as *const _);
if !glx_ptr.is_null() {
let glx_fn: GetProcAddressFn = std::mem::transmute(glx_ptr);
let ptr = glx_fn(name as *const u8);
if !ptr.is_null() {
return ptr;
}
}
// 2. Try eglGetProcAddress
let egl_name = b"eglGetProcAddress\0";
let egl_ptr = libc::dlsym(std::ptr::null_mut(), egl_name.as_ptr() as *const _);
if !egl_ptr.is_null() {
let egl_fn: GetProcAddressFn = std::mem::transmute(egl_ptr);
let ptr = egl_fn(name as *const u8);
if !ptr.is_null() {
return ptr;
}
}
// 3. Fallback: dlsym
libc::dlsym(std::ptr::null_mut(), name)
}
unsafe extern "C" fn on_mpv_render_update(ctx_ptr: *mut std::ffi::c_void) {
if !ctx_ptr.is_null() {
let egui_ctx = &*(ctx_ptr as *const eframe::egui::Context);
egui_ctx.request_repaint();
}
}
enum InternalMpvEvent {
StartFile,
FileLoaded,
TimePos(f64),
Duration(f64),
Pause(bool),
Volume(f64),
Mute(bool),
MediaTitle(String),
IcyTitle(String),
Metadata {
artist: Option<String>,
genre: Option<String>,
stream_title: Option<String>,
},
EndFile {
reason: i32,
error: i32,
},
}
pub struct PlayerController {
_config: PlayerConfig,
pub mpv: MpvHandle,
pub render_ctx: Arc<std::sync::Mutex<Option<MpvRenderContext>>>,
pub status: Arc<Mutex<PlayerStatus>>,
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
status_rx: tokio::sync::watch::Receiver<PlayerStatus>,
pub window_id: Mutex<Option<u64>>,
is_render_initialized: AtomicBool,
is_running: Arc<AtomicBool>,
event_thread: std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
}
impl PlayerController {
pub fn new(config: PlayerConfig) -> Arc<Self> {
let initial_status = PlayerStatus::default();
let (status_tx, status_rx) = tokio::sync::watch::channel(initial_status.clone());
let raw_mpv = unsafe { libmpv_sys::mpv_create() };
if raw_mpv.is_null() {
tracing::error!("Failed to create libmpv instance!");
}
let mpv = MpvHandle(raw_mpv);
// Configure MPV options
mpv.set_option_string("vo", "libmpv");
mpv.set_option_string("hwdec", &config.hwdec);
mpv.set_option_string("idle", "yes");
mpv.set_option_string("keep-open", "yes");
mpv.set_option_string("osd-bar", "no");
mpv.set_option_string("osd-level", "0");
mpv.set_option_string("ytdl", "yes");
let ytdlp_path = if std::path::Path::new("/usr/local/bin/yt-dlp").exists() {
"/usr/local/bin/yt-dlp"
} else if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() {
"/home/bart/.local/bin/yt-dlp"
} else {
"yt-dlp"
};
mpv.set_option_string("script-opts", &format!("ytdl_hook-ytdl_path={}", ytdlp_path));
mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080]+bestaudio/best");
mpv.set_option_string("ytdl-raw-options", "no-playlist=");
let init_res = unsafe { libmpv_sys::mpv_initialize(raw_mpv) };
if init_res != 0 {
tracing::warn!("mpv_initialize returned error code: {}", init_res);
} else {
tracing::info!("libmpv initialized successfully (vo=libmpv, hwdec={})", config.hwdec);
}
// Observe properties
unsafe {
libmpv_sys::mpv_observe_property(raw_mpv, 1, b"time-pos\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
libmpv_sys::mpv_observe_property(raw_mpv, 2, b"duration\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
libmpv_sys::mpv_observe_property(raw_mpv, 3, b"pause\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
libmpv_sys::mpv_observe_property(raw_mpv, 4, b"volume\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
libmpv_sys::mpv_observe_property(raw_mpv, 5, b"media-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING);
libmpv_sys::mpv_observe_property(raw_mpv, 6, b"icy-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING);
libmpv_sys::mpv_observe_property(raw_mpv, 7, b"mute\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
libmpv_sys::mpv_observe_property(raw_mpv, 8, b"metadata\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_NODE);
}
let status_arc = Arc::new(Mutex::new(initial_status));
let render_ctx = Arc::new(std::sync::Mutex::new(None));
let is_running = Arc::new(AtomicBool::new(true));
// Create internal event channel to decouple MPV C thread from Tokio async tasks
let (ev_tx, mut ev_rx) = tokio::sync::mpsc::unbounded_channel::<InternalMpvEvent>();
// Spawn Tokio consumer task
let status_consumer = status_arc.clone();
let status_tx_consumer = status_tx.clone();
tokio::spawn(async move {
while let Some(event) = ev_rx.recv().await {
let mut st = status_consumer.lock().await;
let mut notify = false;
match event {
InternalMpvEvent::StartFile | InternalMpvEvent::FileLoaded => {
st.state = PlaybackState::Playing;
st.error_message = None;
notify = true;
}
InternalMpvEvent::TimePos(pos) => {
st.time_pos = pos;
}
InternalMpvEvent::Duration(dur) => {
st.duration = dur;
}
InternalMpvEvent::Pause(paused) => {
let new_state = if paused { PlaybackState::Paused } else { PlaybackState::Playing };
if st.state != new_state && st.state != PlaybackState::Stopped {
st.state = new_state;
notify = true;
}
}
InternalMpvEvent::Volume(vol) => {
if (st.volume - vol).abs() > 0.01 {
st.volume = vol;
notify = true;
}
}
InternalMpvEvent::Mute(m) => {
if st.is_muted != m {
st.is_muted = m;
notify = true;
}
}
InternalMpvEvent::MediaTitle(mt) => {
if st.media_type == "stream" {
if st.stream_title.is_empty() || st.stream_title != mt {
st.stream_title = mt;
notify = true;
}
} else if st.title.is_empty() {
st.title = mt;
notify = true;
}
}
InternalMpvEvent::IcyTitle(icy) => {
if st.stream_title != icy {
st.stream_title = icy;
st.media_type = "stream".to_string();
notify = true;
}
}
InternalMpvEvent::Metadata { artist, genre, stream_title } => {
if let Some(a) = artist {
if st.artist != a {
st.artist = a;
notify = true;
}
}
if let Some(g) = genre {
if st.genre != g {
st.genre = g;
notify = true;
}
}
if let Some(s) = stream_title {
if st.stream_title != s {
st.stream_title = s;
st.media_type = "stream".to_string();
notify = true;
}
}
}
InternalMpvEvent::EndFile { reason, error } => {
if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_ERROR as i32 {
let err_msg = if error != 0 {
let msg = unsafe {
let p = libmpv_sys::mpv_error_string(error);
if !p.is_null() {
CStr::from_ptr(p).to_str().unwrap_or("Afspeelfout")
} else {
"Afspeelfout"
}
};
format!("Afspeelfout: {}", msg)
} else {
"Afspeelfout".to_string()
};
tracing::warn!("MPV playback error: {}", err_msg);
st.state = PlaybackState::Stopped;
st.error_message = Some(err_msg);
notify = true;
} else if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32 {
st.state = PlaybackState::Stopped;
st.time_pos = 0.0;
st.media_type.clear();
st.title.clear();
st.artist.clear();
st.genre.clear();
st.stream_title.clear();
notify = true;
}
// Note: If reason == STOP (2) or REDIRECT (5), this was triggered by loadfile replacing
// the previous file, so we intentionally keep the new file playing!
}
}
if notify {
let _ = status_tx_consumer.send(st.clone());
}
}
});
// Spawn background event thread for MPV
let mpv_for_thread = mpv;
let is_running_for_thread = is_running.clone();
let handle = std::thread::spawn(move || {
run_mpv_event_loop(mpv_for_thread, is_running_for_thread, ev_tx);
});
Arc::new(Self {
_config: config,
mpv,
render_ctx,
status: status_arc,
status_tx,
status_rx,
window_id: Mutex::new(None),
is_render_initialized: AtomicBool::new(false),
is_running,
event_thread: std::sync::Mutex::new(Some(handle)),
})
}
pub fn status_watch_rx(&self) -> tokio::sync::watch::Receiver<PlayerStatus> {
self.status_rx.clone()
}
pub async fn notify_status(&self) {
let st = self.status.lock().await.clone();
let _ = self.status_tx.send(st);
}
pub async fn set_window_id(&self, wid: Option<u64>) {
let mut guard = self.window_id.lock().await;
*guard = wid;
}
pub fn render_context(&self) -> Option<MpvRenderContext> {
*self.render_ctx.lock().unwrap()
}
pub fn ensure_render_context(&self, ctx: &eframe::egui::Context) {
if self.is_render_initialized.swap(true, Ordering::SeqCst) {
return;
}
if self.mpv.0.is_null() {
return;
}
unsafe {
let mut gl_init = libmpv_sys::mpv_opengl_init_params {
get_proc_address: Some(get_proc_address_cb),
get_proc_address_ctx: std::ptr::null_mut(),
extra_exts: std::ptr::null(),
};
let mut params = [
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
data: libmpv_sys::MPV_RENDER_API_TYPE_OPENGL.as_ptr() as *mut _,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
data: &mut gl_init as *mut _ as *mut _,
},
libmpv_sys::mpv_render_param {
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_INVALID,
data: std::ptr::null_mut(),
},
];
let mut rc = std::ptr::null_mut();
let res = libmpv_sys::mpv_render_context_create(&mut rc, self.mpv.0, params.as_mut_ptr());
if res == 0 {
tracing::info!("libmpv OpenGL render context created successfully!");
let ctx_ptr = Box::into_raw(Box::new(ctx.clone())) as *mut std::ffi::c_void;
libmpv_sys::mpv_render_context_set_update_callback(rc, Some(on_mpv_render_update), ctx_ptr);
*self.render_ctx.lock().unwrap() = Some(MpvRenderContext(rc));
} else {
tracing::warn!("Failed to initialize libmpv OpenGL render context (code: {})", res);
self.is_render_initialized.store(false, Ordering::SeqCst);
}
}
}
pub async fn play(&self, url: &str, title: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let media_type = if url.contains("youtube.com") || url.contains("youtu.be") || (url.starts_with("http") && !url.contains("/Videos/") && !url.contains("/Audio/")) {
"stream"
} else {
"video"
};
self.play_media(url, title, media_type, None, None).await
}
pub async fn play_media(
&self,
url: &str,
title: &str,
media_type: &str,
artist: Option<&str>,
genre: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!("Playing media [type={}]: '{}' -> {}", media_type, title, url);
// Update status immediately
{
let mut st = self.status.lock().await;
st.state = PlaybackState::Playing;
st.media_type = media_type.to_string();
st.title = title.to_string();
st.artist = artist.unwrap_or_default().to_string();
st.genre = genre.unwrap_or_default().to_string();
st.stream_title = if media_type == "stream" { title.to_string() } else { String::new() };
st.time_pos = 0.0;
st.error_message = None;
}
self.notify_status().await;
// Strip playlist/index params from single YouTube video playback
let play_url = if url.contains("youtube.com") || url.contains("youtu.be") {
let mut u = url.to_string();
if let Some(pos) = u.find("&list=") {
u.truncate(pos);
}
if let Some(pos) = u.find("&index=") {
u.truncate(pos);
}
u
} else {
url.to_string()
};
// Always reset video and audio track selection to auto so subsequent content plays normally
self.mpv.cmd(&["set", "vid", "auto"]);
self.mpv.cmd(&["set", "aid", "auto"]);
self.mpv.cmd(&["loadfile", &play_url, "replace"]);
Ok(())
}
pub async fn play_pause(&self) {
self.mpv.cmd(&["cycle", "pause"]);
}
pub async fn stop(&self) {
self.mpv.cmd(&["stop"]);
{
let mut st = self.status.lock().await;
st.state = PlaybackState::Stopped;
st.media_type.clear();
st.title.clear();
st.artist.clear();
st.genre.clear();
st.stream_title.clear();
st.time_pos = 0.0;
st.duration = 0.0;
}
self.notify_status().await;
}
pub async fn seek(&self, seconds: f64) {
self.mpv.cmd(&["seek", &seconds.to_string(), "relative"]);
}
pub async fn seek_percent(&self, percent: f64) {
let p = percent.clamp(0.0, 100.0);
self.mpv.cmd(&["seek", &p.to_string(), "absolute-percent"]);
}
pub async fn seek_time(&self, seconds: f64) {
let s = seconds.max(0.0);
self.mpv.cmd(&["seek", &s.to_string(), "absolute"]);
}
pub async fn toggle_mute(&self) {
{
let mut st = self.status.lock().await;
st.is_muted = !st.is_muted;
}
self.mpv.cmd(&["cycle", "mute"]);
self.notify_status().await;
}
pub async fn set_mute(&self, muted: bool) {
{
let mut st = self.status.lock().await;
st.is_muted = muted;
}
self.mpv.cmd(&["set", "mute", if muted { "yes" } else { "no" }]);
self.notify_status().await;
}
pub async fn set_volume(&self, volume: f64) {
let vol = volume.clamp(0.0, 100.0);
{
let mut st = self.status.lock().await;
st.volume = vol;
}
self.mpv.cmd(&["set", "volume", &vol.to_string()]);
self.notify_status().await;
}
pub async fn volume_up(&self) {
{
let mut st = self.status.lock().await;
st.volume = (st.volume + 5.0).min(100.0);
}
self.mpv.cmd(&["add", "volume", "5"]);
self.notify_status().await;
}
pub async fn volume_down(&self) {
{
let mut st = self.status.lock().await;
st.volume = (st.volume - 5.0).max(0.0);
}
self.mpv.cmd(&["add", "volume", "-5"]);
self.notify_status().await;
}
pub async fn cycle_subtitle(&self) {
self.mpv.cmd(&["cycle", "sub"]);
tracing::info!("Cycled subtitle track");
}
pub async fn cycle_audio(&self) {
self.mpv.cmd(&["cycle", "audio"]);
tracing::info!("Cycled audio track");
}
pub async fn set_audio_track(&self, track_id: i32) {
self.mpv.cmd(&["set", "aid", &track_id.to_string()]);
tracing::info!("Set audio track to aid={}", track_id);
}
pub async fn set_video_visible(&self, _visible: bool) {
// No-op with in-process libmpv OpenGL rendering; egui handles visibility directly.
}
}
impl Drop for PlayerController {
fn drop(&mut self) {
self.is_running.store(false, Ordering::SeqCst);
if !self.mpv.0.is_null() {
unsafe {
libmpv_sys::mpv_wakeup(self.mpv.0);
}
}
if let Ok(mut guard) = self.event_thread.lock() {
if let Some(handle) = guard.take() {
let _ = handle.join();
}
}
let rc_opt = self.render_ctx.lock().unwrap().take();
if let Some(rc) = rc_opt {
unsafe {
libmpv_sys::mpv_render_context_free(rc.0);
}
}
if !self.mpv.0.is_null() {
unsafe {
libmpv_sys::mpv_destroy(self.mpv.0);
}
}
}
}
fn run_mpv_event_loop(
mpv: MpvHandle,
is_running: Arc<AtomicBool>,
event_tx: tokio::sync::mpsc::UnboundedSender<InternalMpvEvent>,
) {
if mpv.0.is_null() {
return;
}
while is_running.load(Ordering::SeqCst) {
let ev = unsafe { libmpv_sys::mpv_wait_event(mpv.0, 0.05) };
if !is_running.load(Ordering::SeqCst) {
break;
}
if ev.is_null() {
continue;
}
let event_id = unsafe { (*ev).event_id };
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_SHUTDOWN {
break;
}
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_START_FILE {
let _ = event_tx.send(InternalMpvEvent::StartFile);
continue;
}
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_FILE_LOADED {
let _ = event_tx.send(InternalMpvEvent::FileLoaded);
continue;
}
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_END_FILE {
let end_data = unsafe { (*ev).data as *const libmpv_sys::mpv_event_end_file };
let (reason, error) = if !end_data.is_null() {
unsafe { ((*end_data).reason, (*end_data).error) }
} else {
(libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32, 0)
};
let _ = event_tx.send(InternalMpvEvent::EndFile { reason, error });
continue;
}
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_PROPERTY_CHANGE {
let prop = unsafe { (*ev).data as *const libmpv_sys::mpv_event_property };
if prop.is_null() {
continue;
}
let prop_name = unsafe {
if (*prop).name.is_null() {
""
} else {
CStr::from_ptr((*prop).name).to_str().unwrap_or("")
}
};
match prop_name {
"time-pos" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
let pos = unsafe { *((*prop).data as *const f64) };
let _ = event_tx.send(InternalMpvEvent::TimePos(pos));
}
}
"duration" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
let dur = unsafe { *((*prop).data as *const f64) };
let _ = event_tx.send(InternalMpvEvent::Duration(dur));
}
}
"pause" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
let paused = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
let _ = event_tx.send(InternalMpvEvent::Pause(paused));
}
}
"volume" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
let vol = unsafe { *((*prop).data as *const f64) };
let _ = event_tx.send(InternalMpvEvent::Volume(vol));
}
}
"mute" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
let m = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
let _ = event_tx.send(InternalMpvEvent::Mute(m));
}
}
"media-title" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING } && !unsafe { (*prop).data.is_null() } {
let str_ptr = unsafe { *((*prop).data as *const *const std::ffi::c_char) };
if !str_ptr.is_null() {
let mt = unsafe { CStr::from_ptr(str_ptr).to_str().unwrap_or("") }.trim();
if !mt.is_empty() {
let _ = event_tx.send(InternalMpvEvent::MediaTitle(mt.to_string()));
}
}
}
}
"icy-title" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING } && !unsafe { (*prop).data.is_null() } {
let str_ptr = unsafe { *((*prop).data as *const *const std::ffi::c_char) };
if !str_ptr.is_null() {
let icy = unsafe { CStr::from_ptr(str_ptr).to_str().unwrap_or("") }.trim();
if !icy.is_empty() {
let _ = event_tx.send(InternalMpvEvent::IcyTitle(icy.to_string()));
}
}
}
}
"metadata" => {
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_NODE } && !unsafe { (*prop).data.is_null() } {
let node = unsafe { &*((*prop).data as *const libmpv_sys::mpv_node) };
if node.format == libmpv_sys::mpv_format_MPV_FORMAT_NODE_MAP {
let mut artist = None;
let mut genre = None;
let mut stream_title = None;
unsafe {
let list = &*node.u.list;
for i in 0..list.num {
let key_ptr = *list.keys.offset(i as isize);
let val_node = list.values.offset(i as isize);
if !key_ptr.is_null() && !val_node.is_null() && (*val_node).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING {
let key = CStr::from_ptr(key_ptr).to_str().unwrap_or("");
let val_ptr = (*val_node).u.string;
if !val_ptr.is_null() {
let val = CStr::from_ptr(val_ptr).to_str().unwrap_or("").trim();
if !val.is_empty() {
if key.eq_ignore_ascii_case("artist") {
artist = Some(val.to_string());
} else if key.eq_ignore_ascii_case("genre") {
genre = Some(val.to_string());
} else if key.eq_ignore_ascii_case("icy-title") || key.eq_ignore_ascii_case("StreamTitle") {
stream_title = Some(val.to_string());
}
}
}
}
}
}
if artist.is_some() || genre.is_some() || stream_title.is_some() {
let _ = event_tx.send(InternalMpvEvent::Metadata { artist, genre, stream_title });
}
}
}
}
_ => {}
}
}
}
}
+966
View File
@@ -0,0 +1,966 @@
use axum::{
extract::State,
response::{IntoResponse, Json},
routing::{get, post},
Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use crate::player::{PlaybackState, PlayerController};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteNavAction {
Up,
Down,
Left,
Right,
Select,
Back,
ToggleOSD,
CycleSubtitle,
CycleAudio,
ToggleMute,
TogglePlaybackView,
ActivateSection(usize),
VolumeUp,
VolumeDown,
PlayPause,
Stop,
}
#[derive(Clone)]
pub struct RemoteNavSender {
tx: UnboundedSender<RemoteNavAction>,
ctx: Arc<std::sync::Mutex<Option<eframe::egui::Context>>>,
}
impl RemoteNavSender {
pub fn new(tx: UnboundedSender<RemoteNavAction>, ctx: Arc<std::sync::Mutex<Option<eframe::egui::Context>>>) -> Self {
Self { tx, ctx }
}
pub fn send(&self, action: RemoteNavAction) -> Result<(), tokio::sync::mpsc::error::SendError<RemoteNavAction>> {
let res = self.tx.send(action);
if let Ok(guard) = self.ctx.lock() {
if let Some(ref c) = *guard {
c.request_repaint();
}
}
res
}
}
impl From<UnboundedSender<RemoteNavAction>> for RemoteNavSender {
fn from(tx: UnboundedSender<RemoteNavAction>) -> Self {
Self {
tx,
ctx: Arc::new(std::sync::Mutex::new(None)),
}
}
}
#[derive(Clone)]
pub struct YatseServerState {
pub player: Arc<PlayerController>,
pub nav_tx: RemoteNavSender,
}
#[derive(Debug, Deserialize)]
struct JsonRpcRequest {
#[allow(dead_code)]
jsonrpc: Option<String>,
method: String,
params: Option<Value>,
id: Option<Value>,
}
pub fn create_yatse_router(state: YatseServerState) -> Router {
Router::new()
.route("/", get(handle_root))
.route("/jsonrpc", post(handle_jsonrpc).get(handle_jsonrpc_get))
.with_state(state)
}
async fn handle_root() -> impl IntoResponse {
"Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse"
}
fn percent_decode(input: &str) -> String {
let mut bytes = Vec::new();
let mut chars = input.bytes();
while let Some(b) = chars.next() {
if b == b'%' {
let h1 = chars.next().unwrap_or(b'0');
let h2 = chars.next().unwrap_or(b'0');
let hex = [h1, h2];
if let Ok(s) = std::str::from_utf8(&hex) {
if let Ok(val) = u8::from_str_radix(s, 16) {
bytes.push(val);
continue;
}
}
bytes.push(b'%');
bytes.push(h1);
bytes.push(h2);
} else if b == b'+' {
bytes.push(b' ');
} else {
bytes.push(b);
}
}
String::from_utf8_lossy(&bytes).to_string()
}
pub fn normalize_playback_url(file: &str) -> String {
let trimmed = file.trim();
if trimmed.starts_with("plugin://plugin.video.youtube") {
if let Some(query) = trimmed.split('?').nth(1) {
for param in query.split('&') {
let mut kv = param.splitn(2, '=');
if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
let k_lower = k.to_lowercase();
if k_lower == "video_id" || k_lower == "videoid" {
let vid = percent_decode(v);
return format!("https://www.youtube.com/watch?v={}", vid);
}
}
}
}
} else if trimmed.starts_with("plugin://plugin.video.sendtokodi") {
if let Some(query) = trimmed.split('?').nth(1) {
let decoded = percent_decode(query);
if decoded.starts_with("http://") || decoded.starts_with("https://") {
return decoded;
}
if let Some(url_part) = decoded.split("url=").nth(1) {
return percent_decode(url_part);
}
}
}
trimmed.to_string()
}
async fn handle_jsonrpc_get(
State(state): State<YatseServerState>,
uri: axum::http::Uri,
) -> impl IntoResponse {
if let Some(query) = uri.query() {
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if let (Some(k), Some(v)) = (parts.next(), parts.next()) {
if k == "request" {
let decoded = percent_decode(v);
if let Ok(val) = serde_json::from_str::<Value>(&decoded) {
if let Some(arr) = val.as_array() {
let mut results = Vec::new();
for item in arr {
if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(item.clone()) {
results.push(dispatch_rpc(&state, parsed).await);
}
}
return Json(Value::Array(results));
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(val) {
let res = dispatch_rpc(&state, parsed).await;
return Json(res);
}
}
}
}
}
}
Json(json!({
"jsonrpc": "2.0",
"result": {
"version": {"major": 19, "minor": 0, "patch": 0}
},
"id": 1
}))
}
pub async fn start_tcp_jsonrpc_server(port: u16, state: YatseServerState) {
let addr = format!("0.0.0.0:{}", port);
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => {
tracing::info!("Yatse Kodi TCP JSON-RPC socket server listening on {}", addr);
l
}
Err(e) => {
tracing::warn!("Failed to bind TCP JSON-RPC server on {}: {}", addr, e);
return;
}
};
tokio::spawn(async move {
loop {
let (socket, peer) = match listener.accept().await {
Ok(res) => res,
Err(e) => {
tracing::warn!("Error accepting TCP JSON-RPC connection: {}", e);
continue;
}
};
tracing::info!("Yatse TCP JSON-RPC connection accepted from {}", peer);
let state_clone = state.clone();
tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let (reader, mut writer) = socket.into_split();
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
loop {
line.clear();
match buf_reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(val) = serde_json::from_str::<Value>(trimmed) {
if let Some(arr) = val.as_array() {
let mut results = Vec::new();
for item in arr {
if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(item.clone()) {
results.push(dispatch_rpc(&state_clone, parsed).await);
}
}
let out = serde_json::to_string(&Value::Array(results)).unwrap_or_default() + "\n";
let _ = writer.write_all(out.as_bytes()).await;
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(val) {
let res = dispatch_rpc(&state_clone, parsed).await;
let out = serde_json::to_string(&res).unwrap_or_default() + "\n";
let _ = writer.write_all(out.as_bytes()).await;
}
}
}
Err(e) => {
tracing::debug!("TCP client {} read error: {}", peer, e);
break;
}
}
}
tracing::info!("Yatse TCP connection closed from {}", peer);
});
}
});
}
async fn handle_jsonrpc(
State(state): State<YatseServerState>,
Json(req): Json<Value>,
) -> impl IntoResponse {
if let Some(arr) = req.as_array() {
let mut results = Vec::new();
for item in arr {
if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(item.clone()) {
results.push(dispatch_rpc(&state, parsed).await);
}
}
Json(Value::Array(results))
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(req) {
let res = dispatch_rpc(&state, parsed).await;
Json(res)
} else {
Json(json!({
"jsonrpc": "2.0",
"error": {"code": -32700, "message": "Parse error"},
"id": Value::Null
}))
}
}
async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
let id = req.id.unwrap_or(Value::Null);
let method = req.method.as_str();
tracing::info!("Yatse JSON-RPC: method={}, params={:?}", method, req.params);
match method {
"JSONRPC.Ping" => json!({
"jsonrpc": "2.0",
"result": "pong",
"id": id
}),
"JSONRPC.Version" => json!({
"jsonrpc": "2.0",
"result": {"version": {"major": 19, "minor": 0, "patch": 0}},
"id": id
}),
"Application.GetProperties" => {
let st = state.player.status.lock().await;
json!({
"jsonrpc": "2.0",
"result": {
"volume": st.volume as i64,
"muted": st.is_muted,
"name": "Kodi",
"version": {"major": 19, "minor": 0, "revision": "20260401", "tag": "stable"},
"language": "en_GB",
"sorttokens": ["The ", "The.", "The_"]
},
"id": id
})
}
"Application.SetMute" => {
let mute_param = req.params.as_ref()
.and_then(|p| p.get("mute").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|m| m.as_bool());
let is_muted = match mute_param {
Some(m) => {
state.player.set_mute(m).await;
m
}
None => {
state.player.toggle_mute().await;
state.player.status.lock().await.is_muted
}
};
json!({
"jsonrpc": "2.0",
"result": is_muted,
"id": id
})
}
"Application.SetVolume" => {
let vol_param = req.params.as_ref()
.and_then(|p| p.get("volume").or_else(|| p.as_array().and_then(|a| a.first())));
let new_vol = if let Some(v_str) = vol_param.and_then(|v| v.as_str()) {
if v_str == "increment" {
state.player.volume_up().await;
state.player.status.lock().await.volume as i64
} else if v_str == "decrement" {
state.player.volume_down().await;
state.player.status.lock().await.volume as i64
} else {
state.player.status.lock().await.volume as i64
}
} else if let Some(v) = vol_param.and_then(|v| v.as_f64()) {
state.player.set_volume(v).await;
v.clamp(0.0, 100.0) as i64
} else {
state.player.status.lock().await.volume as i64
};
json!({
"jsonrpc": "2.0",
"result": new_vol,
"id": id
})
}
"Player.GetActivePlayers" => {
let st = state.player.status.lock().await;
if st.state != PlaybackState::Stopped {
let pid = if st.media_type == "audio" { 0 } else { 1 };
let ptype = if st.media_type == "audio" { "audio" } else { "video" };
json!({
"jsonrpc": "2.0",
"result": [{"playerid": pid, "playertype": "internal", "type": ptype}],
"id": id
})
} else {
json!({
"jsonrpc": "2.0",
"result": [],
"id": id
})
}
}
"Player.GetProperties" => {
let st = state.player.status.lock().await;
let time = seconds_to_kodi_time(st.time_pos);
let totaltime = seconds_to_kodi_time(st.duration);
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
let ptype = if st.media_type == "audio" { "audio" } else { "video" };
json!({
"jsonrpc": "2.0",
"result": {
"canseek": true,
"canchangespeed": true,
"canmove": false,
"canzoom": false,
"canrotate": false,
"speed": speed,
"time": time,
"totaltime": totaltime,
"percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 },
"type": ptype,
"live": false,
"subtitleenabled": true,
"subtitles": [
{"index": 0, "name": "Ondertitel 1", "language": ""},
{"index": 1, "name": "Ondertitel 2", "language": ""}
],
"currentsubtitle": {"index": 0, "name": "Ondertitel", "language": ""},
"audiostreams": [
{"index": 0, "name": "Audiostroom 1", "language": ""},
{"index": 1, "name": "Audiostroom 2", "language": ""}
],
"currentaudiostream": {"index": 0, "name": "Audiostroom", "language": ""}
},
"id": id
})
}
"Player.GetItem" => {
let st = state.player.status.lock().await;
let title = if !st.stream_title.is_empty() {
&st.stream_title
} else if !st.title.is_empty() {
&st.title
} else {
"Media"
};
let itype = if st.media_type == "audio" { "song" } else { "movie" };
json!({
"jsonrpc": "2.0",
"result": {
"item": {
"label": title,
"title": title,
"artist": if !st.artist.is_empty() { vec![st.artist.clone()] } else { Vec::new() },
"type": itype
}
},
"id": id
})
}
"Player.PlayPause" => {
state.player.play_pause().await;
let st = state.player.status.lock().await;
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
json!({
"jsonrpc": "2.0",
"result": {"speed": speed},
"id": id
})
}
"Player.Stop" => {
state.player.stop().await;
let _ = state.nav_tx.send(RemoteNavAction::Stop);
json!({
"jsonrpc": "2.0",
"result": "OK",
"id": id
})
}
"Player.Seek" => {
let mut result_pct = 0.0;
if let Some(ref params) = req.params {
let val = params.get("value").or_else(|| params.as_array().and_then(|a| a.get(1)));
if let Some(v) = val {
if let Some(pct) = v.get("percentage").and_then(|p| p.as_f64()) {
state.player.seek_percent(pct).await;
result_pct = pct;
} else if let Some(time_obj) = v.get("time") {
let h = time_obj.get("hours").and_then(|x| x.as_f64()).unwrap_or(0.0);
let m = time_obj.get("minutes").and_then(|x| x.as_f64()).unwrap_or(0.0);
let s = time_obj.get("seconds").and_then(|x| x.as_f64()).unwrap_or(0.0);
let total_secs = h * 3600.0 + m * 60.0 + s;
state.player.seek_time(total_secs).await;
} else if let Some(step) = v.as_str() {
match step.to_lowercase().as_str() {
"smallforward" => { state.player.seek(10.0).await; }
"smallbackward" => { state.player.seek(-10.0).await; }
"bigforward" => { state.player.seek(60.0).await; }
"bigbackward" => { state.player.seek(-60.0).await; }
_ => {}
}
} else if let Some(n) = v.as_f64() {
if n >= 0.0 && n <= 100.0 {
state.player.seek_percent(n).await;
result_pct = n;
} else {
state.player.seek(n).await;
}
}
}
}
let st = state.player.status.lock().await;
let time = seconds_to_kodi_time(st.time_pos);
let totaltime = seconds_to_kodi_time(st.duration);
let pct = if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { result_pct };
json!({
"jsonrpc": "2.0",
"result": {
"percentage": pct,
"time": time,
"totaltime": totaltime
},
"id": id
})
}
"Player.SetSubtitle" => {
state.player.cycle_subtitle().await;
let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle);
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Player.SetAudioStream" => {
if let Some(ref params) = req.params {
let stream_param = params.get("stream").or_else(|| params.as_array().and_then(|a| a.get(1)));
if let Some(s) = stream_param {
if let Some(idx) = s.as_i64() {
state.player.set_audio_track((idx + 1) as i32).await;
} else if s.as_str() == Some("next") {
state.player.cycle_audio().await;
} else if s.as_str() == Some("previous") {
state.player.cycle_audio().await;
}
} else {
state.player.cycle_audio().await;
}
} else {
state.player.cycle_audio().await;
}
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Player.SetSpeed" => {
if let Some(ref params) = req.params {
let speed_val = params.get("speed").or_else(|| params.as_array().and_then(|a| a.get(1)));
if let Some(s) = speed_val.and_then(|v| v.as_i64()) {
if s == 0 {
let st = state.player.status.lock().await;
if st.state == PlaybackState::Playing {
drop(st);
state.player.play_pause().await;
}
} else if s == 1 {
let st = state.player.status.lock().await;
if st.state == PlaybackState::Paused {
drop(st);
state.player.play_pause().await;
}
}
} else if let Some(s_str) = speed_val.and_then(|v| v.as_str()) {
if s_str == "increment" || s_str == "decrement" {
state.player.play_pause().await;
}
}
}
let st = state.player.status.lock().await;
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
json!({"jsonrpc": "2.0", "result": {"speed": speed}, "id": id})
}
"Player.Open" => {
if let Some(params) = req.params {
if let Some(item) = params.get("item") {
if let Some(file) = item.get("file").and_then(|f| f.as_str()) {
let norm_url = normalize_playback_url(file);
tracing::info!("Player.Open requested file: '{}' -> normalized: '{}'", file, norm_url);
let p = state.player.clone();
tokio::spawn(async move {
let _ = p.play(&norm_url, "Media from Yatse").await;
});
}
}
}
json!({
"jsonrpc": "2.0",
"result": "OK",
"id": id
})
}
// Remote D-Pad inputs forwarded directly to the on-screen UI
"Input.Up" => {
let _ = state.nav_tx.send(RemoteNavAction::Up);
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.Down" => {
let _ = state.nav_tx.send(RemoteNavAction::Down);
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.Left" => {
let _ = state.nav_tx.send(RemoteNavAction::Left);
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.Right" => {
let _ = state.nav_tx.send(RemoteNavAction::Right);
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.Select" => {
let _ = state.nav_tx.send(RemoteNavAction::Select);
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.Back" => {
let _ = state.nav_tx.send(RemoteNavAction::Back);
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.ContextMenu" | "Input.Info" => {
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Input.ExecuteAction" => {
let raw_action = req.params.as_ref()
.and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|a| a.as_str())
.unwrap_or("");
let action = if raw_action.to_lowercase().starts_with("action(") && raw_action.ends_with(')') {
raw_action[7..raw_action.len() - 1].trim().to_lowercase()
} else {
raw_action.to_lowercase()
};
match action.as_str() {
"up" | "cursorup" | "3" => { let _ = state.nav_tx.send(RemoteNavAction::Up); }
"down" | "cursordown" | "4" => { let _ = state.nav_tx.send(RemoteNavAction::Down); }
"left" | "cursorleft" | "1" | "stepback" | "smallstepback" => { let _ = state.nav_tx.send(RemoteNavAction::Left); }
"right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => { let _ = state.nav_tx.send(RemoteNavAction::Right); }
"bigstepforward" => {
let p = state.player.clone();
tokio::spawn(async move { p.seek(60.0).await; });
}
"bigstepback" => {
let p = state.player.clone();
tokio::spawn(async move { p.seek(-60.0).await; });
}
"select" | "enter" | "7" => { let _ = state.nav_tx.send(RemoteNavAction::Select); }
"back" | "previousmenu" | "parentdir" | "close" | "9" | "10" | "escape" => { let _ = state.nav_tx.send(RemoteNavAction::Back); }
"togglefullscreen" | "12" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); }
"osd" | "nextsubtitle" | "114" | "122" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); }
"audionextlanguage" | "nextaudio" => {
let p = state.player.clone();
tokio::spawn(async move { p.cycle_audio().await; });
}
"title" | "menu" | "11" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); }
"volumeup" | "88" => {
let p = state.player.clone();
tokio::spawn(async move { p.volume_up().await; });
}
"volumedown" | "89" => {
let p = state.player.clone();
tokio::spawn(async move { p.volume_down().await; });
}
"playpause" | "pause" | "play" | "15" => { state.player.play_pause().await; }
"stop" | "14" => {
let _ = state.nav_tx.send(RemoteNavAction::Stop);
}
"mute" | "togglemute" | "13" => {
let p = state.player.clone();
tokio::spawn(async move { p.toggle_mute().await; });
}
_ => {}
}
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"GUI.ActivateWindow" => {
if let Some(params) = req.params {
let p_str = params.to_string().to_lowercase();
if p_str.contains("tvshow") || p_str.contains("series") {
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3));
} else if p_str.contains("movietitles") || p_str.contains("movie") || p_str.contains("video") {
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2));
} else if p_str.contains("music") || p_str.contains("song") || p_str.contains("artist") || p_str.contains("album") {
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4));
} else if p_str.contains("picture") || p_str.contains("photo") {
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(5));
} else if p_str.contains("livetv") || p_str.contains("channel") || p_str.contains("tv") {
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1));
} else if p_str.contains("home") {
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(0));
}
}
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"XBMC.GetInfoBooleans" => {
let mut res = serde_json::Map::new();
let mut requested = Vec::new();
if let Some(ref params) = req.params {
if let Some(arr) = params.get("booleans").and_then(|b| b.as_array()) {
for b in arr {
if let Some(s) = b.as_str() {
requested.push(s.to_string());
}
}
} else if let Some(arr) = params.as_array().and_then(|a| a.first()).and_then(|b| b.as_array()) {
for b in arr {
if let Some(s) = b.as_str() {
requested.push(s.to_string());
}
}
}
}
for s in &requested {
let val = match s.as_str() {
"Services.EventServer" | "Services.Webserver" | "Services.UPnP" => true,
"System.Platform.Linux" => true,
s if s.starts_with("System.Platform.") => false,
_ => true,
};
res.insert(s.clone(), json!(val));
}
if res.is_empty() {
res.insert("Services.EventServer".to_string(), json!(true));
res.insert("Services.Webserver".to_string(), json!(true));
res.insert("System.Platform.Linux".to_string(), json!(true));
}
json!({"jsonrpc": "2.0", "result": res, "id": id})
}
"XBMC.GetInfoLabels" => {
let mut res = serde_json::Map::new();
let mut requested = Vec::new();
if let Some(ref params) = req.params {
if let Some(arr) = params.get("labels").and_then(|b| b.as_array()) {
for b in arr {
if let Some(s) = b.as_str() {
requested.push(s.to_string());
}
}
} else if let Some(arr) = params.as_array().and_then(|a| a.first()).and_then(|b| b.as_array()) {
for b in arr {
if let Some(s) = b.as_str() {
requested.push(s.to_string());
}
}
}
}
for s in &requested {
let val = match s.as_str() {
"System.BuildVersion" => "19.4 (19.4.0) Kodi",
"System.FriendlyName" => "HTPC",
"System.KernelVersion" => "Linux 6.8.0",
_ => "",
};
res.insert(s.clone(), json!(val));
}
json!({"jsonrpc": "2.0", "result": res, "id": id})
}
"Settings.GetSettingValue" => {
let setting = req.params.as_ref()
.and_then(|p| p.get("setting").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|s| s.as_str())
.unwrap_or("")
.to_lowercase();
let val = if setting.contains("port") {
if setting.contains("es") { json!(9777) } else { json!(8080) }
} else if setting.contains("passthrough") {
// Audio passthrough is FALSE, so standard volume controls work!
json!(false)
} else {
json!(true)
};
json!({"jsonrpc": "2.0", "result": {"value": val}, "id": id})
}
"Settings.GetSettings" => {
json!({
"jsonrpc": "2.0",
"result": {
"settings": [
{"id": "services.eventserver", "value": true},
{"id": "services.esenabled", "value": true},
{"id": "services.esport", "value": 9777},
{"id": "services.esallinterfaces", "value": true},
{"id": "services.webserver", "value": true},
{"id": "services.webserverport", "value": 8080},
{"id": "audiooutput.passthrough", "value": false}
]
},
"id": id
})
}
"Settings.SetSettingValue" => {
json!({"jsonrpc": "2.0", "result": true, "id": id})
}
"JSONRPC.Permission" => {
json!({
"jsonrpc": "2.0",
"result": {
"ControlGUI": true,
"ControlNotify": true,
"ControlPlayback": true,
"ControlPower": true,
"ControlSystem": true,
"ExecuteAddon": true,
"ManageFiles": true,
"Navigate": true,
"ReadData": true,
"WriteData": true
},
"id": id
})
}
"GUI.GetProperties" => {
json!({
"jsonrpc": "2.0",
"result": {
"currentwindow": {"id": 10000, "label": "Home"},
"fullscreen": true,
"skin": {"id": "skin.estuary", "name": "Estuary"}
},
"id": id
})
}
"System.GetProperties" => {
json!({
"jsonrpc": "2.0",
"result": {
"canreboot": true,
"canshutdown": true,
"cansuspend": false
},
"id": id
})
}
"GUI.ShowNotification" => {
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Addons.GetAddonDetails" => {
let addon_id = req.params.as_ref()
.and_then(|p| p.get("addonid").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|s| s.as_str())
.unwrap_or("plugin.video.youtube");
let (name, a_type, version) = match addon_id {
"plugin.video.youtube" => ("YouTube", "xbmc.python.pluginsource", "7.0.0"),
"script.yatse.kodi" => ("Yatse Kodi Helper Script", "xbmc.service", "2.0.0"),
"plugin.video.sendtokodi" | "plugin.video.sendtokodi.python3" => ("SendToKodi", "xbmc.python.pluginsource", "1.0.0"),
other => (other, "xbmc.python.pluginsource", "1.0.0"),
};
json!({
"jsonrpc": "2.0",
"result": {
"addon": {
"addonid": addon_id,
"name": name,
"type": a_type,
"version": version,
"summary": name,
"description": name,
"author": "Yatse / Community",
"enabled": true
}
},
"id": id
})
}
"Addons.GetAddons" => {
json!({
"jsonrpc": "2.0",
"result": {
"addons": [
{
"addonid": "plugin.video.youtube",
"name": "YouTube",
"type": "xbmc.python.pluginsource",
"version": "7.0.0",
"enabled": true
},
{
"addonid": "script.yatse.kodi",
"name": "Yatse Kodi Helper Script",
"type": "xbmc.service",
"version": "2.0.0",
"enabled": true
},
{
"addonid": "plugin.video.sendtokodi",
"name": "SendToKodi",
"type": "xbmc.python.pluginsource",
"version": "1.0.0",
"enabled": true
}
],
"limits": {
"start": 0,
"end": 3,
"total": 3
}
},
"id": id
})
}
"Addons.ExecuteAddon" => {
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
}
"Files.GetSources" => {
let media = req.params.as_ref()
.and_then(|p| p.get("media").or_else(|| p.as_array().and_then(|a| a.first())))
.and_then(|s| s.as_str())
.unwrap_or("video");
let sources = if media == "video" {
vec![
json!({
"file": "plugin://plugin.video.youtube/",
"label": "YouTube",
"media": "video"
})
]
} else {
vec![]
};
json!({
"jsonrpc": "2.0",
"result": {
"sources": sources,
"limits": {"start": 0, "end": 1, "total": 1}
},
"id": id
})
}
"Files.GetDirectory" => {
json!({
"jsonrpc": "2.0",
"result": {
"files": [],
"limits": {"start": 0, "end": 0, "total": 0}
},
"id": id
})
}
_ => {
json!({
"jsonrpc": "2.0",
"result": "OK",
"id": id
})
}
}
}
fn seconds_to_kodi_time(seconds: f64) -> Value {
let s = seconds.max(0.0) as u64;
let hours = s / 3600;
let minutes = (s % 3600) / 60;
let secs = s % 60;
let millis = ((seconds - s as f64) * 1000.0) as u64;
json!({
"hours": hours,
"minutes": minutes,
"seconds": secs,
"milliseconds": millis
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_playback_url() {
assert_eq!(
normalize_playback_url("plugin://plugin.video.youtube/play/?video_id=6G45Qk0HQNY"),
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
);
assert_eq!(
normalize_playback_url("plugin://plugin.video.youtube/?action=play_video&videoid=6G45Qk0HQNY"),
"https://www.youtube.com/watch?v=6G45Qk0HQNY"
);
assert_eq!(
normalize_playback_url("plugin://plugin.video.sendtokodi/?https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3D6G45Qk0HQNY"),
"https://youtube.com/watch?v=6G45Qk0HQNY"
);
assert_eq!(
normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"),
"https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"
);
}
}
+668
View File
@@ -0,0 +1,668 @@
use reqwest::header::AUTHORIZATION;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::config::YouTubeConfig;
use crate::jellyfin::MediaItem;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_url: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredToken {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: u64,
pub token_type: String,
}
#[derive(Debug, Clone)]
pub enum DevicePollResult {
Pending,
SlowDown,
Success,
Error(String),
}
#[derive(Debug, Clone)]
pub struct YouTubeClient {
config: YouTubeConfig,
client: reqwest::Client,
token: Arc<RwLock<Option<StoredToken>>>,
}
#[derive(Debug, Deserialize)]
struct SubscriptionsResponse {
items: Option<Vec<SubscriptionItem>>,
}
#[derive(Debug, Deserialize)]
struct SubscriptionItem {
snippet: Option<SubscriptionSnippet>,
}
#[derive(Debug, Deserialize)]
struct SubscriptionSnippet {
title: String,
description: Option<String>,
#[serde(rename = "resourceId")]
resource_id: Option<ResourceId>,
thumbnails: Option<Thumbnails>,
}
#[derive(Debug, Deserialize)]
struct ResourceId {
#[serde(rename = "channelId")]
channel_id: Option<String>,
#[serde(rename = "videoId")]
video_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Thumbnails {
default: Option<ThumbnailItem>,
medium: Option<ThumbnailItem>,
high: Option<ThumbnailItem>,
}
#[derive(Debug, Deserialize)]
struct ThumbnailItem {
url: String,
}
#[derive(Debug, Deserialize)]
struct PlaylistItemsResponse {
items: Option<Vec<PlaylistItem>>,
}
#[derive(Debug, Deserialize)]
struct PlaylistItem {
snippet: Option<PlaylistItemSnippet>,
}
#[derive(Debug, Deserialize)]
struct PlaylistItemSnippet {
title: String,
description: Option<String>,
#[serde(rename = "publishedAt")]
published_at: Option<String>,
#[serde(rename = "channelTitle")]
channel_title: Option<String>,
#[serde(rename = "resourceId")]
resource_id: Option<ResourceId>,
thumbnails: Option<Thumbnails>,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
items: Option<Vec<SearchItem>>,
}
#[derive(Debug, Deserialize)]
struct SearchItem {
id: Option<ResourceId>,
snippet: Option<PlaylistItemSnippet>,
}
fn best_thumbnail(t: &Option<Thumbnails>) -> Option<String> {
if let Some(t) = t {
if let Some(h) = &t.high {
return Some(h.url.clone());
}
if let Some(m) = &t.medium {
return Some(m.url.clone());
}
if let Some(d) = &t.default {
return Some(d.url.clone());
}
}
None
}
fn resolve_token_file(path_str: &str) -> PathBuf {
if path_str.starts_with("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(&path_str[2..]);
}
}
PathBuf::from(path_str)
}
fn current_timestamp() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl YouTubeClient {
pub fn new(config: YouTubeConfig) -> Self {
let mut initial_token = None;
let token_path = resolve_token_file(&config.token_path);
if token_path.exists() {
if let Ok(data) = std::fs::read_to_string(&token_path) {
if let Ok(t) = serde_json::from_str::<StoredToken>(&data) {
tracing::info!("Loaded cached YouTube token from {}", token_path.display());
initial_token = Some(t);
}
}
}
Self {
config,
client: reqwest::Client::builder().build().unwrap_or_default(),
token: Arc::new(RwLock::new(initial_token)),
}
}
pub fn is_configured(&self) -> bool {
!self.config.client_id.trim().is_empty()
}
pub async fn is_logged_in(&self) -> bool {
let guard = self.token.read().await;
if let Some(t) = guard.as_ref() {
t.expires_at > current_timestamp() || t.refresh_token.is_some()
} else {
false
}
}
pub async fn logout(&self) {
{
let mut guard = self.token.write().await;
*guard = None;
}
let token_path = resolve_token_file(&self.config.token_path);
if token_path.exists() {
let _ = std::fs::remove_file(token_path);
}
}
fn save_token(&self, token: &StoredToken) {
let token_path = resolve_token_file(&self.config.token_path);
if let Some(parent) = token_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(data) = serde_json::to_string_pretty(token) {
let _ = std::fs::write(&token_path, data);
}
}
pub async fn start_device_auth(&self) -> Result<DeviceCodeResponse, String> {
if !self.is_configured() {
return Err("YouTube Client ID is niet ingesteld in config.yaml".to_string());
}
tracing::info!("Starting YouTube OAuth Device Code flow for client_id: {}...", self.config.client_id);
let params = [
("client_id", self.config.client_id.as_str()),
("scope", "https://www.googleapis.com/auth/youtube.readonly"),
];
let res = self
.client
.post("https://oauth2.googleapis.com/device/code")
.form(&params)
.send()
.await
.map_err(|e| {
tracing::error!("Network error starting YouTube device auth: {}", e);
format!("Netwerkfout bij starten authenticatie: {}", e)
})?;
if !res.status().is_success() {
let status = res.status();
let text = res.text().await.unwrap_or_default();
tracing::error!("Google OAuth device/code error (status {}): {}", status, text);
if text.contains("Invalid client type") {
return Err("Ongeldig OAuth Client ID type. Kies in Google Cloud Console bij het aanmaken van het Client ID het type: 'Tv's en apparaten met beperkte invoer' (TVs and Limited Input devices).".to_string());
}
return Err(format!("Google API fout ({}): {}", status, text));
}
let resp: DeviceCodeResponse = res
.json()
.await
.map_err(|e| {
tracing::error!("Failed to parse Google response: {}", e);
format!("Ongeldig antwoord van Google: {}", e)
})?;
tracing::info!(
"YouTube Device Code generated: user_code='{}', url='{}'",
resp.user_code,
resp.verification_url
);
Ok(resp)
}
pub async fn poll_device_token(&self, device_code: &str) -> DevicePollResult {
let mut params = vec![
("client_id", self.config.client_id.clone()),
("device_code", device_code.to_string()),
(
"grant_type",
"urn:ietf:params:oauth:grant-type:device_code".to_string(),
),
];
if let Some(secret) = &self.config.client_secret {
if !secret.trim().is_empty() {
params.push(("client_secret", secret.clone()));
}
}
let res = match self
.client
.post("https://oauth2.googleapis.com/token")
.form(&params)
.send()
.await
{
Ok(r) => r,
Err(e) => return DevicePollResult::Error(format!("Netwerkfout: {}", e)),
};
let status = res.status();
let body: serde_json::Value = match res.json().await {
Ok(v) => v,
Err(e) => return DevicePollResult::Error(format!("JSON fout: {}", e)),
};
if status.is_success() {
let access_token = body
.get("access_token")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let refresh_token = body
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expires_in = body
.get("expires_in")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
let token_type = body
.get("token_type")
.and_then(|v| v.as_str())
.unwrap_or("Bearer")
.to_string();
let stored = StoredToken {
access_token,
refresh_token,
expires_at: current_timestamp() + expires_in,
token_type,
};
self.save_token(&stored);
{
let mut guard = self.token.write().await;
*guard = Some(stored);
}
return DevicePollResult::Success;
}
if let Some(err) = body.get("error").and_then(|e| e.as_str()) {
match err {
"authorization_pending" => DevicePollResult::Pending,
"slow_down" => DevicePollResult::SlowDown,
"access_denied" => {
DevicePollResult::Error("Toestemming geweigerd door gebruiker.".to_string())
}
"expired_token" => {
DevicePollResult::Error("De koppelcode is verlopen. Probeer opnieuw.".to_string())
}
other => DevicePollResult::Error(format!("Google fout: {}", other)),
}
} else {
DevicePollResult::Error(format!("Onbekende fout van Google (status {})", status))
}
}
pub async fn get_valid_token(&self) -> Result<String, String> {
let (current_access, refresh_opt, is_expired) = {
let guard = self.token.read().await;
if let Some(t) = guard.as_ref() {
let now = current_timestamp();
(t.access_token.clone(), t.refresh_token.clone(), t.expires_at <= now + 60)
} else {
return Err("Niet ingelogd op YouTube".to_string());
}
};
if !is_expired {
return Ok(current_access);
}
// Need to refresh
let refresh_token = match refresh_opt {
Some(rt) => rt,
None => return Err("Geen refresh token aanwezig. Log opnieuw in.".to_string()),
};
let mut params = vec![
("client_id", self.config.client_id.clone()),
("refresh_token", refresh_token.clone()),
("grant_type", "refresh_token".to_string()),
];
if let Some(secret) = &self.config.client_secret {
if !secret.trim().is_empty() {
params.push(("client_secret", secret.clone()));
}
}
let res = self
.client
.post("https://oauth2.googleapis.com/token")
.form(&params)
.send()
.await
.map_err(|e| format!("Netwerkfout bij vernieuwen YouTube token: {}", e))?;
if !res.status().is_success() {
return Err("Kon YouTube sessie niet vernieuwen. Log opnieuw in.".to_string());
}
let body: serde_json::Value = res
.json()
.await
.map_err(|e| format!("JSON fout bij token vernieuwen: {}", e))?;
let new_access = body
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| "Geen access_token in antwoord".to_string())?
.to_string();
let expires_in = body
.get("expires_in")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
let updated = StoredToken {
access_token: new_access.clone(),
refresh_token: Some(refresh_token),
expires_at: current_timestamp() + expires_in,
token_type: "Bearer".to_string(),
};
self.save_token(&updated);
{
let mut guard = self.token.write().await;
*guard = Some(updated);
}
Ok(new_access)
}
pub async fn fetch_subscriptions(&self) -> Result<Vec<MediaItem>, String> {
let token = self.get_valid_token().await?;
let url = "https://www.googleapis.com/youtube/v3/subscriptions?part=snippet&mine=true&maxResults=50&order=alphabetical";
let res = self
.client
.get(url)
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("Fout bij ophalen abonnementen: {}", e))?;
if !res.status().is_success() {
let s = res.status();
let t = res.text().await.unwrap_or_default();
return Err(format!("YouTube API fout ({}): {}", s, t));
}
let data: SubscriptionsResponse = res
.json()
.await
.map_err(|e| format!("JSON parse fout: {}", e))?;
let mut items = Vec::new();
if let Some(list) = data.items {
for item in list {
if let Some(snip) = item.snippet {
let channel_id = snip
.resource_id
.and_then(|r| r.channel_id)
.unwrap_or_default();
if channel_id.is_empty() {
continue;
}
let thumb = best_thumbnail(&snip.thumbnails);
let title = snip.title;
items.push(MediaItem {
id: channel_id,
name: title.clone(),
sort_name: Some(title),
item_type: "YouTubeChannel".to_string(),
series_name: None,
artist_name: None,
genre: None,
index_number: None,
year: None,
overview: snip.description,
rating: None,
runtime_ticks: None,
played_percentage: None,
is_folder: true,
has_primary_image: thumb.is_some(),
has_backdrop_image: false,
parent_primary_image_item_id: thumb,
parent_backdrop_item_id: None,
});
}
}
}
Ok(items)
}
pub async fn fetch_channel_videos(&self, channel_id: &str) -> Result<Vec<MediaItem>, String> {
let token = self.get_valid_token().await?;
// Channel upload playlist ID is channel ID with "UU" instead of "UC"
let playlist_id = if channel_id.starts_with("UC") {
format!("UU{}", &channel_id[2..])
} else {
channel_id.to_string()
};
let url = format!(
"https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId={}&maxResults=30",
playlist_id
);
let res = self
.client
.get(&url)
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("Fout bij ophalen uploads van kanaal: {}", e))?;
if !res.status().is_success() {
let s = res.status();
let t = res.text().await.unwrap_or_default();
return Err(format!("YouTube API fout ({}): {}", s, t));
}
let data: PlaylistItemsResponse = res
.json()
.await
.map_err(|e| format!("JSON parse fout: {}", e))?;
let mut items = Vec::new();
if let Some(list) = data.items {
for item in list {
if let Some(snip) = item.snippet {
let video_id = snip
.resource_id
.and_then(|r| r.video_id)
.unwrap_or_default();
if video_id.is_empty() {
continue;
}
let thumb = best_thumbnail(&snip.thumbnails);
let title = snip.title;
let video_url = format!("https://www.youtube.com/watch?v={}", video_id);
let date_str = snip.published_at.as_ref().map(|d| d.chars().take(10).collect());
items.push(MediaItem {
id: video_url,
name: title.clone(),
sort_name: snip.published_at,
item_type: "YouTubeVideo".to_string(),
series_name: snip.channel_title,
artist_name: None,
genre: date_str,
index_number: None,
year: None,
overview: snip.description,
rating: None,
runtime_ticks: None,
played_percentage: None,
is_folder: false,
has_primary_image: thumb.is_some(),
has_backdrop_image: false,
parent_primary_image_item_id: thumb,
parent_backdrop_item_id: None,
});
}
}
}
Ok(items)
}
pub async fn fetch_feed(&self) -> Result<Vec<MediaItem>, String> {
let subs = self.fetch_subscriptions().await?;
if subs.is_empty() {
return Ok(Vec::new());
}
// Concurrently fetch the latest 3 videos from up to top 15 subscriptions
let top_channels: Vec<String> = subs.into_iter().take(15).map(|c| c.id).collect();
let mut handles = Vec::new();
for channel_id in top_channels {
let client = self.clone();
handles.push(tokio::spawn(async move {
client.fetch_channel_videos(&channel_id).await.unwrap_or_default()
}));
}
let mut all_videos = Vec::new();
for h in handles {
if let Ok(videos) = h.await {
// Take top 3 of each channel
all_videos.extend(videos.into_iter().take(3));
}
}
// Sort descending by published_at (stored in sort_name)
all_videos.sort_by(|a, b| b.sort_name.cmp(&a.sort_name));
Ok(all_videos)
}
pub async fn search(&self, query: &str) -> Result<Vec<MediaItem>, String> {
let token = self.get_valid_token().await?;
let encoded_q = urlencoding_simple(query);
let url = format!(
"https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&q={}&maxResults=25",
encoded_q
);
let res = self
.client
.get(&url)
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("Fout bij zoeken: {}", e))?;
if !res.status().is_success() {
let s = res.status();
let t = res.text().await.unwrap_or_default();
return Err(format!("YouTube API fout ({}): {}", s, t));
}
let data: SearchResponse = res
.json()
.await
.map_err(|e| format!("JSON parse fout: {}", e))?;
let mut items = Vec::new();
if let Some(list) = data.items {
for item in list {
let video_id = item
.id
.and_then(|r| r.video_id)
.unwrap_or_default();
if video_id.is_empty() {
continue;
}
if let Some(snip) = item.snippet {
let thumb = best_thumbnail(&snip.thumbnails);
let title = snip.title;
let video_url = format!("https://www.youtube.com/watch?v={}", video_id);
let date_str = snip.published_at.as_ref().map(|d| d.chars().take(10).collect());
items.push(MediaItem {
id: video_url,
name: title.clone(),
sort_name: snip.published_at,
item_type: "YouTubeVideo".to_string(),
series_name: snip.channel_title,
artist_name: None,
genre: date_str,
index_number: None,
year: None,
overview: snip.description,
rating: None,
runtime_ticks: None,
played_percentage: None,
is_folder: false,
has_primary_image: thumb.is_some(),
has_backdrop_image: false,
parent_primary_image_item_id: thumb,
parent_backdrop_item_id: None,
});
}
}
}
Ok(items)
}
}
fn urlencoding_simple(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
out.push(c);
} else if c == ' ' {
out.push('+');
} else {
for b in c.to_string().as_bytes() {
out.push_str(&format!("%{:02X}", b));
}
}
}
out
}
+6
View File
@@ -0,0 +1,6 @@
{
"access_token": "ya29.a0AehfTOkVjtpy5kB4TQRuD7E0MXJAKSipRjAkysg0HnjvBvZlnb1y2WQ6UCSPxOLPliLxdri6A9AB_MlgMPHp1AXnsKHXL3dA_tkVYp3yuAhud4nUCniG0WDpgUpJ_paOAGYf5vtiJqDprd7W_8612JfnVwKW2ymR1ptNJpJ-aZivVvMsebsJcjDruq05haZGvmQtTkLTaCgYKAYESARESFQHGX2MiCxxRMG9_Lvhm7KGNtUmMIw0207",
"refresh_token": "1//09y6yuAbCKpGPCgYIARAAGAkSNwF-L9Ira4vVAsGF3K-oU7bEma5ZfUMy87MfInMEA_RvnDhxi_koUBGHGuLLAR5tbB9UZ6jcQOk",
"expires_at": 1793817948,
"token_type": "Bearer"
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -eo pipefail
REMOTE_HOST="${REMOTE_HOST:-bart-workstation.lan}"
REMOTE_DIR="${REMOTE_DIR:-~/builds/jellyfin-client}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLIENT_DIR="${SCRIPT_DIR}"
KIOSK_DIR="${SCRIPT_DIR}/jellyfin-kiosk-rs"
COMMAND="${1:-release}"
shift || true
case "${COMMAND}" in
check)
CARGO_CMD="cargo check $*"
FETCH_BIN=false
;;
build)
CARGO_CMD="cargo build $*"
FETCH_BIN=false
;;
release)
CARGO_CMD="cargo build --release $*"
FETCH_BIN=true
;;
test)
CARGO_CMD="cargo test $*"
FETCH_BIN=false
;;
cargo)
CARGO_CMD="cargo $*"
FETCH_BIN=false
;;
*)
CARGO_CMD="cargo ${COMMAND} $*"
FETCH_BIN=false
;;
esac
echo "==> 📡 Syncing source code to ${REMOTE_HOST}:${REMOTE_DIR}..."
rsync -az --delete \
--exclude 'target' \
--exclude '.git' \
--exclude '.venv' \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
"${CLIENT_DIR}/" "${REMOTE_HOST}:${REMOTE_DIR}/"
echo "==> 🚀 Running on ${REMOTE_HOST}: ${CARGO_CMD}..."
ssh -t "${REMOTE_HOST}" "cd ${REMOTE_DIR}/jellyfin-kiosk-rs && ${CARGO_CMD}"
if [ "${FETCH_BIN}" = true ]; then
echo "==> 📥 Downloading compiled release binary to laptop..."
mkdir -p "${KIOSK_DIR}/target/release"
rsync -avz "${REMOTE_HOST}:${REMOTE_DIR}/jellyfin-kiosk-rs/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/target/release/"
install -m 755 "${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/jellyfin-kiosk"
echo "==> ✅ Klaar! Binary beschikbaar in:"
echo " - ${KIOSK_DIR}/target/release/jellyfin-kiosk-rs"
echo " - ${KIOSK_DIR}/jellyfin-kiosk"
fi
View File
-159
View File
@@ -1,159 +0,0 @@
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
event_port: int = 9777
@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),
)
# Environment variable overrides (useful for Docker & CI)
if jf_url := os.environ.get("JELLYFIN_SERVER_URL"):
cfg.jellyfin.server_url = jf_url.rstrip("/")
if jf_user := os.environ.get("JELLYFIN_USERNAME"):
cfg.jellyfin.username = jf_user
if jf_pass := os.environ.get("JELLYFIN_PASSWORD"):
cfg.jellyfin.password = jf_pass
if jf_dev := os.environ.get("JELLYFIN_DEVICE_NAME"):
cfg.jellyfin.device_name = jf_dev
if mpv_vo := os.environ.get("MPV_VO"):
cfg.player.vo = mpv_vo
if mpv_hwdec := os.environ.get("MPV_HWDEC"):
cfg.player.hwdec = mpv_hwdec
if mpv_fs := os.environ.get("MPV_FULLSCREEN"):
cfg.player.fullscreen = mpv_fs.lower() in ("1", "true", "yes")
if mpv_ao := os.environ.get("MPV_AO"):
cfg.player.audio_device = mpv_ao
return cfg
View File
-349
View File
@@ -1,349 +0,0 @@
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 = {
"Authorization": self.auth_header,
"X-Emby-Authorization": self.auth_header,
"Content-Type": "application/json",
"Accept": "application/json",
}
if self.auth_token:
headers["X-Emby-Token"] = self.auth_token
headers["Authorization"] = f'{self.auth_header}, 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 server URL or username not configured; skipping Jellyfin server connection")
return
if not self.config.password and not self.config.token:
logger.info(
"Jellyfin password is empty in config.yaml. Kodi RPC (Yatse) and YouTube sharing are fully active. Set your password in config.yaml to also enable Jellyfin casting."
)
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, headers=self._get_headers()) 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
self.session.headers["Authorization"] = f'{self.auth_header}, 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": [
"VolumeUp",
"VolumeDown",
"Mute",
"Unmute",
"ToggleMute",
"SetVolume",
"SetAudioStreamIndex",
"SetSubtitleStreamIndex",
"DisplayMessage",
],
"SupportsMediaControl": True,
"SupportsPersistentIdentifier": 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}"
)
ws_headers = {
"Authorization": f'MediaBrowser Token="{self.auth_token}"',
"X-Emby-Token": self.auth_token,
}
logger.info("Connecting to Jellyfin WebSocket: %s", ws_url)
async with self.session.ws_connect(ws_url, headers=ws_headers) 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()
-89
View File
@@ -1,89 +0,0 @@
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_eventserver import KodiEventServer
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)
kodi_event = KodiEventServer(config.kodi_rpc.event_port, player)
rest_api = RestApiServer(config.web_api, player)
# Start services
await player.start()
await jellyfin.start()
await kodi_rpc.start()
await kodi_event.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_event.shutdown()
await kodi_rpc.shutdown()
await jellyfin.shutdown()
await player.shutdown()
logger.info("Graceful shutdown complete.")
def main() -> None:
args = parse_args()
log_level = logging.DEBUG if args.debug else logging.INFO
logging.basicConfig(
level=log_level,
format="%(asctime)s [%(levelname)s] (%(name)s) %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
config = AppConfig.load(args.config)
try:
asyncio.run(run_app(config))
except KeyboardInterrupt:
pass
except Exception as e:
logger.critical("Fatal error: %s", e, exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()
View File
-59
View File
@@ -1,59 +0,0 @@
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)
-335
View File
@@ -1,335 +0,0 @@
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, with fallback to headless mode."""
success = await self._spawn_mpv(vo=self.config.vo, hwdec=self.config.hwdec)
if not success and self.config.vo != "null":
logger.warning(
"MPV failed to start with vo=%s (possibly no active X11/Wayland display). Falling back to headless mode (--vo=null --ao=null)...",
self.config.vo,
)
success = await self._spawn_mpv(vo="null", hwdec="no", extra_fallback_args=["--ao=null"])
if not success:
logger.error("Unable to initialize MPV in either display or headless mode.")
async def _spawn_mpv(
self,
vo: str,
hwdec: str,
extra_fallback_args: Optional[List[str]] = None,
) -> bool:
sock_path = Path(self.config.ipc_socket)
if sock_path.exists():
try:
sock_path.unlink()
except OSError:
pass
cmd = [
self.config.mpv_path,
"--idle=yes",
f"--input-ipc-server={self.config.ipc_socket}",
f"--hwdec={hwdec}",
f"--vo={vo}",
"--fs" if (self.config.fullscreen and vo != "null") else "--fs=no",
]
if self.config.audio_device and self.config.audio_device != "auto" and not extra_fallback_args:
cmd.append(f"--audio-device={self.config.audio_device}")
cmd.extend(self.config.extra_args)
if extra_fallback_args:
cmd.extend(extra_fallback_args)
logger.info("Starting MPV: %s", " ".join(cmd))
try:
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 False
# Wait for socket to become available
for _ in range(30):
if sock_path.exists():
break
# If process already exited, abort early
if self.process.returncode is not None:
return False
await asyncio.sleep(0.1)
if not sock_path.exists() or (self.process and self.process.returncode is not None):
if self.process and self.process.returncode is not None:
logger.warning("MPV process exited prematurely with code %s", self.process.returncode)
return False
try:
self._reader, self._writer = await asyncio.open_unix_connection(str(sock_path))
self._read_task = asyncio.create_task(self._socket_reader_loop())
await self._setup_observers()
logger.info("Successfully connected to MPV IPC socket")
return True
except Exception as e:
logger.error("Failed to connect to MPV IPC socket: %s", e)
return False
async def _setup_observers(self) -> None:
"""Observe relevant MPV properties."""
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 or (self.process and self.process.returncode is not None):
logger.debug("Cannot send command, MPV is disconnected: %s", args)
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")
try:
self._writer.write(payload)
await self._writer.drain()
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
except Exception as e:
self._pending_requests.pop(req_id, None)
logger.warning("MPV command error (%s): %s", args, e)
return None
async def play_url(
self,
url: str,
metadata: Optional[MediaMetadata] = None,
start_seconds: float = 0.0,
) -> None:
"""Play a media URL (Jellyfin direct stream, YouTube, or direct file)."""
logger.info("Loading media into MPV: %s (start=%s)", url, start_seconds)
self.status.metadata = metadata or MediaMetadata(url=url)
self.status.state = PlaybackState.PLAYING
self.status.current_time = start_seconds
options = []
if start_seconds > 0:
options.append(f"start={int(start_seconds)}")
if options:
await self.command("loadfile", url, "replace", ",".join(options))
else:
await self.command("loadfile", url, "replace")
# Set title in MPV OSD if known
if self.status.metadata and self.status.metadata.title:
title = self.status.metadata.title
if self.status.metadata.artist:
title = f"{self.status.metadata.artist} - {title}"
await self.command("set_property", "force-media-title", title)
self._notify()
async def pause(self) -> None:
await self.command("set_property", "pause", True)
self.status.state = PlaybackState.PAUSED
self._notify()
async def resume(self) -> None:
await self.command("set_property", "pause", False)
self.status.state = PlaybackState.PLAYING
self._notify()
async def play_pause(self) -> None:
if self.status.state == PlaybackState.PAUSED:
await self.resume()
elif self.status.state == PlaybackState.PLAYING:
await self.pause()
async def stop(self) -> None:
await self.command("stop")
self.status.state = PlaybackState.STOPPED
self._notify()
async def seek(self, seconds: float, absolute: bool = False) -> None:
seek_type = "absolute" if absolute else "relative"
await self.command("seek", seconds, seek_type)
async def set_volume(self, volume: int) -> None:
clamped = max(0, min(100, volume))
await self.command("set_property", "volume", clamped)
self.status.volume = clamped
self._notify()
async def set_mute(self, muted: bool) -> None:
await self.command("set_property", "mute", muted)
self.status.muted = muted
self._notify()
async def toggle_mute(self) -> None:
await self.set_mute(not self.status.muted)
async def set_audio_track(self, track_id: int) -> None:
await self.command("set_property", "aid", track_id)
async def set_subtitle_track(self, track_id: int) -> None:
await self.command("set_property", "sid", track_id)
async def show_osd_message(self, message: str, duration_ms: int = 3000) -> None:
await self.command("show-text", message, duration_ms)
async def shutdown(self) -> None:
self._stopping = True
if self._read_task:
self._read_task.cancel()
if self._writer:
try:
self._writer.close()
await self._writer.wait_closed()
except Exception:
pass
if self.process:
try:
self.process.terminate()
await asyncio.wait_for(self.process.wait(), timeout=2.0)
except Exception:
if self.process:
self.process.kill()
View File
-150
View File
@@ -1,150 +0,0 @@
from __future__ import annotations
import asyncio
import logging
import struct
from typing import Optional
from src.player.mpv_controller import MpvController
logger = logging.getLogger(__name__)
# Kodi EventServer Packet Types
PT_HELO = 0x01
PT_BYE = 0x02
PT_BUTTON = 0x03
PT_MOUSE = 0x04
PT_PING = 0x05
PT_NOTIFICATION = 0x06
PT_BLOB = 0x07
PT_LOG = 0x08
PT_ACTION = 0x09
HEADER_FORMAT = "!4sBBHIIHI10s"
HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 32 bytes
class KodiEventServerProtocol(asyncio.DatagramProtocol):
"""UDP protocol handler for Kodi EventServer packets from Yatse."""
def __init__(self, player: MpvController):
self.player = player
self.transport: Optional[asyncio.DatagramTransport] = None
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
self.transport = transport
logger.info("Kodi EventServer UDP protocol initialized")
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
if len(data) < HEADER_SIZE:
return
try:
sig, majver, minver, pkttype, seq, maxseq, paylen, client_token, _ = struct.unpack(
HEADER_FORMAT, data[:HEADER_SIZE]
)
if sig != b"XBMC":
return
payload = data[HEADER_SIZE : HEADER_SIZE + paylen]
self._handle_packet(pkttype, payload, addr, client_token)
except Exception as e:
logger.debug("Error parsing EventServer packet: %s", e)
def _handle_packet(self, pkttype: int, payload: bytes, addr: tuple[str, int], client_token: int) -> None:
if pkttype == PT_HELO:
# Client registration
devname = payload.split(b"\x00")[0].decode("utf-8", errors="ignore")
logger.info("Yatse EventServer connected from %s (Device: %s)", addr, devname)
# Echo ping ack back to client
if self.transport:
ack = struct.pack(HEADER_FORMAT, b"XBMC", 2, 0, PT_PING, 1, 1, 0, client_token, b"\x00" * 10)
self.transport.sendto(ack, addr)
elif pkttype == PT_PING:
logger.debug("Yatse EventServer PING from %s", addr)
if self.transport:
ack = struct.pack(HEADER_FORMAT, b"XBMC", 2, 0, PT_PING, 1, 1, 0, client_token, b"\x00" * 10)
self.transport.sendto(ack, addr)
elif pkttype == PT_BUTTON:
self._handle_button(payload)
elif pkttype == PT_ACTION:
self._handle_action(payload)
elif pkttype == PT_BYE:
logger.info("Yatse EventServer disconnected from %s", addr)
def _handle_button(self, payload: bytes) -> None:
# Format: code(2B) + map_name(null-terminated) + button_name(null-terminated) + flags(2B) + amount(2B)
if len(payload) < 6:
return
try:
parts = payload[2:].split(b"\x00")
if len(parts) >= 2:
map_name = parts[0].decode("utf-8", errors="ignore")
button_name = parts[1].decode("utf-8", errors="ignore").lower()
# Check flags: 0x01 is KEY_DOWN
tail = b"\x00".join(parts[2:])
if len(tail) >= 2:
flags = struct.unpack("!H", tail[:2])[0]
if not (flags & 0x01): # Ignore key up events
return
logger.info("EventServer button pressed: map=%s, btn=%s", map_name, button_name)
self._dispatch_button(button_name)
except Exception as e:
logger.debug("Error handling EventServer button: %s", e)
def _handle_action(self, payload: bytes) -> None:
# Format: action_type(1B) + action_name(null-terminated string)
if len(payload) < 2:
return
action = payload[1:].split(b"\x00")[0].decode("utf-8", errors="ignore").lower()
logger.info("EventServer action received: %s", action)
if "playpause" in action or "play" in action:
asyncio.create_task(self.player.play_pause())
elif "stop" in action:
asyncio.create_task(self.player.stop())
def _dispatch_button(self, btn: str) -> None:
if btn in ("left", "stepback"):
asyncio.create_task(self.player.seek(-10))
elif btn in ("right", "stepforward"):
asyncio.create_task(self.player.seek(10))
elif btn in ("up", "bigstepforward"):
asyncio.create_task(self.player.seek(60))
elif btn in ("down", "bigstepback"):
asyncio.create_task(self.player.seek(-60))
elif btn in ("select", "enter", "play", "pause", "playpause"):
asyncio.create_task(self.player.play_pause())
elif btn in ("back", "stop"):
asyncio.create_task(self.player.stop())
elif btn in ("volumeup", "volup"):
asyncio.create_task(self.player.set_volume(self.player.status.volume + 5))
elif btn in ("volumedown", "voldown"):
asyncio.create_task(self.player.set_volume(self.player.status.volume - 5))
class KodiEventServer:
"""Manages the UDP socket for Kodi EventServer."""
def __init__(self, port: int, player: MpvController):
self.port = port
self.player = player
self.transport: Optional[asyncio.DatagramTransport] = None
async def start(self) -> None:
loop = asyncio.get_running_loop()
try:
transport, _ = await loop.create_datagram_endpoint(
lambda: KodiEventServerProtocol(self.player),
local_addr=("0.0.0.0", self.port),
)
self.transport = transport
logger.info("Kodi EventServer UDP listener started on 0.0.0.0:%d (Yatse compatible)", self.port)
except Exception as e:
logger.error("Failed to start Kodi EventServer on UDP port %d: %s", self.port, e)
async def shutdown(self) -> None:
if self.transport:
self.transport.close()
-552
View File
@@ -1,552 +0,0 @@
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 Kodi JSON-RPC server (HTTP, WebSocket, and raw TCP socket) for Yatse."""
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._tcp_clients: Set[asyncio.StreamWriter] = set()
self._tcp_server: Optional[asyncio.Server] = None
self.app.router.add_post("/jsonrpc", self._handle_post_jsonrpc)
self.app.router.add_get("/jsonrpc", self._handle_get_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:
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",
},
}
# Broadcast to WebSockets
dead_ws = set()
for ws in self._ws_clients:
try:
await ws.send_json(notification)
except Exception:
dead_ws.add(ws)
self._ws_clients -= dead_ws
# Broadcast to raw TCP clients (Yatse socket)
payload = (json.dumps(notification) + "\n").encode("utf-8")
dead_tcp = set()
for writer in self._tcp_clients:
try:
writer.write(payload)
await writer.drain()
except Exception:
dead_tcp.add(writer)
self._tcp_clients -= dead_tcp
async def _handle_root(self, request: web.Request) -> web.Response:
return web.Response(text="Kodi JSON-RPC emulation server for Yatse")
async def _handle_post_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):
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_get_jsonrpc(self, request: web.Request) -> web.StreamResponse:
# Check if client is requesting a WebSocket upgrade
if request.headers.get("Upgrade", "").lower() == "websocket":
return await self._handle_websocket_jsonrpc(request)
# Standard HTTP GET with ?request=<url-encoded-json> (used by Yatse probe)
req_param = request.query.get("request")
if req_param:
try:
body = json.loads(req_param)
if isinstance(body, list):
res = [await self._dispatch(item) for item in body]
return web.json_response(res)
res = await self._dispatch(body)
return web.json_response(res)
except Exception as e:
logger.error("Failed to parse GET /jsonrpc request query param: %s", e)
return web.json_response(
{"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None}
)
return web.json_response({"jsonrpc": "2.0", "result": "Kodi JSON-RPC Active", "id": None})
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 _handle_tcp_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
self._tcp_clients.add(writer)
addr = writer.get_extra_info("peername")
logger.info("Yatse TCP connection established from %s", addr)
buffer = ""
decoder = json.JSONDecoder()
try:
while not reader.at_eof():
data = await reader.read(4096)
if not data:
break
buffer += data.decode("utf-8", errors="ignore")
while buffer:
buffer = buffer.lstrip()
if not buffer:
break
try:
obj, idx = decoder.raw_decode(buffer)
buffer = buffer[idx:]
res = await self._dispatch(obj)
out = (json.dumps(res) + "\n").encode("utf-8")
writer.write(out)
await writer.drain()
except json.JSONDecodeError:
break
except asyncio.CancelledError:
pass
except Exception as e:
logger.debug("Error in TCP client connection %s: %s", addr, e)
finally:
self._tcp_clients.discard(writer)
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
logger.info("Yatse TCP connection closed from %s", addr)
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.info("Kodi JSON-RPC method called: %s (params=%s, id=%s)", method, params, 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.info("Handled Kodi JSON-RPC method with generic OK: %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_jsonrpc_permission(self, params: Any) -> Dict[str, bool]:
return {
"ControlGUI": True,
"ControlNotify": True,
"ControlPlayback": True,
"ControlPower": True,
"ControlSystem": True,
"ExecuteAddon": True,
"ManageFiles": True,
"Navigate": True,
"ReadData": True,
"WriteData": True,
}
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"
elif prop == "version":
result["version"] = {"major": 19, "minor": 0, "revision": "0", "tag": "release"}
return result
async def _rpc_gui_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
return {
"currentwindow": {"id": 10000, "label": "Home"},
"currentcontrol": {"label": ""},
"skin": {"id": "skin.estuary", "name": "Estuary"},
"fullscreen": True,
}
async def _rpc_system_get_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
return {
"canhibernate": False,
"canreboot": True,
"canshutdown": True,
"cansuspend": False,
}
async def _rpc_xbmc_get_info_booleans(self, params: Dict[str, Any]) -> Dict[str, bool]:
booleans = params.get("booleans", [])
result = {}
for b in booleans:
if b in ("System.Platform.Linux", "Services.EventServer", "Services.Webserver"):
result[b] = True
elif b.startswith("System.Platform."):
result[b] = False
elif b == "System.HasModalDialog":
result[b] = False
else:
result[b] = True
return result
async def _rpc_xbmc_get_info_labels(self, params: Dict[str, Any]) -> Dict[str, str]:
labels = params.get("labels", [])
result = {}
for l in labels:
if l == "System.BuildVersion":
result[l] = "19.4 (19.4.0) Git:20220302-e26f2334f3 Media Center Kodi"
elif l == "System.KernelVersion":
result[l] = "Linux 6.8.0"
elif l == "System.FriendlyName":
result[l] = "HTPC"
else:
result[l] = ""
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)):
target = (float(value) / 100.0) * status.duration
await self.player.seek(target, absolute=True)
elif isinstance(value, dict):
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_ok(self, params: Any) -> str:
return "OK"
async def _rpc_input_back(self, params: Any) -> str:
await self.player.stop()
return "OK"
async def _rpc_settings_get_setting_value(self, params: Dict[str, Any]) -> Dict[str, Any]:
setting = params.get("setting", "").lower()
if "port" in setting:
val = 9777 if "es" in setting else 8080
elif any(k in setting for k in ("enabled", "eventserver", "webserver", "interface", "upnp", "zeroconf")):
val = True
else:
val = True
return {"value": val}
async def _rpc_settings_set_setting_value(self, params: Dict[str, Any]) -> bool:
return True
async def _rpc_settings_get_settings(self, params: Dict[str, Any]) -> Dict[str, Any]:
return {
"settings": [
{"id": "services.eventserver", "value": True},
{"id": "services.esenabled", "value": True},
{"id": "services.esport", "value": 9777},
{"id": "services.esallinterfaces", "value": True},
{"id": "services.webserver", "value": True},
{"id": "services.webserverport", "value": 8080},
]
}
async def _rpc_playlist_get_playlists(self, params: Any) -> List[Dict[str, Any]]:
return [{"playlistid": 0, "type": "audio"}, {"playlistid": 1, "type": "video"}]
async def _rpc_files_get_sources(self, params: Any) -> Dict[str, Any]:
return {"sources": [], "limits": {"start": 0, "total": 0, "end": 0}}
_methods = {
"JSONRPC.Ping": _rpc_ping,
"JSONRPC.Version": _rpc_version,
"JSONRPC.Introspect": _rpc_introspect,
"JSONRPC.Permission": _rpc_jsonrpc_permission,
"Application.GetProperties": _rpc_app_get_properties,
"Application.SetVolume": _rpc_app_set_volume,
"Application.SetMute": _rpc_app_set_mute,
"GUI.GetProperties": _rpc_gui_get_properties,
"System.GetProperties": _rpc_system_get_properties,
"Settings.GetSettingValue": _rpc_settings_get_setting_value,
"Settings.SetSettingValue": _rpc_settings_set_setting_value,
"Settings.GetSettings": _rpc_settings_get_settings,
"XBMC.GetInfoBooleans": _rpc_xbmc_get_info_booleans,
"XBMC.GetInfoLabels": _rpc_xbmc_get_info_labels,
"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,
"Playlist.GetPlaylists": _rpc_playlist_get_playlists,
"Files.GetSources": _rpc_files_get_sources,
"GUI.ShowNotification": _rpc_gui_show_notification,
"Input.Back": _rpc_input_back,
"Input.Home": _rpc_input_ok,
"Input.Up": _rpc_input_ok,
"Input.Down": _rpc_input_ok,
"Input.Left": _rpc_input_ok,
"Input.Right": _rpc_input_ok,
"Input.Select": _rpc_input_ok,
"Input.ContextMenu": _rpc_input_ok,
"Input.Info": _rpc_input_ok,
"Input.ShowOSD": _rpc_input_ok,
}
async def start(self) -> None:
if not self.config.enabled:
logger.info("Kodi RPC emulation server is disabled in config")
return
# Start HTTP & WebSocket server on http_port (8080)
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 HTTP server listening on http://0.0.0.0:%d (Yatse compatible)",
self.config.http_port,
)
# Start raw TCP socket server on ws_port (9090)
await self._start_tcp_server()
async def _start_tcp_server(self) -> None:
try:
self._tcp_server = await asyncio.start_server(
self._handle_tcp_client,
"0.0.0.0",
self.config.ws_port,
)
logger.info("Kodi TCP JSON-RPC socket server listening on port %d", self.config.ws_port)
except Exception as e:
logger.error("Failed to start TCP JSON-RPC server on port %d: %s", self.config.ws_port, e)
async def shutdown(self) -> None:
if self._tcp_server:
self._tcp_server.close()
await self._tcp_server.wait_closed()
if self._runner:
await self._runner.cleanup()
-343
View File
@@ -1,343 +0,0 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from aiohttp import web
from src.config import WebApiConfig
from src.player.models import MediaMetadata, PlaybackState
from src.player.mpv_controller import MpvController
from src.utils.youtube import fetch_youtube_metadata, is_youtube_url
logger = logging.getLogger(__name__)
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTPC Media Remote</title>
<style>
:root {
--bg: #0f172a;
--card-bg: #1e293b;
--accent: #38bdf8;
--text: #f8fafc;
--text-muted: #94a3b8;
--button-bg: #334155;
}
body {
margin: 0;
padding: 1.5rem;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
box-sizing: border-box;
}
.container {
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.card {
background: var(--card-bg);
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
h1 {
font-size: 1.5rem;
margin: 0 0 0.5rem 0;
color: var(--accent);
text-align: center;
}
.title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
}
.artist {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0 0 1rem 0;
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--button-bg);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.3s;
}
.time-info {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 1rem;
}
.controls {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 0.5rem;
}
button {
background: var(--button-bg);
color: var(--text);
border: none;
border-radius: 0.5rem;
padding: 0.75rem 1.25rem;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:active {
background: var(--accent);
color: #000;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
input[type="text"] {
width: 100%;
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid var(--button-bg);
background: #0b1120;
color: var(--text);
font-size: 1rem;
box-sizing: border-box;
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.share-btn {
background: var(--accent);
color: #0b1120;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<h1>HTPC Media Player</h1>
<div class="card">
<div class="input-group">
<label for="urlInput" style="font-size: 0.9rem; color: var(--text-muted);">Share YouTube / Media URL:</label>
<input type="text" id="urlInput" placeholder="Paste YouTube link here..." />
<button class="share-btn" onclick="playUrl()">Play on TV</button>
</div>
</div>
<div class="card">
<div class="title" id="mediaTitle">Nothing Playing</div>
<div class="artist" id="mediaArtist">-</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="time-info">
<span id="currentTime">00:00</span>
<span id="totalTime">00:00</span>
</div>
<div class="controls">
<button onclick="control('seek', {seconds: -15})">-15s</button>
<button onclick="control('playpause')">⏯ Play/Pause</button>
<button onclick="control('stop')">⏹ Stop</button>
<button onclick="control('seek', {seconds: 15})">+15s</button>
</div>
<div style="margin-top: 1.25rem;">
<label style="font-size: 0.85rem; color: var(--text-muted);">Volume: <span id="volLabel">100%</span></label>
<input type="range" id="volRange" min="0" max="100" value="100" onchange="setVolume(this.value)" />
</div>
</div>
</div>
<script>
function fmtTime(sec) {
sec = Math.max(0, Math.floor(sec || 0));
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m < 10 ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s}`;
}
async function updateStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('mediaTitle').innerText = data.title || (data.state === 'idle' ? 'Ready to Play' : 'Unknown Media');
document.getElementById('mediaArtist').innerText = data.artist || (data.source_type ? `Source: ${data.source_type}` : '-');
document.getElementById('progressFill').style.width = `${data.percentage || 0}%`;
document.getElementById('currentTime').innerText = fmtTime(data.current_time);
document.getElementById('totalTime').innerText = fmtTime(data.duration);
document.getElementById('volRange').value = data.volume;
document.getElementById('volLabel').innerText = `${data.volume}%`;
} catch (e) {
console.error(e);
}
}
async function playUrl() {
const input = document.getElementById('urlInput');
const url = input.value.trim();
if (!url) return;
await fetch('/api/play', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url})
});
input.value = '';
setTimeout(updateStatus, 500);
}
async function control(action, body = {}) {
await fetch(`/api/${action}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
setTimeout(updateStatus, 300);
}
async function setVolume(vol) {
await fetch('/api/volume', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({volume: parseInt(vol)})
});
}
setInterval(updateStatus, 2000);
updateStatus();
</script>
</body>
</html>
"""
class RestApiServer:
"""Provides HTTP REST API endpoints and web interface for playback control and URL sharing."""
def __init__(self, config: WebApiConfig, player: MpvController):
self.config = config
self.player = player
self.app = web.Application()
self._runner: Optional[web.AppRunner] = None
self._site: Optional[web.TCPSite] = None
self.app.router.add_get("/", self._handle_index)
self.app.router.add_get("/api/status", self._handle_status)
self.app.router.add_post("/api/play", self._handle_play)
self.app.router.add_post("/api/playpause", self._handle_playpause)
self.app.router.add_post("/api/pause", self._handle_pause)
self.app.router.add_post("/api/resume", self._handle_resume)
self.app.router.add_post("/api/stop", self._handle_stop)
self.app.router.add_post("/api/seek", self._handle_seek)
self.app.router.add_post("/api/volume", self._handle_volume)
async def _handle_index(self, request: web.Request) -> web.Response:
return web.Response(text=HTML_TEMPLATE, content_type="text/html")
async def _handle_status(self, request: web.Request) -> web.Response:
st = self.player.status
meta = st.metadata
data = {
"state": st.state.value,
"current_time": st.current_time,
"duration": st.duration,
"percentage": st.percentage,
"volume": st.volume,
"muted": st.muted,
"title": meta.title if meta else None,
"artist": meta.artist if meta else None,
"album": meta.album if meta else None,
"thumbnail": meta.thumbnail if meta else None,
"source_type": meta.source_type if meta else None,
}
return web.json_response(data)
async def _handle_play(self, request: web.Request) -> web.Response:
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
url = body.get("url")
if not url:
return web.json_response({"error": "Missing 'url' parameter"}, status=400)
metadata: Optional[MediaMetadata] = None
if is_youtube_url(url):
logger.info("REST API received YouTube URL: %s", url)
metadata = await fetch_youtube_metadata(url)
else:
logger.info("REST API received media URL: %s", url)
metadata = MediaMetadata(url=url, title=body.get("title") or url, source_type="url")
start_time = float(body.get("start", 0.0))
await self.player.play_url(url, metadata=metadata, start_seconds=start_time)
return web.json_response({"status": "playing", "title": metadata.title if metadata else url})
async def _handle_playpause(self, request: web.Request) -> web.Response:
await self.player.play_pause()
return web.json_response({"status": self.player.status.state.value})
async def _handle_pause(self, request: web.Request) -> web.Response:
await self.player.pause()
return web.json_response({"status": "paused"})
async def _handle_resume(self, request: web.Request) -> web.Response:
await self.player.resume()
return web.json_response({"status": "playing"})
async def _handle_stop(self, request: web.Request) -> web.Response:
await self.player.stop()
return web.json_response({"status": "stopped"})
async def _handle_seek(self, request: web.Request) -> web.Response:
body = await request.json()
seconds = float(body.get("seconds", 0.0))
absolute = bool(body.get("absolute", False))
await self.player.seek(seconds, absolute=absolute)
return web.json_response({"current_time": self.player.status.current_time})
async def _handle_volume(self, request: web.Request) -> web.Response:
body = await request.json()
vol = int(body.get("volume", 100))
await self.player.set_volume(vol)
return web.json_response({"volume": self.player.status.volume})
async def start(self) -> None:
if not self.config.enabled:
logger.info("REST API server is disabled in config")
return
self._runner = web.AppRunner(self.app)
await self._runner.setup()
self._site = web.TCPSite(self._runner, self.config.host, self.config.port)
await self._site.start()
logger.info("REST API & Web Remote listening on http://%s:%d", self.config.host, self.config.port)
async def shutdown(self) -> None:
if self._runner:
await self._runner.cleanup()
View File
-117
View File
@@ -1,117 +0,0 @@
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},
)
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Local Kodi EventServer (Yatse UDP 9777) test utility and CLI.
This script allows you to test the Kodi EventServer locally by sending
valid Kodi EventServer UDP packets (HELO, PING, BUTTON, ACTION) and
verifying handshake responses.
Usage examples:
# 1. Test HELO and PING handshake
python3 tests/test_eventserver_client.py ping
# 2. Send navigation buttons
python3 tests/test_eventserver_client.py up
python3 tests/test_eventserver_client.py down
python3 tests/test_eventserver_client.py left
python3 tests/test_eventserver_client.py right
python3 tests/test_eventserver_client.py select
python3 tests/test_eventserver_client.py back
# 3. Interactive keyboard control mode:
python3 tests/test_eventserver_client.py interactive
# 4. Run automated test suite against running server:
python3 tests/test_eventserver_client.py test
"""
from __future__ import annotations
import argparse
import random
import socket
import struct
import sys
import time
# Kodi EventServer Constants
HEADER_FORMAT = "!4sBBHIIHI10s"
HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 32 bytes
PT_HELO = 0x01
PT_BYE = 0x02
PT_BUTTON = 0x03
PT_PING = 0x05
PT_ACTION = 0x0A
PTB_DOWN = 0x02
PTB_UP = 0x04
class EventServerClient:
"""UDP Client simulating Yatse / Kodi remote."""
def __init__(self, host: str = "127.0.0.1", port: int = 9777, timeout: float = 2.0):
self.host = host
self.port = port
self.timeout = timeout
self.token = random.randint(100000, 999999)
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.settimeout(timeout)
def close(self) -> None:
self.sock.close()
def _build_header(self, pkttype: int, paylen: int) -> bytes:
return struct.pack(
HEADER_FORMAT,
b"XBMC", # Signature
2, # Major version
0, # Minor version
pkttype, # Packet type
1, # Seq
1, # MaxSeq
paylen, # Payload length
self.token,
b"\x00" * 10,
)
def send_helo(self, device_name: str = "Local Yatse Test") -> bool:
"""Send HELO packet and wait for PING ACK response."""
payload = (
device_name.encode("utf-8") + b"\x00" +
b"\x00" + # Icon type
struct.pack("!H", 0) + # Port
b"\x00" * 8 # Reserved
)
packet = self._build_header(PT_HELO, len(payload)) + payload
self.sock.sendto(packet, (self.host, self.port))
try:
data, addr = self.sock.recvfrom(1024)
if len(data) >= HEADER_SIZE:
sig, maj, minv, pkttype, seq, maxseq, plen, token, _ = struct.unpack(
HEADER_FORMAT, data[:HEADER_SIZE]
)
if sig == b"XBMC" and pkttype == PT_PING and token == self.token:
return True
except socket.timeout:
return False
return False
def send_ping(self) -> bool:
"""Send PING packet and wait for PING ACK response."""
packet = self._build_header(PT_PING, 0)
self.sock.sendto(packet, (self.host, self.port))
try:
data, addr = self.sock.recvfrom(1024)
if len(data) >= HEADER_SIZE:
sig, _, _, pkttype, _, _, _, token, _ = struct.unpack(
HEADER_FORMAT, data[:HEADER_SIZE]
)
if sig == b"XBMC" and pkttype == PT_PING and token == self.token:
return True
except socket.timeout:
return False
return False
def send_button(self, button_name: str, map_name: str = "R1", is_down: bool = True) -> None:
"""Send BUTTON packet (e.g. up, down, left, right, select, back)."""
flags = PTB_DOWN if is_down else PTB_UP
payload = (
struct.pack("!HHH", 0, flags, 0) +
map_name.encode("utf-8") + b"\x00" +
button_name.encode("utf-8") + b"\x00"
)
packet = self._build_header(PT_BUTTON, len(payload)) + payload
self.sock.sendto(packet, (self.host, self.port))
def send_action(self, action_str: str) -> None:
"""Send ACTION packet (e.g. Action(1), Action(7), Up)."""
payload = b"\x01" + action_str.encode("utf-8") + b"\x00"
packet = self._build_header(PT_ACTION, len(payload)) + payload
self.sock.sendto(packet, (self.host, self.port))
def send_bye(self) -> None:
"""Send BYE disconnect packet."""
packet = self._build_header(PT_BYE, 0)
self.sock.sendto(packet, (self.host, self.port))
def run_automated_tests(host: str, port: int) -> bool:
print(f"Testing Kodi EventServer at {host}:{port}...")
client = EventServerClient(host, port, timeout=2.0)
# Test 1: HELO Handshake
print(" [1/4] Testing HELO handshake (client registration)...", end=" ", flush=True)
if client.send_helo("EventServer Test Suite"):
print("OK (Received PING-ACK)")
else:
print("FAILED (No PING-ACK response received)")
print(f" Ensure jellyfin-kiosk is running and listening on UDP {port}")
client.close()
return False
# Test 2: PING Heartbeat
print(" [2/4] Testing PING heartbeat...", end=" ", flush=True)
if client.send_ping():
print("OK (Received PING-ACK)")
else:
print("FAILED")
client.close()
return False
# Test 3: Button Events
print(" [3/4] Sending navigation buttons (Up, Down, Left, Right, Select, Back)...", end=" ", flush=True)
for btn in ["up", "down", "left", "right", "select", "back"]:
client.send_button(btn)
time.sleep(0.05)
print("OK (Packets sent)")
# Test 4: Action Events
print(" [4/4] Sending action strings (Action(1), Action(7))...", end=" ", flush=True)
for act in ["Action(1)", "Action(7)"]:
client.send_action(act)
time.sleep(0.05)
print("OK (Packets sent)")
client.send_bye()
client.close()
print("\nAll EventServer tests passed successfully!")
return True
def run_interactive_mode(client: EventServerClient) -> None:
print("\n--- EventServer Interactive Remote Mode ---")
print("Controls:")
print(" [W] or [Up] : Up")
print(" [S] or [Down] : Down")
print(" [A] or [Left] : Left")
print(" [D] or [Right] : Right")
print(" [Enter] / [O] : Select / OK")
print(" [B] / [Esc] : Back")
print(" [Space] / [P] : Play / Pause")
print(" [X] : Stop")
print(" [Q] : Quit\n")
import termios
import tty
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
while True:
ch = sys.stdin.read(1)
if ch == "\x1b": # Escape or arrow sequence
# Non-blocking check for arrow keys
import select
r, _, _ = select.select([sys.stdin], [], [], 0.05)
if r:
seq = sys.stdin.read(2)
if seq == "[A":
client.send_button("up")
elif seq == "[B":
client.send_button("down")
elif seq == "[C":
client.send_button("right")
elif seq == "[D":
client.send_button("left")
else:
client.send_button("back")
elif ch in ("q", "Q", "\x03"): # Quit or Ctrl+C
break
elif ch in ("w", "W"):
client.send_button("up")
elif ch in ("s", "S"):
client.send_button("down")
elif ch in ("a", "A"):
client.send_button("left")
elif ch in ("d", "D"):
client.send_button("right")
elif ch in ("\r", "\n", "o", "O"):
client.send_button("select")
elif ch in ("b", "B"):
client.send_button("back")
elif ch in (" ", "p", "P"):
client.send_button("playpause")
elif ch in ("x", "X"):
client.send_button("stop")
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
print("\nExited interactive mode.")
def main() -> int:
parser = argparse.ArgumentParser(description="Kodi EventServer Local Test Client")
parser.add_argument("command", nargs="?", default="test",
help="Command: test, ping, helo, up, down, left, right, select, back, play, stop, interactive")
parser.add_argument("--host", default="127.0.0.1", help="EventServer host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=9777, help="EventServer UDP port (default: 9777)")
args = parser.parse_args()
cmd = args.command.lower()
if cmd == "test":
success = run_automated_tests(args.host, args.port)
return 0 if success else 1
client = EventServerClient(args.host, args.port)
try:
if cmd == "ping":
print(f"Sending PING to {args.host}:{args.port}...")
if client.send_ping():
print("Success: Received PING-ACK from EventServer!")
else:
print("Failed: No response (timeout)")
return 1
elif cmd == "helo":
print(f"Sending HELO to {args.host}:{args.port}...")
if client.send_helo():
print("Success: EventServer accepted HELO and replied with PING-ACK!")
else:
print("Failed: No response (timeout)")
return 1
elif cmd in ("up", "down", "left", "right", "select", "back", "play", "pause", "playpause", "stop"):
client.send_button(cmd)
print(f"Sent button '{cmd}' to {args.host}:{args.port}")
elif cmd == "interactive":
run_interactive_mode(client)
else:
print(f"Unknown command: {cmd}")
return 1
finally:
client.close()
return 0
if __name__ == "__main__":
sys.exit(main())